No description
  • Python 59.8%
  • Lua 38.5%
  • Shell 1.2%
  • C 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-15 19:10:13 +00:00
assets Take the thumbnail from any image format, converting at build time 2026-08-14 13:31:39 -06:00
tests Open the last 21 Pals by authoring recipes, not by widening the pool 2026-08-15 13:07:48 -06:00
tools Open the last 21 Pals by authoring recipes, not by widening the pool 2026-08-15 13:07:48 -06:00
ue4ss Add the probe that found where the breeding pool actually lives 2026-08-15 13:07:58 -06:00
vendor-patches Palworld mod: configurable Pal acquisition + tower-boss-free tech tree 2026-08-09 12:31:46 -06:00
.gitignore Add UE4SS runtime mod with a player-editable config 2026-08-09 13:27:21 -06:00
build_eggs.py Open the last 21 Pals by authoring recipes, not by widening the pool 2026-08-15 13:07:48 -06:00
build_lua.py Open the last 21 Pals by authoring recipes, not by widening the pool 2026-08-15 13:07:48 -06:00
build_mod.py Open the last 21 Pals by authoring recipes, not by widening the pool 2026-08-15 13:07:48 -06:00
build_recipes.py Open the last 21 Pals by authoring recipes, not by widening the pool 2026-08-15 13:07:48 -06:00
package.py Describe the mod that exists now 2026-08-15 13:07:58 -06:00
README.md Describe the mod that exists now 2026-08-15 13:07:58 -06:00
recipes.py Open the last 21 Pals by authoring recipes, not by widening the pool 2026-08-15 13:07:48 -06:00
setup.sh Palworld mod: configurable Pal acquisition + tower-boss-free tech tree 2026-08-09 12:31:46 -06:00

PacifistPal

A Palworld mod that makes every Pal obtainable from world eggs or breeding, removes tower-boss prerequisites from the technology tree, and replaces the same prerequisite on expeditions with a character level.

Expeditions are the one part that writes to your save, because that is where their lock lives. Everything else is a runtime lookup that disappears with the mod.

Built and verified against Palworld build 24467282 (Steam, 1.0).

What it changes

Technology (DT_TechnologyRecipeUnlock_Common) — 17 technologies had a RequireDefeatTowerBoss prerequisite; all are cleared, including the Egg Incubator, Breeding Farm, both weapon slots and the Mass Hatching Incubators.

Expeditions (PalGuildCharacterTeamMission.ReleasedMissionInfos) — all 18 are locked behind a tower boss. Each opens at a character level instead, from 10 for Verdant Hollow to 80 for the World Tree and the VeryHard tier, and the menu's lock line says so: "Defeat the Boss at Rayne Syndicate Tower, or reach level 10". This is the one change that writes to your save: the lock lives in saved guild state, the same state beating a tower sets, so an expedition opened this way stays open even if the mod is removed. It only ever grants — a mission below its level is left exactly as found, so a tower you beat early keeps its expedition. Editing the DataTables instead does not work; see The expedition gate.

Breeding (DT_PalCombiUnique) — 21 Paldex Pals cannot be produced by the game's breeding formula at all: the legendaries, the raid bosses, the tower-boss partners. Each gets an authored pairing appended to the recipe table, which is the one breeding path that reaches them. IgnoreCombi, the flag that looks like the switch for this, is read once into a native structure that no data edit reaches — see Breeding is read once, at load. By default the flag is cleared for the 10 that a world egg can already produce, so the two routes agree; the 20 that no egg produced get the egg route instead and stay out of the breeding pool. Add "breed" to any of them to clear the flag too — Jetragon, Frostallion (+ Noct), Paladius, Necromus, Bellanoir (+ Libero), Blazamut Ryu, Shadowbeak and the rest are all listed. Encounter-only rows (BOSS_/GYM_/RAID_), Oil Rig duplicates and unused internal entries are deliberately left alone.

World eggs — untouched. Earlier versions added 54 Pals to the spawner pools. That is gone: vanilla's fourteen spawners already hatch 237 Paldex Pals, which is every seed the recipe sheet needs, and seeding the legendaries into egg pools let a lucky egg skip the entire breeding tree. Dropping it also removed the only part of the build that rewrote whole Blueprint packages, and the only route never confirmed in game.

