Win32外部内存读取与覆盖层绘制:单机游戏血条工具源码解析
简介:面向《魔力宝贝》玩家、游戏逆向与网络编程学习者的看血辅助工具及完整C++源码,主要用于实时查看角色血量,技术点涵盖客户端与服务器通信、数据包抓取、协议字段识别与界面展示。项目在Visual Studio 2005环境下以C++开发,可在Windows XP中运行,包含对话框界面、业务逻辑、资源脚本和工程配置,适合研究Win32/MFC、内存管理、网络通信及早期游戏辅助工具实现思路的读者参考。压缩包共18个文件、约207KB,以h头文件和cpp实现为主,并配有vcproj/sln工程、exe可执行程序、rc资源、ico图标及ReadMe说明,既能直接运行,也便于在VS环境中重新编译、对照源码定位功能入口和继续开发。已有9126人学习下载,可作为了解辅助工具工程组织、通信协议分析流程与调试排错方式的案例;实际使用时应尊重游戏规则与合规边界。
1. 这套看血工具到底在做什么:从「看不见血」的测试环境说起
去年冬天帮人整理一个老回合制网游的单机测试环境,对方的需求朴素到有点好笑:战斗里看不到对面还剩多少血,只能凭感觉判断什么时候收手。这套看血工具就是为这种场景写的,带完整源码。它不往游戏里注入任何东西,而是挂在目标进程外面,按固定节奏把内存里的单位数组读出来,取出血量、最大血量和格子坐标,再用一个透明覆盖窗口把血条画在角色头顶。适合两类人:一类是想照着把「进程内存读取 + 覆盖层绘制」这条链路走通的新手,一类是手里已经攒了一堆偏移地址、想把工具改成自己信息面板的老手。先说清楚,这类东西只在自建的单机或测试环境里做技术验证,别往线上服上怼,后果自负。
2. 架构拆解:从进程句柄到血条覆盖层的四层结构
整套工具拆开看就四层,一层比一层靠上,任何一层出问题都表现为「屏幕上什么都没有」,所以先把边界划清楚,后面排查才不会抓瞎。
2.1 两条技术路线的取舍:外部读取还是进程内注入
做这类工具,业内基本就两条路。
第一条是外部读取:工具自己是独立进程,用OpenProcess拿到目标进程句柄,再用ReadProcessMemory一批一批地把数据拷到自己的地址空间里。优点是稳——工具崩了游戏不受影响,调试的时候可以直接在 VS 里下断点看变量,不用挂着调试器往人家进程里塞 DLL。缺点是每次读都是一次跨进程调用,成本比直接访问内存高一个数量级,而且没法挂钩子,只能被动「看」,不能拦函数。
第二条是进程内注入:写个 DLL,注入进去之后就是同进程了,直接解引用指针,速度飞快,还能挂 API 钩子干更复杂的事。代价是调试体验极差——DLL 一崩,宿主进程跟着一起走,而且版本一更新就要重新编译重新注入,改一版试一版,心态容易崩。
这套源码走的是第一条路,外部读取 + 独立覆盖窗口。理由很实际:看血这个需求本质上是只读的,我不需要改任何东西,只需要每隔一小段时间把一张快照拉出来。既然如此,就没必要为了那点延迟去冒崩溃的风险。常见的刷新节奏是 100ms 一次,也就是每秒 10 帧,人眼看上去已经是连续的了;如果追求更跟手,可以压到 50ms,但要注意 CPU 占用会明显上升,因为每次刷新都是一轮完整的指针链解析加数组遍历。
这里有个容易被忽略的取舍点:读得越频繁,读到「半更新状态」的概率越高。游戏在切场景、单位进出场的时候,单位数组是在动态增删的,你恰好在它改到一半的时候去读,就可能拿到一个已经被释放的地址,读出来一堆垃圾数。所以刷新频率不是越高越好,100ms 是个在跟手性和稳定性之间比较舒服的位置,我后面在避坑章节里还会提到用双读校验来兜这个底。
2.2 工程目录与文件职责
源码包的组织方式是典型的 Win32 小工具结构,没有用 MFC,也没有引第三方 UI 库,全靠原生 API 堆出来。
| 目录 / 文件 | 职责 | 备注 |
|---|---|---|
src/main.cpp | 程序入口,创建覆盖窗口、启动刷新定时器 | 逻辑最薄的一层,只做调度 |
src/process.cpp/.h | 进程查找、句柄管理、ReadProcessMemory封装 | 所有跨进程读都从这里走 |
src/pointer.cpp | 多级指针链解析、模块基址计算 | 偏移配置集中在这里 |
src/unit.cpp | 单位结构体定义、数组遍历、脏数据过滤 | 改字段主要动这里 |
src/render.cpp | 坐标换算、GDI 血条绘制、双缓冲 | 与游戏逻辑完全解耦 |
src/config.h | 基址、偏移、刷新间隔、颜色阈值 | 建议改成外部配置文件 |
res/ | 图标、字体等资源 | 非必需,删掉也能编 |
CMakeLists.txt | 构建脚本 | 强制 32 位编译,原因见第 5 章 |
这个分层最关键的一点是render和unit之间没有直接依赖:unit层只管吐出一个std::vector<Unit>,render层只管把这个 vector 画出来。好处是我想换绘制方案(比如从 GDI 换成 Direct2D)时,完全不用碰内存读取那部分代码;反过来,偏移失效了也只需要在pointer和unit里改,绘制逻辑一行不动。
2.3 关键数据结构:单位对象在内存里长什么样
这一步是整个项目的地基。目标客户端里,每个战斗单位(玩家、宠物、怪)都是一块连续的结构体内存,字段按固定偏移排布。源码里对它的还原是这样:
#pragma pack(push, 1) // 必须按 1 字节对齐,否则偏移全错 struct UnitRaw { uint32_t id; // 0x00 单位唯一 ID,用来做去重和缓存 key uint32_t type; // 0x04 类型:0 玩家 / 1 宠物 / 2 怪物 int32_t gridX; // 0x08 地图格 X int32_t gridY; // 0x0C 地图格 Y uint8_t reserved[0x10]; // 0x10 ~ 0x1F 未解析字段,留白跳过 uint32_t hp; // 0x20 当前血量 uint32_t hpMax; // 0x24 最大血量 uint8_t name[32]; // 0x28 名称,GBK 编码,0 结尾 }; #pragma pack(pop)几个点值得说清楚。
#pragma pack(push, 1)不能省。默认对齐下,编译器会在字段之间塞填充字节,hp的偏移可能就从 0x20 变成 0x24,然后你读出来的血量永远是 0,查半天以为是基址错了。这种玄学问题踩过一次就记住了。
reserved那段是故意留白的。逆向的时候经常会遇到「这块区域明显有数据,但暂时不知道是什么」的情况,与其一个个去猜,不如先跳过,把已知字段跑通再说。后面想做扩展(比如加行动条、加状态标记),就是往这个留白区里填。
name用 GBK 而不是 UTF-8,是因为目标客户端本身是那个年代的产品,字符串就是 GBK 存的。直接std::string接过来打印会乱码,得转一道MultiByteToWideChar(936, ...)。
字段偏移是怎么来的?标准流程是:先用内存扫描工具搜当前血量这个数值(战斗中血量会变,搜变化的数值很容易定位),找到地址后下「谁访问了这个地址」的断点,回溯到指令里用的基址寄存器,再一层层往上追,最终落成一个模块基址 + 一级偏移 + 二级偏移 + ... + 字段偏移的链条。这个过程没什么捷径,就是耐心。
3. 核心链路实现:一次血量刷新从读内存到画到屏幕上
链路拉直了就四步:开进程拿基址 → 走指针链找到数组 → 遍历数组过滤脏数据 → 换算坐标画血条。每一步都有它自己的坑。
3.1 第一步:拿到进程句柄与模块基址
先按进程名找到目标,拿句柄,再取主模块的加载基址。注意基址每次启动都可能不一样(地址随机化),所以配置里存的必须是模块内偏移,运行时再加上实际基址。
// 按进程名查找,返回 PID;找不到返回 0 DWORD FindPidByName(const wchar_t* exeName) { HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snap == INVALID_HANDLE_VALUE) return 0; PROCESSENTRY32W pe{ sizeof(pe) }; DWORD pid = 0; if (Process32FirstW(snap, &pe)) { do { if (_wcsicmp(pe.szExeFile, exeName) == 0) { pid = pe.th32ProcessID; break; } } while (Process32NextW(snap, &pe)); } CloseHandle(snap); return pid; } // 打开进程并取主模块基址 HANDLE OpenTarget(DWORD pid, uintptr_t& moduleBase) { // 只要读权限和查询权限,别多要,权限越大越容易被拦 HANDLE h = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, FALSE, pid); if (!h) return nullptr; HMODULE mods[256]{}; DWORD needed = 0; // LIST_MODULES_32BIT:目标必须是 32 位进程,原因见第 5 章 if (!EnumProcessModulesEx(h, mods, sizeof(mods), &needed, LIST_MODULES_32BIT)) { CloseHandle(h); return nullptr; } moduleBase = reinterpret_cast<uintptr_t>(mods[0]); // 第一个即主模块 return h; }OpenProcess的权限位只申请PROCESS_VM_READ和PROCESS_QUERY_INFORMATION两个就够了。见过不少人图省事直接上PROCESS_ALL_ACCESS,结果在部分环境下被安全软件直接拦下来,返回NULL,然后以为是进程名写错了,查半天。权限要得越少,成功率越高,这是血泪经验。
EnumProcessModulesEx的第四个参数LIST_MODULES_32BIT是给 64 位系统上跑 32 位目标用的。如果你自己编的是 64 位程序去读 32 位进程,模块枚举会失败或者返回一堆 64 位的模块,后面所有指针全部对不上。所以这个项目在 CMake 里强制-A Win32,不是随便定的。
3.2 第二步:多级指针链,从静态基址走到单位数组
目标客户端的单位数组藏得比较深,从模块基址要走三级才能摸到。这个「走」的过程每家写法都差不多:
// chain 里最后一项只加偏移不解引用,前面的都要解引用 bool ReadChain(HANDLE h, uintptr_t base, std::initializer_list<uintptr_t> chain, uintptr_t& outAddr) { uintptr_t addr = base; size_t i = 0, n = chain.size(); for (uintptr_t off : chain) { if (++i == n) { // 最后一级:算出最终地址就返回 outAddr = addr + off; return true; } uintptr_t next = 0; if (!ReadProcessMemory(h, reinterpret_cast<LPCVOID>(addr + off), &next, sizeof(next), nullptr)) { return false; // 这一级读失败,链条断了 } if (next < 0x10000) { // 空指针 / 野指针过滤 return false; } addr = next; } return false; }第一个参数是进程句柄,第二个是模块基址,第三个是偏移列表,第四个是出参。调用起来长这样:
uintptr_t listAddr = 0; // 0x008B4C20 是相对模块基址的静态偏移,0x10 / 0x2C / 0x8 是三级指针偏移 if (!ReadChain(hProc, moduleBase, {0x008B4C20, 0x10, 0x2C, 0x8}, listAddr)) { // 链条断了:要么游戏没进战斗,要么偏移过期 return; }next < 0x10000这个判断看着粗糙,但极其有用。Windows 用户态内存的低 64KB 是保留区,正常情况下不可能有有效对象落在那里,所以任何小于 0x10000 的值基本都是空指针或者垃圾值。加上这一行,能挡掉一大半「读到野指针然后崩溃」的情况。
整张偏移表建议单独拎出来做成配置:
| 配置项 | 示例值 | 含义 | 配错时的表现 |
|---|---|---|---|
baseOffset | 0x008B4C20 | 静态基址(相对模块) | 链条第一级就断,全表空白 |
chain | 0x10, 0x2C, 0x8 | 多级指针偏移序列 | 读到野指针,数值全是天文数字 |
unitPtrSize | 4 | 数组元素是指针,4 字节 | 每隔几个单位乱一次 |
hpOff | 0x20 | 结构体内当前血量偏移 | 血量列恒为 0 |
hpMaxOff | 0x24 | 结构体内最大血量偏移 | 血条比例算不出来,恒满或恒空 |
maxCount | 20 | 单次最多遍历多少个单位 | 人多时漏怪,人少时读到野数据 |
这张表后面在二次开发章节会被整个搬进 JSON 文件,改偏移不用重新编译。
3.3 第三步:遍历单位数组并做有效性过滤
拿到数组首地址之后,还不能直接当结构体数组用——因为数组里存的不是结构体本身,而是指向结构体的指针。所以多了一层间接:
struct Unit { uint32_t id, hp, hpMax; int32_t gx, gy; std::string name; }; void CollectUnits(HANDLE h, uintptr_t listAddr, int maxCount, std::vector<Unit>& out) { out.clear(); out.reserve(maxCount); for (int i = 0; i < maxCount; ++i) { uintptr_t p = 0; // 数组元素是指针,每个 4 字节 if (!ReadProcessMemory(h, reinterpret_cast<LPCVOID>(listAddr + i * 4), &p, sizeof(p), nullptr)) break; if (p < 0x10000) continue; // 空槽位,跳过而不是 break UnitRaw raw{}; if (!ReadProcessMemory(h, reinterpret_cast<LPCVOID>(p), &raw, sizeof(raw), nullptr)) continue; // 三重合理性校验,缺一不可 if (raw.hpMax == 0 || raw.hpMax > 999999) continue; if (raw.hp > raw.hpMax) continue; if (raw.gridX < 0 || raw.gridX > 200 || raw.gridY < 0 || raw.gridY > 200) continue; Unit u; u.id = raw.id; u.hp = raw.hp; u.hpMax = raw.hpMax; u.gx = raw.gridX; u.gy = raw.gridY; u.name.assign(reinterpret_cast<char*>(raw.name), strnlen(reinterpret_cast<char*>(raw.name), 32)); out.push_back(std::move(u)); } }continue和break的选择是有讲究的。读到空指针要用continue——战斗中单位数组中间经常有空槽(怪被打死之后槽位还在,指针被清空),如果这里break,后面的单位全部读不到,表现就是「打着打着血条少了一半」。而读取本身失败(ReadProcessMemory返回 false)才用break,因为那意味着整个地址空间已经不可访问了,继续循环没有意义。
三重校验(hpMax范围、hp <= hpMax、坐标范围)是为了挡脏数据。刚说的「半更新状态」读出来的典型特征就是hp比hpMax大,或者坐标是个负数。这些数据一旦进了绘制层,就会在屏幕外画一条血条,或者算出个负数宽度导致FillRect行为诡异。
名称那段用strnlen而不是strlen,是因为内存里的字符串不保证有 0 结尾,直接strlen有可能一路读到下一个字段去,轻则乱码重则访问违例。这种细节在正常代码里是常识,但在读别人内存的时候特别容易忘。
3.4 第四步:世界坐标到屏幕坐标,再把血条画上去
这是最容易出偏差的一步。目标客户端是 45 度斜视的格子地图,世界坐标用的是格子索引,得先转成等距投影的像素坐标,再叠加摄像机偏移。
// 格子坐标 -> 屏幕像素坐标(等距投影) // tileW / tileH 是单格地砖的宽高,需按实际素材实测 POINT GridToScreen(int gx, int gy, int camGx, int camGy, int originX, int originY, int tileW, int tileH) { int dx = gx - camGx; // 相对摄像机的格子差 int dy = gy - camGy; POINT pt; pt.x = originX + (dx - dy) * (tileW / 2); pt.y = originY + (dx + dy) * (tileH / 2); return pt; } // 画一条带背景槽的血条 void DrawHpBar(HDC dc, int cx, int cy, uint32_t hp, uint32_t hpMax) { const int W = 40, H = 4; RECT back{ cx - W / 2, cy - 34, cx + W / 2, cy - 34 + H }; HBRUSH b = CreateSolidBrush(RGB(30, 30, 30)); FillRect(dc, &back, b); DeleteObject(b); double ratio = hpMax ? static_cast<double>(hp) / hpMax : 0.0; // 三档配色:绿 / 黄 / 红,阈值按自己习惯调 COLORREF c = ratio > 0.5 ? RGB(80, 200, 80) : ratio > 0.2 ? RGB(230, 180, 60) : RGB(220, 60, 60); RECT fg = back; fg.right = fg.left + static_cast<LONG>(W * ratio); HBRUSH f = CreateSolidBrush(c); FillRect(dc, &fg, f); DeleteObject(f); }originX / originY是地图原点的屏幕位置,camGx / camGy是当前摄像机对准的格子(一般就是主角自己的坐标)。这个- camGx的减法不能省,省了之后表现就是「血条全堆在屏幕左上角」,或者「人一走血条就飘走」。
tileW / tileH这两个参数没有通用值,得看具体素材。常见做法是先用 32/16 试一版,截图对比一下血条是不是刚好压在角色脚下,差多少就调多少。这是个体力活,但只调一次。
窗口本身要设成三层属性叠加:
HWND hwnd = CreateWindowExW( WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_TOOLWINDOW, kClassName, L"Overlay", WS_POPUP, 0, 0, screenW, screenH, nullptr, nullptr, hInst, nullptr); // 关键色抠图:把纯黑当成透明色 SetLayeredWindowAttributes(hwnd, RGB(0, 0, 0), 0, LWA_COLORKEY);WS_EX_LAYERED让窗口支持透明,WS_EX_TRANSPARENT让鼠标点击穿透到底下的游戏窗口(不加这个,覆盖层会把所有点击吃掉,游戏直接没法操作),WS_EX_TOPMOST保证它在最前面,WS_EX_TOOLWINDOW让它不出现在任务栏和 Alt+Tab 列表里。
LWA_COLORKEY是关键色透明——把纯黑RGB(0,0,0)当透明色。所以绘制的时候千万别用纯黑画东西,否则会被抠掉,表现为「血条边框莫名其妙缺一块」。这是个很隐蔽的坑,我第一次遇到的时候盯着代码看了半小时。
刷新用一个SetTimer,100ms 一次,每次先把整个窗口用背景色填一遍,再遍历单位画血条。因为窗口本身有透明色,填背景色的效果就是「擦掉上一帧」。
4. 跑起来:编译环境、注入方式与自检步骤
源码能编、能跑、能读到真数据,中间还是隔了几道坎。
4.1 编译环境与依赖
依赖只有三样:Visual Studio(或者 MinGW-w64)、CMake、Windows SDK。没有第三方库,所以不用折腾包管理。
# 关键:必须编 32 位。目标进程是 32 位,64 位程序读不了它的指针 cmake -S . -B build -A Win32 -DCMAKE_BUILD_TYPE=Release cmake --build build --config Release --parallel # 产物 # build/Release/HPViewer.exe-A Win32这个参数是整个构建里最重要的一行。我见过不止一个人在这里翻车:直接cmake -S . -B build走默认,编出来 64 位,运行之后EnumProcessModulesEx返回的基址是个 0x7FF 开头的天文数字,然后所有指针链全部算错,屏幕上一条血条都没有。查的时候还以为是偏移失效,重新逆了一遍,白忙一晚上。
如果用 MinGW,对应的参数是-m32,并且要确认装的是 32 位工具链(i686-w64-mingw32-g++)。顺手把sizeof(void*) == 4打成static_assert放在main.cpp顶上,编错位宽的时候直接编译期报错,比运行期抓瞎强。
4.2 首次运行的四步
程序启动之后不是马上就能看到血条的,得按顺序来:
- 先启动目标客户端,进入战斗场景。非战斗状态下单位数组是空的,程序不会报错,就是什么都不画——这一步劝退过不少人。
- 运行
HPViewer.exe。程序会自动按进程名查找目标,找到的话窗口标题区会显示 PID 和模块基址。 - 切回游戏窗口,按
F8手动触发一次刷新(源码里留了这个热键,方便调试)。正常情况下屏幕上方会浮出一排小黑条。 - 如果血条位置对不上,调
config.h里的tileW / tileH / originY三个值,重新编译。这三个值调好了基本就不用再动。
第 3 步那个F8热键是调试期的救命稻草。自动刷新 100ms 一次,出问题的时候你根本不知道是哪一帧出的错;改成手动触发,就能一帧一帧地对着看。
4.3 读数自检:怎么确认你读到的是真的
这是整个流程里最容易被跳过、但最不该跳过的一步。血条画出来了不等于读对了——它可能读到的是上一条命的残留数据,也可能是某个长得像单位结构体的别的东西。
源码里带了一个dump模式(命令行加--dump),效果是把当前读到的所有单位原样打到控制台:
void DumpUnits(const std::vector<Unit>& units) { printf("count=%zu\n", units.size()); for (const auto& u : units) { printf("id=%-8u hp=%5u/%-5u grid=(%3d,%3d) name=%s\n", u.id, u.hp, u.hpMax, u.gx, u.gy, u.name.c_str()); } }自检的方法很土但很有效:让一个怪掉血,同时盯着dump输出,看对应id那一行的hp是不是跟着从满血往下走;怪死了之后,那一行应该从列表里消失(或者hp归零)。如果血量对得上但名字是乱码,那是编码问题(GBK 没转);如果血量对不上,那是hpOff错了;如果列表里单位数量比画面上多出好几个,那是过滤条件太松,得把坐标范围收紧。
这一步多花十分钟,后面能省两小时。真的。
5. 避坑与排查:五条血泪踩坑记录
下面这五条都是实际撞过的,按「现象 → 原因 → 解决」写,遇到同类问题可以直接对号入座。
5.1 读数类的三个坑
坑一:所有单位血量恒为 0,但数组地址是对的。
现象是dump出来的count正常、名字也可能正常,唯独hp全是 0。
原因几乎都是结构体对齐。UnitRaw定义的时候忘了#pragma pack(push, 1),编译器在reserved和hp之间插了填充字节,实际偏移从 0x20 变成了 0x24,你按 0x20 去读,读到的是填充区。
解决办法是先把sizeof(UnitRaw)打出来,跟逆向时确认的结构体大小对一遍,对不上就说明对齐错了。养成习惯:任何从外部内存映射过来的结构体,一律 1 字节对齐,没有例外。
坑二:运行几秒后突然闪退,或读到一堆七位数血量。
现象是刚启动好好的,打了几回合就崩,或者hp出现4294967295这种值。
原因是读到了「半更新状态」——游戏在释放或重建单位对象的那一瞬间,指针还没改完,你读到的地址指向的是一块正在被改写的内存。也可能是野指针,那个地址压根已经不属于这个进程了。
解决办法有两条:一是所有读出来的数据都过一遍合理性校验(hpMax上限、hp <= hpMax、坐标范围),不合法就丢弃而不是硬画;二是加双读校验,隔 5ms 连读两次,两次结果一致才采用。第二条会多一点开销,但把「偶发崩溃」这个玄学问题压下去了。
坑三:游戏更新之后,工具彻底失灵。
现象很干脆:链条第一级就断,ReadChain直接返回 false。
原因就是偏移变了。客户端一更新,基址和结构体布局都可能动,硬编码的偏移必然失效。
解决办法是不要把偏移写死在代码里,挪到外部配置文件(下一章会讲),失效了改文件就行,不用重新编译。更彻底一点的做法是特征码扫描:不去记死偏移,而是记一段字节特征,运行时在模块内存里搜这段特征,搜到了位置就能反推出基址。这种做法抗版本更新的能力强很多,代价是实现复杂度上去了,适合偏移变化频繁的场景。
5.2 绘制与稳定性类的两个坑
坑四:血条位置对不上,人物一走动条就飘。
现象是站着不动的时候血条压得挺准,一移动就开始偏移,走得越远偏得越多。
原因有两层。一层是坐标换算里忘了减摄像机偏移,camGx / camGy一直用的初始值没更新;另一层是用的是窗口客户区坐标,没做ClientToScreen转换,窗口一挪位置就全错。
解决办法:摄像机坐标每帧从主角自己的单位对象里取(主角的id是固定的,或者干脆取列表里type == 0的那个),不要缓存;屏幕坐标统一走一遍ClientToScreen,顺带处理一下 DPI 缩放——高分屏下如果不除GetDpiForWindow() / 96.0,整张覆盖层会缩成一个角。
坑五:CPU 占用飙到 20%,风扇狂转。
现象是任务管理器里这个工具比游戏还吃 CPU。
原因是每一帧都在CreateSolidBrush然后DeleteObject。GDI 对象的创建销毁是很贵的操作,一帧画 20 条血条就是 40 次创建销毁,每秒 10 帧就是 400 次,白白烧 CPU。
解决办法是把画刷缓存起来,绿色、黄色、红色、背景色四个画刷在初始化时创建一次,全局复用,程序退出时再销毁。这一步改完 CPU 占用能从 20% 掉到 1% 以内。顺手再加个双缓冲(先画到内存 DC,再一次性BitBlt到窗口),闪烁问题也一起解决了。
另外一个变种情况:全屏独占模式(Exclusive Fullscreen)下覆盖层完全不显示。这个不是 bug,是独占全屏会绕过窗口管理器。解决办法是把游戏改成窗口化或无边框窗口化,工具本身没法绕过这个。
6. 二次开发:把看血改成自己的战场信息面板
源码跑通之后,剩下的事就是改成自己想要的样子。这部分我给三个改造方向,从易到难。
6.1 第一步:把硬编码偏移搬进 JSON
改偏移要重新编译,这件事在调试期极其痛苦——改一个数字,编 30 秒,跑一遍,再改。所以第一件事就是把config.h里的常量全挪出去。
// 用 nlohmann/json 之类的小库,或者干脆自己写个 200 行的极简解析 struct Config { uintptr_t baseOffset = 0x008B4C20; std::vector<uintptr_t> chain = {0x10, 0x2C, 0x8}; int unitPtrSize = 4; uintptr_t hpOff = 0x20; uintptr_t hpMaxOff = 0x24; int maxCount = 20; int tileW = 32, tileH = 16; int refreshMs = 100; static Config Load(const char* path); };配套的config.json长这样:
{ "baseOffset": "0x008B4C20", "chain": ["0x10", "0x2C", "0x8"], "unitPtrSize": 4, "hpOff": "0x20", "hpMaxOff": "0x24", "maxCount": 20, "tileW": 32, "tileH": 16, "refreshMs": 100 }注意十六进制得存字符串再自己转,JSON 标准不支持0x字面量。转的时候统一走一个ParseHex,别一边stoul(s, nullptr, 16)一边stoul(s, nullptr, 0),混着用迟早出问题。
配置文件放在 exe 同级目录,热重载可以不做,重启一下程序也就两秒的事。真要做热重载,监听文件修改时间就行,别上文件系统监视那一套,杀鸡用牛刀。
6.2 第二步:加字段与增量刷新
看血只是最基础的信息。实战里真正有用的是「谁先动」——也就是行动顺序。这部分数据通常在单位结构的留白区里,逆向出来之后按 6.1 的方式加成actOff配置项,然后在Unit结构体里补一个字段。
改的时候有个原则:只加字段,不改已有的过滤逻辑。因为过滤条件是所有数据的兜底,动它等于把地基挖了。新加的字段如果读出来不合理(比如行动值超出范围),单独做一个「字段级」的过滤,不要影响整个单位被丢弃。
第二个优化是增量刷新。现在每次都是全量遍历所有槽位,20 个槽位 × 一次间接指针读 × 一次结构体读,大概 40 次跨进程调用,100ms 一轮其实还行。但如果把maxCount开到 50,或者刷新压到 30ms,就明显吃力了。
常见做法是按id做缓存:上一轮读到的id集合留着,这一轮只对新增的id做完整结构体读,已存在的只读血量那几个字段(hp一个字段,一次调用)。单位死亡的时候从缓存里删掉。这样稳态下的调用次数能砍掉一半以上。
6.3 怎么验证改对了
改完不是看着「好像没崩」就算完,得有个可复现的验证流程。我一般用三组对照:
| 验证项 | 操作 | 预期结果 |
|---|---|---|
| 血量准确性 | 让一个怪掉固定数值的血 | dump里对应 id 的hp差值和操作一致 |
| 单位增减 | 打死一个怪、再招一个宠 | 列表长度先减后增,无残留 |
| 位置一致性 | 人物走到地图四个角 | 血条始终压在角色脚下,偏移量恒定 |
三组都过了再谈性能。性能这块用最土的办法测:任务管理器看 CPU,连续跑 10 分钟,占用稳定在 2% 以内就算过。如果不稳定、越跑越高,八成是 GDI 对象没释放,用任务管理器的「GDI 对象」列看一眼数量是不是在涨,涨了就是漏了。
顺带提一句验证时的一个小技巧:把refreshMs临时改成 500,肉眼观察每一帧的变化,比盯着 100ms 的连续刷新好判断得多。调通了再改回去。
我一个很深的教训是,早期做这类工具的时候总想着「先跑起来再说」,偏移写死、画刷不缓存、错误全靠return吞掉。结果版本一更新,两百多处硬编码改到怀疑人生,最后干脆推倒重写。从那以后我每次碰这类读内存的项目,第一版就一定把偏移外部化、把读失败的原因打日志、把绘制资源做成全局缓存——哪怕当时看起来完全是过度设计。希望这些经验能帮到你。
本文还有配套的精品资源,点击获取