跳至正文

通过 MonoModHooks 动态挂钩子

前置知识与提醒

这不是一个基础教程,你最好先确保掌握了这些前置知识:

基础 On 修改

C# 反射

此外,裙中世界内已有相似内容通过反射实现的教程,可作一定参考。

本文提供一种利用 MonoMod 特性实现动态外显名的方法,当然,也可用于修改模组图标

本文文字金色闪烁的效果实际上使用了 Shader绘制,但那不是重点。当然,如果你想要完全看懂最好还是了解一下(所以写完之后才发现,涉及的方面还挺杂的,这就是 ILOn 了,是搭配其他内容使用的)

本文所涉及的代码以及资源已在Example Mod注释汉化项目中开源,主要是这个文件 (别问,问就是私货)

2023.2.25更新:最近tML官方也出了一个 HookEndpointManager 的教程,可供参考,有一些地方还是tML的教程详细些

[重要] 2023.7.9更新:随着tML1.4.4的到来,HookEndpointManager不再适用了(虽然似乎也还能用)。解决方法很简单,把所有 HookEndpointManager 改成 MonoModHooks 就行了,就像这样:

v1.4.3 tModLoader (1.4.3版本才能用):
HookEndpointManager.Add(YourMethodInfo, YourHook);
HookEndpointManager.Modify(YourMethodInfo2, YourHook2);

v1.4.4 tModLoader (最新1.4.4版本):
MonoModHooks.Add(YourMethodInfo, YourHook);
MonoModHooks.Modify(YourMethodInfo2, YourHook2);

当然,运行一遍tModPorter应该也会帮你更新,不过我没试过

迁移神器

瞎写的背景

某日,一个Mod作者看着模组列表上满屏的Mod,陷入了沉思

不是,这也太单调了吧

于是,他打算让自己的Mod在列表中看上去更炫酷一点,以一下子闪瞎玩家的眼

等会,这也太——