The guarantee is a build step. tools/closure.py walks the graph — egg seeds, then ordinary breeding, then authored recipes, then area access — and build_mod.py refuses to write a pak unless all 289 counted Pals come out reachable. See The closure check.

Configuration

There is no per-Pal configuration any more, and that is the point: the routes are a design, not a set of switches. The old PacifistPal.toml and the pals block in config.lua are gone with the two features they drove.

What is editable:

  • recipes.py — the breeding sheet: 19 authored pairings, each tagged with a tier and a one-line note on why it reads as sensible. Parents may be written as an in-game name ("Jormuntide") or an internal id ("Umihebi").
  • config.lua — technology and expeditions, applied at runtime, so editing it and restarting Palworld is enough.

The recipe sheet

Four tiers, checked at build time rather than asserted:

Tier Rule the build enforces
FLAVOUR no gated parent — reachable in an ordinary playthrough
WORLD_TREE at least one parent hatches only from World Tree eggs
LEGENDARY at least one parent is a rarity-20 legendary
KEY opens an area gate, so it may not depend on that area
ROOT the way into the legendary line; takes no legendary parent

The tier check is not decoration. The first draft of the sheet labelled four recipes FLAVOUR that quietly leaned on Lyleen, Lyleen Noct, Faleris and Selyne — all World Tree exclusives — which would have locked half the mid-game behind the endgame area.

The closure check

tools/closure.py computes what a player can actually obtain: world egg seeds, everything ordinary breeding reaches from them, everything the recipes add, to a fixpoint. build_mod.py fails the build if any Paldex Pal falls outside it.

It also models area access. Confirmed in game: the World Tree transporter only runs with BOSS_KingWhale_otomo — the scripted companion Panthalus — in the party or Palbox. Neither the ordinary #203 nor the plain BOSS_KingWhale alpha opens it; both were bred and tried. So seeds from that pool are held back until that exact row is reachable, which also makes circular gates impossible: a door that can only be opened from the far side never opens, and the Pals behind it are reported unreachable.

Reachability: 289 of 289 Pals obtainable from a world egg by breeding
access gates opened: [('worldtree', 'BOSS_KingWhale_otomo')]

Releasing

python3 package.py builds everything and assembles an archive at dist/PacifistPal-<date>.zip. It holds the package itself plus INSTALL.md, an install.sh that copies it into the Steam workshop content directory, and an uninstall.sh (status / remove) — no Python, git, or build step at the player's end beyond a shell.

The in-game mod list (Palworld's own loader)

Palworld's Options → Mod Management screen does not scan the game folder. It scans the Steam workshop content directory for packages — folders holding an Info.json — and installs the ones the player ticks. Subscribing on the Workshop is only how such a folder usually arrives; a hand-built one is listed identically, with no Steam upload involved.

python3 package.py --workshop              # stage the package under dist/workshop
python3 package.py --workshop --install    # and copy it into the workshop dir
python3 package.py --workshop --uninstall  # remove it, and what it deployed

--uninstall follows Mods/ManagedMods/PacifistPal/InstallManifest.json, the loader's own record of every file it copied, so removal is exact rather than a guess at where things landed. It clears the ActiveModList entry too: left behind, it points the loader at a package that is no longer there.

The package holds both halves under one Info.json: a Paks install rule for ./Paks — the whole folder, so every pak the build produced goes in — and a Lua rule for Scripts/. No enabled.txt — Palworld's own Mod Management enables the mod, writing ActiveModList=PacifistPal into Mods/PalModSettings.ini.

The loader installs the two halves to Paks/~WorkshopMods/PacifistPal/ and Mods/NativeMods/UE4SS/Mods/PacifistPal/. Anything left in the older locations — Paks/~mods/, a hand-placed Pal/Binaries/Win64/ue4ss — loads on top of that, so clear it out.

Two more things worth knowing:

  • The Lua half needs a Workshop UE4SS item, which the loader installs to Mods/NativeMods/UE4SS.
  • The loader only re-copies a package when the Version string changes, so Version is the build date plus a digest of the package's own contents — 2026.08.12-8cc4a4e0. It moves whenever a file or a metadata field does, and stays put when a rebuild changes nothing. (A bare date would be a trap: two installs in one day, and the second is ignored with the game still running the first one's files.) --debug sets DebugMode to redeploy every launch regardless.

