重载、轮询与 autorun
F9 模块重载、所有重复观察共用的那一次心跳,以及不用按键就能跑具名动作的文件
读完本页你可以做到
- 改一个 Lua 文件,大约一秒后就在运行中的游戏里看到效果
- 知道哪些改动 F9 拿不到,从而不再反复按键而是直接重启
- 读懂 F9 拒绝时给出的理由,并在它自己解不开时手动清掉
- 注册重复执行的工作,而不必向引擎再要一个定时器
- 在每次载入世界时跑一串具名动作,既不用按键也不用控制台
dev 闸门
这里写的一切,只有在 env.dev 为 true 时才会加载,而它默认是 false。开发会话通过一个可选文件
打开它:tools/deploy.sh 在默认模式下把该文件写进部署目录,加 --release 时删除它。这个文件被
gitignore,所以不可能顺着仓库传给别人。
local env = require("palforge.env")
env.dev = true -- dev 按键(含 F4:解锁全部科技)、F1、F9、各个探针
env.debug = true -- 另外加载 test/hooks 下那二十四个需要游戏在跑的测试钩子env.dev 关闭时,启动日志会明确这么说,并点名所有没有加载的东西。这一点很重要:从外面看,一个从
未被绑定的按键和一个绑定了却收不到的按键完全一样。
在闸门之下会绑定九个键:F1(测试套件)、F2 / F3 / F5 / F6 / F8 / F10(六个探查探针)、F4(无确认地 解锁当前存档的全部科技),以及 F9(本页主题)。
F9 会替换掉什么
F9 把所有 palforge.* 模块从 package.loaded 里丢掉,然后重新跑一遍内核。对 api 模块、内容包、
测试用例或探针的修改,大约一秒后就生效。
[PalForge.reload][info] reloading N module(s)
[PalForge.reload][info] reloaded N module(s) - engine hooks kept from the first load
[PalForge.reload][warn] reload does NOT re-arm native hooks: a change inside an event source
needs a game RESTART to take effect. Everything else is live now.N 是你按下按键那一刻已加载的 palforge.* 模块数量,取决于这次会话碰过些什么。
有四个模块会被有意保留:palforge.env(它拿着重载不得翻动的 dev 开关)、palforge.utils.log
(它负责报告这次重载)、palforge.core.reload(它就是当前正在运行的模块),以及
palforge.core.object_manager——保存着全部已注册定义的注册表。保留最后这一个,正是包里的内容能
熬过 F9 的原因:内容包是拥有自己 require 命名空间的另一个 UE4SS mod,不会被重新 require,它的
定义调用也不会再跑一遍。
重载还会清掉那些裸全局(Pal、Item、Building、Skill、Effect、Audio、Mesh、UI、
Player),免得某个已从 api 消失的模块留下一个过期全局,然后调用 registry.initialize()。失败时
旧模块已卸载、新模块只加载了一半,它会大声说出来:改好文件,再按一次。
什么能熬过重载,什么重载撤不掉
UE4SS 没有办法收回下面三样东西,所以一次朴素的重载每按一次就会叠一份:
| 调用 | 若重新挂一次 |
|---|---|
RegisterHook | 每个处理器会跑两遍,再按就三遍 |
LoopAsync | 第二条心跳会让每个 tick 翻倍 |
RegisterKeyBind | 引擎保留它已有的绑定 |
因此面向引擎的那一层每个会话只挂一次,用 _G.__PalForgeArmed 记下来,重载不去碰它。按键注册表会
原地替换已绑定按键的函数,而不是再绑一次——这正是按键能跨越重载继续工作、同时指向新代码的原因。
出于同样的理由,有四份状态放在 _G 上;换成一张新的空表,会让重载前的那一半对着空气说话:
_G.__PalForgeBus -- 原生钩子已经在往里推的事件总线
_G.__PalForgeBuildingRegistry -- 活着的建筑实例,以及在它们身上触发的钩子
_G.__PalForgeSpatialIndex -- 每个实例的 `_bucket` 所指向的邻居桶
_G.__PalForgePollers -- 那唯一一条心跳负责排空的重复工作重载不会重新挂原生钩子。改动事件源的函数体——也就是 core/event 自己的钩子回调——不会改变已挂钩子
的行为,因为 RegisterHook 无法解除,钩子会一直跑它被创建时的那个闭包。这一类改动需要重启游戏。
处理器、定义、分发以及其他普通模块都能正常重载。
还有两件事重载撤不掉。活着的建筑实例保留它创建时拿到的处理器表,所以按键之前放下的建筑会继续跑旧的
onTick,直到被重新发现为止。轮询器也会继续跑它注册时的那个闭包——这一点会被报告而不是清除,因为
轮询器是有人要的一次观察,悄悄丢掉就等于丢掉它要给出的答案。
F9 什么时候拒绝,以及怎么清掉
在还有重复回调未完成时重载,可能让 UE4SS 手里的 Lua 注册表引用解析不出函数,而它的反应不是跳过:
[UE4SS.EngineTick.LuaModImpl] Hook threw exception:
"[Lua::Registry::get_function_ref] Ref was not function", removing hook!它会摘掉引擎 tick 钩子。本 mod 的每个按键都在 ExecuteInGameThread 里执行自己的函数体,而那条
队列正是由 tick 排空的——于是游戏一切照常,按键却死了。这看起来完全不像按键的问题,代价是一次重启。
所以任何要排一个重复回调的东西都会先申报自己,只要还有未完成的,F9 就拒绝,并点名每一项以及它已经 等了多久:
[PalForge.reload][warn] reload REFUSED: 1 async chain(s) still outstanding: ui input dead-man
(armed 41 s ago). Wait for them to print and press the key again. ...
[PalForge.reload][warn] no key clears this - asyncReset is bound to nothing. If it never clears
by itself (it self-expires after 180 s), the Lua console line is:
require('palforge.core.reload').asyncReset()会申报的有两类:test/probes/watch.lua 的两条原始链(12 秒的放置回读和 60 秒的窗口汇总),以及
每一个通过 core/poll 注册的轮询器。大多数轮询器只有几秒,但有一个不是——UI 输入守死器会一直活到
PalForge 面板放开玩家输入为止,所以开着面板按 F9 会被拒绝,并被点名。
出口有三个,这正是这笔交易可以接受的原因:工作自己跑完、申报在 180 秒后自动过期,或者把
asyncReset() 粘进 Lua 控制台。它没有按键,也不可能有:键盘层调用的是
RegisterKeyBind(code, callback) 这个不带修饰键数组的两参数形式,所以组合键在结构上就到不了,而不
只是没绑。
core/poll:所有观察共用一条心跳
本仓库里没有任何一次观察会自己建定时器。core/event 为整个会话只挂一条 LoopAsync,周期 500 ms,
并且永不停止;所有需要反复看世界的东西,都注册一个函数交给这次 tick 去调用。
函数体之所以要经 ExecuteInGameThread 排队,是因为 UE4SS 要求凡是碰活 UObject 的事都在游戏线程上做。
local poll = require("palforge.core.poll")
poll.every("spawn arrival", function(elapsed, ticks)
if found() then return true end -- true 表示完成:把我摘掉
return elapsed >= 12 -- 放弃要看时钟,绝不看 tick 计数
end)判断依据是 elapsed 而不是 ticks。因为函数体是排队执行的,游戏线程一忙它们就堆积,然后一口气排
空:ticks 的推进速度取决于队列排空多快,而不是时间过了多久。一次实机运行里,二十个 tick 的预算在
一秒内就用光,并报告说一只还没来得及到场的帕鲁不存在。
当已经有 16 个轮询器在跑时,poll.every 返回 false 并写日志。抛异常的轮询器会被丢弃并报告,而不是
留着每个 tick 抛一次直到日志被刷爆。两条丢弃路径都会释放该轮询器对重载守卫的申报,poll.clear()
也一样。
注册同时也意味着以该轮询器的名义占住守卫,这是有意为之而非副作用:一个永远不返回 true 的轮询器会让 F9 一直拒绝,而拒绝信息会说出是哪一个。
core/autorun:从文件里读名字
Scripts/palforge/autorun.txt 不用按键也不用控制台就能跑具名动作。它在 world.ready 时读取,每个
世界读一次——那是世界已存在、玩家 Pawn 已存在、而键盘还没被要求做任何事的唯一时刻。
# 注释行和空行会被忽略
pf_native # 世界就绪后立刻执行
12 pf_teach # 世界就绪 12 秒后执行延迟同样搭那条心跳,这个文件不建自己的定时器。一行是 [delay] name,不携带参数,这也是钩子运行器
为每个钩子生成一个动作名、而不是让这个解析器学会传词的原因。
文件里装的是一串名字,不是代码。core/autorun.lua 不写任何 test/ 底下的名字:test/init.lua
的 install() 用 autorun.setActions(M.ACTIONS) 把表交给它,每一行只在这张交来的表里查一次,查不到
的会被报告并跳过:
[PalForge.autorun][warn] autorun.txt: no action named "pf_typo" - the names are the pf_* commands于是测试面里任何地方注册的命令,从它存在的那一刻起就能从这里跑,不需要改 core/autorun.lua;而在
--release 部署出来的那种没有测试树的副本里,这张表根本不存在,队列就是一个零成本的空操作。一个
乱入的文件也不可能执行这个 mod 按键做不到的事。会写入存档的钩子在此之上还有自己的第二道闸门
(env.debugHooks[id]),这条路径既不知道它,也绕不过它。
文件的位置来自模块自己在磁盘上的位置(debug.getinfo(1, "S").source),而不是工作目录——UE4SS 的
工作目录不是可以依赖的东西。文件不存在是正常情况,它什么也不说。
小结
env.dev默认为 false,打开它的是Scripts/palforge_dev.lua。- F9 替换所有
palforge.*模块并保留四个,其中包括注册表,所以你的内容能活下来。 - 原生钩子、那唯一一条
LoopAsync和已绑定按键每个会话只挂一次;事件源内部的改动需要重启。 - 只要还有重复工作未完成,F9 就拒绝并点名,180 秒后自动过期,也可以从控制台清掉。
- 重复工作用
poll.every(name, fn)写:按经过秒数判断,返回true退场,同时最多 16 个。 autorun.txt每行一个[delay] name,对照test/init.lua交给core/autorun的ACTIONS解析,每次载入世界跑一遍。