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.
| 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.
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.timerupdate.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.
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 insystemctl statuscan 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 hitsMemoryMaxand gets OOM-killed instead of swapping, whichRestart=alwaysturns into a ~15s blip. power-profiles-daemonis not the governor knob here. On this box it offers onlybalanced/power-saverbehind aplaceholderplatform driver, sopowerprofilesctl set performancefails 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.
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.
- 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. sudo ./import-world.sh <converted-world-dir>β installsLevel.sav,LevelMeta.savandPlayers/*.sav. Refuses to run if the source still hasPlayers/000β¦0001.sav(unconverted) or if the server is still up.sudo ./apply-save-fix.shβ repairs work assignments the converters miss. Without this, base Pals stand idle and refuse assignments.- On each player's own PC, restore the map. See below.
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.
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.soTakes 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.
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:
- Join the server once, then fully quit Palworld.
- Copy the old world's
LocalData.savover the one in the folder that join just created:%LOCALAPPDATA%\Pal\Saved\SaveGames\<SteamID64>\<newest folder>\LocalData.sav - 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.
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.
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.
WorldOption.savis deliberately never imported. On a dedicated server a co-op save's copy silently overridesPalWorldSettings.ini, and your rates, difficulty and player cap stop applying.stage_instance_id_belong_toalso holdsβ¦0001on 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/MemoryMaxcap it and the nightly restart inpalworld-maint.timeris the actual mitigation. WithMemorySwapMax=0that restart carries more weight than it used to β the leak now ends in an OOM kill rather than a slow slide into swap.
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.