咳。鉴于我不想我的博客被 GFW 屏蔽,我会稍微注意一下措辞。

背景

大名鼎鼎的 Liquid 社,不知道的请自行了解。他们家的 Galgame(似乎)都是用 LC-Script Engine 做的(就我目前接触到的他们社的游戏而言)。

前阵子搞到了一 L 社生肉资源,用了一整天研究了一下 VNR。但是它这个翻译延迟实在太高了,大概 2s 吧。这严重影响我推 gal 的效率。于是我有了一个主意:提取游戏的文本,然后机翻完之后再放回去!原因是在用 VNR 之后,发现如今的机器翻译其实做得不错(据说以前不太行),勉强能冲。

但是网上的 GalGame 汉化基本就没有教程。一个原因是 Galgame 比较小众,Galgame 汉化更是小众中的小众。而且据说这几年来 Galgame 行业也不景气。不过,毕竟我都写出这博客来了,结果就不用说了。

初期工作篇

首先,当然是直接找这个游戏的汉化版,然而没有找到。

那么就要想办法了解汉化的方法。于是我对着这个游戏的可执行文件lcsebody.exe搜了一通,发现这个游戏是 LC-Script Engine 制作的游戏的标志。但这个 Engine 显然是不开源的。

不过 Github 的神通广大我是妹想到的,真的有一个 LCSELocalizationTools 的项目。这个项目得用 java 运行,作用是解密,并从 LC-Script Engine 打包的文件里解包出一个一个的小文件,比如游戏的脚本,CG 之类的。它也支持打包,也就是说你用这个工具解包后,对解包出来的文件做修改,然后再打包,它就会用你修改过的替换掉原本的,也就实现了你对游戏数据的修改。

然而事情没有这么简单。它不 work。于是我提了个 Issue,然后就联系到了大佬 Charlie。这个大佬似乎做过 LC-Script Engine 的开发,这是我妹想到的。不错,找对人了。

解析文本篇

Charlie 告诉我,LC-Script Engine 主要是异或加密,而我这个游戏的 key 似乎和他测试的那个游戏的 key 不一样。因此改一改项目中的 key 就行了。

就行了……

然后我花了半天的时间,配好了 Kotlin 的开发环境。因为这个项目是 Kotlin 写的。是的,我还得学一波 Kotlin 操作。

不过好在 Kotlin 与 Typescript 很像,除了那个闭包的特性,其他的都挺容易上手。最后,当然是成功了。这样,我就拿到了解包后的数据。鉴于目前的 DeepCreamPy 的去码效果 8 太行,反而容易降低冲的兴致,因此我没有对游戏 CG 做修改,将主要目标转为游戏脚本。

所谓游戏脚本,是指包含了字幕以及选择题逻辑的文本文件。LC-Script Engine 的游戏脚本后缀为 SNX。不过这个 SNX 你用记事本打开,还是乱码。好在 Charlie 说,它的那个项目里有解析 SNX 的模块,但是没有写命令行接口。意思就是说我得自己写一个接口。

为了这波,我只能,真的,去学 Kotlin 了……之前的学,你只需要知道 Kotlin 怎么定义变量,怎么注释,差不多就行了。这次……

一波猛如虎的 Coding 之后,我终于理解了 Charlie 写的代码在干啥,然后写了一个接口,顺利地把 SNX 文件转换为 PO 文件。而 PO 文件,就是可以用记事本编辑的文件了。

有一说一,这个 vscode 真香。当然得配个 vim 插件,不然还是差评。Kotlin 的闭包其实写得蛮爽的,甚至比 js 的箭头函数还简洁。

之后我稍微操作了一下,去百度整了一个 API。别问我为什么百度。国内我只找到百度有免费的翻译。不是指网页版的,网页版的得写爬虫,网上也没有现成的。写了一个 Python 脚本,翻译 PO 文件的内容。然后在稍微润色一下。

在做了一两个句子的翻译后,我把它打包回去,测试了一下,结果发现游戏的对应的这个场景里,没有显示任何文本!乱码都没有……

鉴于 Charlie 是高三学长,时间比较紧张,所以我就,对着他写的模块 debug 了一下,真就发现了一个 bug。修复了一下,果然,有乱码了。

其实很不错了,毕竟有总比没有好。

逆向编程篇

前面的,说实话都是小菜。没怎么卡进度。但是对于一个 OI 人 /Web 开发人,你要我做逆向编程,就比较离谱。

