【CheatEngine基础教程】四、Unity3D游戏《晚上nano好》修改实战
raindiffer
2021年08月13日 19:13
收录于文集
共11篇

说实话,前面的内容我虽然写得还算认真,但只是工作量大,没有花什么心思准备,但这一章就不一样,因为U3D游戏的素材极难找:首先它不能太过简单,否则没有意义,但也不能太复杂,因为与修改不相干的细节会干扰叙述,而且画面效果太好的游戏,我的电脑未必能跑。所以最理想的目标应该是一款简单的2D模拟类游戏,有数值,但比较清晰,有小游戏的玩法,但操作简单,最重要的是,它必须是免费的,便于读者获取。我花了很长时间,试遍了Steam上的免费U3D游戏,才找到了一个理想的目标。所以希望大家多点赞投币。。。

这里十分感谢 网页链接​ 为我们带来 《晚上nano好》这样一款精致有趣的免费游戏,并同意我拿来作为本章的素材。

《晚上nano好》是一款以三消玩法为核心的养成游戏,通过消除各种元素帮助 椎名菜羽​ 成为人气主播,制作的非常精致,你可以在它的Steam主页免费下载它:

代码块
JavaScript
自动换行
复制代码
https://store.steampowered.com/app/1637670/nano/
复制成功

然后,你可以花一点时间先熟悉一下这个游戏之后再阅读后面的内容,相信我,它很好玩。

另外,本章涉及到C#的知识,对于这部分内容本章不会详细陈述,请读者对于自己不熟悉的词汇自和知识点主查找和学习,微软的官方文档中有C#的全部资料和权威教程:

代码块
JavaScript
自动换行
复制代码
https://docs.microsoft.com/zh-cn/dotnet/csharp/
https://docs.microsoft.com/zh-cn/dotnet/csharp/tour-of-csharp/tutorials/hello-world
复制成功

熟知C#对于U3D的修改来说是必要的。

1 基础知识

U3D的知识我就不复制粘贴了,你可以在它的官网找到它的各种信息。这里只说重点。

我们可以把U3D游戏分成两大类,一类是Mono游戏,一类是IL2CPP游戏。

现代的U3D游戏采用C#作为脚本语言,并将脚本通过Mono编译器(Mono是跨平台的C#实现)编译为IL(中间语言)程序集。程序启动后,通过Mono的Runtime(运行时)将中间语言编译为本机代码(机器码)并进行管理。

像这种 源码—(编译器)—IL程序集——(运行时)——机器码 的编译和运行方式,被称作托管语言,因为需要依赖运行时(可以理解为一个运行脚本的宿主)。而C或C++这种 源码—(编译器)—本机代码 的编译方式,被称作非托管语言。一般认为,优化良好的非托管语言比托管语言的性能要高。因为托管语言涉及到即时编译的问题(需要用的时候才会将目标代码从IL编译为机器码),不过实际上,托管语言的性能未必要糟糕太多,某些时候,因为托管语言可以依据当前的硬件进行针对优化,性能反而会更高。

目前大部分U3D游戏都是以这种形式存在的。不过,为了解决动态编译耗时的问题,Unity官方推出了IL2CPP(IL to C plus plus),为Mono脚本提供AOT的支持。AOT,即An ahead-of-time,是指提前编译。它将Mono的IL程序集提前编译为本机代码,有效提升游戏的启动和加载速度。尽管Unity官方更推荐这种形式,但IL2CPP游戏的数量依然远比不上Mono游戏,为修改游戏带来了便利……

遗憾的是,我没有找到合适的IL2CPP游戏素材,昆特牌单机版是一个理想的选择,但一来收费,二来我现在已经运行不了了,只好放弃,不过幸运的是,IL2CPP游戏在本质上虽然与Mono有很大差异,但修改的方式大同小异,在修改方面IL2CPP与Mono最大的区别在于IL2CPP游戏不太方便用第三方工具进行分析,不过,CE的动态分析功能已经足够,如果你掌握了Mono游戏的修改,那么IL2CPP游戏的修改也不成问题。

这里不打算就IL2CPP进行更多的说明了,请读者自行学习。

关于IL2CPP的说明详见这里:

代码块
JavaScript
自动换行
复制代码
https://blog.unity.com/technology/an-introduction-to-ilcpp-internals
复制成功

关于分析IL2CPP游戏的工具可以参考这两个项目:

代码块
JavaScript
自动换行
复制代码
https://github.com/Perfare/Il2CppDumper
https://github.com/yukiarrr/Il2cppSpy
复制成功

相比于IL2CPP,Mono是我们关注的重点。我们先打开《晚上nano好》的游戏文件夹,不出意外它的文档结构应该是这样的:

代码块
HTML
自动换行
复制代码
晚上nano好
│
├─Konnano_Data:游戏的各种资源。
│ │
│ ├─Managed:包含托管的IL代码程序集,包括游戏脚本和相关类库。
│ │
│ │─Plugins:其他资料,这里是Steam服务的PI。
│ │
│ └......:其他文件和文件夹描述省略,都是写游戏配置信息,和资源文件等等,与主题无关。
│
├─MonoBleedingEdge:运行时和其他一些文件。
│ │
│ ├─EmbedRuntime:包含Mono平台的嵌入式运行时,被..\UnityPlayer.dll所使用。
│ │ │
│ │ ├─mono-2.0-bdwgc:mono平台的运行时以及相关API。
│ │ │
│ │ └─MonoPosixHelper.dll:一组Mono的API接口。此游戏中不需要这个文件。
│ │
│ └ etc:mono运行时的其他成员(例如配置),对于此游戏来说,这个文件夹也是没用的。
│
├─Konnano.exe:游戏的可执行文件。
│
├─UnityCrashHandler32.dll:游戏崩溃处理程序。
│
└─UnityPlayer.dll:Unity引擎的全部代码。
复制成功

 顺便说,konnano猜测是konbanwanano的缩写,也就是“晚上好nano”...

我们重点关注.\Konnano_Data\Managed\文件夹下的Assembly-CSharp.dll文件和.\MonoBleedingEdge\EmbedRuntime文件夹下的mono-2.0-bdwgc.dll文件。

