Skip to content

feat: download worlds as single-player saves - #520

Merged
thomasmny merged 11 commits into
masterfrom
feat/world-downloads
Jul 31, 2026
Merged

feat: download worlds as single-player saves#520
thomasmny merged 11 commits into
masterfrom
feat/world-downloads

Conversation

@thomasmny

Copy link
Copy Markdown
Owner

What

Adds /worlds download <world>, which packs a world into a single-player save and hands the player an expiring link to fetch it. Since Paper 26.1 a world is a dimension of the main level, so its folder on its own is not something a client can open — the export rebuilds the save layout around it.

Two unrelated fixes ride along, both found while working here: /worlds setItem now keeps a held skull's texture, and head profiles Mojang does not know (offline-mode UUIDs) no longer log a warning per menu item.

The export

A dimension world has no level.dat, its chunks sit under the wrong dimension, and Paper keeps a per-world copy of the level-scoped data inside the dimension folder while a save keeps one set at the root. The export splits it by scope, following the layout documented for 26.1+:

<world>/
├── level.dat                          the main level's, renamed and stripped of server metadata
├── data/minecraft/                    world_gen_settings, game_rules, weather, …
└── dimensions/minecraft/overworld/
    ├── region/  entities/  poi/
    └── data/minecraft/                raids, world_border, chunk_tickets

A world still in the pre-26.1 flat layout is already a save: only its level.dat is rewritten, and dimensions belonging to other worlds are dropped.

The world is saved with the chunk writer flushed first. World#save() returns while Paper is still writing region files, so both this and existing backups could archive half-written chunks — BackupProfileImpl had the same bug and is fixed too. Paper's save(boolean flush) is resolved reflectively, since this module compiles against spigot-api; Spigot writes chunks synchronously, so its fallback is already flushed.

Security

Off by default: enabling it opens a port that serves world data.

  • The URL path is a random 256-bit token. A request can never name a file, so nothing outside plugins/BuildSystem/downloads is reachable.
  • A link is pinned to the first client that uses it; forwarding it is useless, retry and resume still work.
  • 30 requests/minute per address (429), capped concurrent transfers (503 + Retry-After), per-archive size limit and a shared storage budget.
  • Archives die with their token: on expiry, on reload, on shutdown, and leftovers are wiped at startup.
  • Exports never include player data (players/, playerdata/, stats/, advancements/), so downloading the main world does not hand out the server's inventories. level.dat also loses the server brand, version and server-side datapacks.
  • Plain HTTP: no TLS in the plugin, since a keystore would need managing and reloading on every certificate renewal. url points at a reverse proxy instead, and startup warns while it is not https://.

Config

world:
  download:
    enabled: false
    port: 8080
    url: "http://localhost:8080"
    expiration-minutes: 30
    max-size-mb: 2048
    max-storage-mb: 8192
    max-concurrent-downloads: 3

buildsystem.download, default OP, supports .self / .other. New keys are added to an existing config.yml automatically; no migration needed.

Tests

WorldExporterTest covers both layouts, the scope split, the level.dat rename, progress reporting and the progress bar; DownloadRegistryTest covers link pinning, expiry, purging and the rate limiter. Exports were also verified against a real 26.2 server world: layout, no player data, no foreign dimensions, DataPacks.Enabled: ["vanilla"].

Not verified

Loading an exported save in a 26.2 client. The layout matches the documented format and a real server world, but no client on this machine could open it. Worth one manual check before merging.

Docs: thomasmny/buildsystem-docs@v4.

thomasmny added 11 commits July 31, 2026 12:00
A textured skull set as a world icon lost its texture: only the material
was stored. Read the profile off the held item when it is a head, gated
on the profile being ready so a name-only head never blocks the main
thread on a Mojang lookup. A blank head clears the texture, so the icon
always matches the item that was held.
An offline-mode UUID, a renamed account or a world name that is not a
player is a routine miss: the slot keeps its placeholder head. Log those
at FINE without a stack trace, and keep the warning for unexpected
failures.
Since Paper 26.1 a world is a dimension of the main level and has no
level.dat of its own, so its folder is not a save a client can open.
/worlds download rebuilds the missing layout - the world becomes the
save's overworld and gets a level.dat named after it - and serves the
archive over a built-in HTTP server.

