这件事如果只看标题,很容易被理解成"AI 全自动破解游戏"。完全不是。
真实过程是我和它坐在同一台机器前面,我给方向,它去验证;它的验证结果反过来修正我的判断。两关都是这样一点点磨出来的。这篇记的是那个协作过程本身 —— 包括我判断错的地方,和它推不动的时刻。
仓库:https://github.com/ip2867/CheatEngine-AI-Agent
一、先说清楚我们在干什么
gtutorial-x86_64.exe 是 Cheat Engine 自带的训练程序。它不是真游戏,是专门用来练内存分析的靶子,每一关都在逼你用不同的手段。
我们要打通的是前两关。
为了让 AI 能真正参与,我先给它修了一条通道:把 Cheat Engine 变成一个 MCP 服务。它可以直接读内存、扫数值、下断点、读寄存器、反汇编、改代码 —— 不是我转述,是它自己动手。

通道是前提,但不是解法。 接下来九成的时间花在"到底该往哪查"上。
一点五、这条通道长什么样
在进入攻关过程之前,值得交代一下 AI 手里到底有哪些工具 —— 因为后面每一步卡住或突破,都跟某个工具"有没有被正确使用"直接相关。
一次调用是怎么走完的
AI 的 tool call → ce_mcp_server.py → 命名管道 → ce_ai_bridge.lua 在 CE 里执行 → 原路返回
AI 说一句"读这个地址",中间要过四层。ce_mcp_server.py 是 MCP 服务的壳,负责把工具调用翻译成管道命令;管道 \\.\pipe\CE_AI_Bridge_v1_0 是本机的双向通道;真正干活的是注入在 CE 进程里的 Lua 桥 —— 它能直接用 CE 自己的内存 API,所以读写的是真实进程,不是模拟。
这个设计的代价是:AI 的每一个动作,都必须在 CE 的 API 世界里表达出来。 想要什么能力,就得先在桥里写一个 handler。这也解释了后面为什么"某个功能不支持"是个特别危险的结论 —— 它往往只是这个 handler 没写对。
工具分成这几类
| 分类 | 数量 | 能干什么 |
|---|---|---|
| 进程 | 4 | 列出所有进程、按镜像名挂载、查模块段(基址 / 大小 / 权限) |
| 读取 | 4 | 单个地址按类型读、按块批量读(一次 64 KiB)、读字符串、读结构体字段 |
| 扫描 | 6 | 首次扫描(exact / unknown / range)、按增减收窄、翻页取结果、重扫 |
| 分析 | 11 | 反汇编、取指令字节、找模块、按形状扫实体、看候选集实时状态 |
| 写入 | 6 | 单值写、字节写、批量写、带乐观锁的写 |
| 断点 | 10 | 下读/写/执行断点、查命中、读寄存器、查调试器状态 |
| 假设与标定 | 6 | 开假设、标定基准(baseline / action)、给扫描挂因果前提 |
| 候选集 | 5 | 取幸存集、按实时值收窄、标注失效原因 |
| 因果与审计 | 2 | 探针、调用审计 |
一共 54 个工具。
几个后面会反复出现的
scan_first / scan_next —— 这是 CE 里那个"首次扫描 / 再次扫描"的 API 化版本。type 可以是 dword / float / qword,op 可以是 exact / unknown / increased / decreased / changed。第二关前面六轮全空,就是在不同的组合里试。
scan_results —— 这个不起眼,但它是"假收敛"的解药。它返回的每条结果都带三个字段:live(现在还是不是活的)、changedSinceScan(扫描之后变了没有)、staleReason(失效原因)。没有这三个字段,“收窄"实际上是在用旧快照筛实时值,筛出来的东西没有意义。这是早期反复踩的坑。
read_bytes —— 一次读 64 KiB 回来本地解析。听起来平平无奇,但它是转折点:之前逐地址读,几十万次管道往返直接把游戏拖死过一次(189 个候选试到第 120 个,进程消失)。批量读之后,扫完 4 MB 只要一次调用。
find_struct —— 这次是专门为突破口新写的:不按值筛,而是在 CE 里一次扫完整个窗口,找所有满足"相邻两个 dword 且 hp <= max“的地方。它是把"换一个问法"这个洞察固化成了工具。
set_breakpoint / bp_hits / read_registers —— 下断点、拿命中、读当时的寄存器。第二关能定位共享扣血函数全靠这套:断点抓到 rcx=0x15E4CB0、rdx=2,才知道"血量 -= 伤害"到底在谁身上跑。
write / write_bytes —— 写内存。这里有个设计细节:两个都支持乐观锁,write 用 expect(期望旧值),write_bytes 用 expectHex(期望旧字节)。值被外部改过就明确报 stale,而不是默默覆盖。
hypothesis_open / anchor_calibrate —— 扫描前的三件套,必须先声明一个假设、再标定一次可观测的基准,才能开始扫。强制 AI 在动手前说清楚"我在找什么、根据什么判断它变了”。
这套工具的设计取向
回头看,54 个工具里有两类很有意思:
- 一类是能力:读、写、扫、断点、反汇编 —— 让 AI 能动手。
- 一类是纪律:
live/staleReason、expect乐观锁、debugPathWorks探针、find_struct的hp <= max默认约束 —— 这类工具不是给 AI 更强的能力,是堵住它最容易犯的错。
后面第七章会展开:凡是能让模型"选择不做"的纪律,弱的模型一定会不做。所以纪律必须长在工具里。
二、第一关:从"差一发"到"锁定弹药”
我自己先看出来的那个现象
第一关的靶子会回血。我打了几枪就发现规律很奇怪:
弹药 5 → 4 → 3 → 2 → 1 →(第5枪)→ 5 ← 弹药回满
血量 188 → 142 → 98 → 52 → 8 → 188 ← 血量也回满
第 5 枪不但没有伤害,反而把靶子治回满血。 满血 188 像素、每发 46 像素,188 ÷ 46 ≈ 4.09 —— 它最多能挨 4 发,第 5 发是溢出伤害。
所以这一关有个死局:你永远差一发。打得再准也没用,那个"第 5 发"就是换弹机制本身。
我的判断是:不要去改血量,那是在跟一个每 5 发自动回满的东西较劲。要掐掉换弹这个动作本身。
我把方向给了它
我去翻了自己以前写的教程,找到关键的那句:显示值和内存值不是同向的。界面上显示 3 的时候,候选地址里已经出现 0 了。
这意味着扫描方向不能照常理走 —— 应该从 0 开始,按"增加的值"重扫。
我把这条告诉了 AI。它照做,用这个方向扫弹药计数,很快命中了 0x1573E9C。
然后是我给的第二个动作:把这个地址锁定在满弹对应的值上。锁定之后,装弹流程再也不触发,靶子也就不会回血了。
剩下的就是慢慢打完。

