diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuator.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuator.java index e62344077..e22dda0cd 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuator.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuator.java @@ -120,13 +120,40 @@ public final class IrisTransitionGeometryActuator extends EngineAssignedComponen @Override public BoundaryColumnGeometry.Voxel voxel(int localX, int worldY, int localZ) { - NativeBlockState state = blocks.getRaw(localX, worldY - minimumY, localZ); - if (state == null) { - if (air == null) { - air = IrisPlatforms.get().registries().air(); - } - state = air; + return voxelOf(stateAt(localX, worldY - minimumY, localZ)); + } + + @Override + public void scanColumn(int localX, int localZ, int minimumY, int height, SavedTerrainChunk.VoxelRuns runs) { + if (height <= 0) { + return; } + int base = minimumY - this.minimumY; + NativeBlockState runState = stateAt(localX, base, localZ); + int start = 0; + for (int offset = 1; offset < height; offset++) { + NativeBlockState state = stateAt(localX, base + offset, localZ); + if (state != runState) { + runs.add(start, offset, voxelOf(runState)); + start = offset; + runState = state; + } + } + runs.add(start, height, voxelOf(runState)); + } + + private NativeBlockState stateAt(int localX, int offset, int localZ) { + NativeBlockState state = blocks.getRaw(localX, offset, localZ); + if (state != null) { + return state; + } + if (air == null) { + air = IrisPlatforms.get().registries().air(); + } + return air; + } + + private BoundaryColumnGeometry.Voxel voxelOf(NativeBlockState state) { if (state != lastState) { lastVoxel = voxels.computeIfAbsent(state, GeometrySource::encode); lastState = state; diff --git a/core/src/main/java/art/arcane/iris/world/history/BoundaryColumnGeometry.java b/core/src/main/java/art/arcane/iris/world/history/BoundaryColumnGeometry.java index bdccfd5bb..13272c52b 100644 --- a/core/src/main/java/art/arcane/iris/world/history/BoundaryColumnGeometry.java +++ b/core/src/main/java/art/arcane/iris/world/history/BoundaryColumnGeometry.java @@ -257,11 +257,16 @@ public final class BoundaryColumnGeometry implements NativeBlockColumn { } void add(Voxel voxel) { + add(voxel, 1); + } + + void add(Voxel voxel, int run) { Objects.requireNonNull(voxel, "Geometry voxel"); if (count == 0 || !sameVoxel(voxels[count - 1], voxel)) { voxels[count++] = voxel; } - ends[count - 1] = ++length; + length += run; + ends[count - 1] = length; } BoundaryColumnGeometry build(int minimumY) { diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticCapture.java b/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticCapture.java index 192f3e3dc..7ad475692 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticCapture.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticCapture.java @@ -16,6 +16,9 @@ import art.arcane.iris.generation.hydrology.HydrologyColumnSample; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver; import art.arcane.iris.structure.placement.IrisStructurePlacement; import art.arcane.volmlib.util.mantle.flag.ReservedFlag; @@ -170,13 +173,14 @@ public final class GenerationSemanticCapture { cavern == null ? null : cavern.getCustomBiome() ); }); + IrisComplex complex = engine.getComplex(); mantle.iterateChunk(chunkX, chunkZ, HydrologyCaveCell.class, (localX, y, localZ, cell) -> { if (!caveSpace.test(localX, y, localZ)) { return; } int blockX = minimumX + localX; int blockZ = minimumZ + localZ; - if (!hasFullHydrologyWeight(engine.getComplex(), blockX, blockZ)) { + if (!hasFullHydrologyWeight(complex, blockX, blockZ)) { return; } captureCave( @@ -199,12 +203,8 @@ public final class GenerationSemanticCapture { Math.addExact(minimumZ, localZ)))); } }); - mantle.iterateChunk(chunkX, chunkZ, String.class, (localX, y, localZ, marker) -> { - StructurePlacementMarker.Decoded decoded = StructurePlacementMarker.decode(marker); - if (decoded != null) { - semantics.addObject(decoded.objectKey()); - } - }); + ObjectMarkers objects = new ObjectMarkers(semantics); + mantle.iterateChunk(chunkX, chunkZ, String.class, (localX, y, localZ, marker) -> objects.add(marker)); } private static boolean hasFullHydrologyWeight(IrisComplex complex, int blockX, int blockZ) { @@ -273,10 +273,76 @@ public final class GenerationSemanticCapture { } } + private static final class ObjectMarkers { + private final ChunkGenerationSemantics.Builder semantics; + private String lastMarker; + + private ObjectMarkers(ChunkGenerationSemantics.Builder semantics) { + this.semantics = semantics; + } + + private void add(String marker) { + if (marker != null && marker.equals(lastMarker)) { + return; + } + StructurePlacementMarker.Decoded decoded = StructurePlacementMarker.decode(marker); + if (decoded != null) { + semantics.addObject(decoded.objectKey()); + } + lastMarker = marker; + } + } + + private static final class SubterrainBiomes { + private final Engine engine; + private final boolean live; + private final SubterrainPlanner planner; + private final int minimumY; + + private SubterrainBiomes(Engine engine, boolean live, SubterrainPlanner planner, int minimumY) { + this.engine = engine; + this.live = live; + this.planner = planner; + this.minimumY = minimumY; + } + + private static SubterrainBiomes of(Engine engine, int minimumY) { + if (engine.getClass() != IrisEngine.class) { + return new SubterrainBiomes(engine, false, null, 0); + } + IrisEngine irisEngine = (IrisEngine) engine; + if (!irisEngine.hasGenerationRuntimeScope() && irisEngine.getGenerationHistoryRuntimeRouter().isPresent()) { + return new SubterrainBiomes(engine, false, null, 0); + } + IrisComplex complex = engine.getComplex(); + IrisDimension dimension = engine.getDimension(); + SubterrainPlanner planner = complex == null || dimension == null || !dimension.allowsSubterrainFeatures() + || dimension.getSubterrainFeatures() == null || dimension.getSubterrainFeatures().isEmpty() + ? null + : complex.getSubterrainPlanner(); + return new SubterrainBiomes(engine, true, planner, minimumY); + } + + private IrisBiome resolve(int x, int y, int z) { + if (!live) { + return engine.getSubterrainBiome(x, y, z); + } + if (planner == null) { + return null; + } + SubterrainCell cell = planner.sample(x, y + minimumY, z); + if (!cell.occupied() || cell.room().biome().isEmpty()) { + return null; + } + return engine.getSubterrainBiome(x, y, z); + } + } + private static final class CaveBiomes { private final Engine engine; private final boolean scoped; private final CaveFallback fallback; + private SubterrainBiomes subterrain; private final IrisDimensionCarvingResolver.State fallbackState = new IrisDimensionCarvingResolver.State(); private IrisDimensionCarvingResolver.Snapshot snapshot; private int minimumY; @@ -305,8 +371,9 @@ public final class GenerationSemanticCapture { if (snapshot == null) { snapshot = IrisDimensionCarvingResolver.snapshot(engine); minimumY = engine.getWorld().minHeight(); + subterrain = SubterrainBiomes.of(engine, minimumY); } - IrisBiome featureBiome = engine.getSubterrainBiome(x, y, z); + IrisBiome featureBiome = subterrain.resolve(x, y, z); if (featureBiome != null) { return featureBiome; } diff --git a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeStore.java b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeStore.java index 713dc7905..0fbb00234 100644 --- a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeStore.java +++ b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeStore.java @@ -622,10 +622,16 @@ public final class SavedBiomeStore { private static byte[] encode(SavedBiomeChunk chunk) throws IOException { LinkedHashMap palette = new LinkedHashMap<>(); LinkedHashMap columns = new LinkedHashMap<>(); + byte[] columnIndices = new byte[256]; for (int z = 0; z < 16; z++) { for (int x = 0; x < 16; x++) { SavedBiomeChunk.Column column = chunk.column(x, z); - columns.computeIfAbsent(column, ignored -> columns.size()); + int known = columns.size(); + int index = columns.computeIfAbsent(column, ignored -> columns.size()); + columnIndices[z * 16 + x] = (byte) index; + if (index < known) { + continue; + } palette.computeIfAbsent(column.surface(), ignored -> palette.size()); palette.computeIfAbsent(column.caveBase(), ignored -> palette.size()); for (SavedBiomeChunk.Span span : column.vertical()) { @@ -660,11 +666,7 @@ public final class SavedBiomeStore { writeUnsigned(output, palette.get(span.cell())); } } - for (int z = 0; z < 16; z++) { - for (int x = 0; x < 16; x++) { - output.writeByte(columns.get(chunk.column(x, z))); - } - } + output.write(columnIndices); } if (raw.size() > MAXIMUM_DECODED_BYTES) { throw new IOException("Saved biome chunk exceeds its decoded size limit"); diff --git a/core/src/main/java/art/arcane/iris/world/history/SavedTerrainChunk.java b/core/src/main/java/art/arcane/iris/world/history/SavedTerrainChunk.java index c04c36f0d..a50b110ce 100644 --- a/core/src/main/java/art/arcane/iris/world/history/SavedTerrainChunk.java +++ b/core/src/main/java/art/arcane/iris/world/history/SavedTerrainChunk.java @@ -63,38 +63,32 @@ public final class SavedTerrainChunk { } Math.addExact(minimumY, height); List columns = new ArrayList<>(256); - BoundaryColumnGeometry.Runs runs = new BoundaryColumnGeometry.Runs(height); + ColumnScan scan = new ColumnScan(height); for (int x = 0; x < 16; x++) { for (int z = 0; z < 16; z++) { if (boundaryOnly && x != 0 && x != 15 && z != 0 && z != 15) { columns.add(null); continue; } - runs.clear(); + scan.reset(); + source.scanColumn(x, z, minimumY, height, scan); + scan.requireCovered(); List biomePalette = new ArrayList<>(4); short[] biomeIndices = new short[height / 4]; - int surface = 0; - int floor = 0; - int fluid = -1; - for (int offset = 0; offset < height; offset++) { - int y = minimumY + offset; - BoundaryColumnGeometry.Voxel voxel = Objects.requireNonNull(source.voxel(x, y, z), "voxel"); - runs.add(voxel); - if (voxel.phase() != BoundaryColumnGeometry.Phase.AIR) { - surface = offset; - } - if (voxel.phase() == BoundaryColumnGeometry.Phase.SOLID) { - floor = offset; - } - if (!voxel.fluidStateKey().isEmpty()) { - fluid = offset; - } - if (offset % 4 == 0) { - String biome = Objects.requireNonNull(source.biome(x, y, z), "physical biome"); - biomeIndices[offset / 4] = paletteIndex(biomePalette, biome); + String lastBiome = null; + short lastIndex = 0; + for (int offset = 0; offset < height; offset += 4) { + String biome = Objects.requireNonNull(source.biome(x, minimumY + offset, z), "physical biome"); + if (biome != lastBiome) { + lastIndex = paletteIndex(biomePalette, biome); + lastBiome = biome; } + biomeIndices[offset / 4] = lastIndex; } - BoundaryColumnGeometry geometry = runs.build(minimumY); + int surface = scan.surface; + int floor = scan.floor; + int fluid = scan.fluid; + BoundaryColumnGeometry geometry = scan.runs.build(minimumY); OptionalInt groundSurface = source.groundSurface(x, z); if (groundSurface.isPresent()) { floor = geometry.surfaceOffsetNear(groundSurface.getAsInt()); @@ -209,5 +203,64 @@ public final class SavedTerrainChunk { BoundaryColumnGeometry.Voxel voxel(int localX, int worldY, int localZ) throws IOException; String biome(int localX, int worldY, int localZ) throws IOException; + + default void scanColumn(int localX, int localZ, int minimumY, int height, VoxelRuns runs) throws IOException { + for (int offset = 0; offset < height; offset++) { + runs.add(offset, offset + 1, voxel(localX, minimumY + offset, localZ)); + } + } + } + + @FunctionalInterface + public interface VoxelRuns { + void add(int startOffset, int endOffset, BoundaryColumnGeometry.Voxel voxel); + } + + private static final class ColumnScan implements VoxelRuns { + private final BoundaryColumnGeometry.Runs runs; + private final int height; + private int covered; + private int surface; + private int floor; + private int fluid; + + private ColumnScan(int height) { + this.runs = new BoundaryColumnGeometry.Runs(height); + this.height = height; + } + + private void reset() { + runs.clear(); + covered = 0; + surface = 0; + floor = 0; + fluid = -1; + } + + @Override + public void add(int startOffset, int endOffset, BoundaryColumnGeometry.Voxel voxel) { + Objects.requireNonNull(voxel, "voxel"); + if (startOffset != covered || endOffset <= startOffset || endOffset > height) { + throw new IllegalArgumentException("Voxel runs must cover the column in order"); + } + runs.add(voxel, endOffset - startOffset); + covered = endOffset; + int last = endOffset - 1; + if (voxel.phase() != BoundaryColumnGeometry.Phase.AIR) { + surface = last; + } + if (voxel.phase() == BoundaryColumnGeometry.Phase.SOLID) { + floor = last; + } + if (!voxel.fluidStateKey().isEmpty()) { + fluid = last; + } + } + + private void requireCovered() { + if (covered != height) { + throw new IllegalStateException("Voxel runs must cover the whole column"); + } + } } } diff --git a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreEncodingTest.java b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreEncodingTest.java new file mode 100644 index 000000000..4722891d8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreEncodingTest.java @@ -0,0 +1,140 @@ +package art.arcane.iris.world.history; + +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Random; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; + +import static org.junit.Assert.assertArrayEquals; + +public class SavedBiomeStoreEncodingTest { + @Test + public void encodedRecordsMatchTheReferenceEncoding() throws Exception { + Method encode = SavedBiomeStore.class.getDeclaredMethod("encode", SavedBiomeChunk.class); + encode.setAccessible(true); + Random random = new Random(20261022L); + for (int sample = 0; sample < 40; sample++) { + SavedBiomeChunk chunk = randomChunk(random, sample); + assertArrayEquals("sample " + sample, referenceEncode(chunk), (byte[]) encode.invoke(null, chunk)); + } + } + + private static SavedBiomeChunk randomChunk(Random random, int sample) { + long activation = 1L + random.nextInt(9); + int minimumY = -64; + int height = 384; + List cells = new ArrayList<>(); + int distinctCells = 1 + random.nextInt(sample % 4 == 0 ? 3 : 24); + for (int index = 0; index < distinctCells; index++) { + cells.add(random.nextInt(9) == 0 + ? SavedBiomeChunk.Cell.unresolved(activation) + : new SavedBiomeChunk.Cell(activation, "biome_" + random.nextInt(12), "region_" + random.nextInt(3))); + } + List shared = new ArrayList<>(); + int distinctColumns = 1 + random.nextInt(sample % 3 == 0 ? 4 : 40); + for (int index = 0; index < distinctColumns; index++) { + shared.add(randomColumn(random, cells, minimumY, height)); + } + SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder( + new SavedBiomeChunk.Header(3, -7, activation, minimumY, height)); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + SavedBiomeChunk.Column column = shared.get(random.nextInt(shared.size())); + if (random.nextInt(4) == 0) { + column = new SavedBiomeChunk.Column(column.surface(), column.caveBase(), new ArrayList<>(column.vertical())); + } + builder.column(x, z, column); + } + } + return builder.build(); + } + + private static SavedBiomeChunk.Column randomColumn(Random random, List cells, + int minimumY, int height) { + List spans = new ArrayList<>(); + int start = minimumY; + int end = minimumY + height; + while (start < end) { + int top = Math.min(end, start + 1 + random.nextInt(96)); + spans.add(new SavedBiomeChunk.Span(start, top, cells.get(random.nextInt(cells.size())))); + start = top; + } + return new SavedBiomeChunk.Column(cells.get(random.nextInt(cells.size())), + cells.get(random.nextInt(cells.size())), spans); + } + + private static byte[] referenceEncode(SavedBiomeChunk chunk) throws IOException { + LinkedHashMap palette = new LinkedHashMap<>(); + LinkedHashMap columns = new LinkedHashMap<>(); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + SavedBiomeChunk.Column column = chunk.column(x, z); + columns.computeIfAbsent(column, ignored -> columns.size()); + palette.computeIfAbsent(column.surface(), ignored -> palette.size()); + palette.computeIfAbsent(column.caveBase(), ignored -> palette.size()); + for (SavedBiomeChunk.Span span : column.vertical()) { + palette.computeIfAbsent(span.cell(), ignored -> palette.size()); + } + } + } + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(raw)) { + output.writeLong(chunk.activationId()); + output.writeInt(chunk.header().minimumY()); + output.writeInt(chunk.header().height()); + output.writeInt(palette.size()); + for (SavedBiomeChunk.Cell cell : palette.keySet()) { + output.writeLong(cell.activationId()); + output.writeByte(cell.isResolved() ? 0 : 1); + if (cell.isResolved()) { + output.writeUTF(cell.biomeKey()); + output.writeUTF(cell.regionKey()); + } + } + output.writeInt(columns.size()); + for (SavedBiomeChunk.Column column : columns.keySet()) { + writeUnsigned(output, palette.get(column.surface())); + writeUnsigned(output, palette.get(column.caveBase())); + writeUnsigned(output, column.vertical().size()); + for (SavedBiomeChunk.Span span : column.vertical()) { + writeUnsigned(output, span.maximumYExclusive() - chunk.header().minimumY()); + writeUnsigned(output, palette.get(span.cell())); + } + } + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + output.writeByte(columns.get(chunk.column(x, z))); + } + } + } + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(encoded); + output.writeInt(chunk.chunkX()); + output.writeInt(chunk.chunkZ()); + output.writeInt(raw.size()); + Deflater deflater = new Deflater(Deflater.BEST_SPEED); + try (DeflaterOutputStream compressed = new DeflaterOutputStream(output, deflater)) { + raw.writeTo(compressed); + } finally { + deflater.end(); + } + return encoded.toByteArray(); + } + + private static void writeUnsigned(DataOutputStream output, int value) throws IOException { + int remaining = value; + while ((remaining & ~0x7f) != 0) { + output.writeByte((remaining & 0x7f) | 0x80); + remaining >>>= 7; + } + output.writeByte(remaining); + } +} diff --git a/core/src/test/java/art/arcane/iris/world/history/SavedTerrainCaptureEquivalenceTest.java b/core/src/test/java/art/arcane/iris/world/history/SavedTerrainCaptureEquivalenceTest.java index 0bdd9c171..be2d60d4d 100644 --- a/core/src/test/java/art/arcane/iris/world/history/SavedTerrainCaptureEquivalenceTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/SavedTerrainCaptureEquivalenceTest.java @@ -56,6 +56,24 @@ public class SavedTerrainCaptureEquivalenceTest { } } + @Test + public void runScanningSourcesMatchThePerVoxelReference() throws Exception { + Random random = new Random(20261022L); + for (int sample = 0; sample < 32; sample++) { + RandomSource voxels = new RandomSource(random, sample); + SavedTerrainChunk.VoxelSource runs = new RunScanningSource(voxels, new Random(sample)); + for (boolean boundaryOnly : new boolean[]{true, false}) { + SavedTerrainChunk expected = referenceCapture(-3, 7, voxels, boundaryOnly); + SavedTerrainChunk actual = boundaryOnly + ? SavedTerrainChunk.captureBoundary(-3, 7, MINIMUM_Y, HEIGHT, "minecraft:noise", runs) + : SavedTerrainChunk.capture(-3, 7, MINIMUM_Y, HEIGHT, "minecraft:noise", runs); + assertSameTerrain(expected, actual); + assertArrayEquals(NativeTerrainReceipt.encode(expected, 1L, "epoch"), + NativeTerrainReceipt.encode(actual, 1L, "epoch")); + } + } + } + @Test public void geometryMatchesTheReferenceOnWidePalettes() { Random random = new Random(7L); @@ -184,6 +202,46 @@ public class SavedTerrainCaptureEquivalenceTest { return new BoundaryColumnGeometry.Voxel(state, phase, fluid, protectedContent); } + private static final class RunScanningSource implements SavedTerrainChunk.VoxelSource { + private final RandomSource source; + private final Random splits; + + private RunScanningSource(RandomSource source, Random splits) { + this.source = source; + this.splits = splits; + } + + @Override + public OptionalInt groundSurface(int localX, int localZ) { + return source.groundSurface(localX, localZ); + } + + @Override + public BoundaryColumnGeometry.Voxel voxel(int localX, int worldY, int localZ) { + return source.voxel(localX, worldY, localZ); + } + + @Override + public String biome(int localX, int worldY, int localZ) { + return source.biome(localX, worldY, localZ); + } + + @Override + public void scanColumn(int localX, int localZ, int minimumY, int height, SavedTerrainChunk.VoxelRuns runs) { + int start = 0; + BoundaryColumnGeometry.Voxel current = source.voxel(localX, minimumY, localZ); + for (int offset = 1; offset < height; offset++) { + BoundaryColumnGeometry.Voxel next = source.voxel(localX, minimumY + offset, localZ); + if (next != current || splits.nextInt(16) == 0) { + runs.add(start, offset, current); + start = offset; + current = next; + } + } + runs.add(start, height, current); + } + } + private static final class RandomSource implements SavedTerrainChunk.VoxelSource { private final BoundaryColumnGeometry.Voxel[][] voxels = new BoundaryColumnGeometry.Voxel[256][HEIGHT]; private final String[][] biomes = new String[256][HEIGHT];