使用 Rust 对植物大战僵尸进行修改

借助钩子(Hook)[1]技术实现植物生产大阳光点击生成僵尸

pvz 修改器一般使用 C/C++ 写,得益于这两种语言和内存的直接交互,写起来很轻松。不过我这次想用 Rust 试试看。

顺带一提封面图是真的,这是我以前用 C++ 学 hook 技术时整的活,这框真帅吧

关于本文

Attention

本文内容涉及对 植物大战僵尸 游戏程序的逆向工程与修改 而该行为显然违反 EULA

本文分享的全部内容全部源于本人个人学习、技术研究、以及对游戏底层机制的探索

本文展示的所有代码旨在展示技术概念

本文不鼓励或支持任何破坏公平竞争或侵犯他人权益的行为

不要 将本文内容用于商业目的、制作或发布非法修改/破解版本、破坏他人游戏体验、或影响竞技公平性的行为

阅读、学习或尝试使用本文所述技术产生的一切后果与法律责任,均由读者自行承担

本文所有的代码都已开源至我的Github仓库,可能存在修改的部分

如果你不会写 Rust,但会写 C/C++,其实也可以尝试着看看这篇文章,毕竟 MinHook 就是 C 的库,其中的编写理念是一致的

准备工作

pvz 版本使用 v1.0.0.1051,这个版本在网上有许多逆向的信息,包括一份很完整的函数表[2]和里面数据结构的偏移表[3]

创建项目

使用 cargo 创建这个项目,顺便配置一些设置

1
cargo new pvz_mod --lib

配置项目信息

导出为 pvz 可以用的 DLL,在 Cargo.toml 加上 lib 字段,顺便配置一些之后会用到的库

1
2
3
4
5
6
7
8
9
10
11
12
[lib]
crate-type = ["cdylib"]

[dependencies]
# 简化错误处理
anyhow = "1.0.100"
# 这是一个我在 C++ 里很喜欢用的钩子库
# 本尊: https://github.com/TsudaKageyu/minhook
# rust wrapper: https://github.com/Jakobzs/minhook
minhook = "0.8.0"
# 使用 Windows 函数
windows = { version = "0.62.2", features = ["Win32_System_Console"] }

因为 pvz 是32位的,所以也得编译成32位的程序来使用。需要带上 --target i686-pc-windows-msvc

但我懒得每次都打这一长串,所以配置一下 .cargo/config.toml

1
2
[build]
target = "i686-pc-windows-msvc"

实现函数入口

为了让 DLL 可以主动下钩子[4],需要一个 DLL 的主函数来让系统知道加载的时候需要执行它

根据 Windows 的约定[5],我们需要一个导出为 DllMain 的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
use std::ffi::c_void;
use windows::{
Win32::{
Foundation::HINSTANCE,
System::SystemServices::{
DLL_PROCESS_ATTACH,
DLL_PROCESS_DETACH,
DLL_THREAD_ATTACH,
DLL_THREAD_DETACH
}
},
core::BOOL
};

// 告诉编译器不要改变命名
#[unsafe(no_mangle)]
#[allow(non_snake_case)]
pub extern "system" fn DllMain(
// 自己的句柄
hinstDLL: HINSTANCE,
// 这个函数被调用的原因
fdwReason: u32,
// 一般用不着
lpReserved: *mut c_void
) -> BOOL {
match fdwReason {
DLL_PROCESS_ATTACH => {
// 之后将在这里编写 dll 被加载时进行的操作
},
DLL_PROCESS_DETACH => {

},
DLL_THREAD_ATTACH => {

},
DLL_THREAD_DETACH => {

},

_ => unreachable!()
}

BOOL::from(true)
}

相传螃蟹以前是下钩子的

到底啥是钩子?

钩子允许在程序执行的特点节点插入执行自己的代码与逻辑

想象一下,你有一个这样的函数

1
2
3
int my_cool_func(int a, int b) {
return a + b;
}

