From 4d2e52bcc72f6f30fd137ebfcfaf601d0ab01671 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 21 Oct 2026 23:54:58 -0400 Subject: [PATCH 1/2] Palette section buffer - Add SectionBuffer: chunk palette of written instances and short[4096] ids per section in y, z, x order - Column tops are indexed per x-slice and padded so fan-out slices do not share a cache line - Add ColumnRunHunk and forward column runs through the listening hunk and block update listener - Terrain painter emits column runs and evaluates ores only inside the column's ore bands - Bukkit generation writes into SectionBuffer and applies it through ChunkDataOutput - Remove ChunkDataHunkHolder and the probe's ProductionBlockHunk copy - Probe production buffers use SectionBuffer --- .../generation/chunk/ChunkDataHunkHolder.java | 169 --------- .../generation/chunk/ChunkDataOutput.java | 82 +++++ .../chunk/ColumnExtentListeningHunk.java | 14 +- .../iris/generation/chunk/ColumnRunHunk.java | 36 ++ .../iris/generation/chunk/SectionBuffer.java | 268 +++++++++++++++ .../generation/decoration/IrisOreBands.java | 8 + .../runtime/ChunkBlockUpdateListener.java | 14 + .../stage/IrisTerrainNormalActuator.java | 131 +++++-- .../iris/platform/bukkit/nms/INMSBinding.java | 4 +- .../generation/BukkitChunkGenerator.java | 7 +- .../block/PlatformStateRebindTest.java | 10 +- .../chunk/ChunkDataHunkHolderTest.java | 155 --------- .../generation/chunk/ChunkDataOutputTest.java | 88 +++++ .../generation/chunk/SectionBufferTest.java | 322 ++++++++++++++++++ .../stage/IrisNaturalCaveScanTest.java | 8 +- .../IrisPerfectionModifierColumnTest.java | 14 +- .../iris/probe/ProbeChunkGenerator.java | 3 +- .../iris/probe/ProductionBlockHunk.java | 86 ----- .../arcane/iris/probe/ProductionHunkTest.java | 5 +- 19 files changed, 955 insertions(+), 469 deletions(-) delete mode 100644 core/src/main/java/art/arcane/iris/generation/chunk/ChunkDataHunkHolder.java create mode 100644 core/src/main/java/art/arcane/iris/generation/chunk/ChunkDataOutput.java create mode 100644 core/src/main/java/art/arcane/iris/generation/chunk/ColumnRunHunk.java create mode 100644 core/src/main/java/art/arcane/iris/generation/chunk/SectionBuffer.java delete mode 100644 core/src/test/java/art/arcane/iris/generation/chunk/ChunkDataHunkHolderTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/chunk/ChunkDataOutputTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/chunk/SectionBufferTest.java delete mode 100644 probe/src/main/java/art/arcane/iris/probe/ProductionBlockHunk.java diff --git a/core/src/main/java/art/arcane/iris/generation/chunk/ChunkDataHunkHolder.java b/core/src/main/java/art/arcane/iris/generation/chunk/ChunkDataHunkHolder.java deleted file mode 100644 index 157fc8fad..000000000 --- a/core/src/main/java/art/arcane/iris/generation/chunk/ChunkDataHunkHolder.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Iris is a World Generator for Minecraft Bukkit Servers - * Copyright (c) 2022 Arcane Arts (Volmit Software) - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package art.arcane.iris.generation.chunk; - -import art.arcane.iris.platform.bukkit.nms.INMS; -import art.arcane.volmlib.nativelib.terrain.NativeBlockVolume; -import art.arcane.volmlib.nativelib.terrain.NativeBlockState; -import art.arcane.iris.generation.block.BoundBlockState; -import art.arcane.iris.generation.block.IrisCustomData; -import art.arcane.volmlib.util.hunk.storage.StorageHunk; -import org.bukkit.block.data.BlockData; -import org.bukkit.generator.ChunkGenerator.ChunkData; - -import java.lang.invoke.MethodHandles; -import java.lang.invoke.VarHandle; -import java.util.Arrays; - -/** - * Plain-array block buffer for one chunk. Stages write disjoint columns when they fan out and every hand-off - * between threads is a join, so the cells need no per-access fences. Each column tracks its highest stored y; - * a raise is a CAS so concurrent writers to one column can never lose the maximum. - */ -public class ChunkDataHunkHolder extends StorageHunk implements NativeBlockVolume, ColumnExtent { - private static final BoundBlockState AIR = BoundBlockState.of("AIR"); - private static final VarHandle COLUMN_TOPS = MethodHandles.arrayElementVarHandle(int[].class); - - private final ChunkData chunk; - private final NativeBlockState[] data; - private final int[] columnTops; - private final int zStride; - - public ChunkDataHunkHolder(ChunkData chunk) { - super(16, chunk.getMaxHeight() - chunk.getMinHeight(), 16); - this.chunk = chunk; - this.zStride = 16 * getHeight(); - this.data = new NativeBlockState[zStride * 16]; - this.columnTops = new int[256]; - Arrays.fill(columnTops, -1); - } - - @Override - public int getWidth() { - return 16; - } - - @Override - public int getDepth() { - return 16; - } - - @Override - public void setRaw(int x, int y, int z, NativeBlockState t) { - // Block-hunk contract: null means "no write", never "erase" — ChunkDataHunkView and - // the modded ModdedBlockBuffer already discard nulls, and storing one here made the - // Bukkit output diverge from the modded loaders for the same engine emission. - if (t == null) { - return; - } - data[index(x, y, z)] = t; - int column = (z << 4) | x; - int top = (int) COLUMN_TOPS.getOpaque(columnTops, column); - while (y > top) { - int witness = (int) COLUMN_TOPS.compareAndExchange(columnTops, column, top, y); - if (witness == top) { - break; - } - top = witness; - } - } - - @Override - public NativeBlockState getRaw(int x, int y, int z) { - if (y < 0 || y >= getHeight()) { - return AIR.get(); - } - - NativeBlockState b = data[index(x, y, z)]; - - return b != null ? b : AIR.get(); - } - - @Override - public NativeBlockState getStoredRaw(int x, int y, int z) { - return data[index(x, y, z)]; - } - - @Override - public int highestStoredY(int x, int z) { - return (int) COLUMN_TOPS.getOpaque(columnTops, (z << 4) | x); - } - - private int index(int x, int y, int z) { - return (z * zStride) + (y << 4) + x; - } - - public void apply() { - applyTo(chunk); - } - - public void applyTo(ChunkData target) { - if (INMS.get().applyChunkDataBlocks(target, this)) { - return; - } - - int height = getHeight(); - for (int x = 0; x < getWidth(); x++) { - for (int z = 0; z < getDepth(); z++) { - BlockData activeBlock = null; - int runStart = -1; - - for (int y = 0; y < height; y++) { - NativeBlockState state = getStoredRaw(x, y, z); - BlockData block = state == null ? null : (BlockData) state.nativeHandle(); - // Custom wrappers are not real Bukkit data; write the vanilla base like the - // NMS fast path (NMSBinding.applyChunkDataBlocks) does. - if (block instanceof IrisCustomData custom) { - block = custom.getBase(); - } - if (block == null) { - flushRun(target, x, z, runStart, y, activeBlock); - activeBlock = null; - runStart = -1; - continue; - } - - if (activeBlock != null && activeBlock.equals(block)) { - continue; - } - - flushRun(target, x, z, runStart, y, activeBlock); - activeBlock = block; - runStart = y; - } - - flushRun(target, x, z, runStart, height, activeBlock); - } - } - } - - private void flushRun(ChunkData target, int x, int z, int startY, int endY, BlockData block) { - if (block == null || startY < 0 || endY <= startY) { - return; - } - - int minY = target.getMinHeight(); - if (endY - startY == 1) { - target.setBlock(x, startY + minY, z, block); - return; - } - - target.setRegion(x, startY + minY, z, x + 1, endY + minY, z + 1, block); - } -} diff --git a/core/src/main/java/art/arcane/iris/generation/chunk/ChunkDataOutput.java b/core/src/main/java/art/arcane/iris/generation/chunk/ChunkDataOutput.java new file mode 100644 index 000000000..7d4eb3e52 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/chunk/ChunkDataOutput.java @@ -0,0 +1,82 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.generation.chunk; + +import art.arcane.iris.generation.block.IrisCustomData; +import art.arcane.iris.platform.bukkit.nms.INMS; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import org.bukkit.block.data.BlockData; +import org.bukkit.generator.ChunkGenerator.ChunkData; + +public final class ChunkDataOutput { + private ChunkDataOutput() { + } + + public static void apply(ChunkData target, SectionBuffer blocks) { + if (INMS.get().applyChunkDataBlocks(target, blocks)) { + return; + } + + int height = blocks.getHeight(); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + BlockData activeBlock = null; + int runStart = -1; + + for (int y = 0; y < height; y++) { + NativeBlockState state = blocks.getStoredRaw(x, y, z); + BlockData block = state == null ? null : (BlockData) state.nativeHandle(); + if (block instanceof IrisCustomData custom) { + block = custom.getBase(); + } + if (block == null) { + flushRun(target, x, z, runStart, y, activeBlock); + activeBlock = null; + runStart = -1; + continue; + } + + if (activeBlock != null && activeBlock.equals(block)) { + continue; + } + + flushRun(target, x, z, runStart, y, activeBlock); + activeBlock = block; + runStart = y; + } + + flushRun(target, x, z, runStart, height, activeBlock); + } + } + } + + private static void flushRun(ChunkData target, int x, int z, int startY, int endY, BlockData block) { + if (block == null || startY < 0 || endY <= startY) { + return; + } + + int minY = target.getMinHeight(); + if (endY - startY == 1) { + target.setBlock(x, startY + minY, z, block); + return; + } + + target.setRegion(x, startY + minY, z, x + 1, endY + minY, z + 1, block); + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/chunk/ColumnExtentListeningHunk.java b/core/src/main/java/art/arcane/iris/generation/chunk/ColumnExtentListeningHunk.java index f2d32dd3b..0498d0973 100644 --- a/core/src/main/java/art/arcane/iris/generation/chunk/ColumnExtentListeningHunk.java +++ b/core/src/main/java/art/arcane/iris/generation/chunk/ColumnExtentListeningHunk.java @@ -20,7 +20,7 @@ package art.arcane.iris.generation.chunk; import art.arcane.volmlib.util.hunk.Hunk; -public final class ColumnExtentListeningHunk implements Hunk, ColumnExtent { +public final class ColumnExtentListeningHunk implements Hunk, ColumnExtent, ColumnRunHunk { private final Hunk source; private final Listener listener; @@ -35,6 +35,12 @@ public final class ColumnExtentListeningHunk implements Hunk, ColumnExtent source.setRaw(x, y, z, t); } + @Override + public void fillColumnRun(int x, int z, int yFromInclusive, int yToInclusive, T value) { + listener.onRun(x, z, yFromInclusive, yToInclusive, value); + ColumnRunHunk.fillColumnRun(source, x, z, yFromInclusive, yToInclusive, value); + } + @Override public T getRaw(int x, int y, int z) { return source.getRaw(x, y, z); @@ -63,5 +69,11 @@ public final class ColumnExtentListeningHunk implements Hunk, ColumnExtent @FunctionalInterface public interface Listener { void onWrite(int x, int y, int z, T value); + + default void onRun(int x, int z, int yFromInclusive, int yToInclusive, T value) { + for (int y = yToInclusive; y >= yFromInclusive; y--) { + onWrite(x, y, z, value); + } + } } } diff --git a/core/src/main/java/art/arcane/iris/generation/chunk/ColumnRunHunk.java b/core/src/main/java/art/arcane/iris/generation/chunk/ColumnRunHunk.java new file mode 100644 index 000000000..363e7a8a3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/chunk/ColumnRunHunk.java @@ -0,0 +1,36 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.generation.chunk; + +import art.arcane.volmlib.util.hunk.Hunk; + +public interface ColumnRunHunk { + void fillColumnRun(int x, int z, int yFromInclusive, int yToInclusive, T value); + + @SuppressWarnings("unchecked") + static void fillColumnRun(Hunk hunk, int x, int z, int yFromInclusive, int yToInclusive, T value) { + if (hunk instanceof ColumnRunHunk runs) { + ((ColumnRunHunk) runs).fillColumnRun(x, z, yFromInclusive, yToInclusive, value); + return; + } + for (int y = yToInclusive; y >= yFromInclusive; y--) { + hunk.setRaw(x, y, z, value); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/chunk/SectionBuffer.java b/core/src/main/java/art/arcane/iris/generation/chunk/SectionBuffer.java new file mode 100644 index 000000000..8a6898911 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/chunk/SectionBuffer.java @@ -0,0 +1,268 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.generation.chunk; + +import art.arcane.iris.generation.block.BoundBlockState; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.nativelib.terrain.NativeSectionVolume; +import art.arcane.volmlib.util.hunk.Hunk; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.util.Arrays; + +public final class SectionBuffer implements Hunk, NativeSectionVolume, ColumnExtent, ColumnRunHunk { + private static final BoundBlockState AIR = BoundBlockState.of("AIR"); + private static final VarHandle SECTIONS = MethodHandles.arrayElementVarHandle(short[][].class); + private static final VarHandle COLUMN_TOPS = MethodHandles.arrayElementVarHandle(int[].class); + private static final int SECTION_CELLS = 4096; + private static final int MAX_PALETTE_ID = 0xFFFF; + private static final int INITIAL_PALETTE_CAPACITY = 64; + private static final int COLUMN_SLICE_SHIFT = 5; + + private final int height; + private final short[][] sections; + private final int[] columnTops; + private final Object paletteLock = new Object(); + private PaletteEntry[] slots; + private NativeBlockState[] states; + private int paletteSize; + + public SectionBuffer(int height) { + if (height <= 0) { + throw new IllegalArgumentException("Unsupported section buffer height " + height); + } + this.height = height; + this.sections = new short[(height + 15) >> 4][]; + this.columnTops = new int[16 << COLUMN_SLICE_SHIFT]; + Arrays.fill(columnTops, -1); + this.slots = new PaletteEntry[INITIAL_PALETTE_CAPACITY << 1]; + this.states = new NativeBlockState[INITIAL_PALETTE_CAPACITY]; + this.paletteSize = 1; + } + + @Override + public int getWidth() { + return 16; + } + + @Override + public int getHeight() { + return height; + } + + @Override + public int getDepth() { + return 16; + } + + public int paletteId(NativeBlockState state) { + if (state == null) { + return 0; + } + PaletteEntry[] table = slots; + int mask = table.length - 1; + int slot = System.identityHashCode(state) & mask; + PaletteEntry entry; + while ((entry = table[slot]) != null) { + if (entry.state == state) { + return entry.id; + } + slot = (slot + 1) & mask; + } + return insert(state); + } + + @Override + public void setRaw(int x, int y, int z, NativeBlockState t) { + if (t == null) { + return; + } + writableSection(y >> 4)[((y & 15) << 8) | (z << 4) | x] = (short) paletteId(t); + raiseTop((x << COLUMN_SLICE_SHIFT) | z, y); + } + + @Override + public NativeBlockState getRaw(int x, int y, int z) { + if (y < 0 || y >= height) { + return AIR.get(); + } + short[] section = sections[y >> 4]; + if (section != null) { + int id = section[((y & 15) << 8) | (z << 4) | x] & 0xFFFF; + if (id != 0) { + return state(id); + } + } + return AIR.get(); + } + + @Override + public NativeBlockState getStoredRaw(int x, int y, int z) { + if (y < 0 || y >= height) { + return null; + } + short[] section = sections[y >> 4]; + if (section == null) { + return null; + } + int id = section[((y & 15) << 8) | (z << 4) | x] & 0xFFFF; + return id == 0 ? null : state(id); + } + + @Override + public void fillColumnRun(int x, int z, int yFromInclusive, int yToInclusive, NativeBlockState value) { + if (value == null || yToInclusive < yFromInclusive) { + return; + } + fillColumnRun(x, z, yFromInclusive, yToInclusive, paletteId(value)); + } + + public void fillColumnRun(int localX, int localZ, int yFromInclusive, int yToInclusive, int paletteId) { + if (paletteId == 0 || yToInclusive < yFromInclusive) { + return; + } + short value = (short) paletteId; + int column = (localZ << 4) | localX; + int y = yFromInclusive; + while (y <= yToInclusive) { + int sectionIndex = y >> 4; + short[] section = writableSection(sectionIndex); + int last = Math.min(yToInclusive, (sectionIndex << 4) | 15); + int end = ((last & 15) << 8) | column; + for (int index = ((y & 15) << 8) | column; index <= end; index += 256) { + section[index] = value; + } + y = last + 1; + } + raiseTop((localX << COLUMN_SLICE_SHIFT) | localZ, yToInclusive); + } + + @Override + public int highestStoredY(int x, int z) { + return (int) COLUMN_TOPS.getOpaque(columnTops, (x << COLUMN_SLICE_SHIFT) | z); + } + + @Override + public int sectionCount() { + return sections.length; + } + + @Override + public short[] section(int sectionIndex) { + return sections[sectionIndex]; + } + + @Override + public NativeBlockState[] palette() { + synchronized (paletteLock) { + return Arrays.copyOf(states, paletteSize); + } + } + + private short[] writableSection(int sectionIndex) { + short[] section = sections[sectionIndex]; + if (section != null) { + return section; + } + short[] created = new short[SECTION_CELLS]; + short[] witness = (short[]) SECTIONS.compareAndExchange(sections, sectionIndex, (short[]) null, created); + return witness == null ? created : witness; + } + + private void raiseTop(int column, int y) { + int top = (int) COLUMN_TOPS.getOpaque(columnTops, column); + while (y > top) { + int witness = (int) COLUMN_TOPS.compareAndExchange(columnTops, column, top, y); + if (witness == top) { + return; + } + top = witness; + } + } + + private NativeBlockState state(int id) { + NativeBlockState[] current = states; + if (id < current.length) { + NativeBlockState state = current[id]; + if (state != null) { + return state; + } + } + synchronized (paletteLock) { + return states[id]; + } + } + + private int insert(NativeBlockState state) { + synchronized (paletteLock) { + PaletteEntry[] table = slots; + int mask = table.length - 1; + int slot = System.identityHashCode(state) & mask; + PaletteEntry entry; + while ((entry = table[slot]) != null) { + if (entry.state == state) { + return entry.id; + } + slot = (slot + 1) & mask; + } + int id = paletteSize; + if (id > MAX_PALETTE_ID) { + throw new IllegalStateException("A chunk wrote more than " + MAX_PALETTE_ID + " distinct block state instances"); + } + NativeBlockState[] current = states; + if (id == current.length) { + current = Arrays.copyOf(current, current.length << 1); + } + current[id] = state; + states = current; + paletteSize = id + 1; + PaletteEntry created = new PaletteEntry(state, id); + if (id << 1 >= table.length) { + slots = rehash(table, created); + } else { + table[slot] = created; + } + return id; + } + } + + private static PaletteEntry[] rehash(PaletteEntry[] table, PaletteEntry created) { + PaletteEntry[] grown = new PaletteEntry[table.length << 1]; + for (PaletteEntry entry : table) { + if (entry != null) { + place(grown, entry); + } + } + place(grown, created); + return grown; + } + + private static void place(PaletteEntry[] table, PaletteEntry entry) { + int mask = table.length - 1; + int slot = System.identityHashCode(entry.state) & mask; + while (table[slot] != null) { + slot = (slot + 1) & mask; + } + table[slot] = entry; + } + + private record PaletteEntry(NativeBlockState state, int id) { + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisOreBands.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisOreBands.java index b70f0681e..a15ab4ca2 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisOreBands.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisOreBands.java @@ -79,6 +79,14 @@ public final class IrisOreBands { return minY <= maxY; } + public int lowestY() { + return minY; + } + + public int highestY() { + return maxY; + } + public boolean contains(int y) { return y >= minY && y <= maxY; } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/ChunkBlockUpdateListener.java b/core/src/main/java/art/arcane/iris/generation/runtime/ChunkBlockUpdateListener.java index 2860edbd1..f060a53a6 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/ChunkBlockUpdateListener.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/ChunkBlockUpdateListener.java @@ -50,4 +50,18 @@ final class ChunkBlockUpdateListener implements ColumnExtentListeningHunk.Listen } quiet = state; } + + @Override + public void onRun(int x, int z, int yFromInclusive, int yToInclusive, NativeBlockState state) { + if (state == null || state == quiet) { + return; + } + if (B.isUpdatable(state) || state.isCustom()) { + for (int y = yToInclusive; y >= yFromInclusive; y--) { + updater.catchBlockUpdates(originX + x, y, originZ + z, state); + } + return; + } + quiet = state; + } } diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java index 7c39f3338..3e1ed620b 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java @@ -36,6 +36,7 @@ import art.arcane.iris.generation.hydrology.IrisRiverMaterialConfig; import art.arcane.iris.generation.hydrology.IrisSurfaceRiverBankConfig; import art.arcane.iris.generation.terrain.Terrain3DColumn; import art.arcane.iris.generation.block.BoundBlockState; +import art.arcane.iris.generation.chunk.ColumnRunHunk; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.generation.context.ChunkedDataCache; import art.arcane.iris.generation.context.ChunkContext; @@ -176,6 +177,15 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator h, ChunkContext context) { this.x = x; @@ -260,8 +270,12 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator blocks = null; KList ceilingBlocks = null; @@ -269,7 +283,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator= 0; i--) { if (i == 0 && bedrockEnabled) { - h.setRaw(xf, i, zf, bedrock); + put(i, bedrock); continue; } @@ -280,16 +294,16 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator terrainColumn.floor(terrainSpan)) { - h.setRaw(xf, i, zf, air); + put(i, air); continue; } layerSurfaceY = terrainColumn.floor(terrainSpan); layerCeilingY = terrainColumn.ceiling(terrainSpan); } - NativeBlockState ore = hasSurfaceOres ? surfaceOre(realX, i, realZ) : null; + NativeBlockState ore = i <= surfaceOreTop && i >= surfaceOreFloor ? surfaceOre(realX, i, realZ) : null; if (ore != null) { - h.setRaw(xf, i, zf, ore); + put(i, ore); continue; } @@ -298,7 +312,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator 0 ? layerCeilingY + 2 : 0); if (i >= deepFloor && depth >= Math.max(blocks.size(), paintDepth)) { - paintDeep(xf, zf, realX, realZ, i, deepFloor, rock, hasSurfaceOres, hasUndergroundOres); + paintDeep(realX, realZ, i, deepFloor, rock); i = deepFloor; continue; } @@ -335,7 +349,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator= undergroundOreFloor ? undergroundOre(realX, i, realZ) : null; + put(i, ore != null ? ore : rock); } } + flush(); if (upperContext != null) { - paintUpper(xf, zf, realX, realZ); + paintUpper(realX, realZ); + flush(); } } @@ -366,15 +382,78 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator= floor; y--) { - NativeBlockState ore = hasSurfaceOres ? surfaceOre(realX, y, realZ) : null; - if (ore == null && hasUndergroundOres) { + private void paintDeep(int realX, int realZ, int top, int floor, NativeBlockState rock) { + int bandTop = Math.min(top, Math.max(surfaceOreTop, undergroundOreTop)); + int bandFloor = Math.max(floor, Math.min(surfaceOreFloor, undergroundOreFloor)); + if (bandTop < bandFloor) { + putRun(top, floor, rock); + return; + } + if (top > bandTop) { + putRun(top, bandTop + 1, rock); + } + for (int y = bandTop; y >= bandFloor; y--) { + NativeBlockState ore = y <= surfaceOreTop && y >= surfaceOreFloor ? surfaceOre(realX, y, realZ) : null; + if (ore == null && y <= undergroundOreTop && y >= undergroundOreFloor) { ore = undergroundOre(realX, y, realZ); } - h.setRaw(xf, y, zf, ore != null ? ore : rock); + put(y, ore != null ? ore : rock); } + if (bandFloor > floor) { + putRun(bandFloor - 1, floor, rock); + } + } + + private void put(int y, NativeBlockState state) { + putRun(y, y, state); + } + + private void putRun(int top, int bottom, NativeBlockState state) { + if (runTop >= 0 && state == runState && top == runBottom - 1) { + runBottom = bottom; + return; + } + flush(); + runState = state; + runTop = top; + runBottom = bottom; + } + + private void flush() { + if (runTop < 0) { + return; + } + ColumnRunHunk.fillColumnRun(h, columnX, columnZ, runBottom, runTop, runState); + runTop = -1; + runState = null; + } + + private static int oreTop(IrisOreBands first, IrisOreBands second, IrisOreBands third) { + int top = Integer.MIN_VALUE; + if (first.hasOres()) { + top = Math.max(top, first.highestY()); + } + if (second.hasOres()) { + top = Math.max(top, second.highestY()); + } + if (third.hasOres()) { + top = Math.max(top, third.highestY()); + } + return top; + } + + private static int oreFloor(IrisOreBands first, IrisOreBands second, IrisOreBands third) { + int floor = Integer.MAX_VALUE; + if (first.hasOres()) { + floor = Math.min(floor, first.lowestY()); + } + if (second.hasOres()) { + floor = Math.min(floor, second.lowestY()); + } + if (third.hasOres()) { + floor = Math.min(floor, third.lowestY()); + } + return floor; } private NativeBlockState surfaceOre(int realX, int y, int realZ) { @@ -405,7 +484,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator= chunkHeight - 1) { @@ -420,11 +499,11 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator= upperSurfaceY; y--) { if (y == chunkHeight - 1 && bedrockEnabled) { - h.setRaw(xf, y, zf, bedrock); + put(y, bedrock); continue; } if (!upperColumn.isSolid(y)) { - h.setRaw(xf, y, zf, air); + put(y, air); continue; } int faceY = upperColumn.faceY(y); @@ -434,7 +513,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator biomes = new ColumnBiomeHunk(d.getMaxHeight() - d.getMinHeight(), () -> BukkitBiome.of(Biome.PLAINS)); try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = openGenerationHistoryCoordinateScope(engine, x << 4, z << 4); @@ -1352,7 +1353,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { engine.generate(x << 4, z << 4, blocks, biomes, initialEntryPending || studioEntryBootstrapActive.get() || engine.shouldGenerateMulticore()); - blocks.apply(); + ChunkDataOutput.apply(d, blocks); if (historyScope != null) { historyScope.claimGeneratedSemantics(); } diff --git a/core/src/test/java/art/arcane/iris/generation/block/PlatformStateRebindTest.java b/core/src/test/java/art/arcane/iris/generation/block/PlatformStateRebindTest.java index a0af9659f..f149111e8 100644 --- a/core/src/test/java/art/arcane/iris/generation/block/PlatformStateRebindTest.java +++ b/core/src/test/java/art/arcane/iris/generation/block/PlatformStateRebindTest.java @@ -6,8 +6,7 @@ import art.arcane.iris.spi.IrisPlatforms; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.testsupport.PlatformLeakGuard; -import art.arcane.iris.generation.chunk.ChunkDataHunkHolder; -import org.bukkit.generator.ChunkGenerator; +import art.arcane.iris.generation.chunk.SectionBuffer; import org.junit.After; import org.junit.ClassRule; import org.junit.Test; @@ -37,7 +36,7 @@ public class PlatformStateRebindTest { "art.arcane.iris.generation.biome.IrisBiomeLayerGenerator", "art.arcane.iris.studio.generation.BiomeBuffetGenerator", "art.arcane.iris.generation.mantle.EngineMantle", - "art.arcane.iris.generation.chunk.ChunkDataHunkHolder"); + "art.arcane.iris.generation.chunk.SectionBuffer"); @After public void unbindPlatform() { @@ -47,10 +46,7 @@ public class PlatformStateRebindTest { @Test public void chunkDataAirIsResolvedAgainstTheCurrentlyBoundPlatform() { NativeBlockState first = bindPlatform("first"); - ChunkGenerator.ChunkData chunk = mock(ChunkGenerator.ChunkData.class); - when(chunk.getMinHeight()).thenReturn(-64); - when(chunk.getMaxHeight()).thenReturn(320); - ChunkDataHunkHolder holder = new ChunkDataHunkHolder(chunk); + SectionBuffer holder = new SectionBuffer(384); assertSame(first, holder.getRaw(0, -1, 0)); diff --git a/core/src/test/java/art/arcane/iris/generation/chunk/ChunkDataHunkHolderTest.java b/core/src/test/java/art/arcane/iris/generation/chunk/ChunkDataHunkHolderTest.java deleted file mode 100644 index 020f6db50..000000000 --- a/core/src/test/java/art/arcane/iris/generation/chunk/ChunkDataHunkHolderTest.java +++ /dev/null @@ -1,155 +0,0 @@ -package art.arcane.iris.generation.chunk; - -import art.arcane.iris.platform.bukkit.nms.INMS; -import art.arcane.iris.platform.bukkit.nms.INMSBinding; -import art.arcane.volmlib.nativelib.terrain.NativeBlockState; -import org.bukkit.block.data.BlockData; -import org.bukkit.generator.ChunkGenerator; -import org.junit.Test; -import org.mockito.MockedStatic; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.CountDownLatch; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; - -public class ChunkDataHunkHolderTest { - @Test - public void columnTopTracksTheHighestStoredValueAndIgnoresNulls() { - ChunkDataHunkHolder holder = holder(64); - NativeBlockState stone = mock(NativeBlockState.class); - - assertEquals(-1, holder.highestStoredY(3, 7)); - holder.setRaw(3, 40, 7, null); - assertEquals(-1, holder.highestStoredY(3, 7)); - holder.setRaw(3, 12, 7, stone); - holder.setRaw(3, 30, 7, stone); - holder.setRaw(3, 5, 7, stone); - - assertEquals(30, holder.highestStoredY(3, 7)); - assertEquals(-1, holder.highestStoredY(7, 3)); - assertSame(stone, holder.getStoredRaw(3, 30, 7)); - assertNull(holder.getStoredRaw(3, 31, 7)); - assertEquals(30, ColumnExtent.highestStoredY(new ColumnExtentListeningHunk<>(holder, (x, y, z, state) -> { - }), 3, 7)); - } - - @Test - public void cellsKeepTheZMajorLayout() { - ChunkDataHunkHolder holder = holder(32); - List states = new ArrayList<>(); - for (int x = 0; x < 16; x++) { - for (int z = 0; z < 16; z++) { - for (int y = 0; y < 32; y++) { - NativeBlockState state = mock(NativeBlockState.class); - states.add(state); - holder.setRaw(x, y, z, state); - } - } - } - int index = 0; - for (int x = 0; x < 16; x++) { - for (int z = 0; z < 16; z++) { - for (int y = 0; y < 32; y++) { - assertSame(states.get(index++), holder.getStoredRaw(x, y, z)); - } - } - } - } - - @Test - public void concurrentWritersNeverLoseTheColumnMaximum() throws InterruptedException { - for (int trial = 0; trial < 20; trial++) { - ChunkDataHunkHolder holder = holder(768); - NativeBlockState stone = mock(NativeBlockState.class); - int threads = 8; - CountDownLatch start = new CountDownLatch(1); - List writers = new ArrayList<>(); - for (int thread = 0; thread < threads; thread++) { - int offset = thread; - Thread writer = new Thread(() -> { - try { - start.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - for (int y = offset; y < 768; y += threads) { - holder.setRaw(9, y, 4, stone); - } - }); - writer.start(); - writers.add(writer); - } - start.countDown(); - for (Thread writer : writers) { - writer.join(); - } - assertEquals(767, holder.highestStoredY(9, 4)); - } - } - - @Test - public void fallbackAppliesExplicitCaveAirBetweenSolidRunsWithNegativeMinimumHeight() { - ChunkGenerator.ChunkData chunk = mock(ChunkGenerator.ChunkData.class); - when(chunk.getMinHeight()).thenReturn(-256); - when(chunk.getMaxHeight()).thenReturn(512); - ChunkDataHunkHolder holder = new ChunkDataHunkHolder(chunk); - BlockData dirt = mock(BlockData.class); - BlockData coal = mock(BlockData.class); - BlockData caveAir = mock(BlockData.class); - NativeBlockState dirtState = state(dirt); - NativeBlockState coalState = state(coal); - NativeBlockState caveAirState = state(caveAir); - BlockData[] applied = new BlockData[768]; - Arrays.fill(applied, dirt); - for (int y = 0; y <= 75; y++) { - holder.setRaw(14, y + 256, 6, y < 33 ? dirtState : y <= 54 ? caveAirState : coalState); - } - doAnswer(call -> { - int y = call.getArgument(1); - applied[y + 256] = call.getArgument(3); - return null; - }).when(chunk).setBlock(anyInt(), anyInt(), anyInt(), any(BlockData.class)); - doAnswer(call -> { - int from = call.getArgument(1); - int to = call.getArgument(4); - BlockData block = call.getArgument(6); - Arrays.fill(applied, from + 256, to + 256, block); - return null; - }).when(chunk).setRegion(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any(BlockData.class)); - - try (MockedStatic nms = mockStatic(INMS.class)) { - nms.when(INMS::get).thenReturn(mock(INMSBinding.class)); - holder.apply(); - } - - for (int y = -256; y < 512; y++) { - BlockData expected = y >= 33 && y <= 54 ? caveAir : y >= 55 && y <= 75 ? coal : dirt; - assertSame("column at " + y, expected, applied[y + 256]); - } - } - - private static NativeBlockState state(BlockData data) { - NativeBlockState state = mock(NativeBlockState.class); - when(state.nativeHandle()).thenReturn(data); - return state; - } - - private static ChunkDataHunkHolder holder(int height) { - ChunkGenerator.ChunkData chunk = mock(ChunkGenerator.ChunkData.class); - when(chunk.getMinHeight()).thenReturn(0); - when(chunk.getMaxHeight()).thenReturn(height); - return new ChunkDataHunkHolder(chunk); - } -} diff --git a/core/src/test/java/art/arcane/iris/generation/chunk/ChunkDataOutputTest.java b/core/src/test/java/art/arcane/iris/generation/chunk/ChunkDataOutputTest.java new file mode 100644 index 000000000..e98b40f8b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/chunk/ChunkDataOutputTest.java @@ -0,0 +1,88 @@ +package art.arcane.iris.generation.chunk; + +import art.arcane.iris.platform.bukkit.nms.INMS; +import art.arcane.iris.platform.bukkit.nms.INMSBinding; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import org.bukkit.block.data.BlockData; +import org.bukkit.generator.ChunkGenerator; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.Arrays; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ChunkDataOutputTest { + @Test + public void nativeWriterReceivesTheSectionBuffer() { + ChunkGenerator.ChunkData chunk = mock(ChunkGenerator.ChunkData.class); + SectionBuffer blocks = new SectionBuffer(64); + INMSBinding binding = mock(INMSBinding.class); + when(binding.applyChunkDataBlocks(chunk, blocks)).thenReturn(true); + + try (MockedStatic nms = mockStatic(INMS.class)) { + nms.when(INMS::get).thenReturn(binding); + ChunkDataOutput.apply(chunk, blocks); + } + + verify(binding).applyChunkDataBlocks(chunk, blocks); + verify(chunk, never()).setRegion(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any(BlockData.class)); + } + + @Test + public void fallbackAppliesExplicitCaveAirBetweenSolidRunsWithNegativeMinimumHeight() { + ChunkGenerator.ChunkData chunk = mock(ChunkGenerator.ChunkData.class); + when(chunk.getMinHeight()).thenReturn(-256); + when(chunk.getMaxHeight()).thenReturn(512); + SectionBuffer blocks = new SectionBuffer(768); + BlockData dirt = mock(BlockData.class); + BlockData coal = mock(BlockData.class); + BlockData caveAir = mock(BlockData.class); + NativeBlockState dirtState = state(dirt); + NativeBlockState coalState = state(coal); + NativeBlockState caveAirState = state(caveAir); + BlockData[] applied = new BlockData[768]; + Arrays.fill(applied, dirt); + for (int y = 0; y <= 75; y++) { + blocks.setRaw(14, y + 256, 6, y < 33 ? dirtState : y <= 54 ? caveAirState : coalState); + } + doAnswer(call -> { + int y = call.getArgument(1); + applied[y + 256] = call.getArgument(3); + return null; + }).when(chunk).setBlock(anyInt(), anyInt(), anyInt(), any(BlockData.class)); + doAnswer(call -> { + int from = call.getArgument(1); + int to = call.getArgument(4); + BlockData block = call.getArgument(6); + Arrays.fill(applied, from + 256, to + 256, block); + return null; + }).when(chunk).setRegion(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any(BlockData.class)); + + try (MockedStatic nms = mockStatic(INMS.class)) { + nms.when(INMS::get).thenReturn(mock(INMSBinding.class)); + ChunkDataOutput.apply(chunk, blocks); + } + + for (int y = -256; y < 512; y++) { + BlockData expected = y >= 33 && y <= 54 ? caveAir : y >= 55 && y <= 75 ? coal : dirt; + assertSame("column at " + y, expected, applied[y + 256]); + } + assertTrue(blocks.highestStoredY(14, 6) == 331); + } + + private static NativeBlockState state(BlockData data) { + NativeBlockState state = mock(NativeBlockState.class); + when(state.nativeHandle()).thenReturn(data); + return state; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/chunk/SectionBufferTest.java b/core/src/test/java/art/arcane/iris/generation/chunk/SectionBufferTest.java new file mode 100644 index 000000000..75ab6a014 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/chunk/SectionBufferTest.java @@ -0,0 +1,322 @@ +package art.arcane.iris.generation.chunk; + +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.hunk.Hunk; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.concurrent.CountDownLatch; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class SectionBufferTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + private NativeBlockState air; + + @Before + public void bindPlatform() { + air = mock(NativeBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(air); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void columnTopTracksTheHighestStoredValueAndIgnoresNulls() { + SectionBuffer buffer = new SectionBuffer(64); + NativeBlockState stone = mock(NativeBlockState.class); + + assertEquals(-1, buffer.highestStoredY(3, 7)); + buffer.setRaw(3, 40, 7, null); + assertEquals(-1, buffer.highestStoredY(3, 7)); + buffer.setRaw(3, 12, 7, stone); + buffer.setRaw(3, 30, 7, stone); + buffer.setRaw(3, 5, 7, stone); + + assertEquals(30, buffer.highestStoredY(3, 7)); + assertEquals(-1, buffer.highestStoredY(7, 3)); + assertSame(stone, buffer.getStoredRaw(3, 30, 7)); + assertNull(buffer.getStoredRaw(3, 31, 7)); + assertEquals(30, ColumnExtent.highestStoredY(new ColumnExtentListeningHunk<>(buffer, (x, y, z, state) -> { + }), 3, 7)); + } + + @Test + public void unwrittenAndOutOfRangeCellsReadAsAir() { + SectionBuffer buffer = new SectionBuffer(48); + NativeBlockState stone = mock(NativeBlockState.class); + buffer.setRaw(2, 20, 9, stone); + + assertSame(air, buffer.getRaw(2, 21, 9)); + assertSame(air, buffer.getRaw(2, 3, 9)); + assertSame(air, buffer.getRaw(2, -1, 9)); + assertSame(air, buffer.getRaw(2, 48, 9)); + assertSame(stone, buffer.getRaw(2, 20, 9)); + assertNull(buffer.getStoredRaw(2, 21, 9)); + assertNull(buffer.getStoredRaw(2, 3, 9)); + } + + @Test + public void paletteKeepsEveryDistinctInstance() { + SectionBuffer buffer = new SectionBuffer(32); + NativeBlockState first = mock(NativeBlockState.class); + NativeBlockState second = mock(NativeBlockState.class); + when(first.matches(second)).thenReturn(true); + when(second.matches(first)).thenReturn(true); + + buffer.setRaw(0, 0, 0, first); + buffer.setRaw(1, 0, 0, second); + buffer.setRaw(2, 0, 0, first); + + assertSame(first, buffer.getRaw(0, 0, 0)); + assertSame(second, buffer.getRaw(1, 0, 0)); + assertSame(first, buffer.getRaw(2, 0, 0)); + assertEquals(buffer.paletteId(first), buffer.paletteId(first)); + assertNotEquals(buffer.paletteId(first), buffer.paletteId(second)); + assertEquals(0, buffer.paletteId(null)); + NativeBlockState[] palette = buffer.palette(); + assertEquals(3, palette.length); + assertNull(palette[0]); + assertSame(first, palette[buffer.paletteId(first)]); + assertSame(second, palette[buffer.paletteId(second)]); + } + + @Test + public void sectionsUseTheYzxLayoutAndStayUnallocatedUntilWritten() { + SectionBuffer buffer = new SectionBuffer(64); + NativeBlockState stone = mock(NativeBlockState.class); + NativeBlockState dirt = mock(NativeBlockState.class); + buffer.setRaw(5, 37, 11, stone); + buffer.setRaw(15, 47, 15, dirt); + + assertEquals(4, buffer.sectionCount()); + assertNull(buffer.section(0)); + assertNull(buffer.section(1)); + assertNull(buffer.section(3)); + short[] section = buffer.section(2); + assertEquals(4096, section.length); + assertEquals(buffer.paletteId(stone), section[(5 << 8) | (11 << 4) | 5]); + assertEquals(buffer.paletteId(dirt), section[(15 << 8) | (15 << 4) | 15]); + int written = 0; + for (short id : section) { + written += id == 0 ? 0 : 1; + } + assertEquals(2, written); + } + + @Test + public void columnRunsMatchRepeatedSingleWrites() { + Random random = new Random(41L); + List pool = new ArrayList<>(); + for (int index = 0; index < 9; index++) { + pool.add(mock(NativeBlockState.class)); + } + pool.add(null); + for (int trial = 0; trial < 40; trial++) { + int height = 16 * (1 + random.nextInt(12)); + SectionBuffer runs = new SectionBuffer(height); + SectionBuffer cells = new SectionBuffer(height); + for (int write = 0; write < 300; write++) { + int x = random.nextInt(16); + int z = random.nextInt(16); + int from = random.nextInt(height); + int to = Math.min(height - 1, from + random.nextInt(70)); + NativeBlockState state = pool.get(random.nextInt(pool.size())); + switch (random.nextInt(3)) { + case 0 -> runs.fillColumnRun(x, z, from, to, state); + case 1 -> runs.fillColumnRun(x, z, from, to, runs.paletteId(state)); + default -> ColumnRunHunk.fillColumnRun(runs, x, z, from, to, state); + } + for (int y = to; y >= from; y--) { + cells.setRaw(x, y, z, state); + } + } + assertSameContents(cells, runs); + } + } + + @Test + public void runsWithAnEmptyRangeOrNoStateWriteNothing() { + SectionBuffer buffer = new SectionBuffer(32); + NativeBlockState stone = mock(NativeBlockState.class); + buffer.fillColumnRun(1, 1, 10, 9, stone); + buffer.fillColumnRun(1, 1, 3, 9, (NativeBlockState) null); + buffer.fillColumnRun(1, 1, 3, 9, 0); + + assertEquals(-1, buffer.highestStoredY(1, 1)); + assertNull(buffer.section(0)); + assertEquals(1, buffer.palette().length); + } + + @Test + public void concurrentWritersNeverLoseTheColumnMaximum() throws InterruptedException { + for (int trial = 0; trial < 20; trial++) { + SectionBuffer buffer = new SectionBuffer(768); + NativeBlockState stone = mock(NativeBlockState.class); + int threads = 8; + runConcurrently(threads, thread -> { + for (int y = thread; y < 768; y += threads) { + buffer.setRaw(9, y, 4, stone); + } + }); + assertEquals(767, buffer.highestStoredY(9, 4)); + } + } + + @Test + public void concurrentPaletteGrowthKeepsOneIdPerInstance() throws InterruptedException { + int threads = 8; + int statesPerThread = 300; + NativeBlockState[][] states = new NativeBlockState[threads][statesPerThread]; + NativeBlockState[] shared = new NativeBlockState[40]; + for (int index = 0; index < shared.length; index++) { + shared[index] = mock(NativeBlockState.class); + } + for (int thread = 0; thread < threads; thread++) { + for (int index = 0; index < statesPerThread; index++) { + states[thread][index] = mock(NativeBlockState.class); + } + } + for (int trial = 0; trial < 10; trial++) { + SectionBuffer buffer = new SectionBuffer(384); + runConcurrently(threads, thread -> { + for (int index = 0; index < statesPerThread; index++) { + int x = thread * 2 + (index & 1); + int z = (index >> 1) & 15; + int y = index; + buffer.setRaw(x, y, z, states[thread][index]); + buffer.setRaw(x, y + 1, z, shared[index % shared.length]); + } + }); + for (int thread = 0; thread < threads; thread++) { + for (int index = 0; index < statesPerThread; index++) { + int x = thread * 2 + (index & 1); + int z = (index >> 1) & 15; + assertSame(states[thread][index], buffer.getStoredRaw(x, index, z)); + assertSame(shared[index % shared.length], buffer.getRaw(x, index + 1, z)); + } + } + assertEquals(1 + threads * statesPerThread + shared.length, buffer.palette().length); + for (NativeBlockState state : shared) { + assertSame(state, buffer.palette()[buffer.paletteId(state)]); + } + } + } + + @Test + public void listeningHunksForwardRunsInTopDownOrder() { + SectionBuffer buffer = new SectionBuffer(32); + NativeBlockState stone = mock(NativeBlockState.class); + List heard = new ArrayList<>(); + ColumnExtentListeningHunk listening = new ColumnExtentListeningHunk<>(buffer, + (x, y, z, state) -> heard.add(y)); + + ColumnRunHunk.fillColumnRun(listening, 4, 6, 10, 13, stone); + + assertEquals(List.of(13, 12, 11, 10), heard); + for (int y = 10; y <= 13; y++) { + assertSame(stone, buffer.getStoredRaw(4, y, 6)); + } + assertNull(buffer.getStoredRaw(4, 9, 6)); + assertEquals(13, buffer.highestStoredY(4, 6)); + } + + @Test + public void plainHunksReceiveRunsAsTopDownSingleWrites() { + Hunk array = Hunk.newArrayHunk(16, 32, 16); + NativeBlockState stone = mock(NativeBlockState.class); + array.setRaw(2, 5, 2, stone); + List order = new ArrayList<>(); + Hunk recording = new ColumnExtentListeningHunk<>(array, (x, y, z, state) -> order.add(y)); + + ColumnRunHunk.fillColumnRun(Hunk.newArrayHunk(16, 32, 16), 0, 0, 0, 3, stone); + ColumnRunHunk.fillColumnRun(array, 2, 2, 4, 6, null); + ColumnRunHunk.fillColumnRun(recording, 1, 1, 7, 9, stone); + + assertNull(array.getRaw(2, 5, 2)); + assertSame(stone, array.getRaw(1, 8, 1)); + assertEquals(List.of(9, 8, 7), order); + } + + private static void assertSameContents(SectionBuffer expected, SectionBuffer actual) { + assertEquals(expected.sectionCount(), actual.sectionCount()); + NativeBlockState[] expectedPalette = expected.palette(); + NativeBlockState[] actualPalette = actual.palette(); + for (int sectionIndex = 0; sectionIndex < expected.sectionCount(); sectionIndex++) { + short[] expectedSection = expected.section(sectionIndex); + short[] actualSection = actual.section(sectionIndex); + if (expectedSection == null) { + assertNull(actualSection); + continue; + } + NativeBlockState[] expectedStates = new NativeBlockState[4096]; + NativeBlockState[] actualStates = new NativeBlockState[4096]; + for (int index = 0; index < 4096; index++) { + expectedStates[index] = expectedPalette[expectedSection[index] & 0xFFFF]; + actualStates[index] = actualPalette[actualSection[index] & 0xFFFF]; + } + assertArrayEquals(expectedStates, actualStates); + } + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + assertEquals(expected.highestStoredY(x, z), actual.highestStoredY(x, z)); + } + } + assertEquals(Arrays.stream(expectedPalette).filter(state -> state != null).distinct().count(), + Arrays.stream(actualPalette).filter(state -> state != null).distinct().count()); + } + + private static void runConcurrently(int threads, ThreadTask task) throws InterruptedException { + CountDownLatch start = new CountDownLatch(1); + List workers = new ArrayList<>(); + for (int thread = 0; thread < threads; thread++) { + int index = thread; + Thread worker = new Thread(() -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + task.run(index); + }); + worker.start(); + workers.add(worker); + } + start.countDown(); + for (Thread worker : workers) { + worker.join(); + } + } + + @FunctionalInterface + private interface ThreadTask { + void run(int thread); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisNaturalCaveScanTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisNaturalCaveScanTest.java index 34e934a63..1144815d2 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisNaturalCaveScanTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisNaturalCaveScanTest.java @@ -19,11 +19,10 @@ import art.arcane.iris.generation.decoration.IrisDecorator; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.util.hunk.Hunk; import art.arcane.iris.generation.block.B; -import art.arcane.iris.generation.chunk.ChunkDataHunkHolder; +import art.arcane.iris.generation.chunk.SectionBuffer; import art.arcane.iris.generation.chunk.ColumnExtentListeningHunk; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; -import org.bukkit.generator.ChunkGenerator; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.matter.Matter; @@ -165,10 +164,7 @@ public class IrisNaturalCaveScanTest { fullOutput.listen((x, y, z, state) -> fullWrites.add(x + ":" + y + ":" + z + ":" + state.key())), null); Fixture bounded = new Fixture(true); - ChunkGenerator.ChunkData chunkData = mock(ChunkGenerator.ChunkData.class); - doReturn(0).when(chunkData).getMinHeight(); - doReturn(32).when(chunkData).getMaxHeight(); - ChunkDataHunkHolder holder = new ChunkDataHunkHolder(chunkData); + SectionBuffer holder = new SectionBuffer(32); bounded.sparseOutput(holder); List boundedWrites = new ArrayList<>(); bounded.modifier.decorateNaturalCaves(-32, 48, new ColumnExtentListeningHunk<>(holder, diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisPerfectionModifierColumnTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisPerfectionModifierColumnTest.java index ea1a7ec84..c91f8340e 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisPerfectionModifierColumnTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisPerfectionModifierColumnTest.java @@ -1,7 +1,7 @@ package art.arcane.iris.generation.stage; import art.arcane.iris.generation.block.B; -import art.arcane.iris.generation.chunk.ChunkDataHunkHolder; +import art.arcane.iris.generation.chunk.SectionBuffer; import art.arcane.iris.generation.chunk.ColumnExtentListeningHunk; import art.arcane.iris.generation.concurrent.BurstExecutor; import art.arcane.iris.generation.concurrent.MultiBurst; @@ -15,7 +15,6 @@ import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.util.hunk.Hunk; import art.arcane.volmlib.util.hunk.storage.ArrayHunk; import org.bukkit.block.data.BlockData; -import org.bukkit.generator.ChunkGenerator; import org.junit.Rule; import org.junit.Test; @@ -65,8 +64,8 @@ public class IrisPerfectionModifierColumnTest { NativeBlockState[] palette = {null, fixture.air, fixture.water, fixture.stone, fixture.decorant, fixture.half, fixture.spike(true, false), fixture.spike(false, true)}; for (int trial = 0; trial < 8; trial++) { - ChunkDataHunkHolder bounded = holder(64); - ChunkDataHunkHolder full = holder(64); + SectionBuffer bounded = new SectionBuffer(64); + SectionBuffer full = new SectionBuffer(64); for (int x = 0; x < 16; x++) { for (int z = 0; z < 16; z++) { int top = random.nextInt(64); @@ -91,13 +90,6 @@ public class IrisPerfectionModifierColumnTest { } } - private static ChunkDataHunkHolder holder(int height) { - ChunkGenerator.ChunkData chunk = mock(ChunkGenerator.ChunkData.class); - when(chunk.getMinHeight()).thenReturn(0); - when(chunk.getMaxHeight()).thenReturn(height); - return new ChunkDataHunkHolder(chunk); - } - @Test public void unrelatedTallColumnsAreScannedOnceDuringRepeatedCleanup() { Fixture fixture = new Fixture(); diff --git a/probe/src/main/java/art/arcane/iris/probe/ProbeChunkGenerator.java b/probe/src/main/java/art/arcane/iris/probe/ProbeChunkGenerator.java index efbb08746..64db59352 100644 --- a/probe/src/main/java/art/arcane/iris/probe/ProbeChunkGenerator.java +++ b/probe/src/main/java/art/arcane/iris/probe/ProbeChunkGenerator.java @@ -18,6 +18,7 @@ package art.arcane.iris.probe; +import art.arcane.iris.generation.chunk.SectionBuffer; import art.arcane.iris.generation.context.IrisContext; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.GenerationSessionLease; @@ -70,7 +71,7 @@ final class ProbeChunkGenerator { } private Hunk newBlocks(int height) { - return productionBuffers ? new ProductionBlockHunk(height) : Hunk.newArrayHunk(16, height, 16); + return productionBuffers ? new SectionBuffer(height) : Hunk.newArrayHunk(16, height, 16); } private Hunk newBiomes(int height) { diff --git a/probe/src/main/java/art/arcane/iris/probe/ProductionBlockHunk.java b/probe/src/main/java/art/arcane/iris/probe/ProductionBlockHunk.java deleted file mode 100644 index 91201e3cd..000000000 --- a/probe/src/main/java/art/arcane/iris/probe/ProductionBlockHunk.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Iris is a World Generator for Minecraft Servers - * Copyright (c) 2026 Arcane Arts (Volmit Software) - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package art.arcane.iris.probe; - -import art.arcane.iris.generation.block.BoundBlockState; -import art.arcane.iris.generation.chunk.ColumnExtent; -import art.arcane.volmlib.nativelib.terrain.NativeBlockState; -import art.arcane.volmlib.nativelib.terrain.NativeBlockVolume; -import art.arcane.volmlib.util.hunk.storage.StorageHunk; - -import java.lang.invoke.MethodHandles; -import java.lang.invoke.VarHandle; -import java.util.Arrays; - -final class ProductionBlockHunk extends StorageHunk implements NativeBlockVolume, ColumnExtent { - private static final BoundBlockState AIR = BoundBlockState.of("AIR"); - private static final VarHandle COLUMN_TOPS = MethodHandles.arrayElementVarHandle(int[].class); - - private final NativeBlockState[] data; - private final int[] columnTops; - private final int zStride; - - ProductionBlockHunk(int height) { - super(16, height, 16); - this.zStride = 16 * height; - this.data = new NativeBlockState[zStride * 16]; - this.columnTops = new int[256]; - Arrays.fill(columnTops, -1); - } - - @Override - public void setRaw(int x, int y, int z, NativeBlockState t) { - if (t == null) { - return; - } - data[index(x, y, z)] = t; - int column = (z << 4) | x; - int top = (int) COLUMN_TOPS.getOpaque(columnTops, column); - while (y > top) { - int witness = (int) COLUMN_TOPS.compareAndExchange(columnTops, column, top, y); - if (witness == top) { - break; - } - top = witness; - } - } - - @Override - public NativeBlockState getRaw(int x, int y, int z) { - if (y < 0 || y >= getHeight()) { - return AIR.get(); - } - NativeBlockState state = data[index(x, y, z)]; - return state != null ? state : AIR.get(); - } - - @Override - public NativeBlockState getStoredRaw(int x, int y, int z) { - return data[index(x, y, z)]; - } - - @Override - public int highestStoredY(int x, int z) { - return (int) COLUMN_TOPS.getOpaque(columnTops, (z << 4) | x); - } - - private int index(int x, int y, int z) { - return (z * zStride) + (y << 4) + x; - } -} diff --git a/probe/src/test/java/art/arcane/iris/probe/ProductionHunkTest.java b/probe/src/test/java/art/arcane/iris/probe/ProductionHunkTest.java index 1a21fb510..1917afcd1 100644 --- a/probe/src/test/java/art/arcane/iris/probe/ProductionHunkTest.java +++ b/probe/src/test/java/art/arcane/iris/probe/ProductionHunkTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.probe; +import art.arcane.iris.generation.chunk.SectionBuffer; import art.arcane.volmlib.nativelib.terrain.NativeBiome; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.util.hunk.Hunk; @@ -19,7 +20,7 @@ public final class ProductionHunkTest { @Test public void blockHunkIgnoresNullWritesAndTracksTheHighestStoredCellPerColumn() { - ProductionBlockHunk blocks = new ProductionBlockHunk(HEIGHT); + SectionBuffer blocks = new SectionBuffer(HEIGHT); NativeBlockState stone = StubPlatform.blockStateForTest("minecraft:stone"); NativeBlockState dirt = StubPlatform.blockStateForTest("minecraft:dirt"); @@ -37,7 +38,7 @@ public final class ProductionHunkTest { @Test public void dumpCaptureOfTheBlockHunkMatchesAnArrayHunkHoldingTheSameWrites() { - ProductionBlockHunk production = new ProductionBlockHunk(HEIGHT); + SectionBuffer production = new SectionBuffer(HEIGHT); Hunk array = Hunk.newArrayHunk(16, HEIGHT, 16); Hunk biomes = Hunk.newArrayHunk(16, HEIGHT, 16); NativeBlockState stone = StubPlatform.blockStateForTest("minecraft:stone"); From e376e1f59a25a55d1c6f7ea2dd3438eee4d1066d Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 22 Oct 2026 00:25:11 -0400 Subject: [PATCH 2/2] Purity allowlist successor - ChunkDataOutput replaces ChunkDataHunkHolder as the Bukkit-coupled chunk data writer --- core/purity-allowlist.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/purity-allowlist.txt b/core/purity-allowlist.txt index 0d263819a..838d61c6f 100644 --- a/core/purity-allowlist.txt +++ b/core/purity-allowlist.txt @@ -16,8 +16,8 @@ art/arcane/iris/generation/block/IrisCustomData.java art/arcane/iris/generation/block/LegacyTileData.java art/arcane/iris/generation/block/TileData.java art/arcane/iris/generation/chunk/ChunkBiomeHunkView.java -art/arcane/iris/generation/chunk/ChunkDataHunkHolder.java art/arcane/iris/generation/chunk/ChunkDataHunkView.java +art/arcane/iris/generation/chunk/ChunkDataOutput.java art/arcane/iris/generation/chunk/ChunkHunkView.java art/arcane/iris/generation/chunk/LinkedTerrainChunk.java art/arcane/iris/generation/chunk/TerrainChunk.java