乱码的问题,是因为游戏引擎的问题。我们之前修改的都是游戏的数据文件,游戏引擎(lcsebody.exe)是一根手指都没动的。Charlie 说,乱码问题的解决分两步:

  1. 因为 Galgame 默认你是用日本的系统运行的,因此游戏引擎的编码通常的日文的编码(比如 Shift_JIS)。你得把这个编码改成中文的编码(比如 GB2312)。
  2. 光改编码还不过。游戏引擎通常还会干一件事情:检查你的字符值是否合法。因为对于一种编码方案而言,其字符编码的值域是一个区间。而 Shift_JIS 与 GB2312 的区间显然是不同的。所以你得找到这部分的逻辑判断代码,把它改成 GB2312 的逻辑判断代码。

于是我按照大佬的建议下载了 Ollydbg(OD)用于调试。我熟悉了一下这个软件的操作,然后就操作了一番。但这遇到了一个问题:我这个 Galgame 必须用转区工具打开。也就是说你得用另一个 exe 来启动lcsebody.exe。Ollydbg 支持两种调试方式:

  1. 用 Ollydbg 打开某个 exe。但这个方法我们行不通,因为 Ollydbg 不支持转区。
  2. 用 Ollydbg 去附加在某个正在运行的进程上。这个方法似乎可行。但我只有在第一次附加的时候成功了。其他时候都会导致 Galgame 的进程莫名其妙就结束了。这我怎么调?

我就卡这了。还好还好,只卡了我一天。

在调试的过程中我发现,在看不懂汇编语言的情况下,你根本就不是在 Debug,你是在抽奖,跟买彩票一样,按照网上的博客去操作,但根本操作不了,因为两者的情况大不相同。

因此我决定,先学汇编。

学汇编我花了一些时间学了汇编的基础操作,再结合搜索引擎,终于是看懂了那些常见的指令的含义。

你别看完一句话就说完了。你也可以试试。在不知道有没有用的情况下,去学一门语言。

结果发现,还是有点用的。在学汇编的过程中我也在熟悉 OD 的操作。并一直在搜索“OD 无法附加”之类的词条。功夫不负有心人,在一位知乎大佬的点拨后,我死马当活马医,尝试了一些他的方法,没想到成功了!

直到现在,我才是真正地,能开始做那两个步骤了。

至于后面的步骤,在我熟悉了 OD 的操作后,就几乎比较顺利了。通过暴力查询的方式找到了两个地方,并且多亏我学了会儿汇编。不用反编译就能看懂汇编指令的逻辑,因此顺利完成了这部分的修改。

来一个截图吧

LCSE-rev-prog-1

虽然这个字体粗细有点怪,但总比日文可读多了。

之后大佬也算好人帮到底,讲解了一下字体的修改,帮忙 Debug 了免 LE 启动的方法。最终效果如下:

LCSE-rev-prog-2

有关技术部分的内容在下方,也给出了参考文章。仍有问题的可以评论交流。

总结

这次逆向编程的经历,算是给我打开了一个方向吧。对很多计算机系统方面的知识有了了解。逆向编程整东西,破其一点,就能领悟很多了。当然,其实最大的好处还是,冲!

技术篇

  1. 使用 LCSELocalizationTools,在咨询大佬后得知 LCSE 是异或加密。因此修改异或参数后能够解包 / 封包。
  2. 使用其中配套的模块,实现 SNX 文件解析为 PO 文件。
  3. Python 调用百度翻译 API,然后 Poedit 手动校对翻译。
  4. 翻译好后,封包回去,发现打开是乱码。得知需要修改 LCSE 引擎。
  5. 如何附加lcsebody.exe:用 OD 把入口处的指令修改为 EB FE(死循环),然后转区启动。这时不会有游戏窗口出现(因为卡在入口了),但应该能找到lcsebody.exe的进程。然后用命令行启动 OD,带一个-p的参数,用 pid 来附加就行了(附加进去后记得先改回入口的指令再进行调试。参考)。
  6. 要找到CreateFontIndirectA调用前的字符编码参数,方法是寻找0x80(Shift_JIS)的常量,将其改成0x86(GB2312)。参考
  7. GalGame 一般回检验字符的编码是否在范围内。而不同的编码的范围是不同的,因此要修改这部分的逻辑。方法是寻找0xE0的常量,并对这部分的逻辑做修改。在了解汇编指令的含义后,其实不用反编译就可以理解这部分逻辑,并作出对应的修改。参考 1参考 2参考 3
  8. INIT.snx用 winHex 的 Shift JIS 编码打开,可以找到 MS ゴシック,把 MS 写成黑体即可。注意,你找 MS 的时候用 JIS 编码,编辑黑体的时候要改成 GBK 来写。虽然这样我似乎搞出了宋体?但至少字体不是那种一会儿粗一会儿细的了。
  9. 似乎这是 2djgame 的资源,带了点私货。在游戏根目录加一个2djgame.txt的空文件,就可以免 LE 启动了。

