diff --git a/patches/server/Fix-World-isChunkGenerated-calls.patch b/patches/server/Fix-World-isChunkGenerated-calls.patch
index 8f684191e3..1e52306f13 100644
--- a/patches/server/Fix-World-isChunkGenerated-calls.patch
+++ b/patches/server/Fix-World-isChunkGenerated-calls.patch
@@ -117,7 +117,7 @@ index 0000000000000000000000000000000000000000..00000000000000000000000000000000
 +        }
 +
 +        // Note: Copied from below
-+        return ChunkStatus.getStatus(compound.getCompound("Level").getString("Status"));
++        return ChunkStatus.getStatus(compound.getString("Status"));
 +    }
 +    // Paper end
 +
diff --git a/patches/server/Optimize-NibbleArray-to-use-pooled-buffers.patch b/patches/server/Optimize-NibbleArray-to-use-pooled-buffers.patch
deleted file mode 100644
index 2be574fc3e..0000000000
--- a/patches/server/Optimize-NibbleArray-to-use-pooled-buffers.patch
+++ /dev/null
@@ -1,378 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Aikar <aikar@aikar.co>
-Date: Wed, 6 May 2020 23:30:30 -0400
-Subject: [PATCH] Optimize NibbleArray to use pooled buffers
-
-Massively reduces memory allocation of 2048 byte buffers by using
-an object pool for these.
-
-Uses lots of advanced new capabilities of the Paper codebase :)
-
-diff --git a/src/main/java/net/minecraft/network/protocol/game/ClientboundLevelChunkWithLightPacket.java b/src/main/java/net/minecraft/network/protocol/game/ClientboundLevelChunkWithLightPacket.java
-index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
---- a/src/main/java/net/minecraft/network/protocol/game/ClientboundLevelChunkWithLightPacket.java
-+++ b/src/main/java/net/minecraft/network/protocol/game/ClientboundLevelChunkWithLightPacket.java
-@@ -0,0 +0,0 @@ package net.minecraft.network.protocol.game;
- 
- import java.util.BitSet;
- import javax.annotation.Nullable;
-+
-+import io.netty.channel.ChannelFuture;
- import net.minecraft.network.FriendlyByteBuf;
- import net.minecraft.network.protocol.Packet;
-+import net.minecraft.server.level.ServerPlayer;
- import net.minecraft.world.level.ChunkPos;
- import net.minecraft.world.level.chunk.LevelChunk;
- import net.minecraft.world.level.lighting.LevelLightEngine;
-@@ -0,0 +0,0 @@ public class ClientboundLevelChunkWithLightPacket implements Packet<ClientGamePa
-     private final ClientboundLevelChunkPacketData chunkData;
-     private final ClientboundLightUpdatePacketData lightData;
- 
-+    // Paper start
-+    @Override
-+    public void onPacketDispatch(ServerPlayer player) {
-+        lightData.onPacketDispatch(player);
-+    }
-+
-+    @Override
-+    public void onPacketDispatchFinish(ServerPlayer player, ChannelFuture future) {
-+        lightData.onPacketDispatchFinish(player, future);
-+    }
-+
-+    @Override
-+    public boolean hasFinishListener() {
-+        return true;
-+    }
-+    // Paper end
-+
-     public ClientboundLevelChunkWithLightPacket(LevelChunk chunk, LevelLightEngine lightProvider, @Nullable BitSet skyBits, @Nullable BitSet blockBits, boolean nonEdge) {
-         ChunkPos chunkPos = chunk.getPos();
-         this.x = chunkPos.x;
-diff --git a/src/main/java/net/minecraft/network/protocol/game/ClientboundLightUpdatePacket.java b/src/main/java/net/minecraft/network/protocol/game/ClientboundLightUpdatePacket.java
-index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
---- a/src/main/java/net/minecraft/network/protocol/game/ClientboundLightUpdatePacket.java
-+++ b/src/main/java/net/minecraft/network/protocol/game/ClientboundLightUpdatePacket.java
-@@ -0,0 +0,0 @@ package net.minecraft.network.protocol.game;
- 
- import java.util.BitSet;
- import javax.annotation.Nullable;
-+
-+import io.netty.channel.ChannelFuture;
- import net.minecraft.network.FriendlyByteBuf;
- import net.minecraft.network.protocol.Packet;
-+import net.minecraft.server.level.ServerPlayer;
- import net.minecraft.world.level.ChunkPos;
- import net.minecraft.world.level.lighting.LevelLightEngine;
- 
-@@ -0,0 +0,0 @@ public class ClientboundLightUpdatePacket implements Packet<ClientGamePacketList
-     private final int z;
-     private final ClientboundLightUpdatePacketData lightData;
- 
-+    // Paper start
-+    @Override
-+    public void onPacketDispatch(ServerPlayer player) {
-+        lightData.onPacketDispatch(player);
-+    }
-+
-+    @Override
-+    public void onPacketDispatchFinish(ServerPlayer player, ChannelFuture future) {
-+        lightData.onPacketDispatchFinish(player, future);
-+    }
-+
-+    @Override
-+    public boolean hasFinishListener() {
-+        return true;
-+    }
-+    // Paper end
-+
-     public ClientboundLightUpdatePacket(ChunkPos chunkPos, LevelLightEngine lightProvider, @Nullable BitSet skyBits, @Nullable BitSet blockBits, boolean nonEdge) {
-         this.x = chunkPos.x;
-         this.z = chunkPos.z;
-diff --git a/src/main/java/net/minecraft/network/protocol/game/ClientboundLightUpdatePacketData.java b/src/main/java/net/minecraft/network/protocol/game/ClientboundLightUpdatePacketData.java
-index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
---- a/src/main/java/net/minecraft/network/protocol/game/ClientboundLightUpdatePacketData.java
-+++ b/src/main/java/net/minecraft/network/protocol/game/ClientboundLightUpdatePacketData.java
-@@ -0,0 +0,0 @@ public class ClientboundLightUpdatePacketData {
-     private final List<byte[]> skyUpdates;
-     private final List<byte[]> blockUpdates;
-     private final boolean trustEdges;
-+    // Paper start
-+    java.lang.Runnable cleaner1;
-+    java.lang.Runnable cleaner2;
-+    java.util.concurrent.atomic.AtomicInteger remainingSends = new java.util.concurrent.atomic.AtomicInteger(0);
-+
-+    public void onPacketDispatch(net.minecraft.server.level.ServerPlayer player) {
-+        remainingSends.incrementAndGet();
-+    }
-+
-+    public void onPacketDispatchFinish(net.minecraft.server.level.ServerPlayer player, io.netty.channel.ChannelFuture future) {
-+        if (remainingSends.decrementAndGet() <= 0) {
-+            // incase of any race conditions, schedule this delayed
-+            net.minecraft.server.MCUtil.scheduleTask(5, () -> {
-+                if (remainingSends.get() == 0) {
-+                    cleaner1.run();
-+                    cleaner2.run();
-+                }
-+            }, "Light Packet Release");
-+        }
-+    }
-+    // Paper end
- 
-     public ClientboundLightUpdatePacketData(ChunkPos pos, LevelLightEngine lightProvider, @Nullable BitSet skyBits, @Nullable BitSet blockBits, boolean nonEdge) {
-         this.trustEdges = nonEdge;
-@@ -0,0 +0,0 @@ public class ClientboundLightUpdatePacketData {
-         this.blockYMask = new BitSet();
-         this.emptySkyYMask = new BitSet();
-         this.emptyBlockYMask = new BitSet();
--        this.skyUpdates = Lists.newArrayList();
--        this.blockUpdates = Lists.newArrayList();
-+        this.skyUpdates = Lists.newArrayList();this.cleaner1 = net.minecraft.server.MCUtil.registerListCleaner(this, this.skyUpdates, DataLayer::releaseBytes); // Paper
-+        this.blockUpdates = Lists.newArrayList();this.cleaner2 = net.minecraft.server.MCUtil.registerListCleaner(this, this.blockUpdates, DataLayer::releaseBytes); // Paper
- 
-         for(int i = 0; i < lightProvider.getLightSectionCount(); ++i) {
-             if (skyBits == null || skyBits.get(i)) {
-@@ -0,0 +0,0 @@ public class ClientboundLightUpdatePacketData {
-                 uninitialized.set(y);
-             } else {
-                 initialized.set(y);
--                nibbles.add((byte[])dataLayer.getData().clone());
-+                nibbles.add((byte[])dataLayer.getCloneIfSet()); // Paper
-             }
-         }
- 
-diff --git a/src/main/java/net/minecraft/world/level/chunk/DataLayer.java b/src/main/java/net/minecraft/world/level/chunk/DataLayer.java
-index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
---- a/src/main/java/net/minecraft/world/level/chunk/DataLayer.java
-+++ b/src/main/java/net/minecraft/world/level/chunk/DataLayer.java
-@@ -0,0 +0,0 @@ public final class DataLayer {
-     private static final int NIBBLE_SIZE = 4;
-     @Nullable
-     protected byte[] data;
-+    // Paper start
-+    public static byte[] EMPTY_NIBBLE = new byte[2048];
-+    private static final int nibbleBucketSizeMultiplier = Integer.getInteger("Paper.nibbleBucketSize", 3072);
-+    private static final int maxPoolSize = Integer.getInteger("Paper.maxNibblePoolSize", (int) Math.min(6, Math.max(1, Runtime.getRuntime().maxMemory() / 1024 / 1024 / 1024)) * (nibbleBucketSizeMultiplier * 8));
-+    public static final com.destroystokyo.paper.util.pooled.PooledObjects<byte[]> BYTE_2048 = new com.destroystokyo.paper.util.pooled.PooledObjects<>(() -> new byte[2048], maxPoolSize);
-+    public static void releaseBytes(byte[] bytes) {
-+        if (bytes != null && bytes != EMPTY_NIBBLE && bytes.length == 2048) {
-+            System.arraycopy(EMPTY_NIBBLE, 0, bytes, 0, 2048);
-+            BYTE_2048.release(bytes);
-+        }
-+    }
- 
-+    public DataLayer markPoolSafe(byte[] bytes) {
-+        if (bytes != EMPTY_NIBBLE) this.data = bytes;
-+        return markPoolSafe();
-+    }
-+    public DataLayer markPoolSafe() {
-+        poolSafe = true;
-+        return this;
-+    }
-+    public byte[] getIfSet() {
-+        return this.data != null ? this.data : EMPTY_NIBBLE;
-+    }
-+    public byte[] getCloneIfSet() {
-+        if (data == null) {
-+            return EMPTY_NIBBLE;
-+        }
-+        byte[] ret = BYTE_2048.acquire();
-+        System.arraycopy(getIfSet(), 0, ret, 0, 2048);
-+        return ret;
-+    }
-+
-+    public DataLayer cloneAndSet(byte[] bytes) {
-+        if (bytes != null && bytes != EMPTY_NIBBLE) {
-+            this.data = BYTE_2048.acquire();
-+            System.arraycopy(bytes, 0, this.data, 0, 2048);
-+        }
-+        return this;
-+    }
-+    boolean poolSafe = false;
-+    public java.lang.Runnable cleaner;
-+    private void registerCleaner() {
-+        if (!poolSafe) {
-+            cleaner = net.minecraft.server.MCUtil.registerCleaner(this, this.data, DataLayer::releaseBytes);
-+        } else {
-+            cleaner = net.minecraft.server.MCUtil.once(() -> DataLayer.releaseBytes(this.data));
-+        }
-+    }
-     public DataLayer() {}
- 
-     public DataLayer(byte[] bytes) {
-+        // Paper start
-+        this(bytes, false);
-+    }
-+    public DataLayer(byte[] bytes, boolean isSafe) {
-         this.data = bytes;
-+        if (!isSafe) this.data = getCloneIfSet(); // Paper - clone for safety
-+        registerCleaner();
-+        // Paper end
-         if (bytes.length != 2048) {
-             throw (IllegalArgumentException) Util.pauseInIde(new IllegalArgumentException("DataLayer should be 2048 bytes not: " + bytes.length));
-         }
-@@ -0,0 +0,0 @@ public final class DataLayer {
- 
-     private void set(int index, int value) {
-         if (this.data == null) {
--            this.data = new byte[2048];
-+            this.data = BYTE_2048.acquire(); // Paper
-+            registerCleaner();// Paper
-         }
- 
-         int k = DataLayer.getByteIndex(index);
-@@ -0,0 +0,0 @@ public final class DataLayer {
-     public byte[] getData() {
-         if (this.data == null) {
-             this.data = new byte[2048];
-+        } else { // Paper start
-+            // Accessor may need this object past garbage collection so need to clone it and return pooled value
-+            // If we know its safe for pre GC access, use asBytesPoolSafe(). If you just need read, use getIfSet()
-+            Runnable cleaner = this.cleaner;
-+            if (cleaner != null) {
-+                this.data = this.data.clone();
-+                cleaner.run(); // release the previously pooled value
-+                this.cleaner = null;
-+            }
-         }
-+        // Paper end
- 
-         return this.data;
-     }
- 
-+    @javax.annotation.Nonnull
-+    public byte[] asBytesPoolSafe() {
-+        if (this.data == null) {
-+            this.data = BYTE_2048.acquire(); // Paper
-+            registerCleaner(); // Paper
-+        }
-+
-+        return this.data;
-+    }
-+    // Paper end
-     public DataLayer copy() {
--        return this.data == null ? new DataLayer() : new DataLayer((byte[]) this.data.clone());
-+        return this.data == null ? new DataLayer() : new DataLayer(this.data); // Paper - clone in ctor
-     }
- 
-     public String toString() {
-diff --git a/src/main/java/net/minecraft/world/level/chunk/storage/ChunkSerializer.java b/src/main/java/net/minecraft/world/level/chunk/storage/ChunkSerializer.java
-index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
---- a/src/main/java/net/minecraft/world/level/chunk/storage/ChunkSerializer.java
-+++ b/src/main/java/net/minecraft/world/level/chunk/storage/ChunkSerializer.java
-@@ -0,0 +0,0 @@ public class ChunkSerializer {
-                 }
- 
-                 if (nibblearray != null && !nibblearray.isEmpty()) {
--                    nbttagcompound1.putByteArray("BlockLight", nibblearray.getData());
-+                    nbttagcompound1.putByteArray("BlockLight", nibblearray.asBytesPoolSafe().clone()); // Paper
-                 }
- 
-                 if (nibblearray1 != null && !nibblearray1.isEmpty()) {
--                    nbttagcompound1.putByteArray("SkyLight", nibblearray1.getData());
-+                    nbttagcompound1.putByteArray("SkyLight", nibblearray1.asBytesPoolSafe().clone()); // Paper
-                 }
- 
-                 if (!nbttagcompound1.isEmpty()) {
-diff --git a/src/main/java/net/minecraft/world/level/lighting/DataLayerStorageMap.java b/src/main/java/net/minecraft/world/level/lighting/DataLayerStorageMap.java
-index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
---- a/src/main/java/net/minecraft/world/level/lighting/DataLayerStorageMap.java
-+++ b/src/main/java/net/minecraft/world/level/lighting/DataLayerStorageMap.java
-@@ -0,0 +0,0 @@ public abstract class DataLayerStorageMap<M extends DataLayerStorageMap<M>> {
- 
-     public void copyDataLayer(long pos) {
-         if (this.isVisible) { throw new IllegalStateException("writing to visible data"); } // Paper - avoid copying light data
--        this.data.queueUpdate(pos, ((DataLayer) this.data.getUpdating(pos)).copy()); // Paper - avoid copying light data
-+        DataLayer updating = this.data.getUpdating(pos); // Paper - pool nibbles
-+        this.data.queueUpdate(pos, new DataLayer().markPoolSafe(updating.getCloneIfSet())); // Paper - avoid copying light data - pool safe clone
-+        if (updating.cleaner != null) net.minecraft.server.MCUtil.scheduleTask(2, updating.cleaner, "Light Engine Release"); // Paper - delay clean incase anything holding ref was still using it
-         this.clearCache();
-     }
- 
-diff --git a/src/main/java/net/minecraft/world/level/lighting/LayerLightSectionStorage.java b/src/main/java/net/minecraft/world/level/lighting/LayerLightSectionStorage.java
-index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
---- a/src/main/java/net/minecraft/world/level/lighting/LayerLightSectionStorage.java
-+++ b/src/main/java/net/minecraft/world/level/lighting/LayerLightSectionStorage.java
-@@ -0,0 +0,0 @@ public abstract class LayerLightSectionStorage<M extends DataLayerStorageMap<M>>
- 
-     protected DataLayer createDataLayer(long sectionPos) {
-         DataLayer dataLayer = this.queuedSections.get(sectionPos);
--        return dataLayer != null ? dataLayer : new DataLayer();
-+        return dataLayer != null ? dataLayer : new DataLayer().markPoolSafe(); // Paper
-     }
- 
-     protected void clearQueuedSectionBlocks(LayerLightEngine<?, ?> storage, long sectionPos) {
-@@ -0,0 +0,0 @@ public abstract class LayerLightSectionStorage<M extends DataLayerStorageMap<M>>
- 
-     protected void queueSectionData(long sectionPos, @Nullable DataLayer array, boolean nonEdge) {
-         if (array != null) {
--            this.queuedSections.put(sectionPos, array);
-+            DataLayer remove = this.queuedSections.put(sectionPos, array); if (remove != null && remove.cleaner != null) remove.cleaner.run(); // Paper - clean up when removed
-             if (!nonEdge) {
-                 this.untrustedSections.add(sectionPos);
-             }
-         } else {
--            this.queuedSections.remove(sectionPos);
-+            DataLayer remove = this.queuedSections.remove(sectionPos); if (remove != null && remove.cleaner != null) remove.cleaner.run(); // Paper - clean up when removed
-         }
- 
-     }
-diff --git a/src/main/java/net/minecraft/world/level/lighting/SkyLightSectionStorage.java b/src/main/java/net/minecraft/world/level/lighting/SkyLightSectionStorage.java
-index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
---- a/src/main/java/net/minecraft/world/level/lighting/SkyLightSectionStorage.java
-+++ b/src/main/java/net/minecraft/world/level/lighting/SkyLightSectionStorage.java
-@@ -0,0 +0,0 @@ public class SkyLightSectionStorage extends LayerLightSectionStorage<SkyLightSec
- 
-                 return repeatFirstLayer(dataLayer2);
-             } else {
--                return new DataLayer();
-+                return new DataLayer().markPoolSafe(); // Paper - mark pool use as safe (no auto cleaner)
-             }
-         }
-     }
- 
-     private static DataLayer repeatFirstLayer(DataLayer source) {
-         if (source.isEmpty()) {
--            return new DataLayer();
-+            return new DataLayer().markPoolSafe(); // Paper - mark pool use as safe (no auto cleaner)
-         } else {
-             byte[] bs = source.getData();
-             byte[] cs = new byte[2048];
-@@ -0,0 +0,0 @@ public class SkyLightSectionStorage extends LayerLightSectionStorage<SkyLightSec
-                 System.arraycopy(bs, 0, cs, i * 128, 128);
-             }
- 
--            return new DataLayer(cs);
-+            return new DataLayer(cs).markPoolSafe(cs); // Paper - mark pool use as safe (no auto cleaner)
-         }
-     }
- 
-@@ -0,0 +0,0 @@ public class SkyLightSectionStorage extends LayerLightSectionStorage<SkyLightSec
-                                 this.updatingSectionData.copyDataLayer(l);
-                             }
- 
--                            Arrays.fill(this.getDataLayer(l, true).getData(), (byte)-1);
-+                            Arrays.fill(this.getDataLayer(l, true).asBytesPoolSafe(), (byte)-1); // Paper
-                             int j = SectionPos.sectionToBlockCoord(SectionPos.x(l));
-                             int k = SectionPos.sectionToBlockCoord(SectionPos.y(l));
-                             int m = SectionPos.sectionToBlockCoord(SectionPos.z(l));
-diff --git a/src/main/java/org/bukkit/craftbukkit/CraftChunk.java b/src/main/java/org/bukkit/craftbukkit/CraftChunk.java
-index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
---- a/src/main/java/org/bukkit/craftbukkit/CraftChunk.java
-+++ b/src/main/java/org/bukkit/craftbukkit/CraftChunk.java
-@@ -0,0 +0,0 @@ public class CraftChunk implements Chunk {
-                 sectionSkyLights[i] = CraftChunk.emptyLight;
-             } else {
-                 sectionSkyLights[i] = new byte[2048];
--                System.arraycopy(skyLightArray.getData(), 0, sectionSkyLights[i], 0, 2048);
-+                System.arraycopy(skyLightArray.getIfSet(), 0, sectionSkyLights[i], 0, 2048); // Paper
-             }
-             DataLayer emitLightArray = lightengine.getLayerListener(LightLayer.BLOCK).getDataLayerData(SectionPos.of(x, i, z));
-             if (emitLightArray == null) {
-                 sectionEmitLights[i] = CraftChunk.emptyLight;
-             } else {
-                 sectionEmitLights[i] = new byte[2048];
--                System.arraycopy(emitLightArray.getData(), 0, sectionEmitLights[i], 0, 2048);
-+                System.arraycopy(emitLightArray.getIfSet(), 0, sectionEmitLights[i], 0, 2048); // Paper
-             }
- 
-             if (biome != null) {
diff --git a/patches/server/Rewrite-the-light-engine.patch b/patches/server/Rewrite-the-light-engine.patch
new file mode 100644
index 0000000000..07ffe96bc8
--- /dev/null
+++ b/patches/server/Rewrite-the-light-engine.patch
@@ -0,0 +1,5211 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Spottedleaf <spottedleaf@spottedleaf.dev>
+Date: Wed, 28 Oct 2020 16:51:55 -0700
+Subject: [PATCH] Rewrite the light engine
+
+The standard vanilla light engine is plagued by
+awful performance. Paper's changes to the light engine
+help a bit, however they appear to cause some lighting
+errors - most easily noticed in coral generation.
+
+The vanilla light engine's is too abstract to be modified -
+so an entirely new implementation is required to fix the
+performance and lighting errors.
+
+The new implementation is designed primarily to optimise
+light level propagations (increase and decrease). Unlike
+the vanilla light engine, this implementation tracks more
+information per queued value when performing a
+breadth first search. Vanilla just tracks coordinate, which
+means every time they handle a queued value, they must
+also determine the coordinate's target light level
+from its neighbours - very wasteful, especially considering
+these checks read neighbour block data.
+The new light engine tracks both position and target level,
+as well as whether the target block needs to be read at all
+(for checking sided propagation). So, the work done per coordinate
+is significantly reduced because no work is done for calculating
+the target level.
+In my testing, the block get calls were reduced by approximately
+an order of magnitude. However, the light read checks were only
+reduced by approximately 2x - but this is fine, light read checks
+are extremely cheap compared to block gets.
+
+Generation testing showed that the new light engine improved
+total generation (not lighting itself, but the whole generation process)
+by 2x. According to cpu time, the light engine itself spent 10x less time
+lighting chunks for generation.
+
+diff --git a/src/main/java/ca/spottedleaf/starlight/common/light/BlockStarLightEngine.java b/src/main/java/ca/spottedleaf/starlight/common/light/BlockStarLightEngine.java
+new file mode 100644
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
+--- /dev/null
++++ b/src/main/java/ca/spottedleaf/starlight/common/light/BlockStarLightEngine.java
+@@ -0,0 +0,0 @@
++package ca.spottedleaf.starlight.common.light;
++
++import net.minecraft.core.BlockPos;
++import net.minecraft.world.level.Level;
++import net.minecraft.world.level.block.state.BlockState;
++import net.minecraft.world.level.chunk.ChunkAccess;
++import net.minecraft.world.level.chunk.ChunkStatus;
++import net.minecraft.world.level.chunk.ImposterProtoChunk;
++import net.minecraft.world.level.chunk.LevelChunk;
++import net.minecraft.world.level.chunk.LevelChunkSection;
++import net.minecraft.world.level.chunk.LightChunkGetter;
++import net.minecraft.world.level.chunk.PalettedContainer;
++import net.minecraft.world.phys.shapes.Shapes;
++import net.minecraft.world.phys.shapes.VoxelShape;
++import java.util.ArrayList;
++import java.util.Iterator;
++import java.util.List;
++import java.util.Set;
++import java.util.stream.Collectors;
++
++public final class BlockStarLightEngine extends StarLightEngine {
++
++    public BlockStarLightEngine(final Level world) {
++        super(false, world);
++    }
++
++    @Override
++    protected boolean[] getEmptinessMap(final ChunkAccess chunk) {
++        return chunk.getBlockEmptinessMap();
++    }
++
++    @Override
++    protected void setEmptinessMap(final ChunkAccess chunk, final boolean[] to) {
++        chunk.setBlockEmptinessMap(to);
++    }
++
++    @Override
++    protected SWMRNibbleArray[] getNibblesOnChunk(final ChunkAccess chunk) {
++        return chunk.getBlockNibbles();
++    }
++
++    @Override
++    protected void setNibbles(final ChunkAccess chunk, final SWMRNibbleArray[] to) {
++        chunk.setBlockNibbles(to);
++    }
++
++    @Override
++    protected boolean canUseChunk(final ChunkAccess chunk) {
++        return chunk.getStatus().isOrAfter(ChunkStatus.LIGHT) && (this.isClientSide || chunk.isLightCorrect());
++    }
++
++    @Override
++    protected void setNibbleNull(final int chunkX, final int chunkY, final int chunkZ) {
++        final SWMRNibbleArray nibble = this.getNibbleFromCache(chunkX, chunkY, chunkZ);
++        if (nibble != null) {
++            // de-initialisation is not as straightforward as with sky data, since deinit of block light is typically
++            // because a block was removed - which can decrease light. with sky data, block breaking can only result
++            // in increases, and thus the existing sky block check will actually correctly propagate light through
++            // a null section. so in order to propagate decreases correctly, we can do a couple of things: not remove
++            // the data section, or do edge checks on ALL axis (x, y, z). however I do not want edge checks running
++            // for clients at all, as they are expensive. so we don't remove the section, but to maintain the appearence
++            // of vanilla data management we "hide" them.
++            nibble.setHidden();
++        }
++    }
++
++    @Override
++    protected void initNibble(final int chunkX, final int chunkY, final int chunkZ, final boolean extrude, final boolean initRemovedNibbles) {
++        if (chunkY < this.minLightSection || chunkY > this.maxLightSection || this.getChunkInCache(chunkX, chunkZ) == null) {
++            return;
++        }
++
++        final SWMRNibbleArray nibble = this.getNibbleFromCache(chunkX, chunkY, chunkZ);
++        if (nibble == null) {
++            if (!initRemovedNibbles) {
++                throw new IllegalStateException();
++            } else {
++                this.setNibbleInCache(chunkX, chunkY, chunkZ, new SWMRNibbleArray());
++            }
++        } else {
++            nibble.setNonNull();
++        }
++    }
++
++    @Override
++    protected final void checkBlock(final LightChunkGetter lightAccess, final int worldX, final int worldY, final int worldZ) {
++        // blocks can change opacity
++        // blocks can change emitted light
++        // blocks can change direction of propagation
++
++        final int encodeOffset = this.coordinateOffset;
++        final int emittedMask = this.emittedLightMask;
++
++        final int currentLevel = this.getLightLevel(worldX, worldY, worldZ);
++        final BlockState blockState = this.getBlockState(worldX, worldY, worldZ);
++        final int emittedLevel = blockState.getLightEmission() & emittedMask;
++
++        this.setLightLevel(worldX, worldY, worldZ, emittedLevel);
++        // this accounts for change in emitted light that would cause an increase
++        if (emittedLevel != 0) {
++            this.appendToIncreaseQueue(
++                    ((worldX + (worldZ << 6) + (worldY << (6 + 6)) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                            | (emittedLevel & 0xFL) << (6 + 6 + 16)
++                            | (((long)ALL_DIRECTIONS_BITSET) << (6 + 6 + 16 + 4))
++                            | (blockState.isConditionallyFullOpaque() ? FLAG_HAS_SIDED_TRANSPARENT_BLOCKS : 0)
++            );
++        }
++        // this also accounts for a change in emitted light that would cause a decrease
++        // this also accounts for the change of direction of propagation (i.e old block was full transparent, new block is full opaque or vice versa)
++        // as it checks all neighbours (even if current level is 0)
++        this.appendToDecreaseQueue(
++                ((worldX + (worldZ << 6) + (worldY << (6 + 6)) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                        | (currentLevel & 0xFL) << (6 + 6 + 16)
++                        | (((long)ALL_DIRECTIONS_BITSET) << (6 + 6 + 16 + 4))
++                        // always keep sided transparent false here, new block might be conditionally transparent which would
++                        // prevent us from decreasing sources in the directions where the new block is opaque
++                        // if it turns out we were wrong to de-propagate the source, the re-propagate logic WILL always
++                        // catch that and fix it.
++        );
++        // re-propagating neighbours (done by the decrease queue) will also account for opacity changes in this block
++    }
++
++    protected final BlockPos.MutableBlockPos recalcCenterPos = new BlockPos.MutableBlockPos();
++    protected final BlockPos.MutableBlockPos recalcNeighbourPos = new BlockPos.MutableBlockPos();
++
++    @Override
++    protected int calculateLightValue(final LightChunkGetter lightAccess, final int worldX, final int worldY, final int worldZ,
++                                      final int expect) {
++        final BlockState centerState = this.getBlockState(worldX, worldY, worldZ);
++        int level = centerState.getLightEmission() & 0xF;
++
++        if (level >= (15 - 1) || level > expect) {
++            return level;
++        }
++
++        final int sectionOffset = this.chunkSectionIndexOffset;
++        final BlockState conditionallyOpaqueState;
++        int opacity = centerState.getOpacityIfCached();
++
++        if (opacity == -1) {
++            this.recalcCenterPos.set(worldX, worldY, worldZ);
++            opacity = centerState.getLightBlock(lightAccess.getLevel(), this.recalcCenterPos);
++            if (centerState.isConditionallyFullOpaque()) {
++                conditionallyOpaqueState = centerState;
++            } else {
++                conditionallyOpaqueState = null;
++            }
++        } else if (opacity >= 15) {
++            return level;
++        } else {
++            conditionallyOpaqueState = null;
++        }
++        opacity = Math.max(1, opacity);
++
++        for (final AxisDirection direction : AXIS_DIRECTIONS) {
++            final int offX = worldX + direction.x;
++            final int offY = worldY + direction.y;
++            final int offZ = worldZ + direction.z;
++
++            final int sectionIndex = (offX >> 4) + 5 * (offZ >> 4) + (5 * 5) * (offY >> 4) + sectionOffset;
++
++            final int neighbourLevel = this.getLightLevel(sectionIndex, (offX & 15) | ((offZ & 15) << 4) | ((offY & 15) << 8));
++
++            if ((neighbourLevel - 1) <= level) {
++                // don't need to test transparency, we know it wont affect the result.
++                continue;
++            }
++
++            final BlockState neighbourState = this.getBlockState(offX, offY, offZ);
++            if (neighbourState.isConditionallyFullOpaque()) {
++                // here the block can be conditionally opaque (i.e light cannot propagate from it), so we need to test that
++                // we don't read the blockstate because most of the time this is false, so using the faster
++                // known transparency lookup results in a net win
++                this.recalcNeighbourPos.set(offX, offY, offZ);
++                final VoxelShape neighbourFace = neighbourState.getFaceOcclusionShape(lightAccess.getLevel(), this.recalcNeighbourPos, direction.opposite.nms);
++                final VoxelShape thisFace = conditionallyOpaqueState == null ? Shapes.empty() : conditionallyOpaqueState.getFaceOcclusionShape(lightAccess.getLevel(), this.recalcCenterPos, direction.nms);
++                if (Shapes.faceShapeOccludes(thisFace, neighbourFace)) {
++                    // not allowed to propagate
++                    continue;
++                }
++            }
++
++            // passed transparency,
++
++            final int calculated = neighbourLevel - opacity;
++            level = Math.max(calculated, level);
++            if (level > expect) {
++                return level;
++            }
++        }
++
++        return level;
++    }
++
++    @Override
++    protected void propagateBlockChanges(final LightChunkGetter lightAccess, final ChunkAccess atChunk, final Set<BlockPos> positions) {
++        for (final BlockPos pos : positions) {
++            this.checkBlock(lightAccess, pos.getX(), pos.getY(), pos.getZ());
++        }
++
++        this.performLightDecrease(lightAccess);
++    }
++
++    protected Iterator<BlockPos> getSources(final LightChunkGetter lightAccess, final ChunkAccess chunk) {
++        if (chunk instanceof ImposterProtoChunk || chunk instanceof LevelChunk) {
++            // implementation on Chunk is pretty awful, so write our own here. The big optimisation is
++            // skipping empty sections, and the far more optimised reading of types.
++            List<BlockPos> sources = new ArrayList<>();
++
++            int offX = chunk.getPos().x << 4;
++            int offZ = chunk.getPos().z << 4;
++
++            final LevelChunkSection[] sections = chunk.getSections();
++            for (int sectionY = this.minSection; sectionY <= this.maxSection; ++sectionY) {
++                final LevelChunkSection section = sections[sectionY - this.minSection];
++                if (section == null || section.hasOnlyAir()) {
++                    // no sources in empty sections
++                    continue;
++                }
++                final PalettedContainer<BlockState> states = section.states;
++                final int offY = sectionY << 4;
++
++                for (int index = 0; index < (16 * 16 * 16); ++index) {
++                    final BlockState state = states.get(index);
++                    if (state.getLightEmission() <= 0) {
++                        continue;
++                    }
++
++                    // index = x | (z << 4) | (y << 8)
++                    sources.add(new BlockPos(offX | (index & 15), offY | (index >>> 8), offZ | ((index >>> 4) & 15)));
++                }
++            }
++
++            return sources.iterator();
++        } else {
++            // world gen and lighting run in parallel, and if lighting keeps up it can be lighting chunks that are
++            // being generated. In the nether, lava will add a lot of sources. This resulted in quite a few CME crashes.
++            // So all we do spinloop until we can collect a list of sources, and even if it is out of date we will pick up
++            // the missing sources from checkBlock.
++            for (;;) {
++                try {
++                    return chunk.getLights().collect(Collectors.toList()).iterator();
++                } catch (final Exception cme) {
++                    continue;
++                }
++            }
++        }
++    }
++
++    @Override
++    public void lightChunk(final LightChunkGetter lightAccess, final ChunkAccess chunk, final boolean needsEdgeChecks) {
++        // setup sources
++        final int emittedMask = this.emittedLightMask;
++        for (final Iterator<BlockPos> positions = this.getSources(lightAccess, chunk); positions.hasNext();) {
++            final BlockPos pos = positions.next();
++            final BlockState blockState = this.getBlockState(pos.getX(), pos.getY(), pos.getZ());
++            final int emittedLight = blockState.getLightEmission() & emittedMask;
++
++            if (emittedLight <= this.getLightLevel(pos.getX(), pos.getY(), pos.getZ())) {
++                // some other source is brighter
++                continue;
++            }
++
++            this.appendToIncreaseQueue(
++                    ((pos.getX() + (pos.getZ() << 6) + (pos.getY() << (6 + 6)) + this.coordinateOffset) & ((1L << (6 + 6 + 16)) - 1))
++                            | (emittedLight & 0xFL) << (6 + 6 + 16)
++                            | (((long)ALL_DIRECTIONS_BITSET) << (6 + 6 + 16 + 4))
++                            | (blockState.isConditionallyFullOpaque() ? FLAG_HAS_SIDED_TRANSPARENT_BLOCKS : 0)
++            );
++
++
++            // propagation wont set this for us
++            this.setLightLevel(pos.getX(), pos.getY(), pos.getZ(), emittedLight);
++        }
++
++        if (needsEdgeChecks) {
++            // not required to propagate here, but this will reduce the hit of the edge checks
++            this.performLightIncrease(lightAccess);
++
++            // verify neighbour edges
++            this.checkChunkEdges(lightAccess, chunk, this.minLightSection, this.maxLightSection);
++        } else {
++            this.propagateNeighbourLevels(lightAccess, chunk, this.minLightSection, this.maxLightSection);
++
++            this.performLightIncrease(lightAccess);
++        }
++    }
++}
+diff --git a/src/main/java/ca/spottedleaf/starlight/common/light/SWMRNibbleArray.java b/src/main/java/ca/spottedleaf/starlight/common/light/SWMRNibbleArray.java
+new file mode 100644
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
+--- /dev/null
++++ b/src/main/java/ca/spottedleaf/starlight/common/light/SWMRNibbleArray.java
+@@ -0,0 +0,0 @@
++package ca.spottedleaf.starlight.common.light;
++
++import net.minecraft.world.level.chunk.DataLayer;
++import java.util.ArrayDeque;
++import java.util.Arrays;
++
++// SWMR -> Single Writer Multi Reader Nibble Array
++public final class SWMRNibbleArray {
++
++    /*
++     * Null nibble - nibble does not exist, and should not be written to. Just like vanilla - null
++     * nibbles are always 0 - and they are never written to directly. Only initialised/uninitialised
++     * nibbles can be written to.
++     *
++     * Uninitialised nibble - They are all 0, but the backing array isn't initialised.
++     *
++     * Initialised nibble - Has light data.
++     */
++
++    protected static final int INIT_STATE_NULL   = 0; // null
++    protected static final int INIT_STATE_UNINIT = 1; // uninitialised
++    protected static final int INIT_STATE_INIT   = 2; // initialised
++    protected static final int INIT_STATE_HIDDEN = 3; // initialised, but conversion to Vanilla data should be treated as if NULL
++
++    public static final int ARRAY_SIZE = 16 * 16 * 16 / (8/4); // blocks / bytes per block
++    // this allows us to maintain only 1 byte array when we're not updating
++    static final ThreadLocal<ArrayDeque<byte[]>> WORKING_BYTES_POOL = ThreadLocal.withInitial(ArrayDeque::new);
++
++    private static byte[] allocateBytes() {
++        final byte[] inPool = WORKING_BYTES_POOL.get().pollFirst();
++        if (inPool != null) {
++            return inPool;
++        }
++
++        return new byte[ARRAY_SIZE];
++    }
++
++    private static void freeBytes(final byte[] bytes) {
++        WORKING_BYTES_POOL.get().addFirst(bytes);
++    }
++
++    public static SWMRNibbleArray fromVanilla(final DataLayer nibble) {
++        if (nibble == null) {
++            return new SWMRNibbleArray(null, true);
++        } else if (nibble.isEmpty()) {
++            return new SWMRNibbleArray();
++        } else {
++            return new SWMRNibbleArray(nibble.getData().clone()); // make sure we don't write to the parameter later
++        }
++    }
++
++    protected int stateUpdating;
++    protected volatile int stateVisible;
++
++    protected byte[] storageUpdating;
++    protected boolean updatingDirty; // only returns whether storageUpdating is dirty
++    protected volatile byte[] storageVisible;
++
++    public SWMRNibbleArray() {
++        this(null, false); // lazy init
++    }
++
++    public SWMRNibbleArray(final byte[] bytes) {
++        this(bytes, false);
++    }
++
++    public SWMRNibbleArray(final byte[] bytes, final boolean isNullNibble) {
++        if (bytes != null && bytes.length != ARRAY_SIZE) {
++            throw new IllegalArgumentException("Data of wrong length: " + bytes.length);
++        }
++        this.stateVisible = this.stateUpdating = bytes == null ? (isNullNibble ? INIT_STATE_NULL : INIT_STATE_UNINIT) : INIT_STATE_INIT;
++        this.storageUpdating = this.storageVisible = bytes;
++    }
++
++    public SWMRNibbleArray(final byte[] bytes, final int state) {
++        if (bytes != null && bytes.length != ARRAY_SIZE) {
++            throw new IllegalArgumentException("Data of wrong length: " + bytes.length);
++        }
++        if (bytes == null && (state == INIT_STATE_INIT || state == INIT_STATE_HIDDEN)) {
++            throw new IllegalArgumentException("Data cannot be null and have state be initialised");
++        }
++        this.stateUpdating = this.stateVisible = state;
++        this.storageUpdating = this.storageVisible = bytes;
++    }
++
++    @Override
++    public String toString() {
++        StringBuilder stringBuilder = new StringBuilder();
++        stringBuilder.append("State: ");
++        switch (this.stateVisible) {
++            case INIT_STATE_NULL:
++                stringBuilder.append("null");
++                break;
++            case INIT_STATE_UNINIT:
++                stringBuilder.append("uninitialised");
++                break;
++            case INIT_STATE_INIT:
++                stringBuilder.append("initialised");
++                break;
++            case INIT_STATE_HIDDEN:
++                stringBuilder.append("hidden");
++                break;
++            default:
++                stringBuilder.append("unknown");
++                break;
++        }
++        stringBuilder.append("\nData:\n");
++
++        final byte[] data = this.storageVisible;
++        if (data != null) {
++            for (int i = 0; i < 4096; ++i) {
++                // Copied from NibbleArray#toString
++                final int level = ((data[i >>> 1] >>> ((i & 1) << 2)) & 0xF);
++
++                stringBuilder.append(Integer.toHexString(level));
++                if ((i & 15) == 15) {
++                    stringBuilder.append("\n");
++                }
++
++                if ((i & 255) == 255) {
++                    stringBuilder.append("\n");
++                }
++            }
++        } else {
++            stringBuilder.append("null");
++        }
++
++        return stringBuilder.toString();
++    }
++
++    public SaveState getSaveState() {
++        synchronized (this) {
++            final int state = this.stateVisible;
++            final byte[] data = this.storageVisible;
++            if (state == INIT_STATE_NULL) {
++                return null;
++            }
++            if (state == INIT_STATE_UNINIT) {
++                return new SaveState(null, state);
++            }
++            final boolean zero = isAllZero(data);
++            if (zero) {
++                return state == INIT_STATE_INIT ? new SaveState(null, INIT_STATE_UNINIT) : null;
++            } else {
++                return new SaveState(data.clone(), state);
++            }
++        }
++    }
++
++    protected static boolean isAllZero(final byte[] data) {
++        for (int i = 0; i < (ARRAY_SIZE >>> 4); ++i) {
++            byte whole = data[i << 4];
++
++            for (int k = 1; k < (1 << 4); ++k) {
++                whole |= data[(i << 4) | k];
++            }
++
++            if (whole != 0) {
++                return false;
++            }
++        }
++
++        return true;
++    }
++
++    // operation type: updating on src, updating on other
++    public void extrudeLower(final SWMRNibbleArray other) {
++        if (other.stateUpdating == INIT_STATE_NULL) {
++            throw new IllegalArgumentException();
++        }
++
++        if (other.storageUpdating == null) {
++            this.setUninitialised();
++            return;
++        }
++
++        final byte[] src = other.storageUpdating;
++        final byte[] into;
++
++        if (this.storageUpdating != null) {
++            into = this.storageUpdating;
++        } else {
++            this.storageUpdating = into = allocateBytes();
++            this.stateUpdating = INIT_STATE_INIT;
++        }
++        this.updatingDirty = true;
++
++        final int start = 0;
++        final int end = (15 | (15 << 4)) >>> 1;
++
++        /* x | (z << 4) | (y << 8) */
++        for (int y = 0; y <= 15; ++y) {
++            System.arraycopy(src, start, into, y << (8 - 1), end - start + 1);
++        }
++    }
++
++    // operation type: updating
++    public void setFull() {
++        if (this.stateUpdating != INIT_STATE_HIDDEN) {
++            this.stateUpdating = INIT_STATE_INIT;
++        }
++        Arrays.fill(this.storageUpdating == null || !this.updatingDirty ? this.storageUpdating = allocateBytes() : this.storageUpdating, (byte)-1);
++        this.updatingDirty = true;
++    }
++
++    // operation type: updating
++    public void setZero() {
++        if (this.stateUpdating != INIT_STATE_HIDDEN) {
++            this.stateUpdating = INIT_STATE_INIT;
++        }
++        Arrays.fill(this.storageUpdating == null || !this.updatingDirty ? this.storageUpdating = allocateBytes() : this.storageUpdating, (byte)0);
++        this.updatingDirty = true;
++    }
++
++    // operation type: updating
++    public void setNonNull() {
++        if (this.stateUpdating == INIT_STATE_HIDDEN) {
++            this.stateUpdating = INIT_STATE_INIT;
++            return;
++        }
++        if (this.stateUpdating != INIT_STATE_NULL) {
++            return;
++        }
++        this.stateUpdating = INIT_STATE_UNINIT;
++    }
++
++    // operation type: updating
++    public void setNull() {
++        this.stateUpdating = INIT_STATE_NULL;
++        if (this.updatingDirty && this.storageUpdating != null) {
++            freeBytes(this.storageUpdating);
++        }
++        this.storageUpdating = null;
++        this.updatingDirty = false;
++    }
++
++    // operation type: updating
++    public void setUninitialised() {
++        this.stateUpdating = INIT_STATE_UNINIT;
++        if (this.storageUpdating != null && this.updatingDirty) {
++            freeBytes(this.storageUpdating);
++        }
++        this.storageUpdating = null;
++        this.updatingDirty = false;
++    }
++
++    // operation type: updating
++    public void setHidden() {
++        if (this.stateUpdating == INIT_STATE_HIDDEN) {
++            return;
++        }
++        if (this.stateUpdating != INIT_STATE_INIT) {
++            this.setNull();
++        } else {
++            this.stateUpdating = INIT_STATE_HIDDEN;
++        }
++    }
++
++    // operation type: updating
++    public boolean isDirty() {
++        return this.stateUpdating != this.stateVisible || this.updatingDirty;
++    }
++
++    // operation type: updating
++    public boolean isNullNibbleUpdating() {
++        return this.stateUpdating == INIT_STATE_NULL;
++    }
++
++    // operation type: visible
++    public boolean isNullNibbleVisible() {
++        return this.stateVisible == INIT_STATE_NULL;
++    }
++
++    // opeartion type: updating
++    public boolean isUninitialisedUpdating() {
++        return this.stateUpdating == INIT_STATE_UNINIT;
++    }
++
++    // operation type: visible
++    public boolean isUninitialisedVisible() {
++        return this.stateVisible == INIT_STATE_UNINIT;
++    }
++
++    // operation type: updating
++    public boolean isInitialisedUpdating() {
++        return this.stateUpdating == INIT_STATE_INIT;
++    }
++
++    // operation type: visible
++    public boolean isInitialisedVisible() {
++        return this.stateVisible == INIT_STATE_INIT;
++    }
++
++    // operation type: updating
++    public boolean isHiddenUpdating() {
++        return this.stateUpdating == INIT_STATE_HIDDEN;
++    }
++
++    // operation type: updating
++    public boolean isHiddenVisible() {
++        return this.stateVisible == INIT_STATE_HIDDEN;
++    }
++
++    // operation type: updating
++    protected void swapUpdatingAndMarkDirty() {
++        if (this.updatingDirty) {
++            return;
++        }
++
++        if (this.storageUpdating == null) {
++            this.storageUpdating = allocateBytes();
++            Arrays.fill(this.storageUpdating, (byte)0);
++        } else {
++            System.arraycopy(this.storageUpdating, 0, this.storageUpdating = allocateBytes(), 0, ARRAY_SIZE);
++        }
++
++        if (this.stateUpdating != INIT_STATE_HIDDEN) {
++            this.stateUpdating = INIT_STATE_INIT;
++        }
++        this.updatingDirty = true;
++    }
++
++    // operation type: updating
++    public boolean updateVisible() {
++        if (!this.isDirty()) {
++            return false;
++        }
++
++        synchronized (this) {
++            if (this.stateUpdating == INIT_STATE_NULL || this.stateUpdating == INIT_STATE_UNINIT) {
++                this.storageVisible = null;
++            } else {
++                if (this.storageVisible == null) {
++                    this.storageVisible = this.storageUpdating.clone();
++                } else {
++                    if (this.storageUpdating != this.storageVisible) {
++                        System.arraycopy(this.storageUpdating, 0, this.storageVisible, 0, ARRAY_SIZE);
++                    }
++                }
++
++                if (this.storageUpdating != this.storageVisible) {
++                    freeBytes(this.storageUpdating);
++                }
++                this.storageUpdating = this.storageVisible;
++            }
++            this.updatingDirty = false;
++            this.stateVisible = this.stateUpdating;
++        }
++
++        return true;
++    }
++
++    // operation type: visible
++    public DataLayer toVanillaNibble() {
++        synchronized (this) {
++            switch (this.stateVisible) {
++                case INIT_STATE_HIDDEN:
++                case INIT_STATE_NULL:
++                    return null;
++                case INIT_STATE_UNINIT:
++                    return new DataLayer();
++                case INIT_STATE_INIT:
++                    return new DataLayer(this.storageVisible.clone());
++                default:
++                    throw new IllegalStateException();
++            }
++        }
++    }
++
++    /* x | (z << 4) | (y << 8) */
++
++    // operation type: updating
++    public int getUpdating(final int x, final int y, final int z) {
++        return this.getUpdating((x & 15) | ((z & 15) << 4) | ((y & 15) << 8));
++    }
++
++    // operation type: updating
++    public int getUpdating(final int index) {
++        // indices range from 0 -> 4096
++        final byte[] bytes = this.storageUpdating;
++        if (bytes == null) {
++            return 0;
++        }
++        final byte value = bytes[index >>> 1];
++
++        // if we are an even index, we want lower 4 bits
++        // if we are an odd index, we want upper 4 bits
++        return ((value >>> ((index & 1) << 2)) & 0xF);
++    }
++
++    // operation type: visible
++    public int getVisible(final int x, final int y, final int z) {
++        return this.getVisible((x & 15) | ((z & 15) << 4) | ((y & 15) << 8));
++    }
++
++    // operation type: visible
++    public int getVisible(final int index) {
++        // indices range from 0 -> 4096
++        final byte[] visibleBytes = this.storageVisible;
++        if (visibleBytes == null) {
++            return 0;
++        }
++        final byte value = visibleBytes[index >>> 1];
++
++        // if we are an even index, we want lower 4 bits
++        // if we are an odd index, we want upper 4 bits
++        return ((value >>> ((index & 1) << 2)) & 0xF);
++    }
++
++    // operation type: updating
++    public void set(final int x, final int y, final int z, final int value) {
++        this.set((x & 15) | ((z & 15) << 4) | ((y & 15) << 8), value);
++    }
++
++    // operation type: updating
++    public void set(final int index, final int value) {
++        if (!this.updatingDirty) {
++            this.swapUpdatingAndMarkDirty();
++        }
++        final int shift = (index & 1) << 2;
++        final int i = index >>> 1;
++
++        this.storageUpdating[i] = (byte)((this.storageUpdating[i] & (0xF0 >>> shift)) | (value << shift));
++    }
++
++    public static final class SaveState {
++
++        public final byte[] data;
++        public final int state;
++
++        public SaveState(final byte[] data, final int state) {
++            this.data = data;
++            this.state = state;
++        }
++    }
++}
+diff --git a/src/main/java/ca/spottedleaf/starlight/common/light/SkyStarLightEngine.java b/src/main/java/ca/spottedleaf/starlight/common/light/SkyStarLightEngine.java
+new file mode 100644
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
+--- /dev/null
++++ b/src/main/java/ca/spottedleaf/starlight/common/light/SkyStarLightEngine.java
+@@ -0,0 +0,0 @@
++package ca.spottedleaf.starlight.common.light;
++
++import ca.spottedleaf.starlight.common.util.WorldUtil;
++import it.unimi.dsi.fastutil.shorts.ShortCollection;
++import it.unimi.dsi.fastutil.shorts.ShortIterator;
++import net.minecraft.core.BlockPos;
++import net.minecraft.world.level.BlockGetter;
++import net.minecraft.world.level.ChunkPos;
++import net.minecraft.world.level.Level;
++import net.minecraft.world.level.block.state.BlockState;
++import net.minecraft.world.level.chunk.ChunkAccess;
++import net.minecraft.world.level.chunk.ChunkStatus;
++import net.minecraft.world.level.chunk.LevelChunkSection;
++import net.minecraft.world.level.chunk.LightChunkGetter;
++import net.minecraft.world.phys.shapes.Shapes;
++import net.minecraft.world.phys.shapes.VoxelShape;
++import java.util.Arrays;
++import java.util.Set;
++
++public final class SkyStarLightEngine extends StarLightEngine {
++
++    /*
++      Specification for managing the initialisation and de-initialisation of skylight nibble arrays:
++
++      Skylight nibble initialisation requires that non-empty chunk sections have 1 radius nibbles non-null.
++
++      This presents some problems, as vanilla is only guaranteed to have 0 radius neighbours loaded when editing blocks.
++      However starlight fixes this so that it has 1 radius loaded. Still, we don't actually have guarantees
++      that we have the necessary chunks loaded to de-initialise neighbour sections (but we do have enough to de-initialise
++      our own) - we need a radius of 2 to de-initialise neighbour nibbles.
++      How do we solve this?
++
++      Each chunk will store the last known "emptiness" of sections for each of their 1 radius neighbour chunk sections.
++      If the chunk does not have full data, then its nibbles are NOT de-initialised. This is because obviously the
++      chunk did not go through the light stage yet - or its neighbours are not lit. In either case, once the last
++      known "emptiness" of neighbouring sections is filled with data, the chunk will run a full check of the data
++      to see if any of its nibbles need to be de-initialised.
++
++      The emptiness map allows us to de-initialise neighbour nibbles if the neighbour has it filled with data,
++      and if it doesn't have data then we know it will correctly de-initialise once it fills up.
++
++      Unlike vanilla, we store whether nibbles are uninitialised on disk - so we don't need any dumb hacking
++      around those.
++     */
++
++    protected final int[] heightMapBlockChange = new int[16 * 16];
++    {
++        Arrays.fill(this.heightMapBlockChange, Integer.MIN_VALUE); // clear heightmap
++    }
++
++    protected final boolean[] nullPropagationCheckCache;
++
++    public SkyStarLightEngine(final Level world) {
++        super(true, world);
++        this.nullPropagationCheckCache = new boolean[WorldUtil.getTotalLightSections(world)];
++    }
++
++    @Override
++    protected void initNibble(final int chunkX, final int chunkY, final int chunkZ, final boolean extrude, final boolean initRemovedNibbles) {
++        if (chunkY < this.minLightSection || chunkY > this.maxLightSection || this.getChunkInCache(chunkX, chunkZ) == null) {
++            return;
++        }
++        SWMRNibbleArray nibble = this.getNibbleFromCache(chunkX, chunkY, chunkZ);
++        if (nibble == null) {
++            if (!initRemovedNibbles) {
++                throw new IllegalStateException();
++            } else {
++                this.setNibbleInCache(chunkX, chunkY, chunkZ, nibble = new SWMRNibbleArray(null, true));
++            }
++        }
++        this.initNibble(nibble, chunkX, chunkY, chunkZ, extrude);
++    }
++
++    @Override
++    protected void setNibbleNull(final int chunkX, final int chunkY, final int chunkZ) {
++        final SWMRNibbleArray nibble = this.getNibbleFromCache(chunkX, chunkY, chunkZ);
++        if (nibble != null) {
++            nibble.setNull();
++        }
++    }
++
++    protected final void initNibble(final SWMRNibbleArray currNibble, final int chunkX, final int chunkY, final int chunkZ, final boolean extrude) {
++        if (!currNibble.isNullNibbleUpdating()) {
++            // already initialised
++            return;
++        }
++
++        final boolean[] emptinessMap = this.getEmptinessMap(chunkX, chunkZ);
++
++        // are we above this chunk's lowest empty section?
++        int lowestY = this.minLightSection - 1;
++        for (int currY = this.maxSection; currY >= this.minSection; --currY) {
++            if (emptinessMap == null) {
++                // cannot delay nibble init for lit chunks, as we need to init to propagate into them.
++                final LevelChunkSection current = this.getChunkSection(chunkX, currY, chunkZ);
++                if (current == null || current.hasOnlyAir()) {
++                    continue;
++                }
++            } else {
++                if (emptinessMap[currY - this.minSection]) {
++                    continue;
++                }
++            }
++
++            // should always be full lit here
++            lowestY = currY;
++            break;
++        }
++
++        if (chunkY > lowestY) {
++            // we need to set this one to full
++            final SWMRNibbleArray nibble = this.getNibbleFromCache(chunkX, chunkY, chunkZ);
++            nibble.setNonNull();
++            nibble.setFull();
++            return;
++        }
++
++        if (extrude) {
++            // this nibble is going to depend solely on the skylight data above it
++            // find first non-null data above (there does exist one, as we just found it above)
++            for (int currY = chunkY + 1; currY <= this.maxLightSection; ++currY) {
++                final SWMRNibbleArray nibble = this.getNibbleFromCache(chunkX, currY, chunkZ);
++                if (nibble != null && !nibble.isNullNibbleUpdating()) {
++                    currNibble.setNonNull();
++                    currNibble.extrudeLower(nibble);
++                    break;
++                }
++            }
++        } else {
++            currNibble.setNonNull();
++        }
++    }
++
++    protected final void rewriteNibbleCacheForSkylight(final ChunkAccess chunk) {
++        for (int index = 0, max = this.nibbleCache.length; index < max; ++index) {
++            final SWMRNibbleArray nibble = this.nibbleCache[index];
++            if (nibble != null && nibble.isNullNibbleUpdating()) {
++                // stop propagation in these areas
++                this.nibbleCache[index] = null;
++                nibble.updateVisible();
++            }
++        }
++    }
++
++    // rets whether neighbours were init'd
++
++    protected final boolean checkNullSection(final int chunkX, final int chunkY, final int chunkZ,
++                                             final boolean extrudeInitialised) {
++        // null chunk sections may have nibble neighbours in the horizontal 1 radius that are
++        // non-null. Propagation to these neighbours is necessary.
++        // What makes this easy is we know none of these neighbours are non-empty (otherwise
++        // this nibble would be initialised). So, we don't have to initialise
++        // the neighbours in the full 1 radius, because there's no worry that any "paths"
++        // to the neighbours on this horizontal plane are blocked.
++        if (chunkY < this.minLightSection || chunkY > this.maxLightSection || this.nullPropagationCheckCache[chunkY - this.minLightSection]) {
++            return false;
++        }
++        this.nullPropagationCheckCache[chunkY - this.minLightSection] = true;
++
++        // check horizontal neighbours
++        boolean needInitNeighbours = false;
++        neighbour_search:
++        for (int dz = -1; dz <= 1; ++dz) {
++            for (int dx = -1; dx <= 1; ++dx) {
++                final SWMRNibbleArray nibble = this.getNibbleFromCache(dx + chunkX, chunkY, dz + chunkZ);
++                if (nibble != null && !nibble.isNullNibbleUpdating()) {
++                    needInitNeighbours = true;
++                    break neighbour_search;
++                }
++            }
++        }
++
++        if (needInitNeighbours) {
++            for (int dz = -1; dz <= 1; ++dz) {
++                for (int dx = -1; dx <= 1; ++dx) {
++                    this.initNibble(dx + chunkX, chunkY, dz + chunkZ, (dx | dz) == 0 ? extrudeInitialised : true, true);
++                }
++            }
++        }
++
++        return needInitNeighbours;
++    }
++
++    protected final int getLightLevelExtruded(final int worldX, final int worldY, final int worldZ) {
++        final int chunkX = worldX >> 4;
++        int chunkY = worldY >> 4;
++        final int chunkZ = worldZ >> 4;
++
++        SWMRNibbleArray nibble = this.getNibbleFromCache(chunkX, chunkY, chunkZ);
++        if (nibble != null) {
++            return nibble.getUpdating(worldX, worldY, worldZ);
++        }
++
++        for (;;) {
++            if (++chunkY > this.maxLightSection) {
++                return 15;
++            }
++
++            nibble = this.getNibbleFromCache(chunkX, chunkY, chunkZ);
++
++            if (nibble != null) {
++                return nibble.getUpdating(worldX, 0, worldZ);
++            }
++        }
++    }
++
++    @Override
++    protected boolean[] getEmptinessMap(final ChunkAccess chunk) {
++        return chunk.getSkyEmptinessMap();
++    }
++
++    @Override
++    protected void setEmptinessMap(final ChunkAccess chunk, final boolean[] to) {
++        chunk.setSkyEmptinessMap(to);
++    }
++
++    @Override
++    protected SWMRNibbleArray[] getNibblesOnChunk(final ChunkAccess chunk) {
++        return chunk.getSkyNibbles();
++    }
++
++    @Override
++    protected void setNibbles(final ChunkAccess chunk, final SWMRNibbleArray[] to) {
++        chunk.setSkyNibbles(to);
++    }
++
++    @Override
++    protected boolean canUseChunk(final ChunkAccess chunk) {
++        // can only use chunks for sky stuff if their sections have been init'd
++        return chunk.getStatus().isOrAfter(ChunkStatus.LIGHT) && (this.isClientSide || chunk.isLightCorrect());
++    }
++
++    @Override
++    protected void checkChunkEdges(final LightChunkGetter lightAccess, final ChunkAccess chunk, final int fromSection,
++                                   final int toSection) {
++        Arrays.fill(this.nullPropagationCheckCache, false);
++        this.rewriteNibbleCacheForSkylight(chunk);
++        final int chunkX = chunk.getPos().x;
++        final int chunkZ = chunk.getPos().z;
++        for (int y = toSection; y >= fromSection; --y) {
++            this.checkNullSection(chunkX, y, chunkZ, true);
++        }
++
++        super.checkChunkEdges(lightAccess, chunk, fromSection, toSection);
++    }
++
++    @Override
++    protected void checkChunkEdges(final LightChunkGetter lightAccess, final ChunkAccess chunk, final ShortCollection sections) {
++        Arrays.fill(this.nullPropagationCheckCache, false);
++        this.rewriteNibbleCacheForSkylight(chunk);
++        final int chunkX = chunk.getPos().x;
++        final int chunkZ = chunk.getPos().z;
++        for (final ShortIterator iterator = sections.iterator(); iterator.hasNext();) {
++            final int y = (int)iterator.nextShort();
++            this.checkNullSection(chunkX, y, chunkZ, true);
++        }
++
++        super.checkChunkEdges(lightAccess, chunk, sections);
++    }
++
++    @Override
++    protected void checkBlock(final LightChunkGetter lightAccess, final int worldX, final int worldY, final int worldZ) {
++        // blocks can change opacity
++        // blocks can change direction of propagation
++
++        // same logic applies from BlockStarLightEngine#checkBlock
++
++        final int encodeOffset = this.coordinateOffset;
++
++        final int currentLevel = this.getLightLevel(worldX, worldY, worldZ);
++
++        if (currentLevel == 15) {
++            // must re-propagate clobbered source
++            this.appendToIncreaseQueue(
++                    ((worldX + (worldZ << 6) + (worldY << (6 + 6)) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                            | (currentLevel & 0xFL) << (6 + 6 + 16)
++                            | (((long)ALL_DIRECTIONS_BITSET) << (6 + 6 + 16 + 4))
++                            | FLAG_HAS_SIDED_TRANSPARENT_BLOCKS // don't know if the block is conditionally transparent
++            );
++        } else {
++            this.setLightLevel(worldX, worldY, worldZ, 0);
++        }
++
++        this.appendToDecreaseQueue(
++                ((worldX + (worldZ << 6) + (worldY << (6 + 6)) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                        | (currentLevel & 0xFL) << (6 + 6 + 16)
++                        | (((long)ALL_DIRECTIONS_BITSET) << (6 + 6 + 16 + 4))
++        );
++    }
++
++    protected final BlockPos.MutableBlockPos recalcCenterPos = new BlockPos.MutableBlockPos();
++    protected final BlockPos.MutableBlockPos recalcNeighbourPos = new BlockPos.MutableBlockPos();
++
++    @Override
++    protected int calculateLightValue(final LightChunkGetter lightAccess, final int worldX, final int worldY, final int worldZ,
++                                      final int expect) {
++        if (expect == 15) {
++            return expect;
++        }
++
++        final int sectionOffset = this.chunkSectionIndexOffset;
++        final BlockState centerState = this.getBlockState(worldX, worldY, worldZ);
++        int opacity = centerState.getOpacityIfCached();
++
++        final BlockState conditionallyOpaqueState;
++        if (opacity < 0) {
++            this.recalcCenterPos.set(worldX, worldY, worldZ);
++            opacity = Math.max(1, centerState.getLightBlock(lightAccess.getLevel(), this.recalcCenterPos));
++            if (centerState.isConditionallyFullOpaque()) {
++                conditionallyOpaqueState = centerState;
++            } else {
++                conditionallyOpaqueState = null;
++            }
++        } else {
++            conditionallyOpaqueState = null;
++            opacity = Math.max(1, opacity);
++        }
++
++        int level = 0;
++
++        for (final AxisDirection direction : AXIS_DIRECTIONS) {
++            final int offX = worldX + direction.x;
++            final int offY = worldY + direction.y;
++            final int offZ = worldZ + direction.z;
++
++            final int sectionIndex = (offX >> 4) + 5 * (offZ >> 4) + (5 * 5) * (offY >> 4) + sectionOffset;
++
++            final int neighbourLevel = this.getLightLevel(sectionIndex, (offX & 15) | ((offZ & 15) << 4) | ((offY & 15) << 8));
++
++            if ((neighbourLevel - 1) <= level) {
++                // don't need to test transparency, we know it wont affect the result.
++                continue;
++            }
++
++            final BlockState neighbourState = this.getBlockState(offX, offY, offZ);
++
++            if (neighbourState.isConditionallyFullOpaque()) {
++                // here the block can be conditionally opaque (i.e light cannot propagate from it), so we need to test that
++                // we don't read the blockstate because most of the time this is false, so using the faster
++                // known transparency lookup results in a net win
++                this.recalcNeighbourPos.set(offX, offY, offZ);
++                final VoxelShape neighbourFace = neighbourState.getFaceOcclusionShape(lightAccess.getLevel(), this.recalcNeighbourPos, direction.opposite.nms);
++                final VoxelShape thisFace = conditionallyOpaqueState == null ? Shapes.empty() : conditionallyOpaqueState.getFaceOcclusionShape(lightAccess.getLevel(), this.recalcCenterPos, direction.nms);
++                if (Shapes.faceShapeOccludes(thisFace, neighbourFace)) {
++                    // not allowed to propagate
++                    continue;
++                }
++            }
++
++            final int calculated = neighbourLevel - opacity;
++            level = Math.max(calculated, level);
++            if (level > expect) {
++                return level;
++            }
++        }
++
++        return level;
++    }
++
++    @Override
++    protected void propagateBlockChanges(final LightChunkGetter lightAccess, final ChunkAccess atChunk, final Set<BlockPos> positions) {
++        this.rewriteNibbleCacheForSkylight(atChunk);
++        Arrays.fill(this.nullPropagationCheckCache, false);
++
++        final BlockGetter world = lightAccess.getLevel();
++        final int chunkX = atChunk.getPos().x;
++        final int chunkZ = atChunk.getPos().z;
++        final int heightMapOffset = chunkX * -16 + (chunkZ * (-16 * 16));
++
++        // setup heightmap for changes
++        for (final BlockPos pos : positions) {
++            final int index = pos.getX() + (pos.getZ() << 4) + heightMapOffset;
++            final int curr = this.heightMapBlockChange[index];
++            if (pos.getY() > curr) {
++                this.heightMapBlockChange[index] = pos.getY();
++            }
++        }
++
++        // note: light sets are delayed while processing skylight source changes due to how
++        // nibbles are initialised, as we want to avoid clobbering nibble values so what when
++        // below nibbles are initialised they aren't reading from partially modified nibbles
++
++        // now we can recalculate the sources for the changed columns
++        for (int index = 0; index < (16 * 16); ++index) {
++            final int maxY = this.heightMapBlockChange[index];
++            if (maxY == Integer.MIN_VALUE) {
++                // not changed
++                continue;
++            }
++            this.heightMapBlockChange[index] = Integer.MIN_VALUE; // restore default for next caller
++
++            final int columnX = (index & 15) | (chunkX << 4);
++            final int columnZ = (index >>> 4) | (chunkZ << 4);
++
++            // try and propagate from the above y
++            // delay light set until after processing all sources to setup
++            final int maxPropagationY = this.tryPropagateSkylight(world, columnX, maxY, columnZ, true, true);
++
++            // maxPropagationY is now the highest block that could not be propagated to
++
++            // remove all sources below that are 15
++            final long propagateDirection = AxisDirection.POSITIVE_Y.everythingButThisDirection;
++            final int encodeOffset = this.coordinateOffset;
++
++            if (this.getLightLevelExtruded(columnX, maxPropagationY, columnZ) == 15) {
++                // ensure section is checked
++                this.checkNullSection(columnX >> 4, maxPropagationY >> 4, columnZ >> 4, true);
++
++                for (int currY = maxPropagationY; currY >= (this.minLightSection << 4); --currY) {
++                    if ((currY & 15) == 15) {
++                        // ensure section is checked
++                        this.checkNullSection(columnX >> 4, (currY >> 4), columnZ >> 4, true);
++                    }
++
++                    // ensure section below is always checked
++                    final SWMRNibbleArray nibble = this.getNibbleFromCache(columnX >> 4, currY >> 4, columnZ >> 4);
++                    if (nibble == null) {
++                        // advance currY to the the top of the section below
++                        currY = (currY) & (~15);
++                        // note: this value ^ is actually 1 above the top, but the loop decrements by 1 so we actually
++                        // end up there
++                        continue;
++                    }
++
++                    if (nibble.getUpdating(columnX, currY, columnZ) != 15) {
++                        break;
++                    }
++
++                    // delay light set until after processing all sources to setup
++                    this.appendToDecreaseQueue(
++                            ((columnX + (columnZ << 6) + (currY << (6 + 6)) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                    | (15L << (6 + 6 + 16))
++                                    | (propagateDirection << (6 + 6 + 16 + 4))
++                                    // do not set transparent blocks for the same reason we don't in the checkBlock method
++                    );
++                }
++            }
++        }
++
++        // delayed light sets are processed here, and must be processed before checkBlock as checkBlock reads
++        // immediate light value
++        this.processDelayedIncreases();
++        this.processDelayedDecreases();
++
++        for (final BlockPos pos : positions) {
++            this.checkBlock(lightAccess, pos.getX(), pos.getY(), pos.getZ());
++        }
++
++        this.performLightDecrease(lightAccess);
++    }
++
++    protected final int[] heightMapGen = new int[32 * 32];
++
++    @Override
++    protected void lightChunk(final LightChunkGetter lightAccess, final ChunkAccess chunk, final boolean needsEdgeChecks) {
++        this.rewriteNibbleCacheForSkylight(chunk);
++        Arrays.fill(this.nullPropagationCheckCache, false);
++
++        final BlockGetter world = lightAccess.getLevel();
++        final ChunkPos chunkPos = chunk.getPos();
++        final int chunkX = chunkPos.x;
++        final int chunkZ = chunkPos.z;
++
++        final LevelChunkSection[] sections = chunk.getSections();
++
++        int highestNonEmptySection = this.maxSection;
++        while (highestNonEmptySection == (this.minSection - 1) ||
++                sections[highestNonEmptySection - this.minSection] == null || sections[highestNonEmptySection - this.minSection].hasOnlyAir()) {
++            this.checkNullSection(chunkX, highestNonEmptySection, chunkZ, false);
++            // try propagate FULL to neighbours
++
++            // check neighbours to see if we need to propagate into them
++            for (final AxisDirection direction : ONLY_HORIZONTAL_DIRECTIONS) {
++                final int neighbourX = chunkX + direction.x;
++                final int neighbourZ = chunkZ + direction.z;
++                final SWMRNibbleArray neighbourNibble = this.getNibbleFromCache(neighbourX, highestNonEmptySection, neighbourZ);
++                if (neighbourNibble == null) {
++                    // unloaded neighbour
++                    // most of the time we fall here
++                    continue;
++                }
++
++                // it looks like we need to propagate into the neighbour
++
++                final int incX;
++                final int incZ;
++                final int startX;
++                final int startZ;
++
++                if (direction.x != 0) {
++                    // x direction
++                    incX = 0;
++                    incZ = 1;
++
++                    if (direction.x < 0) {
++                        // negative
++                        startX = chunkX << 4;
++                    } else {
++                        startX = chunkX << 4 | 15;
++                    }
++                    startZ = chunkZ << 4;
++                } else {
++                    // z direction
++                    incX = 1;
++                    incZ = 0;
++
++                    if (direction.z < 0) {
++                        // negative
++                        startZ = chunkZ << 4;
++                    } else {
++                        startZ = chunkZ << 4 | 15;
++                    }
++                    startX = chunkX << 4;
++                }
++
++                final int encodeOffset = this.coordinateOffset;
++                final long propagateDirection = 1L << direction.ordinal(); // we only want to check in this direction
++
++                for (int currY = highestNonEmptySection << 4, maxY = currY | 15; currY <= maxY; ++currY) {
++                    for (int i = 0, currX = startX, currZ = startZ; i < 16; ++i, currX += incX, currZ += incZ) {
++                        this.appendToIncreaseQueue(
++                                ((currX + (currZ << 6) + (currY << (6 + 6)) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                        | (15L << (6 + 6 + 16)) // we know we're at full lit here
++                                        | (propagateDirection << (6 + 6 + 16 + 4))
++                                        // no transparent flag, we know for a fact there are no blocks here that could be directionally transparent (as the section is EMPTY)
++                        );
++                    }
++                }
++            }
++
++            if (highestNonEmptySection-- == (this.minSection - 1)) {
++                break;
++            }
++        }
++
++        if (highestNonEmptySection >= this.minSection) {
++            // fill out our other sources
++            final int minX = chunkPos.x << 4;
++            final int maxX = chunkPos.x << 4 | 15;
++            final int minZ = chunkPos.z << 4;
++            final int maxZ = chunkPos.z << 4 | 15;
++            final int startY = highestNonEmptySection << 4 | 15;
++            for (int currZ = minZ; currZ <= maxZ; ++currZ) {
++                for (int currX = minX; currX <= maxX; ++currX) {
++                    this.tryPropagateSkylight(world, currX, startY + 1, currZ, false, false);
++                }
++            }
++        } // else: apparently the chunk is empty
++
++        if (needsEdgeChecks) {
++            // not required to propagate here, but this will reduce the hit of the edge checks
++            this.performLightIncrease(lightAccess);
++
++            for (int y = highestNonEmptySection; y >= this.minLightSection; --y) {
++                this.checkNullSection(chunkX, y, chunkZ, false);
++            }
++            // no need to rewrite the nibble cache again
++            super.checkChunkEdges(lightAccess, chunk, this.minLightSection, highestNonEmptySection);
++        } else {
++            for (int y = highestNonEmptySection; y >= this.minLightSection; --y) {
++                this.checkNullSection(chunkX, y, chunkZ, false);
++            }
++            this.propagateNeighbourLevels(lightAccess, chunk, this.minLightSection, highestNonEmptySection);
++
++            this.performLightIncrease(lightAccess);
++        }
++    }
++
++    protected final void processDelayedIncreases() {
++        // copied from performLightIncrease
++        final long[] queue = this.increaseQueue;
++        final int decodeOffsetX = -this.encodeOffsetX;
++        final int decodeOffsetY = -this.encodeOffsetY;
++        final int decodeOffsetZ = -this.encodeOffsetZ;
++
++        for (int i = 0, len = this.increaseQueueInitialLength; i < len; ++i) {
++            final long queueValue = queue[i];
++
++            final int posX = ((int)queueValue & 63) + decodeOffsetX;
++            final int posZ = (((int)queueValue >>> 6) & 63) + decodeOffsetZ;
++            final int posY = (((int)queueValue >>> 12) & ((1 << 16) - 1)) + decodeOffsetY;
++            final int propagatedLightLevel = (int)((queueValue >>> (6 + 6 + 16)) & 0xF);
++
++            this.setLightLevel(posX, posY, posZ, propagatedLightLevel);
++        }
++    }
++
++    protected final void processDelayedDecreases() {
++        // copied from performLightDecrease
++        final long[] queue = this.decreaseQueue;
++        final int decodeOffsetX = -this.encodeOffsetX;
++        final int decodeOffsetY = -this.encodeOffsetY;
++        final int decodeOffsetZ = -this.encodeOffsetZ;
++
++        for (int i = 0, len = this.decreaseQueueInitialLength; i < len; ++i) {
++            final long queueValue = queue[i];
++
++            final int posX = ((int)queueValue & 63) + decodeOffsetX;
++            final int posZ = (((int)queueValue >>> 6) & 63) + decodeOffsetZ;
++            final int posY = (((int)queueValue >>> 12) & ((1 << 16) - 1)) + decodeOffsetY;
++
++            this.setLightLevel(posX, posY, posZ, 0);
++        }
++    }
++
++    // delaying the light set is useful for block changes since they need to worry about initialising nibblearrays
++    // while also queueing light at the same time (initialising nibblearrays might depend on nibbles above, so
++    // clobbering the light values will result in broken propagation)
++    protected final int tryPropagateSkylight(final BlockGetter world, final int worldX, int startY, final int worldZ,
++                                             final boolean extrudeInitialised, final boolean delayLightSet) {
++        final BlockPos.MutableBlockPos mutablePos = this.mutablePos3;
++        final int encodeOffset = this.coordinateOffset;
++        final long propagateDirection = AxisDirection.POSITIVE_Y.everythingButThisDirection; // just don't check upwards.
++
++        if (this.getLightLevelExtruded(worldX, startY + 1, worldZ) != 15) {
++            return startY;
++        }
++
++        // ensure this section is always checked
++        this.checkNullSection(worldX >> 4, startY >> 4, worldZ >> 4, extrudeInitialised);
++
++        BlockState above = this.getBlockState(worldX, startY + 1, worldZ);
++
++        for (;startY >= (this.minLightSection << 4); --startY) {
++            if ((startY & 15) == 15) {
++                // ensure this section is always checked
++                this.checkNullSection(worldX >> 4, startY >> 4, worldZ >> 4, extrudeInitialised);
++            }
++            final BlockState current = this.getBlockState(worldX, startY, worldZ);
++
++            final VoxelShape fromShape;
++            if (above.isConditionallyFullOpaque()) {
++                this.mutablePos2.set(worldX, startY + 1, worldZ);
++                fromShape = above.getFaceOcclusionShape(world, this.mutablePos2, AxisDirection.NEGATIVE_Y.nms);
++                if (Shapes.faceShapeOccludes(Shapes.empty(), fromShape)) {
++                    // above wont let us propagate
++                    break;
++                }
++            } else {
++                fromShape = Shapes.empty();
++            }
++
++            final int opacityIfCached = current.getOpacityIfCached();
++            // does light propagate from the top down?
++            if (opacityIfCached != -1) {
++                if (opacityIfCached != 0) {
++                    // we cannot propagate 15 through this
++                    break;
++                }
++                // most of the time it falls here.
++                // add to propagate
++                // light set delayed until we determine if this nibble section is null
++                this.appendToIncreaseQueue(
++                        ((worldX + (worldZ << 6) + (startY << (6 + 6)) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                | (15L << (6 + 6 + 16)) // we know we're at full lit here
++                                | (propagateDirection << (6 + 6 + 16 + 4))
++                );
++            } else {
++                mutablePos.set(worldX, startY, worldZ);
++                long flags = 0L;
++                if (current.isConditionallyFullOpaque()) {
++                    final VoxelShape cullingFace = current.getFaceOcclusionShape(world, mutablePos, AxisDirection.POSITIVE_Y.nms);
++
++                    if (Shapes.faceShapeOccludes(fromShape, cullingFace)) {
++                        // can't propagate here, we're done on this column.
++                        break;
++                    }
++                    flags |= FLAG_HAS_SIDED_TRANSPARENT_BLOCKS;
++                }
++
++                final int opacity = current.getLightBlock(world, mutablePos);
++                if (opacity > 0) {
++                    // let the queued value (if any) handle it from here.
++                    break;
++                }
++
++                // light set delayed until we determine if this nibble section is null
++                this.appendToIncreaseQueue(
++                        ((worldX + (worldZ << 6) + (startY << (6 + 6)) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                | (15L << (6 + 6 + 16)) // we know we're at full lit here
++                                | (propagateDirection << (6 + 6 + 16 + 4))
++                                | flags
++                );
++            }
++
++            above = current;
++
++            if (this.getNibbleFromCache(worldX >> 4, startY >> 4, worldZ >> 4) == null) {
++                // we skip empty sections here, as this is just an easy way of making sure the above block
++                // can propagate through air.
++
++                // nothing can propagate in null sections, remove the queue entry for it
++                --this.increaseQueueInitialLength;
++
++                // advance currY to the the top of the section below
++                startY = (startY) & (~15);
++                // note: this value ^ is actually 1 above the top, but the loop decrements by 1 so we actually
++                // end up there
++
++                // make sure this is marked as AIR
++                above = AIR_BLOCK_STATE;
++            } else if (!delayLightSet) {
++                this.setLightLevel(worldX, startY, worldZ, 15);
++            }
++        }
++
++        return startY;
++    }
++}
+diff --git a/src/main/java/ca/spottedleaf/starlight/common/light/StarLightEngine.java b/src/main/java/ca/spottedleaf/starlight/common/light/StarLightEngine.java
+new file mode 100644
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
+--- /dev/null
++++ b/src/main/java/ca/spottedleaf/starlight/common/light/StarLightEngine.java
+@@ -0,0 +0,0 @@
++package ca.spottedleaf.starlight.common.light;
++
++import ca.spottedleaf.starlight.common.util.CoordinateUtils;
++import ca.spottedleaf.starlight.common.util.IntegerUtil;
++import ca.spottedleaf.starlight.common.util.WorldUtil;
++import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
++import it.unimi.dsi.fastutil.shorts.ShortCollection;
++import it.unimi.dsi.fastutil.shorts.ShortIterator;
++import net.minecraft.core.BlockPos;
++import net.minecraft.core.Direction;
++import net.minecraft.core.SectionPos;
++import net.minecraft.world.level.BlockGetter;
++import net.minecraft.world.level.ChunkPos;
++import net.minecraft.world.level.Level;
++import net.minecraft.world.level.LevelHeightAccessor;
++import net.minecraft.world.level.LightLayer;
++import net.minecraft.world.level.block.Blocks;
++import net.minecraft.world.level.block.state.BlockState;
++import net.minecraft.world.level.chunk.ChunkAccess;
++import net.minecraft.world.level.chunk.LevelChunkSection;
++import net.minecraft.world.level.chunk.LightChunkGetter;
++import net.minecraft.world.phys.shapes.Shapes;
++import net.minecraft.world.phys.shapes.VoxelShape;
++import java.util.ArrayList;
++import java.util.Arrays;
++import java.util.List;
++import java.util.Set;
++import java.util.function.Consumer;
++import java.util.function.IntConsumer;
++
++public abstract class StarLightEngine {
++
++    protected static final BlockState AIR_BLOCK_STATE = Blocks.AIR.defaultBlockState();
++
++    protected static final AxisDirection[] DIRECTIONS = AxisDirection.values();
++    protected static final AxisDirection[] AXIS_DIRECTIONS = DIRECTIONS;
++    protected static final AxisDirection[] ONLY_HORIZONTAL_DIRECTIONS = new AxisDirection[] {
++            AxisDirection.POSITIVE_X, AxisDirection.NEGATIVE_X,
++            AxisDirection.POSITIVE_Z, AxisDirection.NEGATIVE_Z
++    };
++
++    protected static enum AxisDirection {
++
++        // Declaration order is important and relied upon. Do not change without modifying propagation code.
++        POSITIVE_X(1, 0, 0), NEGATIVE_X(-1, 0, 0),
++        POSITIVE_Z(0, 0, 1), NEGATIVE_Z(0, 0, -1),
++        POSITIVE_Y(0, 1, 0), NEGATIVE_Y(0, -1, 0);
++
++        static {
++            POSITIVE_X.opposite = NEGATIVE_X; NEGATIVE_X.opposite = POSITIVE_X;
++            POSITIVE_Z.opposite = NEGATIVE_Z; NEGATIVE_Z.opposite = POSITIVE_Z;
++            POSITIVE_Y.opposite = NEGATIVE_Y; NEGATIVE_Y.opposite = POSITIVE_Y;
++        }
++
++        protected AxisDirection opposite;
++
++        public final int x;
++        public final int y;
++        public final int z;
++        public final Direction nms;
++        public final long everythingButThisDirection;
++        public final long everythingButTheOppositeDirection;
++
++        AxisDirection(final int x, final int y, final int z) {
++            this.x = x;
++            this.y = y;
++            this.z = z;
++            this.nms = Direction.fromNormal(x, y, z);
++            this.everythingButThisDirection = (long)(ALL_DIRECTIONS_BITSET ^ (1 << this.ordinal()));
++            // positive is always even, negative is always odd. Flip the 1 bit to get the negative direction.
++            this.everythingButTheOppositeDirection = (long)(ALL_DIRECTIONS_BITSET ^ (1 << (this.ordinal() ^ 1)));
++        }
++
++        public AxisDirection getOpposite() {
++            return this.opposite;
++        }
++    }
++
++    // I'd like to thank https://www.seedofandromeda.com/blogs/29-fast-flood-fill-lighting-in-a-blocky-voxel-game-pt-1
++    // for explaining how light propagates via breadth-first search
++
++    // While the above is a good start to understanding the general idea of what the general principles are, it's not
++    // exactly how the vanilla light engine should behave for minecraft.
++
++    // similar to the above, except the chunk section indices vary from [-1, 1], or [0, 2]
++    // for the y chunk section it's from [minLightSection, maxLightSection] or [0, maxLightSection - minLightSection]
++    // index = x + (z * 5) + (y * 25)
++    // null index indicates the chunk section doesn't exist (empty or out of bounds)
++    protected final LevelChunkSection[] sectionCache;
++
++    // the exact same as above, except for storing fast access to SWMRNibbleArray
++    // for the y chunk section it's from [minLightSection, maxLightSection] or [0, maxLightSection - minLightSection]
++    // index = x + (z * 5) + (y * 25)
++    protected final SWMRNibbleArray[] nibbleCache;
++
++    // the exact same as above, except for storing fast access to nibbles to call change callbacks for
++    // for the y chunk section it's from [minLightSection, maxLightSection] or [0, maxLightSection - minLightSection]
++    // index = x + (z * 5) + (y * 25)
++    protected final boolean[] notifyUpdateCache;
++
++    // always initialsed during start of lighting.
++    // index = x + (z * 5)
++    protected final ChunkAccess[] chunkCache = new ChunkAccess[5 * 5];
++
++    // index = x + (z * 5)
++    protected final boolean[][] emptinessMapCache = new boolean[5 * 5][];
++
++    protected final BlockPos.MutableBlockPos mutablePos1 = new BlockPos.MutableBlockPos();
++    protected final BlockPos.MutableBlockPos mutablePos2 = new BlockPos.MutableBlockPos();
++    protected final BlockPos.MutableBlockPos mutablePos3 = new BlockPos.MutableBlockPos();
++
++    protected int encodeOffsetX;
++    protected int encodeOffsetY;
++    protected int encodeOffsetZ;
++
++    protected int coordinateOffset;
++
++    protected int chunkOffsetX;
++    protected int chunkOffsetY;
++    protected int chunkOffsetZ;
++
++    protected int chunkIndexOffset;
++    protected int chunkSectionIndexOffset;
++
++    protected final boolean skylightPropagator;
++    protected final int emittedLightMask;
++    protected final boolean isClientSide;
++
++    protected final Level world;
++    protected final int minLightSection;
++    protected final int maxLightSection;
++    protected final int minSection;
++    protected final int maxSection;
++
++    protected StarLightEngine(final boolean skylightPropagator, final Level world) {
++        this.skylightPropagator = skylightPropagator;
++        this.emittedLightMask = skylightPropagator ? 0 : 0xF;
++        this.isClientSide = world.isClientSide;
++        this.world = world;
++        this.minLightSection = WorldUtil.getMinLightSection(world);
++        this.maxLightSection = WorldUtil.getMaxLightSection(world);
++        this.minSection = WorldUtil.getMinSection(world);
++        this.maxSection = WorldUtil.getMaxSection(world);
++
++        this.sectionCache = new LevelChunkSection[5 * 5 * ((this.maxLightSection - this.minLightSection + 1) + 2)]; // add two extra sections for buffer
++        this.nibbleCache = new SWMRNibbleArray[5 * 5 * ((this.maxLightSection - this.minLightSection + 1) + 2)]; // add two extra sections for buffer
++        this.notifyUpdateCache = new boolean[5 * 5 * ((this.maxLightSection - this.minLightSection + 1) + 2)]; // add two extra sections for buffer
++    }
++
++    protected final void setupEncodeOffset(final int centerX, final int centerY, final int centerZ) {
++        // 31 = center + encodeOffset
++        this.encodeOffsetX = 31 - centerX;
++        this.encodeOffsetY = (-(this.minLightSection - 1) << 4); // we want 0 to be the smallest encoded value
++        this.encodeOffsetZ = 31 - centerZ;
++
++        // coordinateIndex = x | (z << 6) | (y << 12)
++        this.coordinateOffset = this.encodeOffsetX + (this.encodeOffsetZ << 6) + (this.encodeOffsetY << 12);
++
++        // 2 = (centerX >> 4) + chunkOffset
++        this.chunkOffsetX = 2 - (centerX >> 4);
++        this.chunkOffsetY = -(this.minLightSection - 1); // lowest should be 0
++        this.chunkOffsetZ = 2 - (centerZ >> 4);
++
++        // chunk index = x + (5 * z)
++        this.chunkIndexOffset = this.chunkOffsetX + (5 * this.chunkOffsetZ);
++
++        // chunk section index = x + (5 * z) + ((5*5) * y)
++        this.chunkSectionIndexOffset = this.chunkIndexOffset + ((5 * 5) * this.chunkOffsetY);
++    }
++
++    protected final void setupCaches(final LightChunkGetter chunkProvider, final int centerX, final int centerY, final int centerZ,
++                                     final boolean relaxed, final boolean tryToLoadChunksFor2Radius) {
++        final int centerChunkX = centerX >> 4;
++        final int centerChunkY = centerY >> 4;
++        final int centerChunkZ = centerZ >> 4;
++
++        this.setupEncodeOffset(centerChunkX * 16 + 7, centerChunkY * 16 + 7, centerChunkZ * 16 + 7);
++
++        final int radius = tryToLoadChunksFor2Radius ? 2 : 1;
++
++        for (int dz = -radius; dz <= radius; ++dz) {
++            for (int dx = -radius; dx <= radius; ++dx) {
++                final int cx = centerChunkX + dx;
++                final int cz = centerChunkZ + dz;
++                final boolean isTwoRadius = Math.max(IntegerUtil.branchlessAbs(dx), IntegerUtil.branchlessAbs(dz)) == 2;
++                final ChunkAccess chunk = (ChunkAccess)chunkProvider.getChunkForLighting(cx, cz);
++
++                if (chunk == null) {
++                    if (relaxed | isTwoRadius) {
++                        continue;
++                    }
++                    throw new IllegalArgumentException("Trying to propagate light update before 1 radius neighbours ready");
++                }
++
++                if (!this.canUseChunk(chunk)) {
++                    continue;
++                }
++
++                this.setChunkInCache(cx, cz, chunk);
++                this.setEmptinessMapCache(cx, cz, this.getEmptinessMap(chunk));
++                if (!isTwoRadius) {
++                    this.setBlocksForChunkInCache(cx, cz, chunk.getSections());
++                    this.setNibblesForChunkInCache(cx, cz, this.getNibblesOnChunk(chunk));
++                }
++            }
++        }
++    }
++
++    protected final ChunkAccess getChunkInCache(final int chunkX, final int chunkZ) {
++        return this.chunkCache[chunkX + 5*chunkZ + this.chunkIndexOffset];
++    }
++
++    protected final void setChunkInCache(final int chunkX, final int chunkZ, final ChunkAccess chunk) {
++        this.chunkCache[chunkX + 5*chunkZ + this.chunkIndexOffset] = chunk;
++    }
++
++    protected final LevelChunkSection getChunkSection(final int chunkX, final int chunkY, final int chunkZ) {
++        return this.sectionCache[chunkX + 5*chunkZ + (5 * 5) * chunkY + this.chunkSectionIndexOffset];
++    }
++
++    protected final void setChunkSectionInCache(final int chunkX, final int chunkY, final int chunkZ, final LevelChunkSection section) {
++        this.sectionCache[chunkX + 5*chunkZ + 5*5*chunkY + this.chunkSectionIndexOffset] = section;
++    }
++
++    protected final void setBlocksForChunkInCache(final int chunkX, final int chunkZ, final LevelChunkSection[] sections) {
++        for (int cy = this.minLightSection; cy <= this.maxLightSection; ++cy) {
++            this.setChunkSectionInCache(chunkX, cy, chunkZ,
++                    sections == null ? null : (cy >= this.minSection && cy <= this.maxSection ? sections[cy - this.minSection] : null));
++        }
++    }
++
++    protected final SWMRNibbleArray getNibbleFromCache(final int chunkX, final int chunkY, final int chunkZ) {
++        return this.nibbleCache[chunkX + 5*chunkZ + (5 * 5) * chunkY + this.chunkSectionIndexOffset];
++    }
++
++    protected final SWMRNibbleArray[] getNibblesForChunkFromCache(final int chunkX, final int chunkZ) {
++        final SWMRNibbleArray[] ret = new SWMRNibbleArray[this.maxLightSection - this.minLightSection + 1];
++
++        for (int cy = this.minLightSection; cy <= this.maxLightSection; ++cy) {
++            ret[cy - this.minLightSection] = this.nibbleCache[chunkX + 5*chunkZ + (cy * (5 * 5)) + this.chunkSectionIndexOffset];
++        }
++
++        return ret;
++    }
++
++    protected final void setNibbleInCache(final int chunkX, final int chunkY, final int chunkZ, final SWMRNibbleArray nibble) {
++        this.nibbleCache[chunkX + 5*chunkZ + (5 * 5) * chunkY + this.chunkSectionIndexOffset] = nibble;
++    }
++
++    protected final void setNibblesForChunkInCache(final int chunkX, final int chunkZ, final SWMRNibbleArray[] nibbles) {
++        for (int cy = this.minLightSection; cy <= this.maxLightSection; ++cy) {
++            this.setNibbleInCache(chunkX, cy, chunkZ, nibbles == null ? null : nibbles[cy - this.minLightSection]);
++        }
++    }
++
++    protected final void updateVisible(final LightChunkGetter lightAccess) {
++        for (int index = 0, max = this.nibbleCache.length; index < max; ++index) {
++            final SWMRNibbleArray nibble = this.nibbleCache[index];
++            if (!this.notifyUpdateCache[index] && (nibble == null || !nibble.isDirty())) {
++                continue;
++            }
++
++            final int chunkX = (index % 5) - this.chunkOffsetX;
++            final int chunkZ = ((index / 5) % 5) - this.chunkOffsetZ;
++            final int chunkY = ((index / (5*5)) % (16 + 2 + 2)) - this.chunkOffsetY;
++            if ((nibble != null && nibble.updateVisible()) || this.notifyUpdateCache[index]) {
++                lightAccess.onLightUpdate(this.skylightPropagator ? LightLayer.SKY : LightLayer.BLOCK, SectionPos.of(chunkX, chunkY, chunkZ));
++            }
++        }
++    }
++
++    protected final void destroyCaches() {
++        Arrays.fill(this.sectionCache, null);
++        Arrays.fill(this.nibbleCache, null);
++        Arrays.fill(this.chunkCache, null);
++        Arrays.fill(this.emptinessMapCache, null);
++        if (this.isClientSide) {
++            Arrays.fill(this.notifyUpdateCache, false);
++        }
++    }
++
++    protected final BlockState getBlockState(final int worldX, final int worldY, final int worldZ) {
++        final LevelChunkSection section = this.sectionCache[(worldX >> 4) + 5 * (worldZ >> 4) + (5 * 5) * (worldY >> 4) + this.chunkSectionIndexOffset];
++
++        if (section != null) {
++            return section.hasOnlyAir() ? AIR_BLOCK_STATE : section.getBlockState(worldX & 15, worldY & 15, worldZ & 15);
++        }
++
++        return AIR_BLOCK_STATE;
++    }
++
++    protected final BlockState getBlockState(final int sectionIndex, final int localIndex) {
++        final LevelChunkSection section = this.sectionCache[sectionIndex];
++
++        if (section != null) {
++            return section.hasOnlyAir() ? AIR_BLOCK_STATE : section.states.get(localIndex);
++        }
++
++        return AIR_BLOCK_STATE;
++    }
++
++    protected final int getLightLevel(final int worldX, final int worldY, final int worldZ) {
++        final SWMRNibbleArray nibble = this.nibbleCache[(worldX >> 4) + 5 * (worldZ >> 4) + (5 * 5) * (worldY >> 4) + this.chunkSectionIndexOffset];
++
++        return nibble == null ? 0 : nibble.getUpdating((worldX & 15) | ((worldZ & 15) << 4) | ((worldY & 15) << 8));
++    }
++
++    protected final int getLightLevel(final int sectionIndex, final int localIndex) {
++        final SWMRNibbleArray nibble = this.nibbleCache[sectionIndex];
++
++        return nibble == null ? 0 : nibble.getUpdating(localIndex);
++    }
++
++    protected final void setLightLevel(final int worldX, final int worldY, final int worldZ, final int level) {
++        final int sectionIndex = (worldX >> 4) + 5 * (worldZ >> 4) + (5 * 5) * (worldY >> 4) + this.chunkSectionIndexOffset;
++        final SWMRNibbleArray nibble = this.nibbleCache[sectionIndex];
++
++        if (nibble != null) {
++            nibble.set((worldX & 15) | ((worldZ & 15) << 4) | ((worldY & 15) << 8), level);
++            if (this.isClientSide) {
++                int cx1 = (worldX - 1) >> 4;
++                int cx2 = (worldX + 1) >> 4;
++                int cy1 = (worldY - 1) >> 4;
++                int cy2 = (worldY + 1) >> 4;
++                int cz1 = (worldZ - 1) >> 4;
++                int cz2 = (worldZ + 1) >> 4;
++                for (int x = cx1; x <= cx2; ++x) {
++                    for (int y = cy1; y <= cy2; ++y) {
++                        for (int z = cz1; z <= cz2; ++z) {
++                            this.notifyUpdateCache[x + 5 * z + (5 * 5) * y + this.chunkSectionIndexOffset] = true;
++                        }
++                    }
++                }
++            }
++        }
++    }
++
++    protected final void postLightUpdate(final int worldX, final int worldY, final int worldZ) {
++        if (this.isClientSide) {
++            int cx1 = (worldX - 1) >> 4;
++            int cx2 = (worldX + 1) >> 4;
++            int cy1 = (worldY - 1) >> 4;
++            int cy2 = (worldY + 1) >> 4;
++            int cz1 = (worldZ - 1) >> 4;
++            int cz2 = (worldZ + 1) >> 4;
++            for (int x = cx1; x <= cx2; ++x) {
++                for (int y = cy1; y <= cy2; ++y) {
++                    for (int z = cz1; z <= cz2; ++z) {
++                        this.notifyUpdateCache[x + (5 * z) + (5 * 5 * y) + this.chunkSectionIndexOffset] = true;
++                    }
++                }
++            }
++        }
++    }
++
++    protected final void setLightLevel(final int sectionIndex, final int localIndex, final int worldX, final int worldY, final int worldZ, final int level) {
++        final SWMRNibbleArray nibble = this.nibbleCache[sectionIndex];
++
++        if (nibble != null) {
++            nibble.set(localIndex, level);
++            if (this.isClientSide) {
++                int cx1 = (worldX - 1) >> 4;
++                int cx2 = (worldX + 1) >> 4;
++                int cy1 = (worldY - 1) >> 4;
++                int cy2 = (worldY + 1) >> 4;
++                int cz1 = (worldZ - 1) >> 4;
++                int cz2 = (worldZ + 1) >> 4;
++                for (int x = cx1; x <= cx2; ++x) {
++                    for (int y = cy1; y <= cy2; ++y) {
++                        for (int z = cz1; z <= cz2; ++z) {
++                            this.notifyUpdateCache[x + (5 * z) + (5 * 5 * y) + this.chunkSectionIndexOffset] = true;
++                        }
++                    }
++                }
++            }
++        }
++    }
++
++    protected final boolean[] getEmptinessMap(final int chunkX, final int chunkZ) {
++        return this.emptinessMapCache[chunkX + 5*chunkZ + this.chunkIndexOffset];
++    }
++
++    protected final void setEmptinessMapCache(final int chunkX, final int chunkZ, final boolean[] emptinessMap) {
++        this.emptinessMapCache[chunkX + 5*chunkZ + this.chunkIndexOffset] = emptinessMap;
++    }
++
++    public static SWMRNibbleArray[] getFilledEmptyLight(final LevelHeightAccessor world) {
++        return getFilledEmptyLight(WorldUtil.getTotalLightSections(world));
++    }
++
++    private static SWMRNibbleArray[] getFilledEmptyLight(final int totalLightSections) {
++        final SWMRNibbleArray[] ret = new SWMRNibbleArray[totalLightSections];
++
++        for (int i = 0, len = ret.length; i < len; ++i) {
++            ret[i] = new SWMRNibbleArray(null, true);
++        }
++
++        return ret;
++    }
++
++    protected abstract boolean[] getEmptinessMap(final ChunkAccess chunk);
++
++    protected abstract void setEmptinessMap(final ChunkAccess chunk, final boolean[] to);
++
++    protected abstract SWMRNibbleArray[] getNibblesOnChunk(final ChunkAccess chunk);
++
++    protected abstract void setNibbles(final ChunkAccess chunk, final SWMRNibbleArray[] to);
++
++    protected abstract boolean canUseChunk(final ChunkAccess chunk);
++
++    public final void blocksChangedInChunk(final LightChunkGetter lightAccess, final int chunkX, final int chunkZ,
++                                           final Set<BlockPos> positions, final Boolean[] changedSections) {
++        this.setupCaches(lightAccess, chunkX * 16 + 7, 128, chunkZ * 16 + 7, true, true);
++        try {
++            final ChunkAccess chunk = this.getChunkInCache(chunkX, chunkZ);
++            if (chunk == null) {
++                return;
++            }
++            if (changedSections != null) {
++                final boolean[] ret = this.handleEmptySectionChanges(lightAccess, chunk, changedSections, false);
++                if (ret != null) {
++                    this.setEmptinessMap(chunk, ret);
++                }
++            }
++            if (!positions.isEmpty()) {
++                this.propagateBlockChanges(lightAccess, chunk, positions);
++            }
++            this.updateVisible(lightAccess);
++        } finally {
++            this.destroyCaches();
++        }
++    }
++
++    // subclasses should not initialise caches, as this will always be done by the super call
++    // subclasses should not invoke updateVisible, as this will always be done by the super call
++    protected abstract void propagateBlockChanges(final LightChunkGetter lightAccess, final ChunkAccess atChunk, final Set<BlockPos> positions);
++
++    protected abstract void checkBlock(final LightChunkGetter lightAccess, final int worldX, final int worldY, final int worldZ);
++
++    // if ret > expect, then the real value is at least ret (early returns if ret > expect, rather than calculating actual)
++    // if ret == expect, then expect is the correct light value for pos
++    // if ret < expect, then ret is the real light value
++    protected abstract int calculateLightValue(final LightChunkGetter lightAccess, final int worldX, final int worldY, final int worldZ,
++                                               final int expect);
++
++    protected final int[] chunkCheckDelayedUpdatesCenter = new int[16 * 16];
++    protected final int[] chunkCheckDelayedUpdatesNeighbour = new int[16 * 16];
++
++    protected void checkChunkEdge(final LightChunkGetter lightAccess, final ChunkAccess chunk,
++                                  final int chunkX, final int chunkY, final int chunkZ) {
++        final SWMRNibbleArray currNibble = this.getNibbleFromCache(chunkX, chunkY, chunkZ);
++        if (currNibble == null) {
++            return;
++        }
++
++        for (final AxisDirection direction : ONLY_HORIZONTAL_DIRECTIONS) {
++            final int neighbourOffX = direction.x;
++            final int neighbourOffZ = direction.z;
++
++            final SWMRNibbleArray neighbourNibble = this.getNibbleFromCache(chunkX + neighbourOffX,
++                    chunkY, chunkZ + neighbourOffZ);
++
++            if (neighbourNibble == null) {
++                continue;
++            }
++
++            if (!currNibble.isInitialisedUpdating() && !neighbourNibble.isInitialisedUpdating()) {
++                // both are zero, nothing to check.
++                continue;
++            }
++
++            // this chunk
++            final int incX;
++            final int incZ;
++            final int startX;
++            final int startZ;
++
++            if (neighbourOffX != 0) {
++                // x direction
++                incX = 0;
++                incZ = 1;
++
++                if (direction.x < 0) {
++                    // negative
++                    startX = chunkX << 4;
++                } else {
++                    startX = chunkX << 4 | 15;
++                }
++                startZ = chunkZ << 4;
++            } else {
++                // z direction
++                incX = 1;
++                incZ = 0;
++
++                if (neighbourOffZ < 0) {
++                    // negative
++                    startZ = chunkZ << 4;
++                } else {
++                    startZ = chunkZ << 4 | 15;
++                }
++                startX = chunkX << 4;
++            }
++
++            int centerDelayedChecks = 0;
++            int neighbourDelayedChecks = 0;
++            for (int currY = chunkY << 4, maxY = currY | 15; currY <= maxY; ++currY) {
++                for (int i = 0, currX = startX, currZ = startZ; i < 16; ++i, currX += incX, currZ += incZ) {
++                    final int neighbourX = currX + neighbourOffX;
++                    final int neighbourZ = currZ + neighbourOffZ;
++
++                    final int currentIndex = (currX & 15) |
++                            ((currZ & 15)) << 4 |
++                            ((currY & 15) << 8);
++                    final int currentLevel = currNibble.getUpdating(currentIndex);
++
++                    final int neighbourIndex =
++                            (neighbourX & 15) |
++                            ((neighbourZ & 15)) << 4 |
++                            ((currY & 15) << 8);
++                    final int neighbourLevel = neighbourNibble.getUpdating(neighbourIndex);
++
++                    // the checks are delayed because the checkBlock method clobbers light values - which then
++                    // affect later calculate light value operations. While they don't affect it in a behaviourly significant
++                    // way, they do have a negative performance impact due to simply queueing more values
++
++                    if (this.calculateLightValue(lightAccess, currX, currY, currZ, currentLevel) != currentLevel) {
++                        this.chunkCheckDelayedUpdatesCenter[centerDelayedChecks++] = currentIndex;
++                    }
++
++                    if (this.calculateLightValue(lightAccess, neighbourX, currY, neighbourZ, neighbourLevel) != neighbourLevel) {
++                        this.chunkCheckDelayedUpdatesNeighbour[neighbourDelayedChecks++] = neighbourIndex;
++                    }
++                }
++            }
++
++            final int currentChunkOffX = chunkX << 4;
++            final int currentChunkOffZ = chunkZ << 4;
++            final int neighbourChunkOffX = (chunkX + direction.x) << 4;
++            final int neighbourChunkOffZ = (chunkZ + direction.z) << 4;
++            final int chunkOffY = chunkY << 4;
++            for (int i = 0, len = Math.max(centerDelayedChecks, neighbourDelayedChecks); i < len; ++i) {
++                // try to queue neighbouring data together
++                // index = x | (z << 4) | (y << 8)
++                if (i < centerDelayedChecks) {
++                    final int value = this.chunkCheckDelayedUpdatesCenter[i];
++                    this.checkBlock(lightAccess, currentChunkOffX | (value & 15),
++                            chunkOffY | (value >>> 8),
++                            currentChunkOffZ | ((value >>> 4) & 0xF));
++                }
++                if (i < neighbourDelayedChecks) {
++                    final int value = this.chunkCheckDelayedUpdatesNeighbour[i];
++                    this.checkBlock(lightAccess, neighbourChunkOffX | (value & 15),
++                            chunkOffY | (value >>> 8),
++                            neighbourChunkOffZ | ((value >>> 4) & 0xF));
++                }
++            }
++        }
++    }
++
++    protected void checkChunkEdges(final LightChunkGetter lightAccess, final ChunkAccess chunk, final ShortCollection sections) {
++        final ChunkPos chunkPos = chunk.getPos();
++        final int chunkX = chunkPos.x;
++        final int chunkZ = chunkPos.z;
++
++        for (final ShortIterator iterator = sections.iterator(); iterator.hasNext();) {
++            this.checkChunkEdge(lightAccess, chunk, chunkX, iterator.nextShort(), chunkZ);
++        }
++
++        this.performLightDecrease(lightAccess);
++    }
++
++    // subclasses should not initialise caches, as this will always be done by the super call
++    // subclasses should not invoke updateVisible, as this will always be done by the super call
++    // verifies that light levels on this chunks edges are consistent with this chunk's neighbours
++    // edges. if they are not, they are decreased (effectively performing the logic in checkBlock).
++    // This does not resolve skylight source problems.
++    protected void checkChunkEdges(final LightChunkGetter lightAccess, final ChunkAccess chunk, final int fromSection, final int toSection) {
++        final ChunkPos chunkPos = chunk.getPos();
++        final int chunkX = chunkPos.x;
++        final int chunkZ = chunkPos.z;
++
++        for (int currSectionY = toSection; currSectionY >= fromSection; --currSectionY) {
++            this.checkChunkEdge(lightAccess, chunk, chunkX, currSectionY, chunkZ);
++        }
++
++        this.performLightDecrease(lightAccess);
++    }
++
++    // pulls light from neighbours, and adds them into the increase queue. does not actually propagate.
++    protected final void propagateNeighbourLevels(final LightChunkGetter lightAccess, final ChunkAccess chunk, final int fromSection, final int toSection) {
++        final ChunkPos chunkPos = chunk.getPos();
++        final int chunkX = chunkPos.x;
++        final int chunkZ = chunkPos.z;
++
++        for (int currSectionY = toSection; currSectionY >= fromSection; --currSectionY) {
++            final SWMRNibbleArray currNibble = this.getNibbleFromCache(chunkX, currSectionY, chunkZ);
++            if (currNibble == null) {
++                continue;
++            }
++            for (final AxisDirection direction : ONLY_HORIZONTAL_DIRECTIONS) {
++                final int neighbourOffX = direction.x;
++                final int neighbourOffZ = direction.z;
++
++                final SWMRNibbleArray neighbourNibble = this.getNibbleFromCache(chunkX + neighbourOffX,
++                        currSectionY, chunkZ + neighbourOffZ);
++
++                if (neighbourNibble == null || !neighbourNibble.isInitialisedUpdating()) {
++                    // can't pull from 0
++                    continue;
++                }
++
++                // neighbour chunk
++                final int incX;
++                final int incZ;
++                final int startX;
++                final int startZ;
++
++                if (neighbourOffX != 0) {
++                    // x direction
++                    incX = 0;
++                    incZ = 1;
++
++                    if (direction.x < 0) {
++                        // negative
++                        startX = (chunkX << 4) - 1;
++                    } else {
++                        startX = (chunkX << 4) + 16;
++                    }
++                    startZ = chunkZ << 4;
++                } else {
++                    // z direction
++                    incX = 1;
++                    incZ = 0;
++
++                    if (neighbourOffZ < 0) {
++                        // negative
++                        startZ = (chunkZ << 4) - 1;
++                    } else {
++                        startZ = (chunkZ << 4) + 16;
++                    }
++                    startX = chunkX << 4;
++                }
++
++                final long propagateDirection = 1L << direction.getOpposite().ordinal(); // we only want to check in this direction towards this chunk
++                final int encodeOffset = this.coordinateOffset;
++
++                for (int currY = currSectionY << 4, maxY = currY | 15; currY <= maxY; ++currY) {
++                    for (int i = 0, currX = startX, currZ = startZ; i < 16; ++i, currX += incX, currZ += incZ) {
++                        final int level = neighbourNibble.getUpdating(
++                                (currX & 15)
++                                        | ((currZ & 15) << 4)
++                                        | ((currY & 15) << 8)
++                        );
++
++                        if (level <= 1) {
++                            // nothing to propagate
++                            continue;
++                        }
++
++                        this.appendToIncreaseQueue(
++                                ((currX + (currZ << 6) + (currY << (6 + 6)) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                        | ((level & 0xFL) << (6 + 6 + 16))
++                                        | (propagateDirection << (6 + 6 + 16 + 4))
++                                        | FLAG_HAS_SIDED_TRANSPARENT_BLOCKS // don't know if the current block is transparent, must check.
++                        );
++                    }
++                }
++            }
++        }
++    }
++
++    public static Boolean[] getEmptySectionsForChunk(final ChunkAccess chunk) {
++        final LevelChunkSection[] sections = chunk.getSections();
++        final Boolean[] ret = new Boolean[sections.length];
++
++        for (int i = 0; i < sections.length; ++i) {
++            if (sections[i] == null || sections[i].hasOnlyAir()) {
++                ret[i] = Boolean.TRUE;
++            } else {
++                ret[i] = Boolean.FALSE;
++            }
++        }
++
++        return ret;
++    }
++
++    public final void forceHandleEmptySectionChanges(final LightChunkGetter lightAccess, final ChunkAccess chunk, final Boolean[] emptinessChanges) {
++        final int chunkX = chunk.getPos().x;
++        final int chunkZ = chunk.getPos().z;
++        this.setupCaches(lightAccess, chunkX * 16 + 7, 128, chunkZ * 16 + 7, true, true);
++        try {
++            // force current chunk into cache
++            this.setChunkInCache(chunkX, chunkZ, chunk);
++            this.setBlocksForChunkInCache(chunkX, chunkZ, chunk.getSections());
++            this.setNibblesForChunkInCache(chunkX, chunkZ, this.getNibblesOnChunk(chunk));
++            this.setEmptinessMapCache(chunkX, chunkZ, this.getEmptinessMap(chunk));
++
++            final boolean[] ret = this.handleEmptySectionChanges(lightAccess, chunk, emptinessChanges, false);
++            if (ret != null) {
++                this.setEmptinessMap(chunk, ret);
++            }
++            this.updateVisible(lightAccess);
++        } finally {
++            this.destroyCaches();
++        }
++    }
++
++    public final void handleEmptySectionChanges(final LightChunkGetter lightAccess, final int chunkX, final int chunkZ,
++                                                final Boolean[] emptinessChanges) {
++        this.setupCaches(lightAccess, chunkX * 16 + 7, 128, chunkZ * 16 + 7, true, true);
++        try {
++            final ChunkAccess chunk = this.getChunkInCache(chunkX, chunkZ);
++            if (chunk == null) {
++                return;
++            }
++            final boolean[] ret = this.handleEmptySectionChanges(lightAccess, chunk, emptinessChanges, false);
++            if (ret != null) {
++                this.setEmptinessMap(chunk, ret);
++            }
++            this.updateVisible(lightAccess);
++        } finally {
++            this.destroyCaches();
++        }
++    }
++
++    protected abstract void initNibble(final int chunkX, final int chunkY, final int chunkZ, final boolean extrude, final boolean initRemovedNibbles);
++
++    protected abstract void setNibbleNull(final int chunkX, final int chunkY, final int chunkZ);
++
++    // subclasses should not initialise caches, as this will always be done by the super call
++    // subclasses should not invoke updateVisible, as this will always be done by the super call
++    // subclasses are guaranteed that this is always called before a changed block set
++    // newChunk specifies whether the changes describe a "first load" of a chunk or changes to existing, already loaded chunks
++    // rets non-null when the emptiness map changed and needs to be updated
++    protected final boolean[] handleEmptySectionChanges(final LightChunkGetter lightAccess, final ChunkAccess chunk,
++                                                        final Boolean[] emptinessChanges, final boolean unlit) {
++        final Level world = (Level)lightAccess.getLevel();
++        final int chunkX = chunk.getPos().x;
++        final int chunkZ = chunk.getPos().z;
++
++        boolean[] chunkEmptinessMap = this.getEmptinessMap(chunkX, chunkZ);
++        boolean[] ret = null;
++        final boolean needsInit = unlit || chunkEmptinessMap == null;
++        if (needsInit) {
++            this.setEmptinessMapCache(chunkX, chunkZ, ret = chunkEmptinessMap = new boolean[WorldUtil.getTotalSections(world)]);
++        }
++
++        // update emptiness map
++        for (int sectionIndex = (emptinessChanges.length - 1); sectionIndex >= 0; --sectionIndex) {
++            Boolean valueBoxed = emptinessChanges[sectionIndex];
++            if (valueBoxed == null) {
++                if (!needsInit) {
++                    continue;
++                }
++                final LevelChunkSection section = this.getChunkSection(chunkX, sectionIndex + this.minSection, chunkZ);
++                emptinessChanges[sectionIndex] = valueBoxed = section == null || section.hasOnlyAir() ? Boolean.TRUE : Boolean.FALSE;
++            }
++            chunkEmptinessMap[sectionIndex] = valueBoxed.booleanValue();
++        }
++
++        // now init neighbour nibbles
++        for (int sectionIndex = (emptinessChanges.length - 1); sectionIndex >= 0; --sectionIndex) {
++            final Boolean valueBoxed = emptinessChanges[sectionIndex];
++            final int sectionY = sectionIndex + this.minSection;
++            if (valueBoxed == null) {
++                continue;
++            }
++
++            final boolean empty = valueBoxed.booleanValue();
++
++            if (empty) {
++                continue;
++            }
++
++            for (int dz = -1; dz <= 1; ++dz) {
++                for (int dx = -1; dx <= 1; ++dx) {
++                    // if we're not empty, we also need to initialise nibbles
++                    // note: if we're unlit, we absolutely do not want to extrude, as light data isn't set up
++                    final boolean extrude = (dx | dz) != 0 || !unlit;
++                    for (int dy = 1; dy >= -1; --dy) {
++                        this.initNibble(dx + chunkX, dy + sectionY, dz + chunkZ, extrude, false);
++                    }
++                }
++            }
++        }
++
++        // check for de-init and lazy-init
++        // lazy init is when chunks are being lit, so at the time they weren't loaded when their neighbours were running
++        // init checks.
++        for (int dz = -1; dz <= 1; ++dz) {
++            for (int dx = -1; dx <= 1; ++dx) {
++                // does this neighbour have 1 radius loaded?
++                boolean neighboursLoaded = true;
++                neighbour_loaded_search:
++                for (int dz2 = -1; dz2 <= 1; ++dz2) {
++                    for (int dx2 = -1; dx2 <= 1; ++dx2) {
++                        if (this.getEmptinessMap(dx + dx2 + chunkX, dz + dz2 + chunkZ) == null) {
++                            neighboursLoaded = false;
++                            break neighbour_loaded_search;
++                        }
++                    }
++                }
++
++                for (int sectionY = this.maxLightSection; sectionY >= this.minLightSection; --sectionY) {
++                    // check neighbours to see if we need to de-init this one
++                    boolean allEmpty = true;
++                    neighbour_search:
++                    for (int dy2 = -1; dy2 <= 1; ++dy2) {
++                        for (int dz2 = -1; dz2 <= 1; ++dz2) {
++                            for (int dx2 = -1; dx2 <= 1; ++dx2) {
++                                final int y = sectionY + dy2;
++                                if (y < this.minSection || y > this.maxSection) {
++                                    // empty
++                                    continue;
++                                }
++                                final boolean[] emptinessMap = this.getEmptinessMap(dx + dx2 + chunkX, dz + dz2 + chunkZ);
++                                if (emptinessMap != null) {
++                                    if (!emptinessMap[y - this.minSection]) {
++                                        allEmpty = false;
++                                        break neighbour_search;
++                                    }
++                                } else {
++                                    final LevelChunkSection section = this.getChunkSection(dx + dx2 + chunkX, y, dz + dz2 + chunkZ);
++                                    if (section != null && !section.hasOnlyAir()) {
++                                        allEmpty = false;
++                                        break neighbour_search;
++                                    }
++                                }
++                            }
++                        }
++                    }
++
++                    if (allEmpty & neighboursLoaded) {
++                        // can only de-init when neighbours are loaded
++                        // de-init is fine to delay, as de-init is just an optimisation - it's not required for lighting
++                        // to be correct
++
++                        // all were empty, so de-init
++                        this.setNibbleNull(dx + chunkX, sectionY, dz + chunkZ);
++                    } else if (!allEmpty) {
++                        // must init
++                        final boolean extrude = (dx | dz) != 0 || !unlit;
++                        this.initNibble(dx + chunkX, sectionY, dz + chunkZ, extrude, false);
++                    }
++                }
++            }
++        }
++
++        return ret;
++    }
++
++    public final void checkChunkEdges(final LightChunkGetter lightAccess, final int chunkX, final int chunkZ) {
++        this.setupCaches(lightAccess, chunkX * 16 + 7, 128, chunkZ * 16 + 7, true, false);
++        try {
++            final ChunkAccess chunk = this.getChunkInCache(chunkX, chunkZ);
++            if (chunk == null) {
++                return;
++            }
++            this.checkChunkEdges(lightAccess, chunk, this.minLightSection, this.maxLightSection);
++            this.updateVisible(lightAccess);
++        } finally {
++            this.destroyCaches();
++        }
++    }
++
++    public final void checkChunkEdges(final LightChunkGetter lightAccess, final int chunkX, final int chunkZ, final ShortCollection sections) {
++        this.setupCaches(lightAccess, chunkX * 16 + 7, 128, chunkZ * 16 + 7, true, false);
++        try {
++            final ChunkAccess chunk = this.getChunkInCache(chunkX, chunkZ);
++            if (chunk == null) {
++                return;
++            }
++            this.checkChunkEdges(lightAccess, chunk, sections);
++            this.updateVisible(lightAccess);
++        } finally {
++            this.destroyCaches();
++        }
++    }
++
++    // subclasses should not initialise caches, as this will always be done by the super call
++    // subclasses should not invoke updateVisible, as this will always be done by the super call
++    // needsEdgeChecks applies when possibly loading vanilla data, which means we need to validate the current
++    // chunks light values with respect to neighbours
++    // subclasses should note that the emptiness changes are propagated BEFORE this is called, so this function
++    // does not need to detect empty chunks itself (and it should do no handling for them either!)
++    protected abstract void lightChunk(final LightChunkGetter lightAccess, final ChunkAccess chunk, final boolean needsEdgeChecks);
++
++    public final void light(final LightChunkGetter lightAccess, final ChunkAccess chunk, final Boolean[] emptySections) {
++        final int chunkX = chunk.getPos().x;
++        final int chunkZ = chunk.getPos().z;
++        this.setupCaches(lightAccess, chunkX * 16 + 7, 128, chunkZ * 16 + 7, true, true);
++
++        try {
++            final SWMRNibbleArray[] nibbles = getFilledEmptyLight(this.maxLightSection - this.minLightSection + 1);
++            // force current chunk into cache
++            this.setChunkInCache(chunkX, chunkZ, chunk);
++            this.setBlocksForChunkInCache(chunkX, chunkZ, chunk.getSections());
++            this.setNibblesForChunkInCache(chunkX, chunkZ, nibbles);
++            this.setEmptinessMapCache(chunkX, chunkZ, this.getEmptinessMap(chunk));
++
++            final boolean[] ret = this.handleEmptySectionChanges(lightAccess, chunk, emptySections, true);
++            if (ret != null) {
++                this.setEmptinessMap(chunk, ret);
++            }
++            this.lightChunk(lightAccess, chunk, true);
++            this.setNibbles(chunk, nibbles);
++            this.updateVisible(lightAccess);
++        } finally {
++            this.destroyCaches();
++        }
++    }
++
++    public final void relightChunks(final LightChunkGetter lightAccess, final Set<ChunkPos> chunks,
++                                    final Consumer<ChunkPos> chunkLightCallback, final IntConsumer onComplete) {
++        // it's recommended for maximum performance that the set is ordered according to a BFS from the center of
++        // the region of chunks to relight
++        // it's required that tickets are added for each chunk to keep them loaded
++        final Long2ObjectOpenHashMap<SWMRNibbleArray[]> nibblesByChunk = new Long2ObjectOpenHashMap<>();
++        final Long2ObjectOpenHashMap<boolean[]> emptinessMapByChunk = new Long2ObjectOpenHashMap<>();
++
++        final int[] neighbourLightOrder = new int[] {
++                // d = 0
++                0, 0,
++                // d = 1
++                -1, 0,
++                0, -1,
++                1, 0,
++                0, 1,
++                // d = 2
++                -1, 1,
++                1, 1,
++                -1, -1,
++                1, -1,
++        };
++
++        int lightCalls = 0;
++
++        for (final ChunkPos chunkPos : chunks) {
++            final int chunkX = chunkPos.x;
++            final int chunkZ = chunkPos.z;
++            final ChunkAccess chunk = (ChunkAccess)lightAccess.getChunkForLighting(chunkX, chunkZ);
++            if (chunk == null || !this.canUseChunk(chunk)) {
++                throw new IllegalStateException();
++            }
++
++            for (int i = 0, len = neighbourLightOrder.length; i < len; i += 2) {
++                final int dx = neighbourLightOrder[i];
++                final int dz = neighbourLightOrder[i + 1];
++                final int neighbourX = dx + chunkX;
++                final int neighbourZ = dz + chunkZ;
++
++                final ChunkAccess neighbour = (ChunkAccess)lightAccess.getChunkForLighting(neighbourX, neighbourZ);
++                if (neighbour == null || !this.canUseChunk(neighbour)) {
++                    continue;
++                }
++
++                if (nibblesByChunk.get(CoordinateUtils.getChunkKey(neighbourX, neighbourZ)) != null) {
++                    // lit already called for neighbour, no need to light it now
++                    continue;
++                }
++
++                // light neighbour chunk
++                this.setupEncodeOffset(neighbourX * 16 + 7, 128, neighbourZ * 16 + 7);
++                try {
++                    // insert all neighbouring chunks for this neighbour that we have data for
++                    for (int dz2 = -1; dz2 <= 1; ++dz2) {
++                        for (int dx2 = -1; dx2 <= 1; ++dx2) {
++                            final int neighbourX2 = neighbourX + dx2;
++                            final int neighbourZ2 = neighbourZ + dz2;
++                            final long key = CoordinateUtils.getChunkKey(neighbourX2, neighbourZ2);
++                            final ChunkAccess neighbour2 = (ChunkAccess)lightAccess.getChunkForLighting(neighbourX2, neighbourZ2);
++                            if (neighbour2 == null || !this.canUseChunk(neighbour2)) {
++                                continue;
++                            }
++
++                            final SWMRNibbleArray[] nibbles = nibblesByChunk.get(key);
++                            if (nibbles == null) {
++                                // we haven't lit this chunk
++                                continue;
++                            }
++
++                            this.setChunkInCache(neighbourX2, neighbourZ2, neighbour2);
++                            this.setBlocksForChunkInCache(neighbourX2, neighbourZ2, neighbour2.getSections());
++                            this.setNibblesForChunkInCache(neighbourX2, neighbourZ2, nibbles);
++                            this.setEmptinessMapCache(neighbourX2, neighbourZ2, emptinessMapByChunk.get(key));
++                        }
++                    }
++
++                    final long key = CoordinateUtils.getChunkKey(neighbourX, neighbourZ);
++
++                    // now insert the neighbour chunk and light it
++                    final SWMRNibbleArray[] nibbles = getFilledEmptyLight(this.world);
++                    nibblesByChunk.put(key, nibbles);
++
++                    this.setChunkInCache(neighbourX, neighbourZ, neighbour);
++                    this.setBlocksForChunkInCache(neighbourX, neighbourZ, neighbour.getSections());
++                    this.setNibblesForChunkInCache(neighbourX, neighbourZ, nibbles);
++
++                    final boolean[] neighbourEmptiness = this.handleEmptySectionChanges(lightAccess, neighbour, getEmptySectionsForChunk(neighbour), true);
++                    emptinessMapByChunk.put(key, neighbourEmptiness);
++                    if (chunks.contains(new ChunkPos(neighbourX, neighbourZ))) {
++                        this.setEmptinessMap(neighbour, neighbourEmptiness);
++                    }
++
++                    this.lightChunk(lightAccess, neighbour, false);
++                } finally {
++                    this.destroyCaches();
++                }
++            }
++
++            // done lighting all neighbours, so the chunk is now fully lit
++
++            // make sure nibbles are fully updated before calling back
++            final SWMRNibbleArray[] nibbles = nibblesByChunk.get(CoordinateUtils.getChunkKey(chunkX, chunkZ));
++            for (final SWMRNibbleArray nibble : nibbles) {
++                nibble.updateVisible();
++            }
++
++            this.setNibbles(chunk, nibbles);
++
++            for (int y = this.minLightSection; y <= this.maxLightSection; ++y) {
++                lightAccess.onLightUpdate(this.skylightPropagator ? LightLayer.SKY : LightLayer.BLOCK, SectionPos.of(chunkX, y, chunkX));
++            }
++
++            // now do callback
++            if (chunkLightCallback != null) {
++                chunkLightCallback.accept(chunkPos);
++            }
++            ++lightCalls;
++        }
++
++        if (onComplete != null) {
++            onComplete.accept(lightCalls);
++        }
++    }
++
++    // contains:
++    // lower (6 + 6 + 16) = 28 bits: encoded coordinate position (x | (z << 6) | (y << (6 + 6))))
++    // next 4 bits: propagated light level (0, 15]
++    // next 6 bits: propagation direction bitset
++    // next 24 bits: unused
++    // last 3 bits: state flags
++    // state flags:
++    // whether the increase propagator needs to write the propagated level to the position, used to avoid cascading light
++    // updates for block sources
++    protected static final long FLAG_WRITE_LEVEL = Long.MIN_VALUE >>> 2;
++    // whether the propagation needs to check if its current level is equal to the expected level
++    // used only in increase propagation
++    protected static final long FLAG_RECHECK_LEVEL = Long.MIN_VALUE >>> 1;
++    // whether the propagation needs to consider if its block is conditionally transparent
++    protected static final long FLAG_HAS_SIDED_TRANSPARENT_BLOCKS = Long.MIN_VALUE;
++
++    protected long[] increaseQueue = new long[16 * 16 * 16];
++    protected int increaseQueueInitialLength;
++    protected long[] decreaseQueue = new long[16 * 16 * 16];
++    protected int decreaseQueueInitialLength;
++
++    protected final long[] resizeIncreaseQueue() {
++        return this.increaseQueue = Arrays.copyOf(this.increaseQueue, this.increaseQueue.length * 2);
++    }
++
++    protected final long[] resizeDecreaseQueue() {
++        return this.decreaseQueue = Arrays.copyOf(this.decreaseQueue, this.decreaseQueue.length * 2);
++    }
++
++    protected final void appendToIncreaseQueue(final long value) {
++        final int idx = this.increaseQueueInitialLength++;
++        long[] queue = this.increaseQueue;
++        if (idx >= queue.length) {
++            queue = this.resizeIncreaseQueue();
++            queue[idx] = value;
++        } else {
++            queue[idx] = value;
++        }
++    }
++
++    protected final void appendToDecreaseQueue(final long value) {
++        final int idx = this.decreaseQueueInitialLength++;
++        long[] queue = this.decreaseQueue;
++        if (idx >= queue.length) {
++            queue = this.resizeDecreaseQueue();
++            queue[idx] = value;
++        } else {
++            queue[idx] = value;
++        }
++    }
++
++    protected static final AxisDirection[][] OLD_CHECK_DIRECTIONS = new AxisDirection[1 << 6][];
++    protected static final int ALL_DIRECTIONS_BITSET = (1 << 6) - 1;
++    static {
++        for (int i = 0; i < OLD_CHECK_DIRECTIONS.length; ++i) {
++            final List<AxisDirection> directions = new ArrayList<>();
++            for (int bitset = i, len = Integer.bitCount(i), index = 0; index < len; ++index, bitset ^= IntegerUtil.getTrailingBit(bitset)) {
++                directions.add(AXIS_DIRECTIONS[IntegerUtil.trailingZeros(bitset)]);
++            }
++            OLD_CHECK_DIRECTIONS[i] = directions.toArray(new AxisDirection[0]);
++        }
++    }
++
++    protected final void performLightIncrease(final LightChunkGetter lightAccess) {
++        final BlockGetter world = lightAccess.getLevel();
++        long[] queue = this.increaseQueue;
++        int queueReadIndex = 0;
++        int queueLength = this.increaseQueueInitialLength;
++        this.increaseQueueInitialLength = 0;
++        final int decodeOffsetX = -this.encodeOffsetX;
++        final int decodeOffsetY = -this.encodeOffsetY;
++        final int decodeOffsetZ = -this.encodeOffsetZ;
++        final int encodeOffset = this.coordinateOffset;
++        final int sectionOffset = this.chunkSectionIndexOffset;
++
++        while (queueReadIndex < queueLength) {
++            final long queueValue = queue[queueReadIndex++];
++
++            final int posX = ((int)queueValue & 63) + decodeOffsetX;
++            final int posZ = (((int)queueValue >>> 6) & 63) + decodeOffsetZ;
++            final int posY = (((int)queueValue >>> 12) & ((1 << 16) - 1)) + decodeOffsetY;
++            final int propagatedLightLevel = (int)((queueValue >>> (6 + 6 + 16)) & 0xFL);
++            final AxisDirection[] checkDirections = OLD_CHECK_DIRECTIONS[(int)((queueValue >>> (6 + 6 + 16 + 4)) & 63L)];
++
++            if ((queueValue & FLAG_RECHECK_LEVEL) != 0L) {
++                if (this.getLightLevel(posX, posY, posZ) != propagatedLightLevel) {
++                    // not at the level we expect, so something changed.
++                    continue;
++                }
++            } else if ((queueValue & FLAG_WRITE_LEVEL) != 0L) {
++                // these are used to restore block sources after a propagation decrease
++                this.setLightLevel(posX, posY, posZ, propagatedLightLevel);
++            }
++
++            if ((queueValue & FLAG_HAS_SIDED_TRANSPARENT_BLOCKS) == 0L) {
++                // we don't need to worry about our state here.
++                for (final AxisDirection propagate : checkDirections) {
++                    final int offX = posX + propagate.x;
++                    final int offY = posY + propagate.y;
++                    final int offZ = posZ + propagate.z;
++
++                    final int sectionIndex = (offX >> 4) + 5 * (offZ >> 4) + (5 * 5) * (offY >> 4) + sectionOffset;
++                    final int localIndex = (offX & 15) | ((offZ & 15) << 4) | ((offY & 15) << 8);
++
++                    final SWMRNibbleArray currentNibble = this.nibbleCache[sectionIndex];
++                    final int currentLevel;
++                    if (currentNibble == null || (currentLevel = currentNibble.getUpdating(localIndex)) >= (propagatedLightLevel - 1)) {
++                        continue; // already at the level we want or unloaded
++                    }
++
++                    final BlockState blockState = this.getBlockState(sectionIndex, localIndex);
++                    if (blockState == null) {
++                        continue;
++                    }
++                    final int opacityCached = blockState.getOpacityIfCached();
++                    if (opacityCached != -1) {
++                        final int targetLevel = propagatedLightLevel - Math.max(1, opacityCached);
++                        if (targetLevel > currentLevel) {
++                            currentNibble.set(localIndex, targetLevel);
++                            this.postLightUpdate(offX, offY, offZ);
++
++                            if (targetLevel > 1) {
++                                if (queueLength >= queue.length) {
++                                    queue = this.resizeIncreaseQueue();
++                                }
++                                queue[queueLength++] =
++                                        ((offX + (offZ << 6) + (offY << 12) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                                | ((targetLevel & 0xFL) << (6 + 6 + 16))
++                                                | (propagate.everythingButTheOppositeDirection << (6 + 6 + 16 + 4));
++                                continue;
++                            }
++                        }
++                        continue;
++                    } else {
++                        this.mutablePos1.set(offX, offY, offZ);
++                        long flags = 0;
++                        if (blockState.isConditionallyFullOpaque()) {
++                            final VoxelShape cullingFace = blockState.getFaceOcclusionShape(world, this.mutablePos1, propagate.getOpposite().nms);
++
++                            if (Shapes.faceShapeOccludes(Shapes.empty(), cullingFace)) {
++                                continue;
++                            }
++                            flags |= FLAG_HAS_SIDED_TRANSPARENT_BLOCKS;
++                        }
++
++                        final int opacity = blockState.getLightBlock(world, this.mutablePos1);
++                        final int targetLevel = propagatedLightLevel - Math.max(1, opacity);
++                        if (targetLevel <= currentLevel) {
++                            continue;
++                        }
++
++                        currentNibble.set(localIndex, targetLevel);
++                        this.postLightUpdate(offX, offY, offZ);
++
++                        if (targetLevel > 1) {
++                            if (queueLength >= queue.length) {
++                                queue = this.resizeIncreaseQueue();
++                            }
++                            queue[queueLength++] =
++                                    ((offX + (offZ << 6) + (offY << 12) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                            | ((targetLevel & 0xFL) << (6 + 6 + 16))
++                                            | (propagate.everythingButTheOppositeDirection << (6 + 6 + 16 + 4))
++                                            | (flags);
++                        }
++                        continue;
++                    }
++                }
++            } else {
++                // we actually need to worry about our state here
++                final BlockState fromBlock = this.getBlockState(posX, posY, posZ);
++                this.mutablePos2.set(posX, posY, posZ);
++                for (final AxisDirection propagate : checkDirections) {
++                    final int offX = posX + propagate.x;
++                    final int offY = posY + propagate.y;
++                    final int offZ = posZ + propagate.z;
++
++                    final VoxelShape fromShape = fromBlock.isConditionallyFullOpaque() ? fromBlock.getFaceOcclusionShape(world, this.mutablePos2, propagate.nms) : Shapes.empty();
++
++                    if (fromShape != Shapes.empty() && Shapes.faceShapeOccludes(Shapes.empty(), fromShape)) {
++                        continue;
++                    }
++
++                    final int sectionIndex = (offX >> 4) + 5 * (offZ >> 4) + (5 * 5) * (offY >> 4) + sectionOffset;
++                    final int localIndex = (offX & 15) | ((offZ & 15) << 4) | ((offY & 15) << 8);
++
++                    final SWMRNibbleArray currentNibble = this.nibbleCache[sectionIndex];
++                    final int currentLevel;
++
++                    if (currentNibble == null || (currentLevel = currentNibble.getUpdating(localIndex)) >= (propagatedLightLevel - 1)) {
++                        continue; // already at the level we want
++                    }
++
++                    final BlockState blockState = this.getBlockState(sectionIndex, localIndex);
++                    if (blockState == null) {
++                        continue;
++                    }
++                    final int opacityCached = blockState.getOpacityIfCached();
++                    if (opacityCached != -1) {
++                        final int targetLevel = propagatedLightLevel - Math.max(1, opacityCached);
++                        if (targetLevel > currentLevel) {
++                            currentNibble.set(localIndex, targetLevel);
++                            this.postLightUpdate(offX, offY, offZ);
++
++                            if (targetLevel > 1) {
++                                if (queueLength >= queue.length) {
++                                    queue = this.resizeIncreaseQueue();
++                                }
++                                queue[queueLength++] =
++                                        ((offX + (offZ << 6) + (offY << 12) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                                | ((targetLevel & 0xFL) << (6 + 6 + 16))
++                                                | (propagate.everythingButTheOppositeDirection << (6 + 6 + 16 + 4));
++                                continue;
++                            }
++                        }
++                        continue;
++                    } else {
++                        this.mutablePos1.set(offX, offY, offZ);
++                        long flags = 0;
++                        if (blockState.isConditionallyFullOpaque()) {
++                            final VoxelShape cullingFace = blockState.getFaceOcclusionShape(world, this.mutablePos1, propagate.getOpposite().nms);
++
++                            if (Shapes.faceShapeOccludes(fromShape, cullingFace)) {
++                                continue;
++                            }
++                            flags |= FLAG_HAS_SIDED_TRANSPARENT_BLOCKS;
++                        }
++
++                        final int opacity = blockState.getLightBlock(world, this.mutablePos1);
++                        final int targetLevel = propagatedLightLevel - Math.max(1, opacity);
++                        if (targetLevel <= currentLevel) {
++                            continue;
++                        }
++
++                        currentNibble.set(localIndex, targetLevel);
++                        this.postLightUpdate(offX, offY, offZ);
++
++                        if (targetLevel > 1) {
++                            if (queueLength >= queue.length) {
++                                queue = this.resizeIncreaseQueue();
++                            }
++                            queue[queueLength++] =
++                                    ((offX + (offZ << 6) + (offY << 12) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                            | ((targetLevel & 0xFL) << (6 + 6 + 16))
++                                            | (propagate.everythingButTheOppositeDirection << (6 + 6 + 16 + 4))
++                                            | (flags);
++                        }
++                        continue;
++                    }
++                }
++            }
++        }
++    }
++
++    protected final void performLightDecrease(final LightChunkGetter lightAccess) {
++        final BlockGetter world = lightAccess.getLevel();
++        long[] queue = this.decreaseQueue;
++        long[] increaseQueue = this.increaseQueue;
++        int queueReadIndex = 0;
++        int queueLength = this.decreaseQueueInitialLength;
++        this.decreaseQueueInitialLength = 0;
++        int increaseQueueLength = this.increaseQueueInitialLength;
++        final int decodeOffsetX = -this.encodeOffsetX;
++        final int decodeOffsetY = -this.encodeOffsetY;
++        final int decodeOffsetZ = -this.encodeOffsetZ;
++        final int encodeOffset = this.coordinateOffset;
++        final int sectionOffset = this.chunkSectionIndexOffset;
++        final int emittedMask = this.emittedLightMask;
++
++        while (queueReadIndex < queueLength) {
++            final long queueValue = queue[queueReadIndex++];
++
++            final int posX = ((int)queueValue & 63) + decodeOffsetX;
++            final int posZ = (((int)queueValue >>> 6) & 63) + decodeOffsetZ;
++            final int posY = (((int)queueValue >>> 12) & ((1 << 16) - 1)) + decodeOffsetY;
++            final int propagatedLightLevel = (int)((queueValue >>> (6 + 6 + 16)) & 0xF);
++            final AxisDirection[] checkDirections = OLD_CHECK_DIRECTIONS[(int)((queueValue >>> (6 + 6 + 16 + 4)) & 63)];
++
++            if ((queueValue & FLAG_HAS_SIDED_TRANSPARENT_BLOCKS) == 0L) {
++                // we don't need to worry about our state here.
++                for (final AxisDirection propagate : checkDirections) {
++                    final int offX = posX + propagate.x;
++                    final int offY = posY + propagate.y;
++                    final int offZ = posZ + propagate.z;
++
++                    final int sectionIndex = (offX >> 4) + 5 * (offZ >> 4) + (5 * 5) * (offY >> 4) + sectionOffset;
++                    final int localIndex = (offX & 15) | ((offZ & 15) << 4) | ((offY & 15) << 8);
++
++                    final SWMRNibbleArray currentNibble = this.nibbleCache[sectionIndex];
++                    final int lightLevel;
++
++                    if (currentNibble == null || (lightLevel = currentNibble.getUpdating(localIndex)) == 0) {
++                        // already at lowest (or unloaded), nothing we can do
++                        continue;
++                    }
++
++                    final BlockState blockState = this.getBlockState(sectionIndex, localIndex);
++                    if (blockState == null) {
++                        continue;
++                    }
++                    final int opacityCached = blockState.getOpacityIfCached();
++                    if (opacityCached != -1) {
++                        final int targetLevel = Math.max(0, propagatedLightLevel - Math.max(1, opacityCached));
++                        if (lightLevel > targetLevel) {
++                            // it looks like another source propagated here, so re-propagate it
++                            if (increaseQueueLength >= increaseQueue.length) {
++                                increaseQueue = this.resizeIncreaseQueue();
++                            }
++                            increaseQueue[increaseQueueLength++] =
++                                    ((offX + (offZ << 6) + (offY << 12) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                            | ((lightLevel & 0xFL) << (6 + 6 + 16))
++                                            | (((long)ALL_DIRECTIONS_BITSET) << (6 + 6 + 16 + 4))
++                                            | FLAG_RECHECK_LEVEL;
++                            continue;
++                        }
++                        final int emittedLight = blockState.getLightEmission() & emittedMask;
++                        if (emittedLight != 0) {
++                            // re-propagate source
++                            // note: do not set recheck level, or else the propagation will fail
++                            if (increaseQueueLength >= increaseQueue.length) {
++                                increaseQueue = this.resizeIncreaseQueue();
++                            }
++                            increaseQueue[increaseQueueLength++] =
++                                    ((offX + (offZ << 6) + (offY << 12) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                            | ((emittedLight & 0xFL) << (6 + 6 + 16))
++                                            | (((long)ALL_DIRECTIONS_BITSET) << (6 + 6 + 16 + 4))
++                                            | (blockState.isConditionallyFullOpaque() ? (FLAG_WRITE_LEVEL | FLAG_HAS_SIDED_TRANSPARENT_BLOCKS) : FLAG_WRITE_LEVEL);
++                        }
++
++                        currentNibble.set(localIndex, 0);
++                        this.postLightUpdate(offX, offY, offZ);
++
++                        if (targetLevel > 0) { // we actually need to propagate 0 just in case we find a neighbour...
++                            if (queueLength >= queue.length) {
++                                queue = this.resizeDecreaseQueue();
++                            }
++                            queue[queueLength++] =
++                                    ((offX + (offZ << 6) + (offY << 12) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                            | ((targetLevel & 0xFL) << (6 + 6 + 16))
++                                            | ((propagate.everythingButTheOppositeDirection) << (6 + 6 + 16 + 4));
++                            continue;
++                        }
++                        continue;
++                    } else {
++                        this.mutablePos1.set(offX, offY, offZ);
++                        long flags = 0;
++                        if (blockState.isConditionallyFullOpaque()) {
++                            final VoxelShape cullingFace = blockState.getFaceOcclusionShape(world, this.mutablePos1, propagate.getOpposite().nms);
++
++                            if (Shapes.faceShapeOccludes(Shapes.empty(), cullingFace)) {
++                                continue;
++                            }
++                            flags |= FLAG_HAS_SIDED_TRANSPARENT_BLOCKS;
++                        }
++
++                        final int opacity = blockState.getLightBlock(world, this.mutablePos1);
++                        final int targetLevel = Math.max(0, propagatedLightLevel - Math.max(1, opacity));
++                        if (lightLevel > targetLevel) {
++                            // it looks like another source propagated here, so re-propagate it
++                            if (increaseQueueLength >= increaseQueue.length) {
++                                increaseQueue = this.resizeIncreaseQueue();
++                            }
++                            increaseQueue[increaseQueueLength++] =
++                                    ((offX + (offZ << 6) + (offY << 12) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                            | ((lightLevel & 0xFL) << (6 + 6 + 16))
++                                            | (((long)ALL_DIRECTIONS_BITSET) << (6 + 6 + 16 + 4))
++                                            | (FLAG_RECHECK_LEVEL | flags);
++                            continue;
++                        }
++                        final int emittedLight = blockState.getLightEmission() & emittedMask;
++                        if (emittedLight != 0) {
++                            // re-propagate source
++                            // note: do not set recheck level, or else the propagation will fail
++                            if (increaseQueueLength >= increaseQueue.length) {
++                                increaseQueue = this.resizeIncreaseQueue();
++                            }
++                            increaseQueue[increaseQueueLength++] =
++                                    ((offX + (offZ << 6) + (offY << 12) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                            | ((emittedLight & 0xFL) << (6 + 6 + 16))
++                                            | (((long)ALL_DIRECTIONS_BITSET) << (6 + 6 + 16 + 4))
++                                            | (flags | FLAG_WRITE_LEVEL);
++                        }
++
++                        currentNibble.set(localIndex, 0);
++                        this.postLightUpdate(offX, offY, offZ);
++
++                        if (targetLevel > 0) {
++                            if (queueLength >= queue.length) {
++                                queue = this.resizeDecreaseQueue();
++                            }
++                            queue[queueLength++] =
++                                    ((offX + (offZ << 6) + (offY << 12) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                            | ((targetLevel & 0xFL) << (6 + 6 + 16))
++                                            | ((propagate.everythingButTheOppositeDirection) << (6 + 6 + 16 + 4))
++                                            | flags;
++                        }
++                        continue;
++                    }
++                }
++            } else {
++                // we actually need to worry about our state here
++                final BlockState fromBlock = this.getBlockState(posX, posY, posZ);
++                this.mutablePos2.set(posX, posY, posZ);
++                for (final AxisDirection propagate : checkDirections) {
++                    final int offX = posX + propagate.x;
++                    final int offY = posY + propagate.y;
++                    final int offZ = posZ + propagate.z;
++
++                    final int sectionIndex = (offX >> 4) + 5 * (offZ >> 4) + (5 * 5) * (offY >> 4) + sectionOffset;
++                    final int localIndex = (offX & 15) | ((offZ & 15) << 4) | ((offY & 15) << 8);
++
++                    final VoxelShape fromShape = (fromBlock.isConditionallyFullOpaque()) ? fromBlock.getFaceOcclusionShape(world, this.mutablePos2, propagate.nms) : Shapes.empty();
++
++                    if (fromShape != Shapes.empty() && Shapes.faceShapeOccludes(Shapes.empty(), fromShape)) {
++                        continue;
++                    }
++
++                    final SWMRNibbleArray currentNibble = this.nibbleCache[sectionIndex];
++                    final int lightLevel;
++
++                    if (currentNibble == null || (lightLevel = currentNibble.getUpdating(localIndex)) == 0) {
++                        // already at lowest (or unloaded), nothing we can do
++                        continue;
++                    }
++
++                    final BlockState blockState = this.getBlockState(sectionIndex, localIndex);
++                    if (blockState == null) {
++                        continue;
++                    }
++                    final int opacityCached = blockState.getOpacityIfCached();
++                    if (opacityCached != -1) {
++                        final int targetLevel = Math.max(0, propagatedLightLevel - Math.max(1, opacityCached));
++                        if (lightLevel > targetLevel) {
++                            // it looks like another source propagated here, so re-propagate it
++                            if (increaseQueueLength >= increaseQueue.length) {
++                                increaseQueue = this.resizeIncreaseQueue();
++                            }
++                            increaseQueue[increaseQueueLength++] =
++                                    ((offX + (offZ << 6) + (offY << 12) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                            | ((lightLevel & 0xFL) << (6 + 6 + 16))
++                                            | (((long)ALL_DIRECTIONS_BITSET) << (6 + 6 + 16 + 4))
++                                            | FLAG_RECHECK_LEVEL;
++                            continue;
++                        }
++                        final int emittedLight = blockState.getLightEmission() & emittedMask;
++                        if (emittedLight != 0) {
++                            // re-propagate source
++                            // note: do not set recheck level, or else the propagation will fail
++                            if (increaseQueueLength >= increaseQueue.length) {
++                                increaseQueue = this.resizeIncreaseQueue();
++                            }
++                            increaseQueue[increaseQueueLength++] =
++                                    ((offX + (offZ << 6) + (offY << 12) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                            | ((emittedLight & 0xFL) << (6 + 6 + 16))
++                                            | (((long)ALL_DIRECTIONS_BITSET) << (6 + 6 + 16 + 4))
++                                            | (blockState.isConditionallyFullOpaque() ? (FLAG_WRITE_LEVEL | FLAG_HAS_SIDED_TRANSPARENT_BLOCKS) : FLAG_WRITE_LEVEL);
++                        }
++
++                        currentNibble.set(localIndex, 0);
++                        this.postLightUpdate(offX, offY, offZ);
++
++                        if (targetLevel > 0) { // we actually need to propagate 0 just in case we find a neighbour...
++                            if (queueLength >= queue.length) {
++                                queue = this.resizeDecreaseQueue();
++                            }
++                            queue[queueLength++] =
++                                    ((offX + (offZ << 6) + (offY << 12) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                            | ((targetLevel & 0xFL) << (6 + 6 + 16))
++                                            | ((propagate.everythingButTheOppositeDirection) << (6 + 6 + 16 + 4));
++                            continue;
++                        }
++                        continue;
++                    } else {
++                        this.mutablePos1.set(offX, offY, offZ);
++                        long flags = 0;
++                        if (blockState.isConditionallyFullOpaque()) {
++                            final VoxelShape cullingFace = blockState.getFaceOcclusionShape(world, this.mutablePos1, propagate.getOpposite().nms);
++
++                            if (Shapes.faceShapeOccludes(fromShape, cullingFace)) {
++                                continue;
++                            }
++                            flags |= FLAG_HAS_SIDED_TRANSPARENT_BLOCKS;
++                        }
++
++                        final int opacity = blockState.getLightBlock(world, this.mutablePos1);
++                        final int targetLevel = Math.max(0, propagatedLightLevel - Math.max(1, opacity));
++                        if (lightLevel > targetLevel) {
++                            // it looks like another source propagated here, so re-propagate it
++                            if (increaseQueueLength >= increaseQueue.length) {
++                                increaseQueue = this.resizeIncreaseQueue();
++                            }
++                            increaseQueue[increaseQueueLength++] =
++                                    ((offX + (offZ << 6) + (offY << 12) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                            | ((lightLevel & 0xFL) << (6 + 6 + 16))
++                                            | (((long)ALL_DIRECTIONS_BITSET) << (6 + 6 + 16 + 4))
++                                            | (FLAG_RECHECK_LEVEL | flags);
++                            continue;
++                        }
++                        final int emittedLight = blockState.getLightEmission() & emittedMask;
++                        if (emittedLight != 0) {
++                            // re-propagate source
++                            // note: do not set recheck level, or else the propagation will fail
++                            if (increaseQueueLength >= increaseQueue.length) {
++                                increaseQueue = this.resizeIncreaseQueue();
++                            }
++                            increaseQueue[increaseQueueLength++] =
++                                    ((offX + (offZ << 6) + (offY << 12) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                            | ((emittedLight & 0xFL) << (6 + 6 + 16))
++                                            | (((long)ALL_DIRECTIONS_BITSET) << (6 + 6 + 16 + 4))
++                                            | (flags | FLAG_WRITE_LEVEL);
++                        }
++
++                        currentNibble.set(localIndex, 0);
++                        this.postLightUpdate(offX, offY, offZ);
++
++                        if (targetLevel > 0) { // we actually need to propagate 0 just in case we find a neighbour...
++                            if (queueLength >= queue.length) {
++                                queue = this.resizeDecreaseQueue();
++                            }
++                            queue[queueLength++] =
++                                    ((offX + (offZ << 6) + (offY << 12) + encodeOffset) & ((1L << (6 + 6 + 16)) - 1))
++                                            | ((targetLevel & 0xFL) << (6 + 6 + 16))
++                                            | ((propagate.everythingButTheOppositeDirection) << (6 + 6 + 16 + 4))
++                                            | flags;
++                        }
++                        continue;
++                    }
++                }
++            }
++        }
++
++        // propagate sources we clobbered
++        this.increaseQueueInitialLength = increaseQueueLength;
++        this.performLightIncrease(lightAccess);
++    }
++}
+diff --git a/src/main/java/ca/spottedleaf/starlight/common/light/StarLightInterface.java b/src/main/java/ca/spottedleaf/starlight/common/light/StarLightInterface.java
+new file mode 100644
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
+--- /dev/null
++++ b/src/main/java/ca/spottedleaf/starlight/common/light/StarLightInterface.java
+@@ -0,0 +0,0 @@
++package ca.spottedleaf.starlight.common.light;
++
++import ca.spottedleaf.starlight.common.util.CoordinateUtils;
++import ca.spottedleaf.starlight.common.util.WorldUtil;
++import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
++import it.unimi.dsi.fastutil.shorts.ShortCollection;
++import it.unimi.dsi.fastutil.shorts.ShortOpenHashSet;
++import net.minecraft.core.BlockPos;
++import net.minecraft.core.SectionPos;
++import net.minecraft.server.level.ServerChunkCache;
++import net.minecraft.server.level.ServerLevel;
++import net.minecraft.server.level.TicketType;
++import net.minecraft.world.level.ChunkPos;
++import net.minecraft.world.level.Level;
++import net.minecraft.world.level.chunk.ChunkAccess;
++import net.minecraft.world.level.chunk.ChunkStatus;
++import net.minecraft.world.level.chunk.DataLayer;
++import net.minecraft.world.level.chunk.LevelChunk;
++import net.minecraft.world.level.chunk.LightChunkGetter;
++import net.minecraft.world.level.lighting.LayerLightEventListener;
++import net.minecraft.world.level.lighting.LevelLightEngine;
++import java.util.ArrayDeque;
++import java.util.ArrayList;
++import java.util.HashSet;
++import java.util.List;
++import java.util.Set;
++import java.util.concurrent.CompletableFuture;
++import java.util.function.Consumer;
++import java.util.function.IntConsumer;
++
++public final class StarLightInterface {
++
++    public static final TicketType<ChunkPos> CHUNK_WORK_TICKET = TicketType.create("starlight_chunk_work_ticket", (p1, p2) -> Long.compare(p1.toLong(), p2.toLong()));
++
++    /**
++     * Can be {@code null}, indicating the light is all empty.
++     */
++    protected final Level world;
++    protected final LightChunkGetter lightAccess;
++
++    protected final ArrayDeque<SkyStarLightEngine> cachedSkyPropagators;
++    protected final ArrayDeque<BlockStarLightEngine> cachedBlockPropagators;
++
++    protected final LightQueue lightQueue = new LightQueue(this);
++
++    protected final LayerLightEventListener skyReader;
++    protected final LayerLightEventListener blockReader;
++    protected final boolean isClientSide;
++
++    protected final int minSection;
++    protected final int maxSection;
++    protected final int minLightSection;
++    protected final int maxLightSection;
++
++    public final LevelLightEngine lightEngine;
++
++    public StarLightInterface(final LightChunkGetter lightAccess, final boolean hasSkyLight, final boolean hasBlockLight, final LevelLightEngine lightEngine) {
++        this.lightAccess = lightAccess;
++        this.world = lightAccess == null ? null : (Level)lightAccess.getLevel();
++        this.cachedSkyPropagators = hasSkyLight && lightAccess != null ? new ArrayDeque<>() : null;
++        this.cachedBlockPropagators = hasBlockLight && lightAccess != null ? new ArrayDeque<>() : null;
++        this.isClientSide = !(this.world instanceof ServerLevel);
++        if (this.world == null) {
++            this.minSection = -4;
++            this.maxSection = 19;
++            this.minLightSection = -5;
++            this.maxLightSection = 20;
++        } else {
++            this.minSection = WorldUtil.getMinSection(this.world);
++            this.maxSection = WorldUtil.getMaxSection(this.world);
++            this.minLightSection = WorldUtil.getMinLightSection(this.world);
++            this.maxLightSection = WorldUtil.getMaxLightSection(this.world);
++        }
++        this.lightEngine = lightEngine;
++        this.skyReader = !hasSkyLight ? LayerLightEventListener.DummyLightLayerEventListener.INSTANCE : new LayerLightEventListener() {
++            @Override
++            public void checkBlock(final BlockPos blockPos) {
++                StarLightInterface.this.lightEngine.checkBlock(blockPos.immutable());
++            }
++
++            @Override
++            public void onBlockEmissionIncrease(final BlockPos blockPos, final int i) {
++                // skylight doesn't care
++            }
++
++            @Override
++            public boolean hasLightWork() {
++                // not really correct...
++                return StarLightInterface.this.hasUpdates();
++            }
++
++            @Override
++            public int runUpdates(final int i, final boolean bl, final boolean bl2) {
++                throw new UnsupportedOperationException();
++            }
++
++            @Override
++            public void enableLightSources(final ChunkPos chunkPos, final boolean bl) {
++                throw new UnsupportedOperationException();
++            }
++
++            @Override
++            public DataLayer getDataLayerData(final SectionPos pos) {
++                final ChunkAccess chunk = StarLightInterface.this.getAnyChunkNow(pos.getX(), pos.getZ());
++                if (chunk == null || (!StarLightInterface.this.isClientSide && !chunk.isLightCorrect()) || !chunk.getStatus().isOrAfter(ChunkStatus.LIGHT)) {
++                    return null;
++                }
++
++                final int sectionY = pos.getY();
++
++                if (sectionY > StarLightInterface.this.maxLightSection || sectionY < StarLightInterface.this.minLightSection) {
++                    return null;
++                }
++
++                if (chunk.getSkyEmptinessMap() == null) {
++                    return null;
++                }
++
++                return chunk.getSkyNibbles()[sectionY - StarLightInterface.this.minLightSection].toVanillaNibble();
++            }
++
++            @Override
++            public int getLightValue(final BlockPos blockPos) {
++                return StarLightInterface.this.getSkyLightValue(blockPos, StarLightInterface.this.getAnyChunkNow(blockPos.getX() >> 4, blockPos.getZ() >> 4));
++            }
++
++            @Override
++            public void updateSectionStatus(final SectionPos pos, final boolean notReady) {
++                StarLightInterface.this.sectionChange(pos, notReady);
++            }
++        };
++        this.blockReader = !hasBlockLight ? LayerLightEventListener.DummyLightLayerEventListener.INSTANCE : new LayerLightEventListener() {
++            @Override
++            public void checkBlock(final BlockPos blockPos) {
++                StarLightInterface.this.lightEngine.checkBlock(blockPos.immutable());
++            }
++
++            @Override
++            public void onBlockEmissionIncrease(final BlockPos blockPos, final int i) {
++                this.checkBlock(blockPos);
++            }
++
++            @Override
++            public boolean hasLightWork() {
++                // not really correct...
++                return StarLightInterface.this.hasUpdates();
++            }
++
++            @Override
++            public int runUpdates(final int i, final boolean bl, final boolean bl2) {
++                throw new UnsupportedOperationException();
++            }
++
++            @Override
++            public void enableLightSources(final ChunkPos chunkPos, final boolean bl) {
++                throw new UnsupportedOperationException();
++            }
++
++            @Override
++            public DataLayer getDataLayerData(final SectionPos pos) {
++                final ChunkAccess chunk = StarLightInterface.this.getAnyChunkNow(pos.getX(), pos.getZ());
++
++                if (chunk == null || pos.getY() < StarLightInterface.this.minLightSection || pos.getY() > StarLightInterface.this.maxLightSection) {
++                    return null;
++                }
++
++                return chunk.getBlockNibbles()[pos.getY() - StarLightInterface.this.minLightSection].toVanillaNibble();
++            }
++
++            @Override
++            public int getLightValue(final BlockPos blockPos) {
++                return StarLightInterface.this.getBlockLightValue(blockPos, StarLightInterface.this.getAnyChunkNow(blockPos.getX() >> 4, blockPos.getZ() >> 4));
++            }
++
++            @Override
++            public void updateSectionStatus(final SectionPos pos, final boolean notReady) {
++                StarLightInterface.this.sectionChange(pos, notReady);
++            }
++        };
++    }
++
++    protected int getSkyLightValue(final BlockPos blockPos, final ChunkAccess chunk) {
++        final int x = blockPos.getX();
++        int y = blockPos.getY();
++        final int z = blockPos.getZ();
++
++        final int minSection = this.minSection;
++        final int maxSection = this.maxSection;
++        final int minLightSection = this.minLightSection;
++        final int maxLightSection = this.maxLightSection;
++
++        if (chunk == null || (!this.isClientSide && !chunk.isLightCorrect()) || !chunk.getStatus().isOrAfter(ChunkStatus.LIGHT)) {
++            return 15;
++        }
++
++        int sectionY = y >> 4;
++
++        if (sectionY > maxLightSection) {
++            return 15;
++        }
++
++        if (sectionY < minLightSection) {
++            sectionY = minLightSection;
++            y = sectionY << 4;
++        }
++
++        final SWMRNibbleArray[] nibbles = chunk.getSkyNibbles();
++        final SWMRNibbleArray immediate = nibbles[sectionY - minLightSection];
++
++        if (!immediate.isNullNibbleVisible()) {
++            return immediate.getVisible(x, y, z);
++        }
++
++        final boolean[] emptinessMap = chunk.getSkyEmptinessMap();
++
++        if (emptinessMap == null) {
++            return 15;
++        }
++
++        // are we above this chunk's lowest empty section?
++        int lowestY = minLightSection - 1;
++        for (int currY = maxSection; currY >= minSection; --currY) {
++            if (emptinessMap[currY - minSection]) {
++                continue;
++            }
++
++            // should always be full lit here
++            lowestY = currY;
++            break;
++        }
++
++        if (sectionY > lowestY) {
++            return 15;
++        }
++
++        // this nibble is going to depend solely on the skylight data above it
++        // find first non-null data above (there does exist one, as we just found it above)
++        for (int currY = sectionY + 1; currY <= maxLightSection; ++currY) {
++            final SWMRNibbleArray nibble = nibbles[currY - minLightSection];
++            if (!nibble.isNullNibbleVisible()) {
++                return nibble.getVisible(x, 0, z);
++            }
++        }
++
++        // should never reach here
++        return 15;
++    }
++
++    protected int getBlockLightValue(final BlockPos blockPos, final ChunkAccess chunk) {
++        final int y = blockPos.getY();
++        final int cy = y >> 4;
++
++        final int minLightSection = this.minLightSection;
++        final int maxLightSection = this.maxLightSection;
++
++        if (cy < minLightSection || cy > maxLightSection) {
++            return 0;
++        }
++
++        if (chunk == null) {
++            return 0;
++        }
++
++        final SWMRNibbleArray nibble = chunk.getBlockNibbles()[cy - minLightSection];
++        return nibble.getVisible(blockPos.getX(), y, blockPos.getZ());
++    }
++
++    public int getRawBrightness(final BlockPos pos, final int ambientDarkness) {
++        final ChunkAccess chunk = this.getAnyChunkNow(pos.getX() >> 4, pos.getZ() >> 4);
++
++        final int sky = this.getSkyLightValue(pos, chunk) - ambientDarkness;
++        final int block = this.getBlockLightValue(pos, chunk);
++        return Math.max(sky, block);
++    }
++
++    public LayerLightEventListener getSkyReader() {
++        return this.skyReader;
++    }
++
++    public LayerLightEventListener getBlockReader() {
++        return this.blockReader;
++    }
++
++    public boolean isClientSide() {
++        return this.isClientSide;
++    }
++
++    public ChunkAccess getAnyChunkNow(final int chunkX, final int chunkZ) {
++        if (this.world == null) {
++            // empty world
++            return null;
++        }
++
++        final ServerChunkCache chunkProvider =  ((ServerLevel)this.world).getChunkSource();
++        final LevelChunk fullLoaded = chunkProvider.getChunkAtIfLoadedImmediately(chunkX, chunkZ);
++        if (fullLoaded != null) {
++            return fullLoaded;
++        }
++
++        return chunkProvider.getChunkAtImmediately(chunkX, chunkZ);
++    }
++
++    public boolean hasUpdates() {
++        return !this.lightQueue.isEmpty();
++    }
++
++    public Level getWorld() {
++        return this.world;
++    }
++
++    public LightChunkGetter getLightAccess() {
++        return this.lightAccess;
++    }
++
++    protected final SkyStarLightEngine getSkyLightEngine() {
++        if (this.cachedSkyPropagators == null) {
++            return null;
++        }
++        final SkyStarLightEngine ret;
++        synchronized (this.cachedSkyPropagators) {
++            ret = this.cachedSkyPropagators.pollFirst();
++        }
++
++        if (ret == null) {
++            return new SkyStarLightEngine(this.world);
++        }
++        return ret;
++    }
++
++    protected final void releaseSkyLightEngine(final SkyStarLightEngine engine) {
++        if (this.cachedSkyPropagators == null) {
++            return;
++        }
++        synchronized (this.cachedSkyPropagators) {
++            this.cachedSkyPropagators.addFirst(engine);
++        }
++    }
++
++    protected final BlockStarLightEngine getBlockLightEngine() {
++        if (this.cachedBlockPropagators == null) {
++            return null;
++        }
++        final BlockStarLightEngine ret;
++        synchronized (this.cachedBlockPropagators) {
++            ret = this.cachedBlockPropagators.pollFirst();
++        }
++
++        if (ret == null) {
++            return new BlockStarLightEngine(this.world);
++        }
++        return ret;
++    }
++
++    protected final void releaseBlockLightEngine(final BlockStarLightEngine engine) {
++        if (this.cachedBlockPropagators == null) {
++            return;
++        }
++        synchronized (this.cachedBlockPropagators) {
++            this.cachedBlockPropagators.addFirst(engine);
++        }
++    }
++
++    public CompletableFuture<Void> blockChange(final BlockPos pos) {
++        if (this.world == null || pos.getY() < WorldUtil.getMinBlockY(this.world) || pos.getY() > WorldUtil.getMaxBlockY(this.world)) { // empty world
++            return null;
++        }
++
++        return this.lightQueue.queueBlockChange(pos);
++    }
++
++    public CompletableFuture<Void> sectionChange(final SectionPos pos, final boolean newEmptyValue) {
++        if (this.world == null) { // empty world
++            return null;
++        }
++
++        return this.lightQueue.queueSectionChange(pos, newEmptyValue);
++    }
++
++    public void forceLoadInChunk(final ChunkAccess chunk, final Boolean[] emptySections) {
++        final SkyStarLightEngine skyEngine = this.getSkyLightEngine();
++        final BlockStarLightEngine blockEngine = this.getBlockLightEngine();
++
++        try {
++            if (skyEngine != null) {
++                skyEngine.forceHandleEmptySectionChanges(this.lightAccess, chunk, emptySections);
++            }
++            if (blockEngine != null) {
++                blockEngine.forceHandleEmptySectionChanges(this.lightAccess, chunk, emptySections);
++            }
++        } finally {
++            this.releaseSkyLightEngine(skyEngine);
++            this.releaseBlockLightEngine(blockEngine);
++        }
++    }
++
++    public void loadInChunk(final int chunkX, final int chunkZ, final Boolean[] emptySections) {
++        final SkyStarLightEngine skyEngine = this.getSkyLightEngine();
++        final BlockStarLightEngine blockEngine = this.getBlockLightEngine();
++
++        try {
++            if (skyEngine != null) {
++                skyEngine.handleEmptySectionChanges(this.lightAccess, chunkX, chunkZ, emptySections);
++            }
++            if (blockEngine != null) {
++                blockEngine.handleEmptySectionChanges(this.lightAccess, chunkX, chunkZ, emptySections);
++            }
++        } finally {
++            this.releaseSkyLightEngine(skyEngine);
++            this.releaseBlockLightEngine(blockEngine);
++        }
++    }
++
++    public void lightChunk(final ChunkAccess chunk, final Boolean[] emptySections) {
++        final SkyStarLightEngine skyEngine = this.getSkyLightEngine();
++        final BlockStarLightEngine blockEngine = this.getBlockLightEngine();
++
++        try {
++            if (skyEngine != null) {
++                skyEngine.light(this.lightAccess, chunk, emptySections);
++            }
++            if (blockEngine != null) {
++                blockEngine.light(this.lightAccess, chunk, emptySections);
++            }
++        } finally {
++            this.releaseSkyLightEngine(skyEngine);
++            this.releaseBlockLightEngine(blockEngine);
++        }
++    }
++
++    public void relightChunks(final Set<ChunkPos> chunks, final Consumer<ChunkPos> chunkLightCallback,
++                              final IntConsumer onComplete) {
++        final SkyStarLightEngine skyEngine = this.getSkyLightEngine();
++        final BlockStarLightEngine blockEngine = this.getBlockLightEngine();
++
++        try {
++            if (skyEngine != null) {
++                skyEngine.relightChunks(this.lightAccess, chunks, blockEngine == null ? chunkLightCallback : null,
++                        blockEngine == null ? onComplete : null);
++            }
++            if (blockEngine != null) {
++                blockEngine.relightChunks(this.lightAccess, chunks, chunkLightCallback, onComplete);
++            }
++        } finally {
++            this.releaseSkyLightEngine(skyEngine);
++            this.releaseBlockLightEngine(blockEngine);
++        }
++    }
++
++    public void checkChunkEdges(final int chunkX, final int chunkZ) {
++        this.checkSkyEdges(chunkX, chunkZ);
++        this.checkBlockEdges(chunkX, chunkZ);
++    }
++
++    public void checkSkyEdges(final int chunkX, final int chunkZ) {
++        final SkyStarLightEngine skyEngine = this.getSkyLightEngine();
++
++        try {
++            if (skyEngine != null) {
++                skyEngine.checkChunkEdges(this.lightAccess, chunkX, chunkZ);
++            }
++        } finally {
++            this.releaseSkyLightEngine(skyEngine);
++        }
++    }
++
++    public void checkBlockEdges(final int chunkX, final int chunkZ) {
++        final BlockStarLightEngine blockEngine = this.getBlockLightEngine();
++        try {
++            if (blockEngine != null) {
++                blockEngine.checkChunkEdges(this.lightAccess, chunkX, chunkZ);
++            }
++        } finally {
++            this.releaseBlockLightEngine(blockEngine);
++        }
++    }
++
++    public void checkSkyEdges(final int chunkX, final int chunkZ, final ShortCollection sections) {
++        final SkyStarLightEngine skyEngine = this.getSkyLightEngine();
++
++        try {
++            if (skyEngine != null) {
++                skyEngine.checkChunkEdges(this.lightAccess, chunkX, chunkZ, sections);
++            }
++        } finally {
++            this.releaseSkyLightEngine(skyEngine);
++        }
++    }
++
++    public void checkBlockEdges(final int chunkX, final int chunkZ, final ShortCollection sections) {
++        final BlockStarLightEngine blockEngine = this.getBlockLightEngine();
++        try {
++            if (blockEngine != null) {
++                blockEngine.checkChunkEdges(this.lightAccess, chunkX, chunkZ, sections);
++            }
++        } finally {
++            this.releaseBlockLightEngine(blockEngine);
++        }
++    }
++
++    public void scheduleChunkLight(final ChunkPos pos, final Runnable run) {
++        this.lightQueue.queueChunkLighting(pos, run);
++    }
++
++    public void removeChunkTasks(final ChunkPos pos) {
++        this.lightQueue.removeChunk(pos);
++    }
++
++    public void propagateChanges() {
++        if (this.lightQueue.isEmpty()) {
++            return;
++        }
++
++        final SkyStarLightEngine skyEngine = this.getSkyLightEngine();
++        final BlockStarLightEngine blockEngine = this.getBlockLightEngine();
++
++        try {
++            LightQueue.ChunkTasks task;
++            while ((task = this.lightQueue.removeFirstTask()) != null) {
++                if (task.lightTasks != null) {
++                    for (final Runnable run : task.lightTasks) {
++                        run.run();
++                    }
++                }
++
++                final long coordinate = task.chunkCoordinate;
++                final int chunkX = CoordinateUtils.getChunkX(coordinate);
++                final int chunkZ = CoordinateUtils.getChunkZ(coordinate);
++
++                final Set<BlockPos> positions = task.changedPositions;
++                final Boolean[] sectionChanges = task.changedSectionSet;
++
++                if (skyEngine != null && (!positions.isEmpty() || sectionChanges != null)) {
++                    skyEngine.blocksChangedInChunk(this.lightAccess, chunkX, chunkZ, positions, sectionChanges);
++                }
++                if (blockEngine != null && (!positions.isEmpty() || sectionChanges != null)) {
++                    blockEngine.blocksChangedInChunk(this.lightAccess, chunkX, chunkZ, positions, sectionChanges);
++                }
++
++                if (skyEngine != null && task.queuedEdgeChecksSky != null) {
++                    skyEngine.checkChunkEdges(this.lightAccess, chunkX, chunkZ, task.queuedEdgeChecksSky);
++                }
++                if (blockEngine != null && task.queuedEdgeChecksBlock != null) {
++                    blockEngine.checkChunkEdges(this.lightAccess, chunkX, chunkZ, task.queuedEdgeChecksBlock);
++                }
++
++                task.onComplete.complete(null);
++            }
++        } finally {
++            this.releaseSkyLightEngine(skyEngine);
++            this.releaseBlockLightEngine(blockEngine);
++        }
++    }
++
++    protected static final class LightQueue {
++
++        protected final Long2ObjectLinkedOpenHashMap<ChunkTasks> chunkTasks = new Long2ObjectLinkedOpenHashMap<>();
++        protected final StarLightInterface manager;
++
++        public LightQueue(final StarLightInterface manager) {
++            this.manager = manager;
++        }
++
++        public synchronized boolean isEmpty() {
++            return this.chunkTasks.isEmpty();
++        }
++
++        public synchronized CompletableFuture<Void> queueBlockChange(final BlockPos pos) {
++            final ChunkTasks tasks = this.chunkTasks.computeIfAbsent(CoordinateUtils.getChunkKey(pos), ChunkTasks::new);
++            tasks.changedPositions.add(pos.immutable());
++            return tasks.onComplete;
++        }
++
++        public synchronized CompletableFuture<Void> queueSectionChange(final SectionPos pos, final boolean newEmptyValue) {
++            final ChunkTasks tasks = this.chunkTasks.computeIfAbsent(CoordinateUtils.getChunkKey(pos), ChunkTasks::new);
++
++            if (tasks.changedSectionSet == null) {
++                tasks.changedSectionSet = new Boolean[this.manager.maxSection - this.manager.minSection + 1];
++            }
++            tasks.changedSectionSet[pos.getY() - this.manager.minSection] = Boolean.valueOf(newEmptyValue);
++
++            return tasks.onComplete;
++        }
++
++        public synchronized CompletableFuture<Void> queueChunkLighting(final ChunkPos pos, final Runnable lightTask) {
++            final ChunkTasks tasks = this.chunkTasks.computeIfAbsent(CoordinateUtils.getChunkKey(pos), ChunkTasks::new);
++            if (tasks.lightTasks == null) {
++                tasks.lightTasks = new ArrayList<>();
++            }
++            tasks.lightTasks.add(lightTask);
++
++            return tasks.onComplete;
++        }
++
++        public synchronized CompletableFuture<Void> queueChunkSkylightEdgeCheck(final SectionPos pos, final ShortCollection sections) {
++            final ChunkTasks tasks = this.chunkTasks.computeIfAbsent(CoordinateUtils.getChunkKey(pos), ChunkTasks::new);
++
++            ShortOpenHashSet queuedEdges = tasks.queuedEdgeChecksSky;
++            if (queuedEdges == null) {
++                queuedEdges = tasks.queuedEdgeChecksSky = new ShortOpenHashSet();
++            }
++            queuedEdges.addAll(sections);
++
++            return tasks.onComplete;
++        }
++
++        public synchronized CompletableFuture<Void> queueChunkBlocklightEdgeCheck(final SectionPos pos, final ShortCollection sections) {
++            final ChunkTasks tasks = this.chunkTasks.computeIfAbsent(CoordinateUtils.getChunkKey(pos), ChunkTasks::new);
++
++            ShortOpenHashSet queuedEdges = tasks.queuedEdgeChecksBlock;
++            if (queuedEdges == null) {
++                queuedEdges = tasks.queuedEdgeChecksBlock = new ShortOpenHashSet();
++            }
++            queuedEdges.addAll(sections);
++
++            return tasks.onComplete;
++        }
++
++        public void removeChunk(final ChunkPos pos) {
++            final ChunkTasks tasks;
++            synchronized (this) {
++                tasks = this.chunkTasks.remove(CoordinateUtils.getChunkKey(pos));
++            }
++            if (tasks != null) {
++                tasks.onComplete.complete(null);
++            }
++        }
++
++        public synchronized ChunkTasks removeFirstTask() {
++            if (this.chunkTasks.isEmpty()) {
++                return null;
++            }
++            return this.chunkTasks.removeFirst();
++        }
++
++        protected static final class ChunkTasks {
++
++            public final Set<BlockPos> changedPositions = new HashSet<>();
++            public Boolean[] changedSectionSet;
++            public ShortOpenHashSet queuedEdgeChecksSky;
++            public ShortOpenHashSet queuedEdgeChecksBlock;
++            public List<Runnable> lightTasks;
++
++            public final CompletableFuture<Void> onComplete = new CompletableFuture<>();
++
++            public final long chunkCoordinate;
++
++            public ChunkTasks(final long chunkCoordinate) {
++                this.chunkCoordinate = chunkCoordinate;
++            }
++        }
++    }
++}
+diff --git a/src/main/java/ca/spottedleaf/starlight/common/util/CoordinateUtils.java b/src/main/java/ca/spottedleaf/starlight/common/util/CoordinateUtils.java
+new file mode 100644
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
+--- /dev/null
++++ b/src/main/java/ca/spottedleaf/starlight/common/util/CoordinateUtils.java
+@@ -0,0 +0,0 @@
++package ca.spottedleaf.starlight.common.util;
++
++import net.minecraft.core.BlockPos;
++import net.minecraft.core.SectionPos;
++import net.minecraft.util.Mth;
++import net.minecraft.world.entity.Entity;
++import net.minecraft.world.level.ChunkPos;
++
++public final class CoordinateUtils {
++
++    // dx, dz are relative to the target chunk
++    // dx, dz in [-radius, radius]
++    public static int getNeighbourMappedIndex(final int dx, final int dz, final int radius) {
++        return (dx + radius) + (2 * radius + 1)*(dz + radius);
++    }
++
++    // the chunk keys are compatible with vanilla
++
++    public static long getChunkKey(final BlockPos pos) {
++        return ((long)(pos.getZ() >> 4) << 32) | ((pos.getX() >> 4) & 0xFFFFFFFFL);
++    }
++
++    public static long getChunkKey(final Entity entity) {
++        return ((long)(Mth.floor(entity.getZ()) >> 4) << 32) | ((Mth.floor(entity.getX()) >> 4) & 0xFFFFFFFFL);
++    }
++
++    public static long getChunkKey(final ChunkPos pos) {
++        return ((long)pos.z << 32) | (pos.x & 0xFFFFFFFFL);
++    }
++
++    public static long getChunkKey(final SectionPos pos) {
++        return ((long)pos.getZ() << 32) | (pos.getX() & 0xFFFFFFFFL);
++    }
++
++    public static long getChunkKey(final int x, final int z) {
++        return ((long)z << 32) | (x & 0xFFFFFFFFL);
++    }
++
++    public static int getChunkX(final long chunkKey) {
++        return (int)chunkKey;
++    }
++
++    public static int getChunkZ(final long chunkKey) {
++        return (int)(chunkKey >>> 32);
++    }
++
++    public static int getChunkCoordinate(final double blockCoordinate) {
++        return Mth.floor(blockCoordinate) >> 4;
++    }
++
++    // the section keys are compatible with vanilla's
++
++    static final int SECTION_X_BITS = 22;
++    static final long SECTION_X_MASK = (1L << SECTION_X_BITS) - 1;
++    static final int SECTION_Y_BITS = 20;
++    static final long SECTION_Y_MASK = (1L << SECTION_Y_BITS) - 1;
++    static final int SECTION_Z_BITS = 22;
++    static final long SECTION_Z_MASK = (1L << SECTION_Z_BITS) - 1;
++    // format is y,z,x (in order of LSB to MSB)
++    static final int SECTION_Y_SHIFT = 0;
++    static final int SECTION_Z_SHIFT = SECTION_Y_SHIFT + SECTION_Y_BITS;
++    static final int SECTION_X_SHIFT = SECTION_Z_SHIFT + SECTION_X_BITS;
++    static final int SECTION_TO_BLOCK_SHIFT = 4;
++
++    public static long getChunkSectionKey(final int x, final int y, final int z) {
++        return ((x & SECTION_X_MASK) << SECTION_X_SHIFT)
++                | ((y & SECTION_Y_MASK) << SECTION_Y_SHIFT)
++                | ((z & SECTION_Z_MASK) << SECTION_Z_SHIFT);
++    }
++
++    public static long getChunkSectionKey(final SectionPos pos) {
++        return ((pos.getX() & SECTION_X_MASK) << SECTION_X_SHIFT)
++                | ((pos.getY() & SECTION_Y_MASK) << SECTION_Y_SHIFT)
++                | ((pos.getZ() & SECTION_Z_MASK) << SECTION_Z_SHIFT);
++    }
++
++    public static long getChunkSectionKey(final ChunkPos pos, final int y) {
++        return ((pos.x & SECTION_X_MASK) << SECTION_X_SHIFT)
++                | ((y & SECTION_Y_MASK) << SECTION_Y_SHIFT)
++                | ((pos.z & SECTION_Z_MASK) << SECTION_Z_SHIFT);
++    }
++
++    public static long getChunkSectionKey(final BlockPos pos) {
++        return (((long)pos.getX() << (SECTION_X_SHIFT - SECTION_TO_BLOCK_SHIFT)) & (SECTION_X_MASK << SECTION_X_SHIFT)) |
++                ((pos.getY() >> SECTION_TO_BLOCK_SHIFT) & (SECTION_Y_MASK << SECTION_Y_SHIFT)) |
++                (((long)pos.getZ() << (SECTION_Z_SHIFT - SECTION_TO_BLOCK_SHIFT)) & (SECTION_Z_MASK << SECTION_Z_SHIFT));
++    }
++
++    public static long getChunkSectionKey(final Entity entity) {
++        return ((Mth.lfloor(entity.getX()) << (SECTION_X_SHIFT - SECTION_TO_BLOCK_SHIFT)) & (SECTION_X_MASK << SECTION_X_SHIFT)) |
++                ((Mth.lfloor(entity.getY()) >> SECTION_TO_BLOCK_SHIFT) & (SECTION_Y_MASK << SECTION_Y_SHIFT)) |
++                ((Mth.lfloor(entity.getZ()) << (SECTION_Z_SHIFT - SECTION_TO_BLOCK_SHIFT)) & (SECTION_Z_MASK << SECTION_Z_SHIFT));
++    }
++
++    public static int getChunkSectionX(final long key) {
++        return (int)(key << (Long.SIZE - (SECTION_X_SHIFT + SECTION_X_BITS)) >> (Long.SIZE - SECTION_X_BITS));
++    }
++
++    public static int getChunkSectionY(final long key) {
++        return (int)(key << (Long.SIZE - (SECTION_Y_SHIFT + SECTION_Y_BITS)) >> (Long.SIZE - SECTION_Y_BITS));
++    }
++
++    public static int getChunkSectionZ(final long key) {
++        return (int)(key << (Long.SIZE - (SECTION_Z_SHIFT + SECTION_Z_BITS)) >> (Long.SIZE - SECTION_Z_BITS));
++    }
++
++    // the block coordinates are not necessarily compatible with vanilla's
++
++    public static int getBlockCoordinate(final double blockCoordinate) {
++        return Mth.floor(blockCoordinate);
++    }
++
++    public static long getBlockKey(final int x, final int y, final int z) {
++        return ((long)x & 0x7FFFFFF) | (((long)z & 0x7FFFFFF) << 27) | ((long)y << 54);
++    }
++
++    public static long getBlockKey(final BlockPos pos) {
++        return ((long)pos.getX() & 0x7FFFFFF) | (((long)pos.getZ() & 0x7FFFFFF) << 27) | ((long)pos.getY() << 54);
++    }
++
++    public static long getBlockKey(final Entity entity) {
++        return ((long)entity.getX() & 0x7FFFFFF) | (((long)entity.getZ() & 0x7FFFFFF) << 27) | ((long)entity.getY() << 54);
++    }
++
++    private CoordinateUtils() {
++        throw new RuntimeException();
++    }
++}
+diff --git a/src/main/java/ca/spottedleaf/starlight/common/util/IntegerUtil.java b/src/main/java/ca/spottedleaf/starlight/common/util/IntegerUtil.java
+new file mode 100644
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
+--- /dev/null
++++ b/src/main/java/ca/spottedleaf/starlight/common/util/IntegerUtil.java
+@@ -0,0 +0,0 @@
++package ca.spottedleaf.starlight.common.util;
++
++public final class IntegerUtil {
++
++    public static final int HIGH_BIT_U32 = Integer.MIN_VALUE;
++    public static final long HIGH_BIT_U64 = Long.MIN_VALUE;
++
++    public static int ceilLog2(final int value) {
++        return Integer.SIZE - Integer.numberOfLeadingZeros(value - 1); // see doc of numberOfLeadingZeros
++    }
++
++    public static long ceilLog2(final long value) {
++        return Long.SIZE - Long.numberOfLeadingZeros(value - 1); // see doc of numberOfLeadingZeros
++    }
++
++    public static int floorLog2(final int value) {
++        // xor is optimized subtract for 2^n -1
++        // note that (2^n -1) - k = (2^n -1) ^ k for k <= (2^n - 1)
++        return (Integer.SIZE - 1) ^ Integer.numberOfLeadingZeros(value); // see doc of numberOfLeadingZeros
++    }
++
++    public static int floorLog2(final long value) {
++        // xor is optimized subtract for 2^n -1
++        // note that (2^n -1) - k = (2^n -1) ^ k for k <= (2^n - 1)
++        return (Long.SIZE - 1) ^ Long.numberOfLeadingZeros(value); // see doc of numberOfLeadingZeros
++    }
++
++    public static int roundCeilLog2(final int value) {
++        // optimized variant of 1 << (32 - leading(val - 1))
++        // given
++        // 1 << n = HIGH_BIT_32 >>> (31 - n) for n [0, 32)
++        // 1 << (32 - leading(val - 1)) = HIGH_BIT_32 >>> (31 - (32 - leading(val - 1)))
++        // HIGH_BIT_32 >>> (31 - (32 - leading(val - 1)))
++        // HIGH_BIT_32 >>> (31 - 32 + leading(val - 1))
++        // HIGH_BIT_32 >>> (-1 + leading(val - 1))
++        return HIGH_BIT_U32 >>> (Integer.numberOfLeadingZeros(value - 1) - 1);
++    }
++
++    public static long roundCeilLog2(final long value) {
++        // see logic documented above
++        return HIGH_BIT_U64 >>> (Long.numberOfLeadingZeros(value - 1) - 1);
++    }
++
++    public static int roundFloorLog2(final int value) {
++        // optimized variant of 1 << (31 - leading(val))
++        // given
++        // 1 << n = HIGH_BIT_32 >>> (31 - n) for n [0, 32)
++        // 1 << (31 - leading(val)) = HIGH_BIT_32 >> (31 - (31 - leading(val)))
++        // HIGH_BIT_32 >> (31 - (31 - leading(val)))
++        // HIGH_BIT_32 >> (31 - 31 + leading(val))
++        return HIGH_BIT_U32 >>> Integer.numberOfLeadingZeros(value);
++    }
++
++    public static long roundFloorLog2(final long value) {
++        // see logic documented above
++        return HIGH_BIT_U64 >>> Long.numberOfLeadingZeros(value);
++    }
++
++    public static boolean isPowerOfTwo(final int n) {
++        // 2^n has one bit
++        // note: this rets true for 0 still
++        return IntegerUtil.getTrailingBit(n) == n;
++    }
++
++    public static boolean isPowerOfTwo(final long n) {
++        // 2^n has one bit
++        // note: this rets true for 0 still
++        return IntegerUtil.getTrailingBit(n) == n;
++    }
++
++    public static int getTrailingBit(final int n) {
++        return -n & n;
++    }
++
++    public static long getTrailingBit(final long n) {
++        return -n & n;
++    }
++
++    public static int trailingZeros(final int n) {
++        return Integer.numberOfTrailingZeros(n);
++    }
++
++    public static int trailingZeros(final long n) {
++        return Long.numberOfTrailingZeros(n);
++    }
++
++    // from hacker's delight (signed division magic value)
++    public static int getDivisorMultiple(final long numbers) {
++        return (int)(numbers >>> 32);
++    }
++
++    // from hacker's delight (signed division magic value)
++    public static int getDivisorShift(final long numbers) {
++        return (int)numbers;
++    }
++
++    // copied from hacker's delight (signed division magic value)
++    // http://www.hackersdelight.org/hdcodetxt/magic.c.txt
++    public static long getDivisorNumbers(final int d) {
++        final int ad = IntegerUtil.branchlessAbs(d);
++
++        if (ad < 2) {
++            throw new IllegalArgumentException("|number| must be in [2, 2^31 -1], not: " + d);
++        }
++
++        final int two31 = 0x80000000;
++        final long mask = 0xFFFFFFFFL; // mask for enforcing unsigned behaviour
++
++        int p = 31;
++
++        // all these variables are UNSIGNED!
++        int t = two31 + (d >>> 31);
++        int anc = t - 1 - t%ad;
++        int q1 = (int)((two31 & mask)/(anc & mask));
++        int r1 = two31 - q1*anc;
++        int q2 = (int)((two31 & mask)/(ad & mask));
++        int r2 = two31 - q2*ad;
++        int delta;
++
++        do {
++            p = p + 1;
++            q1 = 2*q1;                        // Update q1 = 2**p/|nc|.
++            r1 = 2*r1;                        // Update r1 = rem(2**p, |nc|).
++            if ((r1 & mask) >= (anc & mask)) {// (Must be an unsigned comparison here)
++                q1 = q1 + 1;
++                r1 = r1 - anc;
++            }
++            q2 = 2*q2;                       // Update q2 = 2**p/|d|.
++            r2 = 2*r2;                       // Update r2 = rem(2**p, |d|).
++            if ((r2 & mask) >= (ad & mask)) {// (Must be an unsigned comparison here)
++                q2 = q2 + 1;
++                r2 = r2 - ad;
++            }
++            delta = ad - r2;
++        } while ((q1 & mask) < (delta & mask) || (q1 == delta && r1 == 0));
++
++        int magicNum = q2 + 1;
++        if (d < 0) {
++            magicNum = -magicNum;
++        }
++        int shift = p - 32;
++        return ((long)magicNum << 32) | shift;
++    }
++
++    public static int branchlessAbs(final int val) {
++        // -n = -1 ^ n + 1
++        final int mask = val >> (Integer.SIZE - 1); // -1 if < 0, 0 if >= 0
++        return (mask ^ val) - mask; // if val < 0, then (0 ^ val) - 0 else (-1 ^ val) + 1
++    }
++
++    public static long branchlessAbs(final long val) {
++        // -n = -1 ^ n + 1
++        final long mask = val >> (Long.SIZE - 1); // -1 if < 0, 0 if >= 0
++        return (mask ^ val) - mask; // if val < 0, then (0 ^ val) - 0 else (-1 ^ val) + 1
++    }
++
++    //https://github.com/skeeto/hash-prospector for hash functions
++
++    //score = ~590.47984224483832
++    public static int hash0(int x) {
++        x *= 0x36935555;
++        x ^= x >>> 16;
++        return x;
++    }
++
++    //score = ~310.01596637036749
++    public static int hash1(int x) {
++        x ^= x >>> 15;
++        x *= 0x356aaaad;
++        x ^= x >>> 17;
++        return x;
++    }
++
++    public static int hash2(int x) {
++        x ^= x >>> 16;
++        x *= 0x7feb352d;
++        x ^= x >>> 15;
++        x *= 0x846ca68b;
++        x ^= x >>> 16;
++        return x;
++    }
++
++    public static int hash3(int x) {
++        x ^= x >>> 17;
++        x *= 0xed5ad4bb;
++        x ^= x >>> 11;
++        x *= 0xac4c1b51;
++        x ^= x >>> 15;
++        x *= 0x31848bab;
++        x ^= x >>> 14;
++        return x;
++    }
++
++    //score = ~365.79959673201887
++    public static long hash1(long x) {
++        x ^= x >>> 27;
++        x *= 0xb24924b71d2d354bL;
++        x ^= x >>> 28;
++        return x;
++    }
++
++    //h2 hash
++    public static long hash2(long x) {
++        x ^= x >>> 32;
++        x *= 0xd6e8feb86659fd93L;
++        x ^= x >>> 32;
++        x *= 0xd6e8feb86659fd93L;
++        x ^= x >>> 32;
++        return x;
++    }
++
++    public static long hash3(long x) {
++        x ^= x >>> 45;
++        x *= 0xc161abe5704b6c79L;
++        x ^= x >>> 41;
++        x *= 0xe3e5389aedbc90f7L;
++        x ^= x >>> 56;
++        x *= 0x1f9aba75a52db073L;
++        x ^= x >>> 53;
++        return x;
++    }
++
++    private IntegerUtil() {
++        throw new RuntimeException();
++    }
++}
+diff --git a/src/main/java/ca/spottedleaf/starlight/common/util/SaveUtil.java b/src/main/java/ca/spottedleaf/starlight/common/util/SaveUtil.java
+new file mode 100644
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
+--- /dev/null
++++ b/src/main/java/ca/spottedleaf/starlight/common/util/SaveUtil.java
+@@ -0,0 +0,0 @@
++package ca.spottedleaf.starlight.common.util;
++
++import ca.spottedleaf.starlight.common.light.SWMRNibbleArray;
++import ca.spottedleaf.starlight.common.light.StarLightEngine;
++import net.minecraft.nbt.CompoundTag;
++import net.minecraft.nbt.ListTag;
++import net.minecraft.server.level.ServerLevel;
++import net.minecraft.world.level.ChunkPos;
++import net.minecraft.world.level.Level;
++import net.minecraft.world.level.chunk.ChunkAccess;
++import net.minecraft.world.level.chunk.ChunkStatus;
++import org.apache.logging.log4j.LogManager;
++import org.apache.logging.log4j.Logger;
++
++public final class SaveUtil {
++
++    private static final Logger LOGGER = LogManager.getLogger();
++
++    private static final int STARLIGHT_LIGHT_VERSION = 6;
++
++    private static final String BLOCKLIGHT_STATE_TAG = "starlight.blocklight_state";
++    private static final String SKYLIGHT_STATE_TAG = "starlight.skylight_state";
++    private static final String STARLIGHT_VERSION_TAG = "starlight.light_version";
++
++    public static void saveLightHook(final Level world, final ChunkAccess chunk, final CompoundTag nbt) {
++        try {
++            saveLightHookReal(world, chunk, nbt);
++        } catch (final Exception ex) {
++            // failing to inject is not fatal so we catch anything here. if it fails, it will have correctly set lit to false
++            // for Vanilla to relight on load and it will not set our lit tag so we will relight on load
++            LOGGER.warn("Failed to inject light data into save data for chunk " + chunk.getPos() + ", chunk light will be recalculated on its next load", ex);
++        }
++    }
++
++    private static void saveLightHookReal(final Level world, final ChunkAccess chunk, final CompoundTag nbt) {
++        if (nbt == null) {
++            return;
++        }
++
++        final int minSection = WorldUtil.getMinLightSection(world);
++        final int maxSection = WorldUtil.getMaxLightSection(world);
++
++        SWMRNibbleArray[] blockNibbles = chunk.getBlockNibbles();
++        SWMRNibbleArray[] skyNibbles = chunk.getSkyNibbles();
++
++        CompoundTag level = nbt.getCompound("Level");
++        boolean lit = chunk.isLightCorrect() || !(world instanceof ServerLevel);
++        // diff start - store our tag for whether light data is init'd
++        if (lit) {
++            level.putBoolean("isLightOn", false);
++        }
++        // diff end - store our tag for whether light data is init'd
++        ChunkStatus status = ChunkStatus.byName(level.getString("Status"));
++
++        CompoundTag[] sections = new CompoundTag[maxSection - minSection + 1];
++
++        ListTag sectionsStored = level.getList("Sections", 10);
++
++        for (int i = 0; i < sectionsStored.size(); ++i) {
++            CompoundTag sectionStored = sectionsStored.getCompound(i);
++            int k = sectionStored.getByte("Y");
++
++            // strip light data
++            sectionStored.remove("BlockLight");
++            sectionStored.remove("SkyLight");
++
++            if (!sectionStored.isEmpty()) {
++                sections[k - minSection] = sectionStored;
++            }
++        }
++
++        if (lit && status.isOrAfter(ChunkStatus.LIGHT)) {
++            for (int i = minSection; i <= maxSection; ++i) {
++                SWMRNibbleArray.SaveState blockNibble = blockNibbles[i - minSection].getSaveState();
++                SWMRNibbleArray.SaveState skyNibble = skyNibbles[i - minSection].getSaveState();
++                if (blockNibble != null || skyNibble != null) {
++                    CompoundTag section = sections[i - minSection];
++                    if (section == null) {
++                        section = new CompoundTag();
++                        section.putByte("Y", (byte)i);
++                        sections[i - minSection] = section;
++                    }
++
++                    // we store under the same key so mod programs editing nbt
++                    // can still read the data, hopefully.
++                    // however, for compatibility we store chunks as unlit so vanilla
++                    // is forced to re-light them if it encounters our data. It's too much of a burden
++                    // to try and maintain compatibility with a broken and inferior skylight management system.
++
++                    if (blockNibble != null) {
++                        if (blockNibble.data != null) {
++                            section.putByteArray("BlockLight", blockNibble.data);
++                        }
++                        section.putInt(BLOCKLIGHT_STATE_TAG, blockNibble.state);
++                    }
++
++                    if (skyNibble != null) {
++                        if (skyNibble.data != null) {
++                            section.putByteArray("SkyLight", skyNibble.data);
++                        }
++                        section.putInt(SKYLIGHT_STATE_TAG, skyNibble.state);
++                    }
++                }
++            }
++        }
++
++        // rewrite section list
++        sectionsStored.clear();
++        for (CompoundTag section : sections) {
++            if (section != null) {
++                sectionsStored.add(section);
++            }
++        }
++        level.put("Sections", sectionsStored);
++        if (lit) {
++            level.putInt(STARLIGHT_VERSION_TAG, STARLIGHT_LIGHT_VERSION); // only mark as fully lit after we have successfully injected our data
++        }
++    }
++
++    public static void loadLightHook(final Level world, final ChunkPos pos, final CompoundTag tag, final ChunkAccess into) {
++        try {
++            loadLightHookReal(world, pos, tag, into);
++        } catch (final Exception ex) {
++            // failing to inject is not fatal so we catch anything here. if it fails, then we simply relight. Not a problem, we get correct
++            // lighting in both cases.
++            LOGGER.warn("Failed to load light for chunk " + pos + ", light will be recalculated", ex);
++        }
++    }
++
++    private static void loadLightHookReal(final Level world, final ChunkPos pos, final CompoundTag tag, final ChunkAccess into) {
++        if (into == null) {
++            return;
++        }
++        final int minSection = WorldUtil.getMinLightSection(world);
++        final int maxSection = WorldUtil.getMaxLightSection(world);
++
++        into.setLightCorrect(false); // mark as unlit in case we fail parsing
++
++        SWMRNibbleArray[] blockNibbles = StarLightEngine.getFilledEmptyLight(world);
++        SWMRNibbleArray[] skyNibbles = StarLightEngine.getFilledEmptyLight(world);
++
++
++        // start copy from from the original method
++        CompoundTag levelTag = tag.getCompound("Level");
++        boolean lit = levelTag.get("isLightOn") != null && levelTag.getInt(STARLIGHT_VERSION_TAG) == STARLIGHT_LIGHT_VERSION;
++        boolean canReadSky = world.dimensionType().hasSkyLight();
++        ChunkStatus status = ChunkStatus.byName(tag.getCompound("Level").getString("Status"));
++        if (lit && status.isOrAfter(ChunkStatus.LIGHT)) { // diff - we add the status check here
++            ListTag sections = levelTag.getList("Sections", 10);
++
++            for (int i = 0; i < sections.size(); ++i) {
++                CompoundTag sectionData = sections.getCompound(i);
++                int y = sectionData.getByte("Y");
++
++                if (sectionData.contains("BlockLight", 7)) {
++                    // this is where our diff is
++                    blockNibbles[y - minSection] = new SWMRNibbleArray(sectionData.getByteArray("BlockLight").clone(), sectionData.getInt(BLOCKLIGHT_STATE_TAG)); // clone for data safety
++                } else {
++                    blockNibbles[y - minSection] = new SWMRNibbleArray(null, sectionData.getInt(BLOCKLIGHT_STATE_TAG));
++                }
++
++                if (canReadSky) {
++                    if (sectionData.contains("SkyLight", 7)) {
++                        // we store under the same key so mod programs editing nbt
++                        // can still read the data, hopefully.
++                        // however, for compatibility we store chunks as unlit so vanilla
++                        // is forced to re-light them if it encounters our data. It's too much of a burden
++                        // to try and maintain compatibility with a broken and inferior skylight management system.
++                        skyNibbles[y - minSection] = new SWMRNibbleArray(sectionData.getByteArray("SkyLight").clone(), sectionData.getInt(SKYLIGHT_STATE_TAG)); // clone for data safety
++                    } else {
++                        skyNibbles[y - minSection] = new SWMRNibbleArray(null, sectionData.getInt(SKYLIGHT_STATE_TAG));
++                    }
++                }
++            }
++        }
++        // end copy from vanilla
++
++        into.setBlockNibbles(blockNibbles);
++        into.setSkyNibbles(skyNibbles);
++        into.setLightCorrect(lit); // now we set lit here, only after we've correctly parsed data
++    }
++
++    private SaveUtil() {}
++
++}
+diff --git a/src/main/java/ca/spottedleaf/starlight/common/util/WorldUtil.java b/src/main/java/ca/spottedleaf/starlight/common/util/WorldUtil.java
+new file mode 100644
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
+--- /dev/null
++++ b/src/main/java/ca/spottedleaf/starlight/common/util/WorldUtil.java
+@@ -0,0 +0,0 @@
++package ca.spottedleaf.starlight.common.util;
++
++import net.minecraft.world.level.LevelHeightAccessor;
++
++public final class WorldUtil {
++
++    // min, max are inclusive
++
++    public static int getMaxSection(final LevelHeightAccessor world) {
++        return world.getMaxSection() - 1; // getMaxSection() is exclusive
++    }
++
++    public static int getMinSection(final LevelHeightAccessor world) {
++        return world.getMinSection();
++    }
++
++    public static int getMaxLightSection(final LevelHeightAccessor world) {
++        return getMaxSection(world) + 1;
++    }
++
++    public static int getMinLightSection(final LevelHeightAccessor world) {
++        return getMinSection(world) - 1;
++    }
++
++
++
++    public static int getTotalSections(final LevelHeightAccessor world) {
++        return getMaxSection(world) - getMinSection(world) + 1;
++    }
++
++    public static int getTotalLightSections(final LevelHeightAccessor world) {
++        return getMaxLightSection(world) - getMinLightSection(world) + 1;
++    }
++
++    public static int getMinBlockY(final LevelHeightAccessor world) {
++        return getMinSection(world) << 4;
++    }
++
++    public static int getMaxBlockY(final LevelHeightAccessor world) {
++        return (getMaxSection(world) << 4) | 15;
++    }
++
++    private WorldUtil() {
++        throw new RuntimeException();
++    }
++
++}
+diff --git a/src/main/java/com/destroystokyo/paper/PaperCommand.java b/src/main/java/com/destroystokyo/paper/PaperCommand.java
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
+--- a/src/main/java/com/destroystokyo/paper/PaperCommand.java
++++ b/src/main/java/com/destroystokyo/paper/PaperCommand.java
+@@ -0,0 +0,0 @@ public class PaperCommand extends Command {
+         }
+     }
+ 
++    // Paper start - rewrite light engine
++    private void starlightFixLight(ServerPlayer sender, ServerLevel world, ThreadedLevelLightEngine lightengine, int radius) {
++        long start = System.nanoTime();
++        java.util.LinkedHashSet<ChunkPos> chunks = new java.util.LinkedHashSet<>(MCUtil.getSpiralOutChunks(sender.blockPosition(), radius)); // getChunkCoordinates is actually just bad mappings, this function rets position as blockpos
++
++        int[] pending = new int[1];
++        for (java.util.Iterator<ChunkPos> iterator = chunks.iterator(); iterator.hasNext();) {
++            final ChunkPos chunkPos = iterator.next();
++
++            final net.minecraft.world.level.chunk.ChunkAccess chunk = world.getChunkSource().getChunkAtImmediately(chunkPos.x, chunkPos.z);
++            if (chunk == null || !chunk.isLightCorrect() || !chunk.getStatus().isOrAfter(net.minecraft.world.level.chunk.ChunkStatus.LIGHT)) {
++                // cannot relight this chunk
++                iterator.remove();
++                continue;
++            }
++
++            ++pending[0];
++        }
++
++        int[] relitChunks = new int[1];
++        lightengine.relight(chunks,
++                (ChunkPos chunkPos) -> {
++                    ++relitChunks[0];
++                    sender.getBukkitEntity().sendMessage(
++                            ChatColor.BLUE + "Relit chunk " + ChatColor.DARK_AQUA + chunkPos + ChatColor.BLUE +
++                                    ", progress: " + ChatColor.DARK_AQUA + (int)(Math.round(100.0 * (double)(relitChunks[0])/(double)pending[0])) + "%"
++                    );
++                },
++                (int totalRelit) -> {
++                    final long end = System.nanoTime();
++                    final long diff = Math.round(1.0e-6*(end - start));
++                    sender.getBukkitEntity().sendMessage(
++                            ChatColor.BLUE + "Relit " + ChatColor.DARK_AQUA + totalRelit + ChatColor.BLUE + " chunks. Took " +
++                                    ChatColor.DARK_AQUA + diff + "ms"
++                    );
++                });
++        sender.getBukkitEntity().sendMessage(ChatColor.BLUE + "Relighting " + ChatColor.DARK_AQUA + pending[0] + ChatColor.BLUE + " chunks");
++    }
++    // Paper end - rewrite light engine
++
+     private void doFixLight(CommandSender sender, String[] args) {
+         if (!(sender instanceof Player)) {
+             sender.sendMessage("Only players can use this command");
+@@ -0,0 +0,0 @@ public class PaperCommand extends Command {
+         int radius = 2;
+         if (args.length > 1) {
+             try {
+-                radius = Math.min(5, Integer.parseInt(args[1]));
++                radius = Math.min(32, Integer.parseInt(args[1])); // Paper - MOOOOOORE
+             } catch (Exception e) {
+                 sender.sendMessage("Not a number");
+                 return;
+@@ -0,0 +0,0 @@ public class PaperCommand extends Command {
+         ServerLevel world = (ServerLevel) handle.level;
+         ThreadedLevelLightEngine lightengine = world.getChunkSource().getLightEngine();
+ 
++        // Paper start - rewrite light engine
++        if (true) {
++            this.starlightFixLight(handle, world, lightengine, radius);
++            return;
++        }
++        // Paper end - rewrite light engine
++
+         net.minecraft.core.BlockPos center = MCUtil.toBlockPosition(player.getLocation());
+         Deque<ChunkPos> queue = new ArrayDeque<>(MCUtil.getSpiralOutChunks(center, radius));
+         updateLight(sender, world, lightengine, queue);
+diff --git a/src/main/java/net/minecraft/server/level/ChunkHolder.java b/src/main/java/net/minecraft/server/level/ChunkHolder.java
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
+--- a/src/main/java/net/minecraft/server/level/ChunkHolder.java
++++ b/src/main/java/net/minecraft/server/level/ChunkHolder.java
+@@ -0,0 +0,0 @@ public class ChunkHolder {
+     private volatile CompletableFuture<Either<LevelChunk, ChunkHolder.ChunkLoadingFailure>> fullChunkFuture; private int fullChunkCreateCount; private volatile boolean isFullChunkReady; // Paper - cache chunk ticking stage
+     private volatile CompletableFuture<Either<LevelChunk, ChunkHolder.ChunkLoadingFailure>> tickingChunkFuture; private volatile boolean isTickingReady; // Paper - cache chunk ticking stage
+     private volatile CompletableFuture<Either<LevelChunk, ChunkHolder.ChunkLoadingFailure>> entityTickingChunkFuture; private volatile boolean isEntityTickingReady; // Paper - cache chunk ticking stage
+-    private CompletableFuture<ChunkAccess> chunkToSave;
++    public CompletableFuture<ChunkAccess> chunkToSave;  // Paper - public
+     @Nullable
+     private final DebugBuffer<ChunkHolder.ChunkSaveDebug> chunkToSaveHistory;
+     public int oldTicketLevel;
+diff --git a/src/main/java/net/minecraft/server/level/ChunkMap.java b/src/main/java/net/minecraft/server/level/ChunkMap.java
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
+--- a/src/main/java/net/minecraft/server/level/ChunkMap.java
++++ b/src/main/java/net/minecraft/server/level/ChunkMap.java
+@@ -0,0 +0,0 @@ public class ChunkMap extends ChunkStorage implements ChunkHolder.PlayerProvider
+     public final LongSet entitiesInLevel;
+     public final ServerLevel level;
+     private final ThreadedLevelLightEngine lightEngine;
+-    private final BlockableEventLoop<Runnable> mainThreadExecutor;
++    public final BlockableEventLoop<Runnable> mainThreadExecutor; // Paper - public
+     final java.util.concurrent.Executor mainInvokingExecutor; // Paper
+     public ChunkGenerator generator;
+     public final Supplier<DimensionDataStorage> overworldDataStorage;
+diff --git a/src/main/java/net/minecraft/server/level/ThreadedLevelLightEngine.java b/src/main/java/net/minecraft/server/level/ThreadedLevelLightEngine.java
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
+--- a/src/main/java/net/minecraft/server/level/ThreadedLevelLightEngine.java
++++ b/src/main/java/net/minecraft/server/level/ThreadedLevelLightEngine.java
+@@ -0,0 +0,0 @@ import net.minecraft.world.level.lighting.LevelLightEngine;
+ import org.apache.logging.log4j.LogManager;
+ import org.apache.logging.log4j.Logger;
+ 
++// Paper start
++import ca.spottedleaf.starlight.common.light.StarLightEngine;
++import io.papermc.paper.util.CoordinateUtils;
++import java.util.function.Supplier;
++import net.minecraft.world.level.lighting.LayerLightEventListener;
++import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap;
++import it.unimi.dsi.fastutil.longs.LongArrayList;
++import it.unimi.dsi.fastutil.longs.LongIterator;
++import net.minecraft.world.level.chunk.ChunkStatus;
++// Paper end
++
+ public class ThreadedLevelLightEngine extends LevelLightEngine implements AutoCloseable {
+     private static final Logger LOGGER = LogManager.getLogger();
+     private final ProcessorMailbox<Runnable> taskMailbox;
+@@ -0,0 +0,0 @@ public class ThreadedLevelLightEngine extends LevelLightEngine implements AutoCl
+     private volatile int taskPerBatch = 5;
+     private final AtomicBoolean scheduled = new AtomicBoolean();
+ 
++    // Paper start - replace light engine impl
++    protected final ca.spottedleaf.starlight.common.light.StarLightInterface theLightEngine;
++    public final boolean hasBlockLight;
++    public final boolean hasSkyLight;
++    // Paper end - replace light engine impl
++
+     public ThreadedLevelLightEngine(LightChunkGetter chunkProvider, ChunkMap chunkStorage, boolean hasBlockLight, ProcessorMailbox<Runnable> processor, ProcessorHandle<ChunkTaskPriorityQueueSorter.Message<Runnable>> executor) {
+-        super(chunkProvider, true, hasBlockLight);
++        super(chunkProvider, false, false); // Paper - destroy vanilla light engine state
+         this.chunkMap = chunkStorage; this.playerChunkMap = chunkMap; // Paper
+         this.sorterMailbox = executor;
+         this.taskMailbox = processor;
++        // Paper start - replace light engine impl
++        this.hasBlockLight = true;
++        this.hasSkyLight = hasBlockLight; // Nice variable name.
++        this.theLightEngine = new ca.spottedleaf.starlight.common.light.StarLightInterface(chunkProvider, this.hasSkyLight, this.hasBlockLight, this);
++        // Paper end - replace light engine impl
++    }
++
++// Paper start - replace light engine impl
++    protected final ChunkAccess getChunk(final int chunkX, final int chunkZ) {
++        return ((ServerLevel)this.theLightEngine.getWorld()).getChunkSource().getChunkAtImmediately(chunkX, chunkZ);
++    }
++
++    protected long relightCounter;
++
++    public int relight(java.util.Set<ChunkPos> chunks_param,
++                        java.util.function.Consumer<ChunkPos> chunkLightCallback,
++                        java.util.function.IntConsumer onComplete) {
++        if (!org.bukkit.Bukkit.isPrimaryThread()) {
++            throw new IllegalStateException("Must only be called on the main thread");
++        }
++
++        java.util.Set<ChunkPos> chunks = new java.util.LinkedHashSet<>(chunks_param);
++        // add tickets
++        java.util.Map<ChunkPos, Long> ticketIds = new java.util.HashMap<>();
++        int totalChunks = 0;
++        for (java.util.Iterator<ChunkPos> iterator = chunks.iterator(); iterator.hasNext();) {
++            final ChunkPos chunkPos = iterator.next();
++
++            final ChunkAccess chunk = ((ServerLevel)this.theLightEngine.getWorld()).getChunkSource().getChunkAtImmediately(chunkPos.x, chunkPos.z);
++            if (chunk == null || !chunk.isLightCorrect() || !chunk.getStatus().isOrAfter(ChunkStatus.LIGHT)) {
++                // cannot relight this chunk
++                iterator.remove();
++                continue;
++            }
++
++            final Long id = Long.valueOf(this.relightCounter++);
++
++            ((ServerLevel)this.theLightEngine.getWorld()).getChunkSource().addTicketAtLevel(TicketType.CHUNK_RELIGHT, chunkPos, net.minecraft.server.MCUtil.getTicketLevelFor(ChunkStatus.LIGHT), id);
++            ticketIds.put(chunkPos, id);
++
++            ++totalChunks;
++        }
++
++        this.taskMailbox.tell(() -> {
++            this.theLightEngine.relightChunks(chunks, (ChunkPos chunkPos) -> {
++                chunkLightCallback.accept(chunkPos);
++                ((java.util.concurrent.Executor)((ServerLevel)this.theLightEngine.getWorld()).getChunkSource().mainThreadProcessor).execute(() -> {
++                    ((ServerLevel)this.theLightEngine.getWorld()).getChunkSource().chunkMap.getUpdatingChunkIfPresent(chunkPos.toLong()).broadcast(new net.minecraft.network.protocol.game.ClientboundLightUpdatePacket(chunkPos, ThreadedLevelLightEngine.this, null, null, true), false);
++                    ((ServerLevel)this.theLightEngine.getWorld()).getChunkSource().removeTicketAtLevel(TicketType.CHUNK_RELIGHT, chunkPos, net.minecraft.server.MCUtil.getTicketLevelFor(ChunkStatus.LIGHT), ticketIds.get(chunkPos));
++                });
++            }, onComplete);
++        });
++        this.tryScheduleUpdate();
++
++        return totalChunks;
++    }
++
++    private final Long2IntOpenHashMap chunksBeingWorkedOn = new Long2IntOpenHashMap();
++
++    private void queueTaskForSection(final int chunkX, final int chunkY, final int chunkZ, final Supplier<CompletableFuture<Void>> runnable) {
++        final ServerLevel world = (ServerLevel)this.theLightEngine.getWorld();
++
++        final ChunkAccess center = this.theLightEngine.getAnyChunkNow(chunkX, chunkZ);
++        if (center == null || !center.getStatus().isOrAfter(ChunkStatus.LIGHT)) {
++            // do not accept updates in unlit chunks, unless we might be generating a chunk. thanks to the amazing
++            // chunk scheduling, we could be lighting and generating a chunk at the same time
++            return;
++        }
++
++        if (center.getStatus() != ChunkStatus.FULL) {
++            // do not keep chunk loaded, we are probably in a gen thread
++            // if we proceed to add a ticket the chunk will be loaded, which is not what we want (avoid cascading gen)
++            runnable.get();
++            return;
++        }
++
++        if (!world.getChunkSource().chunkMap.mainThreadExecutor.isSameThread()) {
++            // ticket logic is not safe to run off-main, re-schedule
++            world.getChunkSource().chunkMap.mainThreadExecutor.execute(() -> {
++                this.queueTaskForSection(chunkX, chunkY, chunkZ, runnable);
++            });
++            return;
++        }
++
++        final long key = CoordinateUtils.getChunkKey(chunkX, chunkZ);
++
++        final CompletableFuture<Void> updateFuture = runnable.get();
++
++        if (updateFuture == null) {
++            // not scheduled
++            return;
++        }
++
++        final int references = this.chunksBeingWorkedOn.addTo(key, 1);
++        if (references == 0) {
++            final ChunkPos pos = new ChunkPos(chunkX, chunkZ);
++            world.getChunkSource().addRegionTicket(ca.spottedleaf.starlight.common.light.StarLightInterface.CHUNK_WORK_TICKET, pos, 0, pos);
++        }
++
++        // append future to this chunk and 1 radius neighbours chunk save futures
++        // this prevents us from saving the world without first waiting for the light engine
++
++        for (int dx = -1; dx <= 1; ++dx) {
++            for (int dz = -1; dz <= 1; ++dz) {
++                ChunkHolder neighbour = world.getChunkSource().chunkMap.getUpdatingChunkIfPresent(CoordinateUtils.getChunkKey(dx + chunkX, dz + chunkZ));
++                if (neighbour != null) {
++                    neighbour.chunkToSave = neighbour.chunkToSave.thenCombine(updateFuture, (final ChunkAccess curr, final Void ignore) -> {
++                        return curr;
++                    });
++                }
++            }
++        }
++
++        updateFuture.thenAcceptAsync((final Void ignore) -> {
++            final int newReferences = this.chunksBeingWorkedOn.get(key);
++            if (newReferences == 1) {
++                this.chunksBeingWorkedOn.remove(key);
++                final ChunkPos pos = new ChunkPos(chunkX, chunkZ);
++                world.getChunkSource().removeRegionTicket(ca.spottedleaf.starlight.common.light.StarLightInterface.CHUNK_WORK_TICKET, pos, 0, pos);
++            } else {
++                this.chunksBeingWorkedOn.put(key, newReferences - 1);
++            }
++        }, world.getChunkSource().chunkMap.mainThreadExecutor).whenComplete((final Void ignore, final Throwable thr) -> {
++            if (thr != null) {
++                LOGGER.fatal("Failed to remove ticket level for post chunk task " + new ChunkPos(chunkX, chunkZ), thr);
++            }
++        });
++    }
++
++    @Override
++    public boolean hasLightWork() {
++        // route to new light engine
++        return this.theLightEngine.hasUpdates() || !this.queue.isEmpty();
+     }
+ 
++    @Override
++    public LayerLightEventListener getLayerListener(final LightLayer lightType) {
++        return lightType == LightLayer.BLOCK ? this.theLightEngine.getBlockReader() : this.theLightEngine.getSkyReader();
++    }
++
++    @Override
++    public int getRawBrightness(final BlockPos pos, final int ambientDarkness) {
++        // need to use new light hooks for this
++        final int sky = this.theLightEngine.getSkyReader().getLightValue(pos) - ambientDarkness;
++        final int block = this.theLightEngine.getBlockReader().getLightValue(pos);
++        return Math.max(sky, block);
++    }
++    // Paper end - replace light engine imp
++
+     @Override
+     public void close() {
+     }
+@@ -0,0 +0,0 @@ public class ThreadedLevelLightEngine extends LevelLightEngine implements AutoCl
+ 
+     @Override
+     public void checkBlock(BlockPos pos) {
+-        BlockPos blockPos = pos.immutable();
+-        this.addTask(SectionPos.blockToSectionCoord(pos.getX()), SectionPos.blockToSectionCoord(pos.getZ()), ThreadedLevelLightEngine.TaskType.POST_UPDATE, Util.name(() -> {
+-            super.checkBlock(blockPos);
+-        }, () -> {
+-            return "checkBlock " + blockPos;
+-        }));
++        // Paper start - replace light engine impl
++        final BlockPos posCopy = pos.immutable();
++        this.queueTaskForSection(posCopy.getX() >> 4, posCopy.getY() >> 4, posCopy.getZ() >> 4, () -> {
++            return this.theLightEngine.blockChange(posCopy);
++        });
++        // Paper end - replace light engine impl
+     }
+ 
+     protected void updateChunkStatus(ChunkPos pos) {
++        if (true) return; // Paper - replace light engine impl
+         this.addTask(pos.x, pos.z, () -> {
+             return 0;
+         }, ThreadedLevelLightEngine.TaskType.PRE_UPDATE, Util.name(() -> {
+@@ -0,0 +0,0 @@ public class ThreadedLevelLightEngine extends LevelLightEngine implements AutoCl
+ 
+     @Override
+     public void updateSectionStatus(SectionPos pos, boolean notReady) {
+-        this.addTask(pos.x(), pos.z(), () -> {
+-            return 0;
+-        }, ThreadedLevelLightEngine.TaskType.PRE_UPDATE, Util.name(() -> {
+-            super.updateSectionStatus(pos, notReady);
+-        }, () -> {
+-            return "updateSectionStatus " + pos + " " + notReady;
+-        }));
++        // Paper start - replace light engine impl
++        this.queueTaskForSection(pos.getX(), pos.getY(), pos.getZ(), () -> {
++            return this.theLightEngine.sectionChange(pos, notReady);
++        });
++        // Paper end - replace light engine impl
+     }
+ 
+     @Override
+     public void enableLightSources(ChunkPos pos, boolean retainData) {
++        if (true) return; // Paper - replace light engine impl
+         this.addTask(pos.x, pos.z, ThreadedLevelLightEngine.TaskType.PRE_UPDATE, Util.name(() -> {
+             super.enableLightSources(pos, retainData);
+         }, () -> {
+@@ -0,0 +0,0 @@ public class ThreadedLevelLightEngine extends LevelLightEngine implements AutoCl
+ 
+     @Override
+     public void queueSectionData(LightLayer lightType, SectionPos pos, @Nullable DataLayer nibbles, boolean nonEdge) {
++        if (true) return; // Paper - replace light engine impl
+         this.addTask(pos.x(), pos.z(), () -> {
+             return 0;
+         }, ThreadedLevelLightEngine.TaskType.PRE_UPDATE, Util.name(() -> {
+@@ -0,0 +0,0 @@ public class ThreadedLevelLightEngine extends LevelLightEngine implements AutoCl
+ 
+     @Override
+     public void retainData(ChunkPos pos, boolean retainData) {
++        if (true) return; // Paper - replace light engine impl
+         this.addTask(pos.x, pos.z, () -> {
+             return 0;
+         }, ThreadedLevelLightEngine.TaskType.PRE_UPDATE, Util.name(() -> {
+@@ -0,0 +0,0 @@ public class ThreadedLevelLightEngine extends LevelLightEngine implements AutoCl
+     }
+ 
+     public CompletableFuture<ChunkAccess> lightChunk(ChunkAccess chunk, boolean excludeBlocks) {
++        // Paper start - replace light engine impl
++        if (true) {
++            boolean lit = excludeBlocks;
++            final ChunkPos chunkPos = chunk.getPos();
++
++            return CompletableFuture.supplyAsync(() -> {
++                final Boolean[] emptySections = StarLightEngine.getEmptySectionsForChunk(chunk);
++                if (!lit) {
++                    chunk.setLightCorrect(false);
++                    this.theLightEngine.lightChunk(chunk, emptySections);
++                    chunk.setLightCorrect(true);
++                } else {
++                    this.theLightEngine.forceLoadInChunk(chunk, emptySections);
++                    // can't really force the chunk to be edged checked, as we need neighbouring chunks - but we don't have
++                    // them, so if it's not loaded then i guess we can't do edge checks. later loads of the chunk should
++                    // catch what we miss here.
++                    this.theLightEngine.checkChunkEdges(chunkPos.x, chunkPos.z);
++                }
++
++                this.chunkMap.releaseLightTicket(chunkPos);
++                return chunk;
++            }, (runnable) -> {
++                this.theLightEngine.scheduleChunkLight(chunkPos, runnable);
++                this.tryScheduleUpdate();
++            }).whenComplete((final ChunkAccess c, final Throwable throwable) -> {
++                if (throwable != null) {
++                    LOGGER.fatal("Failed to light chunk " + chunkPos, throwable);
++                }
++            });
++        }
++        // Paper end - replace light engine impl
+         ChunkPos chunkPos = chunk.getPos();
+         // Paper start
+         //ichunkaccess.b(false); // Don't need to disable this
+@@ -0,0 +0,0 @@ public class ThreadedLevelLightEngine extends LevelLightEngine implements AutoCl
+     }
+ 
+     public void tryScheduleUpdate() {
+-        if ((!this.queue.isEmpty() || super.hasLightWork()) && this.scheduled.compareAndSet(false, true)) { // Paper
++        if (this.hasLightWork() && this.scheduled.compareAndSet(false, true)) { // Paper  // Paper - rewrite light engine
+             this.taskMailbox.tell(() -> {
+                 this.runUpdate();
+                 this.scheduled.set(false);
+@@ -0,0 +0,0 @@ public class ThreadedLevelLightEngine extends LevelLightEngine implements AutoCl
+         if (queue.poll(pre, post)) {
+             pre.forEach(Runnable::run);
+             pre.clear();
+-            super.runUpdates(Integer.MAX_VALUE, true, true);
++            this.theLightEngine.propagateChanges(); // Paper - rewrite light engine
+             post.forEach(Runnable::run);
+             post.clear();
+         } else {
+             // might have level updates to go still
+-            super.runUpdates(Integer.MAX_VALUE, true, true);
++            this.theLightEngine.propagateChanges(); // Paper - rewrite light engine
+         }
+         // Paper end
+     }
+diff --git a/src/main/java/net/minecraft/server/level/TicketType.java b/src/main/java/net/minecraft/server/level/TicketType.java
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
+--- a/src/main/java/net/minecraft/server/level/TicketType.java
++++ b/src/main/java/net/minecraft/server/level/TicketType.java
+@@ -0,0 +0,0 @@ public class TicketType<T> {
+     public static final TicketType<Unit> PLUGIN = TicketType.create("plugin", (a, b) -> 0); // CraftBukkit
+     public static final TicketType<org.bukkit.plugin.Plugin> PLUGIN_TICKET = TicketType.create("plugin_ticket", (plugin1, plugin2) -> plugin1.getClass().getName().compareTo(plugin2.getClass().getName())); // CraftBukkit
+     public static final TicketType<Long> DELAY_UNLOAD = create("delay_unload", Long::compareTo, 300); // Paper
++    public static final TicketType<Long> CHUNK_RELIGHT = create("light_update", Long::compareTo); // Paper - ensure chunks stay loaded for lighting
+ 
+     public static <T> TicketType<T> create(String name, Comparator<T> argumentComparator) {
+         return new TicketType<>(name, argumentComparator, 0L);
+diff --git a/src/main/java/net/minecraft/world/level/block/state/BlockBehaviour.java b/src/main/java/net/minecraft/world/level/block/state/BlockBehaviour.java
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
+--- a/src/main/java/net/minecraft/world/level/block/state/BlockBehaviour.java
++++ b/src/main/java/net/minecraft/world/level/block/state/BlockBehaviour.java
+@@ -0,0 +0,0 @@ public abstract class BlockBehaviour {
+             this.isViewBlocking = blockbase_info.isViewBlocking;
+             this.hasPostProcess = blockbase_info.hasPostProcess;
+             this.emissiveRendering = blockbase_info.emissiveRendering;
++            this.conditionallyFullOpaque = this.isOpaque() & this.isTransparentOnSomeFaces(); // Paper
+         }
+         // Paper start - impl cached craft block data, lazy load to fix issue with loading at the wrong time
+         private org.bukkit.craftbukkit.block.data.CraftBlockData cachedCraftBlockData;
+@@ -0,0 +0,0 @@ public abstract class BlockBehaviour {
+         protected boolean isTicking;
+         protected FluidState fluid;
+         // Paper end
++        // Paper start
++        protected int opacityIfCached = -1;
++        // ret -1 if opacity is dynamic, or -1 if the block is conditionally full opaque, else return opacity in [0, 15]
++        public final int getOpacityIfCached() {
++            return this.opacityIfCached;
++        }
++
++        protected final boolean conditionallyFullOpaque;
++        public final boolean isConditionallyFullOpaque() {
++            return this.conditionallyFullOpaque;
++        }
++        // Paper end
+ 
+         public void initCache() {
+             this.fluid = this.getBlock().getFluidState(this.asState()); // Paper - moved from getFluid()
+@@ -0,0 +0,0 @@ public abstract class BlockBehaviour {
+                 this.cache = new BlockBehaviour.BlockStateBase.Cache(this.asState());
+             }
+             this.shapeExceedsCube = this.cache == null || this.cache.largeCollisionShape; // Paper - moved from actual method to here
++            this.opacityIfCached = this.cache == null || this.isConditionallyFullOpaque() ? -1 : this.cache.lightBlock; // Paper - cache opacity for light
+ 
+         }
+ 
+diff --git a/src/main/java/net/minecraft/world/level/chunk/ChunkAccess.java b/src/main/java/net/minecraft/world/level/chunk/ChunkAccess.java
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
+--- a/src/main/java/net/minecraft/world/level/chunk/ChunkAccess.java
++++ b/src/main/java/net/minecraft/world/level/chunk/ChunkAccess.java
+@@ -0,0 +0,0 @@ public abstract class ChunkAccess implements BlockGetter, BiomeManager.NoiseBiom
+     protected final LevelHeightAccessor levelHeightAccessor;
+     protected final LevelChunkSection[] sections;
+ 
++    // Paper start - rewrite light engine
++    private volatile ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] blockNibbles;
++
++    private volatile ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] skyNibbles;
++
++    private volatile boolean[] skyEmptinessMap;
++
++    private volatile boolean[] blockEmptinessMap;
++
++    public ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] getBlockNibbles() {
++        return this.blockNibbles;
++    }
++
++    public void setBlockNibbles(final ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] nibbles) {
++        this.blockNibbles = nibbles;
++    }
++
++    public ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] getSkyNibbles() {
++        return this.skyNibbles;
++    }
++
++    public void setSkyNibbles(final ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] nibbles) {
++        this.skyNibbles = nibbles;
++    }
++
++    public boolean[] getSkyEmptinessMap() {
++        return this.skyEmptinessMap;
++    }
++
++    public void setSkyEmptinessMap(final boolean[] emptinessMap) {
++        this.skyEmptinessMap = emptinessMap;
++    }
++
++    public boolean[] getBlockEmptinessMap() {
++        return this.blockEmptinessMap;
++    }
++
++    public void setBlockEmptinessMap(final boolean[] emptinessMap) {
++        this.blockEmptinessMap = emptinessMap;
++    }
++    // Paper end - rewrite light engine
++
+     public ChunkAccess(ChunkPos pos, UpgradeData upgradeData, LevelHeightAccessor heightLimitView, Registry<Biome> biome, long inhabitedTime, @Nullable LevelChunkSection[] sectionArrayInitializer, @Nullable BlendingData blendingData) {
+         this.locX = pos.x; this.locZ = pos.z; // Paper - reduce need for field lookups
+         this.chunkPos = pos; this.coordinateKey = ChunkPos.asLong(locX, locZ); // Paper - cache long key
+diff --git a/src/main/java/net/minecraft/world/level/chunk/EmptyLevelChunk.java b/src/main/java/net/minecraft/world/level/chunk/EmptyLevelChunk.java
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
+--- a/src/main/java/net/minecraft/world/level/chunk/EmptyLevelChunk.java
++++ b/src/main/java/net/minecraft/world/level/chunk/EmptyLevelChunk.java
+@@ -0,0 +0,0 @@ public class EmptyLevelChunk extends LevelChunk {
+         super(world, pos);
+     }
+ 
++    @Override
++    public ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] getBlockNibbles() {
++        return ca.spottedleaf.starlight.common.light.StarLightEngine.getFilledEmptyLight(this.getLevel());
++    }
++
++    @Override
++    public void setBlockNibbles(final ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] nibbles) {}
++
++    @Override
++    public ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] getSkyNibbles() {
++        return ca.spottedleaf.starlight.common.light.StarLightEngine.getFilledEmptyLight(this.getLevel());
++    }
++
++    @Override
++    public void setSkyNibbles(final ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] nibbles) {}
++
++    @Override
++    public boolean[] getSkyEmptinessMap() {
++        return null;
++    }
++
++    @Override
++    public void setSkyEmptinessMap(final boolean[] emptinessMap) {}
++
++    @Override
++    public boolean[] getBlockEmptinessMap() {
++        return null;
++    }
++
++    @Override
++    public void setBlockEmptinessMap(final boolean[] emptinessMap) {}
++
+     // Paper start
+     @Override public BlockState getType(int x, int y, int z) {
+         return Blocks.VOID_AIR.defaultBlockState();
+diff --git a/src/main/java/net/minecraft/world/level/chunk/ImposterProtoChunk.java b/src/main/java/net/minecraft/world/level/chunk/ImposterProtoChunk.java
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
+--- a/src/main/java/net/minecraft/world/level/chunk/ImposterProtoChunk.java
++++ b/src/main/java/net/minecraft/world/level/chunk/ImposterProtoChunk.java
+@@ -0,0 +0,0 @@ public class ImposterProtoChunk extends ProtoChunk {
+     private final LevelChunk wrapped;
+     private final boolean allowWrites;
+ 
++    // Paper start - rewrite light engine
++    @Override
++    public ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] getBlockNibbles() {
++        return this.wrapped.getBlockNibbles();
++    }
++
++    @Override
++    public void setBlockNibbles(final ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] nibbles) {
++        this.wrapped.setBlockNibbles(nibbles);
++    }
++
++    @Override
++    public ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] getSkyNibbles() {
++        return this.wrapped.getSkyNibbles();
++    }
++
++    @Override
++    public void setSkyNibbles(final ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] nibbles) {
++        this.wrapped.setSkyNibbles(nibbles);
++    }
++
++    @Override
++    public boolean[] getSkyEmptinessMap() {
++        return this.wrapped.getSkyEmptinessMap();
++    }
++
++    @Override
++    public void setSkyEmptinessMap(final boolean[] emptinessMap) {
++        this.wrapped.setSkyEmptinessMap(emptinessMap);
++    }
++
++    @Override
++    public boolean[] getBlockEmptinessMap() {
++        return this.wrapped.getBlockEmptinessMap();
++    }
++
++    @Override
++    public void setBlockEmptinessMap(final boolean[] emptinessMap) {
++        this.wrapped.setBlockEmptinessMap(emptinessMap);
++    }
++    // Paper end - rewrite light engine
++
+     public ImposterProtoChunk(LevelChunk wrapped, boolean bl) {
+         super(wrapped.getPos(), UpgradeData.EMPTY, wrapped.levelHeightAccessor, wrapped.getLevel().registryAccess().registryOrThrow(Registry.BIOME_REGISTRY), wrapped.getBlendingData());
+         this.wrapped = wrapped;
+diff --git a/src/main/java/net/minecraft/world/level/chunk/LevelChunk.java b/src/main/java/net/minecraft/world/level/chunk/LevelChunk.java
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
+--- a/src/main/java/net/minecraft/world/level/chunk/LevelChunk.java
++++ b/src/main/java/net/minecraft/world/level/chunk/LevelChunk.java
+@@ -0,0 +0,0 @@ public class LevelChunk extends ChunkAccess {
+ 
+     public LevelChunk(Level world, ChunkPos pos, UpgradeData upgradeData, LevelChunkTicks<Block> blockTickScheduler, LevelChunkTicks<Fluid> fluidTickScheduler, long inhabitedTime, @Nullable LevelChunkSection[] sectionArrayInitializer, @Nullable LevelChunk.PostLoadProcessor entityLoader, @Nullable BlendingData blendingData) {
+         super(pos, upgradeData, world, world.registryAccess().registryOrThrow(Registry.BIOME_REGISTRY), inhabitedTime, sectionArrayInitializer, blendingData);
++        // Paper start - rewrite light engine
++        this.setBlockNibbles(ca.spottedleaf.starlight.common.light.StarLightEngine.getFilledEmptyLight(world));
++        this.setSkyNibbles(ca.spottedleaf.starlight.common.light.StarLightEngine.getFilledEmptyLight(world));
++        // Paper end - rewrite light engine
+         this.tickersInLevel = Maps.newHashMap();
+         this.clientLightReady = false;
+         this.level = (ServerLevel) world; // CraftBukkit - type
+@@ -0,0 +0,0 @@ public class LevelChunk extends ChunkAccess {
+ 
+     public LevelChunk(ServerLevel world, ProtoChunk protoChunk, @Nullable LevelChunk.PostLoadProcessor chunk_c) {
+         this(world, protoChunk.getPos(), protoChunk.getUpgradeData(), protoChunk.unpackBlockTicks(), protoChunk.unpackFluidTicks(), protoChunk.getInhabitedTime(), protoChunk.getSections(), chunk_c, protoChunk.getBlendingData());
++        // Paper start - rewrite light engine
++        this.setBlockNibbles(protoChunk.getBlockNibbles());
++        this.setSkyNibbles(protoChunk.getSkyNibbles());
++        this.setSkyEmptinessMap(protoChunk.getSkyEmptinessMap());
++        this.setBlockEmptinessMap(protoChunk.getBlockEmptinessMap());
++        // Paper end - rewrite light engine
+         Iterator iterator = protoChunk.getBlockEntities().values().iterator();
+ 
+         while (iterator.hasNext()) {
+diff --git a/src/main/java/net/minecraft/world/level/chunk/PalettedContainer.java b/src/main/java/net/minecraft/world/level/chunk/PalettedContainer.java
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
+--- a/src/main/java/net/minecraft/world/level/chunk/PalettedContainer.java
++++ b/src/main/java/net/minecraft/world/level/chunk/PalettedContainer.java
+@@ -0,0 +0,0 @@ public class PalettedContainer<T> implements PaletteResize<T> {
+         return this.get(this.strategy.getIndex(x, y, z));
+     }
+ 
+-    protected T get(int index) {
++    public T get(int index) { // Paper - public
+         PalettedContainer.Data<T> data = this.data;
+         return data.palette.valueFor(data.storage.get(index));
+     }
+diff --git a/src/main/java/net/minecraft/world/level/chunk/ProtoChunk.java b/src/main/java/net/minecraft/world/level/chunk/ProtoChunk.java
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
+--- a/src/main/java/net/minecraft/world/level/chunk/ProtoChunk.java
++++ b/src/main/java/net/minecraft/world/level/chunk/ProtoChunk.java
+@@ -0,0 +0,0 @@ public class ProtoChunk extends ChunkAccess {
+ 
+     public ProtoChunk(ChunkPos pos, UpgradeData upgradeData, @Nullable LevelChunkSection[] sections, ProtoChunkTicks<Block> blockTickScheduler, ProtoChunkTicks<Fluid> fluidTickScheduler, LevelHeightAccessor world, Registry<Biome> biomeRegistry, @Nullable BlendingData blendingData) {
+         super(pos, upgradeData, world, biomeRegistry, 0L, sections, blendingData);
++        // Paper start - rewrite light engine
++        if (!(this instanceof ImposterProtoChunk)) {
++            this.setBlockNibbles(ca.spottedleaf.starlight.common.light.StarLightEngine.getFilledEmptyLight(world));
++            this.setSkyNibbles(ca.spottedleaf.starlight.common.light.StarLightEngine.getFilledEmptyLight(world));
++        }
++        // Paper end - rewrite light engine
+         this.blockTicks = blockTickScheduler;
+         this.fluidTicks = fluidTickScheduler;
+     }
+diff --git a/src/main/java/net/minecraft/world/level/chunk/storage/ChunkSerializer.java b/src/main/java/net/minecraft/world/level/chunk/storage/ChunkSerializer.java
+index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644
+--- a/src/main/java/net/minecraft/world/level/chunk/storage/ChunkSerializer.java
++++ b/src/main/java/net/minecraft/world/level/chunk/storage/ChunkSerializer.java
+@@ -0,0 +0,0 @@ public class ChunkSerializer {
+     private static final String BLOCK_TICKS_TAG = "block_ticks";
+     private static final String FLUID_TICKS_TAG = "fluid_ticks";
+ 
++    // Paper start - replace light engine impl
++    private static final int STARLIGHT_LIGHT_VERSION = 6;
++
++    private static final String BLOCKLIGHT_STATE_TAG = "starlight.blocklight_state";
++    private static final String SKYLIGHT_STATE_TAG = "starlight.skylight_state";
++    private static final String STARLIGHT_VERSION_TAG = "starlight.light_version";
++    // Paper end - replace light engine impl
++
+     public ChunkSerializer() {}
+ 
+     // Paper start - guard against serializing mismatching coordinates
+@@ -0,0 +0,0 @@ public class ChunkSerializer {
+         }
+ 
+         UpgradeData chunkconverter = nbt.contains("UpgradeData", 10) ? new UpgradeData(nbt.getCompound("UpgradeData"), world) : UpgradeData.EMPTY;
+-        boolean flag = nbt.getBoolean("isLightOn");
++        boolean flag = getStatus(nbt).isOrAfter(ChunkStatus.LIGHT) && nbt.get("isLightOn") != null && nbt.getInt(STARLIGHT_VERSION_TAG) == STARLIGHT_LIGHT_VERSION; // Paper
+         ListTag nbttaglist = nbt.getList("sections", 10);
+         int i = world.getSectionsCount();
+         LevelChunkSection[] achunksection = new LevelChunkSection[i];
+         boolean flag1 = world.dimensionType().hasSkyLight();
+         ServerChunkCache chunkproviderserver = world.getChunkSource();
+         LevelLightEngine lightengine = chunkproviderserver.getLightEngine();
++        // Paper start
++        ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] blockNibbles = ca.spottedleaf.starlight.common.light.StarLightEngine.getFilledEmptyLight(world); // Paper - replace light impl
++        ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] skyNibbles = ca.spottedleaf.starlight.common.light.StarLightEngine.getFilledEmptyLight(world); // Paper - replace light impl
++        final int minSection = io.papermc.paper.util.WorldUtil.getMinLightSection(world);
++        final int maxSection = io.papermc.paper.util.WorldUtil.getMaxLightSection(world);
++        boolean canReadSky = world.dimensionType().hasSkyLight();
++        // Paper end
+ 
+         if (flag) {
+             tasksToExecuteOnMain.add(() -> { // Paper - delay this task since we're executing off-main
+@@ -0,0 +0,0 @@ public class ChunkSerializer {
+         DataResult dataresult;
+ 
+         for (int j = 0; j < nbttaglist.size(); ++j) {
+-            CompoundTag nbttagcompound1 = nbttaglist.getCompound(j);
++            CompoundTag nbttagcompound1 = nbttaglist.getCompound(j); CompoundTag sectionData = nbttagcompound1; // Paper
+             byte b0 = nbttagcompound1.getByte("Y");
+             int k = world.getSectionIndexFromSectionY(b0);
+ 
+@@ -0,0 +0,0 @@ public class ChunkSerializer {
+             }
+ 
+             if (flag) {
+-                if (nbttagcompound1.contains("BlockLight", 7)) {
+-                    // Paper start - delay this task since we're executing off-main
+-                    DataLayer blockLight = new DataLayer(nbttagcompound1.getByteArray("BlockLight"));
+-                    tasksToExecuteOnMain.add(() -> {
+-                        lightengine.queueSectionData(LightLayer.BLOCK, SectionPos.of(chunkcoordintpair1, b0), blockLight, true);
+-                    });
+-                    // Paper end - delay this task since we're executing off-main
++                // Paper start - rewrite light engine
++                int y = sectionData.getByte("Y");
++
++                if (sectionData.contains("BlockLight", 7)) {
++                    // this is where our diff is
++                    blockNibbles[y - minSection] = new ca.spottedleaf.starlight.common.light.SWMRNibbleArray(sectionData.getByteArray("BlockLight").clone(), sectionData.getInt(BLOCKLIGHT_STATE_TAG)); // clone for data safety
++                } else {
++                    blockNibbles[y - minSection] = new ca.spottedleaf.starlight.common.light.SWMRNibbleArray(null, sectionData.getInt(BLOCKLIGHT_STATE_TAG));
+                 }
+ 
+-                if (flag1 && nbttagcompound1.contains("SkyLight", 7)) {
+-                    // Paper start - delay this task since we're executing off-main
+-                    DataLayer skyLight = new DataLayer(nbttagcompound1.getByteArray("SkyLight"));
+-                    tasksToExecuteOnMain.add(() -> {
+-                        lightengine.queueSectionData(LightLayer.SKY, SectionPos.of(chunkcoordintpair1, b0), skyLight, true);
+-                    });
+-                    // Paper end - delay this task since we're executing off-mai
++                if (canReadSky) {
++                    if (sectionData.contains("SkyLight", 7)) {
++                        // we store under the same key so mod programs editing nbt
++                        // can still read the data, hopefully.
++                        // however, for compatibility we store chunks as unlit so vanilla
++                        // is forced to re-light them if it encounters our data. It's too much of a burden
++                        // to try and maintain compatibility with a broken and inferior skylight management system.
++                        skyNibbles[y - minSection] = new ca.spottedleaf.starlight.common.light.SWMRNibbleArray(sectionData.getByteArray("SkyLight").clone(), sectionData.getInt(SKYLIGHT_STATE_TAG)); // clone for data safety
++                    } else {
++                        skyNibbles[y - minSection] = new ca.spottedleaf.starlight.common.light.SWMRNibbleArray(null, sectionData.getInt(SKYLIGHT_STATE_TAG));
++                    }
+                 }
++                // Paper end - rewrite light engine
+             }
+         }
+ 
+@@ -0,0 +0,0 @@ public class ChunkSerializer {
+             }, chunkPos);
+ 
+             object = new LevelChunk(world.getLevel(), chunkPos, chunkconverter, levelchunkticks, levelchunkticks1, l, achunksection, ChunkSerializer.postLoadChunk(world, nbt), blendingdata);
++            ((LevelChunk)object).setBlockNibbles(blockNibbles); // Paper - replace light impl
++            ((LevelChunk)object).setSkyNibbles(skyNibbles); // Paper - replace light impl
+         } else {
+             ProtoChunkTicks<Block> protochunkticklist = ProtoChunkTicks.load(nbt.getList("block_ticks", 10), (s) -> {
+                 return Registry.BLOCK.getOptional(ResourceLocation.tryParse(s));
+@@ -0,0 +0,0 @@ public class ChunkSerializer {
+                 return Registry.FLUID.getOptional(ResourceLocation.tryParse(s));
+             }, chunkPos);
+             ProtoChunk protochunk = new ProtoChunk(chunkPos, chunkconverter, achunksection, protochunkticklist, protochunkticklist1, world, iregistry, blendingdata);
++            protochunk.setBlockNibbles(blockNibbles); // Paper - replace light impl
++            protochunk.setSkyNibbles(skyNibbles); // Paper - replace light impl
+ 
+             object = protochunk;
+             protochunk.setInhabitedTime(l);
+@@ -0,0 +0,0 @@ public class ChunkSerializer {
+         DataLayer[] blockLight = new DataLayer[lightenginethreaded.getMaxLightSection() - lightenginethreaded.getMinLightSection()];
+         DataLayer[] skyLight = new DataLayer[lightenginethreaded.getMaxLightSection() - lightenginethreaded.getMinLightSection()];
+ 
+-        for (int i = lightenginethreaded.getMinLightSection(); i < lightenginethreaded.getMaxLightSection(); ++i) {
++        for (int i = lightenginethreaded.getMinLightSection(); false && i < lightenginethreaded.getMaxLightSection(); ++i) { // Paper - don't run loop, we don't need to - light data is per chunk now
+             DataLayer blockArray = lightenginethreaded.getLayerListener(LightLayer.BLOCK).getDataLayerData(SectionPos.of(chunkPos, i));
+             DataLayer skyArray = lightenginethreaded.getLayerListener(LightLayer.SKY).getDataLayerData(SectionPos.of(chunkPos, i));
+ 
+@@ -0,0 +0,0 @@ public class ChunkSerializer {
+     }
+     public static CompoundTag saveChunk(ServerLevel world, ChunkAccess chunk, @org.checkerframework.checker.nullness.qual.Nullable AsyncSaveData asyncsavedata) {
+         // Paper end
++        // Paper start - rewrite light impl
++        final int minSection = io.papermc.paper.util.WorldUtil.getMinLightSection(world);
++        final int maxSection = io.papermc.paper.util.WorldUtil.getMaxLightSection(world);
++        ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] blockNibbles = chunk.getBlockNibbles();
++        ca.spottedleaf.starlight.common.light.SWMRNibbleArray[] skyNibbles = chunk.getSkyNibbles();
++        // Paper end - rewrite light impl
+         ChunkPos chunkcoordintpair = chunk.getPos();
+         CompoundTag nbttagcompound = new CompoundTag();
+ 
+@@ -0,0 +0,0 @@ public class ChunkSerializer {
+         for (int i = lightenginethreaded.getMinLightSection(); i < lightenginethreaded.getMaxLightSection(); ++i) {
+             int j = chunk.getSectionIndexFromSectionY(i);
+             boolean flag1 = j >= 0 && j < achunksection.length;
+-            // Paper start - async chunk save for unload
+-            DataLayer nibblearray; // block light
+-            DataLayer nibblearray1; // sky light
+-            if (asyncsavedata == null) {
+-                nibblearray = lightenginethreaded.getLayerListener(LightLayer.BLOCK).getDataLayerData(SectionPos.of(chunkcoordintpair, i)); /// Paper - diff on method change (see getAsyncSaveData)
+-                nibblearray1 = lightenginethreaded.getLayerListener(LightLayer.SKY).getDataLayerData(SectionPos.of(chunkcoordintpair, i)); // Paper - diff on method change (see getAsyncSaveData)
+-            } else {
+-                nibblearray = asyncsavedata.blockLight[i - lightenginethreaded.getMinLightSection()];
+-                nibblearray1 = asyncsavedata.skyLight[i - lightenginethreaded.getMinLightSection()];
+-            }
+-            // Paper end
++            // Paper - replace light engine
+ 
+-            if (flag1 || nibblearray != null || nibblearray1 != null) {
+-                CompoundTag nbttagcompound1 = new CompoundTag();
++            // Paper start - replace light engine
++            ca.spottedleaf.starlight.common.light.SWMRNibbleArray.SaveState blockNibble = blockNibbles[i - minSection].getSaveState();
++            ca.spottedleaf.starlight.common.light.SWMRNibbleArray.SaveState skyNibble = skyNibbles[i - minSection].getSaveState();
++            if (flag1 || blockNibble != null || skyNibble != null) {
++                // Paper end - replace light engine
++                CompoundTag nbttagcompound1 = new CompoundTag(); CompoundTag section = nbttagcompound1; // Paper
+ 
+                 if (flag1) {
+                     LevelChunkSection chunksection = achunksection[j];
+@@ -0,0 +0,0 @@ public class ChunkSerializer {
+                     nbttagcompound1.put("biomes", (Tag) dataresult1.getOrThrow(false, logger1::error));
+                 }
+ 
+-                if (nibblearray != null && !nibblearray.isEmpty()) {
+-                    nbttagcompound1.putByteArray("BlockLight", nibblearray.getData());
++                // Paper start
++                // we store under the same key so mod programs editing nbt
++                // can still read the data, hopefully.
++                // however, for compatibility we store chunks as unlit so vanilla
++                // is forced to re-light them if it encounters our data. It's too much of a burden
++                // to try and maintain compatibility with a broken and inferior skylight management system.
++
++                if (blockNibble != null) {
++                    if (blockNibble.data != null) {
++                        section.putByteArray("BlockLight", blockNibble.data);
++                    }
++                    section.putInt(BLOCKLIGHT_STATE_TAG, blockNibble.state);
+                 }
+ 
+-                if (nibblearray1 != null && !nibblearray1.isEmpty()) {
+-                    nbttagcompound1.putByteArray("SkyLight", nibblearray1.getData());
++                if (skyNibble != null) {
++                    if (skyNibble.data != null) {
++                        section.putByteArray("SkyLight", skyNibble.data);
++                    }
++                    section.putInt(SKYLIGHT_STATE_TAG, skyNibble.state);
+                 }
++                // Paper end
+ 
+                 if (!nbttagcompound1.isEmpty()) {
+                     nbttagcompound1.putByte("Y", (byte) i);
+@@ -0,0 +0,0 @@ public class ChunkSerializer {
+ 
+         nbttagcompound.put("sections", nbttaglist);
+         if (flag) {
+-            nbttagcompound.putBoolean("isLightOn", true);
++            nbttagcompound.putInt(STARLIGHT_VERSION_TAG, STARLIGHT_LIGHT_VERSION); // Paper
++            nbttagcompound.putBoolean("isLightOn", false); // Paper - set to false but still store, this allows us to detect --eraseCache (as eraseCache _removes_)
+         }
+ 
+         // Paper start