前言

虽然标题写的是FFXIV ACT插件开发入门,但是这篇博客主要写的是我作为一个插件开发新手开发自己的插件的心路历程。本文的目的是给各位新手提供一些参考资料,以及记录一下常见的问题和解决方案。希望这篇文章能够给你一些启示,也欢迎各位补充说明。

本文章假设您有一定的C#语言基础,以及一些基础的WinForm开发基础,并使用Visual Studio作为自己的开发工具。

ACT 插件入门

我们通常看到的ACT插件的成品都是一个DLL(以及附带的一些文件)。我们可以直接在VS中新建一个类库项目,这样它的编译出来就会是一个DLL了。

正如ACT开发提示中所述,所有的ACT插件都需要实现 Advanced_Combat_Tracker.IActPluginV1 接口。为了让我们的类实现这个接口,我们必须先在项目中引用ACT本体。在项目依赖项中添加引用,选择下载好的ACT可执行文件,即可在代码中引用它。

以下是最小插件代码:

using Advanced_Combat_Tracker;

class MyActPlugin : IActPluginV1 {
    // 初始化插件的方法
    void InitPlugin(TabPage pluginScreenSpace, Label pluginStatusText) {
        // 你可以在这里初始化插件逻辑
        // pluginScreenSpace 是插件页面的Tab,需要你自行往内部填充控件
        page.Text = "MyPlugin"; // 设置Tab的名称
        page.Controls.Add(new PluginControl()); // 填充控件,PluginControl继承的是UserControl,是WinForm的界面
        // pluginStatusText 是插件设置页面的状态指示标签,可以用于指示插件工作状态
        pluginStatusText.Text = "Plugin Inited."
    }
    // 反初始化插件的方法
    void DeInitPlugin() {
        // 处理一些收尾工作,如释放资源,保存数据等
    }
}

编译后,你就能得到一个可用的插件了。使用ACT载入编译后的插件,启用后应该会在插件页内看到一个名为MyPlugin的页面。

调用ACT内置的功能

ACT在ActGlobals类下提供了一系列静态变量,插件可以使用这个变量访问ACT内部的各种数据。你需要参考ACT的API文档,了解这些接口的用法。

ActGlobals.oFormActMain是ACT主窗口的实例,它包括了多个常用的API,包括:
ActPlugins: 所有的Act插件。如果你要查找另一个插件,尝试遍历这个变量。
AppDataFolder: 数据存放路径。如果你想要存放数据,请写入此目录中。
ParseRawLogLine: 将一条LogLine交给Act处理。可以用于写入日志,交给其他插件处理。
PlaySound*: 播放指定的声音
TTS: 调用TTS读出指定的文本

插件编写注意事项

如果你之前写过WinForm或是WPF,你就应该知道修改UI的代码必须在主线程上执行。而在写ACT插件时,同样也需要注意这一点。

你的代码不一定运行在UI线程上,这意味着任何对于UI的修改都是不安全并且会报错的。如果你想要修改UI(的某些项的值),你需要保证修改的代码正确的运行在UI线程上。

以下示例代码可以安全的修改UI:

ActGlobals.oFormActMain.Invoke(new Action(() => { lblStatus.Text = "Hello world" }));

FFXIV 插件集成

既然标题写了是FFXIV ACT插件开发,我们就需要涉及到与FFXIV解析插件的交互。

ravahn 在解析插件的仓库内提供了用于开发的未打包的DLL,请下载带有SDK字样的压缩包,并解压备用。而国服解析插件并未提供相应的SDK,若要使用则需要自行解压,或使用国际服版本插件替代。

如果尝试使用国际服SDK,则需要注意SDK的对应版本。国服插件的DLL与国际服SDK中提供的DLL版本可能不同,如果混用可能出现无法加载(提示强签名等)的问题。若出现此情况,请尝试使用其他版本SDK的DLL。

