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。有些版本的函数名和参数有差异,没有这个函数的话就得用别的方案,但思路是一样的。我自己做触发键时通常用组合键,比如 R+Start 或者 L+Start,它们不会被普通操作误触到,也不影响游戏主流程。
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 这两个变量。它们的作用是实现“按下时只执行一次”,而不是“按住时每帧执行”。如果没有这两个变量,你按住 L+Start 的每一帧都会存一次档,控制台直接刷屏,也可能把你好的内存状态覆盖掉。这个边缘触发和电平触发的区别,是新手写自动化脚本最容易翻车的地方。
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,建议从一个最小的脚本开始,先把那个你每天都会重复的动作自动化掉。那种“代码接替手指干活”的体验,只有试过才知道。记住,单机游戏里研究自动化很有意思,但它不该被带进线上对战环境。玩得开心,也要玩得干净。