在部分U3d游戏中,这两个文件不一定叫这个名字,取决于开发正的环境了项目设计,不过功能和作用都是一样的。

先说Assembly-CSharp.dll。它就是游戏的脚本,游戏的整个运行逻辑和效果都在这里头。我们要修改Mono游戏,总是在和这个文件打交道。

前面说过mono-2.0-bdwgc.dll是Mono的嵌入式运行时,它里头提供了一系列用于将IL代码编译为机器码,以及各类负责管理底层运转机制的API,这里先不做描述,等到需要的时候再进行解释。要了解它的相关信息,可以阅读这两个官方文档:

代码块
JavaScript
自动换行
复制代码
https://www.mono-project.com/docs/advanced/embedding/
http://docs.go-mono.com/?link=root:/embed
复制成功

2 基础修改

注:如果在调试阶段游戏报错,可以尝试切换CE的调试器。

《晚上nano好》的核心玩法是直播,以三消形式进行。直播有倒计时,首先我们尝试去掉这个倒计时功能,因为这是游戏中最明显的数值了。

直播开始后,记下剩余时间,然后ESC暂停游戏,在CE中以四字节整数的类型搜索这个值,直到找到唯一的结果。

你也可以用CE的暂停功能,但缺点是它会和后面提到的一些调试方法发生冲突。

数值很好找,但是如果你想要寻找这个地址的指针,会发现徒劳无功。不过如果你仔细阅读了第三章的内容且没有忘记的话,应该会记得我们可以通过人造指针的方式实现。

例如我们找到改写了它的代码:

蓝色高亮的指令就是改写了地址的代码

然后在这里注入代码,存储ebx的值。不过你应该也注意到了,右侧的地址,并非我们之前看到的模块名+偏移量的形式,而是一个普通的内存地址。这意味着每次开启游戏之后,这些代码的地址也会变动。

当然我们还有之前说过AOBScan,我们可以通过CE脚本命令在内存中搜索这段代码,找到它的位置在进行操作……这种方法倒是可以,但一来比较麻烦,二来你会发现,如果游戏运行后还没直播过,那么这种方法做出来的脚本甚至因为找不到代码而无法激活……这个游戏还好,在其他一些游戏中,你甚至可能发现一些在其他位置的无效的、一模一样的代码,给AOBScan带来了巨大的困难。这或许就是很多人说U3D游戏困改的原因。

导致这个原因的,是Mono的运作形式。它是直到游戏需要调用某个方法(函数)了,才开始将它编译出来。以我们要改的游戏为例,如果没开始过直播,这些代码甚至都不会被生成。

那么U3d游戏真就这么麻烦吗?答案是否定的,事实上平均而言,U3D游戏是最最好改的一类游戏。

在CE的主界面,当我们打开《晚上nano好》之后,细心的读者应该会发现多了一个菜单:

CE的Mono特性支持

我们选择Activate mono Features(启用mono特性),会进入短暂的卡顿,恢复之后,如无意外,这个菜单会变成被勾选的状态,然后再去看看我们找到的代码:

注意右侧的地址栏

解释一下<countDown>d__63:MoveNext。冒号前面的<countDown>d__63表示类型,而冒号后面的MoveNext表示类型中的方法。

不幸的是,这里的这个组合是一个特殊的情况,读者可能要直接面临大量不熟悉的词汇和概念的冲击了,请别灰心,根据词汇和概念自己学习~

C#的方法的返回类型可以是迭代器(IEnumerator),在编译此类方法时,会直接生成IEnumerator类型,而其核心代码则在IEnumerator.MoveNext方法中。在这里,<countDown>d__63就是一个派生自IEnumerator的迭代器类型,为“原本的”countDown方法的迭代返回提供支持。

在这种情况下,我们就可以构建脚本,而没有之前的种种限制了:

代码块
clike
自动换行
复制代码
[ENABLE]

//CE脚本支持lua脚本进行高级功能
//这里的意思是说,激活脚本后自动开启mono特性支持,不用自己点了。
//关于CE的lua脚本详见官方Wiki:https://wiki.cheatengine.org/index.php?title=Lua
//关于CE对Mono的支持详见https://wiki.cheatengine.org/index.php?title=Mono:Mono_Lua
{$lua}
if syntaxcheck then return end
if (LaunchMonoDataCollector()==0) then error("No Mono") end
{$asm}

alloc(newmem,2048)

//因为动态编译不一定会生成相同的代码,且为了支持游戏更新,所以使用AOB功能
//aobscanregion是搜索内存区域,
// 第一个参数是目标地址的别名,
// 第二个和第三个参数分别是起始地址和终止地址,
// 第三个参数是要搜索的机器码
aobscanregion(countDownMethod,<countDown>d__63:MoveNext,<countDown>d__63:MoveNext+FF,89 83 B0 00 00 00)
registersymbol(countDownMethod)
label(returnhere)
label(baseAddress)
registersymbol(baseAddress)

newmem:
  mov [baseAddress],ebx
  mov [ebx+000000B0],eax
  jmp returnhere
baseAddress:
  db 00 00 00 00

countDownMethod:
  jmp newmem
  nop
returnhere:

 
[DISABLE]
//关闭脚本后取消Mono特性支持
{$lua}
if not syntaxcheck and monopipe then monopipe = nil,monopipe.Destroy()end
{$asm}

dealloc(newmem)
unregistersymbol(baseAddress)
unregistersymbol(countDownMethod)
countDownMethod:
  //mov [ebx+000000B0],eax
  db 89 83 B0 00 00 00
复制成功

当然我们也可以直接弄一个冻结时间的脚本,还记得上面汇编代码里有个dec eax吗?这个就是时间减少的代码,我们可以这样写:

代码块
clike
自动换行
复制代码
[ENABLE]
<countDown>d__63:MoveNext+48:
  nop

[DISABLE]
<countDown>d__63:MoveNext+48:
  //dec eax
  db 48
复制成功

很简单吧?不过如果仅仅只是这样,我也不会说U3D游戏是最好改的一类游戏了,还有更简单的。