在本文写作时最新的5.58版本中,如果使用对应国际服5.58对应的2.2.x.x版本的SDK,其中的FFXIV_ACT_Plugin.Common.dll 版本为2.2.0.5,会在使用国服解析插件时加载出错。
若需要在国服中使用,则需要适应对应国际服5.55对应的2.0.x.x版本的SDK,其中的FFXIV_ACT_Plugin.Common.dll 版本为2.0.4.10。
在本文尾部提供了一种解决方案,用于解决不同版本的ACT插件之间的依赖兼容性问题。

大部分需要用到的功能都存在于FFXIV_ACT_Plugin.Common.dll中,而这也是解析插件给外部插件提供的接口。你应当只引用这一个DLL,其他的DLL应当用于参考而不是实际使用。在项目依赖项中添加此DLL的引用即可。

实例查找

我们需要首先查找到FFXIV解析插件的实例,才能正确的调用它相关的API。

在上面我们提到了,ACT提供了一个变量,里面存放着所有插件的信息。我们直接遍历这个变量,判断是否为我们需要的插件即可。

抹茶等插件使用的方法是判断插件的文件名,这虽然会有一些问题但是它算是一个能用的方案。你也可以尝试使用反射的方法判断类型是否为指定的类型。

使用文件名判断的方法如下,推荐在初始化插件的时候运行以下代码查找引用:

var plugins = ActGlobals.oFormActMain.ActPlugins;
foreach (var item in plugins)
{
    if (item.pluginFile.Name.ToUpper().Contains("FFXIV_ACT_PLUGIN"))
    {
        var instance = item.pluginObj as FFXIV_ACT_Plugin.FFXIV_ACT_Plugin;
    }
}

使用插件

插件的API文档中描述了插件提供的所有API接口及其用法,请参考文档了解如何使用。

常用的接口有:
– IDataSubscription: 数据监听,用于注册对应的数据事件。
– NetworkReceived: 网络接收事件,可以用于解析网络包。
– NetworkSent: 网络发送事件,可以用于解析网络包。
– IDataRepository: 数据仓库,获取游戏相关的数据。
– GetServerTimestamp: 获取最后一次通信时的服务器时间

对于大部分的插件来说,解析插件提供的那些信息是完全不够的。这种使用我们就需要注册NetworkReceived事件,手动解析网络数据包。

FFXIV 网络数据包入门

在这里我们给出三个概念:
– 数据包(Packet): 是一个网络数据包。包括了许多个分包(Segment),并可能对他们做了压缩。
– 分包 (Segment): 是FFXIV网络通信中的一条指令或一段数据,是最小单元。
– 远程调用(IPC): 是分包的一种子类型,是远程调用指令。其Segment Type为3。大部分的FFXIV网络包都属于这个类型。

Sapphire是三方开发者开发的FFXIV服务器,包含了许多有用的信息。这三种数据包的详细信息请参考Sapphire的包格式定义文件。

NetworkReceived和NetworkSent事件给我们提供了分包数据,我们只需要解析它的message参数中提供的数据即可。下面给出了包头可用的数据。

 0               4                 8              12              16
 +---------------+-----------------+---------------+-------+-------+
 | size          |  source_actor   | target_actor  | type  |  pad  | Segment Header
 +-------+-------+------+----------+---------------+-------+-------+
 | 14 00 | type  |  pad | serverId |   timestamp   |      pad1     | IPC Header
 +-------+-------+------+----------+---------------+---------------+
 |                                                                 |
 :                         IPC Data                                :
 |                                                                 |
 +-----------------------------------------------------------------+

这两个包头中,比较有用的数据有:
– Segment Size: 包长度,包括包头数据的长度。
– Segment Type: 包类型。用于判断此包是否为IPC包
– IPC Type: IPC 类型。又被称为Opcode
– IPC serverId: 服务器ID
– IPC timestamp: 服务器时间戳

Opcode用于识别IPC包类型,在解析过程中扮演着非常重要的作用。然而从4.x版本开始,Opcode引入了混淆机制,各个包的Opcode在每个版本都会被随机打乱。故我们在新版本就需要重新识别各个包,获得他们的Opcode。

附加:如何得到我想要的网络数据

最好的方法是自己抓包分析。我们推荐使用Wireshark搭配FFXIV解析插件抓包并分析。 我们推荐使用 FFXIVMon 软件抓包分析。

