饥荒游戏扫雷笔记(一)|脚本引擎篇——LuaJIT的救赎(合集)

3,887 阅读54分钟
原文链接: zhuanlan.zhihu.com
新写一篇文章,以纪念在制作和调试饥荒游戏LuaJIT桥接PATCH中探过的雷区。

本文大体已经更新完结,如有新的内容会及时补充。

关于饥荒游戏的介绍,直接看这个答案好了:zhihu.com/question/2128

关于本插件的详细介绍,下载及源码请点击:paintdream.github.io/Do

本文所用到的软件:OllyDBG 2.01、Microsoft Visual C++ 2008


-----------------------------------------我是分割线------------------------------------------------

0x00 神船搁浅

我要给饥荒写PATCH,并不是一个刚开始就计划好的事情。饥荒发布于Steam平台并且添加了创意工坊,里面有很多好的、差的、能用的、不能用的、原创的以及抄的Mods,大部分都维护得很好,开游戏时连存档带MOD设置都会被云同步更新,几乎可以是完美了。

然而唯一的美中不足似乎是:这游戏居然很吃配置。N年前买的神船竟然会在玩久了之后就会时不时顿卡,以至于到后来居然越来越卡了。虽然i7-3610QM+GT650M在现在看来也比较渣,但是至少跑跑dota2中配都还很流畅,也不至于在饥荒上惨成这样。

打开任务管理器瞄一眼,发现情况还是很有意思的。在卡顿的时候,CPU只有两个核心满载运行,其余都在打酱油——通过暂停-恢复的办法观察CPU占用率,可以得出结论,CPU0是负责渲染的线程,CPU6是负责脚本逻辑的线程。

卡顿是来自于两方面的,一方面如果绘制的对象较多,机器就会明显开始变卡。这时CPU0的使用率会明显下降,说明有大量绘制指令阻塞了渲染管线,导致绘制线程长期处于等待渲染完成的阻塞状态。同时用户的输入反馈也会被明显阻滞,推测读取用户输入的相关代码也是在这个线程中执行的(饥荒使用了DirectInput来读取键盘操作)。饥荒本身这种哥特式的画风,使用一些预制的纹理搞搞Billboard贴图就可以做出伪2D的画面效果了,渲染本身压力应该不算大。但是考虑到ES版本低的限制用不了Instanced Draw,很可能是每个对象都对应一个draw call,并且每个对象绘制时都重复提交了大量数据,导致带宽用尽。不过这方面我现在还没有做仔细的研究,仅仅是猜测而已。

另一方面如果脚本的逻辑过于复杂,也会明显变卡。这时CPU6将会被占满,而由于逻辑帧与渲染帧同步的关系,CPU0的占用率会变低。这方面的问题相对来说比较严重,因为为了好玩大家都会加载很多MODs,Shipwrecked DLC新增加的游戏特性也非常吃CPU,导致大家在玩这个DLC时感受到的卡顿远远比原版或者Reign of Giants DLC时要明显得多。因此在很多论坛上大家解决这个卡顿的办法只有——别开那么多MOD,换个好点的电脑。

本文尝试解决的是脚本逻辑引起的卡顿。

0x01 积重难返

好消息是饥荒使用了我比较熟悉的lua 5.1.4作为脚本引擎(但是实际上我更希望看到的是lua 5.3),并且随游戏附送了所有的lua脚本(大约18万行,不算DLC),所有的逻辑都是在脚本中实现的。

研究了几个小时后发现,作者似乎仅仅是把它当作一个工具来用,没有专门在lua特性上下功夫。为了写起来更方便和熟悉,当需要异步操作时,大部分的需求都是通过C层面支持的时钟回调(往往配合全局任务队列)来完成的。这个设计也给我造成了麻烦,因为它会使原本完整的逻辑从中断开,调试时打印调用堆栈就只能看到当前事件触发后的逻辑。

那么,为什么不用coroutine呢?我搜了下源码,有三个地方对coroutine进行了封装,其中两个是有关网络的第三方库,主要负责发邮件之类的工作 ,和游戏逻辑没什么关系。唯一用到的就是调度器添加任务的时候,为每一个新任务创建一个coroutine,然后调度的时候枚举所有任务一个个执行。

然而,这个coroutine似乎仅仅是由来保存一个独立执行栈的——调度器对它的操作仅仅是从队列中取出然后执行完删掉。全部代码中只有9个很少执行到的地方用到了Yield()。个人觉得这个coroutine的用法值得商榷——毕竟coroutine本身还是有一点点开销的,而且绝大多数异步需求还是用回调来完成的。

从lua代码架构上看,这套脚本自己实现了一个简单的类似C++的面向对象框架,模块的封装比较任性,耦合度也有点高,经常会出现在底层框架代码中去加一些代码来控制上层实现逻辑的代码。而它发布的两个DLC并不是在原有代码框架上开发的扩展包,而是直接把代码复制出来进行修改的,估计最初写的时候也没想要有个DLC……

可以看出,游戏本身应该是直接从一个简单的原型一步步迭代上来的,虽然在开发的过程中有过抽象和封装,但是积重难返,大体的结构已经固定,没有什么简单的优化空间了。脚本负责了太重的任务,并行方面做的工作也不多,试图直接优化现有代码对于我来说已经成了死局。

因此把lua引擎替换为LuaJIT已经成了为数不多的可以让程序运行起来不那么卡的选择了。虽然从道理上讲,这也只能算是治标不治本,但是好歹能有的玩啊。

0x02 横生枝节

但是事情远远没有想的那么简单。我最初以为直接把饥荒可能用到的lua51.dll换成luajit.dll就万事大吉了,然而翻遍了安装目录也没看到lua51.dll库的影子。那么次一点的结果就是LUA被封装在了别的DLL里,由dontstarve_steam.exe启动时加载。然而最终发现lua引擎是直接作为静态库或者源码编译进主程序的,这是最糟糕的结果。

这个麻烦在于,我们知道exe通常不需要导出函数(仔细看了下dontstarve_steam.exe确实没导出),那么如何定位lua api在exe中的位置就成了首要解决的问题。

EXE中lua api的布局和DLL中是完全不一样的:编译器可以剪掉那些没被引用到的函数与常量,也可以重排函数的顺序。想要通过公开的或者凑巧的办法定位到这些函数是不可能的。那么就剩下了三条路:直接改EXE,硬编码与特征码搜索。

直接改EXE或者硬编码相应函数的RVA看起来简单粗暴,但是万一饥荒EXE更新了就得新发布个版本,而且为了得到地址还得手工用特征码先搜索并较正一遍。想了想还是用特征码吧,而且还要尽量做成非侵入式的,这样就可以有效地兼容饥荒自己的更新。

0x03 探针计划

特征码搜索需要先找到每个函数对应的特征码。为此我的思路是,先编译一份原汁原味的lua 5.1.4的DLL,把LUA API设置为导出,这样就能得到每个API的地址,进而用函数开头的二进制指令作为特征码在EXE中进行搜索。接下来,只需要把搜索到的源地址和名字一一对应起来,然后加载luajit.dll就可以再通过kernel32.dll!GetProcAddress拿到目标地址,通过inline hook将源地址处的执行流导向目标地址。由于这些hooks不需要执行原函数,计算跳板所保留的原函数头部字节数也就没有必要了。

不同的编译器生成目标代码时的算法都是很不一样的,甚至就算是同一系列的编译器,随着版本更新也会有不同。那么应该用什么来编译原版lua DLL呢?当然是与饥荒主程序保持一致。OllyDBG告诉我们答案:MSVC9,即Microsoft Visual C++ 2008。


然而在开始编码之前,先要解决的问题是:怎么让我的代码得到执行?

很明显如果想要做非侵入式的Patch,要么额外写一个启动器,要么通过某种技巧让我们的代码在游戏启动时得到执行。官方的MOD机制允许自定义mod在游戏初始化的时候执行lua代码,然而这时候lua引擎已经建立,再替换就晚了,肯定不行。

于是我的目光移动到exe依赖的那些DLL上,看看有什么偷梁换柱的策略。所谓的偷梁换柱就是指,针对目标EXE所依赖的DLL,写一个傀儡DLL,名字和导出函数都和目标DLL一样,然后放在EXE目录中就可以在EXE启动时被加载。加载后再手动用kernel32.dllLoadLibraryA/W加载目标DLL,并把导出的函数都转接到目标DLL中对应的函数里去。早些年一些病毒为了禁用杀毒软件,就在杀毒软件安装目录下放一个假的ws2_32.dll,这样杀毒软件启动时就会崩溃或者被劫持。注意一些系统核心的DLL是不受这个影响的,如kernel32.dll, ntdll.dll等)