对不起,搞错了,这才是效果图(

有木有很线裤捏

第一步 – 寻找切入点

要实现这么一个效果,首先当然要找到模组列表是在哪里实现的

仔细想想,有什么元素是只会在这一个地方出现的?没错,就是模组图标 icon.png

于是,通过在tML源码中全局搜索 icon.png,我们很容易地找到了要修改的目标——UIModItem

搜索结果

第二步 – 寻找解决方案

考虑到 UIModItem 是个 internal 内部类,不好直接修改它的内容,于是最直接想到的方法无疑是两种 —— 完全反射 On/IL修改

反射?那可比较繁琐,而且简直是查源码的地狱 (而且网站里已经有教程了,我不可能再写一遍这个)

那么接下来考虑 On/IL,我们发现这类里面有一个 Draw 方法

public override void Draw(SpriteBatch spriteBatch) {
	_tooltip = null;
	base.Draw(spriteBatch);
	if (!string.IsNullOrEmpty(_tooltip)) {
		var bounds = GetOuterDimensions().ToRectangle();
		bounds.Height += 16;
		UICommon.DrawHoverStringInBounds(spriteBatch, _tooltip, bounds);
	}
}

那不就来了吗!直接用 On 给这个方法上一个钩子,在原来的绘制之上覆盖一层加了特效的文字绘制就行了!

然而,当你满怀信心准备写代码时,却发现…

用普通的 On 是修改不了 tML 的东西的

难道只能用反射了?不不不,这时候就要有请今天的主角登场了:

MonoModHooks – 这玩意可以让你动态挂 OnIL

第三步 – 挂钩子

MonoModHooks 简要介绍

多说无益,让我们看看 MonoModHooks 里都有什么好东西

哇,你是真的简洁

那么这里我就来粗略地介绍一下我们一般修改所需要用到的

  • Modify – 相当于对指定方法挂 IL 修改
  • Add – 相当于对指定方法挂 On 修改,术语叫 Detour
  • DumpIL – 一些IL修改会随着版本更新失效,然后报错,这个和try-catch一起用可以让失效的IL在log里留下报错信息,而非在游戏内弹出报错。如果你的IL修改不太重要的话,就应该用这个将报错写入日志,而非抛出错误导致Mod无法加载

联系前文所说的思路,我们只需要对 Draw 方法调用 MonoModHooks.Add,并写上我们的修改即可

使用MonoModHooks挂的钩子无论是On(即Detour)还是IL修改,tML都是会在模组卸载时自动卸载的,不需要手动卸载

进行修改

终于开始写代码了,前摇过长(

首先,按照1.4惯例,先继承一个 ModSystem 类,写上 Load

public class GoldenDisplayNameSystem : ModSystem
{
	public override void Load() {
		// 服务器上是没UI的,自然没必要挂(其他方法就不一定了)
		if (Main.dedServ) {
			return;
		}

		// 加载代码
	}
}

先看一下 MonoModHooks.Add 这个方法,是长这样的:

MethodBase 你想到了什么?没错,就是反射(可见,我们到最后还是无法摆脱反射)

直接干!typeof(UIModItem)... 哦草,这个类是 internal 的。没关系,我们可以从程序集中把它揪出来:

// 由于原版中 UIModItem 类是内部(internal)的,无法直接使用 typeof(UIModItem) 获取其 Type 实例,所以我们要通过从程序集中寻找的方法来获取
// typeof(Main).Assembly 是原版的程序集,调用其 GetTypes() 方法并找到第一个名为 UIModItem 的 Type,即可获取到我们想要修改的类的 Type 实例
_uiModItemType = typeof(Main).Assembly.GetTypes().First(t => t.Name == "UIModItem");

注释已经解释得明明白白了,直接下一步——挂钩子

// 接下来反射获取我们要修改的方法
_drawMethod = _uiModItemType.GetMethod("Draw", BindingFlags.Instance | BindingFlags.Public);

// 若方法获取成功(一般来说,只要写得没问题都是成功的),则调用 MonoModHooks.Add 添加 RuntimeDetour
if (_drawMethod is not null) {
	// Add 相当于添加 On 命名空间的委托
	MonoModHooks.Add(_drawMethod, DrawHook);
}

这个 DrawHook 倒是挺有意思的,先看一下实现:

// 这个委托表示原方法,包含一个类实例,以及相应方法的传入参数
// UIModItem 是内部的,无法直接获取,这里可直接用 object 替代
public delegate void DrawDelegate(object uiModItem, SpriteBatch sb);

// 等同于一个 On 命名空间委托,在里面按照 On 的逻辑写就行了
private void DrawHook(DrawDelegate orig, object uiModItem, SpriteBatch sb) {
	// 一定要记得调用原方法,不然你UI就没了
	orig.Invoke(uiModItem, sb);

	// 修改代码
}

为什么 DrawDelegate 的参数是 object uiModItem, SpriteBatch sb,而 DrawHookDrawDelegate orig, object uiModItem, SpriteBatch sb 呢?其实,这和我们平时写 On 修改是一样的,只不过用那个有 VS 的自动补全

参数,一般遵循以下原则:

  • 首先是一个 delegate
    • 若修改的方法是实例(instance)方法,则 delegate 的第一位参数是相应的类,如 Main main。如果这个类的内部的,无法直接调用,使用 object 即可,如 object uiModItem注意:若方法是静态方法则不需要这个传参
    • 接下来的参数对应原方法的参数,这里原方法是 Draw(SpriteBatch spriteBatch) 则为 SpriteBatch sb
  • 后面的传参与 delegate 相同即可

这里提一嘴,参考 On 命名空间下的方法,要是有返回值的方法直接把 void 关键字改成对应值就行了,比如:

public delegate bool PassFiltersDelegate(object uiModItem, UIModsFilterResults filterResults);

private bool PassFiltersHook(PassFiltersDelegate orig, object uiModItem, UIModsFilterResults filterResults) {
	return orig.Invoke(uiModItem, filterResults);
}

IL 修改放到了本文的末尾

第四步 – 写特效

接下来按照普通 On 写就行了,是不是很方便捏?

注:用到的调色板贴图以及Shader文件我都已经放在Github上了,直接点蓝字即可查看

// 等同于一个 On 命名空间委托,在里面按照 On 的逻辑写就行了
private void DrawHook(DrawDelegate orig, object uiModItem, SpriteBatch sb) {
	// 一定要记得调用原方法,不然你UI就没了
	orig.Invoke(uiModItem, sb);

	// 反射获取 _modName,后面修改以及绘制需要用到
	// 找不到 _modName 就报错
	if (_uiModItemType.GetField("_modName", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(uiModItem) is not UIText modName) {
		throw new Exception("出错啦!");
	}

	// 确保是修改自己Mod的名字 (不过你想改别的我也不拦你)
	if (!modName.Text.Contains(Mod.DisplayName)) {
		return;
	}

	// 加载所需的资源
	var texture = ModContent.Request<Texture2D>("ExampleMod/Assets/Textures/Shader/Golden");
	var shader = ModContent.Request<Effect>("ExampleMod/Assets/Effects/Golden", AssetRequestMode.ImmediateLoad).Value;

	// 传入 Shader 所需的参数
	shader.Parameters["uTime"].SetValue(Main.GlobalTimeWrappedHourly * 0.25f);
	Main.instance.GraphicsDevice.Textures[1] = texture.Value; // 传入调色板

	// 为什么y要-2呢?因为原版就这么写的。金色字实现的原理实际上是覆盖原版,所以要保证重合
	var position = modName.GetDimensions().Position() - new Vector2(0f, 2f);

	// 重新开启 SpriteBatch 以应用 Shader
	sb.End();
	// 这个Begin传参可以确保无关参数不被修改,以避免奇怪的错误
	// (而且很方便,不用去找原版都用了哪些参数)
	sb.Begin(SpriteSortMode.Immediate, sb.GraphicsDevice.BlendState, sb.GraphicsDevice.SamplerStates[0],
		sb.GraphicsDevice.DepthStencilState, sb.GraphicsDevice.RasterizerState, shader, Main.UIScaleMatrix);

	// 在开启 Shader 的情况下绘制字,注意别写成带描边的了,不然整个字就糊了
	ChatManager.DrawColorCodedString(sb, FontAssets.MouseText.Value, modName.Text, position, Color.White, 0f, Vector2.Zero, Vector2.One);
	
	// 重新开启 SpriteBatch 以去除 Shader
	sb.End();
	sb.Begin(SpriteSortMode.Deferred, sb.GraphicsDevice.BlendState, sb.GraphicsDevice.SamplerStates[0],
		sb.GraphicsDevice.DepthStencilState, sb.GraphicsDevice.RasterizerState, null, Main.UIScaleMatrix);
}

对了, _uiModItemType 是我存起来的反射 UIModItem 的返回值,可以节约一定的性能

// 由于反复反射开销较大且可读性低,而实际使用中又需要多多用到反射,所以这里存储两个变量
private static Type _uiModItemType;
private static MethodInfo _drawMethod;

Shader 简单介绍

还是简要说一下

实际上就只是一个简单的映射 – 将绘制贴图的坐标映射到调色板上

sampler uTextImage : register(s0); // SpriteBatch.Draw 的内容会自动绑定到 s0
sampler uGoldenBar : register(s1); // 用于获取颜色的调色板
float uTime; // 实现调色板的滚动效果

float4 PixelShaderFunction(float2 coords : TEXCOORD0) : COLOR0
{
    float4 color = tex2D(uTextImage, coords);

	// any 为 false 即透明色,不能改
	if (!any(color))
		return color;
	
	// 根据 uTextImage 坐标以及 uTime 的值获取在调色板上的坐标,注意要 %1.0 以确保他在 [0, 1) 区间内
    float2 barCoord = float2((coords.x + uTime) % 1.0, 0);
	
	// 在调色板上选择颜色
    return tex2D(uGoldenBar, barCoord) * color;
}

而”闪动“特效由 uTime 控制,实现一个类似于滚动调色板的效果,使得每一个像素的颜色都动态改变,并且较为柔和

调色板贴图——以防Github上不去,这里也贴个预览,就是一个金色的条条,而且左右是可以无缝衔接的

要是把这个贴图改成彩虹条(原版贴图里面有)就实现了彩虹字

有一种童话的感觉

自此,闪动的金色字特效就完成了!

再提醒一遍:源码在这里

特效啥的我都没着重介绍(然而这是我花得时间最长的),毕竟重点在 MonoModHooks,不是特效

使用 MonoModHooks 添加 IL 修改

使用 MonoModHooks.Modify 即可

private static Type _uiModItemType;
private static MethodInfo _drawMethod;

public override void Load() {
	if (Main.dedServ) {
		return;
	}
	
	_uiModItemType = typeof(Main).Assembly.GetTypes().First(t => t.Name == "UIModItem");
	_drawMethod = _uiModItemType.GetMethod("Draw", BindingFlags.Instance | BindingFlags.Public);
	
	if (_drawMethod is not null) {
		MonoModHooks.Modify(_drawMethod, Manipulate);
	}
}

private void Manipulate(ILContext il) {
	// 编写你的IL代码
}

来点作业

现在你已经基本掌握了 MonoModHooks 动态挂钩子并给Mod名字上特效了,那么就顺带着把动态Mod图标做了吧!我相信这对你们来说只是小菜一碟(

额外 – RenderTarget2D实现滚动颜色

其实经过仔细观察,你会发现这个文本实际上并不是如预期的那样——颜色从右往左滚动,而是每个字一个颜色,还都互不关联

这是为什么呢?实际上字体在游戏里是以图片的形式存储的,而 ChatManager.DrawColorCodedString (内部实现是 SpriteBatch.DrawString )则是对指定的字符串中的每一个字符,绘制其在字体文件(本质上就是一堆图片)中对应的图片。而 Shader 在是对每一次绘制作用,并非对整个整体。因此整体就看起来没有关联且混乱了

使用 RenderTarget2D 可以解决这个问题(似乎离本文主题更远了呢),即通过提前将整体绘制到 RenderTarget2D 中去,最后给 RenderTarget2DShader 绘制外显

首先是定义 RenderTarget2D

// 用于滚动颜色效果
private static RenderTarget2D _renderTarget;

随后,在某个地方实例化 RenderTarget 并将内容绘制到 RenderTarget 上(一定得是主线程),我这里在 Load 中通过 Main.QueueMainThreadAction 在主线程运行代码

这里这段代码在加载时是仅运行一次的,因为 _renderTarget 所包含的内容不需要变动(实际上永远是位于 (0,0) 的白色的 Example Mod v1.0 字样)。这么做可以节省一定资源

// 在主线程上运行,否则会报错
Main.QueueMainThreadAction(() => {
	// 文字内容与长宽
	string text = Mod.DisplayName + " v" + Mod.Version;
	var size = ChatManager.GetStringSize(FontAssets.MouseText.Value, text, Vector2.One).ToPoint();

	// 实例化 RenderTarget,这里 width 和 height 使用 size 的值就行了
	_renderTarget = new RenderTarget2D(Main.graphics.GraphicsDevice, size.X, size.Y);

	Main.spriteBatch.Begin();

	// 设置 RenderTarget 为我们的
	Main.graphics.GraphicsDevice.SetRenderTarget(_renderTarget);
	Main.graphics.GraphicsDevice.Clear(Color.Transparent);
	
	// 绘制字,注意别写成带描边的了,不然整个字就糊了
	ChatManager.DrawColorCodedString(Main.spriteBatch, FontAssets.MouseText.Value, text, Vector2.Zero, Color.White, 0f, Vector2.Zero, Vector2.One);

	// 还原
	Main.spriteBatch.End();
	Main.graphics.GraphicsDevice.SetRenderTarget(null);
});

最后,将我们造的钩子中原来的文字绘制替换即可

sb.Draw(_renderTarget, position, Color.White);

同样地,要在 Unload 中将其设为 null,释放垃圾

if (_renderTarget is not null) {
    _renderTarget = null;
}

最终呈现效果:

格调,太有格调了
有种纯真的美

《通过 MonoModHooks 动态挂钩子》有4个想法

  1. 无论tml是否提供了On和IL的自动卸载,modder都牢记进行卸载。代码卸载后清理所有痕迹并还原应是每个程序员的责任。你也不想变得和360以及金山毒霸一样臭名昭著吧

    1. 层主说得很对,“代码卸载后清理所有痕迹并还原应是每个程序员的责任”是正确的。但要注意的是,自己在Unload卸载这玩意没有任何意义,因为在Unload(这里指该Mod一切继承ModType类的Unload方法)执行时你的mod的一切detour与il editing已被tml卸载。可以这么说:你应该将一切东西卸载,但detour与il editing是例外。将不手动卸载detour与il editing比喻作“变得和360以及金山毒霸一样”则是不明智的,因为我们在tml的框架下编写代码

      此外,从零开始的mod制作教程qq群内确实有modder出现了自行卸载反而导致无法正常卸载以致报错的情况(删掉自行卸载代码后可以正常运行),不过那是很久以前的事情了,且似乎只对某些hook出问题

      在tml官方教程 https://github.com/tModLoader/tModLoader/wiki/Detouring-and-IL-Editing-using-HookEndpointManager 中标粗提到:tModLoader will make sure all detours and IL edits are unloaded during mod unloading, there is no need to manually unregister them

      我并非不提倡自行卸载,只是将一些事实摆在这里,如果你的detour或il editing通过tml卸载无法完全清理痕迹,那么自行卸载是好事,且应当被大力提倡

  2. Pingback: Tiger 的 IL 教程 - 裙中世界

发表回复