钩子可以做到在执行函数的内容前,读取修改 ab 的内容,或者让这个函数总是返回 1,或者执行这个函数的时候要玩完一次贪吃蛇才能继续执行函数

不需要修改源代码

找到下钩点

查询函数表,可以找到在游戏关卡内生成掉落物的函数签名[6]

别问我是怎么知道是这个函数的,问就是 try it

Note

这个函数签名网站提供的函数参数是根据入栈顺序来写的

实际上正确的参数应该倒过来看,比如这个例子里除了 this,第一个参数应该是 theX

这是由于调用约定[7]中参数从右往左入栈,网站倒过来写某种程度上来说是方便手动调用

写两个用于表示函数签名和地址的变量,以及一个存放这个函数被钩后地址的全局变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// pvz 里生产掉落物的函数地址
// 其实地址这里用 u32 usize 都无所谓,因为后面还得接受 *mut c_void
const BOARD_ADDCOIN: *mut c_void = 0x0040CB10 as _;
// 根据函数签名,放在 ecx 寄存器的是个 this,说明这是个 C++ 类的成员函数
// 这里使用 thiscall 表示类成员函数约定
// 注意正确的参数顺序
// 这里使用 *mut c_void 代表占位符指针,现在不关心 Board* this 和返回值
type BoardAddCoin = extern "thiscall" fn(
*mut c_void,
i32,
i32,
u32,
u32
) -> *mut c_void;
// 关于为什么需要这个全局变量在下一节中会提到
static mut ORIGINAL_BOARD_ADDCOIN: Option<BoardAddCoin> = None;

It’s My 钩!

如果我们希望劫持这个函数,且不打算让程序死掉的话,最好正确接收函数的所有参数

还记得上面的签名定义吗?先创造一个签名相同的函数

1
2
3
4
5
6
7
8
9
10
11
12
use std::ffi::c_void;

extern "thiscall" fn board_add_coin(
board: *mut c_void,
x: i32,
y: i32,
coin_type: u32,
coin_motion: u32
) -> *mut c_void {
// 目前什么都不干,只返回 0
0 as _
}

MinHook 一般这样下钩子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use anyhow::Result;
use minhook::MinHook;

pub fn init_hooks() -> Result<()> {
unsafe {
let trampoline = MinHook::create_hook(
// pvz 的函数地址
BOARD_ADDCOIN,
// 我们的函数
board_add_coin as _
)?;

// 给全局变量赋值强制转换后的返回值
ORIGINAL_BOARD_ADDCOIN = Some(std::mem::transmute(trampoline));

// 启动所有钩子
MinHook::enable_all_hooks()?;
}

Ok(())
}

create_hook 做了什么?

在计算机里,cpu 只能执行特定的二进制操作指令,常用汇编代码助记与表示

所有的函数都会被编码成一大堆操作指令来给 cpu 执行

create_hook 会根据你给定的函数地址,从那个地址后裁出一段最小的、足够一个跳转指令的区域,然后保存被裁出的内容

之后它会把一段特殊的跳转指令塞进这个空位,用于跳转到我们自己的函数

之后 create_hook 会生成一小块区域,包含了被裁掉的那些指令与跳回原本函数的指令,这也就是 create_hook 的返回值

所以说,现在 ORIGINAL_BOARD_ADDCOIN 实际上存储了被修改后的,真正的原本函数地址

不过我们先不用它,编译一下,试试看先

1
2
3
cargo build
# 如果你没改 .cargo/config.toml
# cargo build --target i686-pc-windows-msvc

之后你应该能在 target\i686-pc-windows-msvc\debug 文件夹里找到 pvz_mod.dll

usm能量注入

现在我们有了这个 DLL,需要选择一个合适的注入器来让游戏运行它

Note

如果你找不到一个合适的选择,可以试试我的项目 Hexphage

这是一个轻量级的 DLL 注入器,使用 yaml 进行人类友好的配置,而且能在主程序启动前就进行注入

使用其他 DLL 注入器

Attention

请注意使用 DLL 注入器存在风险,请确保你信任程序来源

