简介面向《魔力宝贝》玩家、游戏逆向与网络编程学习者的看血辅助工具及完整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::vectorUnitrender层只管把这个 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_castuintptr_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_listuintptr_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_castLPCVOID(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 的值基本都是空指针或者垃圾值。加上这一行能挡掉一大半「读到野指针然后崩溃」的情况。整张偏移表建议单独拎出来做成配置配置项示例值含义配错时的表现baseOffset0x008B4C20静态基址相对模块链条第一级就断全表空白chain0x10, 0x2C, 0x8多级指针偏移序列读到野指针数值全是天文数字unitPtrSize4数组元素是指针4 字节每隔几个单位乱一次hpOff0x20结构体内当前血量偏移血量列恒为 0hpMaxOff0x24结构体内最大血量偏移血条比例算不出来恒满或恒空maxCount20单次最多遍历多少个单位人多时漏怪人少时读到野数据这张表后面在二次开发章节会被整个搬进 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::vectorUnit out) { out.clear(); out.reserve(maxCount); for (int i 0; i maxCount; i) { uintptr_t p 0; // 数组元素是指针每个 4 字节 if (!ReadProcessMemory(h, reinterpret_castLPCVOID(listAddr i * 4), p, sizeof(p), nullptr)) break; if (p 0x10000) continue; // 空槽位跳过而不是 break UnitRaw raw{}; if (!ReadProcessMemory(h, reinterpret_castLPCVOID(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_castchar*(raw.name), strnlen(reinterpret_castchar*(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_castdouble(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_castLONG(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, LOverlay, 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让它不出现在任务栏和 AltTab 列表里。LWA_COLORKEY是关键色透明——把纯黑RGB(0,0,0)当透明色。所以绘制的时候千万别用纯黑画东西否则会被抠掉表现为「血条边框莫名其妙缺一块」。这是个很隐蔽的坑我第一次遇到的时候盯着代码看了半小时。刷新用一个SetTimer100ms 一次每次先把整个窗口用背景色填一遍再遍历单位画血条。因为窗口本身有透明色填背景色的效果就是「擦掉上一帧」。4. 跑起来编译环境、注入方式与自检步骤源码能编、能跑、能读到真数据中间还是隔了几道坎。4.1 编译环境与依赖依赖只有三样Visual Studio或者 MinGW-w64、CMake、Windows SDK。没有第三方库所以不用折腾包管理。# 关键必须编 32 位。目标进程是 32 位64 位程序读不了它的指针 cmake -S . -B build -A Win32 -DCMAKE_BUILD_TYPERelease 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::vectorUnit 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::vectoruintptr_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吞掉。结果版本一更新两百多处硬编码改到怀疑人生最后干脆推倒重写。从那以后我每次碰这类读内存的项目第一版就一定把偏移外部化、把读失败的原因打日志、把绘制资源做成全局缓存——哪怕当时看起来完全是过度设计。希望这些经验能帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?