
mGBA 这个模拟器GBA 老玩家应该都不陌生。我最近折腾它的 Lua 脚本功能越玩越觉得这套东西被严重低估了。很多人以为“模拟器加脚本”就等于金手指其实它的玩法远不止改个数值你可以让脚本帮忙盯着内存里的状态、自动按手柄、该存档就存档、该读档就读档甚至把血量、金钱这些信息实时画在游戏画面上。说白了这是给模拟器装了一个可以编程的“副驾驶”想让它怎么配合你完全取决于你写出来的规则。这篇文章从环境准备讲起覆盖核心 API、三个能直接跑的实战脚本、进阶的随机数锁定思路最后分享我实际踩过的一些坑。适合刷素材刷到怀疑人生的玩家、喜欢拆解老游戏机制的技术党也适合刚接触自动化脚本但还没找到合适切入点的朋友。1. 先搞清楚mGBA Lua 脚本到底能做什么1.1 它不是修改器而是一套自动化引擎拿开车来打比方。修改器和金手指相当于给车加氮气直接改变结果Lua 脚本则更像给车装上了“自动驾驶加行车记录仪”。它不会直接篡改所有数值而是在正确的时机做正确的事。mGBA Lua 脚本的能力基本可以分成四类读内存读取游戏运行时的内存数据拿到当前血量、金钱、坐标、对话标志等各种状态。写内存往指定地址写入数据实现类似修改器的效果但比金手指更可控、更精细。发按键向模拟器发送按键指令模拟玩家真实操作。管状态控制模拟器的运行节奏包括暂停、恢复、加速、存档、读档。这四类能力单独拿出来都不稀奇一旦组合起来就能做出非常多有意思的事。比如我平时最常用的三个小脚本自动对话、组合键自动 SL、屏幕 HUD 显示金钱和队伍状态。它们全都没有修改游戏规则只是用代码把我手动的重复劳动接管了。这种“自动化运营”的思路才是脚本真正值钱的地方。1.2 这套玩法适合什么人我的经验是下面几类人最容易从这里获得实际收益刷子型玩家自动连点确认、反复 SL 刷开局、刷掉落这些重复操作是脚本最擅长的场景。机制研究者用内存读取验证游戏机制把隐藏数据可视化比单纯玩更上头。工具开发者给模拟器做辅助层、调试 ROM、做自动回归测试脚本是效率倍增器。当然它也有明显的边界。脚本不是游戏里“一发入魂”的魔法你需要理解游戏逻辑、能定位内存地址、会写基本判断。对格斗游戏、音游这种帧精度要求极高的类型用脚本自动化多数时候不现实因为模拟器的输入时序本身就是按帧走的精细到单个输入帧难度太大。2. 环境准备把 Lua 控制台跑起来2.1 版本选择和下载渠道我实验时用的是 mGBA 0.10.x 这个系列Lua 功能已经非常成熟。强烈建议直接用官方 Release 包不要用别人魔改的整合包否则 Lua 模块有没有启用、API 版本是旧是新都会变成莫名其妙的坑。打开模拟器之后菜单栏的“工具”里一般能直接看到“Lua”子菜单。点进去可以打开脚本编辑器、新建脚本、运行脚本文件。第一次用的人大概率会找不到入口因为它藏得比较深不在最常见的“文件”菜单里。如果菜单里没有 Lua 这个选项多半是构建版本不带脚本支持换官方原版就能解决。2.2 第一个脚本让模拟器对你说话新建一个脚本文件敲下这两行emu:consoleClear() emu:consoleWrite(hello from lua)运行之后模拟器下方的 Lua 控制台会打印出这行信息。不同版本的语法可能有差异比如emu.consoleWrite和emu:consoleWrite的区别原理一样只是冒号和点号的区别不用慌。consoleClear 的作用是把控制台之前的内容清掉相当于每次运行脚本先清理现场。能跑通之后可以立刻试一个更接近“自动化”的写法。把下面这段脚本丢进去运行游戏里的 A 键会自己开始连发local frame 0 function onFrame() frame frame 1 if frame % 4 0 then input:setPressed(1, { A true }) else input:setPressed(1, {}) end end关于onFrame在多数 mGBA 构建中它会作为每帧回调被自动触发。如果你的构建不识别这个全局函数可以在脚本开头用回调注册的方式callbacks:addFrameCallback(function() frame frame 1 -- 同样逻辑 end)两种写法二选一即可。这段脚本的核心是“每帧覆盖”这个机制每一帧你都要告诉模拟器这一帧按了哪些键这一帧没按键就给空表。如果只写一次input:setPressed(1, { A true })而不在后续帧清空A 键就会被一直按住这不是你想要的效果。2.3 调试的基本习惯脚本没反应时consoleWrite 是你最好的朋友。打印一下当前帧数、打印读到的地址值、打印是否进入了某个分支基本就能快速定位问题。不要嫌日志烦调试阶段信息越多越好。改动代码之后不用重启模拟器重新运行脚本即可。我自己的习惯是每次脚本改动都顺手在控制台输出一行标记这样能立刻确认新代码有没有被加载。3. 核心 API内存、输入、模拟器三件套3.1 memory 模块直接读取游戏现场memory 模块最常用的几个函数大概是 readByte、readShort、readWord分别对应 1 字节、2 字节、4 字节读取写入则是 writeByte、writeShort、writeWord。不同版本的函数名可能改过但思路完全一致。宽度为什么重要GBA 是 ARM 小端架构同样的数值在内存里存成多个字节时是低位在前。比如一个 16 位数值 0x1234在内存里实际是0x34 0x12。你用 readByte 去读这个地址得到的是 0x34而不是 0x12。游戏里的人物血量、金钱这类变量大多占 2 字节或 4 字节处理之前最好先确认宽度再选函数。举一个演示场景。假设某款游戏的金钱地址是 0x0201AB00这是演示地址真实情况必须用搜索确认读取的逻辑这样写local money memory:readWord(0x0201AB00) emu:consoleWrite(money .. tostring(money))地址从哪来最通用的办法是用 mGBA 自带的 Cheat Search 功能去搜这个操作我在下一章专门写一段。这里先记住一条核心原则内存地址不是靠猜的是在模拟器里搜出来的。3.2 input 模块“按下”和“按住”是两回事按键集合通常是 A、B、Select、Start、Left、Right、Up、Down、L、R 这 10 个。发送按键用的input:setPressed表达的是“这一帧的按键状态”而不是“触发一次按下事件”。这两者的区别非常关键。游戏里的按键触发检测的往往是下降沿也就是从没按到按下的那一瞬间。如果你在一帧设置 A 按下下一帧立刻给空表游戏就收到了一次短按。如果连续 10 帧设置 A 按下那就是一次 10 帧的长按。所以想在某一个时刻“点一下”不要忘了在下一帧松开。上面那个连发脚本已经演示了这个机制。如果你需要读用户当前物理手柄的输入可以考虑input:getGamepad(0)它返回一个包含按键状态的 table。有些版本的函数名和参数有差异没有这个函数的话就得用别的方案但思路是一样的。我自己做触发键时通常用组合键比如 RStart 或者 LStart它们不会被普通操作误触到也不影响游戏主流程。3.3 emu 模块把模拟器捏在手里emu 模块管的都是大事存档、读档、暂停、恢复、调速。自动 SL 是刷游戏最核心的需求之一代码极简emu:saveState(0) -- 存到 0 号槽 emu:loadState(0) -- 从 0 号槽读档这里要提醒一句saveState 和 loadState 是瞬间完成的不是“慢慢读盘”。如果在每帧回调里无条件调用 loadState模拟器会永远停在读档后的那一帧画面直接不动。实际用的时候必须加条件判断比如某个组合键触发或者检测到特定内存值再触发。调速功能很实用。跑重复流程时emu:setSpeed(0)通常表示不限制速度具体参数看你当前版本跑完再恢复到正常速度。这个配合自动 SL能省非常多的时间。3.4 display 模块往画面上叠加 HUD游戏画面里看不到的内部数据可以用 display 模块绘制到屏幕上。常用的是 showText 输出文字以及 drawRect、fillRect 绘制矩形覆盖层。来看一个 HUD 血条示例框架function onFrame() local hp memory:readShort(0x0201DE00) local maxHp memory:readShort(0x0201DE02) display:fillRect(0, 0, 120, 8, 0xFF000000) if maxHp 0 then local ratio hp / maxHp display:fillRect(1, 1, math.floor(ratio * 118), 6, 0xFF00FF00) end end坐标原点在屏幕左上角x、y 从 0 开始。颜色参数在不同构建里习惯不一致有的接收整数 ARGB有的接收字符串如#FF00FF00写之前扫一眼当前版本 API 说明就行。这套 HUD 做出来之后你会立刻感受到“能看见游戏内部状态”的快感也更容易判断脚本到底在忙什么。4. 实战三个可以直接抄的脚本4.1 自动连点跳过对话、刷确认按钮最前面给的那个连发脚本可以直接拿来用。这里说说参数为什么选 4 帧而不是每帧都按。很多老游戏有防抖窗口每帧都发按键反而会被判定为“过长按住”或者“无效密集输入”实际效果并不好。我实测下来不同游戏最优间隔不一样多数日式 RPG 的对话框用 4 帧比较稳某些游戏要调成 6 帧、8 帧更合适。建议先跑 4 帧观察对话跳跃速度再微调。如果你想要一个更不容易漏按键的版本可以把按下帧的持续时间从 1 帧加长到 2 帧代码上就是连续两帧设置{ A true }再空两帧。这种微调在实战中很常见。4.2 组合键自动 SL刷开局、刷掉落多段代码组合起来就成一个完整的组合键自动 SL 脚本local prevL, prevR false, false function onFrame() local pad input:getGamepad(0) if not pad then return end if pad.L and pad.Start and not prevL then emu:saveState(0) emu:consoleWrite(saved slot0) end if pad.R and pad.Start and not prevR then emu:loadState(0) emu:consoleWrite(loaded slot0) end prevL pad.L and pad.Start prevR pad.R and pad.Start end这里要注意 prevL 和 prevR 这两个变量。它们的作用是实现“按下时只执行一次”而不是“按住时每帧执行”。如果没有这两个变量你按住 LStart 的每一帧都会存一次档控制台直接刷屏也可能把你好的内存状态覆盖掉。这个边缘触发和电平触发的区别是新手写自动化脚本最容易翻车的地方。4.3 用 Cheat Search 找到你需要的地址很多新手对内存搜索望而却步其实 mGBA 已经把这件事做成了傻瓜式操作。假设我要找金钱地址第一步打开工具菜单里的“作弊搜索”/Cheat Search。在游戏里记住当前金钱数比如 5000选择 32 位宽度输入精确值点搜索。 第二步进游戏让金钱变化花掉一点或者赚回一点再打开 Cheat Search输入新的金额继续搜索。 第三步重复上一步地址列表会快速缩小直到只剩一个或几个地址。 第四步把地址记到脚本里读取这个地址就能实时拿到金钱。这里有个我常用的提速技巧搜索时可以用“未变/变化”这种模糊搜索比每次都输入精确值快很多。比如 5000 花了 50直接选“值已变化”搜出来之后再选一次“值已变化”很快就能收窄到唯一结果。4.4 半成品框架第一个完整的自动化循环把前面这些能力组装成一个自动刷钱的循环逻辑非常直白local moneyAddr 0x0201AB00 local target 10000 function onFrame() local money memory:readWord(moneyAddr) if money target then emu:loadState(0) emu:consoleWrite(reset due to low money: .. money) return end -- 这里放你的自动操作比如自动对话、自动按键 end这个循环体现了脚本自动化的普遍套路读状态不满足条件就重置满足就继续推进。不要小看这段逻辑它能解决大量“结果不如意就重来”的刷子需求也是后续写更复杂状态机的基础。等你想清楚“我到底要循环做什么”整个脚本的骨架就已经立住了。5. 进阶随机数锁定与帧级控制5.1 老游戏的随机数是可以被“捏住”的GBA 游戏里的随机数不是真正的随机而是伪随机。每次生成事件之前程序都会从一个种子开始按算法推进这个种子通常保存在某块固定内存地址。脚本只要在恰当的时机把这块内存写回特定值就等于让随机数发生器从一个确定点重新走了一遍。这就是很多“定点刷结果”思路的底层原理。具体到某个游戏种子地址和推进方式都要自己定位方法还是 Cheat Search。搜索策略是比较某个事件触发前和触发后哪些内存值发生了变化反复几次就能锁定候选地址。找到之后脚本核心逻辑非常短local rngAddr 0x03000000 -- 示例地址务必用搜索确认 local lockedSeed 0x12345678 function onFrame() if locked then memory:writeWord(rngAddr, lockedSeed) end end注意一点这个玩法本质上是干预游戏的初始条件但所有运行结果仍然来自游戏逻辑本身。想把这个玩明白需要的是对游戏机制的耐心拆解而不是脚本 API 的熟练度。5.2 锁种子和刷种子的区别锁定种子适合“我要固定结果”的场景。还有一种玩法叫“刷种子”每次生成结果前重新写一个不同的种子然后看结果满不满意不满意就再来。这个相当于把你反复重试的动作也自动化了。我的建议是先玩熟“锁种子”。把种子固定住每次触发事件结果大概率落在同一个组合里。想换结果就手动改一下种子再试。把种子脚本和自动 SL 配合起来效率提升非常明显。但是这里有个容易被忽略的点种子地址在每次读档后可能会被游戏重新初始化所以锁种子脚本最好在读档之后有个重新写入的动作或者在 onFrame 里保持高频写入。5.3 状态机把多步操作编排成流程脚本写到后面总会遇到“先做 A等出现 B再做 C”的流程。如果全堆在 onFrame 里硬写代码会慢慢变成一团乱麻。这时候可以引入最轻量的状态机local step 1 function onFrame() if step 1 then input:setPressed(1, { A true }) step 2 elseif step 2 then input:setPressed(1, {}) if checkSomeFlag() then step 3 end elseif step 3 then -- 做第三步 end end状态机不是什么高深概念它只是把“当前正在做什么”存到一个变量里每帧按当前状态执行对应逻辑。真正容易被忽略的是“状态转移的条件”一定要基于内存值或者帧数来写不要靠延时等待。脚本是跑在模拟器事件循环里的一旦你用阻塞式的方式等时间整个模拟器都会被卡住画面一动不动。6. 踩坑记录与常见问题排查6.1 最常遇到的五个问题现象可能原因解决方式脚本没反应也没报错onFrame 没被识别改用 callbacks:addFrameCallback 注册报错说函数不存在版本 API 差异对照当前版本的 API 文档调整写法按键按下去弹不回来setPressed 是每帧状态没有按键的帧也要给空表读内存读出的值很怪地址不对或者宽度不对用 Cheat Search 定位确认字节宽度读档后脚本变量错乱loadState 不会自动重置 Lua 变量在读档后重新初始化相关变量6.2 三条避坑建议第一玩任何带存档的游戏之前先备份原始存档。脚本一旦误写内存、误存状态可能把几百小时的进度直接搞没。手动复制一份存档文件是最简单也最有效的保护手段。第二脚本不要一上来就写大而全。直接写“自动刷指定随机结果”的超级脚本大概率会因为你地址定位错误而放弃。不如从自动按 A 开始跑通一个动作再加一个判断再组合起来每加一层都验证一轮。这个节奏看着慢实际上最稳。第三学会把 consoleWrite 当眼睛。我调脚本时几乎每段关键逻辑都会打印日志跑通了再把日志删掉。别让它每帧疯狂刷屏拖慢模拟器但调试阶段一定不要省这一步。6.3 一些比脚本本身更有意思的方向这么折腾下来你会发现脚本能力可以被无限扩展。比如把多个独立脚本合成一个“工具箱”在里面加菜单选择用事件回调监听游戏内部更细的状态变化通过 Lua 文件接口读写配置文件为不同游戏保存各自的脚本配置甚至可以写一个自动测试脚本让模拟器反复跑同一段流程。mGBA 的 Lua 支持本质上把游戏内逻辑和外部逻辑打通了这个深度远不止“刷素材”这么简单。最后说一个我自己的习惯。我的脚本文件夹里永远保留着三个最朴素的小工具自动连点、组合键 SL、HUD 显示。每一行都很简单甚至算不上什么高级功能但在模拟器里跑起来的时候我会真切感觉到这个游戏的运行节奏可以由我来设计。如果你也在折腾 mGBA Lua建议从一个最小的脚本开始先把那个你每天都会重复的动作自动化掉。那种“代码接替手指干活”的体验只有试过才知道。记住单机游戏里研究自动化很有意思但它不该被带进线上对战环境。玩得开心也要玩得干净。