你可以通过搜索引擎搜索一些 DLL 注入器,它们的注入方法基本都大同小异,选择一个进程或者程序,然后选一个 DLL,点击注入按钮之类的

使用 Hexphage

假设你选择使用 Hexphage(如果是这样非常感谢你愿意试试)

请为这次 32位 的注入下载 hexphage-0.1.0-windows-msvc-x86.exe

打开 pvz v1.0.0.1051 的文件夹,放入 pvz_mod.dll hexphage.exe

然后编写注入配置 inj.yml

1
2
3
cmd: PlantsVsZombies.exe
dlls:
- pvz_mod.dll

启动游戏

用你选择的注入器启动游戏(别忘了带上这个 DLL),找一个能生产阳光的向日葵或者阳光菇,看着它变亮,然后生产阳光……吗?

我阳光呢?

不得不承认这个过程很戳我的笑点,发出了一声阳光的声音,而我们想要的阳光根本没个影子

哈哈哈哈哈哈哈哈哈

如果你接着打完了这关(没有阳光还能做到你也不是一般人),你会发现你其实打不完这关,过关的植物卡、钱袋、奖杯,全都消失了!

这是因为,程序执行到产生掉落物的代码时,被钩子转到了我们的代码,但我们的函数只是简单返回了个 0,也就是什么都没有!

为了帮助向日葵生产阳光,也为了能成功过关,请修改我们的 board_add_coin 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
extern "thiscall" fn board_add_coin(
board: *mut c_void,
x: i32,
y: i32,
coin_type: u32,
coin_motion: u32
) -> *mut c_void {
unsafe {
// unwrap 是因为能执行到这个函数本身就说明 hook 成功了
ORIGINAL_BOARD_ADDCOIN.unwrap()(
board,
x,
y,
coin_type,
coin_motion
)
// 注意这里并没有末尾的分号,因为我们确实需要这个函数的返回值
}
}

看起来我们只是复读了一下函数的参数,实际上要做的也就这么多

调用原函数会帮我们得出需要返回的内容,原样返回即可

比金银花牛比

聪明的你也许发现了,既然我们只需要调用原本的函数就能让向日葵变得正常,那么这些参数在给原函数之前,就任我们处置了!这便是钩子技术的妙处之一

例如,根据不断调试输出 coin_type,我们可以发现,这是一个枚举,用于标注掉落物的类型

以下是一个常见掉落物的表格

枚举值 对应
1 银币($10)
2 金币($50)
3 钻石($1000)
4 阳光(25)
5 小阳光(15)
6 大阳光(50)

所以,为了让向日葵总是生产大阳光,只需要加上一个判断,使得原本是 4coin_type 全部变成 6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
extern "thiscall" fn board_add_coin(
board: *mut c_void,
x: i32,
y: i32,
coin_type: u32,
coin_motion: u32
) -> *mut c_void {
let my_coin = match coin_type {
4 => 6,

_ => coin_type,
};

unsafe {
ORIGINAL_BOARD_ADDCOIN.unwrap()(
board,
x,
y,
my_coin,
coin_motion
)
}
}

或者你想的话,当然可以把阳光改成钻石!

现在谁才是疯狂戴夫?

更复杂一点的注入

对于常规函数,一般只需要编写一个调用约定和函数签名(主要关注接受的参数和返回值)相同的函数,就可以直接进行 hook

把僵尸塞进游戏需要哪几步?

这次我们的目标就设定为按下一个键,就刷出一只普通僵尸。

这么想来,就需要知道什么时候按下了键,以及怎么刷出一只僵尸

其实直接用 Windows.h 的按键监听回调也可以实现按键的监听,不过这次不使用这个方法

按下了吗?如按

pvz 中有许多会因为按键按下而被触发的函数,这里我们主要看这个函数

这个函数并不在意你是薄膜键盘还是机械键盘

通过观察函数的参数,第一个从 ecx 寄存器传递,其他的通过栈传递,说明这是一个 thiscall 调用约定的类成员函数