Dependencies resolve by PackageName, and the UE4SS rehosts on the Workshop do not agree on one: UE4SS Experimental (Palworld) calls itself UE4SSExperimentalPW, which is the default here because it carries the experimental-palworld build this mod is written against. --ue4ss-package NAME names a different one. After packaging, the workshop directory is checked for a package providing that name, and what is installed there is listed if it is missing — a dependency naming nothing leaves the Lua half silently inert.

An unpublished package still needs an identity: the loader ties a folder to an item through its name and a .workshop.json, and records the result as WorkshopId in Mods/ManagedMods/<PackageName>/InstallManifest.json. Without those, the mod is listed but its checkbox does nothing. So the folder is named after an id — LOCAL_WORKSHOP_ID, outside Steam's range, until --workshop-id supplies a real one — and a matching .workshop.json ships beside Info.json.

--min-revision N sets the minimum game revision (the last 5 digits of the version on the title screen), defaulting to the build the official loader arrived in. assets/thumbnail.png is used for the list icon if present, and a generated placeholder egg otherwise — Steam rejects thumbnails of 1 MB or more.

Editing the deployed Scripts/config.lua under Mods/NativeMods/UE4SS/Mods/ does not stick, unlike the manual install: the loader overwrites it on the next redeploy. recipes.py is the source of truth for breeding, and it is a build-time input — nothing reads it at runtime.

Publishing to the Workshop later needs only the official Palworld Mod Uploader (in the Steam library) to mint a Steam item ID; the package format is the same, so nothing has to be rebuilt for it.

Install

One command builds everything — the recipe pak and the Lua mod's config — and refuses to write a pak the closure check does not pass.

./setup.sh                               # once: fetch and build dependencies
python3 build_mod.py                     # eggs pak + regenerated Lua config
python3 package.py --workshop --install   # package it, drop it in the workshop dir

Then enable it under Options → Mod Management. Removing it again:

python3 package.py --workshop --uninstall
Feature Handled by To change
Technology Lua mod config.lua; defaults in build_lua.py
Expeditions Lua mod (writes your save) Levels in config.lua; defaults in build_lua.py
Breeding PacifistPal_Recipes_P.pak Edit recipes.py, rebuild, reinstall
World eggs nothing — vanilla pools are the seeds n/a

Editing the deployed Scripts/config.lua works for technology and expeditions until the loader next redeploys the package over it. Breeding is baked into a pak and cannot be changed by editing config.lua at all: the sheet lives in recipes.py and needs a rebuild. Their defaults — including every expedition's unlock level — are written by build_lua.py.

Existing saves are fine: every change is a lookup the game performs at runtime, not save data. Pals already hatched or bred are unaffected, and so is the set of bosses you have already beaten.

Requirements

Palworld's own mod loader, and the Workshop's UE4SS Experimental (Palworld) item, which the Lua half depends on. Subscribe to it and the loader installs it to Mods/NativeMods/UE4SS itself — no dwmapi.dll to place, and no WINEDLLOVERRIDES launch option, on Windows or Linux.

The Lua mod

ue4ss/PacifistPal/ patches the loaded DataTables at launch, so technology, expeditions and breeding need no baked assets and no game data is redistributed for them.

python3 build_lua.py    # regenerate Scripts/config.lua from the TOML

Re-running is safe: the expedition pass reads the guild's existing array first and uses it as the idempotency guard, so repeated passes never duplicate an entry and a mission already open is left alone.

World eggs: why they are not touched at all

Two independent reasons, and the second one only became clear once breeding worked.

The mechanical one: spawner class-default objects do not load reliably from Lua. In a 90-second session with LoadAsset issued for all fourteen, one became reachable. A pak avoided that by editing the asset on disk — but a pak that rewrites whole Blueprint packages was also the riskiest thing in the build, and the only route never confirmed in game.

