
dconstruct 是一款用于《最后生还者 第二部》DC-Script 文件的逆向工程工具。它包含反汇编器和反编译器。
工具输出 .asm 文件(包含反汇编出的结构与字节码)以及 .dcpl(DC 伪语言)文件(包含类似 C 语言风格的伪代码)。
你也可以通过命令行对文件进行编辑,包括轻松替换整个结构。这使得创建仅需修改 .bin 文件中少量数值的 mod 变得极其简单。
-e 标志进行编辑,生成可用于 mod 的新文件。首先,建议将解压后的 dconstruct 目录移动到一个安全的位置,例如 C:\Program Files。
为尽可能方便使用 dconstruct,建议将 dconstruct 文件夹内的 .\bin 目录添加到你的 PATH 环境变量中。更多信息请参考这里,或按照以下快速步骤操作:
.\bin 目录的路径。路径应类似这样:
确保路径以“\bin”结尾,而不是“\dconstruct”。
在所有打开的对话框中点击“确定”。
要验证是否成功,请打开一个新的命令提示符窗口,输入 dconstruct --about。你应看到程序输出一些信息,且没有错误消息。
在命令行中运行如下命令,以生成你的第一个反汇编文件:```shell dconstruct my_bin_file.bin
这将输出一个名为 `my_bin_file.bin.asm` 的文件,位于输入文件所在目录。然后你可以用文本/代码编辑器打开该文件。我推荐使用诸如 VSCode 这类具有高级搜索功能且擅长处理大文件的编辑器。不推荐使用标准 Windows 记事本。
要反编译文件,运行命令时加上 `--decompile` 标志。
# 命令行参数
- `-i` - 输入文件或文件夹。如果第一个参数传递了输入路径,则可以省略。
- `-o` - 输出路径。如果输入路径是文件夹,则此项不能是文件。如果未指定输出,.txt 文件将放在输入文件旁边。如果输入是文件夹且未指定输出,程序将在当前工作目录下创建 "output" 目录,并将所有文件放入其中。
- sidbase 从与可执行文件相邻的 `sidbase.bin` 加载。
- `--no_decompile` - 不将反编译伪代码输出到 .dcpl 文件。该文件将放在 .asm 文件旁边。默认值为 false。
- `--no_optimize` - 不优化和清理 dcpl 代码。包括内联函数调用、移除未使用的变量、将兼容的 for 循环转换为 foreach 循环,以及将某些 if-else 链转换为 match 表达式。
- `--pascal_case` - 将游戏函数名称转换为 dcpl 输出中的帕斯卡命名风格,例如 get-boolean -> GetBoolean。
- `--graphs` - 生成包含所有反编译函数的控制流图的 .svg 文件。每个 .bin 文件拥有自己的文件夹,包含其所有图形。这会**显著**降低反编译速度,因此不建议同时反编译大量文件时使用。
- `--emit_once` - 禁止同一结构在反汇编中出现两次。如果一个结构多次出现,仅第一个实例会被完整输出,其他所有出现位置将被替换为 `ALREADY_EMITTED` 标签。这可以显著减小文件大小。
- `-e` - 进行编辑。更多信息见下方章节。
- `--edit_file` - 提供编辑文件。编辑文件每行包含一个编辑操作。它使用与 -e 标志相同的语法。
# 什么是反汇编器?
[反汇编器](https://en.wikipedia.org/wiki/Disassembler)是一种读取二进制指令(即[字节码](https://en.wikipedia.org/wiki/Bytecode)或[机器码](https://en.wikipedia.org/wiki/Machine_code))并将其逐条转换为人类可读版本(称为[助记符](https://en.wikipedia.org/wiki/Assembly_language#Mnemonics))的工具。反汇编器一般不会尝试解释这些指令的太多含义,只是将它们一对一转换为可读版本。例如,指令:```arm
15 00 00 00
4A 01 01 00
43 31 01 00
1C 00 00 01
被反汇编成以下人类可读版本:```arm LookupPointer r0, 0 LoadStaticU64Imm r1, 1 Move r49, r1 CallFf r0, r0, 1
字节码中的所有数字均以[十六进制](https://en.wikipedia.org/wiki/hexadecimal)表示。每行中的第一列代表`opcode`,即要执行的指令类型。下一列是目标寄存器,操作的结果将存储在该寄存器中。最后两列是操作数1和2,它们可以是寄存器或字面数值,操作将对这些数值执行。并非所有指令都使用全部4个字节,例如,第一条`LookupPointer`指令仅需要一个操作数。
dconstruct反汇编器还会添加一些额外信息,旨在使指令的阅读更加容易。它还会插入标签(例如`L_0`)以使代码中的分支更易于跟踪。```arm
15 00 00 00 LookupPointer r0, 0 r0 = ST[0] -> <is-player-abby?>
4A 01 01 00 LoadStaticU64Imm r1, 1 r1 = ST[1] -> <player>
43 31 01 00 Move r49, r1 r49 = player
1C 00 00 01 CallFf r0, r0, 1 r0 = is-player-abby?(player)
2F 0D 00 00 BranchIfNot r0, 0xD IF NOT r0 => L_0
当你希望查看文件的原始内容而不让程序进行过多猜测时,这很有用。但对于大块代码来说,由于完全没有结构,读起来可能会很困难。这时候就需要反编译器了。
反编译器是编译器的逆过程。编译器 是一种接收人类编写的代码(如 C、Java、C++ 等)并生成机器指令的程序。以《最后生还者 第二部》和许多其他顽皮狗游戏为例,所使用的脚本语言称为 'DC',它基本上是编程语言 Racket>) 的一个变体,而“机器”就是游戏本身,在运行游戏时执行这些指令。本质上,程序员编写 DC 代码,然后使用编译器将其转换为随游戏一起发布的 .bin 文件。
反编译器 接收上述反汇编代码,并生成所谓的伪代码。伪代码是_尝试_重建用于生成原始指令的原始源代码。这意味着使代码的理解变得更加容易,然而,生成伪代码的过程相当复杂,因为有许多不同版本的源代码可以生成最终的字节码,此外还有编译过程中发生的优化。
目前,dconstruct 反编译器的输出在语法上_不_类似于原始的 DC。DC(即 Racket)是一种函数式编程语言,具有独特的语法,对于不习惯它的程序员来说简直难以辨认。因此,我目前选择让伪代码更像 C 语言,这样对大多数人来说应该更容易阅读。不过,计划创建更多语法,包括 Racket 以及 Python 版本。
43 00 31 00 15 01 00 00 43 02 00 00 43 31 02 00 1B 01 01 01 43 02 00 00 40 03 01 00 24 02 02 03 2F 0B 02 00 40 02 02 00 2D 0C 00 00 40 02 03 00 43 03 02 00 15 04 04 00 43 05 01 00 43 31 05 00 1C 04 04 01 07 03 03 04 43 01 03 00 00 01 01 00
由此看来,几乎不可能看出代码在做什么。
## 带有标签和符号表的反汇编代码```arm
sqrt-sign = script-lambda [0x9A8D8] {
[1 args]
0000 0x09A928 43 00 31 00 Move r0, r49 r0 = arg_0
0001 0x09A930 15 01 00 00 LookupPointer r1, 0 r1 = ST[0] -> <absf>
0002 0x09A938 43 02 00 00 Move r2, r0 r2 = arg_0
0003 0x09A940 43 31 02 00 Move r49, r2 r49 = arg_0
0004 0x09A948 1B 01 01 01 Call r1, r1, 1 r1 = absf(arg_0)
0005 0x09A950 43 02 00 00 Move r2, r0 r2 = arg_0
0006 0x09A958 40 03 01 00 LoadStaticFloatImm r3, 1 r3 = ST[1] -> <0.000000>
0007 0x09A960 24 02 02 03 FGreaterThanEqual r2, r2, r3 r2 = r2 >= r3
0008 0x09A968 2F 0B 02 00 BranchIfNot r2, 0xB IF NOT r2 => L_0
0009 0x09A970 40 02 02 00 LoadStaticFloatImm r2, 2 r2 = ST[2] -> <1.000000>
000A 0x09A978 2D 0C 00 00 Branch 0xC GOTO => L_1
L_0:
000B 0x09A980 40 02 03 00 LoadStaticFloatImm r2, 3 r2 = ST[3] -> <-1.000000>
L_1:
000C 0x09A988 43 03 02 00 Move r3, r2 r3 = -1.000000
000D 0x09A990 15 04 04 00 LookupPointer r4, 4 r4 = ST[4] -> <sqrt>
000E 0x09A998 43 05 01 00 Move r5, r1 r5 = RET_absf
000F 0x09A9A0 43 31 05 00 Move r49, r5 r49 = RET_absf
0010 0x09A9A8 1C 04 04 01 CallFf r4, r4, 1 r4 = sqrt(RET_absf)
0011 0x09A9B0 07 03 03 04 FMul r3, r3, r4 -1.000000 = -1.000000 * RET_sqrt
0012 0x09A9B8 43 01 03 00 Move r1, r3 r1 = -1.000000
0013 0x09A9C0 00 01 01 00 Return r1 Return
SYMBOL TABLE:
0000 0x09A9C8 function: absf
0001 0x09A9D0 float: 0.000000
0002 0x09A9D8 float: 1.000000
0003 0x09A9E0 float: -1.000000
0004 0x09A9E8 function: sqrt
}
代码现在可读性大大提高,但即使只有单一分支,对于不习惯阅读汇编的人来说,读起来仍然很烦人。
这里不过多赘述,控制流图(CFG) 沿各种分支指令将汇编代码分割成"节点"。在我们分析代码以找出程序"流"可能在何处分叉到不同路径时,这至关重要,可能需要我们生成变量、if语句、for循环等。这些图需要在后台生成,但你可以使用 --graphs 程序标志将它们打印成图像。
u64? sqrt-sign(f32 arg_0) { f32 var_1; if (arg_0 >= 0.00) { var_1 = 1.00; } else { var_1 = -1.00; } return var_1 * sqrt(absf(arg_0)); }
该函数的目的现在非常明确:我们取参数的绝对值,计算其平方根,然后将结果乘以参数的原始符号。例如,`sqrt-sign(-9) = -3`。
## 优化遍次
dconstruct 会自动对伪代码应用优化遍次。以下是一些示例:
### 函数调用内联