Skip to content

Latest commit

Β 

History

8 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

palworld-server-tooling

Operational tooling for a self-hosted Palworld dedicated server on Linux, plus the repair scripts written while migrating a local co-op world onto it.

The server runs 24/7 as its own palworld system user out of /srv/palworld, deliberately isolated from /home so a network-facing game binary cannot read anything in a home directory.

Two halves, and they are independent:

  • Running the server β€” systemd units, nightly backup+restart, steamcmd updates, performance tuning. This half is live.
  • Migrating a co-op world β€” a one-time job that is done. Kept because the failure modes are badly documented elsewhere and the scripts are reproducible. Needs a vendored library and a compiled codec that the server half does not.

Layout

path what
staged/ systemd units and helpers installed to /srv/palworld β€” service, nightly backup+restart timer, steamcmd updater, Engine.ini net-tuning fragment
import-world.sh installs a converted co-op world over the server's live world, with backup and rollback
fix-stale-handles.py repairs the stale owner handles a co-op→dedicated conversion leaves behind (base Pals refusing work)
apply-save-fix.sh runs the patcher against the live save: stop, back up, patch to temp, verify, install, start
check-progress.py read-only: proves a migrated Players/*.sav still carries waypoints, bosses, quests, Paldeck
docs/ the same diagnostics as a static GitHub Pages site, running client-side
evidence/ read-only scripts that reproduce the claims the tooling makes β€” run these before trusting a comment that says "do not touch this"

world-import/ (the migration working set) and tools/ (vendored library) are gitignored β€” see the migration half.


Running the server

Needs nothing from the save-repair half β€” no palsav, no compiled codec.

# System user with /srv/palworld as its home, no login shell.
sudo useradd --system --home-dir /srv/palworld --create-home --shell /usr/sbin/nologin palworld

# Helpers and the net-tuning fragment live next to the install, not in /home:
# ProtectHome=true means the service could not read a home directory anyway.
sudo cp staged/{backup.sh,update.sh,engine-net.ini} /srv/palworld/
sudo chown palworld:palworld /srv/palworld/{backup.sh,update.sh,engine-net.ini}
sudo chmod +x /srv/palworld/{backup.sh,update.sh}

sudo cp staged/palworld.service staged/palworld-maint.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now palworld.service palworld-maint.timer

update.sh runs from ExecStartPre on every start, so the first start also bootstraps steamcmd and installs app 2394010. That download is ~8GB, hence TimeoutStartSec=3600; the - prefix means a Steam outage comes up on the installed version rather than refusing to start.

The server writes PalWorldSettings.ini on first run β€” edit it after that first start, then restart. See Notes for why WorldOption.sav must never be imported over it.

Nightly at 05:00, palworld-maint.timer tars the save dir (keeping 7) and then restarts the service. One unit, in that order, so the newest tarball is always the state the server came back from.

Performance tuning

A dedicated server that is "noticeably but not unplayably laggy" is usually starved rather than misconfigured. Everything below was measured on a 4c/8t 2.1GHz mobile chip (Ryzen 5 3550H, 13G RAM) that also runs other workloads. Each knob records its own reasoning in staged/palworld.service.

symptom cause found fix
stutter spikes 597MB of a 447MB-RSS PalServer in swap, 8.4G of the box free, 66M major faults since boot MemorySwapMax=0
general slowness under load default CPUWeight=100 β€” the game server competing on equal footing with background builds, at 15-min load 5.27 CPUWeight=10000, IOWeight=10000
frame spikes cores idling at 1.65–2.3GHz with the server live; schedutil ramps after it sees load, and the tick is bursty and single-threaded ExecStartPre pins the cpufreq governor to performance (2.6–3.0GHz)
Pals and structures popping in late UE's compiled-in MaxDynamicBandwidth=7000 bytes/s per client β€” under a tight budget UE does not drop replication, it defers it by actor priority staged/engine-net.ini

Two counter-intuitive results worth keeping:

  • A low Memory: figure in systemctl status can be the symptom. This server read 469MB after 7h uptime and looked healthy; it was being reclaimed and faulting the difference back off disk continuously. With swap forbidden it settles ~6G resident, which is what it actually wants. Watch major faults, not RSS. The trade-off: the memory leak now hits MemoryMax and gets OOM-killed instead of swapping, which Restart=always turns into a ~15s blip.
  • power-profiles-daemon is not the governor knob here. On this box it offers only balanced/power-saver behind a placeholder platform driver, so powerprofilesctl set performance fails outright.

engine-net.ini targets Saved/Config/, not Pal/Config/DefaultEngine.ini. The latter is a Steam depot file and update.sh runs app_update … validate on every start, which would restore it and silently drop the settings on every boot. Saved/ is not in the depot. Because UE also rewrites Saved/Config on shutdown, palworld.service re-asserts the block on each start, idempotently.

Not server-side, and no server setting will touch them:

  • Texture and lighting artifacts (e.g. dark rectangles on cliff faces). The server transmits no textures, lightmaps or meshes. Reproduce in single-player on the same client to confirm.
  • Terrain, rock and foliage pop-in. Client-side streaming. Only replicated actors β€” Pals, players, other guilds' structures β€” are the server's problem.
  • WiFi power save, which is a real jitter source (iw <dev> set power_save off) but a host setting, not something these units can carry.

Deliberately not done, both documented in-file: dropping -NoAsyncLoadingThread, which may worsen pop-in by moving asset loading onto the game thread, and raising NetServerMaxTickRate, whose popular 30β†’120 bump costs CPU a box like this does not have. A/B them one at a time.


Migrating a co-op world

This half is a finished side project. The steps below ran once, against one world. They are kept because the failure modes are poorly documented elsewhere.

  1. Convert the co-op save (remaps the host UID …0001 β†’ your real one). The Physgun browser converter works; it emits PlZ2, which the dedicated server reads fine.
  2. sudo ./import-world.sh <converted-world-dir> β€” installs Level.sav, LevelMeta.sav and Players/*.sav. Refuses to run if the source still has Players/000…0001.sav (unconverted) or if the server is still up.
  3. sudo ./apply-save-fix.sh β€” repairs work assignments the converters miss. Without this, base Pals stand idle and refuse assignments.
  4. On each player's own PC, restore the map. See below.

Requirements: palsav and the palooz codec

Needed only by the Python tools in this half. The server half needs neither.

The Python tools need palsav from PalworldSaveTools at tools/palsav/, and for PlM (Oodle) saves a compiled palooz.so at tools/palsav/lib/linux_x86_64/. Neither is committed: the codec is a binary build artifact and the library is not ours to redistribute.

git clone --depth 1 https://github.com/deafdudecomputers/PalworldSaveTools.git
mkdir -p tools && cp -r PalworldSaveTools/src/palsav tools/

Do not pip install palworld-save-tools β€” the PyPI package is pinned at Palworld 0.3.7 and misreads 1.0 saves.

Building palooz (the Oodle codec)

Needed only for PlM saves. It ships as C++ source at src/palsav/palooz with no prebuilt binary, and oozlib expects the result at tools/palsav/lib/linux_x86_64/. It uses the plain CPython C API β€” no pybind11 β€” so it builds with one g++ call and needs neither pip nor setuptools:

sudo apt install -y python3-dev        # for Python.h
INC=$(python3 -c 'import sysconfig;print(sysconfig.get_paths()["include"])')
cd PalworldSaveTools/src/palsav/palooz
g++ -O3 -shared -fPIC -flto -fno-exceptions -fno-rtti -ffast-math -fno-strict-aliasing \
  -DOOZ_BUILD_DLL=1 -I"$INC" -Iooz/dep/ooz/simde \
  ooz/palooz_bindings.cpp \
  ooz/dep/ooz/{bitknit,kraken,lzna,compress,compr_kraken,compr_lzoffset,compr_entropy,compr_match_finder,compr_multiarray,compr_tans}.cpp \
  -o ../palsav/lib/linux_x86_64/palooz.so

Takes about 15 seconds. It emits two warnings from ooz's own compression match finder (an unsigned size computation) β€” cosmetic.

If the codec is missing, import palooz can silently resolve to the source directory as a namespace package and then fail much later as a confusing AttributeError: module 'palooz' has no attribute 'decompress'. Note also that upstream's palsav.core builds OozLib() at import time and loads the codec eagerly, so import palsav.core fails without palooz.so even for pure-zlib saves that never touch Oodle. docs/vendor/ carries a patch making that load lazy β€” see docs/NOTICE.md.

Byte-identity of the compressed form is not a valid round-trip gate for PlM: our Kraken settings differ from the game's, so the container size changes while decompressing to identical bytes. Gate on the GVAS payload instead.

Set PYTHONHASHSEED=0 before invoking palsav's CLI directly; it re-execvs itself otherwise and that fails under a read-only script dir.

The map is a client-side restore

LocalData.sav is not a server file. It is per-player and per-server, it lives on the player's own machine, and the server never serves it. It holds the fog-of-war mask (WorldMapUISaveDataMap.MainMap.MaskTextureData), custom map pins, tutorial flags and the tracked quest.

Skip it and the map opens pitch black, with unlocked fast-travel points invisible because map icons don't render under unrevealed fog β€” only base camps show, since those draw from Level.sav. It reads exactly like total progress loss and is not: the progress lives in Players/*.sav. Confirm with ./check-progress.py <world>/Players/*.sav.

To restore, per player:

  1. Join the server once, then fully quit Palworld.
  2. Copy the old world's LocalData.sav over the one in the folder that join just created: %LOCALAPPDATA%\Pal\Saved\SaveGames\<SteamID64>\<newest folder>\LocalData.sav
  3. Relaunch.

Copy the bytes; don't re-encode. compress_gvas_to_sav() cannot tell PlM from PlZ single-zlib (both report save type 0x31) and will silently change the file's format.

The web version (docs/)

docs/ is a self-contained GitHub Pages site that does the same audit in the browser β€” drop a Level.sav to count stale handles and repair it, or a Players/*.sav to confirm progress survived. Enable it with Settings β†’ Pages β†’ Deploy from a branch β†’ main / /docs. No build step and no Actions workflow.

Saves are parsed locally and never uploaded; the repair download is produced in the tab. It runs Pyodide from jsDelivr plus a vendored pure-Python copy of palsav under docs/vendor/ β€” see docs/NOTICE.md for the provenance and the two local patches.

It reads zlib saves only (PlZ, PlZ2, CNK). Oodle (PlM) needs the native codec, so an untouched save straight from the game must go through a converter first β€” which is the step that remaps your host UID anyway. Because the output stays zlib, the browser never needs an Oodle compressor, only the decompressor it doesn't have; that asymmetry is what makes the zero-WASM version possible.

Verified against this world: the browser's repaired Level.sav is byte-identical to fix-stale-handles.py's output, and its round-trip gates (GVAS lossless, container faithful, save type and magic bytes held) all pass.

To test locally: cd docs && python3 -m http.server β€” it needs to be served over HTTP, not opened as a file:// URL.

Evidence

The two claims most likely to be second-guessed are the two with scripts behind them. Both are read-only.

# Prove the converter SKIPPED work assignments rather than corrupting them, and
# that stage_instance_id_belong_to is a sentinel. Pass the pre-conversion save
# as a second argument for the differential.
./evidence/audit-stale-handles.py <converted Level.sav> [<original Level.sav>]

# Reproduce the PlZ -> PlM format collision. Any Players/*.sav works.
./evidence/repro-format-collision.py <Players/XXXX.sav>

On the world these were written against, the differential comes out identical on every metric β€” 18 work assignments (18 stale), 99 expedition flags, 521 stage sentinels, 476 Pals, 1309 map objects β€” before and after conversion. In the original, all 18 pointing at …0001 was correct, because the co-op host genuinely was …0001. The converter did not corrupt them; it skipped them.


Notes

  • WorldOption.sav is deliberately never imported. On a dedicated server a co-op save's copy silently overrides PalWorldSettings.ini, and your rates, difficulty and player cap stop applying.
  • stage_instance_id_belong_to also holds …0001 on hundreds of map objects. It is a stage sentinel, not a stale player reference. A blind "replace every old-host UID" sweep corrupts the world.
  • The server leaks memory over days of uptime. MemoryHigh/MemoryMax cap it and the nightly restart in palworld-maint.timer is the actual mitigation. With MemorySwapMax=0 that restart carries more weight than it used to β€” the leak now ends in an OOM kill rather than a slow slide into swap.

License

MIT β€” see LICENSE.

docs/vendor/palsav/ is third-party code under its own MIT license (Copyright (c) 2026 Pylar) β€” see docs/vendor/palsav/LICENSE and docs/NOTICE.md, which also records the two local patches.

About

Linux tooling for a self-hosted Palworld dedicated server: hardened systemd units, nightly backup+restart, and measured performance tuning. Plus a client-side doctor for co-op saves migrated to a server.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages