简介:这份面向《魔力宝贝》玩家与C++逆向编程学习者的辅助工具资料,聚焦游戏内角色血量查看需求,提供可运行程序及配套工程源码。压缩包共18个文件、约207KB,以C++源文件与头文件为主体,辅以VS工程配置、资源脚本、图标和可执行文件,便于在Visual Studio 2005环境中打开、编译和继续研究;其中程序基于Win32/MFC界面与网络数据解析实现血量显示,涉及数据包抓取、通信协议分析与内存、指针操作等知识点。已有9126人浏览学习,说明该案例在游戏辅助与逆向入门圈具有一定参考价值。读者可从中了解一个完整小型辅助工具的项目组织方式、界面与逻辑分层、协议字段识别思路及调试排错过程,也可把源码当作网络编程和逆向工程的练习样本;需注意此类工具可能违反游戏用户协议,应仅用于技术学习并尊重游戏公平性。
1. 一个看血工具为什么值得拆:从只读内存到悬浮窗的最小闭环
朋友把这份「看血工具及其源码」丢给我时,我第一反应是这不就是个读内存的小玩意吗。真把压缩包解开顺着源码读下去,才发现它是一个相当完整的 Windows 桌面开发切片:进程枚举、OpenProcess拿句柄、模块基址加多级偏移解引用、内存区域扫描定位数值,最后用分层窗口把血条画在游戏画面角落。整条链路不长,但每一环都能单独拎出来讲,也每一环都能翻车。它适合三类人:想入门 Windows 内存读写的 C++ 新手、想搞懂悬浮 HUD 怎么贴在别人窗口上的桌面开发者、手里有老工具要维护的同学。这份源码最大的技术边界是「只读」——它不写目标进程的内存,改数据这条路根本没铺,所以整篇笔记也都围着「读」和「画」展开。
2. 工程结构与编译:一个 Win32 小工具的模块切分
2.1 目录结构与文件职责
这类工具最怕写成一个几千行的main.cpp,读到一半就迷路。这份源码的目录大致按职责切成了七块,边界比较清晰,我按常见组织方式还原一下:
LookHp/ ├── src/ │ ├── main.cpp // 入口:命令行参数、单实例互斥、启动采样与 UI │ ├── process_reader.h/.cpp // 进程附着、内存读取、模块基址、指针链解析 │ ├── scanner.h/.cpp // 内存区域遍历与数值筛选,用来定位血量地址 │ ├── sampler.h/.cpp // 采样线程:按固定节奏读当前/最大 HP │ ├── overlay_window.h/.cpp // 分层透明窗口、GDI 双缓冲绘制 │ ├── config.h/.cpp // 偏移链、窗口坐标、刷新间隔读写 ini │ └── util/ │ ├── log.h/.cpp // OutputDebugString + 滚动文件日志 │ └── str.h/.cpp // 宽窄字符转换、窗口标题匹配 ├── config/ │ └── offsets.ini // 基址模块名、偏移数组、血量合理区间 ├── build/ │ └── LookHp.sln // VS 工程,x86 配置 └── README.md| 文件 | 职责 | 改动频率 |
|---|---|---|
process_reader.cpp | 所有ReadProcessMemory的出口 | 低 |
scanner.cpp | 一次性调试工具,定位地址用 | 低 |
sampler.cpp | 采样节奏、数据缓存 | 中 |
overlay_window.cpp | 绘制与窗口样式 | 高 |
offsets.ini | 偏移链、血量区间 | 极高 |
把「读内存」和「画界面」拆成两个模块,是这份代码里最值钱的一点。采样线程只管往一个小的共享结构里写数据,UI 线程只管从里面取,中间用std::mutex或者原子变量隔开。后续想换绘制框架(比如换成 Direct2D 或 ImGui),只要不动process_reader,风险就锁在一个文件里。
2.2 技术栈与依赖选择
纯 Win32 + GDI,没有 MFC、没有 Qt、没有 ImGui,C++11 就够。这个选择在 2024 年看着有点土,但对这类工具是合适的:最终 exe 只有一两百 KB,启动无依赖,改颜色改布局也就是几行FillRect。真上 ImGui,光是引入imgui_impl_win32和imgui_impl_dx11就得多带几 MB 运行库,收益并不对等。
日志用OutputDebugStringW加一个文件句柄,Release下编译宏关掉输出。配置用 ini 不用 JSON:偏移链就那么几个数字,用GetPrivateProfileIntW读一行就够了,还免了引第三方解析库。这套组合的隐含要求是:目标客户端必须是 32 位。工具跑 x86 配置,读出来的指针是 4 字节;如果目标进程是 64 位,指针变 8 字节,偏移链上每一级都会错位,读出来的数字就是玄学。这一点后面避坑章节会再提一次。
2.3 编译与运行的最小步骤
环境准备:VS2019 或 VS2022,安装「使用 C++ 的桌面开发」工作负载。注意平台一定要选 x86、配置选 Release,Debug 版在某些环境下和采样线程配合会明显掉帧。
# 1. 用 VS 打开工程(也可以走命令行 MSBuild) msbuild build\LookHp.sln /p:Configuration=Release /p:Platform=Win32 # 2. 先以普通权限启动,工具会自己枚举进程 build\Release\LookHp.exe # 3. 若日志里出现 OpenProcess failed, err=5,右键以管理员身份重新运行err=5是ERROR_ACCESS_DENIED,十有八九是目标客户端本身以管理员权限在跑,权限低的进程读不了权限高的进程,这在 Windows 上是硬规则,不是代码 bug。msbuild那行里的/p:Platform=Win32不要省略,很多人直接敲msbuild xxx.sln会默认走 AnyCPU 或 x64,编译出来的 exe 一挂上去就读不到数据。
运行起来之后,工具会先读一次配置里的偏移链,成功就在游戏窗口左上角浮出一块半透明面板。如果面板出来但数字是 0,说明偏移链失效了,去config/offsets.ini检查基址模块名是不是写错,比如把Client.exe写成了client.exe——GetModuleBaseNameW匹配区分大小写时是比较严格的,我一般统一转小写再比。
3. 进程附着与内存读取:句柄、模块基址与偏移链怎么走通
3.1 打开进程与权限的坑
所有内存读取的第一步都是OpenProcess,这一步错了后面全白搭。源码里通常封装成Attach(pid):
bool ProcessReader::Attach(DWORD pid) { Detach(); // 先释放旧句柄,避免多开切换时句柄泄漏 m_handle = OpenProcess( PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, // 只读,够用 FALSE, pid); if (!m_handle) { DWORD err = GetLastError(); // 5 = ERROR_ACCESS_DENIED,权限不足 // 87 = ERROR_INVALID_PARAMETER,PID 已经退出 LOGW(L"OpenProcess failed, pid=%lu err=%lu", pid, err); return false; } m_pid = pid; return true; }权限掩码是关键参数。PROCESS_VM_READ决定能不能读内存,PROCESS_QUERY_INFORMATION决定能不能查模块信息。有人图省事直接传PROCESS_ALL_ACCESS,在开启了部分防护的系统上反而更容易失败,因为一次性申请全部权限更容易被拒。只申请需要的那两位,成功率明显高。
如果目标进程权限更高,就得给自己的进程提权。常见做法是启动时尝试打开SE_DEBUG_NAME:
bool EnableDebugPrivilege() { HANDLE token = nullptr; if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &token)) return false; TOKEN_PRIVILEGES tp{}; tp.PrivilegeCount = 1; LookupPrivilegeValueW(nullptr, SE_DEBUG_NAME, &tp.Privileges[0].Luid); tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED; BOOL ok = AdjustTokenPrivileges(token, FALSE, &tp, sizeof(tp), nullptr, nullptr); CloseHandle(token); // AdjustTokenPrivileges 返回 TRUE 也可能没生效,必须再查一次 GetLastError return ok && GetLastError() == ERROR_SUCCESS; }这个函数有个经典陷阱:AdjustTokenPrivileges返回TRUE并不代表权限真的加上了,必须再检查一次GetLastError()是否等于ERROR_SUCCESS。我第一次写这个函数时就被坑过,日志显示成功,实际还是读不到数据,查了半天才发现是账户本身没被授予调试特权。所以更稳的做法是:提权失败不阻断流程,只在日志里记一条,然后引导用户用管理员身份重启。
3.2 模块基址与多级指针
拿到句柄后,要解决「血量在哪」的问题。现代客户端几乎不会把血量放在固定绝对地址上,因为每次启动模块基址都会变(ASLR)。通用做法是先找模块基址,再沿偏移链逐级解引用:
uintptr_t ProcessReader::ModuleBase(const wchar_t* name) const { HMODULE mods[256]; DWORD needed = 0; if (!EnumProcessModulesEx(m_handle, mods, sizeof(mods), &needed, LIST_MODULES_ALL)) return 0; for (DWORD i = 0; i < needed / sizeof(HMODULE); ++i) { wchar_t path[MAX_PATH]{}; if (GetModuleBaseNameW(m_handle, mods[i], path, MAX_PATH) && _wcsicmp(path, name) == 0) { return reinterpret_cast<uintptr_t>(mods[i]); } } return 0; } uintptr_t ProcessReader::ResolvePointer(uintptr_t base, const std::vector<uintptr_t>& offsets) const { uintptr_t addr = base; for (size_t i = 0; i < offsets.size(); ++i) { uintptr_t next = 0; if (!Read(addr, next)) return 0; // 任意一级失败就返回 0,交给上层判断 addr = next + offsets[i]; } return addr; }ModuleBase里的LIST_MODULES_ALL是为了把 32 位模块也一起列出来,不加这个标志在某些 64 位进程里会漏掉模块。ResolvePointer的语义要讲清楚:它从base开始,读一次内存拿到指针值,加上偏移,再读,再加,直到偏移链走完,返回最终地址。偏移链通常形如[[[base + 0x1A2B3C] + 0x10] + 0x24],在 ini 里就存成0x1A2B3C,0x10,0x24三个数字。
注意Read的模板写法要按目标位数来。32 位进程里指针是 4 字节,所以next应该声明为uint32_t;如果工具编译成 64 位程序去读 32 位目标,用uintptr_t会一次读 8 字节,把相邻字段也读进来,偏移链立刻错位。这就是前面强调必须编 x86 的原因,不是玄学,是字节数。
3.3 内存扫描定位血量
偏移链不是天上掉的,是扫出来的。源码里scanner.cpp就是那个「一次性调试工具」:先知道血量当前值,然后在目标进程的可读写内存里找这个数,改一下血量再筛一次,直到候选地址收敛到个位数。
// 首次扫描:遍历目标进程所有已提交且可读写的内存区域 std::vector<uintptr_t> ScanInt32(HANDLE h, int32_t target) { std::vector<uintptr_t> hits; MEMORY_BASIC_INFORMATION mbi{}; uintptr_t addr = 0; const size_t kChunk = 64 * 1024; std::vector<uint8_t> buf(kChunk); while (VirtualQueryEx(h, reinterpret_cast<LPCVOID>(addr), &mbi, sizeof(mbi))) { uintptr_t regionEnd = reinterpret_cast<uintptr_t>(mbi.BaseAddress) + mbi.RegionSize; bool readable = (mbi.State == MEM_COMMIT) && (mbi.Protect & (PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE)); if (readable) { for (uintptr_t p = reinterpret_cast<uintptr_t>(mbi.BaseAddress); p < regionEnd; p += kChunk) { SIZE_T want = static_cast<SIZE_T>( min<uintptr_t>(kChunk, regionEnd - p)); SIZE_T got = 0; if (!ReadProcessMemory(h, reinterpret_cast<LPCVOID>(p), buf.data(), want, &got)) continue; // 区域中间可能有空洞,跳过即可,不要中断整轮扫描 for (size_t off = 0; off + sizeof(int32_t) <= got; off += 4) { int32_t v = *reinterpret_cast<int32_t*>(buf.data() + off); if (v == target) hits.push_back(p + off); } } } addr = regionEnd; } return hits; }VirtualQueryEx的返回值和mbi.RegionSize是循环的两个关键:前者为 0 表示走到地址空间末尾,后者告诉你这一段的长度,addr = regionEnd就是往下一段推进。mbi.State == MEM_COMMIT过滤掉未提交的保留区,Protect里带PAGE_READWRITE才是候选数据区,只读区和代码段基本不用扫。
ReadProcessMemory在一整段里也可能失败,比如中间夹着保护页。失败时直接continue而不是break,否则一个区域的头部失败会让后面几十 MB 全被跳过,扫描结果里就会莫名其妙地少一堆候选。步长用 4 是因为血量基本是 32 位整型,对齐访问命中率最高;有些客户端血量是浮点或int64,那就得把步长和比较方式一起改。
扫完之后还不是终点,几十万候选地址里怎么收敛到真地址?靠「值变化再筛」:让血量掉一点,用新值在候选集里过一遍,反复两三次,通常就剩两三个。剩下的地址再反查是谁指向它,一层层倒推回模块基址,得到的就是偏移链。这个过程没有捷径,纯手工加耐心,属于典型的血泪经验。
3.4 一个够用的 Reader 封装
把上面这些揉在一起,ProcessReader对外其实只需要四个方法:Attach、Detach、Read<T>、ResolvePointer。所有读取都走同一个出口,好处是错误处理只需要写一遍,日志里也能带上统一的 PID 和地址,出问题时排查路径非常短。
template <typename T> bool Read(uintptr_t addr, T& out) const { if (!m_handle) return false; SIZE_T got = 0; BOOL ok = ReadProcessMemory(m_handle, reinterpret_cast<LPCVOID>(addr), &out, sizeof(T), &got); return ok && got == sizeof(T); }got == sizeof(T)这个判断别省。ReadProcessMemory返回TRUE但只读了一部分的情况是存在的(跨页边界),此时out里是半截脏数据,直接拿去算血量比例会得到离谱的数字。加上长度校验,读失败就整条链判废,比事后对着屏幕上诡异的数值发呆强。
4. 悬浮窗与采样节奏:让 HUD 贴在别人窗口上还不卡
4.1 分层透明窗口与点击穿透
悬浮窗的核心是三个扩展样式:WS_EX_LAYERED负责透明度,WS_EX_TRANSPARENT负责鼠标点击穿透,WS_EX_TOPMOST负责置顶。少任何一个体验都会崩:没有穿透,面板挡住的地方点不动;没有置顶,切一下窗口面板就被盖住。
HWND OverlayWindow::Create(HINSTANCE hInst) { m_hwnd = CreateWindowExW( WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_TOOLWINDOW, kClassName, L"LookHp HUD", WS_POPUP, // 无边框,纯自绘 0, 0, 320, 96, nullptr, nullptr, hInst, nullptr); // 整体 alpha,232 左右既能看清字又不至于完全挡住画面 SetLayeredWindowAttributes(m_hwnd, 0, 232, LWA_ALPHA); ShowWindow(m_hwnd, SW_SHOWNOACTIVATE); // 不要抢焦点 return m_hwnd; }WS_EX_TOOLWINDOW的作用是让面板不出现在任务栏和 Alt+Tab 列表里,工具感更强。SW_SHOWNOACTIVATE保证窗口显示时不抢焦点,否则游戏会瞬间失去前台状态,有些客户端会因此暂停渲染,看着就像工具把游戏搞卡了。
窗口位置需要跟随目标窗口。常见做法是起一个 500ms 的定时器,用GetWindowRect拿目标窗口的客户区坐标,再用SetWindowPos把 HUD 挪过去。这里要注意 DPI:显示器缩放 125% 或 150% 时,GetWindowRect拿到的是物理像素,如果进程没声明 DPI 感知,Windows 会帮你做一次虚拟化缩放,坐标就对不上。在工程里加一个SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2),位置偏移的问题基本就消失了。
4.2 采样线程与刷新节奏
读内存和画界面必须分线程。放在 UI 线程里,ReadProcessMemory偶尔的几十微秒延迟会直接反映成面板抖动;拆成独立线程,采样节奏和渲染帧率就各管各的。
void Sampler::Run() { while (!m_stop.load(std::memory_order_relaxed)) { HpPair v{}; uintptr_t cur = m_reader.ResolvePointer(m_base, m_offsets); // cur == 0 表示偏移链任意一级读失败,这一轮直接跳过 if (cur && m_reader.Read(cur, v.current) && m_reader.Read(cur + 4, v.maximum)) { if (v.maximum > 0 && v.current >= 0 && v.current <= v.maximum) { std::lock_guard<std::mutex> lk(m_mtx); m_latest = v; // 只有通过合理性校验的数据才写进共享区 } } std::this_thread::sleep_for(std::chrono::milliseconds(m_intervalMs)); } }m_intervalMs一般取 150 到 200。取 16 跟着 60 帧刷新没有意义,血量不是每帧都变的量,读得太勤只会白烧 CPU,还增加被目标进程察觉的概率。cur + 4这种「结构体相邻字段」的写法依赖具体客户端的内存布局,源码里把它抽成常量,改的时候只改一处。
m_latest的写入条件加了合理性校验:current不能小于 0,也不能大于maximum。这条校验救过很多次,因为指针链短暂失效时读到的可能是堆上的随机值,不做校验就会在屏幕上画出超过 100% 的血条,看着像工具算错了,实际是读到了脏数据。用原子变量替换 mutex 也行,HpPair是 8 字节,在 x86 上不能保证原子,所以这里老老实实用锁,开销可以忽略。
4.3 绘制与双缓冲
GDI 直接往窗口 DC 上画会有闪烁,标准解法是内存 DC 双缓冲:先画到CreateCompatibleBitmap,最后一次BitBlt贴回去。
void OverlayWindow::OnPaint(HDC hdc) { RECT rc; GetClientRect(m_hwnd, &rc); HDC mem = CreateCompatibleDC(hdc); HBITMAP bmp = CreateCompatibleBitmap(hdc, rc.right, rc.bottom); HGDIOBJ old = SelectObject(mem, bmp); // 背景:半透明黑,和窗口整体 alpha 叠加后大概 60% 不透明 HBRUSH bg = CreateSolidBrush(RGB(16, 16, 16)); FillRect(mem, &rc, bg); HpPair v{}; { std::lock_guard<std::mutex> lk(m_mtx); v = m_latest; } float ratio = v.maximum > 0 ? float(v.current) / float(v.maximum) : 0.f; RECT bar = { 8, 40, 8 + int(304 * ratio), 74 }; HBRUSH fill = CreateSolidBrush(ratio > 0.3f ? RGB(64, 190, 100) : RGB(210, 70, 70)); FillRect(mem, &bar, fill); FrameRect(mem, &bar, (HBRUSH)GetStockObject(GRAY_BRUSH)); wchar_t text[64]; swprintf_s(text, L"HP %d / %d", v.current, v.maximum); SetBkMode(mem, TRANSPARENT); SetTextColor(mem, RGB(240, 240, 240)); DrawTextW(mem, text, -1, &rc, DT_LEFT | DT_TOP | DT_SINGLELINE); BitBlt(hdc, 0, 0, rc.right, rc.bottom, mem, 0, 0, SRCCOPY); DeleteObject(bg); DeleteObject(fill); DeleteObject(bmp); SelectObject(mem, old); DeleteDC(mem); }血量低于 30% 换红色,是个很小的细节但对可用性帮助很大——余光扫一眼颜色就知道该不该撤,不用去读数字。每次CreateSolidBrush都要配对DeleteObject,GDI 对象泄漏在长时间运行的工具里是会累积的,跑一整天之后面板开始变慢甚至画不出来,八成就是这里漏了。
渲染触发不要用WM_TIMER加InvalidateRect全量重绘,直接开一个 250ms 定时器并只在数据变化时InvalidateRect(hwnd, nullptr, FALSE)。第三个参数给FALSE表示不擦背景,配合双缓冲就不会闪。
4.4 多开场景下的窗口绑定
多开玩家最常问的是「能不能一个面板显示三个角色」。源码默认是单进程绑定,但改造空间留得挺足。常见做法是把「采样线程 + 数据」打包成一个Session对象,用一个std::vector<std::unique_ptr<Session>>管理,每个 Session 记自己的 PID、偏移链、窗口句柄。
struct Session { ProcessReader reader; DWORD pid; HWND targetWnd; // 目标客户端主窗口 HWND hudWnd; // 自己创建的 HUD uintptr_t base; std::vector<uintptr_t> offsets; };进程枚举用CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)遍历,配合Process32FirstW/NextW,按可执行文件名过滤,就能一次抓到所有同名客户端。每个 Session 独享一个句柄,避免多线程共用一个ProcessReader时的状态竞争。这个改法不难,难点在于 HUD 的排布——多块面板叠在一起会互相遮挡,我一般按窗口在屏幕上的相对位置自动错开,或者干脆只在一块面板里堆叠多行文本。
5. 避坑与常见问题:句柄失效、偏移漂移、杀软误报
5.1 句柄与权限类问题
现象:工具启动后日志里反复出现OpenProcess failed, err=5,或者初始能读到数据,切个角色之后数值就卡住不动了。
原因:err=5是权限不足,通常是目标客户端以管理员身份运行而工具没有;数值卡住多半是客户端重启过,PID 变了,旧句柄已经失效,但代码还拿着它读,ReadProcessMemory会持续返回失败。
解决:启动时先尝试提权,失败就提示用管理员重启;采样线程里加一个轻量的存活检测,WaitForSingleObject(handle, 0)返回WAIT_OBJECT_0就说明进程没了,此时重新枚举进程、重新Attach、重新解析基址。重连逻辑一定要写,否则用户每次重启客户端都得关一次工具,体验非常糟。
5.2 偏移链与版本漂移
现象:昨天还好好的,客户端更新一次之后血量全是 0 或者显示离谱的数字。
原因:客户端版本一更新,结构体布局和全局基址都会动,偏移链直接失效。这是这类工具最典型的「版本漂移」,几乎跟客户端更新周期同步。
解决:把偏移链全部挪进offsets.ini,代码里不写死任何一个数字。再加一层校验:读到的最大血量是否落在合理区间(比如 1 到 10 万),不满足就判定为无效并打日志。更新后只需要用scanner重新扫一遍,改 ini 就行,不用重新编译。我自己的习惯是给 ini 加一个version=字段,和客户端版本号对上,出问题时一眼能看出是不是配置过期。
5.3 位数与字节对齐
现象:明明偏移链是从另一台机器上抄来的,对方能用,自己这边解出来是个固定但毫无意义的地址。
原因:工具编成了 x64,目标是 32 位进程。指针从 4 字节变成 8 字节,每一级偏移都错位,最终算出来的地址自然到处乱飞。
解决:工程配置锁死 Win32,代码里所有指针中间变量用uint32_t而不是uintptr_t,并在Attach时用IsWow64Process检查一次位数,不匹配就在日志里明确提示。多花这十行代码,能省掉后面几个小时盯着地址发呆。
5.4 杀毒软件误报
现象:自己编译出来的 exe,双击就没了,或者被拦下来提示可疑。
原因:OpenProcess+ReadProcessMemory这套组合本身就是大量灰色工具的标准动作,行为特征很扎眼;加上个人编译的 exe 没有数字签名,启发式引擎容易直接判可疑。
解决:最稳的是自己从源码编译,别用来源不明的成品 exe;给工具目录加白名单;如果做团队内部分发,走代码签名。这类问题没有根治办法,只能降低误伤概率。顺带说一句,这类工具在联网对战环境里使用会破坏公平性,也可能违反用户协议,拿来做内存读写的技术验证、自己搭的单机环境里跑跑,是更合适的使用姿势。
5.5 悬浮窗的位置漂移
现象:HUD 明明跟着游戏窗口走,但换到高分辨率或副屏上之后,面板总是偏出一段距离。
原因:进程没有声明 DPI 感知,系统做了虚拟化缩放,GetWindowRect返回的逻辑坐标和实际物理坐标之间差了一个缩放系数。
解决:进程启动最早期调用SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2),然后在WM_DPICHANGED里重新计算窗口尺寸。副屏坐标可能是负数(主屏左侧的显示器),所有坐标计算统一用LONG不要用UINT,否则负数会被当成一个巨大的正数,窗口直接飞到屏幕外。
6. 二次开发的改造思路:从「看血」扩成状态面板
把这份源码跑通之后,真正有意思的是往下扩。它现在只读一对current/maximum,但偏移链机制是完全通用的:只要你能定位到某个字段的地址,就能把 MP、经验、坐标、背包数量一并读出来,做成一块多行 HUD。改造路径我建议按这个顺序走,风险从低到高。
| 步骤 | 改动点 | 验证方式 |
|---|---|---|
| 1 | offsets.ini增加一组键值对 | 改完直接重启工具,看日志是否解析成功 |
| 2 | Sampler从读一个字段扩成读一个结构体 | 在面板上多加一行文本,数值与游戏内对得上 |
| 3 | Session化,支持多 PID | 同时开两个客户端,两块 HUD 各自独立刷新 |
| 4 | 绘制换成可配置布局 | 改 ini 里的坐标和字号,面板跟着变 |
第 1 步纯配置,零风险。第 2 步要注意结构体跨页的问题:如果血量、魔法值在内存里隔得比较远,别用一次ReadProcessMemory读一大块,分开读更稳,每次读之前都做一次ResolvePointer,虽然多几微秒,但某一级失效时不会把整块数据带脏。
第 3 步是最容易翻车的地方。多个 Session 共用一个std::vector<Session>本身没问题,但每个 Session 内的ProcessReader持有独立句柄,绝对不能在采样线程和 UI 线程之间共享同一个 Reader——m_handle一旦在多处被Detach和Attach交错调用,读出来的东西就是随机数,这类问题在日志里几乎没有痕迹,是个黑匣子。我的做法是给每个 Reader 加一个std::atomic_flag做「使用中」标记,调试期一旦检测到并发访问就直接断言退出,上生产再关掉。
验证方法也要配套。改完偏移链之后别只盯着面板看,加一个隐藏的调试模式(比如启动参数带--dump),每 5 秒往日志里打一行「地址、原始字节、解析值」,和游戏内数值人工比对三次以上。血量满值、受伤、切图、死亡各看一次,四种状态下都对得上,才敢说这条链是稳的。只测一次满血很容易漏掉指针在中途被重新分配的情况。
绘制层面还有一个容易忽略的点:HUD 窗口是置顶的,如果它盖住了目标窗口的关闭按钮或者输入框,用户会很烦。给面板加一个可拖拽区域,拖拽时临时去掉WS_EX_TRANSPARENT,松手再恢复,这个小功能实现起来不到三十行,但对日常使用的体感提升很明显。
从那以后我每次改偏移相关的东西,都会强制先跑一遍--dump在四个状态下比对,再回头看面板——直接盯着 UI 改是最容易骗自己的,数字看着差不多就以为对了,实际可能只是巧合。希望帮到你。
本文还有配套的精品资源,点击获取