简介:这是基于CGA开源代码改进的魔力宝贝辅助工具MLAssist设计源码,面向具备C++基础的游戏辅助开发者和魔力宝贝玩家,覆盖自动化操作、界面自定义、多语言脚本扩展及账号管理等功能场景。压缩包共2000个文件,以h/hpp头文件为主体,辅以cpp/c源文件,另含css样式、json配置、md文档和txt说明,整体约82.42MB,结构清晰便于按模块研读。工具界面借鉴FZ风格并支持自定义,可运行Lua中文脚本、CGA JS脚本与Python脚本,为研究C++工程中嵌入脚本引擎、处理游戏数据交互的常见写法提供了完整实例;附带的MLAssistTool账号管理工具能实时显示在线角色、同步迷宫地图并查询角色信息,连同游戏ID创建功能构成了一个完整可运行的辅助工具链。目前已有3477人学习下载,适合进一步研究游戏辅助开发、定制魔力宝贝脚本,或作为开源项目二次研发的参考。
1. 从 CGA 到 MLAssist:这份开源辅助工具源码到底拆出了什么
做游戏辅助工具的人大多绕不过去一个问题:大部分公开的源码要么是只讲底层注入的 Demo,要么是套壳收费的半成品,真正能拿来做事的完整工程很少。MLAssist 属于那种少见的、能直接编译运行的完整项目——它基于 CGA(CrossGate Assistant)开源框架,把魔力宝贝辅助工具的核心逻辑拆成了 C++ 底层框架加 Lua 业务脚本两层。说白了,这份源码的价值不在于它替你写好了某个功能,而在于它示范了一个「C++ 做底层能力、Lua 做业务编排」的完整架构。适合三类人:想研究游戏辅助工具进程交互与自动化逻辑的 C++ 开发者、想在 CGA 基础上二次开发自己脚本体系的 Lua 使用者、以及需要一份可编译工程做技术参考的从业者。后面所有内容都以可复现为前提,按编译、运行、脚本编写、踩坑的顺序讲透。
2. CGA 框架剖析:内存交互、注入机制与 Lua 引擎的三角支撑
CGA 能被当成辅助工具底座,核心在于它解决了三件事:与游戏进程的数据交互、代码逻辑的注入执行、以及上层脚本的动态加载。这三件事分别对应内存读写模块、注入模块和 Lua 引擎模块,把它们拆开看才能理解 MLAssist 为什么建立在 CGA 之上。
2.1 进程交互的核心:内存映射与地址管理
CGA 对魔力宝贝客户端的交互走的是最典型的 Windows 进程内存方案:以管理员权限打开进程句柄,通过ReadProcessMemory和WriteProcessMemory完成数据读写。关键不在 API 本身,而在地址管理体系。游戏每次更新后基址都会偏移,CGA 用「模块基址 + 静态偏移 + 动态偏移」的多级寻址方式来解决,模式类似:
// 获取模块基址后再逐级解引用 uintptr_t GetAddressByOffsets(HANDLE hProcess, uintptr_t moduleBase, std::vector<DWORD> offsets) { uintptr_t addr = moduleBase; for (size_t i = 0; i < offsets.size(); ++i) { ReadProcessMemory(hProcess, (LPCVOID)addr, &addr, sizeof(addr), nullptr); addr += offsets[i]; } return addr; }这段代码的逻辑很直接:先拿moduleBase(模块基址),然后按偏移表逐层解引用,每层先读当前地址存的值,再加下一层偏移。注意ReadProcessMemory的第四个参数传的是sizeof(addr),这里读的是指针宽度(32 位下是 4 字节,64 位下是 8 字节),不要写成固定 4。魔力的数据结构通常嵌套四五层,偏移表顺序错一位整个指针链就断了,这是地址管理的经典翻车点。
从工程角度说,我更建议你把所有偏移常量集中到一个配置文件或头文件里,不要散落在业务代码中。每次游戏更新只需要更新这份偏移表,业务逻辑一行不用改。CGA 源码里已经做了类似管理,但 MLAssist 把它推得更彻底,Lua 层能直接通过暴露的接口拿到角色坐标、血量、蓝量、物品列表等数据,说明底层封装已经足够稳定。
2.2 注入与 Hook:从 DLL 注入到消息循环挂接
CGA 选择的注入方式是标准 DLL 注入:把dll文件注入到游戏进程空间,让辅助逻辑运行在目标进程内部。注入完成后还需要 Hook 游戏自身的消息循环或渲染帧函数,才能让辅助逻辑跟随游戏主循环同步执行。CGA 常用的方式是 IAT Hook 或直接改写函数入口,MOD 方式做 inline hook 也行。
// 简化版 Detour 挂接:修改目标函数前 5 字节跳转 void AttachHook(HMODULE hMod, LPCSTR funcName, LPVOID pDetour, LPVOID* ppOriginal) { uintptr_t target = (uintptr_t)GetProcAddress(hMod, funcName); DWORD oldProtect; VirtualProtect((LPVOID)target, 5, PAGE_EXECUTE_READWRITE, &oldProtect); // 保存原始字节用于后续跳板 memcpy(ppOriginal, (LPVOID)target, 5); // 写入 E9 跳转指令 *(BYTE*)target = 0xE9; *(DWORD*)(target + 1) = (DWORD)pDetour - target - 5; VirtualProtect((LPVOID)target, 5, oldProtect, &oldProtect); }这里做的是最原始的 5 字节 E9 跳转:把目标函数开头改成jmp detour,跳转偏移用目标地址 - detour地址 - 5计算,这是 x86 相对跳转的标准格式。VirtualProtect先把目标区域改成可读写执行,写完再恢复。真正的工程环境必须用 Detours 库或 MinHook,手写跳板要考虑指令边界对齐、重定位、调用约定,环境版本一变就崩。你只需要理解 CGA 的注入层在做的事,具体实现直接依赖已有的稳定库。
注入模块在 MLAssist 里的价值不是它有多高级,而是它在注入后把 Lua 引擎也拉进了游戏进程。Lua 虚拟机运行在游戏进程内,意味着脚本可以直接调用 C++ 暴露的函数,不需要跨进程通信,性能和实时性都有保障。
2.3 Lua 引擎与 C++ 的双向通信
Lua 引擎的集成是 CGA 能称之为「框架」而不是「工具」的分界线。CGA 在 C++ 侧维护一个lua_State,用lua_register把游戏操作函数逐个注册进去。MLAssist 大部分自动化逻辑都跑在 Lua 层,这样迭代脚本不用重新编译 C++,改完脚本重启一下就能生效。
// 把 C++ 函数注册为 Lua 全局函数 int Lua_WalkTo(lua_State* L) { int x = (int)luaL_checkinteger(L, 1); int y = (int)luaL_checkinteger(L, 2); bool result = g_GameAPI.WalkTo(x, y); // C++ 侧实现寻路 lua_pushboolean(L, result ? 1 : 0); return 1; // 返回 1 个结果给 Lua } // 注册表 lua_register(L, "WalkTo", Lua_WalkTo);luaL_checkinteger负责从 Lua 栈上取参数做类型检查,取完参数后调用 C++ 侧 API,最后把结果压回 Lua 栈。返回值的数字 1 必须和压入栈的参数个数一致,这是新手最容易出错的地方。C++ 函数签名必须严格遵循int (*)(lua_State*)形式,否则注册时编译器不会报错,运行到调用时直接崩。
双向通信不只是 C++ 提供函数给 Lua 用,Lua 也用lua_pushcfunction和lua_sethook把脚本里的关键函数反向暴露给 C++ 侧,让 C++ 在事件触发时能回调脚本逻辑。比如 C++ 检测到角色血量低于阈值,直接调用 Lua 里定义的OnLowHp()回调,脚本侧只需要关注「血量低了要干什么」,不需要管底层检测逻辑。
3. MLAssist 的脚本系统设计:任务状态机与自动化行为编排
有了 CGA 当底座,MLAssist 的核心价值就落在脚本架构上。它的脚本系统不是简单的「顺序执行操作序列」,而是围绕任务状态机做的行为编排框架。理解这一层,你才算真正看懂了这份源码。
3.1 任务状态机:从「顺序脚本」到「状态响应」
大多数初版辅助脚本都是顺序执行:走路、遇敌、战斗、捡东西、回城,一路写到结束。这种写法在单场景下能用,但一遇到意外就全线崩溃。MLAssist 的状态机思路不一样:它把辅助过程拆成多个状态(寻路、战斗、补给、待机),每个状态定义「进入条件、执行动作、跳出条件」,脚本引擎依据当前游戏状态决定下一步行为。
-- 一个简化版的状态节点定义 local BattleState = { name = "Battle", enter = function(self) self.turnCount = 0 end, execute = function(self) local hp = Game.GetPlayerHp() if hp < 0.3 then return "Escape" -- 血量太低切逃跑状态 end local action = Battle.DecideAction() -- 由战斗策略决定动作 Battle.Execute(action) self.turnCount = self.turnCount + 1 return nil -- nil 表示留在当前状态 end, exitCondition = function(self) return not Game.InBattle() -- 战斗结束则离开 end }这段代码的结构是 MLAssist 脚本的模板:enter在进入状态时初始化数据,execute每帧执行并返回要切换到的状态名,返回nil表示留在当前状态;exitCondition额外提供一个独立的退出检查。这种设计把「当前在干什么」和「下一步干什么」分离,你看脚本时只需要关注每个状态内的逻辑,不必关心其他状态怎么运行。
用状态机组织脚本带来的直接好处是异常处理路径清晰。角色血低、遭遇特殊怪、地图切换、背包满,这些在顺序脚本里要靠if叠if的场景,在状态机里就是一次状态转移。想给辅助加「自动回城补给再回挂机点」的能力,只需要新增一个Supply状态,在战斗状态和寻路状态检测到补给条件时切进去,跑完回来继续之前的事。
3.2 行为树的叠加:复杂任务用树状结构组合
状态机解决的是「整体流程怎么走」的问题,但单个状态内部往往也要做复杂决策。比如战斗状态里面对多个敌人,需要判断先打哪个、技能怎么交、蓝量多少时停止放技能。这些单帧决策逻辑,MLAssist 用类似行为树的组合结构来封装。
-- 战斗决策树,按优先级从上到下检测 local decideTree = { { name = "先手治疗", cond = function() return Party.GetLowestHp() < 0.25 end, action = function() Skill.Cast("强疗", Party.GetLowestHpTarget()) end }, { name = "集火主怪", cond = function() return Battle.GetMainEnemy() ~= nil end, action = function() Attack.Focus(Battle.GetMainEnemy()) end }, { name = "普通攻击", cond = function() return true end, action = function() Attack.Normal() end } } function Battle.DecideAction() for _, node in ipairs(decideTree) do if node.cond() then node.action() return node.name end end end这种实现思路很简单:把所有决策项按优先级存成表,每帧从上到下检查条件,第一个满足的条件对应的动作被执行。工程层面的考量是:决策项之间不能存在互斥要求,否则优先级顺序会掩盖逻辑错误。我在工程里用行为树时习惯给每棵决策树配一个 trace 开关,把每帧命中了哪个节点写进日志,排查「为什么没按预期动作」时效率翻倍——这个习惯让我少熬了不知道多少个夜。
3.3 定时器与事件响应:辅助工具的「心跳」
脚本不能每帧都在跑,MH 客户端逻辑本身就有帧率限制,脚本层之间穿插大量轮询只会白白耗 CPU。MLAssist 在 C++ 侧维护了一套定时器机制,Lua 脚本用起来像这样:
-- 注册一个每 5 秒执行一次的定时器 Timer.Register("check_health", 5000, function() local hp = Game.GetPlayerHp() if hp < 0.5 then Log.Info("HP too low: " .. hp) Game.UseItem("金创药") end end) -- 战斗中每帧执行的逻辑不注册定时器,直接放在 execute 中定时器设计的关键参数有两个:间隔时间和触发时机。间隔不能太短,低于 200ms 的轮询除非有特殊需求否则都是浪费;触发时机要避免多个定时器同时到期挤在同一帧执行。MLAssist 的做法是定时器队列维护一个最小堆,每次循环取最早到期的任务执行,新任务注册时按到期时间插入。这种做法你在实现时也可以参考,改动成本低,但对稳定性的提升是实打实的。
事件响应比定时器更进一步:不做轮询,而是由 C++ 侧主动通知。比如角色进入战斗、升级、遭遇特殊 NPC,这些事件由 C++ 侧监测到后直接回调 Lua 注册的响应函数。脚本层不应该每帧检查Game.InBattle(),而是注册一个Battle.Started事件,触发时自动执行预设逻辑。这份源码的事件系统里,事件名绑定字符串,C++ 侧发事件时用字符串做查表匹配,Lua 侧只关注自己关心的事件。
4. 编译与部署实战:把源码跑起来的完整流程与参数控制
前两章把架构拆完了,接下来是人人都要过的关:把这个工程编译成功并跑起来。这个过程坑不少,值得单独写一章给完整流程和参数说明。
4.1 环境准备与依赖项
MLAssist 依赖的第三方库并不算多,但版本必须对齐,缺一个或版本不对都可能编译失败。核心依赖包括:Visual Studio 2019 或 2022(C++ 桌面开发组件)、Lua 5.1 或 luajit(注意 CGA 对 5.3+ 的 API 兼容性较差,建议 5.1)、Detours 库(微软研究院的 Hook 库)、以及 wxWidgets(如果源码带图形界面版本)。不装界面版本可以跳过 wxWidgets,纯命令行辅助不需要。
# 以 vcpkg 方式安装依赖(仅供参考,实际按源码 README 为准) vcpkg install lua:x86-windows vcpkg install detours:x86-windows注意架构选择:魔力宝贝客户端是 32 位进程,注入 DLL 必须编译成 x86(Win32),不要用 x64。这是最容易犯的错误,Visual Studio 默认新建项目是 x64,编译出来一注入就报「坏图像格式」。把解决方案平台切到 Win32,所有依赖库也必须是 32 位版本,混用必然出问题。
4.2 编译顺序与工程生成
拿到源码后不要直接点「生成解决方案」,建议按依赖顺序依次编译:先编译第三方库项目,再编译 CGA 核心库,最后编译 MLAssist 主程序。CGA 核心库一般会生成一个静态库文件CGA.lib,MLAssist 主程序链接这个库并引用头文件。
# 编译 CGA 核心库(用 cmake 或 msbuild 由工程类型决定) cmake -B build -A Win32 . cmake --build build --config Release-A Win32是指定生成 32 位工程的 CMake 参数,少了它默认生成 x64。编译输出在build/Release目录下,拿到.dll文件后复制到和主程序同目录。如果你的源码自带.sln解决方案文件,直接双击用 VS 打开更省事,但一定确认解决方案平台是 Win32。
编译过程中最常见的错误是 LNK2005、LNK2019 这类链接错误。LNK2019 表示某个函数只有声明没有定义,一般是链接时没有包含对应的.lib。比如用到了DetourTransactionBegin却没链接detours.lib,就会报这个错。逐个检查链接器输入列表,把用到的库全部加进去。
4.3 配置文件与运行参数
MLAssist 编译好之后不能直接双击运行——正确的姿势是:先启动魔力宝贝客户端进入游戏,再运行辅助程序让 DLL 注入。源码通常会带一个配置文件,控制注入的目标进程名、脚本加载路径、日志级别等关键参数。
; config.ini 示例 [Inject] TargetProcess=CrossGate.exe DllPath=bin\MLAssist.dll AutoInject=1 [Script] MainScript=scripts\main.lua ReloadOnChange=1 [Log] Level=DEBUG OutputFile=logs\run.logTargetProcess填游戏进程名,注意和任务管理器里看到的完全一致;AutoInject=1表示检测到目标进程后自动注入,设为 0 则手动触发。ReloadOnChange=1是个很有用的开发选项:scripts 目录下 Lua 文件被修改后自动重载,不用反复重启游戏。日志级别建议开发期设 DEBUG,跑稳定后切 INFO 减少 IO 开销。这里要提醒一句:ReloadOnChange在脚本出错时要小心,它会带着错误状态反复重载,容易造成死循环,我一般只在改动脚本时手动开一下。
4.4 编译完成后的自检步骤
编译成功只是第一步,验证注入链路是否正常更重要。我的习惯是启动后立刻打开日志文件,确认三条关键信息:Module loaded、Lua state created、Main script executed。缺任何一条都意味着链路没通。如果注入成功但脚本没执行,优先查 Lua 路径配置是否正确;如果注入直接失败,查目标进程名是否写错、是否以管理员权限运行、DLL 是否真的在指定路径。
5. 避坑与排查:我在这份源码上踩过的六个典型问题
这一章把所有能预见的坑集中讲透。每条都是实际场景里高频出现的问题,按「现象 → 原因 → 解决」写清楚,照着排查能省大量时间。
5.1 注入成功但 Lua 脚本完全不执行
现象:日志显示Lua state created,但脚本里的print或Log.Info一条都没输出,游戏内也没任何辅助效果。
原因:大概率是MainScript路径配置错误。Lua 的dofile用的是相对路径时,工作目录可能不是程序所在目录,而是游戏进程的工作目录。如果 DLL 注入到游戏进程,Lua 引擎运行在游戏进程内,相对路径基于游戏目录解析,不是你程序目录。
解决:把MainScript配成绝对路径,或者用lua_getcwd打印当前工作目录核对。更稳妥的方案是在配置文件里把脚本路径做成相对 CGA 根目录的路径,C++ 侧先拼出绝对路径再传给 Lua。
5.2 游戏更新后坐标读取全乱
现象:游戏一次版本更新后,原本能正确读取坐标、血量的脚本全部拿到乱值,甚至直接崩溃。
原因:游戏客户端更新后内存布局变了,偏移表全部失效。这是所有辅助工具都逃不掉的命运,内存地址不是稳定 API,游戏厂商随时可以调整数据结构。
解决:用 CE(Cheat Engine)重新扫描定位基址偏移。具体做法是打开 CE 附加到游戏进程,找到角色坐标的当前地址,逐层向上回溯指针链,把新的偏移数列替换配置里的旧偏移。CGA 源码里偏移表通常集中在某个头文件里,改完重新编译核心库,业务脚本一行不用动。这套流程熟练后二十分钟能完成一次偏移更新。
5.3 Lua 脚本一执行就卡死游戏
现象:脚本启动后游戏画面卡住,无响应,只能结束进程。
原因:最常见两种情况:一是 Lua 脚本里写了死循环(while true do end没有os.sleep或 yield);二是调用了 C++ 侧阻塞函数(比如同步读网络数据)。游戏主循环被卡住,画面自然冻结。
解决:Lua 侧所有耗时操作都要用定时器或事件机制拆分,不要在execute里直接写长循环。C++ 侧暴露给 Lua 的函数必须是快速返回的。如果确实需要等待某条件,用Timer.Register轮询而不是while循环。
5.4 多开游戏时辅助只对第一个窗口生效
现象:开两个游戏窗口,辅助工具只作用在最先启动的那个进程上,第二个窗口毫无反应。
原因:默认配置只用进程名匹配合法目标。两个进程同名,注入逻辑默认选第一个找到的进程。MLAssist 如果没有专门处理多开,就会锁死第一个窗口。
解决:确认源码是否支持多开模式。如果不支持,你需要自己改注入选择逻辑:按窗口标题、按进程 PID、或按进程启动时间筛选目标进程。改法不复杂:枚举进程时拿到所有同名进程,把 PID 列表暴露给 Lua,脚本通过Game.Attach(pid)指定目标。
5.5 频繁掉线或检测到辅助行为
现象:用辅助跑一段时间后被服务器踢下线,或者账号提示异常。
原因:写操作太频繁或行为模式太规律,被服务端行为检测系统标记。魔力宝贝的检测机制比起封号更倾向于踢下线,规律性的点击、移动、对话是主要特征。
解决:在脚本层引入随机化。每次操作之间加随机延时,不是固定 50ms 而是一次 30ms 一次 80ms;走路路径不要永远一条线,偶尔绕一下。MLAssist 的定时器只支持固定间隔,我一般在 Lua 侧包一层随机延时函数来替代固定间隔的Timer.Register。这种处理不能保证绝对安全,但能明显降低行为特征的一致性。
5.6 修改 Lua 脚本后热重载崩溃
现象:开启ReloadOnChange后,每次保存脚本文件游戏就崩溃或状态错乱。
原因:热重载没有正确处理运行中的协程和状态。旧状态节点还在执行,新脚本已经把函数替换了,执行到一半的函数调用链断裂。
解决:不要直接在主执行路径上做热重载。安全做法是:先让状态机回到待机状态,再执行脚本重载,然后再重新进入任务状态。如果你只在修改决策树或定时器逻辑时开启热重载,排查完立即关闭,稳定性会好很多。这份源码本身带了这个选项,但我的经验是它更适合改脚本时快速调试,不适合长时间挂着用。
6. 进阶玩法:基于事件驱动重构你手头的任务脚本
前面讲了架构、编译、踩坑,最后一个章节落到一个值得你亲手去试的进阶技巧:把 MLAssist 默认的状态机脚本改造成事件驱动风格。这个改造会让脚本更抗干扰,也更接近商业级辅助工具的实现思路。
改造的核心思路是:大部分逻辑不需要每帧主动轮询,而是等待 C++ 侧事件触发。你只需要维护一个事件注册表,把「什么条件触发什么行为」的关系声明清楚。给你一个可以直接替换主脚本的骨架:
-- events.lua:事件驱动重构后的主脚本 Event.Register("Battle.Started", function() -- 进战斗时重置计数器,准备自动战斗 self.turnCount = 0 self.autoBattle = true end) Event.Register("Battle.Ended", function() self.autoBattle = false if Game.GetPlayerHp() < 0.5 then Game.UseItem("金创药") Log.Info("战后自动回血") end end) Event.Register("Player.LevelUp", function() Log.Info("升级了,当前等级 " .. Game.GetPlayerLevel()) -- 可以在这里触发加点逻辑或装备替换 end) -- 手动逻辑保留在状态 execute 中做补充 Event.Register("Timer.Elapsed.30s", function() -- 每 30 秒检查一次背包是否满 if Game.GetBackpackFull() then Town.ReturnAndSell() end end)这套事件驱动写法的核心收益在于:脚本的执行路径不再是一条线,而是围绕游戏事件分散响应。战斗开始自动做战斗准备,战斗结束自动处理补给,升级自动做后续行为。每个事件都是独立的响应块,互不干扰,日志排查时也更容易定位。
从工程角度再看这组代码:Timer.Elapsed.30s是 CGA 底层定时器模块暴露给 Lua 的事件接口,C++ 侧把定时器到期的回调包装成事件发出来。你如果要在自己的工程里实现这套机制,C++ 侧只需要维护一个从 tick 到事件名的映射表,每次主循环checkTimers()把到期的事件推给 Lua 侧。逻辑不复杂,但收益显著。
如果你决定动手改造,我建议按照这样的顺序推进:
- 第一步:把所有「循环检测类」逻辑替换成事件监听。比如原本每 5 秒查一次血量低于某值才吃药,替换成
Event.Register("Battle.Ended", ...)里做检查。吃药这类操作天然关联战斗结束,而不是一个独立的周期行为。 - 第二步:把跨状态共享的数据放到统一的 context 表里,不要散落各状态自持变量。这样事件回调可以访问「当前血量」「当前地图」「当前任务阶段」,脚本之间职责更清晰。
- 第三步:每一类事件单独建一个 Lua 文件,主脚本只做注册汇总。被动脚本数量多了之后,单文件维护成本会涨得很快,物理分文件是必要的。
事件驱动也不是没有代价。它的排查路径更分散,「为什么这次战斗没触发回血」这种问题需要查事件是否注册成功、触发条件是否满足、回调是否抛了异常。我自己的习惯是在每个事件回调里用Log.Debug(eventName)打一条带事件名的输入日志,开着 DEBUG 跑两轮,所有响应链路一目了然。从那以后我每次改脚本,都强制先看事件链路而不是逐行读逻辑,调试时间省下来的体感非常明显。希望这篇拆解能帮你把这份源码真正吃透,构建出属于你自己的一套辅助框架。
本文还有配套的精品资源,点击获取