在我的机器上,ebx的值是0DD13180。

注意:CE的调试功能可能会导致它对Mono特性的支持失效,需要在调试完毕后再次激活Mono特性。

我们在启用Mono特性的情况下,看看它的数据结构。当我们开始定义新结构时,会弹出如下窗口:

激不激动?

点击Yes,我们会发现CE已经把这个结构完全语义化了!

非常精准

别质疑自己的想法,我们现在看到的结构和里面成员的名称,就是游戏开发者定义的结构和成员名称,而且内存布局和类型完全正确。这是因为对于Mono这种托管语言的实现来说,除非经过特别的加密和混淆,否则它的一切内容对于调试者来说都是透明的!

现在我们注意到之前找到的时间,并不是时间,而是这局游戏的长度。所以修改这个值不一定能达到冻结之间的目的,试了一下果然如此。

不过虽然修改的尝试失败了,我们依然获得了很多有用的信息,相比于对技术的要求,要修改这类游戏,需要挑战的是自己的英语水平和理解能力,不过对于《晚上nano好》来说还需要日语能力。。。比如我们注意到在LiveController结构0x20的danmakuSystem就是游戏中的弹幕系统了。。

有些游戏的变量和方法是用汉语拼音命名的。。只能自求多福。

往下看,我们在LiveController结构0x28的地方看到了Board成员,它的成员中有一个total score变量,我们修改这个值,是不是就可以修改游戏得分了呢?

我改的特别大

果然,在我的帮(zuo)助(bi)下,主播获得了无数的音乐点和粉丝……很方便吧?

在此基础上,我们可以顺势查找一下音乐点数了,因为我们的音乐点足够解锁不少音乐。

顺便找下偏移量吧,看看是什么改写了这个地址,然后我们会发现一个比较奇怪的代码:

音乐点数不是偏移量,而是字面值

发现了吗?在这里,音乐点数的地址不是起始地址+偏移量的形式,而是字面值。在Mono里,这种形式说明,这个地址属于静态字段。

如果你尝试用结构查看器查看这个地址,是走不通的。这个时候我们就要用第三方工具进行辅助分析了。

3 使用逆向分析工具分析游戏源码

前面说过,因为非托管语言的特性,以Mono为平台的U3d游戏对我们来说几乎是透明的,我们可以通过逆向分析工具查看它的源码。在这种情况下,对C#的熟悉程度决定了你可以把游戏修改到什么程度。

3.1 C#逆向分析工具

这里我推荐两个用于C#的逆向分析工具。

一个是dnSpy,它可能是目前开源免费的C#逆向工具中最好用的了。你可以在它的项目主页下载它:

代码块
clike
自动换行
复制代码
https://github.com/dnSpy/dnSpy
复制成功

不过遗憾的是,dnSpy已经被存档,不再更新。但幸运的是,就目前的情况看,它依然适用于Mono的分析,而且在Mono不发生内核性质的重大变动的前提下(在很长一段时间内这是必然的),dnSpy仍旧是我推荐的第一选择。

另外一个选择是JetBrains dotPeek。程序员肯定都知道著名软件开发商JetBrains的大名,dotPeek就是他们推出的免费的C#逆向分析调试工具。它的操作简单,功能比dnSpy还要强大,可以说,dotPeek是现在最强大的C#逆向工具,没有之一。我在用C#​写一些性能和安全敏感的代码的时候,都会用JetBrains提供的IL Viewer检查其中间代码,以找出隐患。而我在这里不将它放在推荐的首位,有两个原因。一是它太“重了”,每个用过JetBrains产品的开发者一定会认同我对这些产品的总结:功能强大,界面美丽,操作简单……以及非常吃内存和性能极度低下。机器稍差一点使用他们的产品甚至会出现各种烦人的卡顿。另一个原因是,它没有中文版。尽管我一直强调英文的重要性,但对于很多读者来说,一下子接触到新领域的那么多陌生的词汇,估计很长一段时间内会觉得无所适从……

而且从修改游戏来看,dnSpy已经足够,而且它还这么轻量,所以下文我会以dnSpy为工具演示游戏源码的分析。

3.2 用dnSpy进行代码分析

dnSpy运行后的界面下图:

dnSpy

dnSpy右侧是代码显示窗口,下面是一些特殊的输出窗口。而左侧则是程序集浏览窗口。我们把游戏的Assembly-CSharp.dll拖入左侧窗口,dnSpy会自动加载此程序集和其他它所依赖的程序集。一般来说开发者会把游戏的源码放在脚本程序集默认的空命名空间中,如下图所示:不过这属于开发者的个人喜好,也有可能在别的地方……

红框的内容就是游戏的源码了

点开这些类就可以查看游戏的源码了。现在我们先看看为什么我们之前试图锁定时间是无效的。我们在右下的搜索栏中填入<countDown>d__63来搜索这个方法的位置,或者你也可以直接搜索本来的方法countDown:

搜索结果

随便点进去一个,我们会发现这样的代码。

代码块
C#
自动换行
复制代码
	private IEnumerator countDown()
	{
		while (this.gameLength > 0)
		{
			Text text = this.counter;
			int num = this.gameLength - 1;
			this.gameLength = num;
			text.text = num.ToString();
			yield return new WaitForSeconds(1f);
		}
		yield break;
	}
复制成功

此方法属于LiveController类,我们发现这个方法是独立的,修改它没有用理所当然。怎么办呢,我们就在这个类看看有没有什么其他方法,最后我发现了这个:

代码块
C#
自动换行
复制代码
	public void togglePause()
	{
		if (!GameController.paused || (GameController.paused && this.pauseMenu.activeSelf))
		{
			Time.timeScale = (float)(GameController.paused ? 1 : 0);
			this.pauseMenu.SetActive(!GameController.paused);
			GameController.paused = !GameController.paused;
			this.musicPlayer.toggleMusic();
			this.player.toggleVoice();
		}
	}
复制成功

顾名思义,这是在三消游戏中暂停的方法。

不过大佬你这就过分了啊,这一个暂停代码搞出好多事情来。。。这种判断逻辑和实现方式一看知道想要时间暂停有多麻烦。

