数值的秘密 | 游戏逆向笔记002

L**t W*r逆向分析

Posted by lxraa on December 08, 2025 · 13 mins read

📖 本文属于系列:游戏逆向笔记

查看系列目录 | 关于我

【游戏逆向笔记】 是 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/           # 本地化文本
└── ...

APK目录结构

典型的Unity + Lua混合架构。

我先看了眼libil2cpp.so,1.75MB,不应该这么小。

正常流程是用Il2CppDumper导出符号,再用IDA分析C#逻辑。

但我思考了一下。

这种SLG手游,核心数值逻辑一般不在C#层。C#做框架和底层,Lua做业务和配置,这是比较常见的架构。

而且从文件大小来看,1.75MB的so文件,大概率只是个loader。

先看Lua,IL2CPP的部分以后再说。


四、找Lua

Lua脚本一般有两种存储方式:

  1. 明文.lua文件(少见,容易被分析)
  2. 编译后的.luac字节码(常见)

我在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的字节码。

Lua字节码文件头

原来所有数据表都打包在这一个ZIP里,997个.luac文件。

但这只是数据表部分,游戏逻辑代码应该还有别的地方。


五、Frida Hook

静态找不到,那就动态提取。

我写了个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

现在问题变成了:怎么把这些字节码反编译回源码?


六、unluac改造

标准的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的逻辑,写一个简化版的加载器。

我的实现思路:

  1. 用Lua写加载器lua_datatable_loader.lua
    • 实现LocalController的核心逻辑(加载index、data、vExt)
    • 支持字段解析和vExt字符串池展开
    • 提供CSV导出功能
  2. 用Python写批量调度工具export_tables_to_csv.py
    • 遍历所有Lua表文件
    • 调用tools\lua.exe执行Lua加载器
    • 批量导出CSV

我把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.bin
  • en_US.bin
  • ar.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条中文文本)


九、CSV查看器

现在我有了:

  • 数据表
  • 本地化字典

但有个问题:怎么快速找到对应的表?

比如,我在游戏里看到”第1天:雷达特训”,我想知道这个活动的配置在哪个表里。

如果手动翻805个表,得翻到猴年马月。

我需要一个工具:

  1. 输入中文文案,反查出是哪个本地化key
  2. 搜索这个key在哪些CSV表里出现
  3. 直接打开对应的表,定位到具体位置

我把需求描述清楚,让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