The design one: seeding the legendaries into egg pools let a lucky egg skip the entire breeding tree, which is the content. Vanilla's own pools already hatch 237 Paldex Pals, and the closure check proves that is enough to reach all 289 by breeding. So the whole feature came out, and the reachability guarantee did not move.

Breeding is read once, at load

Breeding lived in the Lua mod for a long time and looked fine from the log:

[PacifistPal] made 20 Pal rows breedable

That line counts rows written, which turned out to say nothing about rows read. Three pairings, bred in game with the mod loaded and reported by a player, all came out exactly as vanilla:

Pairing Target rank With the flag cleared Vanilla Actual
Aegidron x Sekhmet 450 Anubis Anubis Anubis
Petallia x Smokie 1740 Mimog (1740) Slowatt (1750) Slowatt
Anubis x Dupin 500 Faleris (500) Dualith (510) Dualith

The last two land on a freed Pal's CombiRank exactly and still produced the Pal you get when that row is absent from the pool. The technology half, which uses the identical ForEachRow write on a different table, does work in game — so the write lands, and the game simply is not reading the row again. It builds its breeding pool once, while loading, and keeps the answer.

That is the same shape as the egg spawners, and it takes the same fix: bake the flag in, where it is true before the game starts. build_breeding.py clears one byte per Pal in both DT_PalMonsterParameter and its _Common parent. The composite is a CompositeDataTable naming _Common as its parent, but the cook bakes a full copy of all 753 rows into each, and the composite is the one the game queries.

Sub-species are a separate gate, and the flag is not it. The ordinary formula takes the parents' average CombiRank and picks the nearest primary Paldex form. Forms with a letter after the number — Faleris Aqua is #188B — are never that answer, so clearing IgnoreCombi cannot put one in reach of an arbitrary pairing; they come only from their own row in DT_PalCombiUnique. Those rows ignore IgnoreCombi entirely, which is why Frostallion + Helzephyr produces Frostallion Noct in a completely unmodded game. Of the 10 Pals the default config opens, Horus_Water and LilyQueen_Dark are sub-species and gain nothing from the flag: both already had a working unique pairing.

This also explains why the first pairing above proves nothing. Aegidron x Sekhmet averages to 450, which is Faleris Aqua's rank exactly — but Faleris Aqua is a sub-species, so the real answer is the nearest primary form, Anubis at 480, with or without the mod. (Azurmane, at 420, is equally far and loses on CombiDuplicatePriority.)

The expedition gate

Expeditions are character team missions in the data, and the gate is spread over two tables. Each of the 18 mission rows names its prerequisites by row name:

Dungeon_Grass       ReleaseCondition None                ChallengeCondition DefeatGrassBoss
Dungeon_GrassHard   ReleaseCondition DefeatGrassBoss     ChallengeCondition DefeatGrassBossHard

and the condition table spells out what each name means — DefeatBossType (EPalBossType, the same enum technology rows use), DefeatBossDifficulty, and DefeatHardBossNum for the VeryHard tier, which asks for a boss beaten on Hard as a count rather than a type.

The mod cleared both ends — blanking the mission rows' condition names and zeroing the boss and hard-boss count on every condition row — on the reasoning that whichever end the game consulted, it would find no prerequisite.

That reasoning was wrong, and an in-game test proved it. Expeditions stayed locked, and the menu's condition line changed from "Defeat tower" to 塔を1つ攻略する(仮テキスト) — "clear 1 tower (placeholder text)", an untranslated developer string.

Both halves of that result are explained by one struct:

PalCharacterTeamMissionInfo
    MissionId          Name
    bEnableChallenge   Bool                              <- the lock, already decided
    MasterData         Struct(PalCharacterTeamMissionMasterData)   <- a COPY of the row
    RewardStaticItemIds Array<Name>

The game evaluates each expedition natively and snapshots the answer into bEnableChallenge, alongside a copy of the master data rather than a reference to it. Editing the DataTable afterwards cannot move a boolean that has already been computed. The menu's condition text, by contrast, does re-read the condition table live — which is why the boss name disappeared the moment the mod blanked DefeatBossType, and fell back to the placeholder.

