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
new file mode 100644
index 000000000..2860edbd1
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/generation/runtime/ChunkBlockUpdateListener.java
@@ -0,0 +1,53 @@
+/*
+ * 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.runtime;
+
+import art.arcane.iris.generation.block.B;
+import art.arcane.iris.generation.chunk.ColumnExtentListeningHunk;
+import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
+
+/**
+ * Forwards a chunk's block writes to {@link BlockUpdater#catchBlockUpdates} at world coordinates.
+ * The last state seen to need neither a block update nor custom handling is remembered, so the
+ * long runs of plain terrain skip the per-block checks. Racing writers only ever store such states.
+ */
+final class ChunkBlockUpdateListener implements ColumnExtentListeningHunk.Listener {
+ private final BlockUpdater updater;
+ private final int originX;
+ private final int originZ;
+ private NativeBlockState quiet;
+
+ ChunkBlockUpdateListener(BlockUpdater updater, int originX, int originZ) {
+ this.updater = updater;
+ this.originX = originX;
+ this.originZ = originZ;
+ }
+
+ @Override
+ public void onWrite(int x, int y, int z, NativeBlockState state) {
+ if (state == null || state == quiet) {
+ return;
+ }
+ if (B.isUpdatable(state) || state.isCustom()) {
+ updater.catchBlockUpdates(originX + x, y, originZ + z, state);
+ return;
+ }
+ quiet = state;
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java
index 70cdd6b7c..dc35693c1 100644
--- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java
+++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java
@@ -1472,7 +1472,7 @@ public class IrisEngine implements Engine {
IrisContext.Scope generationScope = IrisContext.open(this, lease.sessionId(), null)) {
getEngineData().getStatistics().generatedChunk();
PrecisionStopwatch p = PrecisionStopwatch.start();
- Hunk blocks = new ColumnExtentListeningHunk<>(vblocks, (xx, y, zz, t) -> catchBlockUpdates(x + xx, y, z + zz, t));
+ Hunk blocks = new ColumnExtentListeningHunk<>(vblocks, new ChunkBlockUpdateListener(this, x, z));
if (getDimension().isDebugChunkCrossSections() && ((x >> 4) % getDimension().getDebugCrossSectionsMod() == 0 || (z >> 4) % getDimension().getDebugCrossSectionsMod() == 0)) {
NativeBlockState crossSection = B.getState("CRYING_OBSIDIAN");
diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/ChunkBlockUpdateListenerTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/ChunkBlockUpdateListenerTest.java
new file mode 100644
index 000000000..d5d992380
--- /dev/null
+++ b/core/src/test/java/art/arcane/iris/generation/runtime/ChunkBlockUpdateListenerTest.java
@@ -0,0 +1,60 @@
+package art.arcane.iris.generation.runtime;
+
+import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class ChunkBlockUpdateListenerTest {
+ @Test
+ public void forwardsExactlyTheWritesWithBlockUpdateEffects() {
+ NativeBlockState stone = state(false, false);
+ NativeBlockState deepslate = state(false, false);
+ NativeBlockState water = state(true, false);
+ NativeBlockState custom = state(false, true);
+ NativeBlockState[] palette = {stone, deepslate, water, custom, null};
+ Random random = new Random(42L);
+ List expected = new ArrayList<>();
+ List actual = new ArrayList<>();
+ BlockUpdater reference = effects(expected);
+ ChunkBlockUpdateListener listener = new ChunkBlockUpdateListener(effects(actual), 160, -48);
+
+ for (int write = 0; write < 20_000; write++) {
+ NativeBlockState state = palette[random.nextInt(8) < 5 ? random.nextInt(2) : random.nextInt(palette.length)];
+ int x = random.nextInt(16);
+ int y = random.nextInt(384);
+ int z = random.nextInt(16);
+ reference.catchBlockUpdates(160 + x, y, -48 + z, state);
+ listener.onWrite(x, y, z, state);
+ }
+
+ assertEquals(expected, actual);
+ }
+
+ private static BlockUpdater effects(List log) {
+ return (x, y, z, data) -> {
+ if (data == null) {
+ return;
+ }
+ if (data.isUpdatable()) {
+ log.add("update " + x + "," + y + "," + z);
+ }
+ if (data.isCustom()) {
+ log.add("custom " + (x >> 4) + "," + (z >> 4));
+ }
+ };
+ }
+
+ private static NativeBlockState state(boolean updatable, boolean custom) {
+ NativeBlockState state = mock(NativeBlockState.class);
+ when(state.isUpdatable()).thenReturn(updatable);
+ when(state.isCustom()).thenReturn(custom);
+ return state;
+ }
+}