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..4a04eb6449d33d3f15c354b2ac98198f4ac12758
--- /dev/null
+++ b/src/main/java/ca/spottedleaf/starlight/common/light/BlockStarLightEngine.java
@@ -0,0 +1,288 @@
+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..5d4feec98b0d2ca014fe963daccebebb07af6394
--- /dev/null
+++ b/src/main/java/ca/spottedleaf/starlight/common/light/SWMRNibbleArray.java
@@ -0,0 +1,436 @@
+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..5f771962afb44175d446f138c8e7453230f48c6c
--- /dev/null
+++ b/src/main/java/ca/spottedleaf/starlight/common/light/SkyStarLightEngine.java
@@ -0,0 +1,709 @@
+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..1b0d92c68407cdb09ed8aac271b625d92db87017
--- /dev/null
+++ b/src/main/java/ca/spottedleaf/starlight/common/light/StarLightEngine.java
@@ -0,0 +1,1572 @@
+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 ySections = (this.maxSection - this.minSection) + 1;
+            final int chunkY = ((index / (5*5)) % (ySections + 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..ef8dcbb6bbc0769e9ccfdadb05e6a46c070eda98
--- /dev/null
+++ b/src/main/java/ca/spottedleaf/starlight/common/light/StarLightInterface.java
@@ -0,0 +1,665 @@
+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;
+
+    private final boolean hasBlockLight;
+    private final boolean hasSkyLight;
+
+    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.hasBlockLight = hasBlockLight;
+        this.hasSkyLight = hasSkyLight;
+        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) {
+        if (!this.hasSkyLight) {
+            return 0;
+        }
+        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) {
+        if (!this.hasBlockLight) {
+            return 0;
+        }
+        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;
+        // Don't fetch the block light level if the skylight level is 15, since the value will never be higher.
+        if (sky == 15) return 15;
+        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..16a4a14e7ccf9e4d7fdf1166674fe8f529c06d39
--- /dev/null
+++ b/src/main/java/ca/spottedleaf/starlight/common/util/CoordinateUtils.java
@@ -0,0 +1,128 @@
+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..177d0a969f3d72a34e773e8309c3719a235ee06d
--- /dev/null
+++ b/src/main/java/ca/spottedleaf/starlight/common/util/IntegerUtil.java
@@ -0,0 +1,226 @@
+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..28c946390221613a442fba90901ba6af94e38902
--- /dev/null
+++ b/src/main/java/ca/spottedleaf/starlight/common/util/SaveUtil.java
@@ -0,0 +1,183 @@
+package ca.spottedleaf.starlight.common.util;
+
+import ca.spottedleaf.starlight.common.light.SWMRNibbleArray;
+import ca.spottedleaf.starlight.common.light.StarLightEngine;
+import com.mojang.logging.LogUtils;
+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.slf4j.Logger;
+
+public final class SaveUtil {
+
+    private static final Logger LOGGER = LogUtils.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 tag) {
+        if (tag == null) {
+            return;
+        }
+
+        final int minSection = WorldUtil.getMinLightSection(world);
+        final int maxSection = WorldUtil.getMaxLightSection(world);
+
+        SWMRNibbleArray[] blockNibbles = chunk.getBlockNibbles();
+        SWMRNibbleArray[] skyNibbles = chunk.getSkyNibbles();
+
+        boolean lit = chunk.isLightCorrect() || !(world instanceof ServerLevel);
+        // diff start - store our tag for whether light data is init'd
+        if (lit) {
+            tag.putBoolean("isLightOn", false);
+        }
+        // diff end - store our tag for whether light data is init'd
+        ChunkStatus status = ChunkStatus.byName(tag.getString("Status"));
+
+        CompoundTag[] sections = new CompoundTag[maxSection - minSection + 1];
+
+        ListTag sectionsStored = tag.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);
+            }
+        }
+        tag.put("sections", sectionsStored);
+        if (lit) {
+            tag.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
+        boolean lit = tag.get("isLightOn") != null && tag.getInt(STARLIGHT_VERSION_TAG) == STARLIGHT_LIGHT_VERSION;
+        boolean canReadSky = world.dimensionType().hasSkyLight();
+        ChunkStatus status = ChunkStatus.byName(tag.getString("Status"));
+        if (lit && status.isOrAfter(ChunkStatus.LIGHT)) { // diff - we add the status check here
+            ListTag sections = tag.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..dd995e25ae620ae36cd5eecb2fe10ad034ba50d2
--- /dev/null
+++ b/src/main/java/ca/spottedleaf/starlight/common/util/WorldUtil.java
@@ -0,0 +1,47 @@
+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 f57ec991111b45dbc33a81d3162ef58872ee3590..d0c55497040158d959693ef2ddf3ff03bd6df05f 100644
--- a/src/main/java/com/destroystokyo/paper/PaperCommand.java
+++ b/src/main/java/com/destroystokyo/paper/PaperCommand.java
@@ -700,6 +700,46 @@ 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");
@@ -708,7 +748,7 @@ 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;
@@ -721,6 +761,13 @@ 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 09616415e8f11d40435dc81282b818ac9cbbdcbe..d061dc4b5de6b629d6ddcdbfe9ca652dfcec8952 100644
--- a/src/main/java/net/minecraft/server/level/ChunkHolder.java
+++ b/src/main/java/net/minecraft/server/level/ChunkHolder.java
@@ -55,7 +55,7 @@ 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 9999f9743184ba929635d625c60836c046ededd4..727ee42ec573fd1d008f526f49d0e63396ef0a9d 100644
--- a/src/main/java/net/minecraft/server/level/ChunkMap.java
+++ b/src/main/java/net/minecraft/server/level/ChunkMap.java
@@ -133,7 +133,7 @@ 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/DistanceManager.java b/src/main/java/net/minecraft/server/level/DistanceManager.java
index ea4e46c113d3f0a5db6c891021e2e4c5eb275cd4..b22c771ca35466574d5a73f2da3fc8762cb29487 100644
--- a/src/main/java/net/minecraft/server/level/DistanceManager.java
+++ b/src/main/java/net/minecraft/server/level/DistanceManager.java
@@ -545,7 +545,7 @@ public abstract class DistanceManager {
     }
 
     public void removeTicketsOnClosing() {
-        ImmutableSet<TicketType<?>> immutableset = ImmutableSet.of(TicketType.UNKNOWN, TicketType.POST_TELEPORT, TicketType.LIGHT, TicketType.FUTURE_AWAIT, TicketType.ASYNC_LOAD, TicketType.REQUIRED_LOAD); // Paper - add additional tickets to preserve
+        ImmutableSet<TicketType<?>> immutableset = ImmutableSet.of(TicketType.UNKNOWN, TicketType.POST_TELEPORT, TicketType.LIGHT, TicketType.FUTURE_AWAIT, TicketType.ASYNC_LOAD, TicketType.REQUIRED_LOAD, TicketType.CHUNK_RELIGHT, ca.spottedleaf.starlight.common.light.StarLightInterface.CHUNK_WORK_TICKET); // Paper - add additional tickets to preserve
         ObjectIterator objectiterator = this.tickets.long2ObjectEntrySet().fastIterator();
 
         while (objectiterator.hasNext()) {
diff --git a/src/main/java/net/minecraft/server/level/ThreadedLevelLightEngine.java b/src/main/java/net/minecraft/server/level/ThreadedLevelLightEngine.java
index bd475554a630fb68433dd6e6640586cf5240cfed..d572d44bdc053c29b2b10f4720d4b940fc47252f 100644
--- a/src/main/java/net/minecraft/server/level/ThreadedLevelLightEngine.java
+++ b/src/main/java/net/minecraft/server/level/ThreadedLevelLightEngine.java
@@ -23,6 +23,17 @@ import net.minecraft.world.level.chunk.LightChunkGetter;
 import net.minecraft.world.level.lighting.LevelLightEngine;
 import org.slf4j.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 = LogUtils.getLogger();
     private final ProcessorMailbox<Runnable> taskMailbox;
@@ -157,13 +168,168 @@ 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.error("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;
+        // Don't fetch the block light level if the skylight level is 15, since the value will never be higher.
+        if (sky == 15) return 15;
+        final int block = this.theLightEngine.getBlockReader().getLightValue(pos);
+        return Math.max(sky, block);
+    }
+    // Paper end - replace light engine imp
+
     @Override
     public void close() {
     }
@@ -180,15 +346,16 @@ 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(() -> {
@@ -211,17 +378,16 @@ 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);
         }, () -> {
@@ -231,6 +397,7 @@ 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(() -> {
@@ -252,6 +419,7 @@ 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(() -> {
@@ -262,6 +430,37 @@ 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.error("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
@@ -304,7 +503,7 @@ 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);
@@ -321,12 +520,12 @@ 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 41ddcf6775f99c56cf4b13b284420061e5dd6bdc..ae46429264e6a7e5c88b6b6a41a6df4db7b3e70d 100644
--- a/src/main/java/net/minecraft/server/level/TicketType.java
+++ b/src/main/java/net/minecraft/server/level/TicketType.java
@@ -32,6 +32,7 @@ public class TicketType<T> {
     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> REQUIRED_LOAD = create("required_load", Long::compareTo); // Paper - make sure getChunkAt does not fail
+    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 471c4600c6663375eb340c190edaee330bf59ef2..d545b05cfbb14e5a15b26efe372509e498605016 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
@@ -686,6 +686,7 @@ 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;
@@ -706,6 +707,18 @@ 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()
@@ -714,6 +727,7 @@ 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 20b288fdef75c9994b174cb4d7d082c5e69eb26b..08433670b54c67319ce13ac3ef0f24bd3f819ea0 100644
--- a/src/main/java/net/minecraft/world/level/chunk/ChunkAccess.java
+++ b/src/main/java/net/minecraft/world/level/chunk/ChunkAccess.java
@@ -84,6 +84,47 @@ public abstract class ChunkAccess implements BlockGetter, BiomeManager.NoiseBiom
     private static final org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry DATA_TYPE_REGISTRY = new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry();
     public org.bukkit.craftbukkit.persistence.DirtyCraftPersistentDataContainer persistentDataContainer = new org.bukkit.craftbukkit.persistence.DirtyCraftPersistentDataContainer(ChunkAccess.DATA_TYPE_REGISTRY);
     // CraftBukkit end
+    // 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
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 a9c65c8d36e5c7080133706df1363b3ce52e3370..d1b175f2bb1bc96e4f044a97b14721feb44d78f5 100644
--- a/src/main/java/net/minecraft/world/level/chunk/EmptyLevelChunk.java
+++ b/src/main/java/net/minecraft/world/level/chunk/EmptyLevelChunk.java
@@ -21,6 +21,38 @@ public class EmptyLevelChunk extends LevelChunk {
         this.biome = holder;
     }
 
+    @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 getBlockState(int x, int y, int z) {
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 37e7f766e0de7b47c7240c9365bb134fda26a756..cac036ac3c7abb520fbd97b9cea4fb1c275a494b 100644
--- a/src/main/java/net/minecraft/world/level/chunk/ImposterProtoChunk.java
+++ b/src/main/java/net/minecraft/world/level/chunk/ImposterProtoChunk.java
@@ -31,6 +31,48 @@ 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 4c5e3e6aa6c6ec88ce472e8dff10bc591245c4f7..b46648301396930478391967b371bf8d201901e1 100644
--- a/src/main/java/net/minecraft/world/level/chunk/LevelChunk.java
+++ b/src/main/java/net/minecraft/world/level/chunk/LevelChunk.java
@@ -100,6 +100,10 @@ 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, net.minecraft.server.MinecraftServer.getServer().registryAccess().registryOrThrow(Registry.BIOME_REGISTRY), inhabitedTime, sectionArrayInitializer, blendingData); // Paper - Anti-Xray - The world isnt ready yet, use server singleton for registry
+        // 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
@@ -330,6 +334,12 @@ public class LevelChunk extends ChunkAccess {
 
     public LevelChunk(ServerLevel world, ProtoChunk protoChunk, @Nullable LevelChunk.PostLoadProcessor entityLoader) {
         this(world, protoChunk.getPos(), protoChunk.getUpgradeData(), protoChunk.unpackBlockTicks(), protoChunk.unpackFluidTicks(), protoChunk.getInhabitedTime(), protoChunk.getSections(), entityLoader, 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 ac0ccaf11eb414d0c2b843f458753fb37aac3f67..bba327d0f235aa35f24098214626763ca2c53b86 100644
--- a/src/main/java/net/minecraft/world/level/chunk/PalettedContainer.java
+++ b/src/main/java/net/minecraft/world/level/chunk/PalettedContainer.java
@@ -186,7 +186,7 @@ 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 d3d2bd5ea57254a4af0572d5dab460127718ce09..3cd5c346425c63e511324ad11b120e281df4a56a 100644
--- a/src/main/java/net/minecraft/world/level/chunk/ProtoChunk.java
+++ b/src/main/java/net/minecraft/world/level/chunk/ProtoChunk.java
@@ -55,6 +55,12 @@ 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 61b26c2cb332afd7215c013fbdf144b4a023d898..54ade15cdab222e9818f2635a8a12418e78e1ce6 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
@@ -87,6 +87,14 @@ 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 = 7;
+
+    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
@@ -146,13 +154,20 @@ 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
@@ -166,7 +181,7 @@ 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);
 
@@ -211,23 +226,29 @@ 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
             }
         }
 
@@ -256,6 +277,8 @@ 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));
@@ -264,6 +287,8 @@ 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);
@@ -409,7 +434,7 @@ 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));
 
@@ -461,6 +486,12 @@ 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();
 
@@ -511,20 +542,14 @@ 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];
@@ -539,13 +564,27 @@ 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);
@@ -556,7 +595,8 @@ 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
