简介:CE中文5.5 魔龙老狼是一款面向游戏调试、内存修改与逆向工程入门学习者的中文版 Cheat Engine 工具,适合希望降低英文界面门槛、快速理解内存扫描与数值修改流程的玩家和开发者使用。压缩包约5.74MB,文件总数与具体类型明细上游暂未提供,包内附带帮助文档及界面相关资源,便于查阅功能说明和熟悉操作入口。目前已有228人浏览学习,说明其在中文 CE 使用群体中具备一定参考价值。核心价值在于可用中文环境完成内存扫描、数值修改、内存变化跟踪以及自定义脚本自动化等操作,既能辅助单机游戏调试,也能作为理解游戏机制、内存管理和逆向分析思路的练手平台;使用时应注意仅在个人设备上进行合法、非商业性用途。
1. CE 5.5 中文整合版:一个把内存黑匣子撬开的旧工具箱
屏幕上显示的血量从 100 掉到 37,你手里只有一堆十六进制数字和一个不知道该搜什么的输入框。这时候救场的往往不是最新工具,而是一个十几年前就停更的老对象。搜「CE 5.5 中文」或者「CE老狼」,翻出来的多半就是这个整合包:Cheat Engine 5.5 主程序 + 中文语言文件 + 几个常用脚本和速查表,解压即用,不用装运行库。
它解决的是同一类问题:把一个正在运行的 32 位进程当成白盒,定位存放数值的内存地址,顺着指针找到静态基址,再用 Auto Assemble 把访问这段内存的指令改成你要的样子,最后封装成一个能重复用的修改器。适合逆向入门、单机游戏调试、老程序行为分析、自己做靶场练手;不适合任何联机环境,也不要对着带反作弊保护的商业软件试——这既是技术边界,也是这类工具最容易被误用的地方。真正值钱的部分不是某个数字,而是“找到它并让它活过重启”的那条路径。
2. 拆开这个包:目录清单、权限与首次附加
拿到压缩包先别急着双击主程序。整合包的价值一半在主程序,另一半在它附带的脚本、语言文件和示例表上;搞不清哪个文件管什么,后面切中文切不出来、脚本不生效、表里的地址全红,你都不知道该找谁。
2.1 整合包的文件构成与各自作用
解压后看到的目录大致是下面这些,文件名各版本略有出入,但职责划分基本固定:
| 路径 | 作用 | 使用中的注意点 |
|---|---|---|
CheatEngine.exe | 主程序,面向 32 位目标进程 | 双击没反应,多半是被杀软或系统策略拦下,先看拦截日志 |
languages\ | 中文语言文件 | 与主程序版本不严格对应时,界面会半中半英 |
autorun\ | 启动时自动执行的 Lua 脚本 | 这里放什么就执行什么,来源不明的脚本等于给自己埋雷 |
Tables\或散落的.CT | 预置作弊表 | 表里常写死绝对地址,换台机器就全废 |
| 内核读写组件(DBK 一类驱动文件) | 提供内核级读写通道 | 加载失败是权限或签名问题;不要在联机产品上尝试 |
| 附带的速查表 / 说明文档 | 快捷键、地址格式、常用特征码 | 老版本菜单名和新版对不上,别照新版教程硬套 |
先把languages目录确认一遍,再进主程序Settings → Language选中文。切完仍然满屏英文,说明语言文件和主程序的字符串表对不上,这种情况在老整合包里很常见,不用怀疑自己操作错了。
autorun目录值得单独说。CE 启动时会把它下面的脚本全部跑一遍,好处是你可以把自己常用的函数(比如打印模块基址、批量采样)固化进去,坏处是别人塞一个脚本进来就能悄悄改你的扫描行为。我一般把autorun里的东西全清空,需要什么手动放、手动看一眼内容。
2.2 位数、权限与“进程列表里没有它”
第一次附加最容易翻车的地方,是你在进程列表里根本找不到目标。原因通常只有三类。
第一类是位数。CE 5.5 这一代主要面向 32 位进程,如果你要附加的目标是 64 位程序,先确认目录里有没有配套的 64 位主程序;没有的话硬上只会得到“打开进程失败”,这不是配置问题,是能力边界。判断方法很直接:任务管理器里看进程有没有“(32 位)”标注,或者用 CE 的进程列表看能不能列出来。
第二类是权限。普通用户权限下,OpenProcess会被拒绝,尤其是目标本身以管理员身份启动的时候。稳妥做法是让两边权限对等——目标用管理员跑,CE 也用管理员跑。反过来,目标普通权限、CE 管理员权限,通常没问题。
第三类是进程名伪装。有些单机老游戏会把自己挂在启动器下面,你看到的是启动器进程,真正的游戏逻辑在子进程里,数值当然扫不到。附加前先在进程列表里按内存占用排一下序,找那个真正吃内存的。
提示:附加不上先别怀疑工具,按“位数 → 权限 → 是不是同一个进程”这个顺序查三遍,能省掉一大半折腾。
2.3 附加成功后先做的三件事
附加成功不代表能改。我习惯在动手扫描前跑三个确认,用的是 CE 自带的 Lua 控制台(Table → Show Cheat Table Lua Script,或者主界面的 Lua 按钮)。
-- 第一步:确认模块基址和大小,后面的偏移计算全靠它 local name = "target.exe" -- 换成你实际附加的模块名 local base = getAddress(name) local size = getModuleSize(name) print(string.format("base = 0x%08X size = 0x%X", base, size)) -- 第二步:确认目标地址所在的内存段是否可写 -- 在 Memory Viewer 里跳到该地址,看窗口下方的保护属性 -- 只读段(r--)上的数值改不动是正常的,别在这里浪费时间 -- 第三步:连续两次读取同一个地址,确认值稳定 local probe = base + 0x0012A4C0 -- 换成你怀疑的地址 local a = readInteger(probe) sleep(50) local b = readInteger(probe) print(string.format("0x%X: %d -> %d", probe, a, b))第一段代码解决“我在哪个坐标系里工作”。所有偏移都必须相对模块基址算,绝对地址一旦碰上 ASLR 或换机器就归零。getAddress传模块名返回基址,getModuleSize给长度,两个值合起来就是一个合法的搜索区间。
第二段不是代码,是操作:Memory Viewer 里地址栏跳转后,窗口下方会显示这段内存的保护属性。可写段是rw-,只读段是r--,可执行段是r-x。老游戏里常见的坑是把显示缓存当成血量地址,改了没反应——因为那块内存根本不参与逻辑运算。
第三段是防玄学。两次读取值都不一样,说明这个地址要么是每帧刷新的临时变量,要么你读的其实是别的进程。这种情况继续往下扫描纯属浪费生命,先换地址。
这三步做完,你才算真正“站在目标进程内部”。后面所有的扫描、指针、注入,都建立在这个前提上。
3. 数值扫描的四条路径:类型、对齐与收敛
扫描界面看起来只有几个下拉框,但它决定了你接下来是五分钟解决问题还是五小时。这一章把Value Type、Scan Type、Fast Scan三个东西的作用讲清楚,然后给一条能复现的收敛路径。
3.1 Value Type 与内存布局的对应关系
CE 的Value Type不是让你猜“这个数看起来像什么”,而是让你声明“这个数在内存里占几个字节、用什么编码”。选错类型,扫描结果永远是 0 条。
| Value Type | 典型用途 | 常见误判 |
|---|---|---|
| Byte | 等级、格子数量、枚举状态 | 老程序实际用 2 Bytes 存,扫 Byte 一条都出不来 |
| 2 Bytes | 血量、魔法值等 16 位整数 | 数值超过 65535 会溢出成负数 |
| 4 Bytes | 金币、经验、整数坐标 | 界面显示值经过倍率换算,实际存的不是这个数 |
| 8 Bytes | 64 位计数、时间戳 | 5.5 对 64 位目标的处理能力有限,别硬啃 |
| Float | 坐标、角度、速度 | 有精度误差,精确值扫不到,要用范围扫 |
| Double | 物理量、高精度坐标 | 同上,且更少见 |
| String | 名字、ID 类文本 | 要区分 UTF-8 和 UTF-16,选错就是乱码 |
| Array of byte | 特征码定位、指令定位 | 空格和大小写容易打错,错一个字节就匹配不上 |
| All | 完全不知道类型时 | 结果集极大,收敛慢,只适合救急 |
经验规则:血量类数值先试 4 Bytes,扫不到换 2 Bytes,还不行就上All反推类型。坐标类数值一定是浮点,而且必须用范围扫而不是精确扫。界面显示 100 但实际存 1000 的情况非常普遍,尤其是带百分比、带倍率的显示层。
3.2 精确值到未知初值:把结果收敛到个位数
标准流程是按Scan Type分阶段走的:先Exact Value拿到初始集合,再用Changed value / Unchanged value / Increased value / Decreased value反复筛。对血量这种“会掉不会涨”的数值,最有效的是“受伤前Exact Value,受伤后Decreased value”,两三轮就能从几十万条压到个位数。
对不知道当前值的数值(比如一个从没显示过的内部计数器),走Unknown initial value,然后靠“变 / 不变”交替筛。这条路慢,但它是唯一能用的。
下面这段 Lua 适合在候选地址只剩十几个、你想批量观察它们的时刻:
-- 对一批候选地址做两次采样,打印变化情况 local candidates = { 0x1A2B3C40, 0x1A2B3C44, 0x1A2B4000 } -- 从扫描结果里拷 local delay = 1000 -- 毫秒,留出操作时间 for _, addr in ipairs(candidates) do local v1 = readInteger(addr) sleep(delay) local v2 = readInteger(addr) local tag = (v1 == v2) and "不变" or "变化" print(string.format("0x%08X %d -> %d [%s]", addr, v1, v2, tag)) endreadInteger按 4 字节有符号读,读 2 字节地址要换成readSmallInteger,读浮点用readFloat。sleep单位是毫秒,设得太短你来不及在游戏里做动作,设得太长批量跑一遍很磨人,1000 毫秒是个能用的起点。
Scan Type里还有一个容易被忽略的项:Value between。浮点坐标必须用它,因为12.34存成单精度之后可能是12.3400001525878906,精确扫永远扫不到。
3.3 Fast Scan 与对齐:一个安静的错误源
Fast Scan勾上之后,CE 只扫 4 字节对齐的地址,速度提升明显,代价是漏掉不对齐的数据。整数通常对齐,所以扫血量时勾着没坏处;但扫浮点、扫字节数组、扫结构体里的紧凑字段时,它会让你的结果凭空少一大半。
Memory Scan Options里的起止地址同样重要。默认全段扫描会把 DLL、堆、栈全扫一遍,结果多且杂。如果你已经知道数值大致在某个模块的范围内,把起止地址限定到base到base + size,结果集会干净很多。
3.4 指针链:为什么重启之后地址就废了
扫到的地址是动态分配出来的堆地址,进程一退出,这块内存就被回收,下次启动分配的位置完全可能不同。想让修改活过重启,必须找到一条从模块静态区出发、经过若干次解引用、最终到达目标地址的路径。
CE 的做法是Pointer scan for this address。参数给得太松会扫出几百万条路径,给得太紧一条都出不来。32 位单机程序上我一般这么配:
| 参数 | 建议值 | 说明 |
|---|---|---|
| Max Level | 4 ~ 5 | 层级再深多半是绕了远路,实际项目很少超过 5 级 |
| Max Offset | 2048 ~ 4096 | 结构体字段偏移一般不超过几 KB,给太大纯属扩大搜索面 |
| Addresses must be static | 勾选 | 只保留以模块基址为起点的链,过滤掉一堆无效结果 |
| 是否对比两次扫描结果 | 建议做 | 重启目标后重扫一次,用Pointer scanner → Compare取交集,能砍掉九成噪音 |
拿到候选链之后必须手工验证,不能直接信。验证方式就是重启目标、重算地址、读值:
-- 手工验证一条两级指针:[base + 0x12A4C0] + 0x2C local base = getAddress("target.exe") + 0x0012A4C0 local level1 = readInteger(base) -- 第一级:从静态区读出一个堆地址 if level1 == 0 then print("第一级为空,这条链无效") else local hpAddr = level1 + 0x2C -- 第二级:结构体内偏移 print(string.format("hp addr = 0x%08X hp = %d", hpAddr, readInteger(hpAddr))) endreadInteger(base)这一步是在读“指针变量的值”,不是读指针变量本身。这是初学者最容易混的地方:base是存放指针的位置,level1才是被指向的地址。链上任何一级读到 0,就说明这一级在当前进程状态下没被初始化,这条链要作废。
多级指针还有个数量的现实问题:层级越深,越依赖程序内部的初始化顺序。有些链只在进入某个场景之后才有效,主菜单界面读出来全是 0。验证时必须在真实使用场景下跑,不能只在启动界面测。
4. 从扫描结果到可复用修改器:AOB 注入与 Lua 面板
扫到地址只是完成了排查,离“能用的东西”还差两步:把硬编码地址换掉,把开关做到界面上。这一章给的是能直接抄的骨架。
4.1 为什么绝不能把绝对地址写进脚本
绝对地址在三种情况下会失效:进程重启后堆重分配、目标换了编译版本、别人机器上模块基址不同。第一种情况最常见,第二种最致命——你辛苦调好的表现在自己机器上好好的,发给别人一运行就把人家游戏改崩了。
正确的做法是定位“访问这段内存的指令”,而不是“这段内存”。指令在模块里的相对位置是稳定的,用特征码(一段不会变的字节序列)把它找出来,再在它旁边插入自己的逻辑。这就是 AOB 注入。
4.2 Auto Assemble 的最小骨架
在 Memory Viewer 里找到写血量的那条指令,右键Find out what writes to this address,拿到指令地址和字节序列,然后Tools → Auto Assemble → Template → AOB Injection。如果你的版本里 Template 菜单没有这一项,照着下面手写等价逻辑。
[ENABLE] // 特征码必须唯一,且覆盖被跳转指令的完整长度 aobscan(HP_WRITE, 89 45 FC 8B 45 F8 83 C0 01) alloc(newmem, 2048) alloc(hpLock, 4) registersymbol(hpLock) label(back) newmem: mov [ebp-04], eax // 原始指令 1:89 45 FC mov eax, [ebp-08] // 原始指令 2:8B 45 F8,千万别漏 cmp byte ptr [hpLock], 00 // 开关:0 关闭,1 开启 je back mov dword ptr [ebp-04], #100 // 开启时把写入值强制成 100 back: jmp return HP_WRITE: jmp newmem nop return: registersymbol(HP_WRITE) [DISABLE] HP_WRITE: db 89 45 FC 8B 45 F8 unregistersymbol(HP_WRITE) unregistersymbol(hpLock) dealloc(newmem) dealloc(hpLock)逐段解释。aobscan在目标进程里搜这串字节,把第一个命中地址赋给符号HP_WRITE。alloc申请两块内存,一块放注入代码,一块放开关变量。registersymbol让 Lua 和别的脚本能通过名字拿到地址。
jmp newmem是 5 字节的近跳转,加一个nop正好 6 字节,所以被覆盖的原始指令必须是 6 字节整——89 45 FC是 3 字节,8B 45 F8是 3 字节,加起来刚好 6。这就是为什么骨架里要把两条原始指令都搬进newmem执行,少搬一条,等于凭空删掉一条指令,程序行为立刻偏离,表现就是“开了开关之后数值乱跳甚至崩溃”。
[DISABLE]段必须把原始字节原样写回,db 89 45 FC 8B 45 F8就是那 6 个字节。写回长度和覆盖长度不一致,禁用脚本时就会在目标里留下半条跳转指令,崩溃是必然的。
#100是 CE 的十进制立即数写法,不带#会被当成十六进制。hpLock用alloc分配后默认是 0,所以脚本一启用默认是关闭状态,符合预期。
4.3 把开关交给 Lua:做一个能点的面板
脚本有了符号,界面就简单了。CE 自带 Lua 引擎,5.5 这一代是 Lua 5.1 语法,别写goto,也别指望有table.unpack。
-- 在 CE 的 Lua 控制台执行,生成一个最小面板 local form = createForm(true) form.Caption = "调试面板 v1" form.Width = 300 form.Height = 120 form.Position = poScreenCenter local cb = createCheckBox(form) cb.Left = 16 cb.Top = 14 cb.Width = 220 cb.Caption = "把写入值锁成 100" cb.OnChange = function(sender) local ok, addr = pcall(getAddress, "hpLock") if not ok or addr == nil or addr == 0 then print("hpLock 还没注册,先在表里启用一次脚本") sender.Checked = false return end writeByte(addr, sender.Checked and 1 or 0) endcreateForm(true)的true表示创建后立即显示,去掉它表单会创建但不弹出来。poScreenCenter是位置枚举,写在Position上让窗口居中,老版本对这个属性支持没问题,实在不行就手写Left/Top。
pcall(getAddress, "hpLock")这层保护是必须的。符号只有在脚本被启用过之后才存在,用户先点复选框、后启用脚本是很常见的操作顺序,没有这层保护就是一句红色报错。
writeByte只写 1 个字节,和 AA 里的cmp byte ptr [hpLock], 00对应。这里如果用writeInteger写 4 字节,虽然低字节也对,但会覆盖相邻内存,属于自己给自己制造玄学。
4.4 交付形式的取舍
| 交付形式 | 优点 | 代价 | 适用场景 |
|---|---|---|---|
.CT表 | 改起来快,AOB 自动生效,体积小 | 对方必须装 CE | 自己用、同好之间交流 |
| 独立 exe(Trainer) | 双击即用,不需要 CE | 更容易被杀软盯上,地址写死风险高 | 纯单机自用 |
| Lua 脚本 + autorun | 可以加校验、加日志、加自检 | 调试信息暴露,别人能直接看逻辑 | 开发期、反复迭代阶段 |
我自己的顺序是:先在 Lua 里跑通逻辑,再固化成 AA 脚本,最后决定要不要打包。跳过中间那步直接做 exe,改一次错就要重新打包一次,效率极低。
5. 避坑:五条血泪记录与一份排查顺序
这一章的东西全是踩出来的,按“现象 → 原因 → 解决”写,遇到对应症状直接对号入座。
5.1 附加与扫描阶段的三条
第一条:进程列表里压根没有目标。
现象是打开进程选择窗口,翻遍了也找不到游戏进程,或者找到了但附加时报“打开进程失败”。原因通常有三种:一是位数不匹配,用 32 位主程序去附加 64 位进程,系统层面就不允许;二是权限不对等,目标以管理员启动,CE 是普通权限;三是你看到的是启动器,真正的逻辑在子进程里。
解决办法按顺序试:先在任务管理器确认目标进程的位数,找配套的主程序;再把 CE 和目标都提到管理员权限运行;最后在进程列表里按内存占用排序,找真正吃内存的那个。三条都排除了还附加不上,基本可以判定目标有保护机制,这个场景就该放弃。
第二条:扫描结果永远是 0 条。
现象是数值明明显示在屏幕上,Exact Value扫下去一条都没有。原因一般是类型选错或者数值被换算过:界面显示 100,内存里存的是 1000;或者界面显示 100,内存里存的是浮点 100.0;还有一种是显示层做了格式化,真实数值在另一个字段里。
解决路径是从宽到窄:先Value Type选All扫一次,看有没有命中,有的话看命中地址的值和显示值是什么换算关系;浮点类数值改用Value between给一个区间;Fast Scan先取消勾选,排除对齐导致漏扫。这一步不要跳,类型判断错了,后面所有收敛都是白做。
第三条:地址改了就崩,或者改了完全没反应。
现象是在扫描结果里双击改值,游戏直接退出;或者值改完瞬间又变回去,像是被什么东西覆盖了。前者的原因是改到了不该改的地方——只读段、代码段,或者一个被当作指针使用的字段。后者的原因是改的是显示缓存,真正的数值每帧从别处同步过来。
解决方式是先看内存段属性,Memory Viewer 下方显示r--或r-x的地址一律不要写;再用Find out what writes to this address看是谁在写这个地址,如果写它的指令来自显示层模块,那这个地址就不是数据源。真正的数据源地址,通常能顺着写入指令的操作数反向找到。
5.2 注入与分发阶段的两条
第四条:脚本一启用,目标秒退。
现象是 AA 脚本[ENABLE]之后进程直接消失,没有任何报错。原因几乎都是指令覆盖长度算错了:jmp占 5 字节,你只覆盖了 3 字节的指令,剩下 2 字节是下一条指令的开头,跳回来之后 CPU 从中间开始解码,立刻非法指令。
解决方法是先数清楚被覆盖区域的字节数,用nop补齐到 5 或 6 的整数倍,并把这段区域里的所有原始指令原样搬进注入代码里执行。搬的时候注意指令顺序和寄存器状态,漏掉一条修改eax的指令,后面的逻辑就会拿着错误的值继续跑。[DISABLE]段的db字节数必须和覆盖长度严格相等,这个数字我一般写完立刻回头数一遍。
第五条:自己机器上好好的,别人一用就废。
现象是打包出去的.CT或 exe 在别人机器上完全不起作用,或者一开就把对方程序搞崩。原因是表里残留了绝对地址、或者 AOB 特征码在对方版本上匹配到了多处,命中了错误的那一个。
解决办法是全面改用“模块 + 偏移”或者 AOB 定位,禁用一切裸地址;AOB 写完在 Memory Viewer 里手工搜一遍,确认只命中一处,命中多处就加长特征码,把前后的常量也纳进来。分发前在干净环境里冷启动测一遍,别在自己已经跑了几小时的进程上测。
5.3 一份五分钟排查顺序
出问题的时候不要东试一下西试一下,按固定顺序走,五分钟能定位到大致方向:
| 顺序 | 检查项 | 判断依据 |
|---|---|---|
| 1 | 是否附加到了正确进程 | 进程列表里目标的内存占用是否在变化 |
| 2 | 模块基址是否读到 | Lua 里getAddress(模块名)返回非 0 |
| 3 | 目标地址是否可写 | Memory Viewer 下方保护属性含w |
| 4 | 值是否稳定 | 连续两次读取结果一致 |
| 5 | AOB 是否唯一命中 | 手工搜索特征码,结果只有一条 |
| 6 | 覆盖长度是否匹配 | jmp5 字节 + 补齐,[DISABLE]字节数相等 |
这张表的价值在于它把“玄学”变成了可验证的步骤。绝大多数所谓“CE 不好使”的情况,卡在第 1 步和第 6 步。
6. 让修改器自证:三段式回归验证
写完脚本最危险的心态是“它能用了”。能用的定义不是“我这次点开有效”,而是“关掉目标、重开、再点开,还是一样的效果”。我后来养成一个固定动作,每次改完脚本跑三遍验证,加起来不到三分钟,能挡掉后面八成返工。
第一段是冷启动重算。完全退出目标进程,重新启动,进到实际使用场景(不是主菜单),加载表、启用脚本、看地址还能不能读到合理值。这一步专门抓指针链失效和依赖初始化顺序的问题。很多人只在已经跑热的进程上验证,那等于给自己吃后悔药——热进程里所有指针都被初始化过了,冷启动才是真实环境。
-- 冷启动后用这个函数确认指针链是否还有效 local function verifyPtr(modName, baseOff, levels, finalOff) local p = getAddress(modName) + baseOff for i, off in ipairs(levels) do p = readInteger(p) if p == nil or p == 0 then return false, string.format("第 %d 级为空", i) end p = p + off end return true, string.format("addr=0x%08X value=%d", p, readInteger(p + finalOff)) end print(verifyPtr("target.exe", 0x0012A4C0, {0x00, 0x10}, 0x2C))levels数组里放的是每一级解引用之后要加的偏移,最后一级加完之后再补一个finalOff才落到真正的字段上。这个划分方式和我读指针的习惯一致:解引用归解引用,结构体偏移归偏移,混在一起写迟早算错。
第二段是特征码唯一性。用 Memory Viewer 的搜索功能手工搜一遍你写进aobscan的那串字节,确认只命中一条。命中两条以上,就在特征码前后各加几个字节,优先选那些看起来像常量或者指令边界的字节,别选00 00 00 00这种到处都有的填充。这一步做完,脚本在别人机器上“匹配错位置”的概率会掉一个数量级。
第三段是边界值测试。把数值依次写成 0、1、负数、上限值,看程序反应。写 0 崩溃说明这个字段被当作除数或者索引用了,那就不能锁 0;写负数变成天文数字说明是无符号存储;写上限溢出说明真正的字段比你想的窄。这三类反应在单机老程序上非常典型,测一遍就知道自己锁的值域该收在哪里。
这三段跑顺之后,表才算能拿出来用。工具本身没有变聪明,变聪明的是验证流程——从那以后我每次改完脚本,都会强制走一遍冷启动、唯一性、边界值这三步,哪怕只是改了一个偏移。希望帮到你。
本文还有配套的精品资源,点击获取