# 说在前面

Another-Mirai-Native22.6.0 版本支持加载小栗子插件,期间被这种插件的加载方式折磨的比较难受,写一篇文来记录一下。

# 相关资源

  • 小栗子易语言 SDK、应用空白模板
  • 小栗子 C# SDK

# 插件种类

根据 SDK 来看,小栗子插件分为 V3 和 V4 版本,区分两种插件的方法是:插件有没有同名的 .dll.json 文件。若存在此文件,则为 V4 插件;若不存在,则是 V3 插件。

# V3 插件

# 插件信息

小栗子插件通过 初始化 这个 Dll 接口来向上层传递插件本身的信息,信息以 Json 格式传递,其中包括:

  • SDK 版本 (sdkv)
  • 应用名称 (appname)
  • 作者 (author)
  • 应用描述 (describe)
  • 应用版本 (appv)

之后在以 addres 结尾字段均为插件暴露的事件函数地址
随后在 data 字段中是插件所需要的所有 API 列表,严格来说也不是列表,是一个对象

7.png

# 插件初始化

我们在通过上一步获取了插件基础信息之后,就可以将事件的所有地址转换为本地函数以供进行事件分发。
此后,我们需要将插件提供的所有 API 列表,通过调用 初始化 方法的方式传递给插件
初始化函数在易语言中定义的参数如下:
8.png

根据 SDK 源码猜测,第一个参数以 Json 数组格式传递 API 地址列表,第二个参数传递类似于 AuthCode 的参数用于标识插件
其中,地址列表的格式如下:

1
2
3
4
[
{ "发送好友消息": 114514 }, // 在SDK中定义的函数名称,不可随意更改。地址为10进制数字
...
]

地址列表,顾名思义就是函数的在内存中的地址列表,而 C# 属于托管语言,获取自身函数在内存的地址较为困难,我们会在后续章节通过曲线救国的方式来解决这个问题。

# 事件分发

# 事件分类

小栗子的事件一共有以下几种,下面是事件的名称与在第一步返回的事件地址键的名称

  1. 被启用处理函数 (useproaddres)
  2. 被禁用处理函数 (banproaddres)
  3. 将被卸载处理函数 (unitproaddres)
  4. 插件菜单处理函数 (setproaddres)
  5. 私聊消息处理函数 (friendmsaddres)
  6. 群聊消息处理函数 (groupmsaddres)
  7. 频道推送统一处理函数 [注:仅在 V4 插件有]
  8. 事件消息处理函数 (eventmsaddres)
  9. 插件消息输出替换处理函数 [注:仅在 V4 插件有]
  10. 滑块识别处理函数 (SliderR)
  11. 短信接码处理函数 (SMSVerification)
  12. GetTicket (getticketaddres) [注:名字我起的,含义未知,仅在 V3 插件有]
  13. GetVefCode (getvefcodeaddres) [注:名字我起的,含义未知,仅在 V3 插件有]

# 事件函数原型

函数的参数表在提供的空白模板中都有,此处不再占用篇幅
想说是插件在进行参数传递时,使用了很多的结构体,甚至结构数组。当然对于 P/Invoke 来说,使用结构体传递复杂参数很正常,但是不正常点在于:易语言的结构体数组与常规的数组结构有很大不同,在这里我也苦恼了好几天,才摸索出门道,将在后面进行详细介绍。

# 窗口

小栗子插件最多只有一个窗口,触发逻辑与调用事件相同

# 调用上层 API

此处是做兼容比较头疼的部分,一方面是小栗子提供的 API 有 400 个之多,甚至他们连发红包的接口都提供了,方法甚至要求输入银行卡号以及支付密码,这我是完全理解不了的,开发者难道不怕框架偷偷把这些信息转发走吗......
9.png

另一方面其实还是和数量多有关,数量多需要做的代码转换工作就多,建议使用 CodeGen 一类的方法来减轻工作量,我搓了一个能够应付易语言的脚本,能够将这些文本转换为 C# 的 DllExport 输出,感兴趣的可以试试。

想吐槽的一点是,从上面的图片也能看出来,他们每次调用 API 的时候都需要从上层提供的 API 列表里查找函数地址,之后再调用,我觉得挺低效的。

# V4 插件

其实没什么差别,唯一不同的点就是调用初始化函数获取插件信息的部分

获取插件信息的步骤和 V3 插件一样,都是通过 初始化 这个方法,交换 API 地址列表与插件基础信息,不同的点是:V4 插件拥有自己的 .json 描述文件,结构如下:
10.png