注:通过dnSpy直接修改Assembly-CSharp.dll文件倒是很简单,但不在本文讨论范围内。

而且仔细想想,直接改时间还是有点低级的样子,不够优雅,既然我们都能看见源码了,为什么不直接从三消游戏本身入手,搞点大事情出来呢?不过在这之前,我们还是先看看之前找到的音乐点数是怎么回事。

之前找到的修改了音乐点数的代码,是在EGuitarDetailsController:unlock中。我们可以直接搜索EGuitarDetailsController类,也可以手动找到它,然后看unlock方法:

代码块
C#
自动换行
复制代码
	public override void unlock()
	{
		this.unlockButton.SetActive(false);
		this.playButton.SetActive(true);
		this.songController.unlock();
		GameController.musicPoints -= this.songController.cost;
		GameController.eGuitarStatus[this.songController.index, 0] = false;
	}
复制成功

我们重点看倒数第三行的GameController.musicPoints -= this.songController.cost语句,显而易见,这个音乐点就是GameController中的musicPoints字段。在dnSpy中直接点击这个字段,然后会自动跳转到GameController类,并定位到这个字段,如下图:

静态字段

果然它们是静态字段,而且显而易见,captains、followers、musicPoints分别表示大航海、粉丝数、音乐点。

让我们切换到CE的主界面,依次点击Mono——.NET Info菜单,会打开程序集动态浏览窗口,在这里我们可以直接查看某类型静态字段的信息:

黑框部分就是三个静态字段的名称/类型/地址/数值

在我的机器上,三个地址分别是0D31CF74、0D31CF78、0D31CF7C。不过我们的目的显然不是简单的查看这个地址,要想做一个修改表,我们肯定希望能把这个些地址放在地址列表里。怎么办呢?有两个办法,一个是用前面说的mono lua,这个方法比较简单,但我个人非必要不喜欢用lua脚本,懒得讲了,请读者在CE的Wiki中自己学习,我这里提供一个间接且效率更高的方式。

在dnSpy中右键点击captains字段,然后选择分析,如下图所示:

分析

在下方的分析器中我们可以看到所有读取了/改写了这个字段的值的方法:

分析器

选择一个最简单的,我选择DataCentre.init()方法:

代码块
C#
自动换行
复制代码
private void init()
	{
		this.captains.text = GameController.captains.ToString();
		this.followers.text = GameController.followers.ToString();
		this.musicPoints.text = GameController.musicPoints.ToString();
	}
复制成功

然后在CE里查看这个方法,不过要去掉括号,并且将“.”换成“:”,即DataCentre:init:

DataCentre:init

结合dnSpy、CE中的.Net Info,是不是汇编代码变得不再晦涩难懂了?

我们可以用脚本的形式,直接从代码中读取这个静态字段的值:

代码块
clike
自动换行
复制代码
[ENABLE]
{$lua}
if syntaxcheck then return end
if (LaunchMonoDataCollector()==0) then error("No Mono") end
{$asm}

//DataCentre:init+f - C7 04 24  74CF310D    - mov [esp],0D31CF74
aobscanregion(dataCentreInitMethod,DataCentre:init,DataCentre:init+FF,C7 04 24)
alloc(newmemCaptians,4)
registersymbol(newmemCaptians)

newmemCaptians:
//readmem是CE脚本指令。
//在地址dataCentreInitMethod+3处读取4字节长度的数据
//并写入当前位置。
readmem(dataCentreInitMethod+3,4)
 
[DISABLE]
dealloc(newmemCaptians)
unregistersymbol(newmemCaptians)

{$lua}
if not syntaxcheck and monopipe then monopipe = nil,monopipe.Destroy()end
{$asm}
复制成功

激活脚本后,newmemCaptians就成为captains字段的指针了,其他两个字段直接按照偏移量来计算就可以,如下图所示:

效果图

3.3 三消游戏的修改

到目前为止,我们已经搞定了游戏核心数值的修改,游戏的各种元素可以很简单的解锁了。但是,这种修改未免有些无聊,我个人一向不喜欢改数值的玩法,因为觉得很机械,而且会降低游戏的乐趣。

《晚上nano好》的核心玩法是三消,对于三消而言,想要爽快,一定要有暴躁的消除效果,把益智游戏变成半无脑割草……让我们试试能不能做到这一点。

如果是普通的C/C++游戏就比较困难了,需要大量的跟踪和调试。不过如果是U3d的话,我们可以节省大部分的时间。

让我们看游戏的源码,如果你看了上篇文章,应该会记得在那里,开发者把游戏区域称作Board。这是比较专业的命名方法, Marsh533​ 的代码写的很专业,所以应该也会采用类似的命名方式。我们找找看,随即发现了一个类:BoardController。

Controller是软件架构的术语,意思是控制器,用于控制目标的运行逻辑。而它下面的那个BoardMaskController是游戏开始后和结束前挡住BoardController的那个的大的提示遮罩。

让我们看看BoardController里的各种方法,最后找到了一个很合适的目标:

代码块
C#
自动换行
复制代码
public bool isAdjacent(TileController tile1, TileController tile2)
{
  return (tile1.X == tile2.X && Mathf.Abs(tile1.Y - tile2.Y) == 1) || (tile1.Y == tile2.Y && Mathf.Abs(tile1.X - tile2.X) == 1);
}
复制成功

都不用看代码,直接看名字就知道,这个方法是用来判断两个方块(tile)是不是挨在一起的。回顾我们移动方块时的效果,让我们化身游戏开发者,想想这个方法用在什么时候呢?

没错,就是用来判断两个方块是否能移动的。如果我们点了两个不相邻的方块(这个方法返回位false,即0),根本就没有任何效果。

所以如果我们能让这个方法总是返回true,即1,是不是不相邻的方块也可以移动了呢?

我们试一试,在CE里定位到BoardController:isAdjacent方法:

BoardController:isAdjacent方法

