import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.DataInput; import java.io.DataInputStream; import java.io.DataOutput; import java.io.DataOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; import java.nio.channels.Channels; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.channels.OverlappingFileLockException; import java.nio.charset.StandardCharsets; import java.nio.file.DirectoryStream; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.UserPrincipal; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; import java.util.Set; import java.util.TreeSet; import java.util.UUID; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; import org.rocksdb.ColumnFamilyDescriptor; import org.rocksdb.ColumnFamilyHandle; import org.rocksdb.ColumnFamilyOptions; import org.rocksdb.DBOptions; import org.rocksdb.Options; import org.rocksdb.RocksDB; import org.rocksdb.RocksIterator; // Moves a world from TrueOG Purpur's RocksDB player data back to vanilla .dat files; see README.md. public class PlayerDataExportOG { private static final int EXIT_OK = 0; private static final int EXIT_FAILED = 1; private static final int EXIT_USAGE = 2; private static final String SKIP_FLAG = "--skip-unexportable"; private static final String BUKKIT_VALUES = "BukkitValues"; private static final String GROUPS = "myworlds:groups"; private static final String TEMP_SUFFIX = ".export-tmp"; private static final String RECORD = "playerdataexport.done"; private static final byte[] MIGRATED_KEY = "migrated".getBytes(StandardCharsets.US_ASCII); public static void main(String[] args) { int code; try { code = run(args); } catch (Exception e) { System.out.println("export failed, the database was not moved: " + e); code = EXIT_FAILED; } System.exit(code); } // One file to produce: where, its uncompressed named NBT, and what it holds. private record Output(Path file, byte[] nbt, String what) { } private static int run(String[] args) throws Exception { boolean skipUnexportable = false; List positional = new ArrayList<>(); for (String arg : args) { if (arg.equals(SKIP_FLAG)) { skipUnexportable = true; } else if (arg.startsWith("--")) { return usage("unknown option " + arg); } else { positional.add(arg); } } if (positional.isEmpty() || positional.size() > 2) { return usage(null); } Path server = Paths.get(positional.get(0)).toAbsolutePath().normalize(); if (!Files.isDirectory(server)) { return usage("not a directory: " + server); } // Worlds live in the server's world container; every MyWorlds world sits beside the level there String level = positional.size() > 1 ? positional.get(1) : levelName(server); Path levelDir = worldContainer(server).resolve(level).normalize(); Path db = levelDir.resolve("playerdatadb"); if (!Files.isDirectory(db)) { return alreadyExported(levelDir, db); } // Folders and files this creates belong to whoever runs it, and the server must be able to write them again String wrongUser = wrongUser(levelDir); if (wrongUser != null) { System.out.println(wrongUser); return EXIT_FAILED; } // The server holds session.lock while the world is open and RocksDB's LOCK while the database is open Path sessionLock = levelDir.resolve("session.lock"); Path databaseLock = db.resolve("LOCK"); try (FileChannel session = FileChannel.open(sessionLock, StandardOpenOption.CREATE, StandardOpenOption.WRITE); FileChannel database = FileChannel.open(databaseLock, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { FileLock sessionHeld = tryLock(session); if (sessionHeld == null) { System.out.println("the server is running (" + sessionLock + " is locked); stop it first"); return EXIT_FAILED; } FileLock databaseHeld = tryLock(database); if (databaseHeld == null) { System.out.println("another process has the database open (" + databaseLock + " is locked); close it first"); return EXIT_FAILED; } return export(server, levelDir, db, skipUnexportable); } } // Null when a file this process creates in the level folder gets the folder's owner, else why it does not. private static String wrongUser(Path levelDir) throws IOException { // Owners only decide write access on POSIX file systems if (!levelDir.getFileSystem().supportedFileAttributeViews().contains("posix")) { return null; } Path probe = Files.createTempFile(levelDir, ".owner", TEMP_SUFFIX); try { UserPrincipal owner = Files.getOwner(levelDir); UserPrincipal self = Files.getOwner(probe); if (owner.equals(self)) { return null; } return levelDir + " belongs to " + owner.getName() + " but this runs as " + self.getName() + ", so the server could not save into the player data folders this would create; run it as " + owner.getName() + " (for example with sudo -u " + owner.getName() + ")"; } finally { Files.deleteIfExists(probe); } } private static FileLock tryLock(FileChannel channel) throws IOException { try { return channel.tryLock(); } catch (OverlappingFileLockException e) { return null; } } private static int usage(String problem) { if (problem != null) { System.out.println(problem); } System.out.println("usage: java -cp PlayerDataExportOG.java [] [" + SKIP_FLAG + "]"); return EXIT_USAGE; } // Without a database the export is done only if this tool recorded it and every file it wrote is still there. private static int alreadyExported(Path levelDir, Path db) throws IOException { Path record = levelDir.resolve(RECORD); if (!Files.isRegularFile(record, LinkOption.NOFOLLOW_LINKS)) { System.out.println("no database at " + db + "; if the worlds live in a subfolder, pass the level as /"); return EXIT_FAILED; } List lines = Files.readAllLines(record, StandardCharsets.UTF_8); Path archive = lines.isEmpty() || !lines.get(0).startsWith("archive ") ? null : Paths.get(lines.get(0).substring("archive ".length())); if (archive == null || !Files.isDirectory(archive, LinkOption.NOFOLLOW_LINKS)) { System.out.println(record + " names no archived database that still exists; nothing can be confirmed"); return EXIT_FAILED; } List missing = new ArrayList<>(); int files = 0; for (String line : lines) { if (line.startsWith("file ")) { files++; Path file = Paths.get(line.substring("file ".length())); if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { missing.add(file.toString()); } } } if (!missing.isEmpty()) { Map> notes = new LinkedHashMap<>(); notes.put("exported files that are gone", missing); print(notes); System.out.println("the export to " + archive + " finished, but the files above were removed since; restore the archive to " + db + " and run again"); return EXIT_FAILED; } System.out.println("nothing to do: already exported to " + archive + ", all " + files + " files are still in place"); return EXIT_OK; } private static int export(Path server, Path levelDir, Path db, boolean skipUnexportable) throws Exception { RocksDB.loadLibrary(); Exporter exporter = new Exporter(levelDir.getParent(), levelDir.resolve("playerdata"), myWorldsConfig(server)); List names = RocksDB.listColumnFamilies(new Options(), db.toString()); List descriptors = new ArrayList<>(); ColumnFamilyOptions columnFamilyOptions = new ColumnFamilyOptions(); for (byte[] name : names) { descriptors.add(new ColumnFamilyDescriptor(name, columnFamilyOptions)); } List handles = new ArrayList<>(); Set otherFamilies = new TreeSet<>(); int unknownKeys = 0; Map claimed = new LinkedHashMap<>(); Set pending = new HashSet<>(); Set targetDirs = new TreeSet<>(); int alreadyPresent = 0; int written = 0; try (RocksDB rocksDB = RocksDB.openReadOnly(new DBOptions(), db.toString(), descriptors, handles)) { ColumnFamilyHandle defaultHandle = null; for (int i = 0; i < names.size(); i++) { String name = new String(names.get(i), StandardCharsets.UTF_8); if (name.equals("default")) { defaultHandle = handles.get(i); } else { otherFamilies.add(name); } } // Pass 1 plans; nothing is written unless the whole plan is clean Map> conflicts = new LinkedHashMap<>(); try (RocksIterator it = rocksDB.newIterator(defaultHandle)) { for (it.seekToFirst(); it.isValid(); it.next()) { byte[] key = it.key(); if (key.length != 16) { if (!Arrays.equals(key, MIGRATED_KEY)) { unknownKeys++; } continue; } for (Output output : exporter.outputs(uuid(key), it.value(), true)) { Path target = target(output.file()); String obstruction = obstruction(target.getParent()); if (obstruction != null) { note(conflicts, "a path on the way to the file is not a directory", obstruction + " (needed for " + target + ")"); continue; } String earlier = claimed.putIfAbsent(target, output.what()); if (earlier != null) { note(conflicts, "two records map to the same file", target + " (" + earlier + ", " + output.what() + ")"); continue; } targetDirs.add(target.getParent()); if (Files.isSymbolicLink(target)) { note(conflicts, "file is a symbolic link", target.toString()); } else if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { if (Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) && sameContent(target, output.nbt())) { alreadyPresent++; } else { note(conflicts, "file already exists with different content", target.toString()); } } else { pending.add(target); } } } // A read error ends iteration exactly like the end of the data; only status() tells them apart it.status(); } // A player file this database did not produce would mix two histories: vanilla and MyWorlds both read it Path playerDir = canonical(levelDir.resolve("playerdata")); targetDirs.add(playerDir); for (Path dir : targetDirs) { for (Path file : playerFiles(dir)) { if (!claimed.containsKey(file)) { note(conflicts, "player file the database does not have (it may be the only copy, e.g. after a failed import; move it somewhere safe, do not delete it)", file.toString()); } } } if (!conflicts.isEmpty()) { print(conflicts); System.out.println("nothing was written and the database was not moved; resolve the files above and run again"); return EXIT_FAILED; } if (!exporter.problems.isEmpty()) { print(exporter.problems); if (!skipUnexportable) { System.out.println("nothing was written and the database was not moved; fix the above or run again with " + SKIP_FLAG + " to leave them only in the archived database"); return EXIT_FAILED; } System.out.println(SKIP_FLAG + ": the above stay only in the archived database"); } // Pass 2 writes from the same read-only snapshot try (RocksIterator it = rocksDB.newIterator(defaultHandle)) { for (it.seekToFirst(); it.isValid(); it.next()) { if (it.key().length != 16) { continue; } for (Output output : exporter.outputs(uuid(it.key()), it.value(), false)) { Path target = target(output.file()); if (pending.contains(target)) { publish(target, output.nbt()); written++; } } } it.status(); } if (written != pending.size()) { throw new IOException("wrote " + written + " files but planned " + pending.size()); } // Pass 3 checks every planned file, written now or found from an earlier run, right before the database moves List wrong = new ArrayList<>(); try (RocksIterator it = rocksDB.newIterator(defaultHandle)) { for (it.seekToFirst(); it.isValid(); it.next()) { if (it.key().length != 16) { continue; } for (Output output : exporter.outputs(uuid(it.key()), it.value(), false)) { Path target = target(output.file()); if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || !sameContent(target, output.nbt())) { wrong.add(target.toString()); } } } it.status(); } if (!wrong.isEmpty()) { Map> notes = new LinkedHashMap<>(); notes.put("files changed or vanished during the export", wrong); print(notes); System.out.println("the database was not moved; run again once nothing else touches these folders"); return EXIT_FAILED; } } // The old archive goes first: the fork refuses to boot when playerdatamigrated already exists long now = System.currentTimeMillis(); Path migrated = levelDir.resolve("playerdatamigrated"); if (Files.exists(migrated, LinkOption.NOFOLLOW_LINKS)) { Path aside = levelDir.resolve("playerdatamigrated-" + now); Files.move(migrated, aside); System.out.println("moved " + migrated + " to " + aside + " so the fork can archive the player data directory again"); } // The record is written before the database moves, so a missing database always comes with one Path archive = levelDir.resolve("playerdatadb-exported-" + now); StringBuilder record = new StringBuilder("archive ").append(archive).append('\n'); for (Path file : claimed.keySet()) { record.append("file ").append(file).append('\n'); } Path recordTemp = levelDir.resolve("." + RECORD + "." + UUID.randomUUID() + TEMP_SUFFIX); Files.writeString(recordTemp, record, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); Files.move(recordTemp, levelDir.resolve(RECORD), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); Files.move(db, archive); System.out.println("wrote " + written + " files (" + exporter.profiles + " profiles, " + exporter.groups + " MyWorlds group profiles), " + alreadyPresent + " were already in place"); if (!otherFamilies.isEmpty() || unknownKeys > 0) { System.out.println("not player data, left in the archive: column families " + otherFamilies + ", " + unknownKeys + " other keys"); } System.out.println("moved the database to " + archive + "; the fork imports the files again on its next start"); return EXIT_OK; } // Splits stored records into the vanilla files MyWorlds and the server read. private static final class Exporter { private final Path container; private final Path playerDir; private final Map> worlds; private final Map groupDirs = new HashMap<>(); private final Map groupFailures = new HashMap<>(); private final Map> problems = new LinkedHashMap<>(); private int profiles; private int groups; Exporter(Path container, Path playerDir, Map> worlds) { this.container = container; this.playerDir = playerDir; this.worlds = worlds; } // Problems and counts are only recorded on the planning pass. List outputs(UUID uuid, byte[] value, boolean planning) throws IOException { List outputs = new ArrayList<>(); Tag root; try { root = Nbt.read(new DataInputStream(new ByteArrayInputStream(value))); } catch (IOException | RuntimeException e) { problem(planning, "stored profile can not be read", uuid + " (" + e + ")"); return outputs; } if (root.type != 10) { problem(planning, "stored profile is not a compound", uuid.toString()); return outputs; } Map record = root.compound(); Tag values = record.get(BUKKIT_VALUES); Tag groupTags = null; if (values != null && values.type == 10) { groupTags = values.compound().remove(GROUPS); if (values.compound().isEmpty()) { record.remove(BUKKIT_VALUES); } } if (groupTags != null && groupTags.type != 10) { problem(planning, "MyWorlds groups entry is not a compound", uuid.toString()); } else if (groupTags != null) { for (Map.Entry group : groupTags.compound().entrySet()) { if (group.getValue().type != 10) { problem(planning, "MyWorlds group is not a compound", uuid + " group '" + group.getKey() + "'"); continue; } Path dir = groupDir(group.getKey()); if (dir == null) { problem(planning, groupFailures.get(group.getKey()), uuid.toString()); continue; } outputs.add(new Output(dir.resolve(uuid + ".dat"), Nbt.bytes(group.getValue()), uuid + " group " + group.getKey())); if (planning) { groups++; } } } // Only a record with a position is a vanilla profile; without one vanilla would place the player at 0,0,0 if (record.containsKey("Pos")) { outputs.add(new Output(playerDir.resolve(uuid + ".dat"), Nbt.bytes(root), uuid + " profile")); if (planning) { profiles++; } } else { Set residual = new TreeSet<>(record.keySet()); residual.remove("DataVersion"); if (!residual.isEmpty()) { problem(planning, "record has data but no position, so vanilla can not load it", uuid + " " + residual); } } return outputs; } private void problem(boolean planning, String what, String who) { if (planning) { note(problems, what, who); } } // Where MyWorlds' file backend reads a group's profiles, or null with the reason in groupFailures. private Path groupDir(String group) throws IOException { if (!groupDirs.containsKey(group) && !groupFailures.containsKey(group)) { String failure = resolve(group); if (failure != null) { groupFailures.put(group, failure); } } return groupDirs.get(group); } private String resolve(String group) throws IOException { if (worlds == null) { return "MyWorlds groups exist but there is no plugins/MyWorlds/worlds.yml to place them by"; } List matches = worlds.getOrDefault(group.toLowerCase(Locale.ROOT), List.of()); if (matches.isEmpty()) { return "MyWorlds has no world named '" + group + "'"; } if (matches.size() > 1) { return "several MyWorlds worlds match '" + group + "' ignoring case"; } MyWorldsWorld world = matches.get(0); if (world.dimension() == null) { return "MyWorlds world '" + world.name() + "' is not written the way MyWorlds saves worlds.yml (indent, tab, # comment or no environment); start and stop the server once so MyWorlds rewrites it, then run again"; } if (world.name().isEmpty() || world.name().equals(".") || world.name().equals("..") || world.name().contains("/") || world.name().contains("\\") || world.name().indexOf('\0') >= 0) { return "MyWorlds world '" + world.name() + "' is not a plain folder name"; } // MyWorlds finds the folder ignoring case, like here Path folder = container.resolve(world.name()); if (!Files.isDirectory(folder)) { List found = new ArrayList<>(); try (DirectoryStream dirs = Files.newDirectoryStream(container, Files::isDirectory)) { for (Path dir : dirs) { if (dir.getFileName().toString().equalsIgnoreCase(world.name())) { found.add(dir); } } } if (found.size() != 1) { return found.isEmpty() ? "no world folder for MyWorlds world '" + world.name() + "'" : "several folders match MyWorlds world '" + world.name() + "' ignoring case: " + found; } folder = found.get(0); } // A loaded nether or end world keeps player data in its dimension folder; MyWorlds reads the root while it is unloaded Path base = folder; if (!world.dimension().isEmpty()) { if (!world.loaded()) { return "MyWorlds world '" + world.name() + "' is a " + world.dimension() + " world that does not load at startup, so MyWorlds reads its group data from two places; set loaded: true in worlds.yml and run again"; } base = folder.resolve(world.dimension()); } groupDirs.put(group, base.resolve("playerdata")); return null; } } // A world from MyWorlds' worlds.yml: its real name, its dimension folder (empty for the overworld kind), and whether it loads at startup. private record MyWorldsWorld(String name, String dimension, boolean loaded) { } // MyWorlds' worlds.yml by lower-case world name, or null without one. Each top-level node is a world. private static Map> myWorldsConfig(Path server) throws IOException { Path file = server.resolve("plugins").resolve("MyWorlds").resolve("worlds.yml"); if (!Files.isRegularFile(file)) { return null; } Map> nodes = new LinkedHashMap<>(); Set unread = new HashSet<>(); Map current = null; String key = null; boolean first = false; for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { if (line.isBlank() || line.trim().startsWith("#")) { continue; } if (!Character.isWhitespace(line.charAt(0))) { current = null; String trimmed = line.trim(); if (trimmed.endsWith(":")) { current = new HashMap<>(); key = unquote(trimmed.substring(0, trimmed.length() - 1)); nodes.put(key, current); first = true; } } else if (current != null) { // MyWorlds writes two-space children; another indent or a tab is a hand edit this reader can not follow boolean child = line.startsWith(" ") && !Character.isWhitespace(line.charAt(2)); if (first && !child || line.substring(0, line.length() - line.stripLeading().length()).indexOf('\t') >= 0) { unread.add(key); } first = false; int colon = line.indexOf(':'); if (child && colon > 0) { String field = line.substring(2, colon).trim(); String raw = line.substring(colon + 1).trim(); String value = unquote(raw); // For MyWorlds a # after whitespace starts a comment, so the value is not what it reads if (Set.of("name", "environment", "loaded").contains(field) && value.equals(raw) && raw.matches(".*\\s#.*")) { unread.add(key); } current.put(field, value); } } } Map> result = new HashMap<>(); for (Map.Entry> node : nodes.entrySet()) { Map fields = node.getValue(); String name = fields.getOrDefault("name", node.getKey()); // Without an environment MyWorlds guesses from the name and folders; only the form it writes itself is trusted String environment = fields.get("environment"); String dimension = environment == null || unread.contains(node.getKey()) ? null : dimension(environment); MyWorldsWorld world = new MyWorldsWorld(name, dimension, "true".equalsIgnoreCase(fields.get("loaded"))); result.computeIfAbsent(name.toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(world); } return result; } // The dimension folder of a MyWorlds environment mode, matching its WorldMode.get parsing: nether_flat is a nether, dim1 an end. private static String dimension(String mode) { String text = mode.toLowerCase(Locale.ROOT); while (true) { if (text.equals("nether") || text.equals("dim-1") || text.startsWith("nether_")) { return "DIM-1"; } if (text.equals("the_end") || text.equals("dim1") || text.startsWith("the_end_")) { return "DIM1"; } if (text.equals("normal") || text.equals("flat") || text.equals("largebiomes") || text.startsWith("normal_")) { return ""; } int underscore = text.indexOf('_'); if (underscore < 0) { return ""; } text = text.substring(underscore + 1); } } // A file's path with its directory resolved through links; the name itself is kept so a link there is still seen. private static Path target(Path file) throws IOException { return canonical(file.getParent()).resolve(file.getFileName()); } // The path with every existing ancestor resolved through links, so two names for one directory compare equal. private static Path canonical(Path path) throws IOException { Path absolute = path.toAbsolutePath().normalize(); Path existing = absolute; List rest = new ArrayList<>(); while (existing != null && !Files.exists(existing)) { rest.add(0, existing.getFileName()); existing = existing.getParent(); } if (existing == null) { return absolute; } Path result = existing.toRealPath(); for (Path part : rest) { result = result.resolve(part); } return result; } // The first existing ancestor that is not a directory, or null when the directory can be created. private static String obstruction(Path dir) { for (Path p = dir; p != null; p = p.getParent()) { if (Files.exists(p, LinkOption.NOFOLLOW_LINKS)) { return Files.isDirectory(p) ? null : p.toString(); } } return null; } // Regular .dat files in a directory, the ones the fork's import and MyWorlds read. private static List playerFiles(Path dir) throws IOException { List files = new ArrayList<>(); if (!Files.isDirectory(dir)) { return files; } try (DirectoryStream entries = Files.newDirectoryStream(dir, "*.dat")) { for (Path file : entries) { String name = file.getFileName().toString(); if (!Files.isRegularFile(file)) { continue; } try { UUID.fromString(name.substring(0, name.length() - 4)); files.add(file); } catch (IllegalArgumentException e) { // not a player file } } } return files; } private static void note(Map> notes, String what, String who) { notes.computeIfAbsent(what, k -> new ArrayList<>()).add(who); } private static void print(Map> notes) { for (Map.Entry> entry : notes.entrySet()) { List who = entry.getValue(); System.out.println(entry.getKey() + " (" + who.size() + "):"); for (String item : who.subList(0, Math.min(who.size(), 20))) { System.out.println(" " + item); } if (who.size() > 20) { System.out.println(" ... and " + (who.size() - 20) + " more"); } } } private static UUID uuid(byte[] key) { ByteBuffer buffer = ByteBuffer.wrap(key); return new UUID(buffer.getLong(), buffer.getLong()); } // Writes a fresh file beside the target and links it into place, which fails instead of replacing anything. private static void publish(Path target, byte[] nbt) throws IOException { Path dir = target.getParent(); Files.createDirectories(dir); Path temp = dir.resolve("." + target.getFileName() + "." + UUID.randomUUID() + TEMP_SUFFIX); try { try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { GZIPOutputStream gzip = new GZIPOutputStream(Channels.newOutputStream(channel)); gzip.write(nbt); gzip.finish(); gzip.flush(); channel.force(true); } try { Files.createLink(target, temp); } catch (FileAlreadyExistsException e) { throw new IOException(target + " appeared while the export was running", e); } } finally { Files.deleteIfExists(temp); } } private static boolean sameContent(Path file, byte[] nbt) { try (InputStream in = new GZIPInputStream(Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS))) { return Arrays.equals(in.readAllBytes(), nbt); } catch (IOException | UnsupportedOperationException e) { return false; } } // Read the way the server reads it, so both agree on the level name. private static String levelName(Path server) throws IOException { Path file = server.resolve("server.properties"); Properties properties = new Properties(); if (Files.isRegularFile(file)) { try (InputStream in = Files.newInputStream(file)) { properties.load(in); } } return properties.getProperty("level-name", "world"); } // The settings.world-container directory from bukkit.yml, the server directory when unset. private static Path worldContainer(Path server) throws IOException { Path file = server.resolve("bukkit.yml"); if (Files.isRegularFile(file)) { for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { String trimmed = line.trim(); if (trimmed.startsWith("world-container:")) { String value = unquote(trimmed.substring("world-container:".length()).trim()); if (!value.isEmpty()) { return server.resolve(value).normalize(); } } } } return server; } private static String unquote(String value) { if (value.length() >= 2 && (value.startsWith("'") && value.endsWith("'") || value.startsWith("\"") && value.endsWith("\""))) { return value.substring(1, value.length() - 1); } return value; } // Minimal NBT tree: type id plus value, written back exactly as read. static final class Tag { final int type; final Object value; Tag(int type, Object value) { this.type = type; this.value = value; } @SuppressWarnings("unchecked") Map compound() { return (Map) value; } } static final class ListValue { final int elementType; final List elements = new ArrayList<>(); ListValue(int elementType) { this.elementType = elementType; } } static final class Nbt { static Tag read(DataInput in) throws IOException { int type = in.readByte(); in.readUTF(); return readPayload(in, type); } // Uncompressed named NBT with an empty root name, the form vanilla gzips into a .dat. static byte[] bytes(Tag root) throws IOException { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); DataOutputStream out = new DataOutputStream(bytes); out.writeByte(root.type); out.writeUTF(""); writePayload(out, root); out.flush(); return bytes.toByteArray(); } static Tag readPayload(DataInput in, int type) throws IOException { switch (type) { case 1: return new Tag(type, in.readByte()); case 2: return new Tag(type, in.readShort()); case 3: return new Tag(type, in.readInt()); case 4: return new Tag(type, in.readLong()); case 5: return new Tag(type, in.readFloat()); case 6: return new Tag(type, in.readDouble()); case 7: { byte[] data = new byte[in.readInt()]; in.readFully(data); return new Tag(type, data); } case 8: return new Tag(type, in.readUTF()); case 9: { int elementType = in.readByte(); int size = in.readInt(); ListValue list = new ListValue(elementType); for (int i = 0; i < size; i++) { list.elements.add(readPayload(in, elementType)); } return new Tag(type, list); } case 10: { Map compound = new LinkedHashMap<>(); int childType; while ((childType = in.readByte()) != 0) { String name = in.readUTF(); compound.put(name, readPayload(in, childType)); } return new Tag(type, compound); } case 11: { int[] data = new int[in.readInt()]; for (int i = 0; i < data.length; i++) { data[i] = in.readInt(); } return new Tag(type, data); } case 12: { long[] data = new long[in.readInt()]; for (int i = 0; i < data.length; i++) { data[i] = in.readLong(); } return new Tag(type, data); } default: throw new IOException("Unknown tag type " + type); } } static void writePayload(DataOutput out, Tag tag) throws IOException { switch (tag.type) { case 1: out.writeByte((Byte) tag.value); break; case 2: out.writeShort((Short) tag.value); break; case 3: out.writeInt((Integer) tag.value); break; case 4: out.writeLong((Long) tag.value); break; case 5: out.writeFloat((Float) tag.value); break; case 6: out.writeDouble((Double) tag.value); break; case 7: { byte[] data = (byte[]) tag.value; out.writeInt(data.length); out.write(data); break; } case 8: out.writeUTF((String) tag.value); break; case 9: { ListValue list = (ListValue) tag.value; out.writeByte(list.elementType); out.writeInt(list.elements.size()); for (Tag element : list.elements) { writePayload(out, element); } break; } case 10: { for (Map.Entry entry : tag.compound().entrySet()) { out.writeByte(entry.getValue().type); out.writeUTF(entry.getKey()); writePayload(out, entry.getValue()); } out.writeByte(0); break; } case 11: { int[] data = (int[]) tag.value; out.writeInt(data.length); for (int v : data) { out.writeInt(v); } break; } case 12: { long[] data = (long[]) tag.value; out.writeInt(data.length); for (long v : data) { out.writeLong(v); } break; } default: throw new IOException("Unknown tag type " + tag.type); } } } }