饥荒主程序目录下那些DLL大多都是C++写的,导出函数的名字都是经过name decoration的,处理起来会比较麻烦。加之导出函数的数量也很多,有点不划算。

找了半天,最佳的选择是DINPUT8.DLL,这个DLL是DirectInput所需要的。选择它的原因是dontstarve_steam.exe只从其中导入了一个函数DirectInput8Create,用来创建DirectInput的设备对象,由于这个对象是用COM封装的,所以DLL不再需要导出其成员函数,导入表项因此就简洁了很多。还有一个好处,由于它是处于系统目录中的DLL,替换它不需要替换原有的DLL,只需要在EXE目录中放置即可被加载,是完美的非侵入式PATCH的选择。

如前文所述,你接管了DINPUT8.DLL,那么也就得实现DirectInput8Create这个函数,否则游戏启动时会找不到入口。这个很容易:

typedef HRESULT(WINAPI *PFNDirectInput8Create)(HINSTANCE hinst, DWORD dwVersion, REFIID riidltf, LPVOID *ppvOut, LPUNKNOWN punkOuter);

static PFNDirectInput8Create pfnDirectInput8Create = NULL;
HRESULT WINAPI DirectInput8Create(HINSTANCE hinst, DWORD dwVersion, REFIID riidltf, LPVOID *ppvOut, LPUNKNOWN punkOuter) {
	return pfnDirectInput8Create(hinst, dwVersion, riidltf, ppvOut, punkOuter);
}

DllMain:
	TCHAR systemPath[MAX_PATH];
	::GetSystemDirectory(systemPath, MAX_PATH);

	HMODULE hInput = ::LoadLibrary(CString(systemPath) + _T("\\DINPUT8.DLL"));
	if (hInput != NULL) {
		pfnDirectInput8Create = (PFNDirectInput8Create)::GetProcAddress(hInput, "DirectInput8Create");
}

在上面的代码里,DllMain里需要加载真正的DINPUT8.DLL,然后获取到DirectInput8Create的地址,然后才能在中继代码中CALL。需要注意的是,我们最好不要包含任何有关DirectX SDK的头文件,这些头文件往往会指定API为导入,而我们需要将同名的中继函数导出。

如果遇到了类型没定义的错误也不用急,按照其长度写一个等价的就行了。后文我会介绍一个更简单的制作中继函数跳板的办法。

那么,接下来是怎么搜的问题。我们先来编译原版lua51.dll,编译设置选择Release版本。


如果在用kernel32.dll!LoadLibraryA/W加载了原版的lua51.dll之后,直接拿API函数开始的二进制码去做搜索,只能搜索到一小部分函数。这是由于DLL中的代码需要根据.reloc节的信息进行重定位,需要定位的地方二进制值会变,因此纯粹的memcmp行不通。

在这里我直接采用了一个较简单但是相对耗时的策略:在搜索时利用反汇编引擎XDE32一条条解析指令格式,发现需要重定位的指令,就读取地址处的值代替地址本身充当特征码。

while (target < entry.instr + INSTR_SIZE) {
	xde_disasm((BYTE*)c, &instr);
	int len = instr.len;
	if (len == 0)
		break;

	if (instr.opcode == 0x68 || instr.addrsize == 4) {
		// read memory data
		PVOID addr = instr.opcode == 0x68 ? *(PVOID*)(c + 1) : (PVOID)instr.addr_l[0];
		char buf[16];
		memset(buf, 0, sizeof(buf));
		if (addr != NULL && ::ReadProcessMemory(::GetCurrentProcess(), addr, buf, 4, NULL)) {
			entry.stringList.push_back(std::make_pair(addr, std::string(buf)));
		}

		BYTE temp[16];
		if (instr.opcode == 0x68) {
			memcpy(temp, c, instr.len);
			*(PVOID*)(temp + 1) = *(PVOID*)buf;
		} else {
			instr.addr_l[0] = *(long*)buf;
			xde_asm(temp, &instr);
		}
		memcpy(target, temp, len + target > entry.instr + INSTR_SIZE ? entry.instr + INSTR_SIZE - target : len);
	} else {
		memcpy(target, c, len + target > entry.instr + INSTR_SIZE ? entry.instr + INSTR_SIZE - target : len);
	}

	c += len;
	target += len;
	if (!edge)
		validLength += len + target > entry.instr + INSTR_SIZE ? entry.instr + INSTR_SIZE - target : len;
	entry.lengthHist[len]++;

	if (*c == 0xCC) {
		edge = true;
	}
}

具体到匹配算法,为了避免因微小长度差异而导致整体错位,我没有直接memcmp,而是用了最长公共子串的动态规划算法,时间和空间复杂度都为O(N * N)。对于不太长的特征序列,性能上的损失可以接受。

static int CommonLength(const BYTE* x, int xlen, const BYTE* y, int ylen) {
	int opt[INSTR_SIZE + 1][INSTR_SIZE + 1];
	memset(opt, 0, sizeof(opt));

	for (int i = 1; i <= xlen; i++) {
		for (int j = 1; j <= ylen; j++) {
			if (x[i - 1] == y[j - 1])
				opt[i][j] = opt[i - 1][j - 1] + 1;
			else
				opt[i][j] = opt[i - 1][j] > opt[i][j - 1] ? opt[i - 1][j] : opt[i][j - 1];
		}
	}

	return opt[xlen][ylen];
}

搜索的范围只需要设置为dontstarve_steam.exe的.text节所在范围即可,其实我在试了几个版本之后,发现.text + 0x170000之后才会有对应的lua api的函数。因此为了速度快些就直接以.text + 0x170000为地点,.text的末尾为终点了。(当然其实这么做有一定风险,不过一直懒得改。。万一哪天饥荒有大更新很可能会出错)

搜索到目标函数之后,标记下来,并与luajit连接即可。下面是inline hook的代码:

static void Hook(BYTE* from, BYTE* to) {
	// prepare inline hook
	unsigned char code[5] = { 0xe9, 0x00, 0x00, 0x00, 0x00 };
	*(DWORD*)(code + 1) = (DWORD)to - (DWORD)from - 5;
	DWORD oldProtect;
	::VirtualProtect(from, 5, PAGE_READWRITE, &oldProtect);
	::memcpy(from, code, 5);
	::VirtualProtect(from, 5, oldProtect, &oldProtect);
}

(个人比较喜欢0xE8/0xE9这种HOOK,不需要改寄存器,长度也很短。当然也可以用PUSH+RET, FF 15/25 即CALL/JMP DWORD PTR [ADDR]的,各有所需)


0x04 引火烧身

写完代码,编译好DLL,并将其改名为DINPUT8.DLL。同时再编译一份luajit的DLL,重命名为luajit.dll(原来的名字是lua51.dll),再加上直接用VS2008编译lua 5.1.4源码得到的lua51.dll,总共是三个文件。

将这三个文件复制到饥荒的bin目录,启动游戏。(见证奇迹的时候到了!!)

不出意外,BOOM!程序崩溃了!!

客位看官不好意思,前面洋洋洒洒写了一大堆,看起来有极大可能并没有什么用。

不过仔细想想,这也不总是悲剧,至少证明了我们还是具有了利用DINPUT8傀儡来捅篓子的能力。

但是为什么会崩溃呢?总得有个交待啊!!通过仔细的排查(其实就是打了个LOG),发现其实有部分函数就没Hook上。

但是为什么没Hook上呢?前面搜索花了那么大的精力,为什么结果还是不对呢?


0x05 调试器下没有秘密

没办法,只能用OllyDBG调试下试试了。由于饥荒的exe你直接点击是不会启动游戏的,它只会启动STEAM,然后用STEAM再启动游戏,所以我只能在DINPUT8.DLL的DllMain里,强行加一个getchar(),然后在STEAM中启动游戏,使用OllyDBG附加调试。

按ALT+E打开模块列表,点击dontstarve_steam.exe,启动Search for => All inter-modular calls,就可以看到大多数函数已经被成功HOOK了。


一个个检查HOOK的函数,发现只要是搜索到了目标,基本上就没有什么大问题。问题在于很多函数就没找到,而且吊诡的是,自己手工去用OD强大的Search Command功能去搜,放宽搜索的匹配条件,也是找不到的。

所以……

难道是……

那些函数压根就不存在……吗?

好吧。这个结论竟然对了一半。

确实有些api在exe中是没有的,因为exe没用到它们,编译器在连接的时候把它们抹掉了。我于是在lua51.dll中删除了它们的导出项。

另一部分呢,确实是没搜索到。通过查找字符串引用逐步定位相关指令的办法(具体就不详述了),我发现了一件怪事:

这些API和lua 5.1.4 DLL中的api在二进制层面上差别非常大!




0x06 飞轮与链条

如果饥荒的作者修改过这些函数的实现,那么情况就非常僵了——我得在luajit中做等价的修改。不过仔细比对后发现,事情没有那么糟糕。

