为什么要自限:MOLEK-SYNTEZ

为什么要自限:MOLEK-SYNTEZ

在有许多“要做”或者说“应该做”的事情摆在面前时,我常常选择逃避。在玩腻了 Balatro,皇室战争,以及一众益智解谜游戏后,我打开了小黑盒,看到了主页上有人对于 Opus Magnum 的得分直方图的感慨,遂回想起同工作室的另一作品 MOLEK-SYNTEZ,在这个离真实世界的化学颇有距离的游戏中,我多少也想出了些有趣的解法,因而早早地想建立一个解法收藏,供大家查阅也好,自己高兴也好,总之不想让这些算是智慧结晶的东西就这样消失在遗忘之海里。先前因为懒惰与生活中其他的琐事缠身,没能做出什么实质性的成果,今天下定决心,打算攻破这个课题。

登高望远

最初的想法则是做一个非常简单的网页展示,后来又出于虚荣想要正确注明作者,但想来想去总归太麻烦,且容易在质量的追求上磨灭太多热情。早早地写了一些 Python Flask 代码,想做一些基本的用户身份相关的功能,深思熟虑之后还是觉得有些多余。

所以最终的想法还是相当简单的,通过 GitHub 这类公开服务收集解法(本身也不会太大),展示什么的也属于方便拓展的内容。

巧妇难为无米之炊:存档格式解析

一切的基础,建立在对游戏玩法的复刻上。而为了避免重复造轮子,最佳的方法就是利用好游戏本来所具有的存档文件格式。如何解析存档成为了第一个难题。

暴力尝试

上网没有搜到任何资源,自己用十六进制编辑器打开存档文件,勉强看出开头的解法名等,其余则毫无头绪。若要猜出每个字节对应什么样的数据,需要开着游戏保存无数份几乎相同又稍有区别的存档,再逐一进行对比。这样的行径显然不是优雅之举,也不符合我个人的行事风格,因此直接浏览二进制数据仅作为对存档大致结构和基本形貌的了解手段。

基本上能总结出的信息有:

  1. 文件名有固定格式,后缀名为

    .solution

  2. 文件开头有一串 Magic Number,紧跟着的是解法名,似乎是一个给定长度的字符串

  3. 文件后面的部分经常有大量的

    00
    字节(在大部分我自己的解法文件中如此),可以合理推测数据存储没有经过压缩

走此小道

另一个方向的尝试则从逆向工程的角度,尝试解析游戏的存档逻辑。

在 Steam 中选择浏览本地文件后,看到的是一个

.app
的 Bundle(笔者用的是 M1 Mac),其中的内容大多是配置文件,资源文件,或是奇怪的
.dll
.dylib
动态库。笔者没有太多逆向工程经验(其实几乎就是0),勉强找到了启动脚本,然后将其指向的二进制文件拖入 IDA 分析,在找了半天
fopen
调用,对着反编译出来的伪 C 代码抓耳挠腮了十分钟后,意识到这个二进制文件似乎和 .NET 的 JIT 有关,这就意味着游戏的主逻辑应该是用 .NET 编写的(或者至少用了 .NET 系列的工具链)。

有趣的事:在

LICENSE.txt
中,找到了这一行:

binreloc is licensed under the WTFPL.

难道说严肃的许可里一定要将这类开玩笑的许可列出吗?很有意思

在意识到这一点后,笔者鼓起勇气

file
了一下那个在 Finder 中以 Wine 图标显示的
.exe
文件,在这之前笔者一直以为这是 Windows 系统的可执行程序,只是打包时一并打进来了而已。


          
MOLEK-SYNTEZ.app/Contents/MacOS/MOLEK-SYNTEZ.exe: PE32+ executable (GUI) x86-64 Mono/.Net assembly, for MS Windows

          
MOLEK-SYNTEZ.app/Contents/MacOS/MOLEK-SYNTEZ.exe: PE32+ executable (GUI) x86-64 Mono/.Net assembly, for MS Windows

