diff --git a/core/src/main/java/art/arcane/iris/generation/chunk/ColumnBiomeHunk.java b/core/src/main/java/art/arcane/iris/generation/chunk/ColumnBiomeHunk.java
new file mode 100644
index 000000000..8872b4c07
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/generation/chunk/ColumnBiomeHunk.java
@@ -0,0 +1,112 @@
+/*
+ * 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.platform.bukkit.BukkitBiome;
+import art.arcane.volmlib.nativelib.terrain.NativeBiome;
+import art.arcane.volmlib.util.hunk.HunkMutationSupport;
+import art.arcane.volmlib.util.hunk.storage.StorageHunk;
+import org.bukkit.block.Biome;
+
+/**
+ * Biome output for one Bukkit chunk, read back only by the generation stages. The biome actuator writes whole
+ * columns, so storage stays at one entry per column until a partial column write arrives; that write switches
+ * the hunk to full resolution. Reads, clamping and the plains fallback match {@link LinkedTerrainChunk}.
+ */
+public final class ColumnBiomeHunk extends StorageHunk {
+ private static final int CHUNK_SIZE = 16;
+
+ private final NativeBiome[] columns = new NativeBiome[CHUNK_SIZE * CHUNK_SIZE];
+ private NativeBiome[] cells;
+ private volatile NativeBiome defaultBiome;
+
+ public ColumnBiomeHunk(int height) {
+ super(CHUNK_SIZE, height, CHUNK_SIZE);
+ }
+
+ @Override
+ public void set(int x1, int y1, int z1, int x2, int y2, int z2, NativeBiome biome) {
+ if (x1 == x2 && z1 == z2 && y1 == 0 && y2 == getHeight() - 1) {
+ fillColumn(x1, z1, LinkedTerrainChunk.canonicalBiome(biome));
+ return;
+ }
+
+ HunkMutationSupport.setRangeInclusive(this, x1, y1, z1, x2, y2, z2, biome);
+ }
+
+ @Override
+ public void setRaw(int x, int y, int z, NativeBiome biome) {
+ NativeBiome[] storage = cells == null ? expand() : cells;
+ storage[cellIndex(x, y, z)] = LinkedTerrainChunk.canonicalBiome(biome);
+ }
+
+ @Override
+ public NativeBiome getRaw(int x, int y, int z) {
+ NativeBiome[] storage = cells;
+ NativeBiome biome = storage == null ? columns[columnIndex(x, z)] : storage[cellIndex(x, y, z)];
+ if (biome != null) {
+ return biome;
+ }
+ NativeBiome fallback = defaultBiome;
+ if (fallback == null) {
+ fallback = BukkitBiome.of(Biome.PLAINS);
+ defaultBiome = fallback;
+ }
+ return fallback;
+ }
+
+ private void fillColumn(int x, int z, NativeBiome biome) {
+ NativeBiome[] storage = cells;
+ if (storage == null) {
+ columns[columnIndex(x, z)] = biome;
+ return;
+ }
+ int stride = CHUNK_SIZE * CHUNK_SIZE;
+ int index = columnIndex(x, z);
+ for (int y = 0; y < getHeight(); y++) {
+ storage[index] = biome;
+ index += stride;
+ }
+ }
+
+ private NativeBiome[] expand() {
+ NativeBiome[] storage = new NativeBiome[CHUNK_SIZE * getHeight() * CHUNK_SIZE];
+ int stride = CHUNK_SIZE * CHUNK_SIZE;
+ for (int column = 0; column < stride; column++) {
+ NativeBiome biome = columns[column];
+ if (biome == null) {
+ continue;
+ }
+ for (int index = column; index < storage.length; index += stride) {
+ storage[index] = biome;
+ }
+ }
+ cells = storage;
+ return storage;
+ }
+
+ private static int columnIndex(int x, int z) {
+ return ((z & (CHUNK_SIZE - 1)) * CHUNK_SIZE) + (x & (CHUNK_SIZE - 1));
+ }
+
+ private int cellIndex(int x, int y, int z) {
+ int clampedY = Math.max(0, Math.min(getHeight() - 1, y));
+ return (clampedY * CHUNK_SIZE * CHUNK_SIZE) + columnIndex(x, z);
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/generation/chunk/LinkedTerrainChunk.java b/core/src/main/java/art/arcane/iris/generation/chunk/LinkedTerrainChunk.java
index 8451bdcfe..8b6d37c41 100644
--- a/core/src/main/java/art/arcane/iris/generation/chunk/LinkedTerrainChunk.java
+++ b/core/src/main/java/art/arcane/iris/generation/chunk/LinkedTerrainChunk.java
@@ -130,7 +130,7 @@ public class LinkedTerrainChunk implements TerrainChunk {
return (clampedY * CHUNK_SIZE + clampedZ) * CHUNK_SIZE + clampedX;
}
- private static NativeBiome canonicalBiome(NativeBiome biome) {
+ static NativeBiome canonicalBiome(NativeBiome biome) {
if (biome instanceof BukkitBiome) {
return biome;
}
diff --git a/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java
index 556dcce12..7c04a6142 100644
--- a/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java
+++ b/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java
@@ -72,7 +72,7 @@ import art.arcane.volmlib.util.bukkit.WorldIdentity;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.M;
import art.arcane.volmlib.util.hunk.Hunk;
-import art.arcane.iris.generation.chunk.TerrainChunkBiomeHunkView;
+import art.arcane.iris.generation.chunk.ColumnBiomeHunk;
import art.arcane.iris.generation.chunk.ChunkDataHunkHolder;
import art.arcane.iris.generation.context.ChunkContext;
import art.arcane.iris.generation.context.IrisContext;
@@ -1298,12 +1298,11 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
try {
Engine engine = getEngine(world);
StudioGenerator selected = computeStudioGenerator();
- TerrainChunk tc = TerrainChunk.create(d);
if (selected != null) {
- selected.generateChunk(engine, tc, x, z);
+ selected.generateChunk(engine, TerrainChunk.create(d), x, z);
} else {
ChunkDataHunkHolder blocks = new ChunkDataHunkHolder(d);
- Hunk biomes = new TerrainChunkBiomeHunkView(tc);
+ Hunk biomes = new ColumnBiomeHunk(d.getMaxHeight() - d.getMinHeight());
try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope =
openGenerationHistoryCoordinateScope(engine, x << 4, z << 4);
GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_terrain_stage");
diff --git a/core/src/test/java/art/arcane/iris/generation/chunk/LinkedTerrainChunkTest.java b/core/src/test/java/art/arcane/iris/generation/chunk/LinkedTerrainChunkTest.java
index 54c386389..bc67df495 100644
--- a/core/src/test/java/art/arcane/iris/generation/chunk/LinkedTerrainChunkTest.java
+++ b/core/src/test/java/art/arcane/iris/generation/chunk/LinkedTerrainChunkTest.java
@@ -18,6 +18,7 @@ import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Proxy;
+import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
@@ -110,6 +111,48 @@ public class LinkedTerrainChunkTest {
}
}
+ @Test
+ public void columnBiomeHunkMatchesTheLinkedChunkView() {
+ NativeBiome plains = BukkitBiome.of(Biome.PLAINS);
+ NativeBiome desert = BukkitBiome.of(Biome.DESERT);
+ NativeBiome alternate = mock(NativeBiome.class);
+ when(alternate.nativeHandle()).thenReturn(Biome.DESERT);
+ NativeBiome[] palette = {plains, desert, alternate};
+ for (int trial = 0; trial < 12; trial++) {
+ Random random = new Random(9127L + trial);
+ TerrainChunkBiomeHunkView expected = new TerrainChunkBiomeHunkView(chunk());
+ ColumnBiomeHunk actual = new ColumnBiomeHunk(16);
+ int operations = 1 + random.nextInt(80);
+ for (int operation = 0; operation < operations; operation++) {
+ NativeBiome biome = palette[random.nextInt(palette.length)];
+ int x = random.nextInt(16);
+ int z = random.nextInt(16);
+ int kind = random.nextInt(trial % 3 == 0 ? 1 : 3);
+ if (kind == 0) {
+ expected.set(x, 0, z, x, 15, z, biome);
+ actual.set(x, 0, z, x, 15, z, biome);
+ } else if (kind == 1) {
+ int minimumY = random.nextInt(16);
+ int maximumY = minimumY + random.nextInt(16 - minimumY);
+ expected.set(x, minimumY, z, x, maximumY, z, biome);
+ actual.set(x, minimumY, z, x, maximumY, z, biome);
+ } else {
+ int y = random.nextInt(20) - 2;
+ int rawX = x + (random.nextBoolean() ? 16 : 0);
+ expected.setRaw(rawX, y, z, biome);
+ actual.setRaw(rawX, y, z, biome);
+ }
+ }
+ for (int x = 0; x < 16; x++) {
+ for (int z = 0; z < 16; z++) {
+ for (int y = -2; y < 18; y++) {
+ assertSame("biome " + x + "," + y + "," + z, expected.getRaw(x, y, z), actual.getRaw(x, y, z));
+ }
+ }
+ }
+ }
+ }
+
private static LinkedTerrainChunk chunk() {
ChunkData data = mock(ChunkData.class);
when(data.getMinHeight()).thenReturn(-16);