【游戏逆向笔记】 是 lxraa 的技术分享系列,记录”我”习得老爹的黑客技艺后,在游戏逆向领域的探索笔记,基于真实游戏逆向分析过程的半虚构日记式写作。
(友情提示)读完之后,你可能一时半会儿都不太想单纯「打游戏」了。
麻将馆
最近老爹有点烦躁。
那天晚上吃饭的时候,他点了根烟,说:”我那麻将局散了。”
老妈愣了一下:”散了?不是打了快十年了吗?”
“老李不来了。”老爹说。
我抬起头。老爹的麻将局我知道——每周三晚上雷打不动,固定四个人:老爹、老王、老刘,还有老李。从我初中开始就这样,十点半准时回来,一身烟味。
我妈问:”老李怎么了?”
老爹说,老李最近玩游戏上瘾了。
老李这人,五十多岁,退休工人,以前在通州一家机械厂当车工。厂子倒闭后拿了一笔补偿金,就在附近买了套老破小。
他没什么爱好,不喝酒,不抽烟,就喜欢打麻将。
老爹说,老李打麻将的风格很稳:不贪大牌,不冒险,能胡就胡,输赢都不大。
“跟他打牌挺舒服的,不会有那种大起大落的感觉。”
但两个月前,老李突然变了。
第一次是在麻将桌上。
老王摸完牌,等老李出牌,叫了两声没人应。
转头一看,老李正低着头玩手机,眉头紧锁,嘴里还嘟囔着什么”差两千木头”、”英雄升不了级”之类的话。
“老李!该你了!”老王拍了拍桌子。
老李这才回过神,慌忙打了张牌,结果一看,点炮了。
“你今天怎么回事?魂儿都不在了。”老刘笑着说。
老李挠挠头,不好意思地说:”哎,玩了个游戏,有点上头。”
“什么游戏?”老王好奇地凑过去。
老李把手机屏幕转过来,上面是一堆僵尸、坦克、还有一个写着大数字的发光门。
“L**t W*r,末日求生的。”老李说,”可好玩了,打僵尸,建基地,还能升级英雄。”
老爹当时瞥了一眼,没多说什么。
但接下来几周,老李在麻将桌上的心思越来越不在牌上。
要么摸牌慢半拍,要么打错牌,甚至有一次直接站起来说:”不好意思啊兄弟们,我基地被人攻击了,得赶紧回去处理一下。”
说完拿着手机就往厕所跑。
那一局,三个人在桌上干等了十五分钟。
老王气得直摇头:”这老李,被游戏绑架了。”
又过了两周,老李干脆不来了。
老王打电话问他,他说:”哎呀,最近游戏里有个活动,得攒资源,没时间打麻将。”
老爹讲完,掐了烟。
我夹了口菜,心里想着那个游戏。L**t W*r,末日求生,数字门,打僵尸。
能让老李放弃打了十年的麻将局,这游戏背后应该有点东西。
任务名称:L**t W*r数值分析 起因:老爹的麻将局岌岌可危
目标:分析L**t W*r数值设置
紧急程度:中等偏高(老爹已经开始考虑要不要三缺一了)
预计耗时:两周(包括我自己差点沦陷的时间)