这一关的实质
这一关真正的难点不是"找地址",是看懂那个死局的形状。
如果一开始就认定"血量是 5"(打五下就死嘛),后面所有搜索都会围绕一个不存在的东西展开。这里我给了它一个纠正:五下打死不能证明血量是 5,得先算出每发伤害。
这是整个项目里我最想说的一点:AI 的能力上限,往往卡在我把问题描述成什么样。 同一套工具,方向给错就是几十轮白跑,方向给对十分钟就完。
三、第二关:三个实体、一个标志位、和一次误判
第二关有两个敌人,而且它们和玩家共用同一套扣血代码。这意味着改一处会同时影响三方 —— 这既是难点,也是解法。
第一步:先把自己变成打不死的
我的要求很直接:先找到自己的血量并锁定,免得自己被打死,然后才能从容地找敌人。
这一步 AI 走得非常艰难。它扫 dword、扫 float、扫双向、扫 0..1 比例区间,六轮全部收敛到几十个候选,然后逐个写进去试,血条一次都没动过。
它一度写进文档说"血量不是任何可达堆窗口里能跟随显示的普通 dword"。这个结论是错的。
是我手动在 CE 里筛出来的:浮点 + 减少的值,三个地址,中间那个 0x0167C1B0,值 76。
它拿到地址之后做了验证,发现这个地址旁边排着一串浮点:
0x0167C1B0 76.0 ← 血量显示
0x0167C1B4 0.25
0x0167C1B8 0.05
0x0167C1BC 0.008
这三个数是伤害百分比参数。 写 38.0,血条立刻掉一半 —— 但写 0.0、锁 201 次、持续 4.8 秒,游戏不判定死亡。因为它每帧都在被游戏重算。
这是我给的第一个关键判断:这个数是显示副本,不是权威值。真正的权威值在别处,而且它是个整数。
顺着"整数"这个方向,它用断点炸出了共享扣血函数:
0x100042D10 48 89 C8 mov rax, rcx
0x100042D13 29 50 60 sub [rax+0x60], edx ; 血量 -= 伤害
0x100042D16 C3 ret
打到 rcx=0x15E4CB0 —— 那是玩家对象。+0x60 就是真实血量,+0x64 是上限。
这一步是整个第二关的地基。 从这里开始,“谁的血量"这件事才第一次有了确定的答案。
第二步:怎么区分敌我 —— 你给的答案
找到共享函数之后,问题变成了:这段代码对玩家和敌人都在跑,我怎么只伤害敌人?
我给的方案是:看结构体里的阵营字段,我方是 0,敌方是 1,用 cmp 区分。
AI 去找了。它一开始拿 +0x24 当判据(那里正好是 float ±0.205 / 0),被我否掉了 —— 那只是恰好相关的浮点参数。真正的标志位是 +0x70:
0x015E4B70 +0x60=200 +0x64=200 +0x70=1 ← 敌方
0x015E4CB0 +0x60=血量 +0x64=100 +0x70=0 ← 我方(唯一)
三个结构体间距 0x140 和 0xA0,全部对得上。
第三步:注入 —— 我给了两个方案
我给了它两段代码,一个是给敌人拉满伤害,一个是直接把敌人血量清零:
; 方案一:给敌人拉满伤害
cmp [rax+60+10],0
je returnhere
mov edx,7FFFFFFF
originalcode:
sub [rax+60],edx
; 方案二:直接把敌人血量清零
cmp [rax+60+10],0
je returnhere
mov [rax+60],0
它落地成了这样,挂在 0x100042D10,跳到 .text 尾部的代码洞 0x10023BB18:
48 89 C8 mov rax, rcx
83 78 70 00 cmp dword [rax+0x70], 0
75 03 jne enemy
C3 ret ; 玩家:一刀不挨
90 90
29 50 60 sub dword [rax+0x60], edx
C7 40 60 00 00 00 00 mov dword [rax+0x60], 0 ; 敌人:一枪秒
C3 ret
几个细节值得记:
- 必须用
[rax+0x70],不能用[rax+0x24]。 后者是 float,cmp dword比的是位模式0x3E51EB85,永远不相等。 - 函数后面只有 9 字节连续零,装不下注入体,所以必须跳跃到代码洞。
jne的位移是 3,不是 2。
装上之后,左敌一枪就死。

