Testing
Press F1 in game to find out whether PalForge and your own content are working, read the result, run the measurements that need the game, and add checks of your own
What you can do after this page
- Find out in a few seconds whether PalForge is loaded and working in your game
- Read the pass, fail and skip line that appears on your screen while you play
- Know why one press cannot run every check, and which second press covers the rest
- Run only the part you care about instead of all of it
- Run the measurements that only a running Palworld can answer, one at a time and by name
- Put your own content on a key the game has not already taken
- Add your own checks, so a broken pack tells you before your players do
Press F1, twice
Press F1 while the game is running. A moment later two lines appear on your screen:
[PalForge] tests: running 19 suite(s)
[PalForge] tests: 577 passed, 0 failed, 35 skipped (31 need a world, 1 need a declared test/hooks run, 3 could not be answered by this session)0 failed is the answer to "is my setup working". It means PalForge loaded, the api you write
against behaves the way the api pages describe it, and the game accepted the calls it made.
Any other number names the broken piece, on screen and in the log.
No single press runs every check, and the summary says so in words. 31 checks need a loaded
save and skip at the title screen. Eighteen more are the inverse: they prove a refusal path — the
shut ready gate, a widget with no owner to draw under, Player.coordinate() with no pawn — so
they can only run where there is nothing to succeed with. Five of those eighteen skip once a world
is up and run at the title screen; the other thirteen need no engine at all and only ever run in a
headless lua5.4 process. No press in any session state measures everything. Press the key once
at the title screen and once inside a save; a green run is two runs.
You can run the same 612 checks with no game at all, and that is what CI does:
npm test # cd Scripts && lua5.4 ... palforge.test.run()tests: 576 passed, 0 failed, 36 skipped (612 total)
tests: 36 skipped (31 need a world, 1 need a declared test/hooks run, 3 could not be answered by this session, 1 did not say which)One check separates the two routes, and it is not a discrepancy. npm test calls run()
directly; F1 gets there through install(), which also binds F1 — so the ui check that verifies
a key PalForge already holds is refused, and the refusal names the holder has a bound key to be
refused against on the F1 route and nothing to look at on the npm test route, where it skips
without a direction. install() additionally runs the headless boot bundle first, which reports
its own 8 passed, 0 failed, 0 skipped line before the 612.
Both figures above are real runs on 2026-08-03 in a plain lua5.4 process with no engine at all.
In the game the world-gated 31 run instead, and two of the three could not be answered by this session skips go away as well — they exist only because a bare Lua process has no LoopAsync and
no UE4SS Key table to check against.
Loading the checks and putting them on F1 happens once, when the game starts. Everything
from F1 pressed down happens again on every press.
Startup only loads these checks. It never runs them. They spawn pals and hand out items,
so a run only ever happens when you ask for one. The headless units/ bundle is the one thing
that does run at boot, and that is why it is a separate directory: it touches nothing but Lua
tables.
The key is installed through core/keyboard/base/registory, which wraps your callback in
ExecuteInGameThread and a pcall. A check that blows up cannot take your keyboard input
with it.
F1 exists only in a dev session, and env.dev ships as false — so does env.debug. Nothing
in the framework turns either of them on. A dev session turns them on through an optional
Scripts/palforge_dev.lua, which tools/deploy.sh writes in its default mode, deletes under
--release, and which is gitignored so it cannot reach a player through the repository:
local env = require("palforge.env")
env.dev = true
env.debug = true
-- Per-hook opt-in for the hooks that write into a real save.
-- env.debugHooks["pal-skills-equip"] = trueWith dev = false PalForge loads the content catalogs and nothing else: no dev keybinds
(F4 unlocks every technology in the loaded save), no F1 suite, no F9 reload, no catalog
dumper, no headless bundle and no test hooks. The startup log names each piece it did and
did not load, so "the key does nothing" and "the key was never bound" are two different lines.
reg.register is fail-soft. If RegisterKeyBind or UE4SS's Key table is unavailable
this session, it logs could not bind F1 (keybinds unavailable this session) and returns
false. There is no key to press then — call test.run() from your own code instead.
The test tree
Everything on this page lives under Scripts/palforge/test/, and it is one tree with five
corners. test/init.lua is the only name production code knows: core/registry.lua requires it
under the dev gate and calls install(), and that one call runs the boot bundle, loads the case
files, binds F1 and the probe keys, registers every pf_* console command including
ps_catalog, and hands core/autorun its action table.
Scripts/palforge/test/
├── init.lua the one entry point; install() wires everything below
├── units/ headless suites, run at STARTUP - two files, 8 checks
├── cases/ the in-game API suite, on F1 - 19 files
├── hooks/ measurements that need Palworld running - 25 declared, never auto-run
├── probes/ discovery dumps - not tests; they pass and fail nothing
├── tools/ dev instruments - catalog.lua, the body of ps_catalog
├── support.lua the world reads, the namespaced ids and the sweep
└── probe.lua the BEGIN/END bracketing a probe or hook writes its output with| directory | what belongs in it | when it runs |
|---|---|---|
units/ | a suite that touches nothing but Lua tables | at startup, every dev session |
cases/ | a suite that needs a world, a pawn, a UObject or a UE4SS global, and skips with a direction when its environment is absent | on F1, never by itself |
hooks/ | a measurement that cannot be taken with the game switched off, usually with a person watching the screen | only when asked for by name |
probes/ | a dump of what the engine actually looks like, so an open question can be closed | on its own key, or as pf_<name> |
tools/ | an instrument that is not a test at all | when its command is typed |
The rule for units/ is the narrow one, and it is what keeps startup safe: the whole bundle runs
before the first world exists, in a game that is still loading, so a suite there that blocks or
raises delays every start. If a check needs a world, a pawn, a UObject or a UE4SS global, it is a
cases/ suite or a hooks/ measurement instead.
A release deploy has none of this. tools/deploy.sh --release deletes palforge/test/ from
the staged copy, all 58 files of it, so the kernel's single require("palforge.test") answers
absent — the correct state for a player's install rather than an incomplete one. Nothing on this
page reaches a player: no suite, no probe key, no hook, no pf_* command and no ps_catalog.
What gets checked
There are 612 checks, grouped into 19 suites — a suite is one group of checks,
covering one area of the api. Each suite is one file under
Scripts/palforge/test/cases/. They run against the real game, in your session.
| suite | checks | only inside a save | only with no engine |
|---|---|---|---|
schema | 28 | 0 | 0 |
registry | 42 | 0 | 0 |
definitions | 23 | 0 | 0 |
store_codec | 19 | 0 | 0 |
store_state | 36 | 0 | 0 |
store_api | 12 | 0 | 1 |
store_runtime | 5 | 0 | 5 |
store_disk | 27 | 0 | 0 |
native | 26 | 0 | 0 |
pal | 34 | 5 | 0 |
item | 39 | 6 | 0 |
building | 32 | 3 | 1 |
skill | 39 | 3 | 0 |
effect | 40 | 2 | 0 |
audio | 24 | 4 | 1 |
mesh | 46 | 3 | 1 |
ui | 116 | 1 | 7 |
player | 10 | 4 | 1 |
events | 14 | 0 | 1 |
The middle column is the 31 world-gated checks, counted off a real run. The right-hand column is
the eighteen that cannot run with the game loaded, and it holds two different gates: five call
support.needNoWorld and do run at the title screen (store_api, building, audio, player,
events), while thirteen call support.needNoEngine and run only in a headless process
(store_runtime's five, ui's seven and one in mesh) — those verify what happens when there is
no LoadAsset, no owner and no FindAllOf to stub, and a UE4SS session has all three.
The order in the table is the run order, and it is M.CASES in
Scripts/palforge/test/init.lua — that list is the only authority on which suites F1 runs.
The suites that touch nothing but Lua go first, so a break in the basics is reported before
anything touches your save.
One check in pal is in neither column: teachAll's write half needs a live pal and mutates
a real save, so it is measured by a declared hook rather than by F1, and its skip line names
the hook that measures it. That hook has been run — pal-skills-equip, 8 pass / 0 fail on a real
save — so the skip means "measured elsewhere", not "unknown".
The five in store_runtime are in neither column either, and for a sharper reason: they drive
the building scan against a fake actor list, and doing that with UE4SS loaded would hand the
real 500 ms sweep a world with none of the player's structures in it — every one of them would
miss and be quarantined. So they run headless under lua5.4 and skip in the game, in both
states, saying so. The other four store suites are pure Lua and run everywhere. None of the
five writes into state/: three substitute the store's I/O entirely, and store_disk — the one
that has to meet a real filesystem — works in a throwaway directory under the OS temp directory.
A suite for the module a player's saved state lives in must not be able to write into a player's
saved state.
Reading the output
Results go to two places. UE4SS.log gets everything, under the scopes
[PalForge.test] (the runner) and [PalForge.unittests] (the framework):
[PalForge.test][info] build 2026-08-02 12:41:30 | dev=true debug=true | game v1.0.2.101103 (live v1.0.2.101103) | running 19 suite(s): schema, registry, definitions, store_codec, store_state, store_api, store_runtime, store_disk, native, pal, item, building, skill, effect, audio, mesh, ui, player, events
[PalForge.unittests][info] SKIP [player] coordinate returns the local player's position as numeric x, y, z (world): no world loaded
[PalForge.unittests][info] tests: 577 passed, 0 failed, 35 skipped (612 total)
[PalForge.unittests][info] tests: 35 skipped (31 need a world, 1 need a declared test/hooks run, 3 could not be answered by this session)
[PalForge.unittests][info] tests: 31 check(s) were not measured because there is no world loaded - load a save and press the key again.
[PalForge.unittests][info] tests: 1 check(s) can only be measured by a declared hook - `pf_hooks` lists them with the reason each one would skip, and each SKIP line above names the action that runs its own.
[PalForge.test][info] swept 127 test definition(s)Read it as five kinds of line:
- The provenance line: the build stamp
tools/deploy.shwrote, both switches, the Palworld build every capability was measured against and the one the running game reports, then what this press is about to do. Lua that is already loaded stays loaded, so a stamp older than your last deploy means F9 was not pressed and nothing below it is evidence about the code you just wrote.unstamped (not deployed by tools/deploy.sh)is a tree you copied by hand. SKIP [suite] test (direction): reason— one line per skipped check, carrying which session state would have run it:world,no-world,hook,opt-in,setuporsession. Logged atinfo, because a skip is not a problem.FAIL [suite] test: message— logged aterr, with the assertion message.tests: P passed, F failed, S skipped (T total)— the one line to look at.- The breakdown underneath it, which is what makes the line above readable: how many skips are waiting on which state, and — when both directions are waiting — the sentence saying that no single run measures everything.
The summary is put on screen with SendSystemAnnounce, prefixed [PalForge], with the same
breakdown clause, so you do not have to alt-tab to find out what was actually measured:
[PalForge] tests: running 19 suite(s)
[PalForge] tests: 577 passed, 0 failed, 35 skipped (31 need a world, 1 need a declared test/hooks run, 3 could not be answered by this session)
[PalForge] FAIL [item] give really adds to the live inventory, measured both ways: the Wood count must RISE across a give (135 -> 135)Every failure is repeated on screen individually. A summary that says 3 failed and
nothing else means going back to the log anyway.
A bare N skipped cannot tell a run that measured almost everything from one that measured
almost nothing, which is why every skip carries a direction now. 31 need a world is a
sentence you can act on; 35 skipped is not.
Running one suite
test.run takes nothing, a suite name, or a list of names:
local test = require("palforge.test")
test.run() -- every suite this module owns
test.run("schema") -- one
test.run({ "pal", "item" }) -- severalIt returns the results table, so you can check it from your own tooling:
local results = test.run("item")
print(results.passed, results.failed, results.skipped, results.total)
for direction, n in pairs(results.needs) do print(direction, n) end -- world, no-world, hook, ...
for _, suite in ipairs(results.suites) do
print(suite.name, suite.passed, suite.failed, suite.skipped)
for _, f in ipairs(suite.failures) do print("FAIL", f.test, f.msg) end
for _, s in ipairs(suite.skips) do print("SKIP", s.test, s.needs, s.msg) end
endtest.run() with no argument runs those nineteen suites and nothing more. The startup bundle is
a separate set of pure-Lua checks — palforge.test.units, run by test/init.lua's install()
rather than by the kernel. They register into the same list, and F1 still deliberately leaves
them alone. To reach every registered suite, go one level down:
local T = require("palforge.core.unittests")
T.names() -- every registered suite name, sorted
T.byName("pal") -- one suite, or nil
T.run() -- EVERY registered suite, including the headless bundle
T.NEEDS -- the seven skip directions, as the summary spells themThere is deliberately no way to empty that registry: the suite list lives for the process, and
the one thing that really needs a fresh slate — reloading edited case files — is F9, which
drops every palforge.* module and rebuilds it from nothing.
palforge.test also reports on itself:
test.CASES -- the case names, in run order
test.loaded -- case name -> suite, or false when the file failed to load
test.suites() -- the names that actually loaded, in run order
test.ACTIONS -- name -> function, every pf_* command this module owns
test.bindings() -- one line per PalForge key, with what the GAME has on the same keytest.bindings() delegates to the keyboard registry's own report, and the second half of each
line is the part worth reading:
keyboard: F1 palforge tests: all suites game: nothing
keyboard: F5 unknown probe reflect (reflection dump: classes, functions, parameters, DataTable rows) - needs a loaded save game: the game's key config has not been readunknown at startup is correct rather than broken: the game's key config lives on a world
subsystem, and there is no world when the mod loads. It fills in from world.ready onward,
and pf_keys prints the whole table on demand.
Three ways in: a key, the console, autorun.txt
Three input routes have failed in turn on a real machine, and each time the work was fine and
only the way in was missing. So every command lives in one table — test.ACTIONS — and three
routes reach it.
The UE4SS console. Every action is registered as a console command, and the list is printed sorted at every startup, generated from the table it describes:
[PalForge.test][info] console commands: pf_hook pf_hook_audio_custom_file_loader pf_hook_audio_setvolume_audible pf_hook_building_actor_streaming pf_hook_building_record_orphans pf_hook_building_runtime_reload pf_hook_building_unlock pf_hook_game_build_live pf_hook_item_datatable_row_read pf_hook_item_satiety_write pf_hook_keymap_key_coverage pf_hook_mesh_actor_identity pf_hook_mesh_color_change pf_hook_mesh_texture_import_live pf_hook_pal_skills_equip pf_hook_pal_spawned_fresh pf_hook_save_survives_pack_removal pf_hook_skill_hit_source pf_hook_skill_projectile_spawn pf_hook_store_base_load_cost pf_hook_store_crash_recovery pf_hook_store_save_roundtrip pf_hook_ui_backhandler pf_hook_ui_host_layer pf_hook_ui_update_event pf_hooks pf_hooks_all pf_keys pf_mesh pf_native pf_pal pf_reflect pf_spawn pf_teach pf_tests pf_title pf_uidecl pf_uievents pf_uiroute pf_uislot pf_uiz pf_watchRegistering a command is not the same as being able to type one. UE4SS ships with its
console switched off, and the handler registers perfectly well into a window that does not
exist — the log says console commands: ... while there is nowhere to put them. Turn it on in
ue4ss/UE4SS-settings.ini and restart the game:
ConsoleEnabled = 1
GuiConsoleEnabled = 1
GuiConsoleVisible = 1PalForge cannot read that file, so the startup log says the same thing as a note rather than a
check: if you cannot type those, UE4SS's console is off.
A key. F1 runs the suite; six discovery probes sit on their own keys. Nothing else in the tree reserves one, because keys are not PalForge's to reserve.
Scripts/palforge/autorun.txt. The route that asks for no input at all: a text file of
action names, read once per world load and run on world.ready. A line is [delay] name,
where the delay is whole seconds after the world was ready:
# comments and blank lines are ignored
pf_keys # as soon as the world is ready
12 pf_hook_item_datatable_row_read # 12 seconds laterThe delay matters more than it looks: a pal takes four to eight seconds to arrive, so an action
that needs one nearby cannot run in the same breath as the spawn that makes it. The file holds
no table of its own — every name is looked up in test.ACTIONS, and a name that is not there
is reported and skipped. It reads a list of names and never code, so nothing in it can run
anything a keypress could not, and a hook that writes into a save keeps its own second gate on
top. A line carries no argument, which is why each hook also gets its own generated action
name.
Binding your own key
test.bind is one line, and takes four shapes:
local test = require("palforge.test")
test.bind("F1") -- everything (installed by default)
test.bind("F11", "pal") -- one suite
test.bind("F12", { "item", "effect" }) -- several
test.bind("INS", function() -- anything at all
Pal.get("ChickenPal"):spawn(Player.coordinate())
end, { desc = "spawn a chicken" })The fourth shape is not a test run at all. Pass a function and the key calls it. Use it for anything you want to try while you play — spawn a pal, hand yourself materials, open a UI panel of your own.
opts goes through to the keybind registry. desc is what test.bindings() prints; when
you leave it out, bind writes one for you (tests: pal, tests: all suites, or
custom).
Pick a key nobody already has. A dev session holds nine of the first ten function keys —
F1 for the suite, F2, F3, F5, F6, F8 and F10 for the six probes, F4 for unlock-all-technologies
and F9 for the reload. The tenth is F7, and F7 is Palworld's own volume control: the game
claims it below UE4SS, so a probe bound there could never be pressed, RegisterKeyBind
returned normally, and nothing in the log said so. From the outside, a key that never arrives
and a probe that ran and found nothing are the same silence.
That question is now a lookup. pf_keys reads Palworld's own key config out of the running
game — the struct the options screen edits — plus the project's shipped DefaultInput.ini
defaults, crosses it against every name UE4SS can bind and against what PalForge already
holds, and prints one row per name:
keymap: UE4SS NAME UNREAL FKEY STATUS WHAT HAS IT
keymap: F1 F1 palforge tests: all suites
keymap: W W game MoveForward [project/axis]
keymap: ESCAPE Escape refused Esc is not a KEY on this build ...
keymap: 64 free, 24 taken by the game, 9 held by PalForge, 1 refused outright, 67 unanswerablefree— no action in the game's key config uses it. The strongest thing anyone can say about a key, and still not a promise the press arrives: the Steam overlay, the OS and UE's own console keys are outside that config. "Free here and it still never arrived" is now a reportable finding about something else instead of an unreadable zero.game— Palworld has an action on it, named. Both fire, or the press never reaches UE4SS at all.palforge— this mod already holds it, and the last column says which binding.refused— Esc, and only Esc. Nothing in PalForge may bind it; the player must always be able to close the game's own menu. A panel that wants Esc declaresUI{ backHandler = true }instead.unknown— the config could not be read (no world yet), or Unreal has noFKeyname for that UE4SS name. Never treat one as free.
It needs a loaded save, because the config lives on a world subsystem. It walks properties
only, calls no UFunction, writes nothing to your settings or save, and takes about a second.
A live run read 107 project mappings, an empty player config — Palworld stores key bindings as
overrides, so a save where nobody rebound anything is legitimately empty — and made zero name
look-ups.
Put the calls anywhere that runs at startup. The dedicated place is a file under
palforge/core/keyboard/functions/, which reg.load() picks up by scanning the directory:
local test = require("palforge.test")
test.bind("F11", { "building", "skill" }, { desc = "tests: beacon domains" })Re-binding a key replaces its behaviour in place. The registry keeps one engine binding
per key and swaps the function behind it, so you never end up with two handlers on F1 — and
the swap is logged as a warning naming both descriptions, so a replacement nobody meant shows
up in the log instead of being discovered by pressing F1 and getting somebody's panel.
reg.claim(key, fn, opts) is the other door: it asks the same question and refuses instead of
replacing.
Do not claim F1 with reg.register from a functions/ file. reg.load() runs earlier in
registry.initialize() than the require("palforge.test") that binds F1, so your handler
is silently overridden a moment later. Requiring palforge.test at the top of your file —
as above — loads it there and then, its own bind("F1") runs first, and your bind wins.
The measurements that need the game running
Some questions cannot be asked from a text editor. Does an active-move write land on a real
pal, and does the game survive it. Does a colour change on screen. Does setVolume do
anything audible. Which UI functions fire, and in what order. Those live under
Scripts/palforge/test/hooks/ as declared hooks: one file per measurement, each naming
what it needs on screen, whether it writes into a save, and what its output means.
Nothing there runs unless it is asked for by name, and no hook file is even loaded unless
env.debug is true.
-- from the UE4SS console
pf_hooks -- list every hook, its gate state, and the sentence that opens it
pf_hook pal-skills-equip -- run one by its id
pf_hooks_all -- run every hook whose gate is open, read-only ones first
-- from autorun.txt, which cannot carry an argument, one generated name per hook
pf_hook_pal_skills_equipThere are three gates, and a closed one is always printed rather than passed over:
env.debug— without it no hook file is loaded at all.devgives you the keys and the suite;debuggives you this directory.needs = { world, player, pal, title }— what has to be true in the running game. The refusal names the gate and the sentence that opens it: not "skipped" but "skipped because there is no pal near you, whistle one out or runpf_spawn".writes = true— a hook that mutates a save needs a second, per-hook opt-in,env.debugHooks["<id>"] = true.debugis a session-wide switch; a write is a per-experiment decision taken on a throwaway save.
The overlay tools/deploy.sh writes lists all nine write opt-ins commented out, one per line,
so each is a separate decision. When a whole session is deliberately sweeping them, one flag turns
the lot on and says so:
tools/deploy.sh --writes # all nine env.debugHooks opt-ins ON. Throwaway save onlyIt is a separate flag rather than the default because three of the nine cannot be undone: a
spawned pal has no per-individual removal, an unlocked technology has no lock — UPalCheatManager
declares four unlock entries and no reverse anywhere in the header dump — and a taught active move
is the one write in this tree that has ever correlated with the game closing. (That correlation is
settled now: the target was a villager, not the write. pal-skills-equip asks for a
PalMonsterCharacter and returned 8 pass / 0 fail with the game still up.)
The twenty-five declared hooks, in the order pf_hooks_all runs them — read-only first, so a run
that ends badly has printed everything it could before it got to a write:
| hook | needs | what it settles |
|---|---|---|
game-build-live | world | the raw GetBuildVersion / GetEngineVersion / GetGameName strings, so the declared build can be compared with the running one — run three times on 2026-08-02: those three carry Unreal's identity and not Palworld's, and the build number comes from PalGameInstance.DisplayVersion and PalUtility.GetDisplayVersion, which both answer v1.0.2.101103 |
keymap-key-coverage | nothing | whether keymap.FKEY covers UE4SS's live 165-name Key table, in both directions, naming every drift — the in-game half of the guard in test/cases/ui.lua, which skips headlessly. The only hook here that declares no needs, so it answers at the title screen too |
mesh-actor-identity | world | whether a table keyed on a UE4SS actor handle still hits on a later sweep — the measurement the whole per-actor re-key rests on. Confirmed in three separate sessions on 2026-08-02: the handle misses, the full name hits, rawequal is false and uo.same is true, so the key has to be the name |
item-datatable-row-read | world | whether a scalar or FName column can be indexed off the struct dt:FindRow(id) returns — run on 2026-08-02: it can, and Item.Handle:recipeOf reads the game's row because of it |
audio-custom-file-loader | world | whether the shipping build exposes any way at all to turn a .wav on disk into something playable — run on 2026-08-02: the Wwise external-media half does not exist here, the UE playback half is alive, and what is left is whether a USoundWave can be constructed and filled |
building-record-orphans | world | the orphan quarantine round trip against a real save: how many records loaded, how many had no registered owner, how many were quarantined — and, the assertion that matters, that a pack merely not loaded this session was not destroyed |
store-base-load-cost | world | what a real base costs the store to load: the files, their bytes, the decode timed on those bytes, and what the same records would have cost as one file — run on 2026-08-02: 0 bytes, 0.00 ms, 0 files, because nothing had registered a building definition |
building-actor-streaming | world, player | whether FindAllOf("PalBuildObject") stops returning a base the player has walked away from, and at what distance — still without a result: the operator reached 287 m and all 7 structures stayed in every one of the 90 samples, so Palworld's streaming radius is simply larger than that |
ui-update-event | world | arms 21 named UI functions and reports which fire, and in what order — run on 2026-08-02: three of them do. CommonActivatableWidget:ActivateWidget, PalHUDService:Push and PalHUDService:Close all fired while the operator opened and closed two screens, which is the rebuild signal UI.Handle:autoRefresh now rides. The other 18 stayed silent, and that is an absence of input rather than a finding |
pal-spawned-fresh | world | timestamps every pal.spawned firing against world.ready, so a fresh spawn can be told from the load storm — run on 2026-08-02: 27 firings, 17 of them nowhere near a world load, so the event really does mean a new pal |
skill-hit-source | world | confirms that nothing in the damage path carries a waza id, and quantifies why correlating activation with damage would be wrong — run on 2026-08-02: not one field in the three damage structs this build declares names a waza (40 + 6 + 12 fields), which settles onHit negatively rather than pending |
ui-host-layer | world | pushes a panel onto one of Palworld's own CommonUI layers and reports whether the game accepted it |
ui-backhandler | world | mounts a panel that claims the CommonUI back action, and finds out what Esc then does |
building-runtime-reload | world | whether the building runtime survives F9, pressed between two runs of this hook: the scan and the dispatch now share one _G.__PalForgeBuildingRegistry, and object_manager is on the reload KEEP list — does a building hook still fire afterwards |
mesh-texture-import-live | world, player | calls ImportFileAsTexture2D, then calls it again for the same path and says whether the cache answered instead of re-importing — run on 2026-08-02: a real Texture2D came back and the second call was the cached one |
mesh-color-change | world, player, writes | puts a tinted mesh in front of the player and changes its colour twice — run on 2026-08-02, and the operator watched it: a chest in the air went red → green → blue → gone |
audio-setvolume-audible | world, player | plays one sound at four output-bus volumes so a person can say whether setVolume does anything audible — the one open item. Run on 2026-08-02: the call returned true at every volume, and the listener reported, twice and hedged, that 0.00 was not silent |
building-unlock | world, player, writes | records every establishable fact about Building.Handle:unlock, then hands the unverifiable half to the operator |
item-satiety-write | world, player, writes | what parameter list the live build declares for SetFullStomach, and whether the satiety / HP write is reachable from Lua at all — run on 2026-08-02: it is. SetFullStomach takes one argument, satiety went 31.648 → 21.648 and was put back, and AddHPByRate landed. That measurement is what Item.Spec.restores is built on |
skill-projectile-spawn | world, pal, writes | prints the declared parameter list of every projectile / spawn route, fires the one that takes no arguments, and refuses the struct ones by name — run on 2026-08-02: seven parameter lists walked, six routes refused, every one takes a struct. Settled negative, and shipped as a refusal that carries all six |
store-save-roundtrip | world, writes | writes a pack's state through the public surface into a real save's store, reads it back off the real path, and — run again after a reload — across a world load. Run 1 on 2026-08-02: 7 pass, 370 bytes written and read back field for field, after it caught a real defect no Linux suite could (ensureDir asked io.open about a directory; Windows says no). Run 2, the half that crosses a world load, is still owed |
store-crash-recovery | world, writes | plants a torn write and an unreadable file in a real save's store and reports which copy the loader recovers from — run on 2026-08-02: all four rows of the recovery table hold on NTFS, not only on paper |
save-survives-pack-removal | world, player, writes | names every id PalForge made this save record, then — after the pack is uninstalled and the save reloaded — says what became of each. Started once, never finished; this is the measurement the whole store pass was begun for |
pal-skills-equip | world, pal, writes | whether an active-move write lands on a real pal, and whether the game survives it — run on 2026-08-02: 8 pass / 0 fail, and the game stayed up. AddEquipWaza equipped Human_Punch and it read back off a live PalMonsterCharacter, :forget took it off, teachAll(pal) answered 2, 2, and ClearEquipWaza cleared the loadout with every move restored afterwards |
Nine of the twenty-five declare writes = true. audio-setvolume-audible deliberately does not:
it is audible, but nothing it changes survives the session or reaches a file, and writes
means "a save is mutated" rather than "something happens". Nor does mesh-texture-import-live,
for the same reason: it allocates a UTexture2D and drops a small PNG beside itself, which
changes the running process and a directory, not the player's save.
Output is bracketed exactly like the discovery probes, so a block can be lifted out of
UE4SS.log and pasted straight into a report:
#### BEGIN item-datatable-row-read
NOTE can a scalar / FName column be indexed off the struct dt:FindRow(id) returns ...
NOTE build 2026-08-02 21:32:44 | dev=true debug=true | game v1.0.2.101103 (live v1.0.2.101103)
VALUE dt:FindRow('Arrow') = ScriptStruct /Script/Pal.PalItemRecipe
VALUE Product_Count = number(10)
VALUE Material1_Id = userdata(Wood)
PASS THE STRUCT ROUTE WORKS: all 4 columns came off the row by name.
NOTE item-datatable-row-read finished: 2 pass, 0 fail
#### END item-datatable-row-readA hook that keeps watching after its body returns prints further blocks of its own,
#### BEGIN <id>-1, -2, and says so in its own words before it returns. While one of those
watchers is alive F9 refuses to reload, by name and with the age of the outstanding chain:
a reload that drops the modules a UE4SS callback is still holding is how this tree crashed
before. Deploy, then run the hook; not the reverse.
Every "this needs the game" skip in the F1 suite names the hook that measures it, so a skip is
always traceable to something you can actually run. pf_hooks lists all twenty-five with the
reason each one would skip right now.
The world gate
Most of the api needs a loaded world. A check that touches one calls support.needWorld(t)
as its first line, and that skips the check rather than failing it when there is no
player pawn — the character the game is currently controlling for you:
s:test(":give hands the player three Wood", function(t)
local pawn = support.needWorld(t) -- returns the pawn, or skips out of the test
local wood = Item.get("Wood")
t:eq(wood:give(3), true, "give reads the count before and after, so true means it rose")
end)Its inverse is support.needNoWorld(t), which skips when a world is loaded. A check calls
it when what it verifies is a refusal — the path taken because there is no pawn, no controller,
no owner — and a loaded world would replace that refusal with a real action against the
player's session. Both ask the same question, so a gated pair is exactly complementary: one of
the two halves always runs, and it is never both.
support.needWorld(t) -- skip unless a world is loaded; returns the pawn
support.needNoWorld(t) -- skip when a world IS loaded
support.player() -- the local PalPlayerCharacter, or nil; never throws
support.worldReady() -- core/event's gate, falling back to the pawn check
support.nearbyPal() -- the nearest PalMonsterCharacter and its class, or nil
support.needGlobal(t, "FindAllOf") -- skip unless that engine global is a functionsupport.worldReady() asks core/event.isWorldReady() first — that gate only opens after
the ready watch has seen a pawn five polls running — and falls back to the raw pawn check
for the moment before the watch has settled. needNoWorld deliberately does not use it: that
gate fails open when LoopAsync is absent, and a session with no engine is exactly the one
where the no-world half is the only half that can run. needGlobal covers the sessions and
hosts that do not expose every UE4SS global.
nearbyPal looks for PalMonsterCharacter and not PalCharacter: the hierarchy is
APalMonsterCharacter : APalNPC : APalCharacter, so the wider name also matches villagers and
merchants, none of which has an equipped move — and a read against one of those reports zero
moves and looks exactly like a broken read.
A check can also skip on its own terms, at any depth, once it learns the precondition is not there. Say which state would have run it, and the summary can count it:
s:test("a coordinate spawn is issued and the world can be enumerated", function(t)
support.needWorld(t)
local coord = support.inFront(600.0, 50.0)
if not coord then t:skipUnanswerable("no player location to spawn in front of") end
local ok = Pal.get("ChickenPal"):spawn(coord)
t:eq(ok, true, "the spawn call was issued")
-- The pal is seconds away, so there is nothing to assert about arrival here.
local near = support.nearestPal(coord)
if not near then t:skipUnanswerable("the world could not be enumerated") end
t:type(near.dist, "number", "and the world can be measured around the point asked for")
end)That shape is worth copying. A spawn is asynchronous, so a check cannot assert that the creature arrived — it ends where the evidence ends, and the arrival goes to the log. Assert what the call can actually answer for, and leave what it cannot to the thing that can see it.
Assertions
The t handed to every test body carries nine assertions. Each one takes an optional final
msg; when you leave it out, the framework writes a readable default.
| call | passes when |
|---|---|
t:assert(cond, msg) | cond is truthy |
t:eq(a, b, msg) | a == b |
t:neq(a, b, msg) | a ~= b |
t:truthy(v, msg) | v is neither nil nor false |
t:falsy(v, msg) | v is nil or false |
t:type(v, want, msg) | type(v) == want |
t:near(a, b, eps, msg) | both are numbers and math.abs(a - b) <= eps |
t:errors(fn, pattern, msg) | fn raises, and the message contains pattern |
t:fail(msg) | never — it fails the test on the spot |
Next to them are the seven ways to stop a check without failing it. Each one records a direction, which is what the summary counts:
| call | means |
|---|---|
t:skipNeedsWorld(why) | load a save and press the key again |
t:skipNeedsNoWorld(why) | quit to the title screen and press the key again |
t:skipNeedsHook(id, why) | only test/hooks/<id> can measure it; the reason names pf_hook_<id> |
t:skipOptIn(why) | deliberately off: it would write to the tester's save |
t:skipNeedsSetup(why) | a world is loaded, but not in the state this needs |
t:skipUnanswerable(why) | this session could not answer, and that IS the finding |
t:skip(why) | unclassified; still counted, reported as "did not say which" |
A direction that is not one of the seven fails the check rather than skipping it: a typo'd
direction would otherwise make the check invisible again, which is the defect this whole api
exists to remove. skipNeedsHook requires the hook id for the same reason — a skip that names
nothing is untraceable.
eq is raw ==. There is no deep table comparison: two tables are equal only when they
are the same table, which is exactly what you want for identity checks like
t:neq(Player.coordinate(), c, "every call returns a new table").
near defaults eps to 0.001. Use it for anything that came back through the engine —
floats rarely survive a round trip exactly:
local base = Player.coordinate()
local off = Player.coordinateOffset(250.0, -750.0, 125.0)
t:near(off.x - base.x, 250.0, 0.001, "x moved by dx")
t:near(off.y - base.y, -750.0, 0.001, "y moved by dy")
t:near(off.z - base.z, 125.0, 0.001, "z moved by dz")errors is how you pin down the api's strict validation. The pattern is a plain
substring, not a Lua pattern — the error text is full of magic characters, so matching it
literally is the only thing that works. It returns the message, so you can check more about
it:
s:test("an unknown field is rejected at define time", function(t)
local msg = t:errors(function()
Pal{ id = support.id("pal"), displayName = "Boss" }
end, 'unknown field "displayName"')
t:truthy(msg:find("did you mean", 1, true), "the error suggests a real field")
end)A failing assertion raises. The runner catches it, marks that one check failed, logs it, and moves on to the next — one bad check never stops the suite. An unexpected Lua error is caught the same way and reported as a failure with its raw message; only a skip is counted separately.
Writing a case
Create the file
Case files live in Scripts/palforge/test/cases/, one per area. Require the framework and
the helpers, make a suite, add checks, return the suite.
local T = require("palforge.core.unittests")
local support = require("palforge.test.support")
local s = support.sweepAfter(T.suite("beacon"))
s:test("a beacon registers under the id it was declared with", function(t)
local id = support.id("building")
local h = Building{ id = id, name = "Beacon" }
t:eq(h.id, id, "the handle carries the id it was defined with")
t:eq(h:name(), "Beacon")
end)
s:test("a beacon pays out wood when it is interacted with", function(t)
support.needWorld(t)
local wood = Item.get("Wood")
local before = wood:count()
if before == nil then t:skipUnanswerable("the inventory count could not be read") end
t:eq(wood:give(3), true, "give answers the before/after delta, so true means the count rose")
local after = wood:count()
if after then t:eq(after - before, 3, "and it rose by exactly what was asked for") end
end)
return sMeasure rather than type-check wherever the game will let you. :give, :take and :count
all report numbers you can subtract, so a check can say what actually moved instead of only that
a boolean came back — and a check written that way still means something the day the numbers
change.
Suite:test is chainable, so s:test(...):test(...) works if you prefer it.
support.sweepAfter(suite) gives the suite its own teardown through Suite:after, so the
throwaway definitions go out the moment that suite finishes rather than at the end of the run.
Register the case name
Add the file's name to M.CASES in Scripts/palforge/test/init.lua. The list is the run
order, so put a pure suite before the ones that touch the world:
M.CASES = {
"schema",
"registry",
"definitions",
"native",
"beacon",
"pal",
-- ...
}Press the key
M.load() requires each name under palforge.test.cases. and records the result in
test.loaded. A file that fails to load does not stop the run: it is logged at err and
left out of test.suites(), so the other eighteen still go.
[PalForge.test][err] case 'beacon' failed to load: ...cases/beacon.lua:12: attempt to index a nil valueGive your suite a name nobody else has used. T.suite(name) hands back the existing
suite when that name is already registered rather than making a second one, so a case file
that picks a taken name has its checks appended to someone else's suite. M.load() notices
and warns:
case 'pal' shares its suite name with an already registered suite; the two are now merged.
Namespaced ids and the sweep
A definition never expires. Once something is defined it stays defined for the rest of the
session, and core/event walks the live registry on every scan. A run that defined
throwaway content therefore has to take it back out again, or pressing the key ten times
would leave ten runs' worth of it behind.
So every id a check creates comes from support.id, which mixes in a per-run counter:
support.id("pal") -- "palforge_test:pal_1"
support.id("pal") -- "palforge_test:pal_2"
support.NAMESPACE -- "palforge_test"
support.isTestId("palforge_test:pal_1") -- true
support.isTestId("ChickenPal") -- falseThe counter never resets inside a session, so running the suite ten times never collides with itself.
support.sweep() walks every object_manager.TYPES entry, unregisters the ids that pass
isTestId through object_manager.unregister, and returns the count. test.run calls it
after the last suite, and the suites that define heavily — schema, pal, item, skill
and effect — also call it as their own teardown, so the registry never carries more than
one suite's worth at a time:
[PalForge.test][info] swept 95 test definition(s) after [pal]
[PalForge.test][info] swept 127 test definition(s)Because the check is a prefix match on palforge_test:, the sweep can never touch real
content — not the native catalogs, not your pack. Pressing the key repeatedly leaves the
registry exactly as it found it.
Support helpers
test/support.lua holds the things a suite running inside a game needs. Every case file
requires it except test/cases/native.lua, which needs no world and creates no ids of its
own: it reads catalogs the kernel already loaded and materialises handles for real game rows.
Reporting:
support.announce("half way through") -- on screen via SendSystemAnnounce, prefixed [PalForge]
support.log("give Wood x3 -> 135 -> 138") -- UE4SS.log under [PalForge.test]announce is wrapped in a pcall and needs both PalUtility and a live
PalPlayerCharacter. With no world it does nothing at all, quietly, so you can call it
without checking first.
World reads, all of them nil-returning rather than throwing:
support.player() -- the local pawn, or nil
support.location(actor) -- { x, y, z }, or nil
support.inFront(600.0, 50.0) -- a point 600cm ahead of the player and 50cm up, or nil
support.nearestPal(coord) -- { actor, pos, dist, count }, or nil
support.nearbyPal() -- the nearest PalMonsterCharacter, and its class nameinFront is where a spawn check puts things so they land visibly in front of you rather
than inside you; it falls back to the +X axis when the pawn's forward vector is
unreadable. nearestPal enumerates PalCharacter and reports the closest one to a
coordinate with its distance and the total count. A spawn check uses it to confirm the game
can still list its characters around the point it asked for.
:spawn returns before the pal exists, so the pal suite does not try to assert an arrival. It
checks that the call was issued, and then that nearestPal can enumerate the world around the
point asked for — which is what the deferred pass uses to find the creature and report it.
Real game ids the suites lean on, so a live check exercises content the game actually has:
support.GAME = {
pal = "ChickenPal",
pal2 = "SheepBall",
item = "Wood",
consume = "Berries",
building = "WorkBench",
palbox = "PalBoxV2",
bgm = "AKE_BGM_Title",
}What a run leaves behind
The sweep un-registers definitions. It cannot un-do what a live check did to your save, and the world checks are written with that in mind — but not all of it is reversible.
- Spawned pals stay.
palasks for aChickenPalin three of its live checks, and each one turns up a few seconds after the run has finished. Nothing in the tree can despawn them. - Given Wood is handed straight back.
itemgives threeWoodand consumes the same three, so a clean run leaves your inventory where it found it. If the give landed and the take did not — a player with nothing equipped cannot take — the three Wood stay in your bag. - Nothing is placed, worn or built. The mesh and pal render checks point at a model path
that deliberately will not load, so the backend bails before it touches the player's mesh
component. The building suite is read-only over what you have already built, and never
calls
inst:save(). The UI suite stops short of constructing a widget when aPalPlayerControllerexists. - Effects clean up after themselves. The one live effect check applies to the pawn under
a
pcalland removes it again. It declares nonativeStatus, so no ailment of the game's own is switched on — an effect that named one would take it off again on the way out. - The suite never writes to a save on purpose. Everything that does is a declared hook,
behind
env.debugHooks, and the F1 suite skips its half with the hook's name attached.
Run the world-touching suites in a throwaway save, not the one you care about. Three chickens is a small mess, but it is a mess, and the framework makes no promise to clean it up — and they turn up seconds after the run says it is done.
A green run does not mean every call in the api does the whole job its name suggests. Three of them reach further, or later, than the name suggests, and the suites pin that down on purpose:
Pal.Handle:spawnanswers about the call, not the creature. The pal is four to eight seconds behind it, so no assertion here can be about arrival — the log is where that lands.Audio.Handle:setVolumeis actor-wide. It scales everything one actor is emitting rather than the sound you called it on, so the checks treat it as a property of the actor.Audio.Handle:stopis actor-wide too. It issuesStopSoundByActor, so it silences everything on that actor rather than the one sound you called it on — a handle for an id that was never defined stops the actor just the same. Play sounds you need to stop separately on separate actors.
Summary
- Press F1 in game.
0 failedon the screen line means your setup works. - Press it twice: once at the title screen, once in a save. 31 checks only run in a save and five only run without one, so no single press measures everything — and the summary says which.
npm testruns the same 612 checks with no game at all: 576 passed, 0 failed, 36 skipped. Throughinstall(), which is the F1 route, it is 577 / 0 / 35 plus the boot bundle's8/0/0.- Skips carry a direction. Only
failedmust stay at zero. test.run("pal")runs one suite, andtest.bind("F11", fn)puts anything you like on a key — afterpf_keyshas told you the key is free.- The measurements that need Palworld running are declared hooks under
test/hooks/: loaded only underenv.debug, run by name withpf_hooks/pf_hook <id>/pf_hooks_all, and the nine that write into a save needenv.debugHooks[id] = trueon top —tools/deploy.sh --writesturns all nine on at once, for a throwaway save. - One tree, five corners —
units/,cases/,hooks/,probes/,tools/— and--releasedeletes the lot, so none of it reaches a player. - Every command is reachable three ways: the UE4SS console (which ships switched off), a key,
or a line in
Scripts/palforge/autorun.txtthat runs onworld.ready. - Checks that need a world start with
support.needWorld(t), so they skip instead of failing. - Give every id you make up in a check
support.id(...), and the sweep removes it afterwards. - Run the world-touching suites in a throwaway save: the pals a live check spawns stay in the world, and they arrive after the run has finished.
Next, read Building a content pack to write the content these checks are there to protect.