想让这个方法总是返回true非常简单,不过在此之前我们需要对方法进行分析。首先isAdjacent(TileController tile1, TileController tile2)方法有三个参数,除了两个显式的参数外,还有一个隐藏的this参数用以传递目标实例的指针。然后我们看看这个函数的开头和结尾。

注:如果目标是静态方法,则没有this参数。

开头:

代码块
C#
自动换行
复制代码
BoardController:isAdjacent - 55                    - push ebp
BoardController:isAdjacent+1- 8B EC                 - mov ebp,esp
BoardController:isAdjacent+3- 57                    - push edi
BoardController:isAdjacent+4- 56                    - push esi
BoardController:isAdjacent+5- 83 EC 20              - sub esp,20
复制成功

结尾:

代码块
C#
自动换行
复制代码
BoardController:isAdjacent+72 - 8D 65 F8              - lea esp,[ebp-08]
BoardController:isAdjacent+75 - 8D 65 F8              - lea esp,[ebp-08]
BoardController:isAdjacent+78 - 5E                    - pop esi
BoardController:isAdjacent+79 - 5F                    - pop edi
BoardController:isAdjacent+7a - C9                    - leave 
BoardController:isAdjacent+7b - C3                    - ret 
复制成功

开头的最初两行指令和结尾的最后两个指令是运行时自动生成的,旨在对栈的初始值进行记录和还原,leave指令相当于如下指令:

代码块
C#
自动换行
复制代码
mov esp, ebp
pop ebp 
复制成功

所以我们可以发现这个函数中并没有把接收到的参数弹出堆栈的操作,所以这是一个cdecl风格的thisCall,我们不必去管堆栈的问题,所以写这个脚本就很省事了:

代码块
clike
自动换行
复制代码
[ENABLE]
//函数最开头的代码是固定的,用不着AOB搜索。
BoardController:isAdjacent:
  xor eax,eax // eax=0
  inc eax // eax=1
  ret
 
[DISABLE]
BoardController:isAdjacent:
  // push ebp
  // mov ebp,esp
  // push edi
  db 55 8B EC 57
复制成功

启用脚本后,你会发现相隔遥远的两个方块,已经可以交换位置了。就是这么枯燥而朴实无华。

但交换位置归交换位置,不同类型的方块还是会“弹”回来,无法消除。所以我们要找找有没有更为暴躁的修改方法。

void swapTiles(TileController tile1, TileController tile2)方法就在bool isAdjacent()方法的下面,不用找,很巧它就是用于判断是否正式交换两个方块并消除的方法,然而这个方法显然不适合修改,因为他内容太多了,我们想在反汇编代码中定位关键代码比较麻烦,更重要的是,交换方块的判断逻辑比较复杂,我们要修改很多地方,成本颇高。另外,方法内联也为修改带来了很大的困难。

这里稍微叉开话题,解释一下方法内联。这在实际修改中会经常遇到。所谓的内联,就是对于小型方法(具体来说就是编译成IL后少于16个字节),运行时在将IL转为本地代码(机器码)时,会直接将它嵌入调用者那里,而非通过调用的方式来执行。

怎么理解呢?看一个例子,还是在BoardController类中,这次我们看游戏总得分的代码:

代码块
C#
自动换行
复制代码
	public int TotalScore
	{
		get
		{
			return this.totalScore;
		}
	}
复制成功

这种形式的代码被称作“属性”,属性的用途用法我就不科普了,不了解的读者请自学,这里只说实施层面的东西。对于属性,编译器其实是将它编译成了方法。鼠标选中get,然后右键分析,你会发现它其实是这种形式的方法:BoardController:get_TotalScore。

对于有set访问器的属性来说,当然还会生成set_XXX方法咯。

而get_TotalScore方法会被下图中的方法所调用。

被调用

按照我们最初的想法,这个语句对应汇编代码,应该是类似Call XXX之类的指令对吧?

但其实不是,查看相应的汇编代码,你会发现这个方法直接被内联了:

高亮的指令就是读取totalScore字段的代码

这种编译方式有利于性能的提升,却很不利与我们修改,因为本来直接改属性对应的方法就可以了,但一旦内联,就意味着我们可能要修改更多更长的方法……

所以我们要向修改游戏的三消,需要找一个操作简单的、有利于我们修改的方法来干坏事。幸运的是,这并不难找,当然前提是你对C#足够熟悉,对项目也要有一定的理解……

在isAdjacent(TileController tile1, TileController tile2)方法的上面,有一个bool fillStep()方法,这是用来进行消除后填充的。B站的代码功能不支持行号,很麻烦,我手动加了行号,请凑合着读吧:

代码块
HTML
自动换行
复制代码
1		public bool fillStep()
2		{
3			bool result = false;
4			for (int i = 1; i < 8; i++)
5			{
6				for (int j = 0; j < 8; j++)
7				{
8					TileController tileController = this.tiles[j, i];
9					if (tileController.isMovable())
10					{
11						TileController tileController2 = this.tiles[j, i - 1];
12						if (tileController2.Type == BoardController.TileType.empty)
13						{
14							Object.Destroy(tileController2.gameObject);
15							tileController.TileMoveRef.move(j, i - 1, this.fillTime);
16							this.tiles[j, i - 1] = tileController;
17							this.createTile(j, i, BoardController.TileType.empty);
18							result = true;
19						}
20					}
21				}
22			}
23			for (int k = 0; k < 8; k++)
24			{
25				int num = 7;
26				TileController tileController3 = this.tiles[k, num];
27				if (tileController3.Type == BoardController.TileType.empty)
28				{
29					Object.Destroy(tileController3.gameObject);
30					GameObject gameObject = Object.Instantiate<GameObject>(this.tilePrefabDict[BoardController.TileType.normal]);
31					gameObject.name = string.Concat(new object[]
32					{
33						"Tile(",
34						k,
35						",",
36						num,
37						")"
38					});
39					gameObject.transform.SetParent(base.transform);
40					gameObject.transform.localPosition = new Vector2(BoardController.positions[k], BoardController.positions[num] + this.gridSize);
41					this.tiles[k, num] = gameObject.GetComponent<TileController>();
42					this.tiles[k, num].init(k, num + 1, this, BoardController.TileType.normal);
43					this.tiles[k, num].TileMoveRef.move(k, num, this.fillTime);
44					do
45					{
46						this.tiles[k, num].TileCategoryRef.setCategory((TileCategory.Category)Random.Range(0, this.tiles[k, num].TileCategoryRef.NumCategories));
47					}
48					while (!this.initialized && this.isMatched(k, num));
49					result = true;
50				}
51			}
52			return result;
53		}
复制成功