第四步:这里我们俩一起走错了一次
装完注入之后,右敌怎么打都打不中。AI 花了很多轮去调瞄准几何 —— 它算出来右敌在 x966 / y0..213,玩家在 x799 / y984..1169,水平差 167 像素、垂直差 985 像素,需要 9.6° 偏角,说"垂直上飞的子弹永远打不到”。
这套计算本身没错,但方向完全错了。
真相是:注入之后窗口标题变成了 Step 2 (Integrity check error),整个战斗循环已经冻结了。 子弹穿过敌人不扣血,敌方开火也无效。它一直在给一个已经死掉的游戏做几何分析。
这是我事后和它一起复盘时才认出来的。代价是白跑了很多轮。
如果重来,判断只需要一句:改完代码先看窗口标题和游戏还在不在跑,再去怀疑自己的数学。
四、真正的突破口:把"找血量"换成"找一对血量"
几个小时的原地打转之后,问题出在一个思路上。
前面所有尝试都在按值筛:扫一个数、打一枪、看哪个数变小、再打一枪、再筛。
这条路能走通,但有个死结:值在变的东西太多了。计时器、帧计数、每个对象里十几个浮点参数,全都在动。而且每轮收窄要跨几十万次管道往返,收敛到几十个候选之后还得逐个写入、截图、看血条 —— 有一次 189 个候选试到第 120 个,游戏被拖死了。
转机是把问题换了一个问法:
不要问"哪些值在按血条的方向变化",要问"哪些相邻的两个数长得像一对血量"。
血量在结构里是这样的:
+0x00 hp 当前血量
+0x04 max 血量上限
相邻,而且必然满足 hp <= max。这不是数值规律,是逻辑约束 —— 计时器、帧计数、浮点参数都不会"碰巧"满足它。
于是扫描从"逐地址问回来"变成"在 CE 内部一次扫完整个窗口"。实测(4 MB 窗口):
| 判据 | 候选数 |
|---|---|
只要求 max 在 {100,200,300,500} 里 | 159 |
再加 hp <= max | 6 |
| 耗时 | 0.9 秒,一次调用,没有开过一枪 |
一个约束砍掉 26 倍,被砍掉的全是 hp=301925169 max=100 这种明显垃圾,三个真实体一个没丢。
顺带纠正一个更早的错误
之前 AI 在同一个窗口里用虚表指针筛实体,只找到 2 个,右敌始终不出现,于是写下了"右敌可能不在这个窗口里"。
它一直都在。 换成形状判据之后,三个实体一次全部出来:
0x015E4B70 hp=200 max=200 ← 敌人 1
0x015E4CB0 hp=100 max=100 ← 玩家
0x015E4D10 hp=200 max=200 ← 敌人 2
间距 0x140 和 0xA0 跟历史记录完全一致。
教训很干净:用会变的东西当锚点,就是给自己埋坑。 虚表值可能因为多态、不同工厂而不同;hp <= max 是游戏逻辑本身决定的,不随实现变化。
最后一步
三个实体到手之后,就没有悬念了。玩家血量上限和当前值都写 100000,两个敌人的血量和上限都写 1:
before: E1=200 PL=100 E2=200
after: E1=1 PL=100000 E2=1
有个细节:两个敌人必须同时改。 只改一个的话,幸存的那个会拿到伤害加成,反而更危险。
通关画面上的红字是 Player Health: 99984 —— 锁血生效了,只是掉了一点点。没有注入、没有改 .text、没有完整性检测报错。