FFXIV 于 6.15(国际服)/ 6.1(国服)版本开始使用OodleNetwork库压缩网络数据包。这一压缩格式的变更导致了 Wireshark 插件无法使用。

需要注意的是,FFXIVMon 默认是给国际服用的,它会尝试使用英文窗口标题查找游戏窗口,并使用国际服务的 memsig 查找 oodle 库,这就导致其无法检测到国服游戏。你需要手动下载源代码,修改其检测用的窗口标题才能够正常使用。
我们提供了一个编译好的FFXIVMon。点击 “Options” -> “Set Game Window Name”,将游戏窗口标题改为“最终幻想XIV” ,并将 “Oodle Implementation Type” 改成 “Library TCP” ,点击 “Set Oodle Library” 手动指定 oodle 库的路径后,即可正常使用。
注意由于 OodleTCP 的限制,你需要先开启抓包,再点击“开始游戏”登录游戏。
FFXIVMon 内置了一些 Opcode,它会自动根据这一 Opcode 判断包类型。当然,它们是无法应用到国服的,你可以直接忽略它自动解析的数据包类型。

假设我想要知道我的雇员信息,我们可以先开始抓包,然后点击雇员传唤铃打开雇员窗口,再停止抓包。此时,获取雇员信息的网络请求数据包就会出现在抓包软件中,根据数据内容即可找到雇员信息相关的包。

由于雇员信息已经是一个已知数据格式的信息了,我们可以直接参考Sapphire的包定义解析内部数据。此时我们只要知道这个包对应的Opcode就行。如果是一个未知格式的数据,你就需要自行辨认数据格式,此时FFXIVMon提供的数据分析功能可能能够帮助到你。

实战:交易板压一压

我们在这一小节中尝试实现交易板压一压的主要功能:记录当前查询物品价格的最低价,将价格减1后复制到剪贴板。

首先使用 FFXIVMon 抓包,找到查询商品价格时服务器发来的数据包。可以看到,服务器下发了多个长度为 1560 的数据包,这非常可疑。经过分析我们可以知道,这个 1560 长度的数据包确实就是我们要的商品价格数据包。

打开 Sapphire 代码库,可以找到 FFXIVIpcMarketBoardItemListing 这一个类。根据名称和内容我们可知这就是我们要找的数据格式。但是这个包的长度并不是 1560,经过对比发现后面的许多 padding 数据在游戏中已经不存在了。

现在,我们已经有了目标包的数据结构,可以开始编写插件了!

首先在 VS 里面新建一个 C# 类库工程。然后添加 ACT 插件引用,将“复制到本地”设置为“不复制”。新建一个类,继承 ACT 接口,并在初始化方法中查找 FFXIV 解析插件的引用。查找完毕后,开始监听解析插件的网络包收到事件。

在收到网络包后,首先判断是否为我们需要的数据包。若是,则继续解析数据包,找到最低价格的物品的价格。在这个价格的基础上减去1,最后将这个数值设置为剪贴板的内容。现在,我们就有一个最小化的压一压插件了!

完整代码参见这里:SimpleYayiya

悬浮窗

现在大多数人使用的应该都是 OverlayPlugin(NGLD 修改版本)。这个插件有丰富的扩展系统,方便第三方开发者开发自己的悬浮窗。

悬浮窗编写

参考资料中的悬浮窗编写文档写的非常详细,建议阅读。

悬浮窗主要使用两个API:
– addOverlayListener: 添加对指定数据源的监听。
– callOverlayHandler: 调用指定的处理程序。

一般来说,悬浮窗的主要工作流程就是添加对数据的监听,处理数据后将其显示出来。最简单的悬浮窗代码应该是这样的:

<script type="text/javascript" src="https://ngld.github.io/OverlayPlugin/assets/shared/common.min.js"></script>
<script>
    // 添加数据处理
    addOverlayListener('CombatData', (data) => {
        console.log(`经历战斗: ${data.title} | ${data.duration} | 团伤: ${data.ENCDPS}`);
    });
    // 注册完毕,启动悬浮窗事件
    startOverlayEvents();