反过来想,既然都在 MacOS 文件夹里了,确实肯定是需要用到才会放进来,搜索了一番如何反编译 .NET 程序后,找到了 ilspy,然后搜了搜又发现有 CLI 可以用,于是


          
dotnet tool install --global ilspycmd

          
ilspycmd -o ./output MOLEK-SYNTEZ.exe

          
dotnet tool install --global ilspycmd

          
ilspycmd -o ./output MOLEK-SYNTEZ.exe

打开

output/MOLEK-SYNTEZ.decompiled.cs
一看,傻眼了,变量名全是形如
#=qtyGJvcIwu5XE5yJjpw2S$Q==
的怪物,不过最外层的部分类名仍保留原样(例如
Atom
等),所以推测是用了一些机械化的混淆或生成工具,好在其中的
if
for
语句仍然能看得懂,说明没有控制流混淆,手动重命名一些符号应该面前能读懂代码。

工欲善其事,必先利其器。笔者在开始埋头苦干之前先安装了 VS Code 的 C# 语言支持(毕竟需要重命名符号这类显然应该是 IDE 来完成而不是 Ctrl-F 的工作),然后想尽可能降低这个工作过程的难度,于是找到了 de4dot,但看到是五年前的 Public Archive,顿感大事不妙。好在又经过一番网上冲浪后在 Reddit 上找到了较新的 fork,尝试在本地构建并运行,很幸运地没有遇到什么问题。


          
git clone [email protected]:kant2002/de4dot.git

          
dotnet build de4dot -c Release

          
dotnet de4dot/Release/net8.0/osx-arm64/de4dot.dll MOLEK-SYNTEZ.exe

          
git clone [email protected]:kant2002/de4dot.git

          
dotnet build de4dot -c Release

          
dotnet de4dot/Release/net8.0/osx-arm64/de4dot.dll MOLEK-SYNTEZ.exe

中途还有一些小插曲,例如 .NET 的版本管理和文件读取权限问题(最后粗暴地用

sudo
解决了),以及构建实际上有多个目标,需要指定
de4dot
才行。总算是得到了
MOLEK-SYNTEZ-cleaned.exe
,然后再用
ilspycmd
反编译了一次,得到的代码就好看多了。虽然变量名还是
item_0
这类不知所谓的东西,但至少长度上没有那么离谱。

那么接下来就是人工队的主场了。

一阵搜索后找到了

Solution
类的位置,明确了一些方法的意义,然后在其引用中找到一处可疑的逐步构建其组成部分的代码,顺着那里找到了
BinaryReader
类(原始类名为数字编号),于是直接上手用 Rust 抄了一份同样的实现,并加入了一些可复用的逻辑(例如很多时候读取某个对象的列表
Obj[]
时,总是先读取一个
i32
作为长度,然后再读取
i32
Obj
)。最后再根据原来的变量名结合实验(开着游戏存存档)半猜半蒙得到了一个大致的存档格式。过程中还发现了 C# 里类似
Option<T>
的有趣的类型,不过似乎在反编译结果中看起来比 Rust 要麻烦一些,不知道写起来是怎么一回事。

比较令人头疼的是反编译的 C# 源码里有大量的强制类型转换,例如

(XXID)reader.ReadI32()
)。这里的
XXID
又是一个枚举类型,这就意味着需要找到对应的枚举表示。好在一番实验后发现都是简单的从 0 开始的非负整数,仅有一个枚举类型的成员不知所踪,最后在多次实验下才完成。

最后发现开头的 Magic Number 实际上应该是一个版本号,超过当前版本或太旧的版本会拒绝解析(反编译的源码里对于

Maybe<Solution>
返回了
None

结算动画

经历一番折腾后,终于得到了一个勉强可用的存档解析库,接下来要做的工作就是写一套模拟游戏逻辑的计算器,用于检查解法正确性并计算各项指标了。

照猫画虎:游戏逻辑模拟

好在玩过这游戏相当长的一段时间,对各种逻辑也非常熟悉,不需要对照反编译结果也能顺利进行。

TBD