五、回头看,这个协作是这样分工的
把两关的每一步拆开看,分工其实非常清楚:
| 环节 | 我做什么 | AI 做什么 |
|---|---|---|
| 第一关:发现"第 5 发回满血" | 打几枪、看出死局 | 实测血量像素,验证 188÷46≈4.09 |
| 第一关:换扫描方向 | 想起"显示值和内存值不同向",定方向 | 按新方向扫,命中弹药字段 |
| 第一关:锁定 | 说"锁在满弹对应的值上" | 执行并验证换弹不再触发 |
| 第二关:先锁自己 | 定优先级:“先别被打死” | 扫了六轮全空 |
| 第二关:血量地址 | 手动在 CE 里筛出 0x167C1B0 | 验证、发现它是显示副本、顺藤找出整数权威值 |
| 第二关:找共享代码 | 说"敌我是共用代码的" | 下断点、抓到 80+ 次命中、反汇编出 0x100042D10 |
| 第二关:区分敌我 | 给出阵营字段 +0x70,我方 0 敌方 1 | 验证 +0x24 是错的、定位三个结构体 |
| 第二关:注入代码 | 给出两段汇编方案 | 落地、算跳转位移、找代码洞 |
| 第二关:卡住时 | 一起复盘,认出是完整性检测 | 承认几何分析白跑 |
| 突破口 | 提出"换成找一对血量" | 实现整窗扫描,159 → 6,0.9 秒 |
看出来了吗 —— 几乎每一个方向性的决定都是我给的,几乎每一个方向的验证都是它做的。
这就是它真实的定位:一个能在一秒内执行几十万次内存读的探索器,但它不知道自己在找什么。
六、AI 在这个过程中犯的错,有个共同形状
现在讲坑,但只讲浓缩的那几条,因为它们全都指向同一件事。
它总是把"相关"当成"因果"
- 数值"五下死" → 以为血量是 5
- 字段"在变小" → 以为是血量
- 血条"跟着某个 float 变" → 以为那是血量(其实是显示副本)
- 断点"读数 0" → 以为这台机器不支持断点(其实只是少调了一次
debugProcess()) - 只找到 2 个实体 → 以为第 3 个在别处(其实是判据选错了)
能立刻改变画面的东西,不等于源头;能被观察到在变化的东西,不等于就是它。
它会把"我调不通"写成"不支持"
中间有很长时间,它认定硬件断点在这个环境彻底废了,还写进了文档。真因只有两行代码:
bps_arm 从不调用 debugProcess()
set_breakpoint 会调用 debugProcess()
不调 debugProcess(),断点只是"登记"了,调试器根本没挂上去。改掉之后同一次会话 80+ 次命中。
“这个功能不支持"是个非常危险的结论 —— 它会让后面所有工作绕开一个其实可用的工具。
它写的 verified: true 不代表游戏保留了那个值
write 返回成功,只证明字节落到了内存并且回读一致,从不证明游戏下一帧不会覆盖它。
真实案例:一批 0x210 步长的对象数组,20 个槽全部写 0,verified: true × 20 —— 血条纹丝不动。因为它是副本,游戏每帧从源头重新拷一遍。
这四条的共同点
它每次都找到了一个"和现象相关"的东西,并把相关性当成了因果。
而真正让它翻盘的,不是"更努力地筛”,是换了一个问法。从"哪些值在按它的方向变"换成"哪些东西长得像一对血量"。
AI 答错的时候,往往不是不够努力,而是问错了问题。
七、所以我们把这些纪律变成了代码
这是这一轮里我认为最重要的一件事。
如果只是在提示词里写"注意类型假设"、“注意假收敛”、“改完要观察”,那没有任何意义 —— 凡是模型可以选择不做的纪律,弱的模型一定会不做。
所以这些东西变成了工具本身的硬性约束:
| 曾经的错误 | 现在的硬约束 |
|---|---|
| 拿扫描快照收窄 | scan_results 返回 live / staleReason / changedSinceScan,收窄只能走实时读 |
| 直接扫全地址空间 | 先查模块段,窗口限在真实可写区(约 123 MB,不是 1 亿候选) |
| 写完就以为改成了 | expect / expectHex 乐观锁,值被外部改动就明确报 stale |
| 断点 0 命中还以为是环境问题 | bps_arm 默认先做 debugPathWorks 探针,调不通直接报错 |
| 抓帧抓到半帧就当真值 | 血条三道几何拒绝 + 弹药字形解码失败即拒帧 |
| 抓帧抓到了别的窗口 | z 序遮挡检测,被挡就删旧图并拒绝保存 |
| 逐地址读把进程拖死 | 64 KiB 批量读,返回里分开报告 requested / returned |
| 按值筛筛到天荒地老 | find_struct 按形状扫全窗口,一次调用 |
最后一条就是这次突破的产物。它现在是一个正式工具:hp <= max 是默认判据,不用再靠人想起来。
八、一句话
这两关不是 AI 自己打通的,也不是我打通的。
是我负责不知道往哪走的时候停下来想,它负责我想到之后在一秒内验证掉。 它扫不出来的那个血量地址,是我手动筛出来的;我算不出来的那个 0.9 秒,是它跑出来的。
如果说这个项目证明了什么,大概就是:一个能看见进程的 AI,比一个只能说话的 AI 强很多;但它依然需要一个能提问的人。
工具和全部踩坑记录都在 https://github.com/ip2867/CheatEngine-AI-Agent。

说些什么吧!