这个方法是在消除方块后进行填充的。我们看第30行的代码:

代码块
C#
自动换行
复制代码
GameObject gameObject = Object.Instantiate<GameObject>(this.tilePrefabDict[BoardController.TileType.normal]);
复制成功

tilePrefabDict字段是一个字典,存储着所有类型的方块的实例,而TileType是一个枚举,内容如下:

代码块
C#
自动换行
复制代码
	public enum TileType
	{
    //空格子
		empty,
    //普通方块
		normal,
     //横消
		rowClear,
     //纵消
		colClear,
     //提督效果,加大航海的
		teitoku,
     //总督效果,同上。。。
		sotoku,
     //其实不是方块类型,是在统计过程中的一个逻辑状态
		count
	}
复制成功

在这条语句中,先从字典里取出一个普通的方块实例,然后再根据它创建(复制)一个GameObject实例。

然后我们再看第42行代码:

代码块
C#
自动换行
复制代码
his.tiles[k, num].init(k, num + 1, this, BoardController.TileType.normal);
复制成功

就是在K,num+的位置初始化一个普通类型的方块。

已知了这两条语句,聪明的读者一定知道我们要怎么做了吧?

没错,只要把BoardController.TileType.normal换成更为暴躁的类型就行了。我们在CE中看看这个方法的反汇编代码。这个方法的反汇编代码很长,但我们不用硬看,而是直接找关键性标志。

在这个方法中,只有一个语句(this.tilePrefabDict[BoardController.TileType.normal])是从字典中获取值的,所以这个语句就很容易定位:

蓝色高亮内容是从字典中取值的方法。

蓝色高亮内容是从字典中取值的方法,而黑框中的内容则是它的(除this指针外的)第一个参数,即BoardController.TileType.normal。我们把1改成其他值就可以了。

然后我们往下找,两条语句离得不算远,所以一定很快就能找到一个有1做参数方法:

代码块
HTML
自动换行
复制代码
BoardController:fillStep+2f6 - 89 4C 24 08           - mov [esp+08],ecx

BoardController:fillStep+2fa - C7 44 24 04 01000000  - mov [esp+04],00000001

BoardController:fillStep+302 - 89 04 24              - mov [esp],eax
BoardController:fillStep+305 - 8B 00                 - mov eax,[eax]
BoardController:fillStep+307 - FF 90 8C000000        - call dword ptr [eax+0000008C]
复制成功

两条关键代码都知道了,可以写脚本啦:

代码块
clike
自动换行
复制代码
[ENABLE]
//mov eax,[edi+24]
//mov [esp+04],00000001
//mov [esp],eax
aobscanregion(fillStepGetDict,BoardController:fillStep,BoardController:fillStep+FFF,8B 47 24 C7 44 24 04 01 00 00 00 89 04 24)
registersymbol(fillStepGetDict)

//mov [esp+08],ecx
//mov [esp+04],00000001
//mov [esp],eax
aobscanregion(fillStepSetType,BoardController:fillStep,BoardController:fillStep+FFF,89 4C 24 08 C7 44 24 04 01 00 00 00 89 04 24)
registersymbol(fillStepSetType)

fillStepGetDict+7:
  db 03 //纵行全消除

fillStepSetType+8:
  db 03 //纵行全消除

[DISABLE]

fillStepGetDict+7:
  db 01 //普通

fillStepSetType+8:
  db 01 //普通

unregistersymbol(fillStepGetDict)
unregistersymbol(fillStepSetType)
复制成功

这里我用了纵行消除,你也可以用横行消除(02),全凭个人喜好。激活脚本,体验一下爽快的消除体验吧!!!

最后放一个游戏全部的CT文本:

代码块
HTML
自动换行
复制代码
<?xml version="1.0" encoding="utf-8"?>
<CheatTable>
  <CheatEntries>
    <CheatEntry>
      <ID>5</ID>
      <Description>"初始化"</Description>
      <Options moHideChildren="1" moManualExpandCollapse="1" moDeactivateChildrenAsWell="1"/>
      <LastState Activated="1"/>
      <VariableType>Auto Assembler Script</VariableType>
      <AssemblerScript>[ENABLE]
{$lua}
if syntaxcheck then return end
if (LaunchMonoDataCollector()==0) then error("No Mono") end
{$asm}

//DataCentre:init+f - C7 04 24  74CF310D    - mov [esp],0D31CF74
aobscanregion(dataCentreInitMethod,DataCentre:init,DataCentre:init+FF,C7 04 24)
alloc(newmemCaptians,4)
registersymbol(newmemCaptians)

newmemCaptians:
readmem(dataCentreInitMethod+3,4)
 
[DISABLE]
dealloc(newmemCaptians)
unregistersymbol(newmemCaptians)

{$lua}
if not syntaxcheck and monopipe then monopipe = nil,monopipe.Destroy()end
{$asm}
</AssemblerScript>
      <CheatEntries>
        <CheatEntry>
          <ID>6</ID>
          <Description>"大航海"</Description>
          <LastState Value="0" RealAddress="0DC1CF74"/>
          <ShowAsSigned>0</ShowAsSigned>
          <VariableType>4 Bytes</VariableType>
          <Address>newmemCaptians</Address>
          <Offsets>
            <Offset>0</Offset>
          </Offsets>
        </CheatEntry>
        <CheatEntry>
          <ID>7</ID>
          <Description>"粉丝数"</Description>
          <LastState Value="0" RealAddress="0DC1CF78"/>
          <ShowAsSigned>0</ShowAsSigned>
          <VariableType>4 Bytes</VariableType>
          <Address>newmemCaptians</Address>
          <Offsets>
            <Offset>4</Offset>
          </Offsets>
        </CheatEntry>
        <CheatEntry>
          <ID>8</ID>
          <Description>"音乐点"</Description>
          <LastState Value="0" RealAddress="0DC1CF7C"/>
          <ShowAsSigned>0</ShowAsSigned>
          <VariableType>4 Bytes</VariableType>
          <Address>newmemCaptians</Address>
          <Offsets>
            <Offset>8</Offset>
          </Offsets>
        </CheatEntry>
        <CheatEntry>
          <ID>10</ID>
          <Description>"远程移动"</Description>
          <LastState Activated="1"/>
          <VariableType>Auto Assembler Script</VariableType>
          <AssemblerScript>[ENABLE]
