快速上手
安装 PalForge,确认它已经加载,并让第一份自己的内容在游戏里跑起来
PalForge 让你把自己做的建筑物、道具、帕鲁、技能、声音、效果和菜单加进 Palworld。你在一个简短的 Lua 文件里写下想要的东西,它就会出现在你的游戏里。
本页会把它装好、确认它在运行,并让你的第一个定义对游戏里的操作作出反应。
读完本页你可以做到
- 在自己的 Palworld 里跑起 PalForge
- 只看游戏自己的日志,就知道它有没有启动
- 加入自己的道具,捡起它时输出一条消息
- 加入一个会数自己被打开过几次的箱子,下次再玩时次数还在
- 自己查出某个东西为什么没出现,不用问别人
安装
复制文件
PalForge 跑在 UE4SS 上,UE4SS 是让 Palworld 能运行 Lua 模组的加载器。如果你已经有别的 Palworld Lua 模组能用,那你就已经装了它。
在 ue4ss/Mods/ 下建一个叫 PalForge 的文件夹,按下面的样子放文件。palforge/ 必须和
main.lua 放在同一层:main.lua 会在自己所在的文件夹里找 PalForge 的其余部分,布局不一样就会
让每一个部分都失效。
ue4ss/
└── Mods/
└── PalForge/
├── enabled.txt <- 空文件;只有它存在时 UE4SS 才会启动这个模组
└── Scripts/
├── main.lua
└── palforge/
├── env.lua <- dev/release 开关,以及声明的游戏版本号
├── types.lua <- 只有编辑器注解;运行时不会被加载
├── autorun.txt <- 不需要按键的 dev 队列(core/autorun.lua 在这里读它)
├── api/ <- 内容包要面对的公开接口
├── core/ <- 引擎本体:内核、事件系统、Palworld 桥接
├── native/ <- 把 Palworld 自己的内容做成的数据目录
├── test/ <- 整棵 dev 测试树(--release 的副本里一个都没有)
│ ├── units/ <- 无界面套件,启动时就跑
│ ├── cases/ <- 游戏内 API 测试套件,挂在 F1 上
│ ├── hooks/ <- 游戏不跑就取不到的测量
│ ├── probes/ <- 调查用的转储;不判定通过或失败
│ └── tools/ <- dev 工具(ps_catalog 背后的那个导出器)
└── utils/ <- log, json, file, items这和 tools/deploy.sh 复制的清单是同一份:Scripts/main.lua 加上整个 Scripts/palforge/,减去
deprecated/ 和 tmp/ —— 这两个只作参考,运行时用不到。在这之上,--release 还会从暂存的副本里
把 palforge/test/ 连同它的 58 个文件一起删掉:玩家手上的树里没有任何测试目录。
在 dev 副本里有两项很容易漏掉,而且漏掉之后都会表现成别的毛病:
palforge/test/。core/registry.lua会 requirepalforge.test并调用它的install()。 就这一次调用绑定了 F1、跑了无界面测试包、给每个探针分了一个键、注册了包括ps_catalog在内 的全部pf_*控制台命令,并把动作表交给core/autorun。少了这个目录的 dev 安装一样都没有,内核 会在 “dev tooling NOT loaded” 那一行里把整棵树点名。以前它旁边还有第二个目录 ——palforge/tests/,复数,只差一个字母 —— 装着无界面测试包和ps_catalog的导出器,而产品代码 两边都伸手去够。现在只有一棵树:测试包是test/units/,导出器是test/tools/catalog.lua。palforge/autorun.txt。core/autorun.lua从palforge/旁边读它,并在世界加载时运行其中 具名的动作。在按键和控制台都失效的机器上,它是唯一能跑起东西的路子 —— 这个项目已经接连有三条 输入通路失效过。
Scripts/palforge/types.lua 只有类型注解,游戏运行时不会有任何地方加载它。它的作用是让你输入时
编辑器能补全定义的每一个字段。参见编辑器设置。
打开这个模组
UE4SS 打开一个 Lua 模组有两种方式:在模组文件夹里放一个空的 enabled.txt 文件,或者在
ue4ss/Mods/mods.txt 里加一行。
CheatManagerEnablerMod : 1
PalForge : 1CheatManagerEnablerMod 的作用,是把游戏自己的管理对象 UPalCheatManager 交给 PalForge,把帕鲁
放进世界要走这个对象。但它是便利,不是硬性要求:core/spawn.lua 的 cheatManager() 先找一个活着
的,再看控制器自己的 CheatManager,两者都没有时就用
StaticConstructObject(pc.CheatClass, pc) 自己造一个。它造不出来的唯一情况,是根本没有玩家控制器
可以挂,而下面这行报告的正是这种情况:
[PalForge.spawn][warn] spawn.pal: no PalCheatManager and none could be constructed (no player controller yet?)启动游戏并读日志
UE4SS 会把模组说的话打印到它的控制台窗口,并把同样的文本写进一个叫 UE4SS.log 的文件。PalForge
的每条消息都经过 utils/log,格式是 [PalForge.<scope>][<level>] <msg>。游戏加载时盯着它看,
具体要找哪几行在接下来的两节里。
游戏启动时发生了什么
main.lua 是 UE4SS 唯一运行的文件。它按顺序做这些事:
- 把 Lua 的
package.path指向自己所在的文件夹 (thisDir .. "?.lua;" .. thisDir .. "?\\init.lua;"),这样require("palforge.api")就能找到 放在旁边的模块。 - require 可选模块
palforge_dev(Scripts/palforge_dev.lua),并记下发生的是三件事中的哪一 件:loaded、absent (release copy)还是FAILED TO LOAD。这一个文件就是整个 dev 开关;第三 种情况会另外发一条 warn,因为“dev 覆盖文件有语法错误”和“框架忽略了这个标志”从外面看一模一样。 - 通过
UKismetSystemLibrary的 CDO 向正在运行的游戏要它的版本字符串,把答案存进env.gameBuildLive。那一刻问不出结果是正常的 —— Lua 模组启动得很早 —— 所以这次读取会在第一次world.ready时重试一次。 - 打印启动横幅:版本号、声明的和实时读到的游戏版本、
dev与debug,以及 dev 覆盖文件在不在。 紧接着是完整的单人游戏声明。 - 在
pcall里调用registry.initialize(),失败就打印initialize failed: <err>,成功就打印ready。 - 发布
_G.PalForge,让别的模组能用同一份正在运行的副本。
local thisDir = debug.getinfo(1, "S").source:match("@?(.*[\\/])") or ""
package.path = thisDir .. "?.lua;" .. thisDir .. "?\\init.lua;" .. package.path
local env = require("palforge.env")
local registry = require("palforge.core.registry")
local log = require("palforge.utils.log").scope("main")
log.info(string.format(
"PalForge v%s starting | game build: declared %s, live %s | dev=%s debug=%s | dev overlay: %s",
tostring(env.version),
tostring(env.gameBuild),
liveBuild and (liveBuild .. " (" .. buildSource .. ")") or ("unknown (" .. buildSource .. ")"),
tostring(env.dev), tostring(env.debug), devOverlay))一切启动起来的地方是 registry.initialize()。它跑五个步骤:
内容目录的加载顺序是:buildings、items、pals、skills、effects、audio、ui。require
其中一个,会运行它里面手写的定义调用,而定义调用在运行时会把自己写进 core/object_manager。那些
文件里长长的 id 列表始终只是普通数据:惰性的 get(id) 用 { register = false } 造出句柄就到此为
止,所以启动时不会注册,单次查询也不会注册。真正注册的是需要显式调用的
<catalog>.publish(id)。native/buildings 什么都不注册:它手写的 WorkBench 和 PalBoxV2
同样带着 { register = false },因为注册一个建筑物不是无害的 —— core/event 的扫描会随即开始
追踪并持久化你世界里已经立着的每一个同类建筑。
_G.PalForge 带着别的模组可能想要的一切:
_G.PalForge = {
env = env,
api = api,
pack = api.pack, -- PalForge.pack("mypack").Item{ ... } 会记下所属内容包
utils = { log = ..., json = ..., file = ..., items = ... },
core = {
registry = ..., event = ..., object_manager = ..., spawn = ..., mesh = ...,
sound = ..., player = ..., spatial = ..., icons = ...,
uobject = ..., assetpath = ...,
},
native = require("palforge.native"),
}正常的启动日志
所有 dev 工具都默认关闭,所以玩家看到的是较短的那份日志。一次干净的发布版启动是这样的:
[PalForge.main][info] PalForge v0.3.0 starting | game build: declared v1.0.2.101103, live unknown (UKismetSystemLibrary CDO did not resolve) | dev=false debug=false | dev overlay: absent (release copy)
[PalForge.main][info] PalForge targets SINGLE-PLAYER Palworld. Dedicated servers and co-op guests are not supported and are not tested: there is no replication layer, and the item, spawn and event routes are all client-authoritative. A pack may appear to work for the host and do nothing for anyone else.
[PalForge.event][info] tick source live
[PalForge.event][info] event wired: bus + sources (tick/world/building/pal/item/skill; onBuild, the three onSpawned candidates and the three passive ones arm at world.ready; skill.activate has three sources and skill.hit two, and the log says which one carried it; building.leftclick and building.break have no native source and never will) + dispatch
[PalForge.registry][info] dev tooling NOT loaded (env.dev = false, the shipped default): no dev keybinds — including F4, unlock all technologies — no F1 API suite, no F9 reload, no ps_catalog dumper, no headless unit bundle and no test hooks. ...
[PalForge.registry][info] initialized (dev=false, debug=false, 17 class(es) registered)
[PalForge.main][info] readylive 是正在运行的游戏被问到版本字符串时给出的回答。UE4SS 启动 Lua 模组很早,所以那一刻是
unknown 属于正常而不是失败 —— 这次读取会在第一次世界加载时重试一次,并把答案记在那里。PalForge
宁可写 unknown 也不猜。声明的版本和实时读到的版本对不上时,它会点名两者,warn 一次。
class 的数量就是注册了多少个定义。17 是只有随包目录时的数字(audio 6、item 3、effect 3、pal 2、
ui 2、skill 1、building 0),你自己的定义会加在上面。表示 PalForge 已经启动的那一行是
[PalForge.main][info] ready。
dev 部署会多出工具相关的行。每次绑定都会在同一行里报告,游戏自己的按键配置在那个键上放了什么:
[PalForge.main][info] PalForge v0.3.0 starting | game build: ... | dev=true debug=true | dev overlay: loaded
[PalForge.keyboard][info] bound F4 [keymap: unknown — the game's key config has not been read yet (the config source needs a loaded world), so nothing is claimed either way; run pf_keys inside a save]
[PalForge.keyboard][info] keybinds loaded (1 function file(s): F4)
[PalForge.keyboard][info] bound F9 [keymap: ...]
[PalForge.hooks][info] 25 game-required hook(s) declared; `pf_hooks` prints each one's gate state and what would open it
[PalForge.unittests][info] tests: 8 passed, 0 failed, 0 skipped (8 total)
[PalForge.keyboard][info] bound F1 [keymap: ...]
[PalForge.test][info] dev console command registered: ps_catalog (DataTable dumper, opt-in)
[PalForge.test][info] console commands: pf_hook pf_hook_audio_custom_file_loader ... pf_uiz pf_watch
[PalForge.test][info] keyboard: F1 unknown tests: all suites game: the game's key config has not been read
[PalForge.registry][info] dev tooling loaded: dev keybinds incl. F4 unlock-all-technologies, F9 reload, headless unit bundle (test/units), in-game API suite on F1 + probe keys, ps_catalog console command (test/tools), autorun action table, game-required test hooks (palforge.test.hooks)
[PalForge.registry][info] initialized (dev=true, debug=true, 17 class(es) registered)
[PalForge.main][info] readytests: 8 passed ... 来自无界面的单元测试包 palforge/test/units/ —— 两个纯 Lua 套件、八项检查,
由 test/init.lua 的 install() 在启动时、在碰到游戏之前跑完。它下面那行 dev tooling loaded:
就是同一个 install() 把每一块经内核报告回来的结果:core/registry.lua 只知道 palforge.test
这一个名字,也只调用一次。要是哪份 dev 安装把这个目录漏掉了,内核会把整棵树点名写进
dev tooling NOT loaded 那一行;而 --release 的副本本来就不带它,所以那里的 absent 是正确的
状态,不是装坏了。这一行两种模式下都会打印,逐项列出没有加载的东西和原因:一个从未被绑定的键,
和一个绑定了却从来收不到按键的键,从外面看是一样的,能区分它们的只有这一行。
另外两行属于世界闸门。PalForge 会等存档加载完成,才去碰世界里的东西;第一行在存档准备好时出现, 第二行在你离开那个世界时出现:
[PalForge.event][info] world ready - building dispatch enabled
[PalForge.event][info] world left - building dispatch pausedworld 源每 1000 毫秒检查一次 FindFirstOf("PalPlayerCharacter"),要连续五次检查有效才会发出
world.ready。在那之前,building、pal 和 item 这几个源都会直接返回,所以你还在标题界面时,你写的
东西都不会触发。tick 心跳是唯一从模组加载起就在跑的源。如果那个检查循环根本装不上,日志会写
ready-watch unavailable (...) - dispatch always on,然后所有东西照常运行。
dev 开关
Scripts/palforge/env.lua 放着 PalForge 运行时读的设置。require() 会缓存它,所以每个模块看到的
是同一张表 —— 而且里面每个开关出厂时都是关的:
return {
dev = false, -- THE dev/release switch (dev tools load only when true)
debug = false, -- game-required test hooks (test/hooks); needs dev too
debugHooks = {}, -- per-hook opt-in for the hooks that WRITE
name = "PalForge",
version = "0.3.0",
gameBuild = "v1.0.2.101103",
gameBuildLive = nil,
multiplayer = false,
}框架里没有任何东西会把 dev 打开。打开它的只有一个可选文件:Scripts/palforge_dev.lua。
main.lua 会在 registry.initialize() 之前立刻 require 它,文件不在就直接忽略。
local env = require("palforge.env")
env.dev = true
env.debug = true
-- env.debugHooks["pal-skills-equip"] = true -- 会写入存档的钩子,需要逐个开启这个文件被 gitignore 了,所以它没法通过仓库到玩家手里;tools/deploy.sh 会替你写出它。脚本会整个
替换部署目录下的 Scripts/(先放到暂存目录,再用两次改名换过去,游戏永远看不到一棵只填了一半的
目录树),给这份副本打上构建时间戳,并且从不改 env.lua:
tools/deploy.sh # 向默认安装目录做 dev 部署;写出覆盖文件
tools/deploy.sh "/path/to/Palworld" # 部署到别的地方
tools/deploy.sh --writes # dev,并把九个写入同意项全部打开。只在可以丢掉的存档上用
tools/deploy.sh --release # 不写覆盖文件,并按名字删掉任何残留的旧副本它会打印自己写了多少个文件,两种模式的差别就是测试树加上那一个覆盖文件:dev 是 130 个,
--release 是 71 个。
所以整个循环是:改文件 → tools/deploy.sh → 在游戏里按 F9(全新部署后的第一次要重启)→ 按
F1。Lua 是在模组加载时读的,所以部署过去的文件在重新加载之前不会改变正在运行的游戏;构建时间戳
的作用,是让一次过时的运行在日志里一眼可见,而不是花一小时去查。
dev 会武装什么:九个键
registry.initialize() 会加载 core/keyboard/ 下的按键绑定文件和 F9 重新加载,然后 require
palforge.test 并调用它的 install() —— 一个名字,一次调用。这次调用跑了无界面单元测试包、绑定
F1、给每个探针各分一个键、注册 ps_catalog DataTable 导出工具和其余所有 pf_* 控制台命令,并把
动作表交给 core/autorun。一共九个键:
| 键 | 做什么 | 屏幕上需要什么 |
|---|---|---|
| F1 | 运行游戏内 API 测试套件:19 个套件,612 项检查 | 哪里都能跑,但没有加载存档时会跳过 31 项 |
| F2 | 探针 title —— 游戏自己的标题菜单按钮 | 标题界面 |
| F3 | 探针 uislot —— 那个按钮的内层 slot,从世界里读 | 一个加载好的存档 |
| F4 | 解锁当前存档里的全部科技 | 一个加载好的存档 |
| F5 | 探针 reflect —— 类、函数、参数、DataTable 行 | 一个加载好的存档 |
| F6 | 探针 pal —— 帕鲁的网格组件、动画类和材质 | 身边站着一只帕鲁 |
| F8 | 探针 watch —— 装上原生钩子,记录你操作时触发了什么 | 加载好的存档,然后制作/丢弃/生成 |
| F9 | 重新加载所有 palforge.* 模块 | —— |
| F10 | 探针 uievents —— 数四个 UI 重建钩子的触发次数 | 加载好的存档,然后退回标题再重新进入 |
九个里有七个只读取和打印。真正会改变什么的只有两个:
- F4 会写进当前加载的存档。按一下,没有确认,全部科技解锁。
- F8 会注册原生钩子,而 UE4SS 没有取消注册的办法 —— 它装上的东西会一直留到你退出游戏。它还会 在你面前生成一只 ChickenPal,并在之后大约 60 秒内拒绝 F9:它那两条最长的异步链还没结束,在这个 窗口里重新加载会把引擎的 tick 钩子一起带走。
F7 被刻意排除在外:那是 Palworld 自己的音量键,绑定会成功,日志也会说成功,然后按下去一次都到不
了。每个动作同时都有控制台命令(pf_tests、pf_watch、pf_hooks,每个探针一个、每个钩子一个),
而 core/autorun.lua 会在世界加载时运行 palforge/autorun.txt 里具名的动作 —— 三条不同的入口,
因为这个项目已经接连有三条输入通路失效过。
env.debug 是第二个、也更窄的开关:它加载 palforge/test/hooks/,也就是那 25 项没有游戏在跑就
测不了的测量。它们是被声明而不是被运行的 —— 用 pf_hook <id> 按名字调用,pf_hooks 会列出每个
钩子以及它会跳过的原因。会写进存档的那些,还需要额外的 env.debugHooks[id] = true。
任何模块都能读同一张表:
local env = require("palforge.env")
local log = require("palforge.utils.log").scope("mypack")
if env.dev then
log.info("dev build " .. tostring(env.version))
enddev 开关是在 registry.initialize() 内部读的。在那之后再改 require("palforge.env").dev,只会改变
你自己的代码看到的值,并不会加载或卸载 dev 工具。覆盖文件之所以有效,是因为 main.lua 在
initialize() 之前 就 require 了它。
你的第一个定义
把你自己的代码放进 main.lua 旁边一个独立的文件里。package.path 已经覆盖了那个文件夹,所以一句
普通的 require 就能找到它。
local api = require("palforge.api")
local log = require("palforge.utils.log").scope("mypack")
api.Item{
id = "Stone",
name = "Stone",
category = "material",
maxStack = 9999,
events = {
onObtain = function(item, ctx)
log.info("picked up " .. tostring(ctx.count) .. " stone")
end,
},
}等 PalForge 本体起来之后,在 main.lua 的末尾加载它:
_G.PalForge = {
-- ... unchanged ...
}
local ok, err = pcall(require, "mypack")
if not ok then print("[mypack] load failed: " .. tostring(err) .. "\n") end
return _G.PalForge一定要在 registry.initialize() 跑完之后再 require 你的文件。在那之前,裸的全局名还没装上,
native 目录也还没加载。pcall 同样重要:定义里写错一个字段会抛出硬错误,不加保护的话,main.lua
剩下的部分就停了。
Item{ id = "Stone" } 是把你的行为和元数据挂在一个游戏里已经有的 id 上。光靠 Lua 没法往游戏的
道具、帕鲁、建筑物表里加一个全新的行,那些是游戏的 DataTable,往里面写新行是 PalSchema 的活。所以
要在已经存在的 id 上做,这也是例子里用原版 id 的原因。不带冒号的 id 就是游戏里真实的 id(Stone、
Wood、PalBoxV2);"pack:name" 是你自己的带命名空间的 id,它对应行名 pack_name。
接下来是一个会记住东西的定义。建筑物实例带着 self.actor、self.pos、self.state 和
self:save():
local api = require("palforge.api")
local log = require("palforge.utils.log").scope("mypack")
api.Building{
id = "ItemChest",
name = "Counted Chest",
gridCm = 100,
state = { uses = 0 },
events = {
onLoad = function(self, ctx)
log.info("chest tracked at " .. string.format("%.0f,%.0f,%.0f", self.pos.x, self.pos.y, self.pos.z))
end,
onRightClick = function(self, ctx)
self.state.uses = self.state.uses + 1
self:save()
log.info("chest opened " .. tostring(self.state.uses) .. " time(s)")
end,
},
}一个 id 只保存一个定义,object_manager 用种类加 id 作为键,所以给一个 PalForge 已经注册过的 id
再定义一次,就会替换掉那一个。随包目录真正注册的,就是启动行里的那 17 个类:native/items 的
Wood、Berries 和 Arrow,native/pals 的 ChickenPal 和 SheepBall,再加上 audio、effect、
skill 和 UI 的那几个。native/buildings 什么都不注册 —— 它的 WorkBench 和 PalBoxV2 是用
{ register = false } 造的,由 native.buildings.publish(id) 按需交出。所以上面的 ItemChest
不会和任何东西冲突;而跨内容包的冲突现在会点名告警,而不是被悄悄覆盖。
再来一个帕鲁的例子,看的是一个动作而不是处理函数。给生成留几秒钟:SpawnMonster 是异步的,唯一
被计时过的一次到达用了 5.9 秒,core/spawn 会盯着世界最多 12 秒,看到它到了就写进日志。:spawn
返回的布尔值说的是那次调用,不是说帕鲁已经站在你面前。
local api = require("palforge.api")
local log = require("palforge.utils.log").scope("mypack")
-- Pal.get works for any game CharacterID, defined or not.
api.Pal.get("ChickenPal"):spawn(api.Player.coordinate()) -- the pal arrives a few seconds later
-- Defining the same id attaches your own mesh and handlers to it. This replaces
-- native/pals' curated ChickenPal demo definition.
local chicken = api.Pal{
id = "ChickenPal",
name = "Chicken Pal",
mesh = api.Mesh{
id = "mypack:chicken_body",
kind = "skeletal",
model = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal.SK_ChickenPal",
animClass = "/Game/Pal/Blueprint/Character/Monster/PalActorBP/ChickenPal/ABP_ChickenPal.ABP_ChickenPal_C",
},
events = {
-- The mesh declared above is attached FOR YOU on pal.spawned; nothing here calls
-- renderOn. Your own handler runs after that attach, and may fire more than once.
onSpawned = function(pal, ctx)
log.info("chicken spawned: " .. tostring(ctx.actor))
end,
},
}
chicken:spawn(api.Player.coordinateOffset(300, 0, 0)) -- 3 m away, a few seconds later在游戏里确认
读取一个存档,盯着日志看。
确认世界闸门开了
[PalForge.event][info] world ready - building dispatch enabled这一行出现之前,building、pal 和 item 这几个源都会直接返回,只有 tick 心跳在跑。
触发随包的演示
PalForge 自带的定义上已经挂好了能用的处理函数,所以你什么都不写也能证明整条线路是通的:
[PalForge.native.items][info] Wood onObtain: count=1
[PalForge.native.items][info] Berries onUse: target=...
[PalForge.native.pals][info] ChickenPal onDamaged: ...第一条砍一棵树,第二条吃浆果,第三条打一只野生的 Chicken。
触发你自己的
挖一块石头,你的处理函数就会跑:
[PalForge.mypack][info] picked up 3 stone从游戏到你的函数的路径:
ctx.itemId 和 ctx.count 取自游戏自己的获取日志结构体,这个 id 会拿到 object_manager 里查。
一个你从没定义过的原版 id 什么都匹配不上,整件事就安静地什么也不做。
查看注册了什么
local registry = require("palforge.core.registry")
local log = require("palforge.utils.log").scope("mypack")
for id in pairs(registry.registered().item) do
log.info("item: " .. id)
endregistry.registered() 返回一份快照,先按种类再按 id 组织,种类有 item、pal、building、
skill、effect、audio、mesh、ui。
object_manager.owner(type, id) 会说出某个注册属于哪个内容包,entry 和 byResolved 从另外两个
方向回答同一个问题。做了 dev 部署的话,最快的冒烟测试就是 F1:它会跑完 19 个套件,而摘要会写
明有多少项检查没能被回答、以及是哪个方向没能回答。
访问 API
require("palforge.api") 一次做两件事:它返回一张带着所有东西的表,同时还创建了可以直接用的普通
全局名。两边指向的是同一批对象。
local api = require("palforge.api")
api.Item.get("Wood"):give(10)
api.Pal.get("ChickenPal"):spawn(api.Player.coordinate()) -- the pal arrives a few seconds later每一种内容的用法都一样,所以学会一种就等于学会全部:
local pal = Pal{ id = "example:Boss", name = "Boss" } -- make one
Pal.get("ChickenPal") -- find one by id
Pal.get_all() -- list every one把你的包做成独立模组
main.lua 发布了 _G.PalForge,所以另一个模组可以用已经在跑的那一份,而不用再加载一份。
ue4ss/Mods/MyPack/
├── enabled.txt
└── Scripts/
└── main.lualocal PF = _G.PalForge
if not PF then
print("[mypack] PalForge is not loaded - check the mod load order\n")
return
end
local api = PF.api
local log = PF.utils.log.scope("mypack")
api.Item{
id = "Stone",
name = "Stone",
category = "material",
maxStack = 9999,
events = {
onObtain = function(item, ctx)
log.info("picked up " .. tostring(ctx.count) .. " stone")
end,
},
}
log.info("mypack loaded")在 ue4ss/Mods/mods.txt 里把 PalForge 写在你的包上面,这样你的定义运行之前,它已经启动完毕。
UE4SS 给每个 Lua 模组各自独立的 Lua state。_G.PalForge 能被加载进 PalForge 的 main.lua 所在
state 的代码访问到;另一个模组文件夹是否共享那个 state,取决于你的 UE4SS 版本和加载配置。请留着
if not PF then ... return end 这道防线,如果它真的触发了,就照“你的第一个定义”一节里的做法,从
PalForge 自己的 Scripts/ 文件夹加载你的文件。
除了 api,公开出来的这张表还给你工具箱和引擎:
local PF = _G.PalForge
PF.utils.log.scope("mypack").info("hello")
PF.utils.items.unlockAllTech()
PF.native.items.get("Arrow_Fire")
PF.native.buildings.WorkBench:unlock()
PF.core.event.on("tick", function(ctx)
if ctx.count % 120 == 0 then PF.utils.log.scope("mypack").info("one minute") end
end)启动之后再定义也没问题。之后定义的建筑物会被下一次扫描拾到,帕鲁和道具的事件在触发的那一刻按 id
解析,所以在 gameStart 一分钟之后才注册的定义照样能收到自己的事件。
排查问题
一行 [PalForge.*] 都没有
模组根本没加载。按顺序检查:
Scripts/main.lua就在ue4ss/Mods/PalForge/下面,palforge/在它旁边。main.lua是根据自己 文件的位置算出package.path的,文件夹一挪,每个require都会坏。- 模组是开着的:模组文件夹里有个空的
enabled.txt,或者ue4ss/Mods/mods.txt里有PalForge : 1。 - UE4SS 本身确实在加载 Lua 模组(别的 Lua 模组会打印它们自己的行)。
initialize failed: ...
[PalForge.main][err] initialize failed: ...有一个错误从 registry.initialize() 里跑了出来。main.lua 把它接住了,所以游戏还能跑,但什么都
没起来,没有源,没有分发,没有目录。冒号后面的文字才是真正的错误。只有某一个目录失败时,会单独出
一行 native catalog 'palforge.native.items' load error: ...。
未知字段
PalForge: Pal: unknown field "meshSpec" (did you mean "mesh"?). Valid fields: id, name, description, skills, mesh, material, color, texture, icon, events, data不属于定义的字段会直接报硬错误,并附上修改建议,所以名字拼错绝不会被悄悄忽略。同样的检查也会跑在
嵌套的表上,包括 events:
PalForge: Pal: field "events" (Pal.Spec.Events): unknown field "onSpawn" (did you mean "onSpawned"?). Valid fields: onSpawned, onDamaged, onDeath, onCaptured, onTick一个调用接受什么,你也可以从自己的代码里问:
local schema = require("palforge.core.schema")
print(schema.help("Pal.Spec")) -- every field, type, default and meaning
print(schema.help("Pal.Spec.Events")) -- the handler list
schema.get("Pal.Spec").fields -- the same as a table, for tooling缺少 id
PalForge: Item: field "id" is required (item id: a game ItemId ("Wood") or "pack:name")每一种内容都必须有 id。括号里的文字是这个字段自己的文档,所以这条消息会告诉你该给一个什么样的值。
值不对或类型不对
PalForge: Item: field "category" must be one of { "material", "consumable", "equipment", "ammo", "ingredient", "other" }, got "food"
PalForge: Item: field "maxStack" expects number, got string
PalForge: Building: field "mesh" (Building.Spec.Mesh): field "model" is required (UStaticMesh asset path, or an OBJ path for the procedural backend)调用不会做到一半就算成功:检查一旦不通过,在注册任何东西之前就先报错。
处理函数一直不触发
-
检查有没有东西能触发那个钩子。道具的四个事件全都会触发:
onObtain和onUse来自背包与使用 路径,onCraft来自两个地图对象模型的OnFinishWorkInServer,onDiscard来自RequestDrop_ToServer/RequestDispose_ToServer。真正永远不触发的是建筑物的onLeftClick和onBreak:把所有可能拥有它们的类的完整函数列表读完之后,这件事是以否定的方式定下来的。请改用onRightClick,消失则用onRemove(会带着reason = "missing")。第三个是技能的onHit,它 仍然没有解决。生命周期列出了哪些是能用的。 -
检查世界闸门:
world ready - building dispatch enabled之前什么都不会跑。onBuild、onSpawned和被动技能的来源都是在world.ready那一刻才装上的,不是在模组加载时,所以在世界始终没加载完 的那一局里它们也会一直沉默。 -
检查日志里有没有点名来源的那一行。一个通道第一次运来东西时一定会说:
channel item.craft carried its first event this session — the native source is LIVE, whether or not any definition handled it。没有这一行,就说明游戏一次都没调用过它。 -
检查有没有游戏不让 PalForge 装上的钩子:
[PalForge.event][warn] hook unavailable (feature disabled): /Script/Pal.PalBuildObject:OnBeginInteractBuilding -> ... -
检查 id。道具按
ctx.itemId匹配,帕鲁按蓝图类名(BP_<Id>_C)匹配,那是游戏内部给这只帕鲁的 actor 起的名字。在object_manager里匹配不到的 id 会安静地什么都不做,这不算错误。
处理函数触发了,但里面出了问题
你的处理函数跑在 pcall 里,所以里面写错也不会让游戏崩溃。错误会写进日志,带上出问题的通道和钩子:
[PalForge.event][err] item.use -> onUse handler failed: ...建筑物的 onTick 如果一直失败就会被关掉,这样一个坏掉的处理函数不会永远烧着心跳:
[PalForge.event][err] onTick 'WorkBench@1204,-431,84' failed: ...
[PalForge.event][warn] onTick 'WorkBench@1204,-431,84' disabled after 5 failures开发时自己把有风险的活包起来,消息说什么由你决定:
events = {
onRightClick = function(self, ctx)
local ok, err = pcall(function()
-- your work here
end)
if not ok then log.err("onRightClick: " .. tostring(err)) end
end,
}生成帕鲁没有反应
先再等几秒。:spawn 返回 true 的意思是原生调用已经发出去了,不是帕鲁已经存在:带坐标的那种形式
实测是在约 5.9 秒后到达的,core/spawn 会盯着世界等它,并单独用一行把到达记进日志 —— 见
Pal。等过这段时间还是什么都没出现,就去找下面两行之一:
[PalForge.spawn][warn] spawn.pal: no PalCheatManager and none could be constructed (no player controller yet?)
[PalForge.spawn][err] spawn.pal: SpawnMonster did not execute for ChickenPal [evidence ...]前一行表示当时没有玩家控制器可以用来造一个作弊管理器,也就是你还没进世界。后一行表示
core/signature 拒绝了这次调用,因为活的类声明 SpawnMonster 的方式和头文件导出的不一样,而它
已经把自己期望的参数个数写进日志了。
接下来读什么
定义
怎么做一个、怎么找一个、怎么列出全部,以及你拿回来的是什么。
Schema
每一种内容接受哪些字段,以及运行时怎么读它们。
生命周期
哪些处理函数会触发、什么时候触发、ctx 里有什么。
第一个内容包
用每一种内容从头到尾搭出来的一个完整的包。
小结
- PalForge 放在
ue4ss/Mods/PalForge/Scripts/,palforge/和main.lua并排。换成别的布局就 不能用。dev 副本还要一起复制palforge/test/和palforge/autorun.txt:少了前者就没有 F1、也 没有任何pf_*命令,少了后者就没有不靠按键的入口。--release会整个丢掉palforge/test/, 那才是玩家手上的副本。 - 所有 dev 工具都以关闭状态出厂。武装那九个键的是
Scripts/palforge_dev.lua—— 由tools/deploy.sh写出、被 gitignore、绝不会出现在玩家的副本里 —— 而 F4 会解锁当前存档的全部 科技。 [PalForge.main][info] ready表示 PalForge 启动了;world ready - building dispatch enabled表示你的处理函数现在可以触发了。- 把你的内容写进
main.lua旁边你自己的文件里,并在main.lua末尾、registry.initialize()之后require它。 Item{ ... }、Building{ ... }和Pal{ ... }的用法完全一样:传一张表调用就是做一个,get(id)是找一个,get_all()是列出全部。- 不带冒号的 id 是游戏自己的;
"pack:name"是你的。 - 字段名写错会让调用停下,消息里会点出那个字段并列出有效的字段,读它,别猜。
接着读定义,看看一个定义会返回什么、你能拿它做什么。