简介:MLAssist是基于CGA开源代码改进的魔力宝贝游戏辅助工具源码,面向具备C++与脚本开发基础、希望研究或二次开发游戏辅助工具的开发者。项目在CGA基础上优化界面风格并增加自定义功能,支持Lua中文脚本、CGA JS脚本与Python脚本,还附带游戏ID创建功能及账号管理工具MLAssistTool,可实时展示在线角色信息、同步迷宫地图并查询角色数据。压缩包共2000个文件,以1052个h头文件与883个hpp头文件为主体,另有57个cpp实现文件及少量css、json、md等配置与说明文件,整体约82.42MB,目录结构清晰,便于按模块检索。目前已有3481人学习下载。源码完整呈现了辅助工具在界面定制、多语言脚本扩展与账号管理上的实现思路,适合作为游戏辅助开发、C++工程组织与脚本引擎集成的参考案例。
1. 从 CGA 到 MLAssist:魔力宝贝辅助工具到底在解决什么问题
魔力宝贝这类回合制网游的日常操作有个共同特征:重复、机械、对时序敏感。抓宠要反复遇敌、战斗要按固定技能顺序出手、补给要盯着血蓝阈值、走路要沿固定路线循环。人手动做二十分钟就开始走神,而脚本一旦时序错半拍,轻则空放技能,重则卡在地图角落一整晚。MLAssist 就是冲着这个场景来的——它基于 CGA 开源代码做二次封装,把「识别游戏状态 → 决策下一步动作 → 注入操作」这条链路拆成可配置的模块,让辅助逻辑从硬编码脚本变成可维护的工程。
CGA 本身是一套面向该游戏的自动化框架,提供了内存读取、封包收发、图像识别等底层能力,但它的原始接口偏底层,直接拿来写业务逻辑要处理大量状态机细节。MLAssist 的价值在于中间层:它把「当前处于什么界面」「队伍血量如何」「背包还剩几个格子」这类语义状态抽象出来,上层只需要写「如果血低于 40% 就吃血瓶」这种接近自然语言的规则。适合谁?一是想自己改脚本但被底层 API 劝退的玩家,二是需要批量管理多开窗口、要求稳定挂机的重度用户,三是把这套东西当 Python 工程练手、想理解游戏自动化架构的开发者。源码在手,意味着你能改判定阈值、能加自定义动作、能接自己的日志系统,而不是被某个成品工具的黑匣子框死。
2. CGA 开源代码的模块拆解与 MLAssist 的二次封装思路
2.1 CGA 提供了哪几层能力,边界在哪
CGA 的代码结构大致分四层,理解这个分层是读懂 MLAssist 的前提。最底层是进程交互层,负责附加到游戏进程、读写指定内存地址、调用 Windows API 发送键鼠消息。这一层是平台相关的,换系统或换游戏版本就可能失效。往上是数据解析层,把原始内存字节翻译成角色坐标、HP/MP 数值、地图 ID、NPC 列表这类结构化数据。再往上是封包层,处理与服务器的协议收发,用于那些内存里读不到、只能靠协议交互的状态。最上层是动作层,封装了「移动到某坐标」「使用某技能」「打开某界面」等原子操作。
边界很明确:CGA 不负责业务决策。它不知道你什么时候该补血,也不知道抓宠失败后要不要重试。这些判断逻辑必须由调用方写。很多新手拿到 CGA 后直接在里面堆 if-else,结果代码越写越乱,一个版本更新就全崩。MLAssist 的封装思路正是针对这个痛点——把决策逻辑从 CGA 里剥出来,用配置和规则引擎承载。
2.2 MLAssist 的三层封装:状态抽象、规则引擎、动作调度
MLAssist 在 CGA 之上加了三层。第一层是状态抽象,它周期性轮询 CGA 的原始数据,聚合成一个GameState对象,包含队伍成员血量百分比、当前地图、是否在战斗中、背包剩余格数等字段。这一层的关键是「快照一致性」——不能出现血量读了一半、地图已经切了的脏数据,所以要用版本号或时间戳做校验。
第二层是规则引擎。规则用 Python 字典或 YAML 描述,形如{"condition": "hp_pct < 0.4", "action": "use_item", "params": {"item": "血瓶"}}。引擎按优先级遍历规则,命中就执行。这样改策略不用动代码,改配置就行。
第三层是动作调度。它把规则产出的动作排进队列,按冷却时间和互斥关系依次下发到 CGA 的动作层。比如「移动」和「使用技能」不能同时发,调度器要保证时序。
下面是一个最小化的状态轮询与规则匹配骨架,展示这三层怎么串起来:
import time from dataclasses import dataclass, field @dataclass class GameState: hp_pct: float = 1.0 mp_pct: float = 1.0 in_battle: bool = False map_id: int = 0 bag_free: int = 20 version: int = 0 class StatePoller: """从 CGA 原始接口拉数据,聚合成一致快照""" def __init__(self, cga_api): self.api = cga_api self._last_version = 0 def poll(self) -> GameState: # 先读版本号,读完数据再校验版本,防止读到切换中的脏数据 v1 = self.api.get_state_version() raw = self.api.read_raw_state() v2 = self.api.get_state_version() if v1 != v2: return None # 状态在读取过程中变了,丢弃本次 return GameState( hp_pct=raw["hp"] / max(raw["hp_max"], 1), mp_pct=raw["mp"] / max(raw["mp_max"], 1), in_battle=raw["battle_flag"] == 1, map_id=raw["map_id"], bag_free=raw["bag_free"], version=v2, ) class RuleEngine: def __init__(self, rules): self.rules = sorted(rules, key=lambda r: r.get("priority", 99)) def decide(self, state: GameState): for rule in self.rules: if self._match(rule["condition"], state): return rule return None def _match(self, cond: str, state: GameState) -> bool: # 用受限的 eval 环境,只暴露 state 字段 return bool(eval(cond, {"__builtins__": {}}, vars(state)))这段代码里,StatePoller.poll的双版本校验是核心,它解决的是「读到一半状态变了」的经典问题。RuleEngine用eval做条件匹配,生产环境建议换成ast.literal_eval或专门的表达式解析库,避免注入风险。规则按priority排序,数值小的先匹配,这样「紧急补血」可以排在「继续巡逻」前面。
2.3 把 CGA 接口包成可替换的适配器
CGA 的接口在不同游戏版本间会变,直接调用会让 MLAssist 跟着一起碎。常见做法是加一层适配器,把 CGA 的调用收敛到少数几个方法里。这样版本更新时只改适配器,规则和调度层不动。
class CGAAdapter: """所有对 CGA 的调用都收在这里,方便版本切换""" def __init__(self, cga_module): self.cga = cga_module def get_state_version(self) -> int: return self.cga.mem.read_int(0x00A1B2C0) def read_raw_state(self) -> dict: return { "hp": self.cga.mem.read_int(0x00A1B2D0), "hp_max": self.cga.mem.read_int(0x00A1B2D4), "mp": self.cga.mem.read_int(0x00A1B2D8), "mp_max": self.cga.mem.read_int(0x00A1B2DC), "battle_flag": self.cga.mem.read_int(0x00A1B2E0), "map_id": self.cga.mem.read_int(0x00A1B2E4), "bag_free": self.cga.mem.read_int(0x00A1B2E8), } def move_to(self, x: int, y: int): self.cga.action.move(x, y) def use_skill(self, skill_id: int, target=None): self.cga.action.skill(skill_id, target)适配器里的内存地址是示意值,实际地址需要用 Cheat Engine 或类似工具在目标版本上重新定位。把地址集中在一处的好处是:版本更新后你只需要重新找这几个偏移,而不是满代码库搜read_int。
3. 从零跑通 MLAssist:环境、配置与第一个自动补血规则
3.1 环境准备与依赖安装
MLAssist 是 Python 工程,跑起来需要 Python 3.8 以上、CGA 的运行库、以及几个常用依赖。CGA 通常以 DLL 或 pyd 形式提供,需要和 Python 位数对齐——32 位游戏配 32 位 Python,这是最常见的翻车点,位数不匹配会直接报「无法附加进程」。
# 建议用虚拟环境隔离,避免和系统 Python 冲突 python -m venv venv venv\Scripts\activate # Windows # source venv/bin/activate # Linux/macOS pip install pyyaml loguru pynput # CGA 的 Python 绑定按官方方式安装,通常是拷贝到 site-packages 或加进 PYTHONPATHpyyaml用来读规则配置,loguru做日志,pynput用于兜底的键鼠模拟。CGA 绑定不要用 pip 装,它一般不在 PyPI 上,按项目提供的方式放置即可。
3.2 规则配置文件怎么写
规则用 YAML 描述,比 Python 字典更适合非程序员改。一个自动补血的配置长这样:
# rules.yaml rules: - name: emergency_heal priority: 1 condition: "hp_pct < 0.3 and in_battle" action: use_item params: item: "高级血瓶" cooldown: 1.5 - name: normal_heal priority: 5 condition: "hp_pct < 0.6 and in_battle" action: use_item params: item: "普通血瓶" cooldown: 2.0 - name: rest_when_low_mp priority: 10 condition: "mp_pct < 0.2 and not in_battle" action: wait params: duration: 30priority越小越先匹配,所以紧急补血排在普通补血前面。cooldown是这条规则触发后的冷却秒数,防止同一秒内连发多次。condition里能用的字段就是GameState的属性名,改字段名要同步改这里。
3.3 主循环:轮询、决策、执行、日志
主循环把前面几层串起来,节奏是「轮询状态 → 规则决策 → 调度执行 → 记录日志」。轮询间隔不能太短,否则 CPU 占用高且容易读到不稳定状态;也不能太长,否则反应迟钝。常见做法是 100 到 200 毫秒一次。
import time import yaml from loguru import logger def load_rules(path="rules.yaml"): with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f)["rules"] def main_loop(adapter, poller, engine, interval=0.15): last_action_time = {} while True: state = poller.poll() if state is None: time.sleep(0.02) continue rule = engine.decide(state) if rule: now = time.time() cd = rule.get("params", {}).get("cooldown", 0) last = last_action_time.get(rule["name"], 0) if now - last >= cd: execute(adapter, rule) last_action_time[rule["name"]] = now logger.info(f"触发规则 {rule['name']} | hp={state.hp_pct:.2f} " f"mp={state.mp_pct:.2f} battle={state.in_battle}") time.sleep(interval) def execute(adapter, rule): action = rule["action"] params = rule.get("params", {}) if action == "use_item": adapter.use_item(params["item"]) elif action == "use_skill": adapter.use_skill(params["skill_id"], params.get("target")) elif action == "wait": time.sleep(params.get("duration", 1)) elif action == "move": adapter.move_to(params["x"], params["y"])last_action_time用规则名做键,保证每条规则独立冷却。execute是个分发函数,新增动作类型就在这里加分支。日志里带上关键状态,出问题时能回溯「当时为什么触发了这条规则」。
3.4 参数怎么调:轮询间隔、冷却、阈值
三个参数最影响体感。轮询间隔 0.15 秒是平衡点,低于 0.1 秒 CPU 明显上升,高于 0.3 秒补血会慢半拍。冷却时间要略大于动作的实际动画时长,比如吃药动画 1.2 秒,冷却设 1.5 秒,设太短会连点导致浪费。血量阈值分两档:紧急档 0.3、普通档 0.6,两档之间留出缓冲,避免在 0.6 附近反复触发。MP 恢复阈值设 0.2 是因为低于这个值很多技能放不出来,提前坐下回蓝比战斗中空蓝强。
4. 多开窗口与状态同步:MLAssist 的并发处理
4.1 多开场景下的状态隔离
单窗口跑通后,下一步通常是多开。多开的核心问题是状态隔离——每个窗口有自己的GameState,不能混。做法是给每个窗口分配独立的CGAAdapter和StatePoller实例,规则引擎可以共享(规则一样),但last_action_time必须按窗口分开。
class WindowWorker: """一个窗口一个 worker,状态和冷却都独立""" def __init__(self, window_id, adapter, rules): self.window_id = window_id self.adapter = adapter self.poller = StatePoller(adapter) self.engine = RuleEngine(rules) self.last_action_time = {} def tick(self): state = self.poller.poll() if state is None: return rule = self.engine.decide(state) if rule and self._cooldown_ok(rule): execute(self.adapter, rule) self.last_action_time[rule["name"]] = time.time() logger.info(f"[窗口{self.window_id}] {rule['name']}") def _cooldown_ok(self, rule): cd = rule.get("params", {}).get("cooldown", 0) last = self.last_action_time.get(rule["name"], 0) return time.time() - last >= cd每个WindowWorker持有自己的适配器和轮询器,last_action_time也是实例属性,天然隔离。主线程用列表管理所有 worker,轮流 tick。
4.2 用线程还是协程:IO 等待型的选型
多开的瓶颈在 IO 等待——读内存、发按键、等动画,都是阻塞操作。用线程的话,每个窗口一个线程,Python 的 GIL 在 IO 等待时会释放,所以多线程能跑出并发效果。用协程的话,需要把 CGA 的阻塞调用包成异步,改造量大。常见做法是多线程,窗口数在 10 个以内时线程开销可以接受。
import threading def run_workers(workers, interval=0.15): def loop(worker): while True: worker.tick() time.sleep(interval) threads = [threading.Thread(target=loop, args=(w,), daemon=True) for w in workers] for t in threads: t.start() for t in threads: t.join()daemon=True保证主线程退出时子线程跟着结束,不会挂住进程。每个线程独立 tick,互不干扰。窗口数超过 15 个时,考虑用进程池或把轮询间隔调大,否则线程切换开销会拖慢整体响应。
4.3 共享资源加锁:日志、配置热重载
多线程下共享资源要加锁。日志用loguru本身线程安全,不用额外处理。配置热重载是常见需求——改完规则不想重启所有窗口。做法是用一个带锁的配置容器,worker 每次 tick 前读一次当前规则。
class RuleStore: def __init__(self, path): self.path = path self._lock = threading.Lock() self._rules = load_rules(path) self._mtime = os.path.getmtime(path) def get_rules(self): with self._lock: mtime = os.path.getmtime(self.path) if mtime != self._mtime: self._rules = load_rules(self.path) self._mtime = mtime logger.info("规则已热重载") return self._rulesget_rules每次检查文件修改时间,变了就重载。锁只保护读和重载的临界区,粒度小,不影响 tick 频率。worker 里把self.engine换成每次从RuleStore取规则重建,或者让RuleEngine支持动态更新规则列表。
5. 避坑与排查:MLAssist 落地时最容易翻车的五件事
5.1 附加进程失败,报「找不到窗口」或「权限不足」
现象是启动就报附加失败,日志里看不到任何状态数据。原因通常有两个:一是游戏以管理员权限运行,而 Python 脚本没有,权限不对等导致无法读写;二是窗口标题或类名匹配写死了,游戏更新后标题变了。解决办法是让脚本和游戏同权限运行,窗口匹配改成按进程名而非标题,并在适配器里加一段探测逻辑,附加失败时打印当前所有可见窗口的进程名,方便对照。
5.2 状态读到一半就变了,导致误判
现象是偶尔出现「明明血够却吃药」或「血空了没反应」。原因是轮询过程中游戏状态发生了切换,读到的血量是旧值、战斗标志是新值,拼出一个不存在的状态。解决办法就是前面StatePoller里的双版本校验——读前读后各取一次版本号,不一致就丢弃本次。如果 CGA 没有版本号接口,可以用「关键字段读两遍,一致才采用」的退化方案。
5.3 冷却时间设太短,动作连发被服务器丢弃
现象是日志显示规则触发了,但游戏里没效果,或者效果断断续续。原因是冷却时间小于动作的实际动画时长,脚本在动画没结束时又发了一次,服务器把重复请求丢了。解决办法是把冷却设成动画时长的 1.2 到 1.5 倍,实测方法是手动放一次技能,用秒表掐动画时间,再乘系数。不同技能动画时长不同,冷却要按技能分别设。
5.4 多开时所有窗口抢同一个内存地址
现象是多开时只有一个窗口正常,其他窗口状态全是乱的。原因是所有适配器读了同一个硬编码地址,而不同游戏窗口的内存基址不同。解决办法是每个窗口附加后先获取自己的模块基址,所有偏移都基于这个基址计算,而不是用绝对地址。CGA 一般提供获取基址的接口,适配器初始化时存下来,后续读取都用「基址 + 偏移」。
5.5 规则条件写错,eval 抛异常导致整个循环挂掉
现象是改完规则后脚本直接不动了,日志停在某一行。原因是condition里写了不存在的字段名,或者语法错误,eval抛异常没被捕获,主循环退出。解决办法是在RuleEngine._match里包一层 try-except,捕获异常后记录规则名和错误信息,返回 False 继续匹配下一条。同时启动时做一次规则校验,把所有 condition 用假状态跑一遍,提前暴露语法问题。
6. 进阶:把 MLAssist 的决策层做成可回放的状态机
跑通基础版后,最有价值的进阶方向是给决策层加「回放」能力。挂机出问题时,你往往不知道当时发生了什么——是状态读错了,还是规则匹配错了,还是动作没发出去。回放的做法是把每次 tick 的GameState快照和命中的规则序列化到磁盘,出问题后离线重跑,逐帧看决策链路。
实现上,给GameState加一个to_dict方法,tick 时把状态和规则名写进环形缓冲区,缓冲区满了落盘成 JSONL。回放时读 JSONL,把状态喂给规则引擎,对比当时命中的规则和现在命中的规则是否一致。不一致就说明规则改过了,或者状态读取有随机性。
import json from collections import deque class ReplayRecorder: def __init__(self, path, maxlen=2000): self.path = path self.buffer = deque(maxlen=maxlen) def record(self, state, rule_name): self.buffer.append({ "ts": time.time(), "state": vars(state), "rule": rule_name, }) def flush(self): with open(self.path, "a", encoding="utf-8") as f: for item in self.buffer: f.write(json.dumps(item, ensure_ascii=False) + "\n") self.buffer.clear() def replay(self): """离线重跑,返回每帧的状态和当时规则""" with open(self.path, "r", encoding="utf-8") as f: for line in f: yield json.loads(line)deque的maxlen控制内存占用,满了自动丢最旧的。flush追加写,不覆盖历史。回放时逐行读,把state还原成GameState对象再喂给规则引擎,就能复现当时的决策。这个能力在调阈值时特别有用——你可以把阈值改一改,用同一份回放数据跑一遍,看新阈值下会触发多少次,不用真的挂机等。
验证方法上,我一般会做「对照回放」:同一份状态数据,用旧规则和新规则各跑一遍,统计触发次数和触发时机的差异。如果新规则在紧急档的触发次数明显减少,说明阈值调高了,可能漏补;如果普通档触发次数暴涨,说明阈值调低了,会浪费药。这个对比比拍脑袋改参数靠谱得多。
一个具体技巧是给回放数据加「标签」。挂机时如果观察到某次异常(比如卡住不动),手动在日志里打个标记,回放时优先看标记附近的帧。标记可以用一个特殊规则实现——检测到某个按键按下就往缓冲区写一条带标记的记录。这样排查时不用从头翻几千帧,直接跳到标记点。
最后说个血泪经验:我早期图省事,把内存地址硬编码在业务逻辑里,结果游戏一次小更新,所有地址偏移全变,脚本集体罢工,排查了一整晚才发现是基址变了。后来把所有地址收敛到适配器的一个字典里,更新时只改那个字典,十分钟搞定。状态机也好,回放也好,本质都是让「变化的部分」和「稳定的部分」分开,这样每次游戏更新你只需要改一小块,而不是推倒重来。希望帮到你。
本文还有配套的精品资源,点击获取