小触l 饥荒 伊泽瑞尔皮肤生存 有更新了什么吗

拒绝访问 | 3gmfw.cn | 百度云加速
请打开cookies.
此网站 (3gmfw.cn) 的管理员禁止了您的访问。原因是您的访问包含了非浏览器特征(3ece3-ua98).
重新安装浏览器,或使用别的浏览器小触 l 饥荒之伊泽瑞尔生存 真!徒手掏牛粪!_期-游戏-高清正版视频–爱奇艺
更多频道内容在这里查看
爱奇艺用户将能永久保存播放记录
过滤短视频
暂无长视频(电视剧、纪录片、动漫、综艺、电影)播放记录,
按住视频可进行拖动
&正在加载...
请选择打赏金额:
{{ each data as item index}}
{{ each data as item index}}
{{if item.isLast}}
&正在加载...
{{ each data as item index}}
{{item.playcount}}
&正在加载...
收藏成功,可进入查看所有收藏列表
把视频贴到Blog或BBS
当前浏览器仅支持手动复制代码
视频地址:
flash地址:
html代码:
通用代码:
通用代码可同时支持电脑和移动设备的分享播放
方式1:用手机看
用爱奇艺APP或微信扫一扫,在手机上继续观看
当前播放时间:
方式2:一键下载至手机
限爱奇艺安卓6.0以上版本
使用微信扫一扫,扫描左侧二维码,下载爱奇艺移动APP
其他安装方式:手机浏览器输入短链接//71.am/udn
下载安装包到本机:&&
设备搜寻中...
请确保您要连接的设备(仅限安卓)登录了同一爱奇艺账号 且安装并开启不低于V6.0以上版本的爱奇艺客户端
连接失败!
请确保您要连接的设备(仅限安卓)登录了同一爱奇艺账号 且安装并开启不低于V6.0以上版本的爱奇艺客户端
部安卓(Android)设备,请点击进行选择
请您在手机端下载爱奇艺移动APP(仅支持安卓客户端)
使用微信扫一扫,下载爱奇艺移动APP
其他安装方式:手机浏览器输入短链接http://71.am/udn
下载安装包到本机:&&
爱奇艺云推送
请您在手机端登录爱奇艺移动APP(仅支持安卓客户端)
使用微信扫一扫,下载爱奇艺移动APP
180秒后更新
打开爱奇艺移动APP,点击“我的-扫一扫”,扫描左侧二维码进行登录
没有安装爱奇艺视频最新客户端?
正在检测客户端...
您尚未安装客户端,正在为您下载...安装完成后点击按钮即可下载
30秒后自动关闭
:小触 l 饥荒之伊泽瑞尔生存 真!徒手掏牛粪!
请选择打赏金额:
播放量12.7万
播放量数据:快去看看谁在和你一起看视频吧~
更多数据:
{{each data}}
抱歉,没有“{{feature}}”的其他视频了.
&正在加载...
&正在加载...
&正在加载...
&正在加载...
&正在加载...
&正在加载...
{{ each data as item index}}
Copyright (C) 2018
All Rights Reserved
您使用浏览器不支持直接复制的功能,建议您使用Ctrl+C或右键全选进行地址复制
正在为您下载爱奇艺客户端安装后即可快速下载海量视频
正在为您下载爱奇艺客户端安装后即可免费观看1080P视频
&li data-elem="tabtitle" data-seq="{{seq}}"&
&a href="javascript:void(0);"&
&span>{{start}}-{{end}}&/span&
&li data-downloadSelect-elem="item" data-downloadSelect-selected="false" data-downloadSelect-tvid="{{tvid}}"&
&a href="javascript:void(0);"&{{pd}}&/a&
选择您要下载的《》剧集:
您使用浏览器不支持直接复制的功能,建议您使用Ctrl+C或右键全选进行地址复制饥荒L.m.u汉化组汉化补丁V.10下载(适用11月25日之后的版本)-乐游网游戏下载
安全的单机游戏下载大全
→ 饥荒L.m.u汉化组汉化补丁V.10 (适用11月25日之后的版本)
饥荒L.m.u汉化组汉化补丁V.10(适用11月25日之后的版本)( 沙盒冒险游戏《饥荒》最新版本的汉化补丁 )
本次小编给大家带来的是沙盒冒险游戏《饥荒》最新版本的汉化补丁,该补丁由L.m.u汉化组汉化提供,适用于11月25日更新之后的版本,需要的小伙伴可以来乐游网下载体验。
游戏大小:16M
本次小编给大家带来的是沙盒《》最新版本的汉化补丁,该补丁由L.m.u汉化组汉化提供,适用于11月25日更新之后的版本,需要的小伙伴可以来乐游网下载体验。使用说明:1.解压缩2.复制全部文件覆盖到游戏目录3.开始游戏即可生效注意事项:包含编码文件+汉化补丁,为游戏增加了预设,启动游戏后自动加载汉化MOD。其他版本游戏,启动游戏后需要点击mods(模组)手动加载汉化MOD日游戏更新后,之前的汉化补丁不再支持当前游戏版本,会提示汉化并未完全支持该版本,打上本汉化即可解决该问题。汉化说明:完整汉化,汉化掉了10月26日更新文本,以及给字体加了描边。对11月25日更新后的游戏增加了编码支持。游戏简介:饥荒是由《闪克》制造组Klei制造发行的一款动作冒险类求生游戏,《饥荒》的故事讲述的是关于一名科学家被恶魔传送到了异世界荒野。他必需用本人的聪慧在残酷的野外环境中求生,差不多就是《东京丛林》加上能乖巧活动的双手,或者《我的世界》加上消化系统。
安卓官方手机版
IOS官方手机版
本次小编给大家带来的是沙盒冒险游戏《饥荒》最新版本的汉化补丁,该补丁由L.m.u汉化组汉化提供,适用于11月2...
其他版本下载
饥荒L.m.u汉化组汉化补丁V.10 (适用11月25日之后的版本)
下载周排行
下载总排行
◎ 我们收集了众多网友经常出现的问题,点击浏览:如果下载的时候提示 &Service Unavailable& ?请务必使用
下载本站游戏!
◎ 游戏1号群:336883(满),游戏2号群:,欢迎加入。
◎ 提供 饥荒L.m.u汉化组汉化补丁V.10
中文破解版,保证完全无毒病,请大家放心下载.
◎ 本站仅创建用户沟通交流的平台,所展示的游戏资源内容均来自于用户上传分享,版权问题均与我站无关。资源仅作为用户间分享讨论之用,如该游戏触犯了您的权利,请发送至邮箱:,我们第一时间给予删除。饥荒游戏扫雷笔记 系列全文合集a year ago在上面的代码里,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)
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;
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);
memcpy(target, c, len + target & entry.instr + INSTR_SIZE ? entry.instr + INSTR_SIZE - target : 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;
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调用内部函数被inline2. 饥荒作者删除了部分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()
问题很明显,饥荒作者使用了旧的表示可变参数表的语法。在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);
vcall = 0;
expr_init(&key, VKNUM, 0);
setintV(&key.u.nval, (int)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 = 0x;
/* 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(...)
env.AddTask = function(...)
arg = {...}
initprint("AddTask", arg[1])
require("map/tasks")
AddTask(...)
env.AddRoom = function(...)
arg = {...}
initprint("AddRoom", arg[1])
require("map/rooms")
AddRoom(...)
(然后这句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:244if (lj_char_isdigit(c)) {
if (lj_char_isdigit(cp_get(cp))) {
c = c*8 + (cp-&c - '0');
cp_save(cp, (c & 0xff));
只能强行兼容不合法的单个斜杠了。同时,如果%后面没有合法的格式标记,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];
fs-&p = p+1;
return (sf | sx | ((c & 0x20) ? 0 : STRFMT_F_UPPER));
c = 's'-'A'; // 这里的写法突出了我懒的本质。
uint32_t sx = strfmt_map[c];
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
/* Hexadecimal escape '\xXX'. */
c = (lex_next(ls) & 15u) && 4;
if (!lj_char_isdigit(ls-&c)) {
if (!lj_char_isxdigit(ls-&c)) goto err_
c += 9 && 4;
/* Skip whitespace. */
lex_next(ls);
while (lj_char_isspace(ls-&c))
if (lex_iseol(ls)) lex_newline(ls); else lex_next(ls);
心好累。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 )
这里并没有检查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:
// system("pause");
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):)这看起来也没有什么问题,那么为什么客户端人物不能走动?没办法只能开两个游戏调试下了。我在工作电脑上跑起游戏作服务端,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)
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
action = ACTION_IDS[action] ~= nil and ACTIONS[ACTION_IDS[action]] or nil
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()
这里我加了点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)
local playercontroller = player.components.playercontroller
if playercontroller ~= nil then
playercontroller:OnRemoteDragWalking(x, z)
很明显,这个调用真的串线了。从它的参数上来看更可能调用的是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
--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
还好我在很早之前看到云风写的有关lua字符串Hash算法为了防DDOS的变动,一眼直接就看出了问题(终于不用走弯路了):这样生成RPC代号的方法在新版lua中是错误的。原因在于新版lua的字符串hash算法中,包含了一个随机种子,它避免了因算法固定而导致被黑客构造大量具有相同HASH值的不同字符串招至的DDOS攻击。因此每次启动时,针对相同字符串的HASH结果都不总是相同。然而pairs内部依赖于next来做表遍历,next的遍历顺序则与key的hash值相关(lua会将key的hash值对表长度的进行一个modpow2的取余,将结果作为hashpart的index,感谢ms2008指出)。这里的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)
table.sort(temp)
for k, v in ipairs(temp) do
RPC[v] = k
local i = 1
for k, v in pairs(RPC_HANDLERS) do
RPC[k] = i
0x0E 拆包炸弹改了之后,人物是可以动了,但是客户端退出时还是有黑影,再进就进不来了。这很诡异,第一感觉也许是某个任务没有执行完导致的。但是又没有报错信息,去哪去找是哪个任务没完成呢?一路从玩家消失时的消息跟踪过来,发现问题出在scripts/components/playerspawner.lua:79local function PlayerRemove(player, deletesession, migrationdata, readytoremove)
if readytoremove then
player:OnDespawn()
if deletesession then
DeleteUserSession(player)
player.migration = migrationdata ~= nil and {
worldid = TheShard:GetShardId(),
portalid = migrationdata.portalid,
sessionid = TheWorld.meta.session_identifier,
SerializeUserSession(player)
player:Remove()
if migrationdata ~= nil then
TheShard:StartMigration(migrationdata.player.userid, migrationdata.worldid)
player:DoTaskInTime(0, PlayerRemove, deletesession, migrationdata, true)
这里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
而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(...)
其中toarrayornil是C方面实现的函数,相当于{...},但是当...为空时返回nil。那么,是toarrayornil出了问题吗?在探索这个函数之前,我忽然想到,lua中table的数组部分有个非常奇怪的性质,其长度是从1到第一个nil为止(不包括nil),那么如果打包中的参数含有nil值,会不会nil值后面东西就被忽略了呢?恭喜我又猜对了!编写一个这样的脚本,存为test.lua:function foo(...)
return {...}
print(unpack(foo(1, 2, 3, nil, 5)))
在lua 5.1.4官方版的执行结果如下:1
在luajit 2.1.0版的执行结果如下:1
这里官方的结果比较有意思,居然能把nil后的值读出来,但是luajit就不行了。而lua文档里也没明确这种情况下lua的行为,所以只能说是实现不同吧。在这个例子里,PlayerRemove被调用时只有player这个参数不是nil,其余全是nil,所以在调用DoTaskInTime时,虽然readytoremove被指定了true,但是它前面两个参数的值是nil,所以就触发了LuaJIT的这个问题。打开lib_base.c:220LJLIB_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);
cTValue *tv = lj_tab_getint(t, i);
copyTV(L, L-&top++, tv);
setnilV(L-&top++);
} while (i++ & e);
注意在&----处我用一个新的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))) {
if (j) --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
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)
local ba = BufferedAction(self.inst, nil, v, useitem, quantizedTarget)
if distance then
ba.action.distance = distance
table.insert(ret, ba)
return ret
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)
if targetobject.inherentsceneaction and not right then
table.insert(actions, targetobject.inherentsceneaction)
if targetobject.inherentscenealtaction and right then
table.insert(actions, targetobject.inherentscenealtaction)
if #actions == 0 and targetobject.components.inspectable then
table.insert(actions, ACTIONS.WALKTO)
return self:SortActionList(actions, targetobject)
仔细看了半天,这里似乎是一个比较关键的地方。所有的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
local function PrintList(actions)
for i, v in ipairs(actions) do
print("ACTION [" .. i .. "] = " .. (mapActionToName[v] or "NULL"))
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)
local ba = BufferedAction(self.inst, nil, v, useitem, quantizedTarget)
if distance then
ba.action.distance = distance
table.insert(ret, ba)
return ret
在排序前后我都把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
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)
if targetobject.inherentsceneaction and not right then
table.insert(actions, targetobject.inherentsceneaction)
if targetobject.inherentscenealtaction and right then
table.insert(actions, targetobject.inherentscenealtaction)
if #actions == 0 and targetobject.components.inspectable then
table.insert(actions, ACTIONS.WALKTO)
return self:SortActionList(actions, targetobject)
不管你信不信,问题就出在这个函数里的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初始化表的时候会出错。出错时表大小达到了惊人的0x多,经过简单的跟踪,表结构已经被破坏。再仔细看时发现LUAJIT指令中BCMAX_D这个常数不能随便加大,否则32位指令会放不下。看了下luajit的BBS,发现Mike回答过这个问题: LuaJIT has different limits and different design constraints.For a single table that consists only of constants there'seffectively no limit (memory is the limit).But your test case has lots of small, nested tables. Everyindividual table only occupies a single constant slot (and not onefor each of its keys/values). But there are too many of thesetables to keep them in a single function.The short term solution is to split the table construction intosmaller pieces, wrapped into individual functions. The long termsolution 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&
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 == '{') {
if (check) {
const char* ts = "(function () return {";
memcpy(q, ts, strlen(ts));
q += strlen(ts);
levelMasks[level - 2] = 1;
check = 0;
happen = 1;
check = 1;
*q++ = (char)ch;
*q++ = (char)ch;
if (level & 0 && ch == '}') {
if (levelMasks[level] != 0) {
const char* ts = "end)()";
memcpy(q, ts, strlen(ts));
q += strlen(ts);
levelMasks[level] = 0;
check = 0;
if (ch == '\\') {
slash = !slash;
slash = 0;
*q++ = (char)ch;
check = 0;
if (happen) {
/* 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"这两条。一旦你在火山里呆的时间超过一天,这个时间就应该要同步,但是由于作者的大意,这个同步永远不可能发生。。。0x15 再遇精致BUG 如果你是一位饥荒老玩家,那么制作“高温”陷阱几乎是黄金必备技能。Shipwrecked DLC中,玩家可以使用天然形成的双帽贝岩或者手工移植两丛咖啡,配合雪球发射机(即我们俗称的“灭火器”),来制作“高温”陷阱。相关教程可以参考深辰S的饥荒视频: ,40分钟开始。为什么我要把“高温”两个字加上引号呢?这是因为,在这篇文章里,我将详细地介绍这个陷阱的原理。在这里提前剧透一下——这种陷阱和高温其实没有什么关系,只不过说成高温大家理解比较方便~为什么“高温”陷阱如此受欢迎呢?这是因为你只需要找到合适的帽贝岩组,在旁边不远处造个灭火器就大功告成了。只要雪球发射机开着,它就会不停地向其中一个帽贝岩发射雪球,这个雪球可以在发射路径以及目标点一小片周围造成范围冰冻效果,配合猴子陷阱/猪人陷阱可以在较短时间内刷出香蕉等资源。猎狗和坎普斯来袭的时候也能将其冻住逐个击杀。饥荒里雪球发射机只要处于开启状态就会以恒定速度消耗燃料,发射雪球的速率再快消耗速度也是恒定的。用的时候开启,刷完后关掉,没有额外的开销。在前一篇的专栏文章()里,我介绍了将饥荒原版lua引擎替换为LuaJIT引擎的种种奇遇。本篇是前篇的续作,纪录一个关于“高温”陷阱的BUG。如果说之前的捕鸟器BUG(原篇0x0F 人间蒸发的海盗鹦鹉)比较精致的话,这个BUG就精致得匪夷所思了——在使用了我的LuaJIT补丁之后,“高温”陷阱中的雪球发射机变得萎靡不振,开始偷懒——原本处于连发状态的雪球,变成了2~3秒一发。所以麻烦在于,如果是LuaJIT补丁引起的BUG,按常理破坏了某些机制后,雪球应该一个也不发才对。但是间隔变长了是什么鬼啊?我的第一感是os.clock()之类的API返回的时间单位可能有差异,然而检查了一下LuaJIT,发现它确实是按标准实现的,没什么问题。没办法一点点打LOG来查吧。绕了几个小时的路,终于摸清了连发雪球的原理。在必要的地方添加了print语句后,挂入OllyDBG可以看到连发状态下的状态日志:刚开始我一直试图去找出是什么东西触发了雪球连发,然而跟踪了半天却发现一环套着一环的函数调用似乎都没有什么问题。最早的触发源是一个处于FireDetector中的间隔为1s的定时器来调用LookForFireAndFirestarters。但是奇怪的是雪球发射的速度是远远快于1s的(实际为0.3s左右),在安装LuaJIT补丁之后,这个1s定时器也是正常工作的。可见问题并不在这里。0x16 永动机 这个图中隐含了一个比较重要的信息,那就是每个框里的动作其实并不是单独触发的,下一个框中的LookForFireAndFirestarters其实是上一个框结尾时调用的,也就是说,雪球机连射其实是一个间接的逻辑递归。LookForFireAndFirestarters的触发点不止一个,而且最初我以为是顺序流程就没在GoToState()进入shoot状态之后跟进代码了。哪知shoot完后下一个状态机被调度的时候,状态机会根据shoot状态再去调用一次LookForFireAndFirestarters()从而形成递归。游戏中雪球之间0.3s的间隔也由此而来。这时栈里不会有形式上递归的痕迹,更不会造成爆栈。并且这个逻辑流程和最初FireDetector那个1s间隔的触发流程交织在一起,大大增加了调试的难度。那么为什么应用了LuaJIT补丁之后,雪球的连发就没有了呢?我们来看看启用补丁后LOG:图中红框整个部分才相当于原版LOG图的一个框。可以看出,State&shoot&这个状态莫名其妙走向了spin_down而不是维持在shoot状态,这直接对应着雪球机打一炮休息一下的情况。但是为什么它不维持在shoot状态呢?按照之前的理解,一但通过火情进入了shoot状态,便会驱动递归形成永动机。但是这里明显断气了。0x17 越狱 从日常生活逻辑推测,雪球机在成功扑灭火焰或者灌溉了作物之后,应该会智能判断下需不需要停止工作。怎么判断呢?自然是判断还有没有火焰或者枯萎的作物:LookForFireAndFirestarters():function FireDetector:LookForFiresAndFirestarters()
local x,y,z = self.inst:GetPosition():Get()
local priority = 1
local ents = {}
while priority &= #self.YESTAGS and #ents == 0 do
ents = TheSim:FindEntities(x,y,z, self.range, self.YESTAGS[priority], self.NOTAGS)
if #ents & 0 then
local toRemove = {}
for k,v in pairs(ents) do
if self.detectedItems[v] then
table.insert(toRemove, k)
toRemove = table.reverse(toRemove)
for k,v in pairs(toRemove) do
table.remove(ents, v)
--dumptable(ents)
priority = priority + 1
其中YESTAGS是这么定义的: self.YESTAGS = { --in priority order
{"smolder"},
{"withered"},
{"witherable"},
{"wildfirestarter"},
{"burnable"},
那么这里的逻辑就很清楚了,通过TheSim:FindEntities这个API按tag查询雪球发射机附近的对象,其中tag是有优先级的,最紧急的当然是正在燃烧的对象,接着是燃烧前闷烧的小火焰,接着的东西就比较重要了,是“已经枯萎的对象”,和“可以枯萎的对象”。之所以雪球机会对帽贝岩有反应,是因为帽贝岩在一定延迟之后会含有witherable的tag,相关代码在DLC0002\scripts\prefabs\impetrock.lua:129 local variance = math.random() * 4 - 2
inst.makewitherabletask = inst:DoTaskInTime(TUNING.WITHER_BUFFER_TIME + variance, function(inst) inst.components.pickable:MakeWitherable() end)
看到这里大家应该知道了,所谓的“高温”陷阱其实和高温没有关系。只不过是帽贝岩是可枯萎的对象罢了。但是这里许多玩家也许会有人问,如果不是一个岩石比另一块温度高的话,那为什么雪球机只喷其中一个,而不理另外那个呢?这才是问题的核心。如上面代码所示,在FindEntities之后,会对获取到的列表进行一次筛选,去掉刚刚检测(浇灌)过的对象(.detectedItems)。每个对象在被浇灌过后,就会进入之个detectedItems的列表2秒钟,避免下次被浇灌。function FireDetector:AddDetectedItem(item)
if self.detectedItems[item] then
self.detectedItems[item] = item
self.inst:DoTaskInTime(2, function(inst) self.detectedItems[item] = nil end)
这样的好处是一旦出现大面积火情,雪球就会分配得比较均匀。所以按理说,只有一个对象需要扑灭或者浇灌的时候,是无论如何也不会引起雪球发射机的连发的。这么一说应用了LuaJIT补丁之后得到的结果才是正确的。但是为什么在原版里两个都在雪球发射机范围内的帽贝岩会引起连发BUG呢?原因在于,上面LookForFireAndFirestarters中过滤对象的代码有问题: local toRemove = {}
for k,v in pairs(ents) do
if self.detectedItems[v] then
table.insert(toRemove, k)
toRemove = table.reverse(toRemove)
for k,v in pairs(toRemove) do
table.remove(ents, v)
起初我觉得这个代码虽然有点蠢但是其实还说得过去。但是搞不好就是这里的问题呢?于是我写了一个正常蠢的代码:local idx = 1
for k, v in ipairs(ents)
if (not self.detectedItems[v]) then
ents[idx] = v
idx = idx + 1
ents[idx] = nil
功能上讲是等价的。然而替换掉原来的代码之后——原版的连发不见了!看来问题就在这里。但是按理说reverse过后开始删,k是递减的,应该不会有问题吧……咦,不对,这个table.reverse是哪来的?突然有点眼生,查了一眼lua api中根本没这个函数。搜下源码,找到了util.lua:100-- only for indexed tables!
function table.reverse ( tab )
local size = #tab
local newTable = {}
for i,v in ipairs ( tab ) do
newTable[size-i] = v
return newTable
原作者煞有介事地写了一行only for indexed tables的注释。不过很可惜的是,这个代码在原版lua中返回的数组是有问题的。让我们作个实验。打开lua,输入下面的代码:t = {};t[1] = 1;t[0] = 0
for k, v in pairs(t) do print(v) end
for k, v in ipairs(t) do print(v) end
再试试下面的代码:t = {};t[2] = 1;t[1] = 0
for k, v in pairs(t) do print(v) end
for k, v in ipairs(t) do print(v) end
所以结论是,在原版lua中,0这个下标是处于hash区域而非array区域的。然而同样的代码让LuaJIT来跑又不一样:在LuaJIT中,0这个下标处于array区域。因此,这个代码中newtable[size-i] = v中的size-i导致了问题。如果作者写成size+1-i就不会出错了。这可能是C语言留下来的习惯。(然而为什么要写这么复杂的代码来过滤数组呢……)真相就在毫厘之间。这里出错怎么会影响后续的结果呢?还是那段代码,我们接着看: toRemove = table.reverse(toRemove)
for k,v in pairs(toRemove) do
table.remove(ents, v)
如果遍历toRemove得到的顺序是k = 1, 0的话,那么v的顺序就是1, 2,那么删除ents第一个元素之后,第二个元素就会落到第一个元素的位置。因为2这个下标的元素已经移走,接着的table.remove(ents, 2)其实就没有做任何事情。最后的数组里就会剩下原数组中第二个元素。所以到这里真相大白。应该被标记为已经浇灌的对象在下次审查中占拒2号位逃过一劫,从而次次被浇灌,雪球发射机也就停不下来了。而LuaJIT的实现恰好能让这个在原版下有BUG的代码跑出正确的结果,从而造成了“高温”陷阱失效的BUG。那怎么“修复”呢?为了让大家感受到来自BUG的幸福,我在luajit的代码中加了一行,把table.reverse改成了在luajit有同样BUG的版本。这次可是名副其实的BUG变FEATURE了。注:哪个对象占2号位是FindEntities返回结果顺序决定的,而FindEntities的结果顺序可能会在存一次档之后发生变动。因此做这个陷阱之后,最好先存个档再读进来,看看是在浇灌哪一个帽贝岩,再在这块岩石上做其他陷阱。0x18 绘制上的问题 如前文所述,饥荒游戏的性能问题主要是由两方面原因造成的:1. 脚本逻辑过于冗长复杂2. 绘制过程低效替换为LuaJIT引擎主要缓解了问题1,但是要想彻底解决需要修改大量游戏代码,这不是我一个小小的补丁就能解决问题的。在实际的测试当中,虽然脚本引擎卡顿现象得到明显缓解,但是当屏幕上有较多动画对象时,界面依然会非常卡。这是由于问题2所导致的渲染时间变长,逻辑帧必须等待渲染帧导致的。从现在开始,我将逐步分析和实现问题2的解决方案,以做一个彻底的优化。与前面文章所不同的是,本文的写作和相关代码的编写将是同步进行的,而不是像之前补丁都测试了一个多月才开始总结——因此,我探索的过程可能会走不少弯路,加之要找工作和做毕设,总体完成时间也可能会非常长,所以不要催更哈~你没看错,饥荒虽然脚本是开源的,但是shaders并没有开源:这些.ksh都是二进制文件,网上找了半天没有相关的资料,应该是自定义格式的。不过这并不是问题,在glCompileShader调用的时候,不是得乖乖得给咱看原文吗?于是打开OllyDBG,搜索字符串"anim.ksh",找到一处引用。至于怎么解码的其实没有必要关心,我只需要这里下个断点就好了。运行,命中。然后接着在libGLESv2.dll!glCompileShader上下断点。一般情况,程序是加载一个SHADER文本然后编译一个的,因此这时断在这里就可以看到anim.ksh的明文了~再运行,命中两次(一次VS,一次FS)。一切顺利,我们顺利导出了shader的文本:vertex shader:uniform mat4 MatrixP;
uniform mat4 MatrixV;
uniform mat4 MatrixW;
attribute vec3 POSITION;
attribute vec3 TEXCOORD0;
varying vec3 PS_TEXCOORD;
varying vec3 PS_POS;
#if defined( FADE_OUT )
uniform mat4 STATIC_WORLD_MATRIX;
varying vec2 FADE_UV;
void main()
mat4 mtxPVW = MatrixP * MatrixV * MatrixW;
gl_Position = mtxPVW * vec4( POSITION.xyz, 1.0 );
vec4 world_pos = MatrixW * vec4( POSITION.xyz, 1.0 );
PS_TEXCOORD = TEXCOORD0;
PS_POS = world_pos.xyz;
#if defined( FADE_OUT )
vec4 static_world_pos = STATIC_WORLD_MATRIX * vec4( POSITION.xyz, 1.0 );
vec3 forward = normalize( vec3( MatrixV[2][0], 0.0, MatrixV[2][2] ) );
float d = dot( static_world_pos.xyz, forward );
vec3 pos = static_world_pos.xyz + ( forward * -d );
vec3 left = cross( forward, vec3( 0.0, 1.0, 0.0 ) );
FADE_UV = vec2( dot( pos, left ) / 4.0, static_world_pos.y / 8.0 );
可以看到,这里实现得中规中矩:世界坐标是三维的,通过MVP变换得到屏幕坐标。注意Model-View矩阵这里是分开的两个矩阵MatrixV和MatrixW的积,其中MatrixW是模型本身的变换矩阵,MatrixV则是视图矩阵。这样世界坐标就可以直接用MatrixV乘以输入位置而得到,接着传入Fragment Shader。fragment shader:#if defined( GL_ES )
precision mediump float;
uniform sampler2D SAMPLER[4];
#ifndef LIGHTING_H
#define LIGHTING_H
// Lighting
varying vec3 PS_POS;
uniform vec3 AMBIENT;
// xy = min, zw = max
uniform vec4 LIGHTMAP_WORLD_EXTENTS;
#define LIGHTMAP_TEXTURE SAMPLER[3]
#ifndef LIGHTMAP_TEXTURE
#error If you use lighting, you must #define the sampler that the lightmap belongs to
vec3 CalculateLightingContribution()
vec2 uv = ( PS_POS.xz - LIGHTMAP_WORLD_EXTENTS.xy ) * LIGHTMAP_WORLD_EXTENTS.zw;
vec3 colour = texture2D( LIGHTMAP_TEXTURE, uv.xy ).rgb + AMBIENT.rgb;
return clamp( colour.rgb, vec3( 0, 0, 0 ), vec3( 1, 1, 1 ) );
vec3 CalculateLightingContribution( vec3 normal )
return vec3( 1, 1, 1 );
#endif //LIGHTING.h
varying vec3 PS_TEXCOORD;
uniform vec4 TINT_ADD;
uniform vec4 TINT_MULT;
uniform vec2 PARAMS;
#define ALPHA_TEST PARAMS.x
#define LIGHT_OVERRIDE PARAMS.y
#if defined( FADE_OUT )
uniform vec3 EROSION_PARAMS;
varying vec2 FADE_UV;
#define ERODE_SAMPLER SAMPLER[2]
#define EROSION_MIN EROSION_PARAMS.x
#define EROSION_RANGE EROSION_PARAMS.y
#define EROSION_LERP EROSION_PARAMS.z
void main()
vec4 colour;
if( PS_TEXCOORD.z & 0.5 )
colour.rgba = texture2D( SAMPLER[0], PS_TEXCOORD.xy );
colour.rgba = texture2D( SAMPLER[1], PS_TEXCOORD.xy );
if( colour.a &= ALPHA_TEST )
gl_FragColor.rgba = colour.rgba;
gl_FragColor.rgba *= TINT_MULT.rgba;
gl_FragColor.rgb += vec3( TINT_ADD.rgb * colour.a );
#if defined( FADE_OUT )
float height = texture2D( ERODE_SAMPLER, FADE_UV.xy ).a;
float erode_val = clamp( ( height - EROSION_MIN ) / EROSION_RANGE, 0.0, 1.0 );
gl_FragColor.rgba = mix( gl_FragColor.rgba, gl_FragColor.rgba * erode_val, EROSION_LERP );
vec3 light = CalculateLightingContribution();
gl_FragColor.rgb *= max( light.rgb, vec3( LIGHT_OVERRIDE, LIGHT_OVERRIDE, LIGHT_OVERRIDE ) );
这里手动实现了一个Alpha testing,并且根据PS_TEXCOORD.z来选择是第一张还是第二张纹理。光照部分使用lightmap,但是lightmap的纹理似乎经过颜色下采样处理,在光照强度低的情况下会在游戏里出现比较明显的阶梯状效果。单单看这里的实现,应该可以排除是shader逻辑复杂导致的卡顿(不过这样一个2D风格的游戏shader能复杂到哪里去呢)。0x19 马儿要吃草 那还可能会在哪里卡呢?我猜想问题很大程度上没有出在GPU,而在于向显卡传输数据的这一过程中。太多太多的游戏有这样的问题了——比如当年Minecraft的VBO惨案。那么下一步就很明确了,在glVertexAttribPointer下断点:还好试了几次,第六个参数pointer的值都是NULL。这说明饥荒还是绑定了VBO的,不像某游戏那样在渲染时一个一个地指定。但是为什么还是慢呢?我把目光投向了另外两个事故高发区:glBufferData/glBufferSubData:问题终于浮出了水面。原来,即使是在游戏刚开始的没有任何动态元素的MODS界面,这个glBufferData仍然会不断地命中。这充分说明饥荒游戏就算画个固定不变的窗口,其所用的窗口坐标缓冲区也是每一帧的时候重新填充的……而且他们用的是glBufferData而不是glBufferSubData……后者从始至终一次也没命中过……我们可以设想一个情景,当屏幕上有几千个活动对象的时候,每一帧都会有几千次的glBufferData的调用重建缓冲区,而绘图指令需要等待数据传输完毕之后才能开始渲染。而进一步的跟踪表明,glBufferData和glDrawArrays的调用是交替进行的。那么,是不是每个对象在绘制的时候,都对应单独的一条glDrawArrays呢?答案是否定的。调用glDrawArrays所指定的大小有几百之多,而且每次都不尽相同。但是有大量对象都是拥有相同的大小的,这说明每个对象也是分开绘制的。因此我们的优化就可以从这个地方入手了——尝试解析glBufferData中数据的规律,看看是否能够减少直接的glBufferData调用,以起到缓解总线压力、减少同步状态的作用。另外如果可能的话减少glDrawArrays的使用(不过没有instanced draw的情况下太难了)。使用API HOOK挂上glGenBuffers, glDeleteBuffers, glBufferData之后,我发现问题比我想的还要糟糕——所有的buffer连同ID在渲染的时候都是即时分配的,用完就删。这能快起来才有鬼呢==(虽然glGenBuffers的实际操作也就是分配个数字而已)那怎么办呢?看来比较有希望的办法就是自己做一个中间层,把Buffer ID的分配,回收都接管起来。我们希望某些buffer在删除的时候不作OpenGL层的删除,仅仅在我们的bitmap表里将其删除。下一次这个buffer再用的时候直接用OpenGL层中内容相同的buffer即可,这样可以避免重新传数据。但是这里有个麻烦事:需要复用缓冲的时候,怎么才能知道是哪一个buffer里的数据和当前要传输的数据是相同的从而复用它呢?这就是我说的“糟糕”的地方。如果原来的游戏程序里Buffer ID是事先分配好并绑定到具体对象的,这个映射关系就不需要我们来维护了。而现在每一帧的Buffer ID都是重新分配的,从逻辑上讲前后两帧的ID之间并无实际联系,那我们怎么才能迅速定位到需要的buffer呢?更进一步,由于我们不知道一份数据是否会被之后的访问用到,所以每个opengl buffer都需要一段时间无访问后及时释放,以避免对象泄漏。0x1A 有简单的复用办法吗?目前的结论比较杯具,但是也得慢慢来。一个简单的想法是,所有的buffers都不删除,然后模仿OpenGL的Buffer ID分配策略(看了下在我的机子上是栈式的回收策略)制作一个中间层。这样,如果渲染流程比较理想和稳定,前后两帧之间相同的对象应该差不多能分配到相同ID的buffers,这时候只需要比较下新来的数据和原本buffer里存的是否相同就可以决定是否要提交更改:void _stdcall Proxy_glBufferData(int target, int size, const void *data, int usage) {
if (currentBufferID &= bufferData.size()) {
bufferData.resize(currentBufferID * 2 + 1);
CachedBuffer& buffer = bufferData[currentBufferID];
int orgTarget = buffer.target;
std::string& lastData = buffer.data;
int lastSize = lastData.size();
bool canCache = orgTarget == target && data != NULL && size == lastSize && memcmp(data, lastData.c_str(), size) == 0;
// printf("%s\n", canCache ? "CACHE!" : "LOAD!");
if (!canCache) {
((PFNGLBUFFERDATAARBPROC)(PROC)hook_glBufferData)(target, size, data, usage);
buffer.target = target;
if (data == NULL) {
lastData.clear();
lastData.assign((const char*)data, size);
但是简单试了下发现问题非常大。实际的情况是相同的对象在两次渲染之中基本不可能分配到相同的buffer id,跑了一下cache命中率只有5%-10%,这就没什么搞头了。0x1B 重新实现ID分配器 那么,既然buffer生成的顺序不固定,那么根据数据HASH而不是固有的buffer id来区分不同的buffer成了唯一的路。这个想法更复杂也更容易实现得比较低效,反而有可能还不如什么都不做。不过总得试试看吧。首先要实现一个自己的中间层ID分配器,对于游戏程序而言,中间层ID可以当作是OpenGL Buffer ID来使用,但是我们内部还需要管理中间层ID到真正OpenGL Buffer ID:struct BufferID {
BufferID() : glBufferID(0), nextFreeIndex(0) {}
int glBufferID;
int nextFreeIndex;
unsigned int currentFreeIndex = 0; // 0 is reserved
void _stdcall Proxy_glGenBuffers(int n, unsigned int* buffers) {
while (n-- & 0) {
if (currentFreeIndex != 0) {
unsigned int p = currentFreeIndex;
*buffers++ = p;
currentFreeIndex = bufferIDs[p].nextFreeIndex;
bufferIDs[p].nextFreeIndex = 0;
// generate new one
*buffers++ = bufferIDs.size();
bufferIDs.push_back(BufferID());
void _stdcall Proxy_glDeleteBuffers(int n, const unsigned int* buffers) {
for (int i = 0; i & n; i++) {
unsigned int id = buffers[i];
bufferIDs[id].nextFreeIndex = currentFreeIndex;
currentFreeIndex = id;
// ((PFNGLDELETEBUFFERSARBPROC)(PROC)hook_glDeleteBuffers)(n, buffers);
delete buffers的时候通过nextFreeIndex这个域形成一个链表,可以方便下一次的分配。glBufferID是对应于关联底层OPENGL BUFFER ID的,在这里我们不在GenBuffers的时候就直接生成OpenGL BufferID,以便于我们在glBufferData时从缓存中选择一个选择的OpenGL BufferID与之关联。0x1C 缓存的实现 unsigned int currentBufferID = 0;
struct BufferRef {
BufferRef() : ref(0), nextFreeIndex(0) {}
int nextFreeIndex;
std::tr1::unordered_map&std::string, unsigned int&::iterator iterator;
std::vector&BufferRef& bufferRefs;
unsigned int currentFreeBufferRef = 0;
std::tr1::unordered_map&std::string, unsigned int& mapDataToBufferID;
void _stdcall Proxy_glBindBuffer(int target, unsigned int buffer) {
currentBufferID = buffer;
((PFNGLBINDBUFFERARBPROC)(PROC)hook_glBindBuffer)(target, bufferIDs[currentBufferID].glBufferID);
void _stdcall Proxy_glBufferData(int target, int size, const void *data, int usage) {
std::string content((const char*)data, size);
content.append(std::string((const char*)&target, sizeof(target)));
std::tr1::unordered_map&std::string, unsigned int&::iterator p = mapDataToBufferID.find(content);
if (p != mapDataToBufferID.end()) {
unsigned int id = p-&second;
((PFNGLBINDBUFFERARBPROC)(PROC)hook_glBindBuffer)(target, id);
CheckRef(id);
// printf("REUSE!!!! %d\n", id);
// Allocate gl buffer id
unsigned int id = 0;
if (currentFreeBufferRef != 0) {
id = currentFreeBufferRef;
currentFreeBufferRef = bufferRefs[id].nextFreeIndex;
((PFNGLGENB}

我要回帖

更多关于 光执事 伊泽瑞尔 的文章

更多推荐

版权声明:文章内容来源于网络,版权归原作者所有,如有侵权请点击这里与我们联系,我们将及时删除。

点击添加站长微信