BoardController:isAdjacent:
  xor eax,eax // eax=0
  inc eax // eax=1
  ret
 
[DISABLE]
BoardController:isAdjacent:
  // push ebp
  // mov ebp,esp
  // push edi
  db 55 8B EC 57
</AssemblerScript>
        </CheatEntry>
        <CheatEntry>
          <ID>11</ID>
          <Description>"超级消除"</Description>
          <LastState/>
          <VariableType>Auto Assembler Script</VariableType>
          <AssemblerScript>[ENABLE]
//mov eax,[edi+24]
//mov [esp+04],00000001
//mov [esp],eax
aobscanregion(fillStepGetDict,BoardController:fillStep,BoardController:fillStep+FFF,8B 47 24 C7 44 24 04 01 00 00 00 89 04 24)
registersymbol(fillStepGetDict)
//mov [esp+08],ecx
//mov [esp+04],00000001
//mov [esp],eax
aobscanregion(fillStepSetType,BoardController:fillStep,BoardController:fillStep+FFF,89 4C 24 08 C7 44 24 04 01 00 00 00 89 04 24)
registersymbol(fillStepSetType)

fillStepGetDict+7:
  db 03 //纵行全消除

fillStepSetType+8:
  db 03 //纵行全消除

[DISABLE]

fillStepGetDict+7:
  db 01 //普通

fillStepSetType+8:
  db 01 //普通

unregistersymbol(fillStepGetDict)
unregistersymbol(fillStepSetType)
</AssemblerScript>
        </CheatEntry>
      </CheatEntries>
    </CheatEntry>
  </CheatEntries>
</CheatTable>
复制成功

最后留个小作业共有兴趣的读者研究:已知成就的英文是Achivement,尝试给自己制作一个快速解锁成就的脚本吧,它不是很难。

4 调用游戏中的方法

《晚上nano好》其实用不到这个,但我认为这个知识还是很有必要的,因为说不定读者改其他游戏的时候就用到了,所以仅做演示。

在C++的游戏中,我们可以自己写一个函数F,F用来调用游戏的函数,然后通过CE脚本命令CreateThread(F)来远程调用F函数。达到调用游戏函数的目的。Mono游戏也可以这样做,不过更为复杂一些。

我们从那里开始就从哪里结束,在最开始,我们尝试时间冻结,然后发现了togglePause()方法,用来切换暂停菜单。其实,在游戏的房间场景中,也是用类似的方式切换暂停菜单的。在dnSpy中查看RoomController,然后找到它的void togglePause()方法:

代码块
C#
自动换行
复制代码
	public void togglePause()
	{
		if (!GameController.paused || (GameController.paused && this.pauseMenu.activeSelf))
		{
			if (!GameController.paused)
			{
				this.hasFrozen = this.freeze;
			}
			Time.timeScale = (float)(GameController.paused ? 1 : 0);
			this.pauseMenu.SetActive(!GameController.paused);
			this.freeze = (this.hasFrozen ? this.hasFrozen : (!this.freeze));
			GameController.paused = !GameController.paused;
			this.musicPlayer.toggleMusic();
			this.playerModels[GameController.playerModel].GetComponent<PlayerController>().toggleVoice();
		}
	}
复制成功

我们的目的就是调用这个方法,通过CE脚本来切换它。

首先这个方法不是静态函数,需要一个默认的this指针,我们第一步要获取这个指针。

CE的场景,或者说元件,显示后,都会调用它的Update方法以不断刷新,而RoomController也不例外:

代码块
C#
自动换行
复制代码
	private void Update()
	{
		if (Input.GetKeyDown(KeyCode.Escape) && !this.mask.gameObject.activeSelf)
		{
			this.togglePause();
		}
	}
复制成功

mask应该是指变暗之类固定的效果遮罩。这里是说,如果你按下了ESC键,且mask没有激活显示,则相应按键,切换暂停/非暂停。

不过这里我们不关心这些代码,只是用这个一直被调用的方法获取RoomController实例的指针。

在CE中查看Update方法:

代码块
HTML
自动换行
复制代码
RoomController:Update - 55                    - push ebp
RoomController:Update+1- 8B EC                 - mov ebp,esp
RoomController:Update+3- 83 EC 18              - sub esp,18
RoomController:Update+6- C7 04 24  1B000000    - mov [esp],0000001B
RoomController:Update+d- 8B C0                 - mov eax,eax
RoomController:Update+f- E8 4C000000           - call UnityEngine:Input:GetKeyDown
复制成功

我们利用之前学到的只是,计算一下前几行代码时栈的情况。

这个函数只又一个参数this指针,所以代码执行到RoomController:Update的时候,栈顶指针Esp指向的值([Esp])是函数的返回地址,而[esp+4]则是this指针。长跳转指令需要5个字节长度,我们在这里做跳转不合适,应该从RoomController:Update+1做跳转,在这里,因为压入了ebp,所以this指针的位置变成了[esp+8],因为函数头部内容固定,所以不用AOB搜索,于是脚本则应该是:

代码块
clike
自动换行
复制代码
[ENABLE]
alloc(newmem,2048)
label(returnhere)
label(RoomControllerBase)


newmem:
  mov ebp,[esp+8]
  mov [RoomControllerBase],ebp
  mov ebp,esp
  sub esp,18
  jmp returnhere

RoomControllerBase:
  db 00 00 00 00

RoomController:Update+1:
  jmp newmem