这样有正常调用约定的函数非常好下钩子,参考上一章掉落物函数的 hook,可以仿写出如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
use std::ffi::c_void;
use anyhow::Result;
use minhook::MinHook;

/// `Board::KeyDown` 的地址
const BOARD_KEYDOWN: *mut c_void = 0x0041B820 as _;
/// `Board::KeyDown` 的签名
type BoardKeyDown = extern "thiscall" fn(
*mut c_void,
i32,
);
/// `Board::KeyDown` 的跳板
static mut ORIGINAL_BOARD_KEYDOWN: Option<BoardKeyDown> = None;

/// `Board::KeyDown` 的 hook 函数
extern "thiscall" fn board_keydown(
this: *mut c_void,
keycode: i32,
) {
unsafe {
// 回调
ORIGINAL_BOARD_KEYDOWN.unwrap()(
this,
keycode
);
}
}

pub fn init_hooks() -> Result<()> {
unsafe {
let trampoline = MinHook::create_hook(
BOARD_KEYDOWN,
board_keydown as _
)?;

ORIGINAL_BOARD_KEYDOWN = Some(std::mem::transmute(trampoline));

MinHook::enable_all_hooks()?;
}

Ok(())
}

这样我们就劫持了按键按下的函数

问题来了,我要怎么知道我按没按下呢?或者我怎么测试我hook成功了没?这个问题将在下文解决。

顺带一提,Board 类表示一个关卡,Board::KeyDown 实际上完美符合我们需要监听关卡内按键按下的需求

我按下了哪个键?

不知道你有没有发现,如果我们想要用类似 println!() 之类的函数打印点日志,好像没什么用。pvz 自己似乎并没有一个控制台来输出调试信息

当然写入文件是可以的,但是至少我不太喜欢盯着文件看

解决方法很简单,我们在一开始导入了 windows crate,且启用了 Win32_System_Console 特性,这使得我们可以用 AllocConsole 手动为 pvz 分配一个新的终端

编写一个分配终端的函数,然后在 DLL 附加成功的时候调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
use anyhow::Result;
use windows::Win32::System::Console::AllocConsole;

/// 用安全函数封装
pub fn alloc_console() -> Result<()> {
unsafe {
// 分配终端
AllocConsole()?;

Ok(())
}
}

// 这是 DLL 入口,只是做个示例
#[unsafe(no_mangle)]
#[allow(non_snake_case)]
pub extern "system" fn DllMain(
hinstDLL: HINSTANCE,
fdwReason: u32,
lpReserved: *mut c_void
) -> BOOL {
let result = match fdwReason {
DLL_PROCESS_ATTACH => {
alloc_console().is_ok()
},

_ => true
}

BOOL::from(result)
}

然后在随便哪个 hook 函数里使用比如 println!(),就可以在终端中看见输出了!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// 例如之前的按下按键 hook
///
/// 别忘了这个函数只有在关卡里才会被触发,所以记得开一局
extern "thiscall" fn board_keydown(
this: *mut c_void,
keycode: i32,
) {
println!("Hello! You pressed keycode {:#x}", keycode);
unsafe {
// 回调
ORIGINAL_BOARD_KEYDOWN.unwrap()(
this,
keycode
);
}
}

死灵法师秘籍

要刷出一只僵尸,先要在僵尸数组中分配它的内存

分配僵尸内存

我们可以使用这个函数

*念咒*

我们来看看这个函数的签名

什么鬼?

有个参数居然通过 esi 寄存器传递?这不符合调用约定

Note

这其实并不是因为软件作者不遵守约定

编译器在编译时,会进行很多优化,例如在这里,编译器也许认为参数完全没有必要用栈传递,所以优化为了一种奇怪的调用,看起来很奇怪,但是运行时不会有任何问题。

在这篇文章里,我们编写的 hook 函数几乎都要写上原函数的调用约定,但这个函数根本不能直接用已有的调用约定

先把 hook 的老几样写好

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::ffi::c_void;

/// 定义一个别名避免看差了
type Zombie = c_void;