Disabled by default. An archive is reachable only through a random
256-bit token that forms the whole URL path, so a request can neither
name a file nor escape the download folder, and archives are deleted
with their token on expiry, reload and shutdown.
Adds the limits the first cut left out: a link is pinned to the client
that uses it first, so a forwarded link is useless; requests are rate
limited per address and concurrent transfers capped, so one host cannot
saturate the endpoint; exports are bounded by a per-archive size and a
shared storage budget, so a large world cannot fill the disk.

Exports also stop carrying data that is not theirs. The main level holds
every player's inventory, position and statistics - none of it ships now
- and the exported level.dat is rewritten through the NBT library the
plugin already depends on to drop the server's brand, version and
server-side datapacks. level.dat_old is excluded for the same reason: it
is an untouched copy of what the rewrite removes.

TLS stays out of the plugin. A keystore would need managing and
reloading on every certificate renewal; a proxy in front of the port
does it better, so the server logs a warning while its URL is not
https.
Bukkit#getWorldFolder for the main level is not always the level root:
on some setups it is <level>/dimensions/minecraft/overworld, and the
export then looked for level.dat inside it and failed. Search upwards
from the reported folder instead, far enough to cover the dimension
shape and no further.
The export walks the world twice - once to size it, once to pack it - so
the action bar can show real progress rather than an indeterminate
spinner. A highlight sweeps through the filled part of the bar and a
spinner turns beside it, both driven by the redraw counter, so an export
sitting on one large region file still reads as working.

The spinner is ASCII: the client's default font renders it everywhere,
which is not true of the braille glyphs usually used for this. The whole
line is a message key, so servers can restyle or shorten it.
The whole finished line carried the click and hover, so clicking
anywhere on it opened the browser. The message now marks the button with
%button% and only that component gets the events; a message customized
without the placeholder still gets the button appended rather than
losing its link.
Two display bugs from the last two commits.

A messages.yml written before %button% existed keeps its old
worlds_download_finished line, because loading only fills in missing
keys. Appending the button to such a message printed the words a second
time; the whole line becomes clickable instead, as it was before.

The spinner also shifted the message. The default font is variable-width
and the action bar is centered, so the two-pixel | moved everything
sideways on every fourth frame. The rotation now uses only six-pixel
frames, and the percentage is padded to a fixed width for the same
reason.
An exported world would not load: the client reported datapack errors,
then invalid save data in Safe Mode.

Paper keeps a per-world copy of the level-scoped files inside each
dimension folder, because every Bukkit world is a level of its own. A
single-player save keeps one set in the save root's data/minecraft and
only raids, world_border and chunk_tickets inside the dimension. The
export copied the dimension folder wholesale, so world_gen_settings.dat
never reached the root and the client could not build the level.

Files are now routed by scope. Verified against the layout documented on
the wiki for 26.1+, and against a real dimension world from a 26.2
server.
World#save returns while Paper's chunk writer is still running, so both
a backup and a download could archive half-written region files - the
bug reported as chunks missing from a downloaded world.

Paper's save(boolean flush) waits for the writer. It is not in the
Spigot API this plugin compiles against, so it is resolved reflectively
and falls back to the plain save, which is already synchronous on
Spigot. No Paper class is referenced at compile time.
The previous commit removed the import along with the World::save
reference, but three other call sites still use the type, so the module
did not compile.
@thomasmny
thomasmny merged commit 39beff4 into master Jul 31, 2026
2 checks passed
@thomasmny
thomasmny deleted the feat/world-downloads branch July 31, 2026 11:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant