一句话总结:本篇解决数据来源——用 OpenProcess 拿句柄、CreateToolhelp32Snapshot 找模块基址,再顺着指针链用 ReadProcessMemory 把玩家坐标、血量、阵营读进自己的进程,上一篇的空画布从此有了可以画的东西。
上一篇我们把玻璃架好了,但画布上只有一个写死的红框。从这篇开始,红框的位置改由数据决定。数据从哪来?答案是:游戏进程的内存里。
老规矩,演示对象是虚构的《星陨 Online》(StarFall.exe),所有偏移都是编的。方法是真的,换个靶场同样适用。
第一步:找到进程,拿到句柄
Windows 不允许你随便读别的进程内存,得先通过 OpenProcess 申请权限。要读内存,PROCESS_VM_READ 是必须的,有些操作还需要 PROCESS_QUERY_INFORMATION。
#include <windows.h>
#include <tlhelp32.h>
#include <cstdio>
uintptr_t GetProcessIdByName(const wchar_t* name) {
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
PROCESSENTRY32W pe = {};
pe.dwSize = sizeof(pe);
if (Process32FirstW(snap, &pe)) {
do {
if (_wcsicmp(pe.szExeFile, name) == 0) {
CloseHandle(snap);
return pe.th32ProcessID;
}
} while (Process32NextW(snap, &pe));
}
CloseHandle(snap);
return 0;
}
// 用法
DWORD pid = (DWORD)GetProcessIdByName(L"StarFall.exe");
HANDLE hProc = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION,
FALSE, pid);
if (!hProc) {
printf("OpenProcess failed: %lu\n", GetLastError());
}
三个常见的翻车点:目标进程不存在(返回 0 要判空);权限不够(用管理员身份运行 VS 和程序);64 位进程句柄混用(自己编译成 x64,别用 x86 去读 x64 的游戏)。
第二步:拿模块基址
游戏里的地址几乎都是「模块基址 + 偏移」的形式,基址每次启动都会变(ASLR 的功劳),所以要动态拿。继续用工具帮助快照,这回拍的是模块:
uintptr_t GetModuleBase(DWORD pid, const wchar_t* modName) {
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, pid);
MODULEENTRY32W me = {};
me.dwSize = sizeof(me);
if (Module32FirstW(snap, &me)) {
do {
if (_wcsicmp(me.szModule, modName) == 0) {
CloseHandle(snap);
return (uintptr_t)me.modBaseAddr;
}
} while (Module32NextW(snap, &me));
}
CloseHandle(snap);
return 0;
}
uintptr_t base = GetModuleBase(pid, L"StarFall.exe");
我们的靶场是单模块游戏,直接取主模块基址即可。真实项目里常见的是 client.dll、unityplayer.dll 这类模块,方法一模一样。
第三步:顺着指针链读数据
内存里的数据结构像一棵树,你手里只有树根。以下是本系列虚构的偏移表:
| 偏移 | 名称 | 说明 |
|---|---|---|
| 0x18AC000 | dwLocalPlayer | 本地玩家指针(相对基址) |
| 0x191F2C8 | dwEntityList | 实体列表基址 |
| 0x138 | m_vecOrigin | 世界坐标,float[3] |
| 0x100 | m_iHealth | 血量 |
| 0xF4 | m_iTeamNum | 阵营(2 匪 3 警) |
| 0x101 | m_lifeState | 0 活着 / 非 0 死亡 |
| 0x26A8 | m_dwBoneMatrix | 骨骼矩阵基址(第三篇画骨骼用) |
这些偏移怎么来的?真实项目里靠逆向工具(Cheat Engine、x64dbg、ReClass)动态分析,那是另一个大话题,系列里不展开。靶场练习同理,先用现成偏移把链路跑通。
图 1:四步链路一图看完,输出是一份 30Hz 刷新的实体快照
读取用 ReadProcessMemory,封装一个模板省得每次写尺寸:
template <typename T>
bool RPM(HANDLE h, uintptr_t addr, T* out) {
SIZE_T read = 0;
return ReadProcessMemory(h, (LPCVOID)addr, out,
sizeof(T), &read) && read == sizeof(T);
}
uintptr_t localPlayer = 0;
RPM(hProc, base + 0x18AC000, &localPlayer);
struct Vec3 { float x, y, z; };
Vec3 myPos = {};
int myTeam = 0;
RPM(hProc, localPlayer + 0x138, &myPos);
RPM(hProc, localPlayer + 0xF4, &myTeam);
实体遍历的套路是「列表基址 + 索引 × 步长」:
struct Entity {
Vec3 pos;
int hp;
int team;
bool alive;
};
bool ReadEntity(int index, Entity& e) {
uintptr_t ent = 0;
if (!RPM(hProc, base + 0x191F2C8 + index * 0x10, &ent) || !ent)
return false;
RPM(hProc, ent + 0x138, &e.pos);
RPM(hProc, ent + 0x100, &e.hp);
RPM(hProc, ent + 0xF4, &e.team);
uint8_t life = 1;
RPM(hProc, ent + 0x101, &life);
e.alive = (life == 0 && e.hp > 0);
return e.alive;
}
在渲染循环里跑一遍 1 到 32 的索引,把活着的敌人存进数组,画布上画框的数据就齐了。
两个工程上的提醒
读失败的容错。ReadProcessMemory 会失败,原因包括地址刚被释放、跨了不可读页。上面的封装返回 bool,调用处必须处理——很多人图省事忽略返回值,然后被偶发的崩溃折磨一晚上。
读的频率。每帧读几十次 RPM,跨进程调用的开销不小。合理的做法是单独开一个读取线程,比如 30Hz 更新一份快照数据,渲染循环直接用快照。这样读和画解耦,卡顿感会小很多。
下一篇预告
数据有了,但坐标是世界坐标(三维),屏幕是二维的,中间差一步投影。下一篇写 WorldToScreen:读视图矩阵,做向量乘法,把游戏世界里的坐标换算成你屏幕上的像素点——方框画在哪,就靠它说了算。

发表评论
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。