/// `DataArray::DataArrayAlloc` 构造函数的地址
const ADDR_DATA_ARRAY_ALLOC: *mut c_void = 0x0041DDA0 as _;
/// `DataArray::DataArrayAlloc` 构造函数的签名
///
/// 由于不是标准调用,这个签名只能用作标注,没有实际作用
type SignDataArrayAlloc = fn(
this: *mut c_void,
) -> *mut Zombie;
/// `DataArray::DataArrayAlloc` 构造函数的跳板
static mut ORIGINAL_DATA_ARRAY_ALLOC: Option<SignWidgetManagerConstructor> = None;

/// `DataArray::DataArrayAlloc` 的 hook 函数
extern "stdcall" fn data_array_alloc(
this: c_void,
) -> *mut Zombie {
// 由于非标准函数没法直接调用,先留空
todo!()
}

至于如何解决调用问题?我们需要两个帮助函数。

DataArrayAllocHelper

第一个帮助函数,用来把本在寄存器中的参数压栈,这样我们就可以使用 stdcall 约定来从栈中提取参数,并负责回传函数的返回值。

普通的函数没法做到这一点,因为极有可能破坏寄存器与栈的内容。我们必须在调用函数前保存他们

这需要编写一个裸函数

Attention

使用裸函数就意味着我们需要手动管理普通函数调用时候发生的所有事情,缺少任何部分都会直接导致程序崩溃

裸函数内只能使用内联汇编,无法使用常规代码

编写的时候请万分小心

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::arch::naked_asm;

/// 从非标准调用中提取参数的辅助函数
#[unsafe(naked)]
extern "stdcall" fn DataArrayAllocHelper() {
naked_asm!(
// 压栈 esi 作为参数
"push esi",
// 调用 hook 函数
"call {hook}",
// 返回
"ret",

// 传入 hook 函数符号地址
hook = sym data_array_alloc,
)
}

这个裸函数首先压栈了 esi,之后 call 调用我们的函数,这就做到了一个与 stdcall 的转换。由于函数的返回值会存储在 eax 中,这和 DataArray::DataArrayAlloc 的行为一致,所以无需编写更多操作。(如果你想可以写一个提示用的 mov eax, eax,哈哈哈哈)

顺便写一下下钩子的函数,这里指向 DataArrayAllocHelper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use anyhow::Result;
use minhook::MinHook;

/// 下钩子
pub fn init_hooks() -> Result<()> {
unsafe {
let trampoline = MinHook::create_hook(
ADDR_DATA_ARRAY_ALLOC,
DataArrayAllocHelper as _
)?;

ORIGINAL_DATA_ARRAY_ALLOC = Some(std::mem::transmute(trampoline));

MinHook::enable_all_hooks()?;
}

Ok(())
}
DataArrayAllocWrapper

现在我们劫持了原函数的调用,但如果不调用原函数,就会让所有僵尸都没法被分配。这可不是我们的目的

为了调用这个奇怪的函数,我们可以编写一个包装,来将 stdcall 转换回它期望的内容

原函数那放在 esi 中的参数已经被安全转移到了我们的函数,因此我们可以编写一个正常的 stdcall 函数来调用原函数

注意非标准调用约定函数的区别

我非常推荐你使用 IDA[8] 之类的工具去查看非标准调用约定函数的签名

这相当的重要,否则会绕很多远路

关于软件的使用方法,可以通过搜索引擎搜索教程,还挺多的

这里我的查看结果如下

注意调用约定为 __usercall

这意味着这个非标准调用约定需要我们在调用完函数后自己清理栈中的参数[9]

不过这个 usercall 并没有用栈传递参数,所以不用管它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/// 回调辅助函数
pub extern "stdcall" fn DataArrayAllocWrapper(
this: *mut c_void,
) -> *mut Zombie {
unsafe {
let result;
asm!(
// 保存 esi
"push esi",
// 把参数放入原函数期望的寄存器中
"mov esi, {this}",
// 调用原函数
"call {func}",
// 恢复 esi
"pop esi",
// 提取返回值
"mov {result}, eax",

this = in(reg) this,
func = in(reg) ORIGINAL_DATA_ARRAY_ALLOC.unwrap(),
result = out(reg) result,
);
result
}
}

这里使用了普通的内联汇编,不使用裸函数是因为前后的调用约定都是标准的,没有必要手搓,只有在调用原函数的那部分才需要精准操作寄存器

保存 esi 寄存器是因为它是非易失性寄存器[10]

于是在我们的 hook 函数中,可以直接这样调用

1
2
3
4
5
6
7
/// `DataArray::DataArrayAlloc` 的 hook 函数
pub extern "stdcall" fn data_array_alloc(
this: *mut c_void,
) -> *mut Zombie {
println!("alloc zombie");
DataArrayAllocWrapper(this)
}

至此,我们便做到了对这个奇怪函数的 hook

初始化僵尸

查阅函数表,我们可以选择这个函数初始化刚分配的僵尸内存

还有你的兄弟

聪明的你可能注意到了,这个函数也有通过寄存器传递的参数,所以我们仍然需要编写辅助函数

以下是完整的 hook 代码,其中的注释标注了需要关注的点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
use anyhow::Result;
use minhook::MinHook;

/// `Zombie::ZombieInitialize` 的地址
const ADDR_ZOMBIE_ZOMBIE_INITIALIZE: *mut c_void = 0x00522580 as _;
/// `Zombie::ZombieInitialize` 的签名
///
/// 仅标注用
type SignZombieZombieInitialize = fn(
this: *mut Zombie,
theRow: i32,
theZombieType: i32,
theVariant: BOOL,
theParentZombie: *mut Zombie,
theFromWave: i32,
);
/// `Zombie::ZombieInitialize` 的跳板
static mut ORIGINAL_ZOMBIE_ZOMBIE_INITIALIZE: Option<SignZombieZombieInitialize> = None;

/// hook 函数
pub extern "stdcall" fn zombie_zombie_initialize(
this: *mut Zombie,
theRow: i32,
theZombieType: i32,
theVariant: BOOL,
theParentZombie: *mut Zombie,
theFromWave: i32,
) {
println!("初始化 行 {} 类型 {} 来自第 {} 波", theRow, theZombieType, theFromWave);
ZombieInitializeWrapper(
this,
theRow,
theZombieType,
theVariant,
theParentZombie,
theFromWave
);
}

/// 从 `usercall` 中提取参数的辅助函数
#[unsafe(naked)]
extern "stdcall" fn ZombieInitializeHelper() {
naked_asm!(
// 压栈 usercall 参数
"push eax",
// 修正参数位置
// 由于压入新参数后,和原参数之间隔着 call 指令压入的栈地址和返回地址
// 这里进行一个三个栈单位的旋转操作
//
// 压入 eax 后栈原本的样子
// [原参数2]
// [原参数1]
// [原栈地址]
// [返回地址]
// [压入的eax]
"mov eax, [esp]",
"xchg eax, [esp+8]",
"xchg eax, [esp+4]",
"mov [esp], eax",
// 旋转后
// [原参数2]
// [原参数1]
// [压入的eax]
// [原栈地址]
// [返回地址]

// 调用 hook 函数
"jmp {hook}",

hook = sym zombie_zombie_initialize,
)
}

/// 回调辅助函数
pub extern "stdcall" fn ZombieInitializeWrapper(
this: *mut Zombie,
theRow: i32,
theZombieType: i32,
theVariant: BOOL,
theParentZombie: *mut Zombie,
theFromWave: i32,
) {
unsafe {
asm!(
// 调用原函数
"push {}",
"push {}",
"push {}",
"push {}",
"push {}",
"call dword ptr {func}",
in(reg) theFromWave,
in(reg) theParentZombie,
in(reg) theVariant.0,
in(reg) theZombieType,
in(reg) this,
// 将 theRow 放回它需要的 eax
in("eax") theRow,
func = sym ORIGINAL_ZOMBIE_ZOMBIE_INITIALIZE,
);
}
}

