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));
}
}