</script>

我们只需要引入 OverlayPlugin 的公共库,注册事件并启动悬浮窗,即实现一个最小化的悬浮窗。

呆萌关闭了 OverlayPlugin 公共库的国内镜像,你现在需要使用官方的源,或者自行托管这一公共库。

在你的页面路径后面添加?OVERLAY_WS=ws://127.0.0.1:10501/ws并访问,打开浏览器的控制台即可看到上面打印输出的信息。

悬浮窗扩展

如果要为自己的悬浮窗提供一些数据,一个比较通用的方法是调用ACT的ParseRawLogLine方法,将自己的数据写入到日志中。这样,其他插件就可以通过日志行来得到对应的数据了,悬浮窗也能通过监听LogLine数据来得到需要的数据。

这个方案在只有一些简单数据的时候是非常好用的,但是一旦需要传递比较多数据且有较多交互操作时,这一方案就不太够用了。这时候,就需要扩展悬浮窗插件,增加所需的数据源和对应处理程序了。

为了调用悬浮窗的对应API,你需要下载悬浮窗的插件并添加引用。将插件的libs文件夹下的OverlayPlugin.Common.dllOverlayPlugin.Core.dll加入引用即可。

与ACT插件一样,悬浮窗的插件同样也是通过实现指定的接口来编写的。悬浮窗会自动查找所有实现了RainbowMage.OverlayPlugin.IOverlayAddonV2接口的类,并尝试调用其Init方法。我们就只需要在Init方法中调用相关API注册事件。

using RainbowMage.OverlayPlugin;
public class AddonExample : IOverlayAddonV2
{
    public void Init()
    {
        var container = Registry.GetContainer();
        var registry = container.Resolve<Registry>();

        // 注册事件源
        registry.StartEventSource(new AddonExampleEventSource(container));

        // 注册悬浮窗
        registry.RegisterOverlay<AddonExampleOverlay>();

        // 或者注册悬浮窗预设
        registry.RegisterOverlayPreset2(new AddonExampleOverlayPresent());
    }
}

Registry是悬浮窗插件的注册器,它有以下的几个方法可供使用:

  • StartEventSource: 注册一个事件源。事件源提供了悬浮窗的数据源以及调用的处理,我们需要的大部分功能都在这里。
  • RegisterOverlay: 注册一个自定义悬浮窗。自定义悬浮窗提供了高级的方法更新页面文档,可以精细的控制悬浮窗页面。如果没有特殊需要,不需要使用此方法。
  • RegisterOverlayPreset2: 注册一个悬浮窗预设。悬浮窗预设是在新建悬浮窗的预设中添加一项,其可以自定义名称、页面地址、大小等信息。可以使用此方法为用户添加一项自己插件的预设。
    • IOverlayPreset 是预设的接口,其中
    • Type是悬浮窗类型,一般为MiniParse
    • Supports表示悬浮窗支持的功能。modern 表示是 OverlayPlugin 兼容的悬浮窗,actws 表示是 ACTWS 插件兼容的悬浮窗。

想要自定义数据源,就需要注册一个事件源。事件源需要继承EventSourceBase抽象类。

public class AddonExampleEventSource : EventSourceBase
{
    public AddonExampleEventSource(TinyIoCContainer container) : base(container)
    {
        // 设置事件源名称,必须是唯一的
        Name = "AddonExampleES";

        // 注册数据源名称。此数据源提供给悬浮窗监听
        RegisterEventTypes(new List<string>()
        {
            "onAddonExampleEmbeddedTimerFiredEvent",
        });

        // 注册事件处理程序,提供给悬浮窗调用
        RegisterEventHandler("addonExampleCurrentTime", (msg) => {
            var ret = new JObject();
            ret["time"] = DateTimeOffset.UtcNow.ToString();
            return ret;
        });
    }

    public override Control CreateConfigControl()
    {
        // 创建配置页面,与ACT插件页面的UserControl类似。
        // 如果不想显示页面,返回null
        return null;
    }

    public override void LoadConfig(IPluginConfig config)
    {
        // 载入配置
    }

