2024-02-10-CF1.0私服窗口化
0. 起始
元旦那会儿开了几个CF1.0的私服,爽了一阵之后发现一个问题,CF1.0那会儿的LithTech引擎过于老旧,不支持窗口化功能,而我用的一个仿MacOS UI的软件(MyDockFinder)会在切换分辨率的时候出现各种奇怪的Bug。
又因为私服被去掉了反外挂系统, 而群里又有位师傅折腾出了一个窗口化,但是并不愿意发出来分享,因此萌生了自己动手的想法。
首先我尝试找当年的CF窗口化工具,看有没有人发布易语言源码,或者直接对窗口化工具进行逆向,结果发现在12年前,做外挂的就已经给二进制攻防上强度了,动不动就是VMP保护,找了几个源码编译后对游戏也无效,遂放弃,开始自己动手调试。
1. 分辨率与全屏之谜
找到主程序crossfire.exe(中间还踩了个坑,进游戏需要带启动参数才行)x64dbg载入,随便单步按了几下,发现crossfireBase.dll有一个切换分辨率的Windows API(ChangeDisplaySettings)调用,调用完这个后屏幕分辨率会被强制设为800*600。
伪C代码:
1 | pfVar1 = ChangeDisplaySettingsA; |
然后发现这个函数被cshell.dll调用,查了一下LithTech的资料,这个好像是引擎的核心逻辑,而且代码疑似是被动态加载的,每次在x64dbg里的地址都不一样,入口点在一个.data段里,而且还加了壳,IDA没法直接看到函数。。
研究了几天怒从心头起,我又不是来二开你引擎的,我是来实现窗口化的,直接给修改分辨率的汇编打了个patch,这里的汇编是先把改分辨率函数的地址扔给了寄存器,再调寄存器的函数,IDA的伪C代码识别成了把函数指针扔给临时变量后,用指针调用函数,也就是pfVar1(&pdStack348, 4);。
对应汇编如下:
1 | push 4 |
NOP填充掉push、call语句,结果如下:

解决掉分辨率切换之后,下一个问题出现了,当按ESC呼出菜单,再关掉菜单的时候,光标会被锁死在屏幕左上角400*300的地方。
无奈之下只能给ClipCursor下断点,发现引擎每帧都会尝试重置鼠标光标(FPS的正常特性),于是转头研究crossfireBase.dll的代码,发现修改分辨率是被d3d9.dll里面初始化d3d设备的函数触发的,研究了一下这个函数的入参:

根据入参定义,下断点看一下实参,结果发现CF1.0在初始化d3d设备的时候压根传的就是窗口化,整个游戏是一个假的独占全屏!
这里还踩了一个坑,由于我一开始设置了Win7兼容模式,导致代码走进apphelp.dll里,而且在兼容模式时会设置一次真全屏,多走了一些弯路。
难怪切屏的时候是先出现Alt+Tab的UI再切换分辨率,实质上是游戏窗口检测到失去焦点时,将分辨率手动重置到原始分辨率,在切换到游戏的时候再修改到800*600!
难怪整个游戏在高刷屏下画面撕裂,手感延迟,它压根就不是独占全屏的游戏。。
知道了这点并不有助于解决问题,而且新的问题又出现了,如果把窗口切出去,游戏本身会冻结,再切回来的时候才会恢复,这期间游戏似乎是不接受网络请求的,因此切久了会掉线。。
2. 窗口冻结
既然是切换窗口的时候发生的逻辑,那就找监听窗口事件的入口,结果非常惊喜的在IDA找到游戏里居然有输出调试日志:

这下好办了,你不是shutting down renderer吗,我直接把if块置空,从x64dbg里找到大致地址,定位到输出日志的汇编:
1 | push crossfire.690554 |
然后往上往下阅读,得到一整个if块对应的汇编代码如下:
1 | ; 对应开头的两次=1赋值 |
暴力NOP填充这些汇编即可。
3. 窗口样式
解决了冻结和分辨率后,我得到了一个无边框窗口模式的CF,而很多现成的窗口化工具也是只设置了游戏的窗口样式,而游戏本体也应该是在指定窗口大小,或者创建窗口handle的逻辑附近去把窗口改成无边框的。
根据这个思路,给SetWindowLong下断点,兜兜转转又回到了crossfireBase.dll,找到如下汇编:
1 | mov eax,dword ptr ds:[eax+2E981C] |
看SetWindowLong的文档得知具体窗口的样式是用数字里的每个bit去控制的,这个94000000就是无边框窗口的意思,改成94CF0000就是正常窗口样式了。
4. 鼠标锁定
到这里我们得到了一个可以随意拖动,不切换分辨率,不切屏的CF,最后的问题是:按ESC呼出光标后,再按ESC,这时候鼠标会锁定在整个屏幕左上角400*300的地方。
在窗口化之前并没有什么问题,分辨率是800 * 600的时候,400 * 300一定是屏幕中间,但现在不是了,只有给SetCursorPos()下断点,然后按ESC再取消,看汇编,再去IDA找到对应函数,看伪C代码:
1 | if ((dword_6DDB30 != 0) && (dword_6E9838 == 0)) { |
这里可以看出游戏用GetWindowRect()获取游戏窗口4个点,然后SetCursorPos()锁在屏幕左上角+游戏宽高/2的地方,(iStack96 - iStack104) 和(iStack100 - iStack108)就是宽和高;
对应汇编:
1 | mov edx,dword ptr ss:[ebp-8] |
这里比较麻烦,观察x64dbg发现GetWindowRect调用结束后,EBP+偏移的这四个栈上地址分别是窗口的左上角、右下角的x y坐标位置,我们要做的是把鼠标锁在左上角xy坐标 + 游戏宽高,而由于汇编语句长度不足,游戏宽高我们不能计算,幸好可以写死400和300,才勉强塞下。
这里需要手写一点儿汇编,用x64dbg的16进制模式来替换,而不是逐语句替换:
1 | ; 保持原语句不变,维持长度 |
改成这样之后,就可以把鼠标锁在游戏窗口左上角+400*300的位置,至此才达到和2.0一样可以随意拖拽窗口、ESC可以随意挪动鼠标、可以随意切换窗口焦点的效果。
这也是头一次做正经的x86逆向,也要感谢Stars师傅繁星天海的空间给的一些提示和引导,虽然最后这些1.0私服服务端泄露的泄露,倒闭的倒闭,外挂和恶意玩家泛滥,加上游戏bug众多,运营手段粗劣(有的直接发全道具,有的调物价后每周签到发游戏币),到重新整理本文(2024-11-1)的时候应该已经没有能玩的了,但这次逆向也是一次非常有趣的历程。
2024-02-10-CF1.0私服窗口化