So the edit is not merely ineffective, it is worse than vanilla: the condition rows end up naming boss None, which no tower can ever satisfy, so beating the tower stops working as an unlock too. Hence the feature ships off.

The lesson generalises: the technology tree is patchable this way because it queries DT_TechnologyRecipeUnlock at the moment it needs an answer. Whether a table is read live or snapshotted is not visible in the table itself, and only a play session can tell you which.

DefeatBossDifficulty was left alone throughout: with no boss and no count to satisfy, it had nothing left to qualify.

What would have to happen instead

The lock lives in bEnableChallenge, so any working approach has to reach that rather than the tables:

  • Set the flag on the live info objects once the game has built them, and keep setting it — anything that rebuilds the list would overwrite it.
  • Hook the native function that computes it, and force its result. This is the cleanest option if the function is reachable as a UFunction; BP_CharacterTeamMissionFunctions only does the strength maths, so the check is not in Blueprint.
  • Write the defeated-boss record in the player's save data so the vanilla check passes on its own. This one does touch save state, unlike everything else this mod does, and would need saying so loudly.

None of these is implemented.

The level gate

Neither table has a level field — the mission row carries times, strengths and condition names, the condition row a boss, a difficulty and a count. So a level gate cannot be expressed as data at all: the mod has to hold the missions shut and open each one as the player reaches its level.

That forces three things:

  • Polling. Nothing tells Lua that the player levelled up, so the gate is re-evaluated every 30s for as long as anything is still locked, and stops once everything is open. The level itself comes from FindAllOf("PalIndividualCharacterParameter") filtered on SaveParameter.IsPlayer — players and Pals share that class, so the save parameter is what tells them apart, and a level-99 Pal standing next to a level-10 player must not open anything.
  • Order between the two ends. Condition rows are shared — DefeatDarkIslandBoss gates six missions — so a row may only be neutralised once every mission naming it is open. Until then the open ones rely on their cleared name alone, and the shared row stays intact so it can keep gating the missions that have not been reached yet. Mission names are therefore the per-mission lever; condition rows are the endgame tidy-up.
  • Failing closed. If no player level can be read — the main menu, the first seconds of a world load — nothing opens, rather than an unreadable level being taken for a low one that happens to clear a threshold.

Two limits worth knowing. The gate is global, not per-player: the tables it lives in are shared, so on a co-op world the highest player level present is what counts. And the in-game lock text still reads "Defeat boss" for an expedition below its level, because the UI takes that string from the condition row the mission still names — which, for a level-locked mission, is still true: beating that boss does open it.

A station caches its mission list when it is created, which is during world load — before the guild array can be patched, since the guild does not exist that early either. So the first working build unlocked expeditions only on a station built after the patch: one already standing kept showing the old locks until it was demolished and rebuilt. Writing the array is therefore only half the job; standing stations are told to re-read afterwards by calling their own OnRep_State and OnRep_TargetMissionId, which re-broadcast values that have not changed, so the only effect is the redraw. Whether the guild's OnRep_ReleasedMissionInfos call lands is logged rather than swallowed, since a silent failure there looks exactly like a stale station.

The lock's own words

A locked expedition reads "Defeat the Boss at Rayne Syndicate Tower", which stops being the whole truth once a level opens it too. The mod hooks the widget's own SetupDisabledMissionInfo — a Blueprint function, so it crosses the script VM where UE4SS can reach it — and appends to the sentence the game already wrote:

Defeat the Boss at Rayne Syndicate Tower, or reach level 10

Appending rather than composing keeps the tower half in the game's own wording and language; only the added clause is English. The clause itself is the idempotency guard, since the hook fires on every redraw and a sentence must not collect one per frame. show_level_in_lock_text = false restores the vanilla line.

Getting there took four wrong attempts, all of them the same mistake in different clothes — assuming a binding convention instead of asking:

Attempt Error What it actually meant
FindRow(FName(...)) crash, twice FindRow takes a string; the mod's own code always did
type(FText) == "function" test skipped FText is callable userdata, exactly like FName
widget:GetMissionID() expected 1 parameters, received 0 out parameters count toward arity
widget:GetMissionID(FName(...)) no table was on the stack UE4SS fills an out parameter by writing into a Lua table you pass in

The working form is local out = {}; widget:GetMissionID(out), with the id arriving as out.MissionID, keyed by the Blueprint's pin name. The same arity rule fixed the station refresh: OnRep_State is a replication handler, so it takes the property's previous value, and calling it bare had been failing silently behind a counter that only said "2 refresh call(s) failed".

The lesson for the mocks is sharper than the lesson for the code. Each wrong attempt shipped with a passing test, because the mock implemented whatever the assumption was. The mocks now assert the game's rules — a bare call raises an arity error, an out parameter must be handed a table and is written into rather than returned — so reverting either fix fails five checks instead of none.

One bug worth recording, because the test suite could not have caught it. ForEachRow hands the callback an FName, not a string, so unlockLevels[name] missed every key and read as "no level required" — the in-game log said 18 open, 0 still level-locked (player level 38) when it should have opened three. The mock passed strings, which is exactly the seam the tests warn about: they validate our logic, not UE4SS's bindings. The mock now hands FNames to the expedition tables, and reverting the fix makes it fail with the same 18/0 the game reported.

The mod reads CharacterMaxLevel off BP_PalGameSetting and warns about any unlock level above it, since such a level can never be reached and would leave that expedition on its boss gate forever. Palworld 1.0's cap is 80.

Composite tables

Both DT_PalMonsterParameter and DT_TechnologyRecipeUnlock are CompositeDataTables that aggregate a _Common parent. The composite is what the game queries, and it copies rows from its parent at load — so a runtime patch to the parent alone does nothing. The Lua mod patches the composite (and the parent, so they cannot disagree). The pak build is unaffected: it edits the parent's serialized bytes before load, so the composite is built from them.

Tests

tests/test_lua_mod.lua runs main.lua against a mock UE4SS environment populated with real rows exported from the game tables, and asserts the right rows changed and nothing else did:

python3 tests/gen_fixture.py    # once: derive fixture data from the game
lua5.4 tests/test_lua_mod.lua

The fixture is generated rather than committed: it is derived from the installed game and tracks its version.

The expedition checks run the mock player up from level 55 to 80 and re-drive the mod's own timer, so they cover the gate opening mid-session as well as the shared-condition rule — a condition row must not be cleared while a still-locked mission names it.

It validates the mod's logic, not UE4SS's bindings — only that we drive them correctly.

Confirmed in-game

Running under UE4SS experimental-palworld on Linux/Proton:

[Lua] [PacifistPal] cleared tower-boss requirement from 34 technology rows
[Lua] [PacifistPal] expeditions: 4 open (0 added), 14 still level-locked (player level 47)

34 is exactly what tests/test_lua_mod.lua predicts (17 technologies across the composite and its parent), which cross-checks the object paths and the composite reasoning against the real game.

Breeding was confirmed separately, by hatching eggs:

Recipe Result
a probe pairing → JetDragon hatched, proving authored recipes work at all
Whalaska + Jormuntide → BOSS_KingWhale hatched — breeding can produce encounter-only rows
Whalaska + Jormuntide → BOSS_KingWhale_otomo hatched, and opened the World Tree transporter

The other sixteen recipes use the identical code path and are verified byte-for-byte in the pak, but have not each been hatched.

How it works

Palworld cooks its packages with unversioned properties, so property names and types are absent from the assets and a .usmap is required to interpret them. The toolchain here is self-contained Python plus one C++ dependency:

File Purpose
tools/pak.py Reads UE5 .pak v11 archives (index walk, entry extraction)
tools/pakwriter.py Writes the mod _P.pak (uncompressed entries)
tools/oodle.py Oodle decompression via vendor/ooz
tools/uasset.py .uasset summary, name map, imports, exports
tools/usmap.py .usmap mappings parser (names, enums, struct schemas)
tools/datatable.py Unversioned property decoding for UDataTable exports
tools/eggspawner.py Reads the egg spawner Blueprint lottery arrays
tools/geo.py Boss spawn points, and where each egg spawner sits on the map
tools/repack.py Grows a package's name map and fixes up every offset

