Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@

public boolean saveAllChunks(final boolean silent, final boolean flush, final boolean force) {
- this.scoreboard.storeToSaveDataIfDirty(this.getDataStorage().computeIfAbsent(ScoreboardSaveData.TYPE));
+ if (this.overworld() != null) this.scoreboard.storeToSaveDataIfDirty(this.overworld().getDataStorage().computeIfAbsent(ScoreboardSaveData.TYPE)); // Paper - don't try to save if the overworld was not loaded, generally during early startup failures
+ if (this.overworld() != null) this.scoreboard.storeToSaveDataIfDirty(this.getDataStorage().computeIfAbsent(ScoreboardSaveData.TYPE)); // Paper - save scoreboard in shared world data
boolean result = false;

for (ServerLevel level : this.getAllLevels()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -968,7 +968,7 @@
public @Nullable MapItemSavedData getMapData(final MapId id) {
- return this.getServer().getDataStorage().get(MapItemSavedData.type(id));
+ // Paper start - Call missing map initialize event and set id
+ final SavedDataStorage storage = this.getServer().overworld().getDataStorage();
+ final SavedDataStorage storage = this.getServer().getDataStorage(); // Paper - save maps in shared world data
+
+ final Optional<net.minecraft.world.level.saveddata.SavedData> cacheEntry = storage.cache.get(MapItemSavedData.type(id));
+ if (cacheEntry == null) { // Cache did not contain, try to load and may init
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.datafix.DataFixers;
import net.minecraft.util.worldupdate.UpgradeProgress;
import net.minecraft.world.level.dimension.DimensionType;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.storage.LevelDataAndDimensions;
import net.minecraft.world.level.storage.LevelStorageSource;
Expand All @@ -25,6 +26,7 @@
import org.slf4j.Logger;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Locale;

public record PaperWorldLoader(MinecraftServer server, String levelId) {
Expand Down Expand Up @@ -67,14 +69,54 @@ private WorldLoadingInfo getWorldInfo(
return new WorldLoadingInfo(dimension, name, worldType, stemKey, enabled);
}

static Path getTargetWorldFolder(final Path root, final ResourceKey<LevelStem> stemKey) {
return DimensionType.getStorageFolder(Registries.levelStemToLevel(stemKey), root);
}

// A data-only dimensions/... folder is not enough to consider a split world container migrated.
// File-fixers can create dimension-scoped saved data before the actual chunk payload
// (`region`, `entities`, `poi`) has been moved into the split world container.
static boolean hasWorldPayload(final Path root) {
return java.nio.file.Files.isDirectory(root.resolve("region"))
|| java.nio.file.Files.isDirectory(root.resolve("entities"))
|| java.nio.file.Files.isDirectory(root.resolve("poi"));
}

static boolean hasExistingWorldPayload(final Path root, final ResourceKey<LevelStem> stemKey) {
final Path targetWorld = getTargetWorldFolder(root, stemKey);
if (hasWorldPayload(targetWorld)) {
return true;
}

final Path legacyWorld = LevelStorageSource.getStorageFolder(root, stemKey);
return !legacyWorld.equals(targetWorld) && hasWorldPayload(legacyWorld);
}

static Path findSourceWorldFolder(final Path root, final ResourceKey<LevelStem> stemKey) {
final Path targetWorld = getTargetWorldFolder(root, stemKey);
if (java.nio.file.Files.isDirectory(targetWorld)) {
return targetWorld;
}
// Fallback to legacy if we can't find the new world folder structure
return LevelStorageSource.getStorageFolder(root, stemKey);
}

private void migrateWorldFolder(final WorldLoadingInfo info) {
// Migration of old CB world folders...
if (info.dimension() == 0) {
return;
}

File newWorld = LevelStorageSource.getStorageFolder(new File(info.name()).toPath(), info.stemKey()).toFile();
File oldWorld = LevelStorageSource.getStorageFolder(new File(this.levelId).toPath(), info.stemKey()).toFile();
final Path newRootPath = new File(info.name()).toPath();
// Check if we are already migrated to the new folder path
if (hasExistingWorldPayload(newRootPath, info.stemKey())) {
return;
}

final Path newWorldPath = getTargetWorldFolder(newRootPath, info.stemKey());
final Path oldWorldPath = findSourceWorldFolder(new File(this.levelId).toPath(), info.stemKey());
File newWorld = newWorldPath.toFile();
File oldWorld = oldWorldPath.toFile();
File oldLevelDat = new File(new File(this.levelId), "level.dat"); // The data folders exist on first run as they are created in the PersistentCollection constructor above, but the level.dat won't

if (!newWorld.isDirectory() && oldWorld.isDirectory() && oldLevelDat.isFile()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package io.papermc.paper.world;

import java.nio.file.Files;
import java.nio.file.Path;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.dimension.LevelStem;
import org.bukkit.support.environment.Normal;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

@Normal
class PaperWorldLoaderPathTest {

@Test
void targetWorldFolderUsesDimensionLayout() {
assertEquals(
Path.of("world_nether", "dimensions", "minecraft", "the_nether"),
PaperWorldLoader.getTargetWorldFolder(Path.of("world_nether"), LevelStem.NETHER)
);
assertEquals(
Path.of("world_the_end", "dimensions", "minecraft", "the_end"),
PaperWorldLoader.getTargetWorldFolder(Path.of("world_the_end"), LevelStem.END)
);
}

@Test
void sourceWorldFolderFallsBackToLegacyBuiltinLayout(@TempDir final Path tempDir) throws Exception {
final Path root = tempDir.resolve("world");
Files.createDirectories(root.resolve("DIM-1"));

assertEquals(root.resolve("DIM-1"), PaperWorldLoader.findSourceWorldFolder(root, LevelStem.NETHER));
}

@Test
void sourceWorldFolderPrefersDimensionLayoutWhenPresent(@TempDir final Path tempDir) throws Exception {
final Path root = tempDir.resolve("world");
final Path netherModern = root.resolve("dimensions").resolve("minecraft").resolve("the_nether");
Files.createDirectories(netherModern);

assertEquals(netherModern, PaperWorldLoader.findSourceWorldFolder(root, LevelStem.NETHER));
}

@Test
void dataOnlyDimensionsArtifactDoesNotCountAsExistingSplitWorldPayload(@TempDir final Path tempDir) throws Exception {
// Datafixer migration of world files can migrate some of the end files preemptively, which was causing migration to halt.
// This change verifies that we have also migrated the region/other dat files instead of just verifying folder existence.
final Path root = tempDir.resolve("world_the_end");
Files.createDirectories(root.resolve("dimensions").resolve("minecraft").resolve("the_end").resolve("data").resolve("minecraft"));

assertFalse(PaperWorldLoader.hasExistingWorldPayload(root, LevelStem.END));
}

@Test
void migratedSplitWorldCountsAsExistingPayload(@TempDir final Path tempDir) throws Exception {
final Path root = tempDir.resolve("world_the_end");
Files.createDirectories(root.resolve("dimensions").resolve("minecraft").resolve("the_end").resolve("region"));

assertTrue(PaperWorldLoader.hasExistingWorldPayload(root, LevelStem.END));
}

@Test
void legacySplitWorldPayloadCountsAsExistingPayload(@TempDir final Path tempDir) throws Exception {
final Path targetRoot = tempDir.resolve("world_the_end");
Files.createDirectories(targetRoot.resolve("DIM1").resolve("region"));

assertTrue(PaperWorldLoader.hasExistingWorldPayload(targetRoot, LevelStem.END));
}

@Test
void customDimensionFallbackMatchesCurrentDimensionsLayout(@TempDir final Path tempDir) throws Exception {
final ResourceKey<LevelStem> customDimension = ResourceKey.create(
Registries.LEVEL_STEM,
Identifier.fromNamespaceAndPath("paper", "custom")
);
final Path root = tempDir.resolve("world");
final Path customPath = root.resolve("dimensions").resolve("paper").resolve("custom");
Files.createDirectories(customPath);

assertEquals(customPath, PaperWorldLoader.getTargetWorldFolder(root, customDimension));
assertEquals(customPath, PaperWorldLoader.findSourceWorldFolder(root, customDimension));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package io.papermc.paper.world;

import com.mojang.datafixers.DataFixer;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import net.minecraft.core.HolderLookup;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.ServerScoreboard;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.saveddata.maps.MapId;
import net.minecraft.world.level.saveddata.maps.MapIndex;
import net.minecraft.world.level.saveddata.maps.MapItemSavedData;
import net.minecraft.world.level.storage.SavedDataStorage;
import net.minecraft.world.scores.ScoreboardSaveData;
import org.bukkit.craftbukkit.map.CraftMapView;
import org.bukkit.support.environment.Normal;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Answers;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@Normal
class SharedDataStorageRoutingTest {

@Test
void scoreboardSavesToSharedServerStorage() throws ReflectiveOperationException {
final MinecraftServer server = mock(MinecraftServer.class, Answers.CALLS_REAL_METHODS);
final ServerScoreboard scoreboard = mock(ServerScoreboard.class);
final SavedDataStorage sharedStorage = mock(SavedDataStorage.class);
final SavedDataStorage overworldStorage = mock(SavedDataStorage.class);
final ScoreboardSaveData sharedScoreboard = new ScoreboardSaveData(ScoreboardSaveData.Packed.EMPTY);
final ScoreboardSaveData overworldScoreboard = new ScoreboardSaveData(ScoreboardSaveData.Packed.EMPTY);
final ServerLevel overworld = mock(ServerLevel.class);

when(sharedStorage.computeIfAbsent(ScoreboardSaveData.TYPE)).thenReturn(sharedScoreboard);
when(overworldStorage.computeIfAbsent(ScoreboardSaveData.TYPE)).thenReturn(overworldScoreboard);
when(overworld.getDataStorage()).thenReturn(overworldStorage);

setField(server, MinecraftServer.class, "savedDataStorage", sharedStorage);
setField(server, MinecraftServer.class, "scoreboard", scoreboard);
setField(server, MinecraftServer.class, "levels", Map.of(Level.OVERWORLD, overworld));

assertTrue(server.saveAllChunks(true, false, false));
verify(scoreboard).storeToSaveDataIfDirty(sharedScoreboard);
verify(scoreboard, never()).storeToSaveDataIfDirty(overworldScoreboard);
}

@Test
void mapReadsUseSharedServerStorage(@TempDir final Path tempDir) throws Exception {
final SavedDataStorage sharedStorage = createStorage(tempDir.resolve("shared"));
final SavedDataStorage overworldStorage = createStorage(tempDir.resolve("overworld"));
final MinecraftServer server = mock(MinecraftServer.class);
final ServerLevel level = mock(ServerLevel.class, Answers.CALLS_REAL_METHODS);
final MapId id = new MapId(3);
final MapItemSavedData expected = createMapData();

sharedStorage.set(MapItemSavedData.type(id), expected);
when(server.getDataStorage()).thenReturn(sharedStorage);
setField(level, ServerLevel.class, "server", server);

assertSame(expected, level.getMapData(id));
assertNull(overworldStorage.get(MapItemSavedData.type(id)));
}

@Test
void mapWritesUseSharedServerStorage(@TempDir final Path tempDir) throws Exception {
final SavedDataStorage sharedStorage = createStorage(tempDir.resolve("shared"));
final SavedDataStorage overworldStorage = createStorage(tempDir.resolve("overworld"));
final MinecraftServer server = mock(MinecraftServer.class);
final ServerLevel level = mock(ServerLevel.class, Answers.CALLS_REAL_METHODS);
final MapId id = new MapId(9);
final MapItemSavedData data = createMapData();

when(server.getDataStorage()).thenReturn(sharedStorage);
setField(level, ServerLevel.class, "server", server);

level.setMapData(id, data);

assertEquals(id, data.id);
assertSame(data, sharedStorage.get(MapItemSavedData.type(id)));
assertNull(overworldStorage.get(MapItemSavedData.type(id)));
}

@Test
void mapIdsUseSharedServerStorage(@TempDir final Path tempDir) throws Exception {
final SavedDataStorage sharedStorage = createStorage(tempDir.resolve("shared"));
final SavedDataStorage overworldStorage = createStorage(tempDir.resolve("overworld"));
final MinecraftServer server = mock(MinecraftServer.class);
final ServerLevel level = mock(ServerLevel.class, Answers.CALLS_REAL_METHODS);

when(server.getDataStorage()).thenReturn(sharedStorage);
setField(level, ServerLevel.class, "server", server);

assertEquals(new MapId(0), level.getFreeMapId());
assertNotNull(sharedStorage.get(MapIndex.TYPE));
assertNull(overworldStorage.get(MapIndex.TYPE));
}

private static SavedDataStorage createStorage(final Path path) throws java.io.IOException {
Files.createDirectories(path);
return new SavedDataStorage(path, mock(DataFixer.class), mock(HolderLookup.Provider.class));
}

private static MapItemSavedData createMapData() throws ReflectiveOperationException {
final MapItemSavedData data = mock(MapItemSavedData.class);
setField(data, MapItemSavedData.class, "mapView", new CraftMapView(data));
return data;
}

private static void setField(final Object target, final Class<?> owner, final String name, final Object value) throws ReflectiveOperationException {
final Field field = owner.getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
}