- Python 59.8%
- Lua 38.5%
- Shell 1.2%
- C 0.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| assets | ||
| tests | ||
| tools | ||
| ue4ss | ||
| vendor-patches | ||
| .gitignore | ||
| build_eggs.py | ||
| build_lua.py | ||
| build_mod.py | ||
| build_recipes.py | ||
| package.py | ||
| README.md | ||
| recipes.py | ||
| setup.sh | ||
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
Versionstring changes, soVersionis 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.)--debugsetsDebugModeto 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_CharacterTeamMissionFunctionsonly 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 onSaveParameter.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 —
DefeatDarkIslandBossgates 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 asbyte != 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:
- 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. NamesReferencedFromExportDatawas 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_TowerandLazyDragon_Electric_Tower(the tower-arena versions of Grizzbolt and Orserk) have real Paldex numbers, so they are included. Remove them from the filters inbuild_mod.pyif 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.