那天晚上吃完饭,我回到房间,躺在床上刷B站。
广告弹出来了。
不是那种五秒可跳过的普通广告,而是信息流里的”内容推荐”——画面里,一群小人拿着枪往前冲,前面有个发光的门,门上写着巨大的数字。
小人冲过门的瞬间,人数暴涨——从几十人,变成密密麻麻一大片,然后一股脑冲向僵尸群。
这不就是老爹说的那个游戏?
虽然心里闪过一丝”手机是不是在偷听”的诡异感,但也没多想——这游戏最近广告铺得到处都是,刷到很正常。
我又往下刷了几条,结果接下来又是两条同样的广告。
算法很执着。
我看了好几遍,逐渐理解了玩法核心:
部队通过门时,根据门上的数字进行加减乘除运算,人数暴涨或暴跌。玩家要做的,就是选择合适的路线,让部队数量最大化,然后碾压敌人。
看着确实挺爽的。
我顺手下载了一个,想看看是什么东西能让老李放弃打了十年的麻将局。
我低估了这游戏的”粘性”。
本来只是想随便玩玩,结果一玩就是三个小时。
这游戏的节奏设计得很巧妙:
就这样,一关接一关,根本停不下来。
最让我印象深刻的,是那个”数字门”的瞬间。
当你的部队从 500 人通过一个”×3”的门,变成 1500 人的时候,那种视觉冲击和心理满足感非常强烈。
你会觉得:”我变强了。”
尽管这只是个数字的变化,尽管下一关这个数字会清零重来,但在那一瞬间,你确实会有一种”爽”的感觉。
我放下手机,看了眼时间:凌晨两点。
然后我突然意识到一个问题:
这游戏为什么这么容易让人上瘾?
不只是我,老李也是,甚至愿意为此放弃打了十年的麻将局。
这背后,肯定有什么机制在起作用。
第二天早上,我把APK下载下来,丢进工作目录。
标准流程:解包、看结构、找入口。
apk/
├── lib/arm64-v8a/
│ ├── libil2cpp.so # Unity IL2CPP
│ └── libxlua.so # Lua引擎
├── assets/
│ ├── bin/Data/ # Unity资源
│ ├── table/ # 数据表?
│ └── locale/ # 本地化文本
└── ...

典型的Unity + Lua混合架构。
我先看了眼libil2cpp.so,1.75MB,不应该这么小。
正常流程是用Il2CppDumper导出符号,再用IDA分析C#逻辑。
但我思考了一下。
这种SLG手游,核心数值逻辑一般不在C#层。C#做框架和底层,Lua做业务和配置,这是比较常见的架构。
而且从文件大小来看,1.75MB的so文件,大概率只是个loader。
先看Lua,IL2CPP的部分以后再说。
Lua脚本一般有两种存储方式:
我在assets目录下翻了一圈,没找到明显的lua文件。
但注意到一个可疑的文件:assets/table/table_27151_4f2c4fb3e712c5a49d5f71e2f2bb51f5.data

11MB左右,看起来是个打包文件。
我用十六进制编辑器看了下文件头:
50 4B 03 04 14 00 00 00
ZIP格式。
改个后缀名,解压:
table/
├── lw_hero
├── lw_hero_skill
├── lw_building
├── activity_blue_shop
├── ...
└── (997个文件,没有扩展名)
这些文件没有扩展名,但看magic number就知道是什么:
1B 4C 75 61 53
Lua 5.3的字节码。

原来所有数据表都打包在这一个ZIP里,997个.luac文件。
但这只是数据表部分,游戏逻辑代码应该还有别的地方。
静态找不到,那就动态提取。
我写了个Frida脚本,Hook libxlua.so的Lua脚本加载函数,在运行时把所有加载的字节码dump出来。
关键逻辑:
客户端(TypeScript):
// Hook luaL_loadbufferx函数
Interceptor.attach(p_luaL_loadbufferx, {
onEnter: function(args) {
const buff = args[1]; // 脚本内容
const size = args[2].toInt32(); // 大小
const name = args[3]; // 脚本名
const script_name = name.readCString() || "unknown";
if (size > 0 && size < 10 * 1024 * 1024) {
const script_data = buff.readByteArray(size);
// 检查是否是lua字节码 (0x1b 0x4c 0x75 0x61)
const header = new Uint8Array(script_data.slice(0, 4));
const is_bytecode = header[0] === 0x1b && header[1] === 0x4c;
// 发送到Python端保存
send({
type: 'LuaScript',
name: script_name,
size: size,
is_bytecode: is_bytecode
}, script_data);
}
}
});
服务端(Python):
case "LuaScript":
script_name = sanitize_filename(payload['name'])
is_bytecode = payload.get('is_bytecode', False)
ext = '.luac' if is_bytecode else '.lua'
filename = f"{lua_script_count:04d}_{script_name}{ext}"
lua_script_count += 1
save_file(f"./lua_scripts/{filename}", data)
启动游戏,挂上Frida,等待。
脚本开始疯狂输出:
[*] luaL_loadbufferx 被调用
name: @Common.Main
size: 15234 bytes
类型: Lua字节码(luac)
[√] 已dump脚本: @Common.Main
[*] luaL_loadbufferx 被调用
name: @LuaLogic.Hero.HeroController
size: 45678 bytes
类型: Lua字节码(luac)
[√] 已dump脚本: @LuaLogic.Hero.HeroController
...
最终提取了2371个.luac文件。
看文件名,确实有游戏逻辑:
HeroController.luac - 英雄管理BattleManager.luac - 战斗管理ShopController.luac - 商店逻辑还有71个数据表文件:
@LuaDatatable.lw_hero.luac@LuaDatatable.lw_hero_skill.luac现在问题变成了:怎么把这些字节码反编译回源码?
标准的Lua反编译工具是unluac。我试了一下:
java -jar unluac.jar 0002_@Common.Main.luac
报错:
IllegalStateException: The input chunk reports a non-standard lua format: 1
又是自定义格式。
我用十六进制编辑器对比了标准Lua 5.3和Last War的字节码header:
标准版:
0x00-0x03: 1B 4C 75 61 (魔数)
0x04: 53 (版本 5.3)
0x05: 00 (format = 0)
...
Last War版:
0x00-0x03: 1B 4C 75 61
0x04: 53
0x05: 01 (format = 1) ← 改了这里
...
继续往下看,发现他们还调整了header字段的顺序,删掉了sizeof(lua_Number)字段。
典型的反逆向保护:改个format标志,让标准工具识别不了。
不过这个保护强度很低,改一下unluac源码就能绕过。
我找到unluac的源码,修改了LHeaderType.java:
// 修改1:支持format=1
if(format != 0 && format != 1) {
throw new IllegalStateException("...");
}
// 修改2:根据format调整字段解析顺序
if(s.format == 1) {
// Last War修改版
parse_instruction_size(buffer, header, s);
parse_integer_size(buffer, header, s);
parse_size_t_size(buffer, header, s);
s.lFloatSize = 8; // 使用默认值
} else {
// 标准版
parse_size_t_size(buffer, header, s);
parse_instruction_size(buffer, header, s);
parse_integer_size(buffer, header, s);
parse_float_size(buffer, header, s);
}
重新编译,再试:
java -jar unluac.jar 0002_@Common.Main.luac
成功输出完整的Lua源码。
我写了个批量反编译脚本,跑了一遍:
Total: 2371 files
Success: 2356 files (99.4%)
Failed: 15 files (0.6%)
失败的15个文件应该是损坏或者特殊格式,不影响大局。
现在我有Lua源文件了。
相关资源:
反编译出来的数据表文件,内容是这样的:
local L0_1, L1_1, L2_1, L3_1, L4_1
L0_1 = {}
-- 表结构定义(index)
L1_1 = {}
L2_1 = {}
L3_1 = 1 -- 第1列
L4_1 = "number" -- 类型是number
L2_1[1] = L3_1
L2_1[2] = L4_1
L1_1.id = L2_1 -- 字段名:id
L2_1 = {}
L3_1 = 2 -- 第2列
L4_1 = "number"
L2_1[1] = L3_1
L2_1[2] = L4_1
L1_1.switch_type = L2_1 -- 字段名:switch_type
...
L0_1.index = L1_1 -- 保存表结构
-- 实际数据(data)
L1_1 = {}
L2_1 = {}
L3_1 = 101 -- 第1列数据
L4_1 = 1 -- 第2列数据
L2_1[1] = L3_1
L2_1[2] = L4_1
L1_1[101] = L2_1 -- 一行数据,ID=101
L2_1 = {}
L3_1 = 102
L4_1 = 1
L2_1[1] = L3_1
L2_1[2] = L4_1
L1_1[102] = L2_1 -- 一行数据,ID=102
...
L0_1.data = L1_1 -- 保存数据
-- 字符串池(vExt,有些表有,有些表没有)
L1_1 = {}
L2_1 = "hero_name_001"
L3_1 = "hero_desc_001"
L1_1[1] = L2_1
L1_1[2] = L3_1
L0_1.vExt = L1_1 -- 保存字符串池
return L0_1
结构很清晰:
index:表结构定义(字段名、列索引、数据类型)data:实际数据(每行是一个table,以ID为key)vExt:字符串池(用来减小体积,数据中存索引而不是完整字符串)这种格式人看不懂,需要转成CSV才能分析。
但转换不是简单的格式转换。好消息是这些Lua表没有外部依赖,可以直接用Lua VM加载;坏消息是游戏用LocalController读取这些表,而LocalController有外部依赖,不能直接运行。
解决方案:参照原版LocalController的逻辑,写一个简化版的加载器。
我的实现思路:
lua_datatable_loader.lua)
export_tables_to_csv.py)
tools\lua.exe执行Lua加载器我把LocalController的关键逻辑喂给AI,让它按照这个思路生成代码。调试几轮后,跑起来了:
python export_tables_to_csv.py all
数据表批量导出工具 (优化版)
============================================================
[*] 找到 805 个数据表
进度: [1/805]
[*] 正在导出表: ABtest_controller
[...] 正在导出...
[+] 完成: 7 行已导出到 re-code\datatable\ABtest_controller.csv
进度: [2/805]
[*] 正在导出表: ai_switch
[...] 正在导出...
[+] 完成: 12 行已导出到 re-code\datatable\ai_switch.csv
...
进度: [805/805]
[*] 正在导出表: zombie_talent
[...] 正在导出...
[+] 完成: 43 行已导出到 re-code\datatable\zombie_talent.csv
============================================================
导出完成!
============================================================
成功: 805/805
805个CSV文件,一个不少。
随手打开一个看看:
id,switch_type,switch_title,switch_des
101,1,900517,900524
102,1,900518,900524
103,1,900519,900524
...
表头清晰,数据整齐,完美。
完整数据表下载:all_datatable_csv.zip (805个CSV文件)
完整的导出工具代码:
CSV表里有些字段是这样的:
name: revival_plan_phasename01
desc: revival_plan_phase01_explain01
这些是本地化key,需要转换成中文才能看懂。
我找到本地化文件的位置:apk/assets/locale/22299/
里面有好几个.bin文件:
zh_CN.binen_US.binar.bin又是自定义格式。
我看了下二进制结构,推测是某种key-value存储,但具体格式不知道。
我又找到了加载本地化的Lua代码,理解了格式:
header (固定字节)
key_count (int32)
for i=1 to key_count:
key_length (int16)
key_string (utf8)
value_length (int16)
value_string (utf8)
我把这段逻辑描述给AI,让它写一个二进制转JSON的脚本。
10分钟后,脚本写好了,跑一遍:
python convert_locale.py zh_CN.bin zh_CN.json
生成了一个45484行的JSON文件:
{
"revival_plan_phasename01": "第1天:雷达特训",
"revival_plan_phase01_explain01": "完成指定任务获得奖励",
...
}
本地化文件下载:locale.zip (45484条中文文本)
现在我有了:
但有个问题:怎么快速找到对应的表?
比如,我在游戏里看到”第1天:雷达特训”,我想知道这个活动的配置在哪个表里。
如果手动翻805个表,得翻到猴年马月。
我需要一个工具:
我把需求描述清楚,让AI写一个GUI工具。几轮对话后,一个完整的CSV查看器就写好了:
功能:
✓ 打开CSV文件,自动识别编码
✓ 本地化转换(key → 中文)
✓ 反向搜索(中文 → 定位表和位置)
✓ 导出Excel
✓ 列排序、筛选
我测试了一下:
搜索:"第1天:雷达特训"
↓
找到key: revival_plan_phasename01
↓
找到表: revival_plan.csv, 第3行
↓
双击打开,自动定位
完整的CSV查看器工具:csv_viewer.zip
现在终于到了核心环节:数值分析。
我打开heroes_levelup.csv,这是英雄升级表:
id,exp,army_num,lv_attr_atk1,...,spend,break_require
1,15,30,15,...,,
2,20,40,37,...,,
3,30,45,58,...,,
...
10,300,80,210,...,500,1
11,350,85,233,...,,
...
20,2300,130,435,...,2000,1
...
30,6400,180,675,...,4000,1
...
40,10700,230,870,...,10000,1
...
我花了两天时间,查阅了一些行为心理学和游戏化设计的资料,分析这套数值背后的机制。
1. 变量比率强化(Variable Ratio Reinforcement)
升级所需经验的增长规律:
Lv 1-9: 每级增加 +10~50 exp(线性增长)
Lv 10: 突然跳到 300 exp("突破"概念)
Lv 11-19: 继续增长 +50~400 exp
Lv 20: 跳到 2300 exp(第二个"突破")
Lv 30: 6400 exp
Lv 40: 10700 exp
前期升级很快,给玩家即时反馈;中期开始放缓,产生”差一点就到下一级”的状态;后期设置”突破”关卡,制造质变感。
这是斯金纳箱的经典应用:不可预测的奖励间隔。
2. 禀赋效应(Endowment Effect)
每次升级,属性全面提升:
Lv 1: 攻击 15, 部队数 30
Lv 10: 攻击 210, 部队数 80 (+1400% / +267%)
Lv 40: 攻击 870, 部队数 230 (+5800% / +767%)
投入的时间越多,角色越强,沉没成本越高。升到30级后,弃坑的心理成本远高于继续玩的成本。
3. 峰终定律(Peak-End Rule)
每10级设置一个”突破”关卡:
Lv 10: 需要消耗 500 "spend"
Lv 20: 需要消耗 2000
Lv 30: 需要消耗 4000
Lv 40: 需要消耗 10000
“突破”时的属性飙升和视觉特效,制造峰值体验。这个瞬间会被记忆系统强化,让玩家期待下一个”突破”。
4. 蔡格尼克效应(Zeigarnik Effect)
升级节奏设计:
前期:经验容易获取,快速升级
中期:经验变慢,但"突破"在眼前
后期:经验获取困难,但已经投入太多
“差一点就到下一级”的状态,会让大脑一直记挂着这件事。”再玩一会儿,马上就到40级了”,然后不知不觉玩到凌晨。
每一个数字背后,都有对应的心理学依据。
这套系统的目标很明确:让玩家维持在”接近目标但未达成”的状态,持续产生多巴胺。
需要说明的是:以上分析只是根据数值规律的推测,我们并不知道策划的真实意图。也许只是碰巧设计成了这样。
两周后,老爹的麻将局又开始了。
我听说老李回归了麻将桌。
具体原因是:上周日下午,老李在家玩游戏,他老婆让他出门买包盐。老李嘴上答应了,手没停,眼睛盯着屏幕。半小时后,他老婆在厨房等不到盐,出来一看,老李还坐在沙发上。
他老婆把手机拿过来,当着他面卸载了游戏,问他:”你是想玩游戏,还是想过日子?”
第二天晚上,老爹回来的时候心情不错。
“老李回来了?”我问。
“嗯,输了两百多。”老爹点了根烟。
我没说话。
老爹看了我一眼:”你最近也在玩吧?”
“玩过。”
“玩明白了?”
“差不多。”
老爹弹了弹烟灰,没再说什么。
任务状态卡片
任务名称:L**t W*r数值分析 起因:老爹的麻将局岌岌可危 目标:分析L**t W*r数值设置 紧急程度:中等偏高(老爹已经开始考虑要不要三缺一了) 预计耗时:两周(包括我自己差点沦陷的时间) 实际耗时:两周 任务状态:✓ 已完成(问题在其他模块得到解决)
工具/脚本
逆向产物
代码会说真话。
本文为基于真实技术分析的虚构故事,文中截图、图片如有展示,均作技术示意,与具体厂商和项目无直接指向关系。
本文仅供学习研究使用,请勿用于非法用途。
未经授权 禁止转载。
版权所有 © lxraa 2025