    public override void SaveConfig(IPluginConfig config)
    {
        // 保存配置
    }

    public void FireEvent()
    {
        // 将数据发送给悬浮窗
        DispatchEvent(JObject.FromObject(new
        {
            type = "onAddonExampleEmbeddedTimerFiredEvent",
            message = "EmbeddedTimer fired!"
        }));
    }
}

程序集引用问题

如果需要调用某个接口,就需要引用它的程序集。然而,直接引入一个程序集会造成一些问题,例如版本接口不匹配的问题,或者是依赖地狱问题。

很多时候我们仅仅只是想要访问某个特定的接口,而不想要关心它的实现。在不引入程序集的情况下,我们还可以使用反射的方式解决这一引用问题。

我们提供了一个 PluginCommon 的库,用于解决插件开发中的一些常见的问题。它提供了
– FFXIV解析插件的代理类,在不引入程序集的情况下可以直接访问
– 同时,提供了一个网络包的解析类NetworkParser,可以快速将对应的网络包转换为自定义数据结构
– EventSourceBase抽象类,在不引入OverlayPlugin.Core的情况下可以实现悬浮窗事件
– 简易的日志记录工具
– WPF MVVM架构需要的PropertyNotifier
– 简易的升级和打包工具

希望对你有帮助。

快速开始模板

我们在上面的公共库的基础上,还提供了插件快速开始模板。当前包括

  • 对FFXIV解析插件的引用
  • 基于WPF的空白插件界面
  • 悬浮窗预设和事件源模板

使用这个模板可以不用每次都烦恼上述的问题,而专注于插件开发本身。

依赖与程序集打包

上面已经说过了,你要引入了不同的版本,它就能爆炸给你看。特别是在国服这种环境中,有原版、Cafe修改版和呆萌修改版三种版本的插件,每次你都需要在三种环境下尽可能测试,引用的版本是否兼容。

在.Net开发中,为了方便分发,大部分人都会使用 Costura.Fody 这个 Nuget 包将引用的库和程序本体打包至一个单独的文件中。Costura 会将当前程序引用的库打包为资源,然后生成一个自己的AssemblyLoader用于解析并加载这些库。这个AssemblyLoader有一个公共静态方法Attach,而这个静态方法则会在模块初始化的时候执行。模块初始化依赖于一项名为Module Initializer的 .NET CLR 功能,此功能保证指定的代码在访问模块的某个静态字段或调用某个方法前执行。

这一切听上去都很美好,但是当它遇到了ACT后,就变得不那么美好了起来。ACT的插件加载代码如下:

// 以下代码经过简化和改写,仅用于参考
private void pluginPanelEnabledChecked(object sender, EventArgs e)
{
    // 读取文件
    var rawAssembly = File.ReadAllBytes(actPluginData.pluginFile.FullName);
    // 加载程序集
    var assembly = AppDomain.CurrentDomain.Load(rawAssembly);
    try
    {
        foreach (var type in assembly.GetTypes())
        {
            foreach (var ifType in type.GetInterfaces())
            {
                if (ifType == typeof(IActPluginV1)) {
                    actPluginData.pluginObj = (IActPluginV1)assembly.CreateInstance(type.FullName);
                    break;
                }
            }
            if (actPluginData.pluginObj != null) break;
        }
    }
    catch (ReflectionTypeLoadException ex4)
    {
        // log & warn
    }
    actPluginData.pluginObj.InitPlugin(actPluginData.tpPluginSpace, actPluginData.lblPluginStatus);
}

ACT在加载程序集后,直接使用了GetTypes方法获取程序集内的所有类型。此时,由于ACT没有调用这个程序集的任何方法,故ModuleInitializer没有执行,也就没有加载对应的依赖程序集。如果你的程序集中含有任何继承了外部程序集的类型,它就会报错。

要解决这个问题,你需要保证你的程序集中的类型没有继承任何外部程序集;如果有,那么你就需要提前加载这个程序集,或者使用ILMerge将目标程序集包含到当前程序集中。这也就是为什么推荐将上面的公共库ILMerge到你的插件内的原因。

参考资料