/// 下钩子
pub fn init_hooks() -> Result<()> {
unsafe {
let trampoline = MinHook::create_hook(
ADDR_ZOMBIE_ZOMBIE_INITIALIZE,
ZombieInitializeHelper as _
)?;

ORIGINAL_ZOMBIE_ZOMBIE_INITIALIZE = Some(std::mem::transmute(trampoline));

MinHook::enable_all_hooks()?;
}

Ok(())
}

是时候召唤了

我们的目的是按下按键刷出僵尸,这次就选 Z

根据调试可以知道,Z 键的花语键码是 90(0x5a)

回到我们的 Board::KeyDown hook 函数

在里边加上对键码的判断,以及分配僵尸内存、初始化的一条龙服务

Note

分配僵尸内存需要一个 DataArray*

你可以通过访问偏移表[3]来查看

偏移表

由于我们已经有 Board*(也就是 Board::KeyDown 函数参数的 this) ,可以不管这个 0x768

如图中所示,DataArray* 位于 Board* + 0x90

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
extern "thiscall" fn board_keydown(
this: *mut c_void,
keycode: i32
) {
match keycode {
65 => {
// 把指针转换为整数方便计算
// 算完了转换回去
let array = ((this as u32) + 0x90) as *mut c_void;
// 分配僵尸内存
let zombie = data_array_alloc(
array
);
// 初始化僵尸
zombie_zombie_initialize(
// 未初始化的僵尸区域
zombie,
// 第一行
0,
// 僵尸id 0,也就是普通僵尸
0,
// 填 false 就对了
false.into(),
// 没有父级僵尸
0 as _,
// 来自第 0 波
0
)
}
_ => (),
}

unsafe {
// 回调
ORIGINAL_BOARD_KEYDOWN.unwrap()(
this,
keycode
);
}
}

Attention

初始化僵尸必须在分配内存的同一帧中进行

否则下一帧游戏尝试绘制僵尸时会遇到空指针或者除0错误

现在打开游戏,进入关卡,疯狂地按 Z,你应该会看到第一行开始源源不断涌出僵尸

ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ

后记

写这篇文章时真的是相当费劲,我几乎是在一边重写以前的项目一遍编写文档

实际上文章里很多概念的理解都源于大量的尝试和经验的积累,如果你有兴趣去做它,请务必多多实操,亲自去探索它

如果这篇文章的任何地方有帮到你,我很荣幸

如果没有,也感谢你愿意分出精力去阅读我的文章


  1. 三言两语讲不完,有关这项技术还请使用搜索引擎或者AI进行详细了解
  2. 函数表 https://func.dyj333.cn/
  3. 内存基址与偏移 https://wiki.pvz1.com/doku.php?id=%E6%8A%80%E6%9C%AF:%E5%86%85%E5%AD%98%E5%9F%BA%E5%9D%80
  4. 指对特定函数进行 hook 操作
  5. DLL入口点 https://learn.microsoft.com/zh-cn/windows/win32/dlls/dllmain
  6. 描述函数的名称、参数类型、参数个数和返回类型
  7. 指如何正确传递参数,x86程序常用的 stdcall 参数从右往左入栈,而 thiscall 除了 this 参数在 ecx 寄存器中其他与 stdcall 一致。参阅 https://learn.microsoft.com/zh-cn/cpp/cpp/calling-conventions?view=msvc-170
  8. 官网 https://hex-rays.com/ida-pro
  9. 一个函数调用完毕后,通常会自动清理压入栈中的参数来达到栈平衡。在 IDA 的反汇编中,__usercall 代表需要我们清理栈,__userpurge 代表不需要我们清理栈
  10. 非易失性寄存器表示这个寄存器在函数调用前后不应该被破坏,我们需要负责确保这一点。于此相反的还有可以随便用的易失性寄存器

使用 Rust 对植物大战僵尸进行修改
https://mygo.plus/articles/pvz-modify/
作者
Peter Shen
发布于
2025年12月6日
许可协议