感觉和 V3 相比清晰了一些,但是不多。
V4 插件将事件更改为了导出的函数名称,这样上层就可以使用名称来找函数导出而不是使用地址。所以当调用初始化方法时,函数返回的内容就只有前五个基础信息了。

# 兼容难点

  1. 可读性不高的 SDK:易语言我只是凑合能看所以不评价这个。而 C# SDK 即使是抄的 Jie2GG 的酷 Q SDK,新增的内容命名的方式非常的百度翻译、部分字段命名非常迷惑,可读性一塌糊涂。
  2. API 传递使用地址而不是 P/Invoke:可能对于非托管语言来说这个问题不大,但是 C# 是托管语言,一旦双方均使用 C#,就会发生各种命名空间冲突,发生异常。
  3. 易语言的结构体数组非常规

# 使用地址调用 API

最一开始的实现里,所有的 API 均在框架的一个静态类中,通过 Marshal.GetFunctionPointerForDelegate 来获取地址,确实能够获取得到,但是当插件调用时就发生了问题:当插件也是 C# 插件时,会发生类型不匹配,这是因为双方的委托虽然参数列表都一致,但是由于对方也是 C#,在 CLR 看来无异于将一个不同类型的委托复制给一个变量,所以发生了类型不一致的异常。

1
2
3
4
5
6
7
8
9
10
11
public string OutLog(string message, int text_color = 16711680, int background_color = 16777215)
{
string ret = string.Empty;
int privateMsgAddress = int.Parse(JObject.Parse(jsonstr).SelectToken("输出日志").ToString());
IntPtr intPtr = new IntPtr(privateMsgAddress);
// 此处从API列表获取地址,之后转换成委托
OutputLog outputLog = (OutputLog)Marshal.GetDelegateForFunctionPointer(intPtr, typeof(OutputLog));
ret = Marshal.PtrToStringAnsi(outputLog(pluginkey, message, text_color, background_color));
outputLog = null;
return ret;
}

解决方案其实原理我也没闹明白,只是能用而已。
我将所有的 API 实现均移植到了 CQP.dll (这个是酷 Q 插件 API 实现的位置),之后通过 DllExport 导出。在调用初始化函数提供 API 地址列表的时候,改为通过 GetProcAddress 获取。
我也不知道通过这个导出之后在 CLR 看来有什么变化,反正是不异常了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private string BuildSelfEntryInfo()
{
JObject json = [];
foreach(var item in API.APINames)
{
string attribute = item.Key;
if (string.IsNullOrEmpty(attribute))
{
continue;
}
// 酷Q要求加载 CQP.dll,所以可以获取到此句柄
IntPtr handle = WinNative.GetProcAddress(base.CQPHandle, item.Value);
if (handle == IntPtr.Zero)
{
Console.WriteLine(item.Value);
continue;
}
json.Add(new JProperty(attribute, handle.ToInt64()));
}
return json.ToString();
}

# 易语言结构体非常规

在 C++ 中,数组是一串连续的内存块,每一块中放置数据的地址。但是在易语言中,结构体数组结构如下:

0x00x40x4 + 40x4 + 8...0x4 + 4 * n
起始位置 (一般写 1)数组成员数量 (? 猜的)第一个成员的地址第二个成员的地址...第 n 个成员的地址

这样的结构如果使用预先设定的结构很难实现

1
2
3
4
5
6
7
8
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct StructList
{
public int index;
public int count;

public Intptr[] pAddrList;
}

这样的结构是不可行的,我通过观察内存结构发现,这个数组不会像上图一样铺开,而是单纯放置一个指针,其实还挺合理的。

查询了一些资料无果后,决定使用仿造结构的方式,直接操作内存来构造这个结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
int dataListSize = Marshal.SizeOf(typeof(int)) * 2 + Marshal.SizeOf(typeof(int)) * list.Count; // 通过欲构建的数量计算内存大小
var rawPtr = Marshal.AllocHGlobal(dataListSize); // 申请整个结构的内存
Marshal.WriteInt32(rawPtr, 1);
Marshal.WriteInt32(rawPtr + 4, list.Count);

for (int i = 0; i < list.Count; i++)
{
var info = ... // 省略构造
var ptr = Marshal.AllocCoTaskMem(Marshal.SizeOf(info)); // 申请子元素内存

Marshal.StructureToPtr(info, ptr, false);// 创建指针
Marshal.WriteInt32(rawPtr + 8 + i * 4, (int)ptr); // 写入内存
}