returnhere:
 
 
[DISABLE]
dealloc(newmem)
unregistersymbol(RoomControllerBase)

RoomController:Update+1:
  //mov ebp,esp
  //sub esp,18
  db 8B EC 83 EC 18
复制成功

然后我们需要写一个函数,以调用togglePause()方法,所以上面的脚本可以更新为:

代码块
clike
自动换行
复制代码
[ENABLE]
alloc(newmem,2048)
label(returnhere)
label(RoomControllerBase)
registersymbol(RoomControllerBase)
label(InvokeMethod)
registersymbol(InvokeMethod)

newmem:
  mov ebp,[esp+8]
  mov [RoomControllerBase],ebp
  mov ebp,esp
  sub esp,18
  jmp returnhere

InvokeMethod:
  push [RoomControllerBase]
  call "RoomController:togglePause"
  add esp,4 //调用方管理栈
  ret

RoomControllerBase:
  db 00 00 00 00

RoomController:Update+1:
  jmp newmem
returnhere:

 
[DISABLE]
dealloc(newmem)
unregistersymbol(RoomControllerBase)
unregistersymbol(InvokeMethod)

RoomController:Update+1:
  //mov ebp,esp
  //sub esp,18
  db 8B EC 83 EC 18
复制成功

然后我们新建个脚本,直接通过CreateThread远程调用InvokeMethod就可以了:

代码块
clike
自动换行
复制代码
[ENABLE]
createthread(InvokeMethod)

 
[DISABLE]
createthread(InvokeMethod)
复制成功

我提前说,这样的写法是不行的,对其他游戏有效,但对U3D游戏无效!!!!!想要尝试,请先保存游戏!!!

另外,想要看看出了什么错,请这么设置:

选择Always

CreateThread的本质是远程在游戏中创建一个线程,执行目标函数,但这个线程是无法访问被Mono管理的方法的,要解决这个问题,先要让mono附加到线程。所以最终的代码要这样:

代码块
clike
自动换行
复制代码
[ENABLE]
//code from here to '[DISABLE]' will be used to enable the cheat
alloc(newmem,2048)
label(returnhere)
label(RoomControllerBase)
registersymbol(RoomControllerBase)
label(InvokeMethod)
registersymbol(InvokeMethod)

newmem:
  mov ebp,[esp+8]
  mov [RoomControllerBase],ebp
  mov ebp,esp
  sub esp,18
  jmp returnhere

InvokeMethod:
  // 所有的函数都是cdecl
  sub esp,8
  //获取domain,无参数
  call mono-2.0-bdwgc.mono_get_root_domain
  mov [esp],eax
  // 附加到线程,1各参数,domain。
  call mono-2.0-bdwgc.mono_thread_attach
  //eax是附加到线程后返回的handler。
  //这里直接放到esp+4.
  mov [esp+4],eax
  mov eax,[RoomControllerBase]
  mov [esp],eax
  //切换菜单
  call "RoomController:togglePause"
  add esp,4 //弹出栈顶。
  // 此时[esp]就是之前附加线程返回的handler
  // 取消附加线程。
  call mono-2.0-bdwgc.mono_thread_detach
  add esp,4
  ret
attachHandler:
  db 00 00 00 00
RoomControllerBase:
  db 00 00 00 00

RoomController:Update+1:
  jmp newmem
returnhere:

 
[DISABLE]
dealloc(newmem)
unregistersymbol(RoomControllerBase)
unregistersymbol(InvokeMethod)

RoomController:Update+1:
  //mov ebp,esp
  //sub esp,18
  db 8B EC 83 EC 18
复制成功

激活此脚本后,再使用之前那个createthread的脚本,随意激活关闭,你会发现游戏的暂停菜单也随之开启关闭。

哦对了,补充个东西,有的时候你可能希望脚本在激活后可以自动关闭,可以用类似这样的脚本:

代码块
clike
自动换行
复制代码
[ENABLE]
{$lua}
local t = createTimer()
t.Interval = 200
t.OnTimer = function(t)
  t.destroy()
  memrec.Active = false
end
{$asm}
createthread(replaceItem)
  
[DISABLE]
复制成功

这里的lua脚本是说,脚本激活后会创造一个间隔为200毫秒的计时器,(200毫秒之后)计时器开始第一次计时后,销毁自身,并取消脚本激活。

5 IL2CPP游戏的修改

因为没有合适的例子我就只简单谈谈吧。

其实IL2CPP和mono的修改没太大差别,CE的MONO分析功能一样适用,各种类和数据结构也一样看,最大的问题就是不能用dnSpy之类的工具查看C#的源码了。

嗯,就这些,没了。

6 总结

这个系列的教程远比我之前想象的要粗糙,创作难度也远比我之前想象的要高。

教程不是谁都能写的,自己懂得不代表就能指导人家。我在写的时候遇见的最大的问题就是不知道读者需要了解什么知识,我尽量用最浅显的方式来分析和调试游戏,但不得不承认这个过程依旧依赖于对各领域知识的熟悉和经验。内容太多了,很难下笔。最终我只能机械的罗列一个个专业词汇,做出不标准的解释,期待读者自己去了解这方面的知识。从这个角度看,我在前言写的那些话有些口出狂言的意思。

尽管我写得还算用心,但依然少了很多东西,比如CE的各种用法(制作修改器、lua脚本),C#语言的内容和实现原理、汇编的技巧和编译器生成,操作系统的API知识……我本来想一一尽叙,但每一块的内容都多得让我生畏。

我不止一次的说过,游戏修改涉及到方方面面的知识,如果想要更深入的研究,需要非功利地在其中投入不少的时间,不过真的很有趣对吧?玩游戏可以收获一份快乐,而合理的修改也能收获一份快乐,这就是双份的快乐。

但想要感受快乐,可能需要先承受学习的痛苦,或者利用我们的好奇心把痛苦变成快乐,这就是三倍的快乐了。

所以无论如何,这个系列的教程到这里就结束了。以后遇到合适的游戏我可能会进行针对性的更新,否则它应该就是各位看到的这个样子了。

感谢大家的阅读。