更新日志

  • 2022/2/12: 添加 PluginCommon 开发公共库
  • 2022/9/28: 抓包工具从wireshark换为ffxivmon,更新悬浮窗公共库链接
  • 2022/10/7: 添加示例插件——交易板压一压
  • 2023/2/25: 添加插件模板,并说明为什么要使用ILMerge
分类: 未分类

14 条评论

marix · 2023 年 4 月 9 日 下午 11:38

使用FFXIVMon修改版 Capture菜单下的 start 会闪退,是因为Set Game Path路径中存在中文么

    Jim · 2023 年 4 月 9 日 下午 11:41

    诶,我这边也是有中文路径的来着。明天我看看我这边能否复现吧

      Jim · 2023 年 4 月 10 日 下午 7:35

      看上去我这边能够正常工作,不知道你那边咋回事了。

    marix · 2023 年 4 月 10 日 上午 11:16

    是需要安装Sapphire配合使用吗

      Jim · 2023 年 4 月 10 日 上午 11:18

      不需要的。理论上直接解压打开就能用

        marix · 2023 年 4 月 10 日 下午 7:32

        再仔细检查了遍,”Set Game Window Name” 是 最终幻想XIV ,勾选了”Use KoreanFfxivUdp instead.” 依旧失败,苦恼

Locria · 2023 年 2 月 25 日 下午 12:33

感谢分享!请问一下,那个修改版的 ffxivmon 我打开之后报了下面的错,应该怎么解决?

[Versioning] Failed to connect to Github.

System.Exception: Could not complete Request.
at FFXIVMonReborn.Database.GitHub.GitHubApi.Request(String endpoint, Boolean ignoreCache) in D:\GitDownload\ffxivmon\FFXIVMonReborn\Database\GitHub\GitHubApi.cs:line 82
at FFXIVMonReborn.Database.GitHub.GitHubApi.LoadCommits() in D:\GitDownload\ffxivmon\FFXIVMonReborn\Database\GitHub\GitHubApi.cs:line 58
at FFXIVMonReborn.Database.GitHub.GitHubApi.Update() in D:\GitDownload\ffxivmon\FFXIVMonReborn\Database\GitHub\GitHubApi.cs:line 40
at FFXIVMonReborn.Database.GitHub.GitHubApi..ctor(String repo) in D:\GitDownload\ffxivmon\FFXIVMonReborn\Database\GitHub\GitHubApi.cs:line 33
at FFXIVMonReborn.Database.Versioning..ctor() in D:\GitDownload\ffxivmon\FFXIVMonReborn\Database\Versioning.cs:line 24

    Jim · 2023 年 2 月 25 日 下午 12:34

    看上去是连接Github失败了,打开梯子试试?不过这个错误应该不影响使用吧?

      Nakamoto · 2023 年 3 月 2 日 上午 12:15

      影响的,现在Sapphire没有develop分支导致的GitHubApi.cs报错

        Jim · 2023 年 3 月 2 日 上午 12:17

        那我找个时间修一下

        Jim · 2023 年 3 月 3 日 上午 10:10

        软件已更新到6.25版本,我这边能够正常使用,看上去没有遇到你说的那个问题。

tiduazgy · 2022 年 9 月 28 日 下午 3:21

太棒了,一直想研究一下插件开发,但是不知道怎么入门,中文社区很难搜索到教人入门的文章。这篇文章给了一个很好的指引。

希望大佬能有后续的更新,比如一个简单插件一步步开发的流程。

另外,包解析那里,是怎么对应到游戏指令上,帮助开发的呢?希望能够详细解释一下

    Jim · 2022 年 9 月 28 日 下午 5:13

    感谢回复!关于你说的简单的插件的开发流程,这个可以有!写好之后我会在这里继续更新的。关于包解析是如何对应到游戏指令的问题,这个就需要自行根据包的内容猜测了,毕竟这是一个完全的黑盒。

    Jim · 2022 年 10 月 7 日 下午 11:03

    简单插件的开发流程现已上线!

发表回复

Avatar placeholder

您的邮箱地址不会被公开。 必填项已用 * 标注