简介:《魔力宝贝》经典的看血辅助工具及配套源码,面向游戏玩家、初学游戏辅助开发或对逆向工程感兴趣的开发者。工具由 C++ 编写,在 Visual Studio 2005 与 Windows XP 环境下完成,核心功能是实时读取角色血量,方便玩家调整战斗策略。源码重点展示了通过抓取并解析客户端与服务端通信数据获取血量字段的过程,涉及数据包分析、协议字段定位、内存管理、指针操作与多线程界面更新等实践,对理解早期网络游戏的通信机制很有帮助。压缩包共 18 个文件,约 207KB,包含 .h/.cpp 源文件、可直接运行的 .exe、Visual Studio 解决方案及工程文件(.sln、.vcproj、.suo)和界面资源文件,整体结构完整,方便在 VS 中打开、编译与二次修改。已有 9125 人学习浏览,适合想拆解小型游戏辅助项目、系统学习 C++ 与 WinAPI/MFC 交互开发的读者。需要提醒的是,使用此类工具可能违反游戏用户协议,学习技术原理时应遵守游戏规则。
1. 一套老魔力服的看血工具源码,拆开才发现它做的事很简单
“对面BOSS还剩多少血”这个问题,在老的回合制网游里基本靠猜,血条显示得再长也只是百分比。我手头这套在某怀旧魔力服务器里流传多年的看血工具源码,圈内习惯叫它外挂,可拆开看,它只做了三件事:找到游戏进程、把内存里的血量数字读出来、用一个半透明窗口浮在游戏画面上显示。前后不过几千行代码,核心逻辑链路比我预想短得多。它适合两类人:一类是还在玩怀旧服、想自己补全游戏体验的玩家;另一类是刚接触 Windows 内存读取、想做点真实项目的开发者。这篇就把这套源码的原理、编译方式和改动思路完整过一遍。
2. 工具定位与技术选型:一个辅助程序到底碰了系统哪几层
2.1 看血工具的本质是“外部读内存”,不是改内存
很多没拆过这类工具的人,第一反应是它会不会改了游戏客户端。其实不会。这套源码从头到尾没写过一次WriteProcessMemory,它只做读取。从 Windows 的角度看,它就是一个普通 GUI 程序,运行在用户态,通过 Win32 API 拿到游戏进程的句柄,再用ReadProcessMemory把指定地址的整数读出来。
这带来一个好特性:只要游戏进程本身不崩溃,工具做任何操作都不会把游戏搞挂。相对的,坏处也明显,游戏一旦更新,内存地址偏移全部失效。源码作者把偏移集中放在一个常量文件里,更新游戏后只需要重新搜一遍地址,改几个数值就能继续用。整个工具对系统的影响面很小:一个进程、一个置顶窗口、一个定时器,不注入、不挂钩子,从这个角度说它更像一台“外部仪表盘”。
2.2 技术选型:Delphi 7 时代的老工程,结构却清晰得不像老代码
从源码的工程文件、控件引用和语法风格推断,这套工具是 Delphi 7 时期写的。当年做这类辅助程序的圈子偏爱 Delphi,原因简单:编译出来体积小,原生支持 Win32 API 调用,写窗口完全不费劲,而且对内存操作没有 C# 那套托管限制。
源码里主体分为几个单元:主窗体负责初始化和布局,一个内存读取单元封装了OpenProcess和ReadProcessMemory,另一个窗体单元负责透明的覆盖显示,还有一个常量单元保存进程名、窗口标题、基址和偏移。模块边界非常清楚,放在今天看仍然不算过时。我重新组织目录时,基本没有做大手术,只调整了命名和注释。
2.3 目录结构与源文件清单
拿到源码包解开后,核心文件如下:
| 文件 | 职责 |
|---|---|
Main.dfm/Main.pas | 主窗体,启动时调用附加逻辑 |
MemReader.pas | 封装OpenProcess、ReadProcessMemory、多级指针寻址 |
Overlay.dfm/Overlay.pas | 透明置顶窗体,鼠标穿透,绘制 HP/MP 数值 |
Offsets.inc | 进程名、窗口标题、基址与各级偏移常量 |
Injector.dpr | 工程入口,Delphi 项目文件 |
README.txt | 原作者写的使用方法简述 |
Offsets.inc是这套源码的命门,其他文件基本不需要动。下面几章会反复提到这个文件,因为二次开发的核心动作基本都集中在它身上。
3. 核心模块拆解:从进程查找到血量上屏的完整链路
3.1 第一步:通过窗口标题拿到游戏进程句柄
工具启动后要做的第一件事,不是读内存,而是找到游戏进程。源码里用了最朴素的方法:FindWindow按窗口标题找主窗口,再从窗口句柄反查进程 ID,最后OpenProcess打开进程并请求读取权限。
function FindGameProcess(out AHandle: THandle): Boolean; var Wnd: THandle; Pid: Cardinal; BytesReturned: DWORD; begin // 老客户端窗口标题固定,如果标题变了会直接失败 Wnd := FindWindow(nil, 'Client - 魔力怀旧版'); if Wnd = 0 then Exit(False); GetWindowThreadProcessId(Wnd, @Pid); AHandle := OpenProcess( PROCESS_VM_READ or PROCESS_QUERY_INFORMATION, False, Pid ); Result := AHandle <> 0; end;这里FindWindow的第一个参数传nil,表示只按窗口标题匹配;第二个参数'Client - 魔力怀旧版'是游戏客户端的窗口标题,一旦游戏改了标题就得同步更新。GetWindowThreadProcessId把窗口和进程关联起来,输出参数@Pid拿到进程 ID。OpenProcess的访问权限只申请了PROCESS_VM_READ(读内存)和PROCESS_QUERY_INFORMATION(查询进程信息),这是权限最小的组合,够用即可。
要注意的是:如果直接用管理员权限运行工具,而游戏是普通权限启动的,OpenProcess一般没问题;反过来工具普通权限、游戏管理员权限,读取会直接被拒绝。所以源码里加了一句提示,建议工具以管理员身份运行,这是我这几年调试这类程序最常见的翻车点。
3.2 第二步:ReadProcessMemory 与多级指针寻址
拿到句柄后,真正的技术核心在寻址。游戏里角色对象的地址不是固定的,每个登录会话都可能不同,但对象内部各个字段的偏移是固定的。源码里存了两部分:一个运行时基址,一个偏移数组。读取时从基址出发,一层层解引用,最后加上最后一个偏移读数值。
function ReadRoleHP(GameHandle: THandle; BaseAddr: Cardinal; Offsets: array of Cardinal): Integer; var Addr: Cardinal; I: Integer; Value: Integer; BytesRead: NativeUInt; begin Addr := BaseAddr; // 多级指针:每一层先从地址里读出一个新地址 for I := 0 to High(Offsets) - 1 do begin if not ReadProcessMemory(GameHandle, Pointer(Addr), @Addr, SizeOf(Addr), BytesRead) then Exit(-1); Inc(Addr, Offsets[I]); end; // 最后一层才是真正的血量字段 Inc(Addr, Offsets[High(Offsets)]); if not ReadProcessMemory(GameHandle, Pointer(Addr), @Value, SizeOf(Value), BytesRead) then Result := -1 else Result := Value; end;这段代码隐含了一个重要细节:中间层读取的地址长度永远是 4 字节指针;最后一层读取的是 4 字节整数。Offsets数组最后一个值表示字段在最终对象里的偏移,前面的值都表示每一层指针结构里的偏移。如果某一层地址变成了 0,说明对象还没创建或者已经销毁,ReadProcessMemory会返回失败,函数返回 -1,上层就会显示“离线”。
BytesRead这个输出参数在这类代码里容易被忽略,但强烈建议每次都检查它。有些时候函数返回成功,但读取到的字节数不是 4,说明权限或地址对齐有问题,这时候数值读出来也是错的。
3.3 第三步:透明覆盖窗与鼠标穿透
数值读出来后,需要一个窗口显示。工具没有把数值画到游戏界面上,而是单独创建了一个无边框窗口,覆盖在游戏窗口上方。这个覆盖窗用到了两个关键窗口样式:WS_EX_LAYERED让窗口支持透明,WS_EX_TRANSPARENT让鼠标点击直接穿透窗口,点到后面的游戏上,不至于挡住玩家操作。
procedure TOverlayForm.CreateParams(var Params: TCreateParams); begin inherited CreateParams(Params); // 无边框置顶窗口,点击穿透 Params.Style := Params.Style or WS_POPUP; Params.ExStyle := Params.ExStyle or WS_EX_LAYERED or WS_EX_TRANSPARENT; end;在FormShow事件里,工具会设置窗口的位置和大小,让它紧贴游戏窗口客户区,然后调用SetLayeredWindowAttributes把整体透明度调到 220 左右,既能看到数字,又不会完全遮住画面。
procedure TOverlayForm.FormShow(Sender: TObject); begin // 半透明,透明度 220/255 SetLayeredWindowAttributes(Handle, 0, 220, LWA_ALPHA); // 置顶,避免被其他窗口盖住 SetWindowPos(Handle, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE or SWP_NOSIZE); end;这里的 220 是整体 alpha 值,数值越小越透明。刚开始调试时我习惯先设 250 保证数字清晰,等位置对准了再降下来。窗口本身不抢焦点,因为它根本不需要接收键盘鼠标输入,WS_EX_TRANSPARENT保证了所有鼠标事件都穿过它落到游戏上。
3.4 定时刷新:时钟间隔与数值稳定性
数值读取不能一次性完成,战斗中 HP 每分每秒都在变。源码使用了 Windows 消息循环里的SetTimer,每 200 毫秒触发一次刷新。这个间隔是作者权衡过的:太快会增加不必要的 CPU 占用,太慢会感觉数值有明显滞后。战斗中的掉血动画时长通常在几百毫秒,200 毫秒的刷新已经能保持视觉连续。
要注意的是SetTimer的计时精度受到消息循环影响,如果主窗口被系统频繁重绘,实际触发间隔可能漂移。源码里没有用多媒体定时器,说明作者对实时性要求不高。如果你想把这个工具用在更看重时效的场景,可以把SetTimer换成CreateTimerQueueTimer,但对应要处理线程同步问题,普通场景没必要。
3.5 血量换算与显示边界
工具读到的数值是游戏内存里的原始整数,直接显示给玩家即可,但源码里还是做了几个边界处理。HP 为 0 时显示“死亡”,读取出错返回 -1 时显示“未知”,MP 只在战斗面板展开时才读取,避免不必要的开销。
显示层用的是最朴素的TCanvas.TextOut,每隔 200ms 重绘一次。因为窗口是透明的,每次重绘前先清空上次绘制的内容,否则数字会重叠成一片。这也是我在调试时踩过的一个坑:如果发现覆盖窗上的数字拖影,先检查是不是只画了新的没清旧的。
4. 编译与运行:把源码跑在 Win7/Win10 上的实操记录
4.1 Delphi 7 编译环境的搭建
拿到源码后,第一步并不是直接打开.dpr,而是先确认机器上的编译器能兼容这个老工程。Delphi 7 在 Win10 上安装时会报兼容性问题,常见做法是把安装包设置成 Windows 7 兼容模式运行,然后以管理员身份安装。安装完成后,打开工程,先执行一次Project -> Build All Projects。
编译过程中最容易出现的报错是 RTL 版本差异。源码里用了FormatDateTime和老式PChar转换,在较新的 Delphi 版本里语法都能兼容,但在 Delphi 7 里运行最稳。如果手头只有新版本 Delphi,我一般会先用旧版编译一次,再把.dpr升级到新版,这样两个版本都能用。
4.2 没有游戏也能验证:先造一个模拟目标进程
直接在真实游戏上调试有个问题:内存地址和窗口标题都依赖特定客户端,改错了很难分清是自己的代码错了还是游戏环境变了。我建议先造一个模拟目标进程,把看血工具的所有逻辑跑通,再切换到真实环境。
模拟进程的逻辑很简单:一个控制台程序,在固定偏移上放一个结构体,每秒让血量随机增减。关键点是要让这个结构体地址可预测,我一般直接用全局变量,这样地址从符号表就能定下来。
#include <windows.h> #include <stdlib.h> struct RoleData { int hp; int maxHp; int mp; int maxMp; }; // 固定偏移,便于外部读取 RoleData g_role = { 0, 0, 0, 0 }; int main() { g_role.maxHp = 1200; g_role.maxMp = 300; while (true) { // 模拟战斗中掉血和回蓝 g_role.hp = rand() % g_role.maxHp; g_role.mp = rand() % g_role.maxMp; Sleep(1000); } return 0; }编译这个模拟进程后用 CE(Cheat Engine)附加,搜索g_role.hp的地址,把这个地址填进Offsets.inc作为基址,HpOffset填 0。然后启动看血工具,把窗口标题改成模拟进程的窗口标题。如果覆盖窗能稳定显示随机变化的血量数值,说明内存读取链路没问题,剩下的只有真实游戏的内存偏移。
4.3 真机运行:日志输出与附加失败定位
真实游戏运行时,最常见的现象是工具启动后覆盖窗一片空白,没有任何数字。源码里留了个简单的调试日志文件log.txt,每次OpenProcess失败或ReadProcessMemory返回失败都会写一行。
定位流程我一般这样走:先确认窗口标题匹配,拿Spy++看一下真实客户端的窗口标题到底是什么;再确认进程权限,打开任务管理器看游戏进程是否以管理员运行;最后确认基址偏移,用 CE 附加游戏,搜索当前角色 HP,比对拿到的地址和Offsets.inc里的值。三步走完,大多数问题都出在第一步或第三步,第二步极少遇到。
4.4 Win10 下的兼容性设置
老程序在 Win10 上有两个典型问题,一个是窗口位置计算错误,一个是读取权限被拦截。位置错误是因为老代码用了Screen.Width而不是GetSystemMetrics(SM_CXSCREEN),在缩放比例非 100% 的系统上会偏半个屏。源码里主窗体启动时做了一次边界校正,如果窗口左边距超出屏幕范围,就强制拉回 0。权限问题则是把编译出来的 exe 做成“以管理员身份运行”,并在工程选项里把{$SetPEFlags IMAGE_FILE_LARGE_ADDRESS_AWARE}保留下来。
5. 避坑记录:偏移、时钟与窗口层级的四类典型翻车
5.1 现象:读出来的血量永远是上一秒的数值
第一次在模拟进程上测试时,我发现覆盖窗的数字总是比进程实际值慢一拍,而且是稳定的延迟。排查后确认ReadProcessMemory本身没有延迟,问题出在刷新机制上:主窗体绘制完数值后,下一次定时器触发时又去读了一次旧缓存。SetTimer的窗口消息在重绘频繁时会被合并,导致读取逻辑实际低于 200ms 一次。
解决方式是把“读取”和“绘制”拆成两个阶段:定时器里只做读取和消息标记,WM_PAINT里再做重绘。这样即使消息合并,读取频率也是稳定的。源码里最初版本是直接在定时器里调Invalidate然后立即绘制,我改成拆分后数值刷新明显跟手了。
5.2 现象:一部分怪物血量读出来是 0
真实游戏里有几种怪物的血量结构不一样,BOSS、小怪、队友对象在内存里的层级不同,偏移数组长度也不一致。源码只适配了普通战斗对象的地址路径,遇到无对象的场景比如观战模式,读出来就是 0。
解决方法是把每种对象类型各做一条读取链路,而不是在一个函数里硬套同一组偏移。我在MemReader.pas里增加了一个分派函数,按对象类型走不同的偏移表。这个改动虽然让代码量翻倍,但换来的是编译时代就能暴露偏移缺失。
5.3 现象:覆盖窗盖住了游戏按钮,鼠标点不到
透明穿透窗口在最开始是被我写成普通置顶窗的,结果启动后右上角一大块区域点击全部失效,游戏里的技能按钮按不了。原因就是只加了WS_EX_LAYERED没加WS_EX_TRANSPARENT,窗口虽然透明,但仍然会拦截鼠标消息。
解决方式见 3.3 的CreateParams,加上WS_EX_TRANSPARENT后鼠标事件直接穿透。要注意这个样式只对设置后的鼠标消息生效,如果窗口里的控件需要响应点击,就不能整窗穿透,得改成在非交互控件上自绘。
5.4 现象:杀毒软件把编译出来的 exe 当木马删了
这类读内存的辅助程序,特征几乎逃不过杀毒软件的启发式扫描:OpenProcess加ReadProcessMemory的组合就是经典的内存操作特征。源码本身没有恶意行为,但会被误报。
我的处理习惯是:本机调试时在杀毒软件里加白名单,发布时先做代码签名。至于网上流传的所谓“免杀”加壳技巧,那是另一套方法论,不在这个项目的讨论范围内,也不建议碰。
6. 二次开发:把看血工具改造成通用内存读取框架
6.1 把偏移表抽成外部配置
源码里偏移量集中在Offsets.inc里,每次改代码后要重新编译才能生效。改造成通用框架的第一步,就是把这个常量文件改成运行时加载的 INI 配置文件。
[Addresses] Base=0x010F3A4C HpOffset=0x28,0x4C,0x00 MaxHpOffset=0x28,0x4C,0x04 MpOffset=0x28,0x50,0x00 MaxMpOffset=0x28,0x54,0x00 [Timing] RefreshMs=200 [Window] Title=Client - 魔力怀旧版改造后的读取函数从“代码里写死偏移”变成“从 INI 读偏移数组”。这样游戏更新后,只要跑一次 CE 找到新偏移,写在配置文件里就能立刻生效,不用重新编译整个工具。我在改这个框架时顺手把FindGameProcess的窗口标题也读入了配置,因为有些服务器用的客户端窗口标题不一样,这个改动几乎零成本。
6.2 自造验证脚本:每次改造都先跑一遍模拟进程
偏移抽成配置后,最怕改动引入低级错误。我把第 4 章的模拟进程扩展成了一个带随机波动的脚本,给g_role.hp按正弦波变化,这样一眼就能看出刷新是否平滑,数值是否正确。这个模拟进程相当于“内存读取的测试桩”,所有后续改造都先用它验证,再上真实游戏。
从那以后,我给自己订了个规矩:凡是跟内存地址打交道的改动,强制先跑一轮模拟进程,通过后再接真实客户端。这套流程救过我很多次,尤其是在基址偏移批量调整的时候,一条路径算错,模拟进程会立刻暴露出问题,总比在战斗中被误导强得多。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取