Two details worth knowing if you extend this:

  • Schema order is derived-first. UE walks the property chain from the most derived struct upwards, so a struct's own properties precede inherited ones. Getting this backwards silently misaligns every value.
  • The technology and breeding edits are in-place byte patches. Both values already occupy a byte in the stream, so writing zero (EPalBossType::None, false) leaves every offset and size identical and no header is rewritten. A false bool would normally be encoded by dropping its byte and setting a bit in the block's zero mask, which moves every row after it; UE reads a bool that is present as byte != 0, so zeroing it in place says false just as well. Only the egg Blueprints need real re-serialization.

Finding your Palworld install

Nothing hardcodes a game path. tools/gamedir.py walks Steam's own libraryfolders.vdf across the usual roots (native, flatpak, macOS, Windows), so second drives and non-default libraries are found automatically. Override it for anything unusual:

export PALWORLD_DIR=/mnt/games/SteamLibrary/steamapps/common/Palworld

python3 tools/gamedir.py prints what it resolved. If it cannot find the game it lists every path it tried rather than silently using a wrong one.

Dependencies

Building needs git, gcc, curl, and Python ≥ 3.11 (tomllib), plus lua5.4 if you want to run the tests. setup.sh is bash; on Windows use WSL or Git Bash.

vendor/ooz is the open-source Oodle decompressor; Palworld statically links Oodle so no oo2core library exists on disk to borrow. Build it with:

cd vendor/ooz && g++ -O2 -fPIC -shared -msse4.1 -fpermissive -w \
    -o libooz.so kraken_lib.cpp lzna.cpp bitknit.cpp

vendor/mappings/Mappings.usmap comes from the community PalworldModding/UsefulFiles repository (updated for 1.0).

Verification

build_mod.py self-checks as it runs. The strongest check is in the egg step: re-encoding each untouched lottery array must reproduce the original bytes exactly before any Pal is added — if the encoder disagrees with the game's serializer by a single byte, the build stops.

After building, every rebuilt Blueprint is re-parsed to confirm the name map grew correctly, TotalHeaderSize matches the file, export offsets and sizes still tile the .uexp, and all Pal names resolve.

Static analysis only. None of this proves the game accepts the result — and it didn't the first time. See below.

Fixed: crash when loading a world

An early build crashed with EXCEPTION_ACCESS_VIOLATION on world load. Two defects in repack.rebuild were responsible:

  1. The import table offset was never shifted. Offsets were bumped only when value > insert_at, but the import table begins exactly at the end of the name table — the insertion point. It kept its old offset and the game read the import table out of the middle of the newly inserted names, producing garbage class references. This was almost certainly the crash.
  2. NamesReferencedFromExportData was not widened. That field bounds the leading slice of the name table export data may reference; appended Pal names fell outside it.

Both are fixed, and tools/verify_repack.py now runs as part of every build: it diffs each rebuilt package against the original and fails the build if any byte changed that wasn't meant to. That check is what caught defect 1 — the targeted assertions before it all passed, because import_count is read from the summary and only the offset was wrong.

That machinery now has one user. PacifistPal_Recipes_P.pak appends rows to DT_PalCombiUnique: 23 bytes each, cloned from a shipped row so the property header is the cooker's own, with NumRows bumped and the export grown through repack.rebuild. The egg pak that used to rewrite whole Blueprint packages is gone, so nothing in the build re-serializes a package any more.

Multiplayer needs the mod on the server too. These changes alter DataTables and Blueprint defaults the server is authoritative for. On a dedicated server the same package must be installed server-side, which needs an InstallRule carrying "IsServer": true; for co-op the host needs it. A client-only install is expected to misbehave against an unmodded server regardless of the crash above.

Known quirks

  • GrassPanda_Electric_Tower and LazyDragon_Electric_Tower (the tower-arena versions of Grizzbolt and Orserk) have real Paldex numbers, so they are included. Remove them from the filters in build_mod.py if you would rather they stay boss-only.
  • Rebuild the mod after a game update: a patch can change the data tables, and the mappings file may need refreshing too.