附 汇编语言 学习日志

mov 指令

将某个地址(寄存器)的数据用用另一个(地址的)数据覆盖。

MOV ax, 8
MOV ax, bx
MOV ax, [0]
MOV [0], ax
MOV ds, ax
  • 其中 ds 是段寄存器。
  • [x]是指向 ds:x 的内存单元,其对应的内存地址为 ds 左移 4 位加上 x。更准确地说,它表示一个偏移地址为 x 的内存单元。
  • 段寄存器不能直接用数据 MOV,这是 8086CPU 的硬件问题。
  • 在汇编源代码中,如果[]内是常量,就需要用ds:[]显示地表示指向的内存单元。

ADD(SUB) 指令

将某个寄存器或者地址的值加上另一个数值。类似 C++ 中的+=

ADD ax, 8
ADD ax, bx
ADD ax, [0]
ADD [0], ax
  • ADD 不能直接操作段寄存器。
  • 加法溢出的进位会被舍弃。
  • SUB 的用法和 ADD 是类似的。

JMP 指令

CS:IP 指向的地址是 CPU 读取指令的地址。

要修改 CS:IP,需要使用 JMP 指令。

JMP 2AE3:3

执行后 CS=2AE3H,IP=0003H。CPU 将从 2AE33H 处读取指令。

JMP ax

其含义等同 MOV IP, ax

PUSH & POP 指令

CPU 提供栈的机制。8086CPU 中有段寄存器 SS 和寄存器 SP,任意时刻,SS:SP 指向栈顶元素。

PUSH ax
  • SP = SP-2,然后将 ax 的内容写入 SS:SP。
POP ax
  • 将 SS:SP 的内容写入 ax,然后 SP = SP+2。

有关段寄存器和寄存器的操作,在之前已经有所介绍,因此不再多说。

[BX] & [BX+idata] 指令

[BX] 指代的是 ds:bx 的地址。相当于吧偏移量存到寄存器 bx 中。

[BX+idata] 的偏移地址为 (BX)+idata。

也可以写作 idata[BX]。可以理解为,idata 是一个静态偏移量,相当于静态数组头指针。而 BX 就是迭代器。

LOOP 指令

LOOP 指令与一个特定寄存器 cx 有关。

LOOP sign

CPU 执行到 LOOP 时有两部操作:

  • cx = cx-1
  • 判断 cx 存储的值是否为 0,不为 0 就转到 sign 处执行程序(相当于执行JMP sign),否则继续向下执行。

sign代指一个内存单元的地址。

DW & DB 指令

dw 指令可以定义一段数据,以字为单位(16 位)。

相对地,db 则表示以字节为单位的数据(8 位)。

DB 'unIX'

相当于

DB 75H, 6EH, 49H, 58H

AND & OR 指令

与 ADD 指令用法类似。含义就是对应的位运算。

INC 指令

INC ax

把 ax 寄存器里的值 +1。

[BX+SI] & [BX+DI] & [BX+SI+idata] & [BX+DI+idata]

SI 和 DI 是两个,不能当作 8 位寄存器的,16 位寄存器。

MOV ax,[bx+si]

等价于

MOV ax,[bx][si]

类似地,以下命令的效果是一样的

MOV ax,[bx+200+si]
MOV ax,[200+bx+si]
MOV ax,200[bx][si]
MOV ax,[bx].200[si]
MOV ax,[bx][si].200

寻址寄存器

  1. 只有 BX、SI、DI 和 BP 可以用来寻址(放[]里)。
  2. []中,它们可以单独出现,或者以四种组合出现:BX+SI、BX+DI、BP+SI、BP+DI。其中可以加常量。
  3. []中使用 BP,且指令没有显式给出段地址,那么段地址默认取 SS。