diff --git a/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/containers/ChunkContainer.java b/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/containers/ChunkContainer.java index ff1a39d0..b84e5ec2 100644 --- a/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/containers/ChunkContainer.java +++ b/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/containers/ChunkContainer.java @@ -13,6 +13,7 @@ import org.bukkit.World; import org.bukkit.entity.EntityType; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.util.EnumMap; import java.util.Map; @@ -70,7 +71,7 @@ public ChunkCuboid getChunkCuboid() { return cuboid; } - public abstract void getChunkSections(Consumer<@NotNull ChunkSection> sectionConsumer) throws IOException; + public abstract void getChunkSections(Consumer<@Nullable ChunkSection> sectionConsumer) throws IOException; public abstract void getChunkEntities(Consumer<@NotNull ChunkEntity> entityConsumer) throws IOException; @@ -90,6 +91,10 @@ public DistributionStorage get() { int maxSectionY = blockMaxY >> 4; try { getChunkSections(section -> { + // Skip null sections - they represent empty sections that don't need to be counted + // unless we're calculating totals, but for limits we only care about non-air blocks + if (section == null) return; + int sectionY = section.index(); if (sectionY < minSectionY || sectionY > maxSectionY) return; int minY = sectionY == minSectionY ? blockMinY & 15 : 0; diff --git a/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/containers/UnloadedChunkContainer.java b/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/containers/UnloadedChunkContainer.java index 37c4e846..6f5cac6c 100644 --- a/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/containers/UnloadedChunkContainer.java +++ b/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/containers/UnloadedChunkContainer.java @@ -28,7 +28,7 @@ public UnloadedChunkContainer( } @Override - public void getChunkSections(Consumer<@Nullable ChunkSection> sectionConsumer) { + public void getChunkSections(Consumer<@Nullable ChunkSection> sectionConsumer) throws IOException { nms.getUnloadedChunkSections(world, chunkX, chunkZ, sectionConsumer); } diff --git a/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/storage/AddonStorage.java b/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/storage/AddonStorage.java index 806155b5..c86a3940 100644 --- a/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/storage/AddonStorage.java +++ b/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/storage/AddonStorage.java @@ -3,24 +3,138 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +/** + * Thread-safe LRU cache with TTL for addon region storage. + */ public class AddonStorage { - private final Map distributionMap; + private static final int DEFAULT_MAX_SIZE = 500; + private static final long DEFAULT_TTL_MINUTES = 10; + private static final long CLEANUP_INTERVAL_MINUTES = 1; + + private final Map distributionMap; + private final int maxSize; + private final long ttlMillis; + private final ScheduledExecutorService cleanupExecutor; public AddonStorage() { + this(DEFAULT_MAX_SIZE, DEFAULT_TTL_MINUTES); + } + + public AddonStorage(int maxSize, long ttlMinutes) { this.distributionMap = new ConcurrentHashMap<>(); + this.maxSize = maxSize; + this.ttlMillis = TimeUnit.MINUTES.toMillis(ttlMinutes); + this.cleanupExecutor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "Insights-AddonStorage-Cleanup"); + t.setDaemon(true); + return t; + }); + cleanupExecutor.scheduleAtFixedRate( + this::cleanupExpiredEntries, + CLEANUP_INTERVAL_MINUTES, + CLEANUP_INTERVAL_MINUTES, + TimeUnit.MINUTES + ); } public Optional get(String key) { - return Optional.ofNullable(distributionMap.get(key)); + CacheEntry entry = distributionMap.get(key); + if (entry == null) { + return Optional.empty(); + } + if (entry.isExpired(ttlMillis)) { + distributionMap.remove(key, entry); + return Optional.empty(); + } + entry.touch(); + return Optional.of(entry.getStorage()); } public void put(String key, Storage storage) { - this.distributionMap.put(key, storage); + while (distributionMap.size() >= maxSize) { + evictOldest(); + } + distributionMap.put(key, new CacheEntry(storage)); } public void remove(String key) { - this.distributionMap.remove(key); + distributionMap.remove(key); + } + + public int size() { + return distributionMap.size(); + } + + public void clear() { + distributionMap.clear(); + } + + public void shutdown() { + cleanupExecutor.shutdown(); + try { + if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + cleanupExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + cleanupExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + private void cleanupExpiredEntries() { + long now = System.currentTimeMillis(); + distributionMap.entrySet().removeIf(entry -> entry.getValue().isExpired(ttlMillis, now)); + } + + private void evictOldest() { + String oldestKey = null; + long oldestTime = Long.MAX_VALUE; + for (Map.Entry entry : distributionMap.entrySet()) { + long accessTime = entry.getValue().getLastAccessTime(); + if (accessTime < oldestTime) { + oldestTime = accessTime; + oldestKey = entry.getKey(); + } + } + if (oldestKey != null) { + distributionMap.remove(oldestKey); + } + } + + private static class CacheEntry { + private final Storage storage; + private final long creationTime; + private volatile long lastAccessTime; + + CacheEntry(Storage storage) { + this.storage = storage; + this.creationTime = System.currentTimeMillis(); + this.lastAccessTime = this.creationTime; + } + + Storage getStorage() { + return storage; + } + + long getLastAccessTime() { + return lastAccessTime; + } + + void touch() { + this.lastAccessTime = System.currentTimeMillis(); + } + + boolean isExpired(long ttlMillis) { + return isExpired(ttlMillis, System.currentTimeMillis()); + } + + boolean isExpired(long ttlMillis, long currentTime) { + return (currentTime - creationTime) > ttlMillis; + } } } diff --git a/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/storage/ChunkStorage.java b/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/storage/ChunkStorage.java index 416304b8..1d8adaf8 100644 --- a/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/storage/ChunkStorage.java +++ b/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/storage/ChunkStorage.java @@ -1,16 +1,34 @@ package dev.frankheijden.insights.api.concurrent.storage; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; +/** + * Thread-safe LRU cache for chunk storage. + */ public class ChunkStorage { + private static final int DEFAULT_MAX_CACHED_CHUNKS = 5000; private final Map distributionMap; + private final int maxSize; public ChunkStorage() { - this.distributionMap = new ConcurrentHashMap<>(); + this(DEFAULT_MAX_CACHED_CHUNKS); + } + + public ChunkStorage(int maxSize) { + this.maxSize = maxSize; + this.distributionMap = Collections.synchronizedMap( + new LinkedHashMap<>(maxSize, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxSize; + } + } + ); } public Set getChunks() { @@ -28,4 +46,12 @@ public void put(long chunkKey, Storage storage) { public void remove(long chunkKey) { distributionMap.remove(chunkKey); } + + public int size() { + return distributionMap.size(); + } + + public int getMaxSize() { + return maxSize; + } } diff --git a/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/storage/WorldStorage.java b/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/storage/WorldStorage.java index 08dc4594..648ebc61 100644 --- a/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/storage/WorldStorage.java +++ b/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/storage/WorldStorage.java @@ -4,15 +4,30 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +/** + * Per-world chunk cache container. + */ public class WorldStorage { + private static final int DEFAULT_CHUNK_CACHE_SIZE = 5000; + private final Map chunkMap; + private final int chunkCacheSize; public WorldStorage() { + this(DEFAULT_CHUNK_CACHE_SIZE); + } + + public WorldStorage(int chunkCacheSize) { this.chunkMap = new ConcurrentHashMap<>(); + this.chunkCacheSize = chunkCacheSize; } public ChunkStorage getWorld(UUID worldUid) { - return chunkMap.computeIfAbsent(worldUid, k -> new ChunkStorage()); + return chunkMap.computeIfAbsent(worldUid, k -> new ChunkStorage(chunkCacheSize)); + } + + public int getChunkCacheSize() { + return chunkCacheSize; } } diff --git a/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/tracker/AddonScanTracker.java b/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/tracker/AddonScanTracker.java index 4fc63f4e..4bf1fd33 100644 --- a/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/tracker/AddonScanTracker.java +++ b/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/tracker/AddonScanTracker.java @@ -4,6 +4,9 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +/** + * Thread-safe tracker for addon region scans in progress. + */ public class AddonScanTracker { private final Set tracker; @@ -16,6 +19,13 @@ public void add(String key) { this.tracker.add(key); } + /** + * Atomically adds key if not present. Returns true if added, false if already exists. + */ + public boolean tryAdd(String key) { + return this.tracker.add(key); + } + public boolean isQueued(String key) { return this.tracker.contains(key); } @@ -23,4 +33,12 @@ public boolean isQueued(String key) { public void remove(String key) { this.tracker.remove(key); } + + public int size() { + return this.tracker.size(); + } + + public void clear() { + this.tracker.clear(); + } } diff --git a/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/tracker/ChunkScanTracker.java b/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/tracker/ChunkScanTracker.java index 78a6dc6f..ba202da2 100644 --- a/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/tracker/ChunkScanTracker.java +++ b/Insights-API/src/main/java/dev/frankheijden/insights/api/concurrent/tracker/ChunkScanTracker.java @@ -19,6 +19,6 @@ public boolean set(Long obj, boolean queued) { @Override public boolean isQueued(Long obj) { - return false; + return queuedChunks.contains(obj); } } diff --git a/Insights-API/src/main/java/dev/frankheijden/insights/api/config/Settings.java b/Insights-API/src/main/java/dev/frankheijden/insights/api/config/Settings.java index dee2339f..fecd8783 100644 --- a/Insights-API/src/main/java/dev/frankheijden/insights/api/config/Settings.java +++ b/Insights-API/src/main/java/dev/frankheijden/insights/api/config/Settings.java @@ -53,6 +53,9 @@ public class Settings { public final int REDSTONE_UPDATE_AGGREGATE_TICKS; public final int REDSTONE_UPDATE_AGGREGATE_SIZE; public final boolean REDSTONE_UPDATE_LIMITER_BLOCK_OUTSIDE_REGION; + public final int CHUNK_CACHE_MAX_SIZE; + public final int ADDON_CACHE_MAX_SIZE; + public final long ADDON_CACHE_TTL_MINUTES; /** * Constructs a new Settings object from the given YamlParser. @@ -74,6 +77,10 @@ public Settings(InsightsPlugin plugin, YamlParser parser) { CHUNK_SCANS_MODE = parser.getEnum("settings.chunk-scans.mode", ChunkScanMode.ALWAYS); CHUNK_SCANS_PLAYER_TRACKER_INTERVAL_TICKS = parser.getInt("settings.chunk-scans.player-tracker-interval-ticks", 5, 1, Integer.MAX_VALUE); + CHUNK_CACHE_MAX_SIZE = parser.getInt("settings.chunk-cache.max-size", 5000, 100, Integer.MAX_VALUE); + ADDON_CACHE_MAX_SIZE = parser.getInt("settings.addon-cache.max-size", 500, 1, Integer.MAX_VALUE); + ADDON_CACHE_TTL_MINUTES = parser.getInt("settings.addon-cache.ttl-minutes", 10, 1, Integer.MAX_VALUE); + NOTIFICATION_TYPE = parser.getEnum("settings.notification.type", NotificationType.BOSSBAR); NOTIFICATION_BOSSBAR_COLOR = parser.getEnum("settings.notification.bossbar.color", BossBar.Color.BLUE); diff --git a/Insights-API/src/main/java/dev/frankheijden/insights/api/listeners/InsightsListener.java b/Insights-API/src/main/java/dev/frankheijden/insights/api/listeners/InsightsListener.java index 48093f00..49b96399 100644 --- a/Insights-API/src/main/java/dev/frankheijden/insights/api/listeners/InsightsListener.java +++ b/Insights-API/src/main/java/dev/frankheijden/insights/api/listeners/InsightsListener.java @@ -253,6 +253,15 @@ private Optional handleAddonAddition( AddonStorage addonStorage = plugin.getAddonStorage(); Optional storageOptional = addonStorage.get(key); if (storageOptional.isEmpty()) { + // Use tryAdd to atomically check if a scan is already in progress + if (!plugin.getAddonScanTracker().tryAdd(key)) { + // A scan is already in progress, but we need to remove the tracker + // since scanRegion won't be called + return Optional.empty(); + } + // Remove the tracker since scanRegion will add it again + plugin.getAddonScanTracker().remove(key); + // Notify the user scan started if (plugin.getSettings().canReceiveAreaScanNotifications(player)) { plugin.getMessages().getMessage(Messages.Key.AREA_SCAN_STARTED).addTemplates( @@ -352,8 +361,14 @@ protected void handleRemoval(Player player, Location location, ScanObject ite } private void scanRegion(Player player, Region region, Consumer storageConsumer) { + String key = region.getKey(); + + // Use tryAdd to atomically prevent duplicate scans for the same region + if (!plugin.getAddonScanTracker().tryAdd(key)) { + return; + } + // Submit the cuboid for scanning - plugin.getAddonScanTracker().add(region.getAddon()); List chunkParts = region.toChunkParts(); ScanTask.scan( plugin, @@ -365,10 +380,10 @@ private void scanRegion(Player player, Region region, Consumer storageC DistributionStorage::new, (storage, loc, acc) -> storage.mergeRight(acc), storage -> { - plugin.getAddonScanTracker().remove(region.getAddon()); + plugin.getAddonScanTracker().remove(key); // Store the cuboid - plugin.getAddonStorage().put(region.getKey(), storage); + plugin.getAddonStorage().put(key, storage); // Give the result back to the consumer storageConsumer.accept(storage); diff --git a/Insights-Core/src/main/java/dev/frankheijden/insights/Insights.java b/Insights-Core/src/main/java/dev/frankheijden/insights/Insights.java index cfe67e77..2ff0bd58 100644 --- a/Insights-Core/src/main/java/dev/frankheijden/insights/Insights.java +++ b/Insights-Core/src/main/java/dev/frankheijden/insights/Insights.java @@ -126,8 +126,8 @@ public void onEnable() { }, 1); playerList = new PlayerList(Bukkit.getOnlinePlayers()); - worldStorage = new WorldStorage(); - addonStorage = new AddonStorage(); + worldStorage = new WorldStorage(settings.CHUNK_CACHE_MAX_SIZE); + addonStorage = new AddonStorage(settings.ADDON_CACHE_MAX_SIZE, settings.ADDON_CACHE_TTL_MINUTES); worldChunkScanTracker = new WorldChunkScanTracker(); addonScanTracker = new AddonScanTracker(); executor = ContainerExecutorService.newExecutor( @@ -163,6 +163,7 @@ public void onDisable() { placeholderExpansion = null; } chunkContainerExecutor.shutdown(); + addonStorage.shutdown(); audiences.close(); } @@ -191,6 +192,14 @@ public void reloadSettings() { File file = new File(getDataFolder(), SETTINGS_FILE_NAME); try { settings = Settings.load(this, file, getResource(SETTINGS_FILE_NAME)).exceptionally(getLogger()); + // Recreate storages with new settings if they exist + if (worldStorage != null) { + worldStorage = new WorldStorage(settings.CHUNK_CACHE_MAX_SIZE); + } + if (addonStorage != null) { + addonStorage.shutdown(); + addonStorage = new AddonStorage(settings.ADDON_CACHE_MAX_SIZE, settings.ADDON_CACHE_TTL_MINUTES); + } } catch (IOException ex) { ex.printStackTrace(); } diff --git a/Insights-Core/src/main/java/dev/frankheijden/insights/listeners/ChunkListener.java b/Insights-Core/src/main/java/dev/frankheijden/insights/listeners/ChunkListener.java index 4de83836..e7ffa062 100644 --- a/Insights-Core/src/main/java/dev/frankheijden/insights/listeners/ChunkListener.java +++ b/Insights-Core/src/main/java/dev/frankheijden/insights/listeners/ChunkListener.java @@ -17,13 +17,13 @@ public ChunkListener(Insights plugin) { } /** - * Cleans up any chunk data from insights when a chunk unloads. + * Cleans up redstone count when chunk unloads. + * Chunk cache is NOT removed - managed by LRU (max 5000 chunks). */ @EventHandler public void onChunkUnload(ChunkUnloadEvent event) { Chunk chunk = event.getChunk(); long chunkKey = ChunkUtils.getKey(chunk); - plugin.getWorldStorage().getWorld(chunk.getWorld().getUID()).remove(chunkKey); insights.getRedstoneUpdateCount().remove(chunkKey); } } diff --git a/Insights-Core/src/main/java/dev/frankheijden/insights/listeners/PistonListener.java b/Insights-Core/src/main/java/dev/frankheijden/insights/listeners/PistonListener.java index dfaf1ebf..d8c1b192 100644 --- a/Insights-Core/src/main/java/dev/frankheijden/insights/listeners/PistonListener.java +++ b/Insights-Core/src/main/java/dev/frankheijden/insights/listeners/PistonListener.java @@ -97,11 +97,16 @@ private boolean handlePistonBlock(Block from, Block to) { if (storageOptional.isEmpty()) { if (regionOptional.isPresent()) { Region region = regionOptional.get(); - plugin.getAddonScanTracker().add(region.getAddon()); + String key = region.getKey(); + // Use tryAdd to atomically prevent duplicate scans + if (!plugin.getAddonScanTracker().tryAdd(key)) { + // Another scan is already in progress + return true; + } List chunkParts = region.toChunkParts(); ScanTask.scan(plugin, chunkParts, chunkParts.size(), ScanOptions.scanOnly(), info -> {}, storage -> { - plugin.getAddonScanTracker().remove(region.getAddon()); - plugin.getAddonStorage().put(region.getKey(), storage); + plugin.getAddonScanTracker().remove(key); + plugin.getAddonStorage().put(key, storage); }); } else { plugin.getChunkContainerExecutor().submit(chunk); diff --git a/Insights-Core/src/main/resources/config.yml b/Insights-Core/src/main/resources/config.yml index 8af21446..4d026416 100644 --- a/Insights-Core/src/main/resources/config.yml +++ b/Insights-Core/src/main/resources/config.yml @@ -11,6 +11,19 @@ settings: chunk-scans: mode: "ALWAYS" player-tracker-interval-ticks: 5 + + # Chunk cache: stores scanned chunk data per world (LRU eviction) + chunk-cache: + # Max chunks cached PER WORLD (e.g. 3 worlds = up to 15000 total) + max-size: 5000 + + # Addon cache: stores region data for Lands, WorldGuard, etc. (LRU + TTL) + addon-cache: + # Max regions in cache (LRU evicts least recently used when full) + max-size: 500 + # Minutes before cached region expires and requires re-scan + ttl-minutes: 10 + notification: type: "BOSSBAR" bossbar: @@ -48,4 +61,4 @@ settings: limit: 50000 aggregate-ticks: 10 aggregate-size: 30 - block-outside-region: false + block-outside-region: false \ No newline at end of file diff --git a/Insights-NMS/Core/src/main/java/dev/frankheijden/insights/nms/core/InsightsNMS.java b/Insights-NMS/Core/src/main/java/dev/frankheijden/insights/nms/core/InsightsNMS.java index c3f595a0..1e507813 100644 --- a/Insights-NMS/Core/src/main/java/dev/frankheijden/insights/nms/core/InsightsNMS.java +++ b/Insights-NMS/Core/src/main/java/dev/frankheijden/insights/nms/core/InsightsNMS.java @@ -34,7 +34,7 @@ public abstract void getUnloadedChunkSections( int chunkX, int chunkZ, Consumer sectionConsumer - ); + ) throws IOException; public abstract void getLoadedChunkEntities(Chunk chunk, Consumer entityConsumer); diff --git a/Insights-NMS/Current/src/main/java/dev/frankheijden/insights/nms/impl/InsightsNMSImpl.java b/Insights-NMS/Current/src/main/java/dev/frankheijden/insights/nms/impl/InsightsNMSImpl.java index 7e2a3a0b..59e6b3dc 100644 --- a/Insights-NMS/Current/src/main/java/dev/frankheijden/insights/nms/impl/InsightsNMSImpl.java +++ b/Insights-NMS/Current/src/main/java/dev/frankheijden/insights/nms/impl/InsightsNMSImpl.java @@ -1,24 +1,10 @@ package dev.frankheijden.insights.nms.impl; -import ca.spottedleaf.concurrentutil.util.Priority; -import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO; -import com.mojang.serialization.Codec; -import com.mojang.serialization.DataResult; import dev.frankheijden.insights.nms.core.ChunkEntity; import dev.frankheijden.insights.nms.core.ChunkSection; import dev.frankheijden.insights.nms.core.InsightsNMS; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.ListTag; -import net.minecraft.nbt.NbtOps; -import net.minecraft.nbt.Tag; -import net.minecraft.util.Mth; import net.minecraft.world.entity.Entity; -import net.minecraft.world.level.ChunkPos; -import net.minecraft.world.level.block.Blocks; -import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.LevelChunkSection; -import net.minecraft.world.level.chunk.PalettedContainer; -import net.minecraft.world.level.chunk.Strategy; import net.minecraft.world.level.chunk.status.ChunkStatus; import org.bukkit.Chunk; import org.bukkit.Material; @@ -27,9 +13,7 @@ import org.bukkit.craftbukkit.CraftWorld; import org.bukkit.craftbukkit.entity.CraftEntity; import org.bukkit.craftbukkit.util.CraftMagicNumbers; -import org.bukkit.entity.EntityType; import java.io.IOException; -import java.util.Optional; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -44,83 +28,31 @@ public void getLoadedChunkSections(Chunk chunk, Consumer sectionCo } @Override - public void getUnloadedChunkSections(World world, int chunkX, int chunkZ, Consumer sectionConsumer) { - var serverLevel = ((CraftWorld) world).getHandle(); - int sectionsCount = serverLevel.getSectionsCount(); - var chunkMap = serverLevel.getChunkSource().chunkMap; - var chunkPos = new ChunkPos(chunkX, chunkZ); - - Optional tagOptional = chunkMap.read(chunkPos).join(); - if (tagOptional.isEmpty()) return; - CompoundTag tag = tagOptional.get(); - - Optional optionalSectionsTagList = tag.getList("sections"); - if (optionalSectionsTagList.isEmpty()) { - logger.severe(String.format( - CHUNK_ERROR, - chunkX, - 0, - chunkZ, - "Sections tag is missing" - )); - return; - } - ListTag sectionsTagList = optionalSectionsTagList.get(); - - DataResult> dataResult; - int nonNullSectionCount = 0; - for (int i = 0; i < sectionsTagList.size(); i++) { - Optional optionalSectionTag = sectionsTagList.getCompound(i); - if (optionalSectionTag.isEmpty()) { - logger.severe(String.format( - CHUNK_ERROR, - chunkX, - i, - chunkZ, - "Section tag is missing" - )); - continue; - } - CompoundTag sectionTag = optionalSectionTag.get(); - var chunkSectionPart = sectionTag.getByte("Y").orElseThrow(); - var sectionIndex = serverLevel.getSectionIndexFromSectionY(chunkSectionPart); - if (sectionIndex < 0 || sectionIndex >= sectionsCount) continue; - - PalettedContainer blockStateContainer; - Strategy strategy = serverLevel.palettedContainerFactory().blockStatesStrategy(); - if (sectionTag.contains("block_states")) { - Codec> blockStateCodec = PalettedContainer.codecRW( - BlockState.CODEC, - strategy, - Blocks.AIR.defaultBlockState(), - new BlockState[0] - ); - dataResult = blockStateCodec.parse(NbtOps.INSTANCE, sectionTag.getCompound("block_states").orElseThrow()) - .promotePartial(message -> logger.severe(String.format( - CHUNK_ERROR, - chunkX, - chunkSectionPart, - chunkZ, - message - ))); - - try { - blockStateContainer = dataResult.getOrThrow(); - } catch (IllegalStateException ex) { - logger.severe(ex.getMessage()); - throw ex; - } + public void getUnloadedChunkSections( + World world, + int chunkX, + int chunkZ, + Consumer sectionConsumer + ) throws IOException { + // Use getChunkAtAsync to safely load the chunk without touching files directly + // This prevents data corruption and uses Bukkit's proper chunk loading mechanism + // The chunk will be loaded temporarily and then unloaded automatically + try { + Chunk chunk = world.getChunkAtAsync(chunkX, chunkZ, false).join(); + if (chunk != null) { + // Chunk loaded successfully, use the same logic as loaded chunks + getLoadedChunkSections(chunk, sectionConsumer); } else { - blockStateContainer = new PalettedContainer<>(Blocks.AIR.defaultBlockState(), strategy, new BlockState[0]); + // Chunk doesn't exist or couldn't be loaded + // Return null sections for all expected sections + var serverLevel = ((CraftWorld) world).getHandle(); + int sectionsCount = serverLevel.getSectionsCount(); + for (int i = 0; i < sectionsCount; i++) { + sectionConsumer.accept(null); + } } - - LevelChunkSection chunkSection = new LevelChunkSection(blockStateContainer, null); - sectionConsumer.accept(new ChunkSectionImpl(chunkSection, sectionIndex)); - nonNullSectionCount++; - } - - for (int i = nonNullSectionCount; i < sectionsCount; i++) { - sectionConsumer.accept(null); + } catch (Exception e) { + throw new IOException("Failed to load chunk at (" + chunkX + ", " + chunkZ + ")", e); } } @@ -144,40 +76,16 @@ public void getUnloadedChunkEntities( int chunkZ, Consumer entityConsumer ) throws IOException { - var serverLevel = ((CraftWorld) world).getHandle(); - CompoundTag tag = MoonriseRegionFileIO.loadData( - serverLevel, - chunkX, - chunkZ, - MoonriseRegionFileIO.RegionFileType.ENTITY_DATA, - Priority.BLOCKING - ); - if (tag == null) return; - - readChunkEntities(tag.getList("Entities").orElseThrow(), entityConsumer); - } - - private void readChunkEntities(ListTag listTag, Consumer entityConsumer) { - for (Tag tag : listTag) { - readChunkEntities((CompoundTag) tag, entityConsumer); - } - } - - private void readChunkEntities(CompoundTag nbt, Consumer entityConsumer) { - var typeOptional = net.minecraft.world.entity.EntityType.byString(nbt.getString("id").orElseThrow()); - if (typeOptional.isPresent()) { - String entityTypeName = net.minecraft.world.entity.EntityType.getKey(typeOptional.get()).getPath(); - ListTag posList = nbt.getList("Pos").orElseThrow(); - entityConsumer.accept(new ChunkEntity( - EntityType.fromName(entityTypeName), - Mth.floor(Mth.clamp(posList.getDouble(0).orElseThrow(), -3E7D, 3E7D)), - Mth.floor(Mth.clamp(posList.getDouble(1).orElseThrow(), -2E7D, 2E7D)), - Mth.floor(Mth.clamp(posList.getDouble(2).orElseThrow(), -3E7D, 3E7D)) - )); - } - - if (nbt.contains("Passengers")) { - readChunkEntities(nbt.getList("Passengers").orElseThrow(), entityConsumer); + // Use getChunkAtAsync to safely load the chunk without touching files directly + try { + Chunk chunk = world.getChunkAtAsync(chunkX, chunkZ, false).join(); + if (chunk != null) { + // Chunk loaded successfully, use the same logic as loaded chunks + getLoadedChunkEntities(chunk, entityConsumer); + } + // If chunk is null, no entities to return + } catch (Exception e) { + throw new IOException("Failed to load chunk entities at (" + chunkX + ", " + chunkZ + ")", e); } }