diff --git a/paper-server/patches/sources/net/minecraft/server/MinecraftServer.java.patch b/paper-server/patches/sources/net/minecraft/server/MinecraftServer.java.patch index 6fd1ef671143..bff5d9c11da4 100644 --- a/paper-server/patches/sources/net/minecraft/server/MinecraftServer.java.patch +++ b/paper-server/patches/sources/net/minecraft/server/MinecraftServer.java.patch @@ -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()) { diff --git a/paper-server/patches/sources/net/minecraft/server/level/ServerLevel.java.patch b/paper-server/patches/sources/net/minecraft/server/level/ServerLevel.java.patch index 9e9ec1b390cb..f6f4cb18cd90 100644 --- a/paper-server/patches/sources/net/minecraft/server/level/ServerLevel.java.patch +++ b/paper-server/patches/sources/net/minecraft/server/level/ServerLevel.java.patch @@ -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 cacheEntry = storage.cache.get(MapItemSavedData.type(id)); + if (cacheEntry == null) { // Cache did not contain, try to load and may init diff --git a/paper-server/src/main/java/io/papermc/paper/world/PaperWorldLoader.java b/paper-server/src/main/java/io/papermc/paper/world/PaperWorldLoader.java index 8e9ed8ca960e..504074f84cbb 100644 --- a/paper-server/src/main/java/io/papermc/paper/world/PaperWorldLoader.java +++ b/paper-server/src/main/java/io/papermc/paper/world/PaperWorldLoader.java @@ -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; @@ -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) { @@ -67,14 +69,54 @@ private WorldLoadingInfo getWorldInfo( return new WorldLoadingInfo(dimension, name, worldType, stemKey, enabled); } + static Path getTargetWorldFolder(final Path root, final ResourceKey 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 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 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()) { diff --git a/paper-server/src/test/java/io/papermc/paper/world/PaperWorldLoaderPathTest.java b/paper-server/src/test/java/io/papermc/paper/world/PaperWorldLoaderPathTest.java new file mode 100644 index 000000000000..db2bd2554c3b --- /dev/null +++ b/paper-server/src/test/java/io/papermc/paper/world/PaperWorldLoaderPathTest.java @@ -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 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)); + } +} diff --git a/paper-server/src/test/java/io/papermc/paper/world/SharedDataStorageRoutingTest.java b/paper-server/src/test/java/io/papermc/paper/world/SharedDataStorageRoutingTest.java new file mode 100644 index 000000000000..df7c11a16b0a --- /dev/null +++ b/paper-server/src/test/java/io/papermc/paper/world/SharedDataStorageRoutingTest.java @@ -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); + } +}