不一致的地方主要来自于两种原因:

1. 部分API调用内部函数被inline

2. 饥荒作者删除了部分API中关于luaC_checkGC调用



对于1,参考下图:

luaO_pushfstring是lua_pushfstring所调用的函数,在饥荒主程序里这个调用是被inline的,导致其与我编译的lua51.dll中lua_pushfstring的二进制码不一致。


对于2,参考下图:

饥荒作者删除了代码中的一些luaC_checkGC的调用,从而使得一些函数不再会触发垃圾收集。这种想法是为了避免某些BUG吗?(比如C层面持有的对象因没有维持引用而失效)还是试图降低卡顿?我不得而知。但是奇怪的是,联机版的饥荒(针对联机版的内容主要参见后文)改动的地点和单机版并不一致。(上图中我添加的luaC_checkGC_是一个空宏,等价于把GC调用删除掉)

经过如上的修改之后重新编译lua51.dll,我们重新制作的链条终于能和饥荒原程序的飞轮啮合在一起了。

0x07 打包炸弹

启动时崩溃,按照OllyDBG的LOG跟踪到是luajit有部分常数的值太小(如函数串最多常量个数,参数列表最多参数个数),改成大一些的值即可,不在此详述了。(饥荒真是内存杀手)

折腾了半天,我们的破补丁总算能勉强跑起来了。顺利度过loading界面,主界面成功启动!久违的背景音乐响起~


先随便试试基本的功能吧,先点下MODS看看会不会挂……

由墨菲定律可知:如果一个地方你感觉会出错,那么它就会出错。。于是有插件崩溃了。

虽然在游戏中按“`”键也可以打开内置的控制台,但是这里LOG显示的行数有限,且不能滚屏。于是直接打开OllyDBG的LOG看看倒底发生了什么:


(这些LOG是用OutputDebugString输出的,需要OllyDBG 2.0以上版本才支持在调试器内显示它们。)

从上面的文字中可以看出,在加载翼语MOD的时候,mods.lua第42行报错,提示'arg'这个变量没有被定义。

那么就去看看喽:

local runmodfn = function(fn,mod,modtype)
	return (function(...)
		if fn then
			local status, r = xpcall( function() return fn(unpack(arg)) end, debug.traceback) --<< 42
			if not status then
				print("error calling "..modtype.." in mod "..ModInfoname(mod.modname)..": \n"..r)
				ModManager:RemoveBadMod(mod.modname,r)
				ModManager:DisplayBadMods()
			else
				return r
			end
		end
	end)
end

问题很明显,饥荒作者使用了旧的表示可变参数表的语法。在5.1以前,你可以使用arg来表示{...}这个表,arg[i]即可用于提取可变参数中的第i项。但是后来这个语法就被默认弃用掉了,仅保留一个宏可以开启这个兼容。LuaJIT则完全不兼容这个写法,通过仔细查看代码,它甚至删除了实现arg兼容所占用的mask bit而将这个bit用在了其他地方。

当时分析到这的时候,我觉得直接要求用户修改这个文件也不是什么难事,毕竟添加如下一行就可以解决问题:

local arg = {...}

于是我写好了详细的说明,将程序及源码发布在了Github上,并且在贴吧开了贴收集用户反馈。


0x08 冒烟的补丁

第一波的反馈喜忧参半,可喜的是有部分用户能成功安装补丁,并且说确实有明显的效果,特别是针对Shipwrecked DLC加上大量Mods,忧的是仍然有大量不能正确启动的bug,报错也是千奇百怪,甚至有大量连游戏本身启动什么也没看到就闪退的问题。

对我冲击比较大的是,修改源代码文件这个要求对于普通用户来说实在是太难了。很多用户找不到文件,不知道用什么工具打开,看不懂英文因而无从下手,打了中文标点也浑然不知。而且很多第三方MOD里也用了这个旧的arg语法,要想举一反三,从我给出的修改mods.lua的方案直接得出修正第三方mod中相应语法问题的玩家非常少——毕竟贴吧以娱乐为主,不像知乎会有很多程序员。这很难办。

怎么办呢,只能自己把这个兼容性补丁做了。

在研究了一下午luajit parser之后,我放弃了按照lua源码中相同的设计添加兼容的思路。一方面如上所述,没有可用的mask bit,想要跑起来需要增加对应FLAG变量的位宽;另一方面lua源码中这个功能是在VM中解释时实现的,而luajit没有解释器,它直接跑的是原生指令。而想看明白JIT COMPILER的实现机制并且从中精准地插入这个功能并非易事。

不过又看了一下午parser之后,发现其实还有一个简单的办法。那就是在检测到当前编译的函数是可变参数的时候,动态插入一句local arg = {...}。这样的话有两种做法:

1、检测源文件文本,作一个简单的处理,定位到函数定义的地方,然后插入一行。

2、直接在AST生成的时候插入语法结点。

方法1的难度是比较低的,但是也需要稍微作一点解析工作,比如把注释,字符串跳过,检测函数定义的语句之类的。比较容易想不周全而出错。

要想想周全,就得搞个简单的parser。直接按方法2借用luajit自己的parser是最好的选择。为了使探索更有目的,我编写了这样的一个函数:

function test(...) local arg = { ... } end

然后跑起luajit来看看local arg = { ... }这句话究竟都会走哪些路径来生成AST。调试了一个小时之后,终于搞出来了。

打开lj_parse.c,定位到:

static void parse_chunk(LexState *ls)
{
  int islast = 0;
  synlevel_begin(ls);
  add_argstmt(ls); // HERE!!!!!
  while (!islast && !parse_isend(ls->tok)) {
    islast = parse_stmt(ls);
    lex_opt(ls, ';');
    lua_assert(ls->fs->framesize >= ls->fs->freereg &&
	       ls->fs->freereg >= ls->fs->nactvar);
    ls->fs->freereg = ls->fs->nactvar;  /* Free registers after each stmt. */
  }
  synlevel_end(ls);
}

在标记处加一行调用add_argstmt的语句,然后编写这个函数:

static void add_argstmt(LexState* ls)
{
  ExpDesc e;

  if (ls->fs->flags & PROTO_VARARG) {
    var_new_lit(ls, 0, "arg");
// nexps = expr_list(ls, &e);
    {
      synlevel_begin(ls);
  // expr_unop(ls, &e);
      {
    // expr_simple(ls, v);
        {
      // expr_table(ls, v);
          {
            ExpDesc key, val;
            FuncState *fs = ls->fs;
            BCLine line = ls->linenumber;
            BCInsLine *ilp;
            BCIns *ip;
            ExpDesc en;
            BCReg base;

            GCtab *t = NULL;
            int vcall = 0, needarr = 0, fixt = 0;
        uint32_t narr = 1;  /* First array index. */
        uint32_t nhash = 0;  /* Number of hash entries. */
            BCReg freg = fs->freereg;
            BCPos pc = bcemit_AD(fs, BC_TNEW, freg, 0);
            expr_init(&e, VNONRELOC, freg);
            bcreg_reserve(fs, 1);
            freg++;

            vcall = 0;
            expr_init(&key, VKNUM, 0);
            setintV(&key.u.nval, (int)narr);
            narr++;
            needarr = vcall = 1;

            // expr(ls, &val);
            {
              checkcond(ls, fs->flags & PROTO_VARARG, LJ_ERR_XDOTS);
              bcreg_reserve(fs, 1);
              base = fs->freereg-1;
              expr_init(&val, VCALL, bcemit_ABC(fs, BC_VARG, base, 2, fs->numparams));
              val.u.s.aux = base;
            }

            if (expr_isk(&key)) expr_index(fs, &e, &key);
            bcemit_store(fs, &e, &val);
            fs->freereg = freg;

            ilp = &fs->bcbase[fs->pc-1];
            expr_init(&en, VKNUM, 0);
            en.u.nval.u32.lo = narr-1;
        en.u.nval.u32.hi = 0x43300000;  /* Biased integer to avoid denormals. */
            if (narr > 256) { fs->pc--; ilp--; }
            ilp->ins = BCINS_AD(BC_TSETM, freg, const_num(fs, &en));
            setbc_b(&ilp[-1].ins, 0);

        e.k = VNONRELOC;  /* May have been changed by expr_index. */


            ip = &fs->bcbase[pc].ins;
            if (!needarr) narr = 0;
            else if (narr < 3) narr = 3;
            else if (narr > 0x7ff) narr = 0x7ff;
            setbc_d(ip, narr|(hsize2hbits(nhash)<<11));
          }
        }
      }
      synlevel_end(ls);
    }
    assign_adjust(ls, 1, 1, &e);
    var_add(ls, 1);
  }  
}

上面的代码是按粗略的执行路径搞出来的,可能有些判断不会触发,还可以更简短些。不过影响不大就不深究了。

重新编译luajit,这次启动成功,MOD也能启用了。


这里说个好玩的,其实饥荒代码中这样不经声明直接用arg的地方还真不多。大部分都是直接用了...来传递,少部分呢,其实是这样的(为了提取指定参数,很明显他不知道有select这个函数可以用):(modutil.lua)

env.AddLevel = function(...)
	arg = {...}
	initprint("AddLevel", arg[1], arg[2].id)
	require("map/levels")
	AddLevel(...)
end
env.AddTask = function(...)
	arg = {...}
	initprint("AddTask", arg[1])
	require("map/tasks")
	AddTask(...)
end
env.AddRoom = function(...)
	arg = {...}
	initprint("AddRoom", arg[1])
	require("map/rooms")
	AddRoom(...)
end

(然后这句arg = { ... } 实际声明/修改了个全局变量arg。)

那为什么mods.lua中不用...来传递呢?原因很可能是,...不能穿过闭包的边界(即调用xpcall时的作为参数的匿名函数)成为upvalue,只有兼容版本的arg可以。开发者没办法只能用了arg,但是忘记声明local arg = { ... }了。

接下来由于兼容模式是开着的,这个小问题不会引起执行异常,所以这样的代码也就保留下来了。(我乱猜的)



0x09 请写规范字

接下来来看玩家反馈的崩溃问题,发现很多都似乎是字符串上的乱子,有的案例会出现饥荒自带的崩溃界面,有的则直接闪退。

有崩溃界面长这个样子:

虽然上面显示是在原版的饥荒脚本里出的错,但是实际上,引发出错的是一个旧版的汉化包。它汉化后的字符串是这样的:

#: STRINGS.MODS.VERSIONING.OUT_OF_DATE
msgid "Mod \"%s\" is out of date. The server needs to get the latest version from the Steam Workshop so other users can join."
msgstr "Mod\“% \”有更新,服务器需要从创意工坊获得最新版本,以便其他用户加入。"

虽然lua中字符串的转义符方案和C的基本一致,但是MODs以及汉化的作者往往没有C语言相关的经验,所以容易写错。上面代码中不应该转义的中文引号被加了反斜杠,同时格式占位符的%s中的s也忘记写了。

还有一些MOD中大量用单个反斜杠来表示反斜杠本身而并没有适当的转义。但是原版的lua 5.1.4 编译器会忽略这个错误。从lua官方的文档上来看,并没有规定如果用户提供了不合语法的反斜框组合后具体的行为是什么。

于是LuaJIT就僵掉了:本来是出于严谨的考虑,LuaJIT不允许不合语法的反斜杠,否则就报错。而这个报错正好就挂出了错误界面。

更要命的是,mod中并不是所有的代码都是在正确的Sandbox保护下执行的(后文中会提到一例)。作为数据文件的lua格式的info文件本来就是简单的一个lua表,没什么函数。但是如果其中含有这样的字符串,执行时就会直接挂掉。这时程序内置的错误界面不会弹出而是会闪退。

这算是比较严重的设计问题。甚至,它不需要有问题的mod被激活(因为出问题的代码在info信息里,加载mod cache时就会挂)。由于mod本身的更新是在游戏内部中通过同步steam创创意工坊来完成的,一但mod出这种错误,根本没有机会通过在创意工坊中取消该MOD的订阅来解决问题,只能手工去删除mod的文件。这对普通用户来说是个严峻的挑战。

然而如果我给测试用户们解释这么多原因内容可能并不会有什么用。因为玩家会这样想:本来跑得好好的,用了你的补丁就闪退!一定是你搞坏了!

确实,别人不了解原理,你讲这些话其实与甩锅是等价的。

没办法,再重的锅也得背。改代码来兼容这个问题吧。。。打开lj_cparse.c:244

if (lj_char_isdigit(c)) {
  c -= '0';
  if (lj_char_isdigit(cp_get(cp))) {
    c = c*8 + (cp->c - '0');
  } else {
	  c = '\\';
  }
  cp_save(cp, (c & 0xff));
  continue;
}

只能强行兼容不合法的单个斜杠了。

同时,如果%后面没有合法的格式标记,LuaJIT也会报错。没办法,改成默认%s好了。

打开lj_strfmt.c:70

	/* Parse conversion. */
c = (uint32_t)*p - 'A';
if (LJ_LIKELY(c <= (uint32_t)('x' - 'A'))) {
	uint32_t sx = strfmt_map[c];
	if (sx) {
		fs->p = p+1;
		return (sf | sx | ((c & 0x20) ? 0 : STRFMT_F_UPPER));
	}
}

c = 's'-'A'; // 这里的写法突出了我懒的本质。
{
	uint32_t sx = strfmt_map[c];
	if (sx) {
		fs->p = p+1;
		return (sf | sx | ((c & 0x20) ? 0 : STRFMT_F_UPPER));
	}
}

都已经妥协成这样了!还有问题吗!!


0x0A 冇问题吗

然而事情的诡异程度远远超出了我的想象。。在解决了这个问题很久很久之后,我发现有又mod因为字符串崩了。

崩的原因是它使用了LuaJIT的扩展转义符\u。这个符号在LuaJIT中是由于支持直接UNICODE内码的。然而这个MOD作者的本意是\umbrella之类……

很好,是在下输了。TAT

这次我直接把LuaJIT和原版不一样的转义符扩展整个给砍了。定位到lj_lex.c:216

#if 0
      case 'x':  /* Hexadecimal escape '\xXX'. */
	c = (lex_next(ls) & 15u) << 4;
	if (!lj_char_isdigit(ls->c)) {
	  if (!lj_char_isxdigit(ls->c)) goto err_xesc;
	  c += 9 << 4;
	}
...
	break;
      case 'z':  /* Skip whitespace. */
	lex_next(ls);
	while (lj_char_isspace(ls->c))
	  if (lex_iseol(ls)) lex_newline(ls); else lex_next(ls);
	continue;
#endif
心好累。

0x0B 未雨绸缪的失火

再次发布了新版本之后,大家表示比较满意。只有一些上古版本(至少一年前的)的用户表示游戏启动不了。出于对正版的支持,我就不处理这部分要求了。(正版的游戏是随着steam更新的,除非用户故意取消掉是会一直保持最新的。)

于是我考虑制作饥荒联机版的MOD。联机版饥荒基于Reign of Giants DLC制作,但是是独立发布的,内容上针对多人游戏体验有不少改动。如前文所述,联机版饥荒主程序中嵌入的lua引擎的代码和单机版有一点点的不同(主要是GC调用的地方)。我使用预编译宏DST来区分两个版本,并将生成的lua DLL命名为lua51DST.dll。

万事妥当。把生成的文件复制到联机版安装目录的bin子目录里。运行游戏。

然后就(日常)崩了。

直接闪退,连个界面都没给我剩下。没法子,再次请出OllyDBG加载游戏,看看有什么新的幺蛾子:

从右下角的圈出的地方可以看到程序是停在了开发人员设置的一个Release中仍然启用的Assert宏上。

右上角的调用堆栈指出这是在luajit回调原程序相关函数时出现的错误。

再看左下角的LOG窗口,提示的信息显示RunInSandboxSafe的第二个参数没有提供正确的值。这在lua中有两种可能,一是提供的参数的值确实是nil空值,另一种可能是调用时没有提供足够的参数。

然后我就搜了下代码中调用RunInSandboxSafe的地方:

令我大吃一惊的是,只有一处是按照正确用法提供两个参数的,其余全少了。缺失的参数从名字上来看是错误处理例程,它被传入xpcall,一旦有错误发生,就会被调用。

但是看了下RunInSandboxSafe的源码,似乎有点问题:

function RunInSandboxSafe(untrusted_code, error_handler)
	if untrusted_code:byte(1) == 27 then return nil, "binary bytecode prohibited" end
	local untrusted_function, message = loadstring(untrusted_code)
	if not untrusted_function then return nil, message end
	setfenv(untrusted_function, {} )
	return xpcall(untrusted_function, error_handler )
end

这里并没有检查error_handler的合法性,那么那句"got nil, function expected"是怎么来的呢?

答案是最后一句xpcall是tail call,即尾调用。尾调用本质上相当于JMP指令,所以这里跳转到xpcall里执行的时候,当前栈仍然显示的是RunInSandboxSafe的。

因此,是xpcall对传入的error_handler为空表示了强烈的不满。有趣的是,在原版lua5.1.4里,没有对这个参数作检查,只有当xpcall所执行的代码中已经出现了错误,才会去尝试调用error_handler,从而出错。而LuaJIT未雨绸缪,直接防患于未然,于是就挂掉了。

好玩的地方在于,这里饥荒的作者使用了ASSERT来断言执行RunInSandboxSafe时lua_call调用的结果。一般来说,只有程序逻辑本身的错误(即程序员自己的锅)才适合用ASSERT来断言,而RunInSandboxSafe执行的可是外部MOD/存档中的字串,这是程序逻辑中的异常处理,应该进入异常处理流程(如显示报错对话框)才对。所以这个地方也是少数MOD导致程序无法启动的原因。


PATCH的早些版本里,我要求用户去修改这个文件,添加一行:

error_handler = error_handler or function (str) print("Klei, you missed this line: " .. str) end

后来由于众所周知的原因,我直接把这个函数的修改版集成到了luajit.dll里面。



0x0C 药引子

不过联机版有些神奇的设定。默认情况下,玩家可以选择自己作主机,让好友连入。在主机玩家的电脑上,所有的游戏过程模拟都是在一个单独的饥荒进程中完成的。但是如果你要启用洞穴的话,就相当于添加了一个平行世界。

在单机版里,玩家只能同时存在于其中一个世界。所以如果玩家跳洞穴,就把地上部分世界暂停并存档,然后把地下世界恢复并同步时间就OK了。

但是联机版不能这么做,因为同一时间要允许有的玩家在地上,有的玩家在地下。由于原来的程序框架是为单机版设计的,这里会变得无比僵硬。

所以游戏作者想了半天提出一个非常完美的思路。反正我们也要建立一些独立服务器来方便玩家联入(因为大部分玩家都没有公网IP,自己建的主往往只能在局域网内可见,所以Klei官方会有一批独立服务器让玩家联入。同时你也可以安装dedicated server这个工具来自己用VPS搭),不如就把洞穴开启时的模式改成独立服务器吧。

具体来说,如果你在创建世界时选择了创建洞穴,则每次主机启动存档的时候,都会额外创建两个进程来分别作为地上世界服务端和地下世界服务端。然后主机和所有局域网内的小伙伴都是连接到这两个服务端上的。最终对于主机而言,同时要跑三个游戏模拟器。因此在添加洞穴的时候,游戏都会先问玩家是否决定让自己的电脑接受三重计算的冲击。

当然了,通过dedicated server,你可以把那两个服务端托管到另一台电脑上(通常是买的VPS),这样本地的压力会小很多。不过由于网络传输的渣渣优化,广域网游戏比局域网要卡得多。

说了这么一大堆,和我们现在在做的有什么关系呢?当然有关系:

启用的那两个服务端虽然和客户端没多大区别,但是作者却为它另创建了一个EXE,命名为dontstarve_steam_nullrenderer.exe。

从名字上来看,为了减轻电脑压力,这是个缩水版的饥荒主程序,没有渲染功能。

另外一点它没写脸上,然而却是结结实实的重击。

它也不需要读取用户输入。

所以它不依赖DINPUT8.DLL。

僵。



所以应该怎么办呢?我们之前那么完美的注入点DINPUT8.DLL没办法用了吗?

是的。如果用户启用了洞穴,或者在专用服务器上用dedicated server,我们的PATCH就加载不了。

真是悲剧。看了半天,联机版似乎也没有什么更好的注入点选择,唯一剩下的就是WS2_32.DLL和WINMM.DLL了。

虽然饥荒主程序从WS2_32.DLL中仅导入了两个函数,但是经过我的观察,仅仅在傀儡中实现两个是远远不够的。原因是Steamapi.dll本身也依赖WS2_32.dll,而且导入了更多函数,你必须都得一一实现掉。

还好winmm.dll依赖关系简单些,唯一 的问题就是函数多了点。不过想了想,其实也没那么麻烦:

#define ENTRY(name) \
static void* pfn##name = NULL; \
__declspec(naked)void name() { \
	_asm jmp pfn##name \
}

#define INIT(name) \
pfn##name = ::GetProcAddress(hOrg, #name);

ENTRY(mciExecute)
ENTRY(CloseDriver)
ENTRY(DefDriverProc)
ENTRY(DriverCallback)
... // 中间略去
ENTRY(waveOutUnprepareHeader)
ENTRY(waveOutWrite)
ENTRY(wid32Message)
ENTRY(wod32Message)


void Init() {
  TCHAR sysPath[MAX_PATH * 2];
  ::GetSystemDirectory(sysPath, MAX_PATH);
  _tcscat(sysPath, _T("\\WINMM.DLL"));

  HMODULE hOrg = ::LoadLibrary(sysPath);
  if (hOrg != NULL) {
    INIT(mciExecute)
    INIT(CloseDriver)
    INIT(DefDriverProc)
    INIT(DriverCallback)
    ...
    INIT(waveOutUnprepareHeader)
    INIT(waveOutWrite)
    INIT(wid32Message)
    INIT(wod32Message)
  }

  ::LoadLibrary(_T("DINPUT8.DLL"));
}

BOOL WINAPI DllMain(HINSTANCE hInstance, DWORD ul_reason_for_call, LPVOID reserved) {
  switch (ul_reason_for_call) {
    case DLL_PROCESS_ATTACH:
    Init();
  // system("pause");
    break;
  }

  return TRUE;
}

其实我们并不需要完完全全按照原先的标准的写法挨个定义跳板函数,既然我们不做处理,直接用一条JMP指令就可以了。参数什么的其实已经由调用者填好了。注意要使用__declspec(naked)这个修饰,避免生成C的框架代码。

新的WINMM.dll就相当对针对服务端的药引子,它唯二的作用就是转发去往winmm.dll的调用,并在初始化的时候加载DINPUT8.DLL。



0x0D 鬼使神差

按理说,联机版饥荒和单机的差别应该不大,该填的坑也填的差不多了(人生三大错觉之一)。在自己机子上玩了独自试验了几次,一切都很正常。

不过一旦有别人连入,问题就又来了。据贴吧吧友反馈,有时客户端人物不能走动;退出的时候会留下黑影,再进就进不来了,除非重启服务端。

于是我翻开饥荒lua脚本的源码,查找相关的服务器逻辑。结果发现其实大部分通信都是通过一个简单的RPC机制来实现的(scripts/networkclientrpc.lua)。每个请求都有一个独立的rpc代号,客户端将代号和参数打包之后发到服务端,由服务端解包后执行。(这里多说一句,由于lua 5.1.4的限制,这个打包过程很低效——根据数据来生成一个表定义的lua代码,解包的时候再执行一次得到数据。在lua 5.3中有string.dump这个神器可以完美地以二进制方式打包,为此我结合LZ系列压缩算法的思路,写了个简单的模块,代码在这里(Core.Encode):github.com/paintdream/…

这看起来也没有什么问题,那么为什么客户端人物不能走动?没办法只能开两个游戏调试下了。

我在工作电脑上跑起游戏作服务端,Surface Pro 3上跑客户端。看看出了什么鬼。

吧友所指的问题果然得到复现,另外我发现即使能成功进入主机,各种行为也很不正常,甚至不能走路(会被弹回去)。那么这就很奇怪了。

于是我改了些RPC Handler的代码,添加了一些LOG语句,看看有什么异常。结果发现:

有些调用的参数明显是在调另外一个函数!

举个例子:

    RPC_HANDLERS.DoWidgetButtonAction = function(player, action, target, mod_name)
        if not (checknumber(action) and
                optentity(target) and
                optstring(mod_name)) then
            print("Current RPC CODE  = " .. CURRENT_RPC_CODE)
            printinvalid("DoWidgetButtonAction", player)
            return
        end
        local playercontroller = player.components.playercontroller
        if playercontroller ~= nil and playercontroller:IsEnabled() and not player.sg:HasStateTag("busy") then
            if mod_name ~= nil then
                action = ACTION_MOD_IDS[mod_name] ~= nil and ACTION_MOD_IDS[mod_name][action] ~= nil and ACTIONS[ACTION_MOD_IDS[mod_name][action]] or nil
            else
                action = ACTION_IDS[action] ~= nil and ACTIONS[ACTION_IDS[action]] or nil
            end
            if action ~= nil then
                local container = target ~= nil and target.components.container or nil
                if container == nil or container.opener == player then
                    BufferedAction(player, target, action):Do()
                end
            end
        end
    end,

这里我加了点print,结果发现神奇的东西来了。player是正确的player table,但是mod_name却是nil,而且action和target居然是两个数字(大约200,300的样子)!

往上找找,还发现一个函数:

    DragWalking = function(player, x, z)
        if not (checknumber(x) and
                checknumber(z)) then
            printinvalid("DragWalking", player)
            return
        end
        local playercontroller = player.components.playercontroller
        if playercontroller ~= nil then
            playercontroller:OnRemoteDragWalking(x, z)
        end
    end,

很明显,这个调用真的串线了。从它的参数上来看更可能调用的是DragWalking这个函数,那两个数字其实是x和z,即人物坐标。当然也有可能是参数与DragWalking类似的其他函数。

所以问题应该就出在那个调用代号上。往下看看调用代号是怎么生成的,果然发现了问题:

RPC = {}

--Generate RPC codes from table of handlers
local i = 1
for k, v in pairs(RPC_HANDLERS) do
    RPC[k] = i
    i = i + 1
end
i = nil

--Switch handler keys from code name to code value
for k, v in pairs(RPC) do
    RPC_HANDLERS[v] = RPC_HANDLERS[k]
    RPC_HANDLERS[k] = nil
end

还好我在很早之前看到云风写的有关lua字符串Hash算法为了防DDOS的变动,一眼直接就看出了问题(终于不用走弯路了):这样生成RPC代号的方法在新版lua中是错误的。

原因在于新版lua的字符串hash算法中,包含了一个随机种子,它避免了因算法固定而导致被黑客构造大量具有相同HASH值的不同字符串招至的DDOS攻击。因此每次启动时,针对相同字符串的HASH结果都不总是相同。

然而pairs内部依赖于next来做表遍历,next则是根据key的hash值来按顺序遍历的。这里的key明显就是RPC_HANDLERS的key,也就是RPC处理例程的名字。因此,这种依赖于遍历顺序而生成调用代号的方法会导致服务器和客户端用的代号完全错位,结果导致了串线故障。

那么怎么改呢。要么改HASH算法,要么改lua代码。

为了世界和平,我们要保护服务器不被DDOS!!

于是我机智(chun)地选择了让用户改lua代码!(这里说一句,本文是按BUG顺序整理的,所以在时间顺序上会不一致,在当时发布这个版本的时候,前几处代码还需要用户手工来改,所以当时觉得再多改些也不是问题)

定位到722行,改成这样:

local temp = {}
for k, v in pairs(RPC_HANDLERS) do
    table.insert(temp, k)
end

table.sort(temp)

for k, v in ipairs(temp) do
    RPC[v] = k
end
--[[
local i = 1
for k, v in pairs(RPC_HANDLERS) do
    RPC[k] = i
    i = i + 1
end
i = nil
]]


0x0E 拆包炸弹

改了之后,人物是可以动了,但是客户端退出时还是有黑影,再进就进不来了。

这很诡异,第一感觉也许是某个任务没有执行完导致的。但是又没有报错信息,去哪去找是哪个任务没完成呢?

一路从玩家消失时的消息跟踪过来,发现问题出在scripts/components/playerspawner.lua:79

local function PlayerRemove(player, deletesession, migrationdata, readytoremove)
    if readytoremove then
        player:OnDespawn()
        if deletesession then
            DeleteUserSession(player)
        else
            player.migration = migrationdata ~= nil and {
                worldid = TheShard:GetShardId(),
                portalid = migrationdata.portalid,
                sessionid = TheWorld.meta.session_identifier,
            } or nil
            SerializeUserSession(player)
        end
        player:Remove()
        if migrationdata ~= nil then
            TheShard:StartMigration(migrationdata.player.userid, migrationdata.worldid)
        end
    else
        player:DoTaskInTime(0, PlayerRemove, deletesession, migrationdata, true)
    end
end

这里DoTaskInTime是一个延迟调用的API,它回调了PlayerRemove自己,于是形成了死循环。(真的很想吐槽这里的代码)

可这怎么可能是死循环呢?明明延迟调用时就指定了readytoremove肯定是true了,下次就不会进else分支了,怎么可能呢?

然而我print了一下,确实是死循环,但是一直在打印的readytoremove值既不是所期待的true,也不是false。

而是nil。

所以,发生的事情经过就是PlayerRemove一直在死循环,后续的处理得不到执行,服务端认为要退出的玩家还在线,所以这个玩家再想登录进来就会被拒。

好了,为什么是nil呢?通过查看DoTaskInTime的内部实现可知,这是它有一个打包变长参数的过程(scheduler.lua:286)

function Scheduler:ExecutePeriodic(period, fn, limit, initialdelay, id, ...)
    local list, nexttick = self:GetListForTimeFromNow(initialdelay or period)
    local periodic = Periodic(fn, period, limit, id, nexttick, ...)
    list[periodic] = true
    periodic.list = list
    return periodic
end

而Periodic又做了什么呢?看37行:

Periodic = Class(function(self, fn, period, limit, id, nexttick, ...)
    self.fn = fn
    self.id = id
    self.period = period
    self.limit = limit
    self.nexttick = nexttick
    self.list = nil
    self.onfinish = nil
    self.arg = toarrayornil(...)
end)

其中toarrayornil是C方面实现的函数,相当于{...},但是当...为空时返回nil。

那么,是toarrayornil出了问题吗?在探索这个函数之前,我忽然想到,lua中table的数组部分有个非常奇怪的性质,其长度是从1到第一个nil为止(不包括nil),那么如果打包中的参数含有nil值,会不会nil值后面东西就被忽略了呢?

恭喜我又猜对了!

编写一个这样的脚本,存为test.lua:

function foo(...)
	return {...}
end

print(unpack(foo(1, 2, 3, nil, 5)))

在lua 5.1.4官方版的执行结果如下:

1  2  3  nil  5

在luajit 2.1.0版的执行结果如下:

1  2  3

这里官方的结果比较有意思,居然能把nil后的值读出来,但是luajit就不行了。而lua文档里也没明确这种情况下lua的行为,所以只能说是实现不同吧。

在这个例子里,PlayerRemove被调用时只有player这个参数不是nil,其余全是nil,所以在调用DoTaskInTime时,虽然readytoremove被指定了true,但是它前面两个参数的值是nil,所以就触发了LuaJIT的这个问题。

打开lib_base.c:220

LJLIB_CF(unpack)
{
  GCtab *t = lj_lib_checktab(L, 1);
  int32_t n, i = lj_lib_optint(L, 2, 1);
  int32_t e = (L->base+3-1 < L->top && !tvisnil(L->base+3-1)) ?
	      lj_lib_checkint(L, 3) : (int32_t)lj_tab_arraylen(t); // <---
  if (i > e) return 0;
  n = e - i + 1;
  if (n <= 0 || !lua_checkstack(L, n))
    lj_err_caller(L, LJ_ERR_UNPACK);
  do {
    cTValue *tv = lj_tab_getint(t, i);
    if (tv) {
      copyTV(L, L->top++, tv);
    } else {
      setnilV(L->top++);
    }
  } while (i++ < e);
  return n;
}

注意在<----处我用一个新的lj_tab_arraylen代替了原有的lj_tab_len调用,它的实现如下:

MSize LJ_FASTCALL lj_tab_arraylen(GCtab *t)
{
  MSize j = (MSize)t->asize;
  while (j > 1 && tvisnil(arrayslot(t, j - 1))) {
    j--;
  }
  if (j) --j;
  return j;
}

重新编译luajit并应用,问题解决。

0x0F 人间蒸发的海盗鹦鹉

新的版本持续用了很久,新的BUG反馈与很少了,而且基本上都是已经使用了旧的patch或者自己不会按要求改代码导致的。

安逸的日子一直持续到有用户开始报告说:陷阱类物品使用鼠标点击后捕捉到的动物消失,并且物品的耐久没有减少。

这看起来很难理解,这个bug是如此的精致,精致到不像是脚本引擎更换时引起的bug,反而更像是lua代码本身的bug。若是PATCH引起的bug,为什么游戏中的其他逻辑能精确地运行,唯独陷阱不行呢?这是怎么做到的呢?

(前方高能提醒:这个是制作此Patch时遇到的最诡异,逻辑链最长,但同时也最好玩的一个)


为此我打开游戏(启用PATCH),建了个新档,用控制台刷出一个捕鸟器和一只海盗鹦鹉。拾取后果然鹦鹉没了,捕鸟器耐久也没掉。

那么,问题出在哪里呢?通过仔细比对启用PATCH前后的画面,发现一件怪事:

(启用前)

(启用后)

所以问题应该是出在原本应该是Check的操作变成了Pick up,导致玩家捡起了捕鸟器而不是收获捕到的鸟。但是如果使用空格捡的话,则会正确地执行Check操作。

接下来目标就很明确了:这个Pick up是哪里来的?

通过检索lua源码中"Pick up",可以发现在strings.lua中有其定义:

STRINGS = {    --ACTION MOUSEOVER TEXT
    ACTIONS =
    {
        REPAIR = "Repair",
        REPAIRBOAT = "Repair",
        PICKUP = "Pick up",
        CHOP = "Chop",
        FERTILIZE = "Fertilize",
        SMOTHER = "Extinguish",
...
}
}

再看看哪些地方引用了STRINGS.ACTIONS.PICKUP这个值,并没有找到,再找ACTIONS.PICKUP,发现一处有价值的线索:

也就是说,所有操作都是通过构造一个BufferedAction来封装的,从名字上来看既然是Buffered,应该会有一个队列之类的来存储Actions,继续找"BufferedAction"可以发现在scripts/components/playeractionpicker.lua里有些奇怪的东西:

function PlayerActionPicker:SortActionList(actions, target, useitem)
    if #actions > 0 then
        table.sort(actions, function(l, r) return l.priority > r.priority end)
        local ret = {}
        for k,v in ipairs(actions) do
            if not target then
                table.insert(ret, BufferedAction(self.inst, nil, v, useitem))
            elseif target:is_a(EntityScript) then
                table.insert(ret, BufferedAction(self.inst, target, v, useitem))
            elseif target:is_a(Vector3) then
                local quantizedTarget = target 
                local distance = nil 

                --If we're deploying something it might snap to a grid, if so we want to use the quantized position as the target pos 
                if v == ACTIONS.DEPLOY and useitem.components.deployable then 
                    distance = useitem.components.deployable.deploydistance
                    quantizedTarget = useitem.components.deployable:GetQuantizedPosition(target)
                end

                local ba = BufferedAction(self.inst, nil, v, useitem, quantizedTarget)
                if distance then 
                    ba.action.distance = distance 
                end 
                table.insert(ret, ba)
            end
        end
        return ret
    end
end

function PlayerActionPicker:GetSceneActions(targetobject, right)
    local actions = {}

    for k,v in pairs(targetobject.components) do
        if v.CollectSceneActions then
            v:CollectSceneActions(self.inst, actions, right)
        end
    end

	if targetobject.inherentsceneaction and not right then
		table.insert(actions, targetobject.inherentsceneaction)
	end

	if targetobject.inherentscenealtaction and right then
		table.insert(actions, targetobject.inherentscenealtaction)
	end

    if #actions == 0 and targetobject.components.inspectable then
        table.insert(actions, ACTIONS.WALKTO)
    end

    return self:SortActionList(actions, targetobject)
end

仔细看了半天,这里似乎是一个比较关键的地方。所有的BufferedAction在这里通过table.sort按priority排了个序。我猜想游戏逻辑应该是先收集各种可选的操作选项,然后把操作们按优先级排个序,顶端的即为优胜者,将会被选作默认的操作(亲,你直接选个最大值不就得了吗)。

因此,要么是排序前的actions有已经有问题了,要么是排序本身出的问题。通过这里我们应该可以缩小检查的范围。

0x10 不稳定天平

为了验证我的想法,在SortSceneActions里写点LOG。看看好不好玩:

local mapActionToName = {}
for k, v in pairs(STRINGS.ACTIONS) do
    local m = ACTIONS[k]
    if (m) then
        mapActionToName[m] = k
    end
end

local function PrintList(actions)
    for i, v in ipairs(actions) do
        print("ACTION [" .. i .. "] = " .. (mapActionToName[v] or "NULL"))
    end
end

function PlayerActionPicker:SortActionList(actions, target, useitem)
    if #actions > 0 then
        print("-----------------------")
        print("Before sorting ... ")
        PrintList(actions)
        table.sort(actions, function(l, r) return l.priority > r.priority end)
        print("After sorting ... ")
        PrintList(actions)
        
        local ret = {}
        for k,v in ipairs(actions) do
            if not target then
                table.insert(ret, BufferedAction(self.inst, nil, v, useitem))
            elseif target:is_a(EntityScript) then
                table.insert(ret, BufferedAction(self.inst, target, v, useitem))
            elseif target:is_a(Vector3) then
                local quantizedTarget = target 
                local distance = nil 

                --If we're deploying something it might snap to a grid, if so we want to use the quantized position as the target pos 
                if v == ACTIONS.DEPLOY and useitem.components.deployable then 
                    distance = useitem.components.deployable.deploydistance
                    quantizedTarget = useitem.components.deployable:GetQuantizedPosition(target)
                end

                local ba = BufferedAction(self.inst, nil, v, useitem, quantizedTarget)
                if distance then 
                    ba.action.distance = distance 
                end 
                table.insert(ret, ba)
            end
        end
        return ret
    end
end

在排序前后我都把actions数组中的值打印出来,看看这个排序做了什么:

(启用PATCH前)

(启用PATCH后)

注意看图中蓝框的部分,排序后的结果果然变成PICKUP第一了。于是一个直接的想法就是,饥荒作者并不知道table.sort排序是不稳定的,LuaJIT中的快排算法很可能和原版的不一样,在最大元素不唯一的情况下,二者的结果就会有差异!!

于是我打开actions.lua,一切都似乎明白了:

Action = Class(function(self, priority, instant, rmb, distance, crosseswaterboundary) 
	self.priority = priority or 0
	self.fn = function() return false end
	self.strfn = nil
	self.testfn = nil
	self.instant = instant or false
	self.rmb = rmb or nil
	self.distance = distance or nil
	self.crosseswaterboundary = crosseswaterboundary or false
end)

ACTIONS=
{
	REPAIR = Action(),
	...
	PICKUP = Action(2),
	...
	CHECKTRAP = Action(2),
	BUILD = Action(),
	PLANT = Action(),
	...
}

非常明显,PICKUP和CHECKTRAP的优先级都是2,那么排序就有可能出现PICKUP在CHECKTRAP前面的情况。

想要解决也很容易,把CHECKTRAP的优先级调大些(如2.5),就好了。事实证明调整后,bug也确实消失了。

然而问题就到这里完结了吗?

0x11 依赖于错误的正确

这个bug的神奇之处在于,并不仅仅如此。

在发布了修补办法之后,吧友表示问题解决了,但是还有其他的类似问题,比如船只不能Inspect,MOD人物翼语的莲花台不能右键拾取,烤箱MOD异常等等。

确实,这些都是原版饥荒Actions优先级设置导致的,那么有两种可能:

1. 作者们修改了排序算法,使之变成稳定的(如冒泡排序),所以在优先级相同的时候,原序列中排前面的排序后也排前面。

2. 作者们压根就不知道快排还有不稳定一说,出现结果异常的时候就调调优先级,结果要是符合预期,就不管了。lua5.1.4中快排实现和LuaJIT中不一样导致了这个问题。

最初我以为是1的问题,于是手写了个稳定排序,挂入后果然解决了陷阱的问题(即使优先级都是2)。但是其余的bug不能都解决,此路不通。

那么如果按2来说,快排实现不一样,那么我换一个lua5.1.4原版的DLL试试呢?

于是我把lua51.dll改名成luajit.dll,运行游戏,果然一切正常。看来就是排序算法的问题了。

于是我打开lua5.1.4的源码,对比luajit的源码,却发现了神奇的事情:排序算法除了报错部分有点区别以外,竟然是一模一样!!

那这个就奇怪了,排序算法也是一样的,为什么结果不一样呢?我漏掉了什么东西吗?

再仔细检查这两张图,终于发现了问题所在:

(启用PATCH前)

(启用PATCH后)

之前我一直关注排序后的结果,却没发现排序前的数据顺序也是不一样的(红框所示)。也就是说,问题本身与排序算法没有关系,与错误的priority虽有关系,但不是致命的。

致命的是排序前数据的顺序是为何不同的!

沿着SortActionList往上找,果然,刚刚就在眼皮底下错过了:

function PlayerActionPicker:GetSceneActions(targetobject, right)
    local actions = {}

    for k,v in pairs(targetobject.components) do
        if v.CollectSceneActions then
            v:CollectSceneActions(self.inst, actions, right)
        end
    end

	if targetobject.inherentsceneaction and not right then
		table.insert(actions, targetobject.inherentsceneaction)
	end

	if targetobject.inherentscenealtaction and right then
		table.insert(actions, targetobject.inherentscenealtaction)
	end

    if #actions == 0 and targetobject.components.inspectable then
        table.insert(actions, ACTIONS.WALKTO)
    end

    return self:SortActionList(actions, targetobject)
end

不管你信不信,问题就出在这个函数里的for循环上。

如果您看过前文的话,就能明白我的意思——for循环的枚举顺序是与string HASH算法有关的!而v:CollectSceneActions是顺序往actions中添加ACTION的,那么,不同的枚举顺序就会导致ACTION在actions里的顺序不一致。

而LuaJIT的string HASH算法和原版lua的并不一样,这也是前文联机版RPC出bug的原因。

那么,我们把逻辑整理一下,完整的bug触发流程是:

string HASH算法不一致 => table里key的遍历顺序不一致 => actions里的ACTION顺序不一致 + 排序算法不稳定且待排序数组中存在键相等(priority相等)的问题 => 排序结果不一致 => 选中的操作不一致。

事已至此,所有的谜团都已经解开了。回头来看,如果饥荒作者在发现actions排序后顺序奇怪的时候能想到这是排序算法的稳定性,那么就绝不会只调整个别ACTION的priority来解决,而是会重新为所有的ACTION明确不同的priority。如果他们这么做了,整个问题就完全不会出现。

而现在,程序能够正确运行完全依赖于特定排序算法对特定数据的排序结果。试想如果有一个mod手工添加了一个ACTION;或者随着版本更新,作者又在targetobject.components里添加了一个默认components,都会导致排序的结果与预期的不一致,而且这种不一致会导致大面积的逻辑错误,极难排除。

更麻烦的在于,已经有不少第三方MOD使用了ACTION。如果随便改掉默认ACTION的priority值可能会导致这些MOD出错。

因此这个bug就慢慢地变成了feature,且无人敢动。

那么怎么解决呢?我没办法,只能把lua5.1.4的string HASH算法复制出来,替换掉luajit的那份实现了。这个同时也解决了之前RPC的问题,不用再修改代码了。我其实不想这么改,因为这样的设计将会面临更高的安全风险。但是没办法,将错就错吧。

0x12 瓶颈

根据吧友的反馈,我发现了一处LuaJIT自身的限制:在加载存档时,如果存档太大,常数个数超过65536,LuaJIT初始化表的时候会出错。出错时表大小达到了惊人的0x65000000多,经过简单的跟踪,表结构已经被破坏。再仔细看时发现LUAJIT指令中BCMAX_D这个常数不能随便加大,否则32位指令会放不下。看了下luajit的BBS,发现Mike回答过这个问题:

lua-users.org/lists/lua

LuaJIT has different limits and different design constraints.

For a single table that consists only of constants there's
effectively no limit (memory is the limit).

But your test case has lots of small, nested tables. Every
individual table only occupies a single constant slot (and not one
for each of its keys/values). But there are too many of these
tables to keep them in a single function.

The short term solution is to split the table construction into
smaller pieces, wrapped into individual functions. The long term
solution would be to add a workaround for these limits to LuaJIT.
But I'm not sure about the best way, yet.

> [2]	The Lua bug it exposed has since been fixed
> 	http://www.lua.org/bugs.html#5.1.4-6

This problem is not present in LuaJIT 2.0.

(实际上luajit 2.0还是有这个问题。)

原因很简单,就是饥荒存档的时候使用了很烂的策略,把表序列化成了lua 代码。然后就含有了巨量的表、常数。从而在load时超出了LuaJIT的限制。如果想要解决,最简单的办法似乎是重写load的代码,为存档专门分块加载。彻底点的办法是修改存档格式,但是这样就会无法兼容旧存档。

但是仔细看了Mike的话后我发现其实只需要把数据表分层用function 包起来,就可以缓解这个问题。只要每层的function常量不超过65536个,就可以正确加载。

于是打开lib_base.c:

void filter(lua_State* L) {
	char ch, check = 0;
	int level = 0;
	int t = lua_type(L, 1);
	int happen = 0;
	int quote = 0;
  int slash = 0;
	const char* p = lua_tostring(L, 1);
	long size = lua_objlen(L, 1);
	char levelMasks[1024];
	memset(levelMasks, 0, sizeof(levelMasks));
	if (t == LUA_TSTRING) {
		char* target = (char*)malloc(size * 2);
		char* q = target;
		while (size-- > 0) {
			ch = *p++;
			if (ch == '"' && !slash) {
				quote = !quote;
			}

			if (!quote) {
				if (ch == '{') {

					level++;
					if (check) {
						const char* ts = "(function () return {";
						--q;
						memcpy(q, ts, strlen(ts));
						q += strlen(ts);
						levelMasks[level - 2] = 1;
						check = 0;
						happen = 1;
					}

					check = 1;
					*q++ = (char)ch;
				} else {
					*q++ = (char)ch;
					if (level > 0 && ch == '}') {
						level--;
						
						if (levelMasks[level] != 0) {
							const char* ts = "end)()";
							memcpy(q, ts, strlen(ts));
							q += strlen(ts);
							levelMasks[level] = 0;
						}
					}
					check = 0;
				}
			} else {
        if (ch == '\\') {
          slash = !slash;
        } else {
          slash = 0;
        }
				*q++ = (char)ch;
				check = 0;
			}
		}

		if (happen) {
			*q = 0;
      /* printf("TARGET: %s\n", target); */
/*
      FILE* tg = fopen("modified.lua", "wb");
      fwrite(target, q-target, 1, tg);
      fclose(tg);*/
			lua_pushlstring(L, target, q - target);
			lua_replace(L, 1);
		}

		free(target);
	}
}

LJLIB_CF(loadstring)
{
  filter(L);
  return lj_cf_load(L);
}

当检测到有连续的两个{时(没办法,只能为DS作这个兼容了)。就在这个子表外插入一个function 边界。重新编译后,问题解决。



0x13 总结

经过将近一个半月的努力,我的PATCH终于成功地解决了绝大多数的bug,正式发布了。同时,为了减轻用户的压力,我通过各种办法集成了对原版饥荒lua代码的修改,使得用户只需要把发布的文件复制到饥荒bin目录即可启用。

当初真的没有想到会遇到如此之多的问题,但是通过解决这些BUG,我阅读了相当数量的源码,用OllyDBG+WinDBG调试和分析了很多的崩溃报告。虽然大多数猜想和试验由于与最终结果不符没有放上来,但是谁又能保证一下子就找到bug呢~

同时,通过阅读他人的代码,我也在思考着设计和编码的问题。这个地方的实现好不好,为什么?作者当时应该是怎么想的?为什么要有这样的设定或者限制?如果这个让我来写,我应该怎么设计?能不能实现得更好?

另外,编码其实只是游戏体验中的一部分,游戏的世界观设定,元素设定,数值设计,画风选择,音乐的制作等等都构成了这个游戏不可分割的一部分。在我看来,饥荒为什么这么火,和它这些方面的努力是分不开的。或许在编程角度来看,饥荒本身的实现槽点很多,但是这并不妨碍它成为一款优秀的沙盒游戏。


附:

在本文写作的时候,仍然有bug没有得到解决:

在启用了PATCH之后,上下洞穴有一定概率会导致季节错乱。这个BUG我在PATCH的早些版本曾经自己玩出来过,但是最新版本都没有成功复现。据吧友反馈这个问题仍然存在,但是所有说问题存在的吧友只有一位按我的要求提供了存档和MODS,但是仍然没能在我的机器上重现。其余的两三位吧友在提问之后就消失了,再也没有反馈。我在查阅了吧里旧的帖子后发现这个问题原版应该也会出现,但是那个帖子是很久以前发的,作者是否尝试“修复”过,并不得而知。

其实写这个PATCH最大的阻力并非来自程序代码本身,而是在众多的反馈之中,很少能有人能够有效地按要求描述出bug的具体经过,细节,以及如何重现。很多bug的解决都是通过简单的描述猜出来的,因此浪费了大量时间在不确切的猜测上。



0x14 火山结界(番外)

(这一部分解决的是一个原版饥荒中自火山开放以来一直存在的bug,即在Shipwrecked DLC中进出火山时日期会错乱。8月10号我收到了Klei官方的回复,应该会在下一个版本中修复这个问题!)

(再次强调下,这个BUG的触发与是否启用了我的PATCH没有关系)

饥荒的作者在日期设计上有点奇怪,他不是采用统一的时间,而是每个世界(包括洞穴,火山)都有一一个独立的时间,只有当前世界的表会走。这样跳世界的时候时间会不一致。
按理说用跳之前世界的时间盖掉新世界的时间不就简单了吗?可是作者想允许不同世界的时间不一样,所以要用player_age(即玩家年龄)来同步两个世界(ROG和SW跳除外)。(这个设计真的是无力吐槽)
然后呢,当检测到用户是从一个世界跳到另一个世界的时候,它就触发这个同步的代码。跳世界(travel)的方式总共有:"ascend""descend"(上下洞穴)"shipwrecked"(跳ROG和SW)"ascend_volcano""descend_volcano"(进出火山)这几种。


下面打开data\DLC0002\scripts\gamelogic.lua文件,定位到:

if travel_direction == "ascend" or travel_direction == "descend" then
    print ("Catching up world", catch_up, "(", player_age,"/",world_age,")" )

当上下洞穴和进出火山的时候都需要同步时间(跳ROG和SW不需要),所以要在加载世界的时候需要检测下是不是要同步。

所以BUG的源头就是,作者在这里漏掉了"descend_volcano"和"ascend_volcano"这两条。一旦你在火山里呆的时间超过一天,这个时间就应该要同步,但是由于作者的大意,这个同步永远不可能发生。。。