diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/ChunkSnapshotFactory.java b/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/ChunkSnapshotFactory.java index df79403c..d9b52852 100644 --- a/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/ChunkSnapshotFactory.java +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/ChunkSnapshotFactory.java @@ -1,6 +1,5 @@ package xyz.jpenilla.squaremap.common.chunksnapshot; -import com.mojang.serialization.Codec; import java.util.Arrays; import java.util.EnumMap; import java.util.Map; @@ -9,7 +8,6 @@ import net.minecraft.core.Holder; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.ListTag; -import net.minecraft.nbt.NbtOps; import net.minecraft.nbt.NbtUtils; import net.minecraft.util.Mth; import net.minecraft.world.level.ChunkPos; @@ -19,9 +17,6 @@ import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.ChunkAccess; import net.minecraft.world.level.chunk.LevelChunkSection; -import net.minecraft.world.level.chunk.PalettedContainer; -import net.minecraft.world.level.chunk.PalettedContainerFactory; -import net.minecraft.world.level.chunk.PalettedContainerRO; import net.minecraft.world.level.chunk.storage.SerializableChunkData; import net.minecraft.world.level.dimension.DimensionType; import net.minecraft.world.level.levelgen.Heightmap; @@ -51,8 +46,8 @@ public static ChunkSnapshot snapshotLiveChunk(final Level level, final ChunkAcce final LevelHeightAccessor heightAccessor = LevelHeightAccessor.create(chunk.getMinY(), chunk.getHeight()); final int sectionCount = heightAccessor.getSectionsCount(); final LevelChunkSection[] sections = chunk.getSections(); - final PalettedContainer[] states = new PalettedContainer[sectionCount]; - final PalettedContainerRO>[] biomes = new PalettedContainerRO[sectionCount]; + final SectionData[] states = new SectionData[sectionCount]; + final SectionData>[] biomes = new SectionData[sectionCount]; final boolean[] empty = new boolean[sectionCount]; if (!chunk.hasPrimedHeightmap(Heightmap.Types.WORLD_SURFACE)) { @@ -68,10 +63,10 @@ public static ChunkSnapshot snapshotLiveChunk(final Level level, final ChunkAcce if (sectionEmpty) { states[i] = EmptySectionHolder.getEmptySectionBlockStates(); } else { - states[i] = sections[i].getStates().copy(); + states[i] = SectionData.of(sections[i].getStates().copy()); } - biomes[i] = sections[i].getBiomes().copy(); + biomes[i] = SectionData.of(sections[i].getBiomes().copy()); } return new ChunkSnapshotImpl( @@ -91,12 +86,12 @@ public static ChunkSnapshot snapshotLiveChunk(final Level level, final ChunkAcce * *

The caller must supply NBT already upgraded to the current data version and classified * as {@link ChunkSnapshotEligibility#ELIGIBLE}. The data is only read, so it may be shared - * with other readers, but must not be mutated while this runs. This method does not check - * generation status or complete retrogen and may run off-thread.

+ * with other readers, but must not be mutated afterward; the snapshot keeps its data arrays. + * This method does not check generation status or complete retrogen and may run off-thread.

* * @param levelHeight the level's vertical bounds * @param dimensionType the level's dimension type - * @param containerFactory the level's palette container factory + * @param sectionDecoder the level's section decoder * @param chunkData the upgraded, snapshot-ready chunk data * @return the decoded snapshot * @throws SerializableChunkData.ChunkReadException if a section palette cannot be decoded @@ -105,23 +100,20 @@ public static ChunkSnapshot snapshotLiveChunk(final Level level, final ChunkAcce public static ChunkSnapshot snapshotFromChunkData( final LevelHeightAccessor levelHeight, final DimensionType dimensionType, - final PalettedContainerFactory containerFactory, + final SectionDecoder sectionDecoder, final CompoundTag chunkData ) { final ChunkPos chunkPos = readChunkPos(chunkData); final ListTag sectionTags = chunkData.getListOrEmpty(SerializableChunkData.SECTIONS_TAG); final int sectionCount = levelHeight.getSectionsCount(); - final PalettedContainer[] states = new PalettedContainer[sectionCount]; - final PalettedContainerRO>[] biomes = new PalettedContainerRO[sectionCount]; + final SectionData[] states = new SectionData[sectionCount]; + final SectionData>[] biomes = new SectionData[sectionCount]; final boolean[] empty = new boolean[sectionCount]; Arrays.fill(states, EmptySectionHolder.getEmptySectionBlockStates()); Arrays.fill(biomes, EmptySectionHolder.getEmptySectionBiomes()); Arrays.fill(empty, true); - final Codec>> biomesCodec = containerFactory.biomeContainerCodec(); - final Codec> blockStatesCodec = containerFactory.blockStatesContainerCodec(); - for (int i = 0; i < sectionTags.size(); ++i) { final Optional maybeSectionTag = sectionTags.getCompound(i); if (maybeSectionTag.isPresent()) { @@ -129,12 +121,16 @@ public static ChunkSnapshot snapshotFromChunkData( final int y = sectionTag.getByteOr(ChunkDataKeys.SECTION_Y, (byte) 0); final int index = y - levelHeight.getMinSectionY(); if (index >= 0 && index < sectionCount) { - final Optional> maybeBlocks = sectionTag.getCompound(ChunkDataKeys.BLOCK_STATES) - .map((container) -> blockStatesCodec.parse(NbtOps.INSTANCE, container).promotePartial((msg) -> Logging.logger().warn("Failed to decode chunk {} section {}: {}", chunkPos, y, msg)).getOrThrow(SerializableChunkData.ChunkReadException::new)); - final PalettedContainer blocks = maybeBlocks.orElse(EmptySectionHolder.getEmptySectionBlockStates()); - final Optional>> maybeBiomes = sectionTag.getCompound(ChunkDataKeys.BIOMES) - .map((container) -> biomesCodec.parse(NbtOps.INSTANCE, container).promotePartial((msg) -> Logging.logger().warn("Failed to decode chunk {} section {}: {}", chunkPos, y, msg)).getOrThrow(SerializableChunkData.ChunkReadException::new)); - final PalettedContainerRO> sectionBiomes = maybeBiomes.orElse(EmptySectionHolder.getEmptySectionBiomes()); + final SectionData blocks = sectionTag.getCompound(ChunkDataKeys.BLOCK_STATES) + .map(container -> sectionDecoder.blockStates(container) + .promotePartial(msg -> logSectionDecodeErrors(chunkPos, y, msg)) + .getOrThrow(SerializableChunkData.ChunkReadException::new)) + .orElseGet(EmptySectionHolder::getEmptySectionBlockStates); + final SectionData> sectionBiomes = sectionTag.getCompound(ChunkDataKeys.BIOMES) + .map(container -> sectionDecoder.biomes(container) + .promotePartial(msg -> logSectionDecodeErrors(chunkPos, y, msg)) + .getOrThrow(SerializableChunkData.ChunkReadException::new)) + .orElseGet(EmptySectionHolder::getEmptySectionBiomes); states[index] = blocks; empty[index] = !blocks.maybeHas(state -> !state.isAir()); biomes[index] = sectionBiomes; @@ -188,10 +184,15 @@ public static ChunkPos readChunkPos(final CompoundTag chunkData) { ); } + private static void logSectionDecodeErrors(final ChunkPos chunkPos, final int sectionY, final String message) { + // Same message as vanilla's SerializableChunkData.logErrors. + Logging.logger().error("Recoverable errors when loading section [{}, {}, {}]: {}", chunkPos.x(), sectionY, chunkPos.z(), message); + } + private static Map readOrComputeHeightmaps( final LevelHeightAccessor levelHeight, final CompoundTag chunkData, - final PalettedContainer[] states, + final SectionData[] states, final boolean[] empty ) { final int bits = Mth.ceillog2(levelHeight.getHeight() + 1); @@ -208,10 +209,10 @@ private static Map readOrComputeHeightmaps( } @SuppressWarnings({"unchecked", "rawtypes"}) - private static @Nullable Holder[] uniformBiomes(final PalettedContainerRO>[] biomes) { + private static @Nullable Holder[] uniformBiomes(final SectionData>[] biomes) { final @Nullable Holder[] uniform = new Holder[biomes.length]; for (int i = 0; i < biomes.length; i++) { - final Holder first = biomes[i].get(0, 0, 0); + final Holder first = biomes[i].get(0); // Checks palette entries only, so a palette with unused entries reads as mixed. if (!biomes[i].maybeHas(biome -> biome != first)) { uniform[i] = first; diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/ChunkSnapshotImpl.java b/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/ChunkSnapshotImpl.java index 0e97d9ec..19e46d93 100644 --- a/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/ChunkSnapshotImpl.java +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/ChunkSnapshotImpl.java @@ -11,8 +11,6 @@ import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; -import net.minecraft.world.level.chunk.PalettedContainer; -import net.minecraft.world.level.chunk.PalettedContainerRO; import net.minecraft.world.level.dimension.DimensionType; import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.material.FluidState; @@ -24,8 +22,8 @@ @DefaultQualifier(NonNull.class) record ChunkSnapshotImpl( LevelHeightAccessor heightAccessor, - PalettedContainer[] states, - PalettedContainerRO>[] biomes, + SectionData[] states, + SectionData>[] biomes, @Nullable Holder[] uniformBiomes, Map heightmaps, boolean[] emptySections, @@ -95,7 +93,7 @@ public Holder getNoiseBiome(final int quartX, final int quartY, final int if (uniform != null) { return uniform; } - return this.biomes[sectionIndex].get(quartX & 3, clampedQuartY & 3, quartZ & 3); + return this.biomes[sectionIndex].get((clampedQuartY & 3) << 4 | (quartZ & 3) << 2 | quartX & 3); } @Override diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/EmptySectionHolder.java b/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/EmptySectionHolder.java index f404344e..a08fe528 100644 --- a/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/EmptySectionHolder.java +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/EmptySectionHolder.java @@ -4,7 +4,6 @@ import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; -import net.minecraft.world.level.chunk.PalettedContainer; import net.minecraft.world.level.chunk.PalettedContainerFactory; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.NonNull; @@ -12,31 +11,28 @@ @DefaultQualifier(NonNull.class) public final class EmptySectionHolder { - private static @MonotonicNonNull PalettedContainer EMPTY_SECTION_BLOCK_STATES; - private static @MonotonicNonNull PalettedContainer> EMPTY_SECTION_BIOMES; + private static @MonotonicNonNull SectionData EMPTY_SECTION_BLOCK_STATES; + private static @MonotonicNonNull SectionData> EMPTY_SECTION_BIOMES; private EmptySectionHolder() { } public static void init(final PalettedContainerFactory palettedContainerFactory) { if (EMPTY_SECTION_BLOCK_STATES == null) { - EMPTY_SECTION_BLOCK_STATES = new PalettedContainer<>( - Blocks.AIR.defaultBlockState(), - palettedContainerFactory.blockStatesStrategy() - ); + EMPTY_SECTION_BLOCK_STATES = SectionData.Packed.single(Blocks.AIR.defaultBlockState()); } // Always replace, biomes can change between integrated server runs - EMPTY_SECTION_BIOMES = palettedContainerFactory.createForBiomes(); + EMPTY_SECTION_BIOMES = SectionData.Packed.single(palettedContainerFactory.defaultBiome()); } - static PalettedContainer getEmptySectionBlockStates() { + static SectionData getEmptySectionBlockStates() { if (EMPTY_SECTION_BLOCK_STATES == null) { throw new IllegalStateException("EmptySectionHolder not initialized"); } return EMPTY_SECTION_BLOCK_STATES; } - static PalettedContainer> getEmptySectionBiomes() { + static SectionData> getEmptySectionBiomes() { if (EMPTY_SECTION_BIOMES == null) { throw new IllegalStateException("EmptySectionHolder not initialized"); } diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/HeightmapSnapshot.java b/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/HeightmapSnapshot.java index 3a69cc0f..61fbcce8 100644 --- a/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/HeightmapSnapshot.java +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/HeightmapSnapshot.java @@ -7,7 +7,6 @@ import net.minecraft.world.level.LevelHeightAccessor; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.ChunkAccess; -import net.minecraft.world.level.chunk.PalettedContainer; import net.minecraft.world.level.levelgen.Heightmap; final class HeightmapSnapshot { @@ -36,7 +35,7 @@ final class HeightmapSnapshot { static HeightmapSnapshot computeWorldSurface( final LevelHeightAccessor heightAccessor, - final PalettedContainer[] sections, + final SectionData[] sections, final boolean[] empty ) { final SimpleBitStorage data = new SimpleBitStorage(Mth.ceillog2(heightAccessor.getHeight() + 1), 256); @@ -44,7 +43,7 @@ static HeightmapSnapshot computeWorldSurface( // Rebuild directly from palette data using the heightmap type's predicate, // avoiding live section construction and block counting. for (int sectionIndex = sections.length - 1; sectionIndex >= 0; sectionIndex--) { - final PalettedContainer section = sections[sectionIndex]; + final SectionData section = sections[sectionIndex]; if (empty[sectionIndex]) { continue; } @@ -56,7 +55,7 @@ static HeightmapSnapshot computeWorldSurface( continue; } for (int y = 15; y >= 0; y--) { - if (isOpaque.test(section.get(x, y, z))) { + if (isOpaque.test(section.get(y << 8 | z << 4 | x))) { data.set(index, sectionIndex * 16 + y + 1); break; } diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/SectionData.java b/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/SectionData.java new file mode 100644 index 00000000..15e319f2 --- /dev/null +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/SectionData.java @@ -0,0 +1,80 @@ +package xyz.jpenilla.squaremap.common.chunksnapshot; + +import java.util.function.Predicate; +import net.minecraft.util.SimpleBitStorage; +import net.minecraft.world.level.chunk.MissingPaletteEntryException; +import net.minecraft.world.level.chunk.PalettedContainer; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.checkerframework.framework.qual.DefaultQualifier; + +/** + * Read-only block states or biomes of a chunk section. + * + * @param the value type + */ +@DefaultQualifier(NonNull.class) +sealed interface SectionData permits SectionData.Container, SectionData.Packed { + /** + * Gets a value by its index, as in {@link PalettedContainer#get(int)}. + * + * @param index the index + * @return the value + */ + T get(int index); + + /** + * Checks the palette, like {@link PalettedContainer#maybeHas(Predicate)}. + * + * @param predicate the predicate + * @return whether any palette entry matches + */ + boolean maybeHas(Predicate predicate); + + static SectionData of(final PalettedContainer container) { + return new Container<>(container); + } + + record Container(PalettedContainer container) implements SectionData { + @Override + public T get(final int index) { + return this.container.get(index); + } + + @Override + public boolean maybeHas(final Predicate predicate) { + return this.container.maybeHas(predicate); + } + } + + // Storage is null for a single-value palette. + record Packed(T[] palette, @Nullable SimpleBitStorage storage) implements SectionData { + @SuppressWarnings("unchecked") + static Packed single(final T value) { + return new Packed<>((T[]) new Object[]{value}, null); + } + + @Override + public T get(final int index) { + if (this.storage == null) { + return this.palette[0]; + } + final int id = this.storage.get(index); + if (id >= this.palette.length) { + // Like vanilla's linear and hash map palettes, which also fail on read. + throw new MissingPaletteEntryException(id); + } + return this.palette[id]; + } + + @Override + public boolean maybeHas(final Predicate predicate) { + for (final T value : this.palette) { + if (predicate.test(value)) { + return true; + } + } + return false; + } + } +} diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/SectionDecoder.java b/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/SectionDecoder.java new file mode 100644 index 00000000..e39294e8 --- /dev/null +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/SectionDecoder.java @@ -0,0 +1,151 @@ +package xyz.jpenilla.squaremap.common.chunksnapshot; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import java.util.Map; +import java.util.StringJoiner; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.IntFunction; +import java.util.stream.LongStream; +import net.minecraft.core.Holder; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.LongArrayTag; +import net.minecraft.nbt.NbtOps; +import net.minecraft.nbt.Tag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.Mth; +import net.minecraft.util.SimpleBitStorage; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.checkerframework.framework.qual.DefaultQualifier; + +/** + * Decodes saved sections with the same results as vanilla's container codecs, decoding each + * distinct palette entry once. Thread-safe. + */ +@DefaultQualifier(NonNull.class) +public final class SectionDecoder { + private static final String PALETTE = "palette"; + private static final String DATA = "data"; + + private final CachingPaletteDecoder blockStates; + private final int blockEntries; + private final CachingPaletteDecoder> biomes; + private final int biomeEntries; + + public SectionDecoder(final PalettedContainerFactory containerFactory, final Registry biomeRegistry) { + this.blockStates = new CachingPaletteDecoder<>(BlockState.CODEC, containerFactory.defaultBlockState()); + this.blockEntries = containerFactory.blockStatesStrategy().entryCount(); + this.biomes = new CachingPaletteDecoder<>(biomeRegistry.holderByNameCodec(), containerFactory.defaultBiome()); + this.biomeEntries = containerFactory.biomeStrategy().entryCount(); + } + + public static SectionDecoder create(final ServerLevel level) { + return new SectionDecoder(level.palettedContainerFactory(), level.registryAccess().lookupOrThrow(Registries.BIOME)); + } + + /** + * Decodes a section's block states. + * + * @param container the saved {@code block_states} tag + * @return the block states + */ + DataResult> blockStates(final CompoundTag container) { + return this.blockStates.resolvePalette(container, BlockState[]::new).flatMap(palette -> { + // Matches Strategy.createForBlockStates: at least 4 bits once there is more than one entry. + final int bits = palette.length == 1 ? 0 : Math.max(4, Mth.ceillog2(palette.length)); + return packed(palette, container, bits, this.blockEntries); + }); + } + + /** + * Decodes a section's biomes. + * + * @param container the saved {@code biomes} tag + * @return the biomes + */ + @SuppressWarnings("unchecked") + DataResult>> biomes(final CompoundTag container) { + final DataResult[]> palette = this.biomes.resolvePalette(container, Holder[]::new); + // Matches Strategy.createForBiomes, which stores the minimum bits for the palette size. + return palette.flatMap(entries -> packed(entries, container, Mth.ceillog2(entries.length), this.biomeEntries)); + } + + private static DataResult> packed(final T[] palette, final CompoundTag container, final int bits, final int entryCount) { + if (bits == 0) { + // Vanilla ignores data for single-value palettes. + return DataResult.success(new SectionData.Packed<>(palette, null)); + } + final long @Nullable [] data = data(container.get(DATA)); + if (data == null) { + return DataResult.error(() -> "Missing values for non-zero storage"); + } + try { + return DataResult.success(new SectionData.Packed<>(palette, new SimpleBitStorage(bits, entryCount, data))); + } catch (final SimpleBitStorage.InitializationException e) { + return DataResult.error(() -> "Failed to read PalettedContainer: " + e.getMessage()); + } + } + + private static long @Nullable [] data(final @Nullable Tag tag) { + if (tag instanceof LongArrayTag longArray) { + return longArray.getAsLongArray(); + } + if (tag == null) { + return null; + } + // Vanilla also accepts other numeric lists, and its lenient data field treats unreadable data as missing. + return NbtOps.INSTANCE.getLongStream(tag).result().map(LongStream::toArray).orElse(null); + } + + // Caches entries that decode without errors, keyed by their tags, which compare by value. + private record CachingPaletteDecoder(Codec codec, T defaultValue, Map decoded) { + CachingPaletteDecoder(final Codec codec, final T defaultValue) { + this(codec, defaultValue, new ConcurrentHashMap<>()); + } + + DataResult resolvePalette(final CompoundTag container, final IntFunction newArray) { + if (!(container.get(PALETTE) instanceof ListTag paletteTag) || paletteTag.isEmpty()) { + return DataResult.error(() -> "Missing or empty palette"); + } + final T[] palette = newArray.apply(paletteTag.size()); + @Nullable StringJoiner errors = null; + for (int i = 0; i < palette.length; i++) { + final Tag entry = paletteTag.get(i); + final @Nullable T cached = this.decoded.get(entry); + if (cached != null) { + palette[i] = cached; + continue; + } + final DataResult result = this.codec.parse(NbtOps.INSTANCE, entry); + if (result instanceof DataResult.Error error) { + // Like ExtraCodecs.orElsePartial in vanilla's container codecs: the partial result if any, otherwise the default. + if (errors == null) { + errors = new StringJoiner("; "); + } + if (error.partialValue().isPresent()) { + palette[i] = error.partialValue().get(); + errors.add(error.message()); + } else { + palette[i] = this.defaultValue; + errors.add("(" + error.message() + " -> using default)"); + } + } else { + final T value = result.getOrThrow(); + palette[i] = value; + this.decoded.put(entry, value); + } + } + if (errors != null) { + return DataResult.error(errors::toString, palette); + } + return DataResult.success(palette); + } + } +} diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/VanillaChunkSnapshotProvider.java b/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/VanillaChunkSnapshotProvider.java index b8800ee5..72e76c55 100644 --- a/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/VanillaChunkSnapshotProvider.java +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/VanillaChunkSnapshotProvider.java @@ -25,7 +25,6 @@ import net.minecraft.world.level.TicketStorage; import net.minecraft.world.level.chunk.ChunkAccess; import net.minecraft.world.level.chunk.ImposterProtoChunk; -import net.minecraft.world.level.chunk.PalettedContainerFactory; import net.minecraft.world.level.chunk.status.ChunkStatus; import net.minecraft.world.level.dimension.DimensionType; import net.minecraft.world.level.levelgen.BelowZeroRetrogen; @@ -35,7 +34,7 @@ import xyz.jpenilla.squaremap.common.util.concurrent.CompletableFutures; @DefaultQualifier(NonNull.class) -record VanillaChunkSnapshotProvider(ServerLevel level, boolean moonrise) implements ChunkSnapshotProvider { +record VanillaChunkSnapshotProvider(ServerLevel level, boolean moonrise, SectionDecoder sectionDecoder) implements ChunkSnapshotProvider { private Executor mainThreadExecutor() { return task -> { if (this.level.getServer().isSameThread()) { @@ -71,7 +70,6 @@ private Executor mainThreadExecutor() { // Read in the same main thread task as the holder checks: unload saves are queued to the // IO worker on main, so this read is ordered after any save of a chunk we didn't see. final LevelHeightAccessor heightAccessor = LevelHeightAccessor.create(this.level.getMinY(), this.level.getHeight()); - final PalettedContainerFactory palettedContainerFactory = this.level.palettedContainerFactory(); final DimensionType dimensionType = this.level.dimensionType(); return chunkMap.squaremap$readChunk(chunkPos).thenComposeAsync( maybeTag -> { @@ -85,7 +83,7 @@ private Executor mainThreadExecutor() { case INELIGIBLE -> CompletableFuture.completedFuture(null); case ELIGIBLE -> { if (ChunkSnapshotFactory.chunkPosMatches(tag, x, z)) { - yield CompletableFuture.completedFuture(ChunkSnapshotFactory.snapshotFromChunkData(heightAccessor, dimensionType, palettedContainerFactory, tag)); + yield CompletableFuture.completedFuture(ChunkSnapshotFactory.snapshotFromChunkData(heightAccessor, dimensionType, this.sectionDecoder, tag)); } else { yield this.vanillaChunkSystemSnapshot(x, z); } @@ -200,7 +198,6 @@ private CompletableFuture vanillaLoadChunkAsync( final ChunkTaskScheduler scheduler = ((ChunkSystemServerLevel) this.level).moonrise$getChunkTaskScheduler(); final Executor executor = task -> scheduler.loadExecutor.createTask(task, Priority.NORMAL).queue(); final LevelHeightAccessor heightAccessor = LevelHeightAccessor.create(this.level.getMinY(), this.level.getHeight()); - final PalettedContainerFactory palettedContainerFactory = this.level.palettedContainerFactory(); final DimensionType dimensionType = this.level.dimensionType(); return data.thenComposeAsync( rawTag -> { @@ -214,7 +211,7 @@ private CompletableFuture vanillaLoadChunkAsync( case INELIGIBLE -> CompletableFuture.completedFuture(null); case ELIGIBLE -> { if (ChunkSnapshotFactory.chunkPosMatches(tag, x, z)) { - yield CompletableFuture.completedFuture(ChunkSnapshotFactory.snapshotFromChunkData(heightAccessor, dimensionType, palettedContainerFactory, tag)); + yield CompletableFuture.completedFuture(ChunkSnapshotFactory.snapshotFromChunkData(heightAccessor, dimensionType, this.sectionDecoder, tag)); } else { yield this.moonriseChunkSystemSnapshot(x, z); } diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/VanillaChunkSnapshotProviderFactory.java b/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/VanillaChunkSnapshotProviderFactory.java index dd0970a9..5bc6a2c0 100644 --- a/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/VanillaChunkSnapshotProviderFactory.java +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/chunksnapshot/VanillaChunkSnapshotProviderFactory.java @@ -19,6 +19,6 @@ private VanillaChunkSnapshotProviderFactory(final SquaremapPlatform platform) { @Override public ChunkSnapshotProvider createChunkSnapshotProvider(final ServerLevel level) { - return new VanillaChunkSnapshotProvider(level, this.platform.hasMod("moonrise")); + return new VanillaChunkSnapshotProvider(level, this.platform.hasMod("moonrise"), SectionDecoder.create(level)); } } diff --git a/common/src/test/java/xyz/jpenilla/squaremap/common/chunksnapshot/ChunkSnapshotFactoryTest.java b/common/src/test/java/xyz/jpenilla/squaremap/common/chunksnapshot/ChunkSnapshotFactoryTest.java index 17a3748f..85e43ac8 100644 --- a/common/src/test/java/xyz/jpenilla/squaremap/common/chunksnapshot/ChunkSnapshotFactoryTest.java +++ b/common/src/test/java/xyz/jpenilla/squaremap/common/chunksnapshot/ChunkSnapshotFactoryTest.java @@ -40,6 +40,7 @@ class ChunkSnapshotFactoryTest { private static PalettedContainerFactory factory; + private static SectionDecoder decoder; private static DimensionType dimensionType; @BeforeAll @@ -51,6 +52,7 @@ static void bootstrap() { biomes.register(Biomes.PLAINS, registries.lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.PLAINS).value(), RegistrationInfo.BUILT_IN); biomes.freeze(); factory = PalettedContainerFactory.create(new RegistryAccess.ImmutableRegistryAccess(List.of(biomes))); + decoder = new SectionDecoder(factory, biomes); dimensionType = registries.lookupOrThrow(Registries.DIMENSION_TYPE).getOrThrow(BuiltinDimensionTypes.OVERWORLD).value(); EmptySectionHolder.init(factory); } @@ -69,7 +71,7 @@ void reconstructsWorldSurfaceLikeVanilla(final int minY, final int height, final saved.remove(SerializableChunkData.HEIGHTMAPS_TAG); } - final ChunkSnapshot snapshot = ChunkSnapshotFactory.snapshotFromChunkData(chunk, dimensionType, factory, saved); + final ChunkSnapshot snapshot = ChunkSnapshotFactory.snapshotFromChunkData(chunk, dimensionType, decoder, saved); final int[] expected = new int[256]; final int[] actual = new int[256]; for (int z = 0; z < 16; z++) { @@ -84,7 +86,7 @@ void reconstructsWorldSurfaceLikeVanilla(final int minY, final int height, final @Test void identifiesEmptyDecodedSections() { final ProtoChunk chunk = terrainFixture(-64, 384); - final ChunkSnapshot snapshot = ChunkSnapshotFactory.snapshotFromChunkData(chunk, dimensionType, factory, serialize(chunk)); + final ChunkSnapshot snapshot = ChunkSnapshotFactory.snapshotFromChunkData(chunk, dimensionType, decoder, serialize(chunk)); for (int i = 0; i < chunk.getSectionsCount(); i++) { assertEquals(chunk.getSection(i).hasOnlyAir(), snapshot.sectionEmpty(i), "Section " + i); } diff --git a/common/src/test/java/xyz/jpenilla/squaremap/common/chunksnapshot/SectionDecoderTest.java b/common/src/test/java/xyz/jpenilla/squaremap/common/chunksnapshot/SectionDecoderTest.java new file mode 100644 index 00000000..dd2c7aa3 --- /dev/null +++ b/common/src/test/java/xyz/jpenilla/squaremap/common/chunksnapshot/SectionDecoderTest.java @@ -0,0 +1,211 @@ +package xyz.jpenilla.squaremap.common.chunksnapshot; + +import com.mojang.serialization.DataResult; +import com.mojang.serialization.Lifecycle; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; +import net.minecraft.SharedConstants; +import net.minecraft.core.Holder; +import net.minecraft.core.MappedRegistry; +import net.minecraft.core.RegistrationInfo; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.data.registries.VanillaRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.LongTag; +import net.minecraft.nbt.NbtOps; +import net.minecraft.nbt.StringTag; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.MissingPaletteEntryException; +import net.minecraft.world.level.chunk.PalettedContainer; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.storage.SerializableChunkData; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Compares {@link SectionDecoder} against vanilla's container codecs on the same section data. + */ +class SectionDecoderTest { + private static PalettedContainerFactory factory; + private static SectionDecoder decoder; + private static List allStates; + private static List> allBiomes; + + @BeforeAll + static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + final var registries = VanillaRegistries.createWorldLookup(); + final MappedRegistry biomes = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable()); + registries.lookupOrThrow(Registries.BIOME).listElements() + .forEach(biome -> biomes.register(biome.key(), biome.value(), RegistrationInfo.BUILT_IN)); + biomes.freeze(); + factory = PalettedContainerFactory.create(new RegistryAccess.ImmutableRegistryAccess(List.of(biomes))); + decoder = new SectionDecoder(factory, biomes); + allStates = new ArrayList<>(); + Block.BLOCK_STATE_REGISTRY.forEach(allStates::add); + allBiomes = new ArrayList<>(); + factory.biomeStrategy().globalMap().forEach(allBiomes::add); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3, 5, 16, 17, 100, 256, 257, 1000}) + void decodesBlockStatesLikeVanilla(final int paletteSize) { + assertBlockStatesMatch(blockStatesTag(paletteSize, paletteSize)); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3, 4, 5, 8, 9, 30}) + void decodesBiomesLikeVanilla(final int paletteSize) { + assertBiomesMatch(biomesTag(paletteSize)); + } + + @Test + void unknownBlockUsesDefault() { + final CompoundTag tag = blockStatesTag(5, 5); + tag.getListOrEmpty("palette").set(2, StringTag.valueOf("squaremap_test:missing")); + assertBlockStatesMatch(tag); + } + + @Test + void unknownBiomeUsesDefault() { + final CompoundTag tag = biomesTag(3); + tag.getListOrEmpty("palette").set(1, StringTag.valueOf("squaremap_test:missing")); + assertBiomesMatch(tag); + } + + @Test + void missingDataMatchesVanilla() { + final CompoundTag tag = blockStatesTag(5, 5); + tag.remove("data"); + assertThrows(SerializableChunkData.ChunkReadException.class, () -> vanillaBlockStates(tag)); + assertThrows(SerializableChunkData.ChunkReadException.class, () -> decodeBlockStates(tag)); + } + + @Test + void wrongDataLengthMatchesVanilla() { + final CompoundTag tag = blockStatesTag(5, 5); + tag.putLongArray("data", new long[10]); + assertThrows(SerializableChunkData.ChunkReadException.class, () -> vanillaBlockStates(tag)); + assertThrows(SerializableChunkData.ChunkReadException.class, () -> decodeBlockStates(tag)); + } + + @Test + void listDataMatchesVanilla() { + final CompoundTag tag = blockStatesTag(5, 5); + final ListTag data = new ListTag(); + for (final long value : tag.getLongArray("data").orElseThrow()) { + data.add(LongTag.valueOf(value)); + } + tag.put("data", data); + assertBlockStatesMatch(tag); + } + + @ParameterizedTest + @ValueSource(ints = {5, 300}) + void outOfRangeIndexMatchesVanilla(final int paletteSize) { + final CompoundTag tag = blockStatesTag(paletteSize, paletteSize); + final long[] data = tag.getLongArray("data").orElseThrow(); + final int bits = paletteSize <= 16 ? 4 : 9; + // Set the first value to the highest index the bits can hold, past the end of the palette. + data[0] = data[0] & ~((1L << bits) - 1) | (1L << bits) - 1; + tag.putLongArray("data", data); + // Vanilla fails on read for small palettes, but repacks global ones on decode and throws there. + // Our decoder never repacks, so it fails on read for both, with the same exception. + if (paletteSize <= 256) { + final SectionData vanilla = vanillaBlockStates(tag); + assertThrows(MissingPaletteEntryException.class, () -> vanilla.get(0)); + } else { + assertThrows(MissingPaletteEntryException.class, () -> vanillaBlockStates(tag)); + } + final SectionData decoded = decodeBlockStates(tag); + assertThrows(MissingPaletteEntryException.class, () -> decoded.get(0)); + } + + @Test + void emptyPaletteFails() { + final CompoundTag tag = new CompoundTag(); + tag.put("palette", new ListTag()); + // Vanilla decodes this, but every read from the section throws. + assertThrows(SerializableChunkData.ChunkReadException.class, () -> decodeBlockStates(tag)); + } + + private static CompoundTag blockStatesTag(final int paletteSize, final long seed) { + final Random random = new Random(seed); + final List states = new ArrayList<>(allStates); + Collections.shuffle(states, random); + final PalettedContainer container = factory.createForBlockStates(); + // Use every chosen state at least once, so the written palette has exactly paletteSize entries. + for (int i = 0; i < 4096; i++) { + final BlockState state = states.get(i < paletteSize ? i : random.nextInt(paletteSize)); + container.set(i & 15, i >> 8, i >> 4 & 15, state); + } + final CompoundTag tag = (CompoundTag) factory.blockStatesContainerCodec().encodeStart(NbtOps.INSTANCE, container).getOrThrow(); + assertEquals(paletteSize, tag.getListOrEmpty("palette").size()); + return tag; + } + + private static CompoundTag biomesTag(final int paletteSize) { + final Random random = new Random(paletteSize); + final PalettedContainer> container = factory.createForBiomes(); + for (int i = 0; i < 64; i++) { + final Holder biome = allBiomes.get(i < paletteSize ? i : random.nextInt(paletteSize)); + container.set(i & 3, i >> 4, i >> 2 & 3, biome); + } + final CompoundTag tag = (CompoundTag) factory.biomeContainerCodec().encodeStart(NbtOps.INSTANCE, container).getOrThrow(); + assertEquals(paletteSize, tag.getListOrEmpty("palette").size()); + return tag; + } + + // Like ChunkSnapshotFactory, without logging. + private static T orThrow(final DataResult result) { + return result.promotePartial(msg -> {}).getOrThrow(SerializableChunkData.ChunkReadException::new); + } + + private static SectionData decodeBlockStates(final CompoundTag tag) { + return orThrow(decoder.blockStates(tag)); + } + + private static SectionData> decodeBiomes(final CompoundTag tag) { + return orThrow(decoder.biomes(tag)); + } + + private static SectionData vanillaBlockStates(final CompoundTag tag) { + return SectionData.of(orThrow(factory.blockStatesContainerCodec().parse(NbtOps.INSTANCE, tag))); + } + + private static SectionData> vanillaBiomes(final CompoundTag tag) { + return SectionData.of((PalettedContainer>) orThrow(factory.biomeContainerCodec().parse(NbtOps.INSTANCE, tag))); + } + + private static void assertBlockStatesMatch(final CompoundTag tag) { + assertSectionDataMatch(vanillaBlockStates(tag), decodeBlockStates(tag), 4096); + } + + private static void assertBiomesMatch(final CompoundTag tag) { + assertSectionDataMatch(vanillaBiomes(tag), decodeBiomes(tag), 64); + } + + // For fixtures that decode; any exception fails the test. + private static void assertSectionDataMatch(final SectionData expected, final SectionData actual, final int size) { + for (int i = 0; i < size; i++) { + assertEquals(expected.get(i), actual.get(i), "Index " + i); + } + assertEquals( + expected.maybeHas(value -> !(value instanceof BlockState state) || !state.isAir()), + actual.maybeHas(value -> !(value instanceof BlockState state) || !state.isAir()) + ); + } +} diff --git a/paper/src/main/java/xyz/jpenilla/squaremap/paper/chunksnapshot/PaperChunkSnapshotProvider.java b/paper/src/main/java/xyz/jpenilla/squaremap/paper/chunksnapshot/PaperChunkSnapshotProvider.java index ffcd1496..16b8b6ff 100644 --- a/paper/src/main/java/xyz/jpenilla/squaremap/paper/chunksnapshot/PaperChunkSnapshotProvider.java +++ b/paper/src/main/java/xyz/jpenilla/squaremap/paper/chunksnapshot/PaperChunkSnapshotProvider.java @@ -15,7 +15,6 @@ import net.minecraft.world.level.LevelHeightAccessor; import net.minecraft.world.level.chunk.ChunkAccess; import net.minecraft.world.level.chunk.ImposterProtoChunk; -import net.minecraft.world.level.chunk.PalettedContainerFactory; import net.minecraft.world.level.chunk.status.ChunkStatus; import net.minecraft.world.level.dimension.DimensionType; import net.minecraft.world.level.levelgen.BelowZeroRetrogen; @@ -30,13 +29,15 @@ import xyz.jpenilla.squaremap.common.chunksnapshot.ChunkSnapshotFactory; import xyz.jpenilla.squaremap.common.chunksnapshot.ChunkSnapshotProvider; import xyz.jpenilla.squaremap.common.util.concurrent.CompletableFutures; +import xyz.jpenilla.squaremap.common.chunksnapshot.SectionDecoder; import xyz.jpenilla.squaremap.paper.util.Folia; @DefaultQualifier(NonNull.class) record PaperChunkSnapshotProvider( ServerLevel level, Server server, - JavaPlugin plugin + JavaPlugin plugin, + SectionDecoder sectionDecoder ) implements ChunkSnapshotProvider { @Override public CompletableFuture<@Nullable ChunkSnapshot> asyncSnapshot(final int x, final int z) { @@ -73,7 +74,6 @@ record PaperChunkSnapshotProvider( final ChunkTaskScheduler scheduler = this.level.moonrise$getChunkTaskScheduler(); final Executor executor = task -> scheduler.loadExecutor.createTask(task, Priority.NORMAL).queue(); final LevelHeightAccessor heightAccessor = LevelHeightAccessor.create(this.level.getMinY(), this.level.getHeight()); - final PalettedContainerFactory palettedContainerFactory = this.level.palettedContainerFactory(); final DimensionType dimensionType = this.level.dimensionType(); return data.thenComposeAsync( rawTag -> { @@ -87,7 +87,7 @@ record PaperChunkSnapshotProvider( case INELIGIBLE -> CompletableFuture.completedFuture(null); case ELIGIBLE -> { if (ChunkSnapshotFactory.chunkPosMatches(tag, x, z)) { - yield CompletableFuture.completedFuture(ChunkSnapshotFactory.snapshotFromChunkData(heightAccessor, dimensionType, palettedContainerFactory, tag)); + yield CompletableFuture.completedFuture(ChunkSnapshotFactory.snapshotFromChunkData(heightAccessor, dimensionType, this.sectionDecoder, tag)); } else { yield this.chunkSystemSnapshot(x, z); } diff --git a/paper/src/main/java/xyz/jpenilla/squaremap/paper/chunksnapshot/PaperChunkSnapshotProviderFactory.java b/paper/src/main/java/xyz/jpenilla/squaremap/paper/chunksnapshot/PaperChunkSnapshotProviderFactory.java index 44fbc6c1..52069018 100644 --- a/paper/src/main/java/xyz/jpenilla/squaremap/paper/chunksnapshot/PaperChunkSnapshotProviderFactory.java +++ b/paper/src/main/java/xyz/jpenilla/squaremap/paper/chunksnapshot/PaperChunkSnapshotProviderFactory.java @@ -9,6 +9,7 @@ import org.checkerframework.framework.qual.DefaultQualifier; import xyz.jpenilla.squaremap.common.chunksnapshot.ChunkSnapshotProvider; import xyz.jpenilla.squaremap.common.chunksnapshot.ChunkSnapshotProviderFactory; +import xyz.jpenilla.squaremap.common.chunksnapshot.SectionDecoder; @DefaultQualifier(NonNull.class) @Singleton @@ -24,6 +25,6 @@ private PaperChunkSnapshotProviderFactory(final Server server, final JavaPlugin @Override public ChunkSnapshotProvider createChunkSnapshotProvider(final ServerLevel level) { - return new PaperChunkSnapshotProvider(level, this.server, this.plugin); + return new PaperChunkSnapshotProvider(level, this.server, this.plugin, SectionDecoder.create(level)); } }