diff --git a/ADDITIONS.md b/ADDITIONS.md
index 8a4e668c..ad12c3a4 100644
--- a/ADDITIONS.md
+++ b/ADDITIONS.md
@@ -147,3 +147,6 @@ val color: TileColor = TileColor.DEFAULT
 
 #### Dungeons
  * All brushes are now deterministic
+
+#### Plant drop entities (vines or steps dropping on ground)
+ * Collision is now determined using hull instead of rectangle
diff --git a/gradle.properties b/gradle.properties
index f4c46836..27c52b3f 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -3,7 +3,7 @@ org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
 
 kotlinVersion=1.9.10
 kotlinCoroutinesVersion=1.8.0
-kommonsVersion=2.15.0
+kommonsVersion=2.15.1
 
 ffiVersion=2.2.13
 lwjglVersion=3.3.0
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/Starbound.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/Starbound.kt
index aa22e49a..33249044 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/Starbound.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/Starbound.kt
@@ -6,6 +6,8 @@ import com.github.benmanes.caffeine.cache.Scheduler
 import com.google.gson.*
 import com.google.gson.stream.JsonReader
 import it.unimi.dsi.fastutil.objects.ObjectArraySet
+import it.unimi.dsi.fastutil.objects.ReferenceOpenHashSet
+import kotlinx.coroutines.Runnable
 import kotlinx.coroutines.asCoroutineDispatcher
 import org.apache.logging.log4j.LogManager
 import org.classdump.luna.compiler.CompilerChunkLoader
@@ -76,6 +78,7 @@ import java.io.*
 import java.lang.ref.Cleaner
 import java.text.DateFormat
 import java.time.Duration
+import java.util.Collections
 import java.util.concurrent.CompletableFuture
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.Executor
@@ -135,7 +138,7 @@ object Starbound : BlockableEventLoop("Universe Thread"), Scheduler, ISBFileLoca
 
 	@JvmField
 	val IO_EXECUTOR: ExecutorService = ThreadPoolExecutor(0, 64, 30L, TimeUnit.SECONDS, LinkedBlockingQueue(), ThreadFactory {
-		val thread = Thread(it, "Starbound Storage IO ${ioPoolCounter.getAndIncrement()}")
+		val thread = Thread(it, "IO Worker ${ioPoolCounter.getAndIncrement()}")
 		thread.isDaemon = true
 		thread.priority = Thread.MIN_PRIORITY
 
@@ -151,12 +154,6 @@ object Starbound : BlockableEventLoop("Universe Thread"), Scheduler, ISBFileLoca
 	@JvmField
 	val COROUTINE_EXECUTOR = ExecutorWithScheduler(EXECUTOR, this).asCoroutineDispatcher()
 
-	// this is required for Caffeine since it ignores scheduler
-	// (and suffers noticeable throughput penalty) in rescheduleCleanUpIfIncomplete()
-	// if executor is specified as ForkJoinPool.commonPool()
-	@JvmField
-	val SCREENED_EXECUTOR: ExecutorService = object : ExecutorService by EXECUTOR {}
-
 	@JvmField
 	val CLEANER: Cleaner = Cleaner.create {
 		val t = Thread(it, "Starbound Global Cleaner")
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/collect/RandomListIterator.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/collect/RandomListIterator.kt
index 03ceeb73..a02131ce 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/collect/RandomListIterator.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/collect/RandomListIterator.kt
@@ -1,41 +1,45 @@
 package ru.dbotthepony.kstarbound.collect
 
-class RandomListIterator<E>(private val elements: MutableList<E>, index: Int = 0) : MutableListIterator<E> {
-	private var index = index - 1
-
+class RandomListIterator<E>(private val elements: MutableList<E>, private var index: Int = 0) : MutableListIterator<E> {
 	override fun hasPrevious(): Boolean {
 		return this.index > 0
 	}
 
 	override fun nextIndex(): Int {
-		return this.index + 1
+		return this.index
 	}
 
 	override fun previous(): E {
-		return elements[--this.index]
+		lastIndex = --this.index
+		return elements[lastIndex]
 	}
 
 	override fun previousIndex(): Int {
-		return (this.index - 1).coerceAtLeast(-1)
+		return this.index - 1
 	}
 
 	override fun add(element: E) {
 		elements.add(this.index++, element)
+		lastIndex = -1
 	}
 
 	override fun hasNext(): Boolean {
-		return this.index < elements.size - 1
+		return this.index < elements.size
 	}
 
+	private var lastIndex = -1
+
 	override fun next(): E {
-		return elements[++this.index]
+		lastIndex = this.index++
+		return elements[lastIndex]
 	}
 
 	override fun remove() {
-		elements.removeAt(this.index--)
+		elements.removeAt(lastIndex)
+		lastIndex = -1
 	}
 
 	override fun set(element: E) {
-		elements[this.index] = element
+		elements[lastIndex] = element
 	}
 }
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/EntityType.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/EntityType.kt
index 18d6e064..db99eb54 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/EntityType.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/EntityType.kt
@@ -1,6 +1,5 @@
 package ru.dbotthepony.kstarbound.defs
 
-import com.google.gson.stream.JsonWriter
 import ru.dbotthepony.kstarbound.json.builder.IStringSerializable
 
 enum class EntityType(override val jsonName: String, val storeName: String) : IStringSerializable {
@@ -8,7 +7,7 @@ enum class EntityType(override val jsonName: String, val storeName: String) : IS
 	OBJECT("object", "ObjectEntity"),
 	VEHICLE("vehicle", "VehicleEntity"),
 	ITEM_DROP("itemDrop", "ItemDropEntity"),
-	PLANT_DROP("plantDrop", "PlantDropEntity"), // wat
+	PLANT_DROP("plantDrop", "PlantDropEntity"),
 	PROJECTILE("projectile", "ProjectileEntity"),
 	STAGEHAND("stagehand", "StagehandEntity"),
 	MONSTER("monster", "MonsterEntity"),
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonPart.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonPart.kt
index 8d3d5be1..8714aff2 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonPart.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonPart.kt
@@ -10,6 +10,7 @@ import com.google.gson.stream.JsonReader
 import com.google.gson.stream.JsonWriter
 import kotlinx.coroutines.future.await
 import org.apache.logging.log4j.LogManager
+import ru.dbotthepony.kommons.arrays.Object2DArray
 import ru.dbotthepony.kstarbound.math.AABBi
 import ru.dbotthepony.kommons.util.KOptional
 import ru.dbotthepony.kstarbound.math.vector.Vector2i
@@ -225,8 +226,13 @@ class DungeonPart(data: JsonData) {
 			return true
 
 		return world.waitForRegionAndJoin(Vector2i(x, y), reader.size) {
+			val cells = Object2DArray(reader.size.x, reader.size.y) { tx, ty ->
+				world.parent.getCell(x + tx, y + ty)
+			}
+
 			reader.walkTiles<Boolean> { tx, ty, tile ->
-				if (!tile.canPlace(x + tx, y + ty, world)) {
+				// TMX allows to define objects with out-of-bounds coordinates...
+				if (!tile.canPlace(x + tx, y + ty, world, cells.getOrNull(tx, ty) ?: world.parent.getCell(x + tx, y + ty))) {
 					return@walkTiles KOptional(false)
 				}
 
@@ -235,12 +241,17 @@ class DungeonPart(data: JsonData) {
 		}.orElse(true)
 	}
 
-	fun canPlace(x: Int, y: Int, world: ServerWorld): Boolean {
-		if (overrideAllowAlways || reader.size.x == 0 || reader.size.y == 0)
+	fun canPlace(x: Int, y: Int, world: ServerWorld, allowAlways: Boolean = this.overrideAllowAlways): Boolean {
+		if (allowAlways || reader.size.x == 0 || reader.size.y == 0)
 			return true
 
+		val cells = Object2DArray(reader.size.x, reader.size.y) { tx, ty ->
+			world.getCell(x + tx, y + ty)
+		}
+
 		return reader.walkTiles<Boolean> { tx, ty, tile ->
-			if (!tile.canPlace(x + tx, y + ty, world)) {
+			// TMX allows to define objects with out-of-bounds coordinates...
+			if (!tile.canPlace(x + tx, y + ty, world, cells.getOrNull(tx, ty) ?: world.getCell(x + tx, y + ty))) {
 				return@walkTiles KOptional(false)
 			}
 
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonRule.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonRule.kt
index b02e902e..5db9c75b 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonRule.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonRule.kt
@@ -20,6 +20,7 @@ import ru.dbotthepony.kstarbound.defs.tile.isObjectTile
 import ru.dbotthepony.kstarbound.json.builder.IStringSerializable
 import ru.dbotthepony.kstarbound.json.stream
 import ru.dbotthepony.kstarbound.server.world.ServerWorld
+import ru.dbotthepony.kstarbound.world.api.AbstractCell
 
 @JsonAdapter(DungeonRule.Adapter::class)
 abstract class DungeonRule {
@@ -139,11 +140,11 @@ abstract class DungeonRule {
 		return false
 	}
 
-	open fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld): Boolean {
+	open fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld, cell: AbstractCell = world.parent.getCell(x, y)): Boolean {
 		return true
 	}
 
-	open fun checkTileCanPlace(x: Int, y: Int, world: ServerWorld): Boolean {
+	open fun checkTileCanPlace(x: Int, y: Int, world: ServerWorld, cell: AbstractCell = world.getCell(x, y)): Boolean {
 		return true
 	}
 
@@ -161,7 +162,7 @@ abstract class DungeonRule {
 		override val requiresLiquid: Boolean
 			get() = true
 
-		override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld): Boolean {
+		override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld, cell: AbstractCell): Boolean {
 			val cell = world.parent.template.cellInfo(x, y)
 			return cell.oceanLiquid.isNotEmptyLiquid && cell.oceanLiquidLevel > y
 		}
@@ -172,7 +173,7 @@ abstract class DungeonRule {
 	}
 
 	object MustNotContainLiquid : DungeonRule() {
-		override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld): Boolean {
+		override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld, cell: AbstractCell): Boolean {
 			val cell = world.parent.template.cellInfo(x, y)
 			return cell.oceanLiquid.isEmptyLiquid || cell.oceanLiquidLevel <= y
 		}
@@ -186,20 +187,17 @@ abstract class DungeonRule {
 		override val requiresSolid: Boolean
 			get() = true
 
-		override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld): Boolean {
+		override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld, cell: AbstractCell): Boolean {
 			if (world.markSurfaceLevel != null)
 				return y < world.markSurfaceLevel
 
-			val cell = world.parent.getCell(x, y)
-
 			if (cell.foreground.material.isObjectTile && world.isClearingTileEntityAt(x, y))
 				return false
 
 			return cell.foreground.material.isNotEmptyTile && !world.isClearingTileEntityAt(x, y)
 		}
 
-		override fun checkTileCanPlace(x: Int, y: Int, world: ServerWorld): Boolean {
-			val cell = world.getCell(x, y)
+		override fun checkTileCanPlace(x: Int, y: Int, world: ServerWorld, cell: AbstractCell): Boolean {
 			return cell.foreground.material.isNotEmptyTile
 		}
 
@@ -212,16 +210,14 @@ abstract class DungeonRule {
 		override val requiresOpen: Boolean
 			get() = true
 
-		override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld): Boolean {
+		override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld, cell: AbstractCell): Boolean {
 			if (world.markSurfaceLevel != null)
 				return y >= world.markSurfaceLevel
 
-			val cell = world.parent.getCell(x, y)
 			return cell.foreground.material.isEmptyTile || cell.foreground.material.isObjectTile && world.isClearingTileEntityAt(x, y)
 		}
 
-		override fun checkTileCanPlace(x: Int, y: Int, world: ServerWorld): Boolean {
-			val cell = world.getCell(x, y)
+		override fun checkTileCanPlace(x: Int, y: Int, world: ServerWorld, cell: AbstractCell): Boolean {
 			return cell.foreground.material.isEmptyTile
 		}
 
@@ -234,20 +230,17 @@ abstract class DungeonRule {
 		override val requiresSolid: Boolean
 			get() = true
 
-		override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld): Boolean {
+		override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld, cell: AbstractCell): Boolean {
 			if (world.markSurfaceLevel != null)
 				return y < world.markSurfaceLevel
 
-			val cell = world.parent.getCell(x, y)
-
 			if (cell.background.material.isObjectTile && world.isClearingTileEntityAt(x, y))
 				return false
 
 			return cell.background.material.isNotEmptyTile
 		}
 
-		override fun checkTileCanPlace(x: Int, y: Int, world: ServerWorld): Boolean {
-			val cell = world.getCell(x, y)
+		override fun checkTileCanPlace(x: Int, y: Int, world: ServerWorld, cell: AbstractCell): Boolean {
 			return cell.background.material.isNotEmptyTile
 		}
 
@@ -260,16 +253,14 @@ abstract class DungeonRule {
 		override val requiresOpen: Boolean
 			get() = true
 
-		override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld): Boolean {
+		override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld, cell: AbstractCell): Boolean {
 			if (world.markSurfaceLevel != null)
 				return y >= world.markSurfaceLevel
 
-			val cell = world.parent.getCell(x, y)
 			return cell.background.material.isEmptyTile || cell.background.material.isObjectTile && world.isClearingTileEntityAt(x, y)
 		}
 
-		override fun checkTileCanPlace(x: Int, y: Int, world: ServerWorld): Boolean {
-			val cell = world.getCell(x, y)
+		override fun checkTileCanPlace(x: Int, y: Int, world: ServerWorld, cell: AbstractCell): Boolean {
 			return cell.background.material.isEmptyTile
 		}
 
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonTile.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonTile.kt
index be3b29ff..01900e70 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonTile.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonTile.kt
@@ -16,6 +16,7 @@ import ru.dbotthepony.kstarbound.defs.tile.NO_DUNGEON_ID
 import ru.dbotthepony.kstarbound.json.builder.JsonFactory
 import ru.dbotthepony.kstarbound.json.getAdapter
 import ru.dbotthepony.kstarbound.server.world.ServerWorld
+import ru.dbotthepony.kstarbound.world.api.AbstractCell
 
 @JsonAdapter(DungeonTile.Adapter::class)
 data class DungeonTile(
@@ -68,28 +69,24 @@ data class DungeonTile(
 	// TODO: find a way around this, to make dungeons less restricted by this
 	// but thats also not a priority, since this check happens quite quickly
 	// to have any noticeable impact on world's performance
-	fun canPlace(x: Int, y: Int, world: DungeonWorld): Boolean {
-		val cell = world.parent.getCell(x, y)
-
+	fun canPlace(x: Int, y: Int, world: DungeonWorld, cell: AbstractCell = world.parent.getCell(x, y)): Boolean {
 		if (cell.dungeonId != NO_DUNGEON_ID)
 			return false
 
 		if (!world.geometry.x.isValidCellIndex(x) || !world.geometry.y.isValidCellIndex(y))
 			return false
 
-		return rules.none { !it.checkTileCanPlace(x, y, world) }
+		return rules.none { !it.checkTileCanPlace(x, y, world, cell) }
 	}
 
-	fun canPlace(x: Int, y: Int, world: ServerWorld): Boolean {
-		val cell = world.getCell(x, y)
-
+	fun canPlace(x: Int, y: Int, world: ServerWorld, cell: AbstractCell = world.getCell(x, y)): Boolean {
 		if (cell.dungeonId != NO_DUNGEON_ID)
 			return false
 
 		if (!world.geometry.x.isValidCellIndex(x) || !world.geometry.y.isValidCellIndex(y))
 			return false
 
-		return rules.none { !it.checkTileCanPlace(x, y, world) }
+		return rules.none { !it.checkTileCanPlace(x, y, world, cell) }
 	}
 
 	fun place(x: Int, y: Int, phase: DungeonBrush.Phase, world: DungeonWorld) {
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/ImagePartReader.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/ImagePartReader.kt
index 428e7303..b44ee033 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/ImagePartReader.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/ImagePartReader.kt
@@ -15,8 +15,12 @@ import ru.dbotthepony.kstarbound.defs.image.Image
 import java.lang.ref.Reference
 
 class ImagePartReader(part: DungeonPart, val images: ImmutableList<Image>) : PartReader(part) {
-	override val size: Vector2i
-		get() = if (images.isEmpty()) Vector2i.ZERO else images.first().size
+	override val size: Vector2i by lazy {
+		if (images.isEmpty())
+			return@lazy Vector2i.ZERO
+
+		Vector2i(images.maxOf { it.size.x }, images.maxOf { it.size.y })
+	}
 
 	// ObjectArrayList doesn't check for concurrent modifications
 	private val layers = ObjectArrayList<Layer>()
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledPartReader.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledPartReader.kt
index 3c7b6350..ee83d939 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledPartReader.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledPartReader.kt
@@ -26,8 +26,12 @@ class TiledPartReader(part: DungeonPart, parts: Stream<String>) : PartReader(par
 	// also why would you ever want multiple maps specified lmao
 	// it already has layers and everything else you would ever need
 
-	override val size: Vector2i
-		get() = maps.firstOrNull()?.size ?: Vector2i.ZERO
+	override val size: Vector2i by lazy {
+		if (maps.isEmpty())
+			return@lazy Vector2i.ZERO
+
+		Vector2i(maps.maxOf { it.size.x }, maps.maxOf { it.size.y })
+	}
 
 	override fun bind(def: DungeonDefinition) {
 
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/image/Image.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/image/Image.kt
index be0bc5ec..9396c8a1 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/image/Image.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/image/Image.kt
@@ -4,7 +4,6 @@ import com.github.benmanes.caffeine.cache.AsyncLoadingCache
 import com.github.benmanes.caffeine.cache.CacheLoader
 import com.github.benmanes.caffeine.cache.Caffeine
 import com.github.benmanes.caffeine.cache.LoadingCache
-import com.github.benmanes.caffeine.cache.Scheduler
 import com.google.common.collect.ImmutableList
 import com.google.common.collect.ImmutableSet
 import com.google.gson.JsonArray
@@ -15,7 +14,6 @@ import com.google.gson.TypeAdapter
 import com.google.gson.stream.JsonReader
 import com.google.gson.stream.JsonWriter
 import it.unimi.dsi.fastutil.objects.Object2ObjectArrayMap
-import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap
 import it.unimi.dsi.fastutil.objects.ObjectArraySet
 import org.apache.logging.log4j.LogManager
 import org.lwjgl.opengl.GL45
@@ -50,7 +48,7 @@ import java.util.Collections
 import java.util.Optional
 import java.util.concurrent.CompletableFuture
 import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.locks.ReentrantLock
+import java.util.function.Consumer
 
 class Image private constructor(
 	val source: IStarboundFile,
@@ -108,6 +106,9 @@ class Image private constructor(
 	}
 
 	val data: ByteBuffer
+		get() = dataCache.get(source).join()
+
+	val dataFuture: CompletableFuture<ByteBuffer>
 		get() = dataCache.get(source)
 
 	val texture: GLTexture2D get() {
@@ -125,10 +126,12 @@ class Image private constructor(
 			client.named2DTextures1.get(this) {
 				val tex = GLTexture2D(width, height, GL45.GL_RGBA8)
 
-				tex.upload(GL45.GL_RGBA, GL45.GL_UNSIGNED_BYTE, data)
+				dataFuture.thenAcceptAsync(Consumer {
+					tex.upload(GL45.GL_RGBA, GL45.GL_UNSIGNED_BYTE, data)
 
-				tex.textureMinFilter = GL45.GL_NEAREST
-				tex.textureMagFilter = GL45.GL_NEAREST
+					tex.textureMinFilter = GL45.GL_NEAREST
+					tex.textureMagFilter = GL45.GL_NEAREST
+				}, client)
 
 				tex
 			}
@@ -334,19 +337,19 @@ class Image private constructor(
 			return ReadDirectData(data, getWidth[0], getHeight[0], components[0])
 		}
 
-		private val dataCache: LoadingCache<IStarboundFile, ByteBuffer> = Caffeine.newBuilder()
+		private val dataCache: AsyncLoadingCache<IStarboundFile, ByteBuffer> = Caffeine.newBuilder()
 			.expireAfterAccess(Duration.ofMinutes(1))
 			.weigher<IStarboundFile, ByteBuffer> { key, value -> value.capacity() }
 			.maximumWeight((Runtime.getRuntime().maxMemory() / 4L).coerceIn(1_024L * 1_024L * 32L /* 32 МиБ */, 1_024L * 1_024L * 256L /* 256 МиБ */))
 			.scheduler(Starbound)
-			.executor(Starbound.EXECUTOR) // SCREENED_EXECUTOR shouldn't be used here
-			.build { readImageDirect(it).data }
+			.executor(Starbound.IO_EXECUTOR)
+			.buildAsync(CacheLoader { readImageDirect(it).data })
 
 		private val spaceScanCache = Caffeine.newBuilder()
 			.expireAfterAccess(Duration.ofMinutes(30))
 			.softValues()
 			.scheduler(Starbound)
-			.executor(Starbound.SCREENED_EXECUTOR)
+			.executor(Starbound.EXECUTOR)
 			.build<SpaceScanKey, ImmutableSet<Vector2i>>()
 
 		@JvmStatic
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/WorldTemplate.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/WorldTemplate.kt
index aade96f5..a707f2a9 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/WorldTemplate.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/WorldTemplate.kt
@@ -20,7 +20,6 @@ import ru.dbotthepony.kstarbound.defs.tile.isNotEmptyLiquid
 import ru.dbotthepony.kstarbound.json.builder.JsonFactory
 import ru.dbotthepony.kstarbound.math.quintic2
 import ru.dbotthepony.kstarbound.util.random.random
-import ru.dbotthepony.kstarbound.util.random.staticRandom64
 import ru.dbotthepony.kstarbound.util.random.staticRandomInt
 import ru.dbotthepony.kstarbound.world.Universe
 import ru.dbotthepony.kstarbound.world.UniversePos
@@ -112,14 +111,12 @@ class WorldTemplate(val geometry: WorldGeometry) {
 		return data
 	}
 
-	fun findSensiblePlayerStart(): Vector2d? {
+	fun findSensiblePlayerStart(random: RandomGenerator): Vector2d? {
 		val layout = worldLayout ?: return null
 
 		if (layout.playerStartSearchRegions.isEmpty())
 			return null
 
-		val random = random()
-
 		for (i in 0 until Globals.worldTemplate.playerStartSearchTries) {
 			val region = layout.playerStartSearchRegions.random(random)
 			val x = random.nextInt(region.mins.x, region.maxs.x)
@@ -298,23 +295,30 @@ class WorldTemplate(val geometry: WorldGeometry) {
 		var backgroundCave = false
 	}
 
-	private val cellCache = Caffeine.newBuilder()
-		.maximumSize(1_500_000L) // plentiful of space, and allows for high hit ratio (around 79%) in most situations
-		// downside is memory consumption, but why should it matter when we save 80% of cpu time?
-		.expireAfterAccess(Duration.ofSeconds(20))
-		.executor(Starbound.SCREENED_EXECUTOR)
-		.scheduler(Starbound)
-		// .recordStats()
-		.build<Vector2i, CellInfo> { (x, y) -> cellInfo0(x, y) }
+	// as said by Ben Manes, if cache is write-heavy, it is up to end users
+	// to stripe it into multiple distinct caches (so write buffer doesn't get overflown and force
+	// to be drained in place)
+	// https://github.com/ben-manes/caffeine/issues/1320#issuecomment-1812884592
+	private val cellCache = Array(256) {
+		Caffeine.newBuilder()
+			.maximumSize(50_000L) // plentiful of space, and allows for high hit ratio (around 79%) in most situations
+								  // downside is memory consumption, but why should it matter when we save 80% of cpu time?
+			.expireAfterAccess(Duration.ofSeconds(20))
+			.executor(Starbound.EXECUTOR)
+			.scheduler(Starbound)
+			// .recordStats()
+			.build<Vector2i, CellInfo> { (x, y) -> cellInfo0(x, y) }
+	}
 
 	fun cellInfo(x: Int, y: Int): CellInfo {
 		worldLayout ?: return CellInfo(x, y)
-		return cellCache.get(Vector2i(x, y))
+		val vec = Vector2i(x, y)
+		return cellCache[vec.hashCode() and 255].get(vec)
 	}
 
 	fun cellInfo(pos: Vector2i): CellInfo {
 		worldLayout ?: return CellInfo(pos.x, pos.y)
-		return cellCache.get(pos)
+		return cellCache[pos.hashCode() and 255].get(pos)
 	}
 
 	private fun cellInfo0(x: Int, y: Int): CellInfo {
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/io/Streams.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/io/Streams.kt
index dab4bf35..256732f5 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/io/Streams.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/io/Streams.kt
@@ -128,9 +128,16 @@ fun InputStream.readAABB(): AABB {
 	return AABB(readVector2d(), readVector2d())
 }
 
+fun InputStream.readAABB(isLegacy: Boolean): AABB {
+	if (isLegacy)
+		return readAABBLegacy()
+	else
+		return readAABB()
+}
+
 fun OutputStream.writeAABBLegacy(value: AABB) {
-	writeStruct2f(value.mins.toFloatVector())
-	writeStruct2f(value.maxs.toFloatVector())
+	writeStruct2d(value.mins, true)
+	writeStruct2d(value.maxs, true)
 }
 
 fun OutputStream.writeAABBLegacyOptional(value: KOptional<AABB>) {
@@ -150,6 +157,11 @@ fun OutputStream.writeAABB(value: AABB) {
 	writeStruct2d(value.maxs)
 }
 
+fun OutputStream.writeAABB(value: AABB, isLegacy: Boolean) {
+	writeStruct2d(value.mins, isLegacy)
+	writeStruct2d(value.maxs, isLegacy)
+}
+
 private fun InputStream.readBoolean(): Boolean {
 	val read = read()
 
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/lua/bindings/WorldObjectBindings.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/lua/bindings/WorldObjectBindings.kt
index cfed6f3a..d07e808c 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/lua/bindings/WorldObjectBindings.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/lua/bindings/WorldObjectBindings.kt
@@ -149,7 +149,7 @@ fun provideWorldObjectBindings(self: WorldObject, lua: LuaEnvironment) {
 		val results = newTable()
 
 		for (connection in self.inputNodes[index.toInt()].connections) {
-			val entity = self.world.entityIndex.tileEntityAt(connection.entityLocation) as? WorldObject
+			val entity = self.world.entityIndex.tileEntityAt(connection.entityLocation, WorldObject::class)
 
 			if (entity != null) {
 				results[entity.entityID] = connection.index
@@ -163,7 +163,7 @@ fun provideWorldObjectBindings(self: WorldObject, lua: LuaEnvironment) {
 		val results = newTable()
 
 		for (connection in self.outputNodes[index.toInt()].connections) {
-			val entity = self.world.entityIndex.tileEntityAt(connection.entityLocation) as? WorldObject
+			val entity = self.world.entityIndex.tileEntityAt(connection.entityLocation, WorldObject::class)
 
 			if (entity != null) {
 				results[entity.entityID] = connection.index
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/math/AABB.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/math/AABB.kt
index 93b6654c..6c1228ca 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/math/AABB.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/math/AABB.kt
@@ -277,6 +277,37 @@ data class AABB(val mins: Vector2d, val maxs: Vector2d) {
 			return AABB(pos, pos + Vector2d(width, height))
 		}
 
+		fun ofPoints(points: Collection<Vector2d>): AABB {
+			if (points.isEmpty())
+				return NEVER
+
+			val minX = points.minOf { it.x }
+			val maxX = points.maxOf { it.x }
+			val minY = points.minOf { it.y }
+			val maxY = points.maxOf { it.y }
+
+			return AABB(
+				Vector2d(minX, minY),
+				Vector2d(maxX, maxY),
+			)
+		}
+
+		@JvmName("ofPointsI")
+		fun ofPoints(points: Collection<Vector2i>): AABB {
+			if (points.isEmpty())
+				return NEVER
+
+			val minX = points.minOf { it.x }.toDouble()
+			val maxX = points.maxOf { it.x }.toDouble()
+			val minY = points.minOf { it.y }.toDouble()
+			val maxY = points.maxOf { it.y }.toDouble()
+
+			return AABB(
+				Vector2d(minX, minY),
+				Vector2d(maxX, maxY),
+			)
+		}
+
 		@JvmField val ZERO = AABB(Vector2d.ZERO, Vector2d.ZERO)
 		@JvmField val NEVER = AABB(Vector2d(Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY), Vector2d(Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY))
 	}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/network/packets/serverbound/ConnectWirePacket.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/network/packets/serverbound/ConnectWirePacket.kt
index 58adcbc0..66f78e1f 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/network/packets/serverbound/ConnectWirePacket.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/network/packets/serverbound/ConnectWirePacket.kt
@@ -17,8 +17,8 @@ class ConnectWirePacket(val target: WireConnection, val source: WireConnection)
 
 	override fun play(connection: ServerConnection) {
 		connection.enqueue {
-			val target = entityIndex.tileEntityAt(target.entityLocation) as? WorldObject ?: return@enqueue
-			val source = entityIndex.tileEntityAt(source.entityLocation) as? WorldObject ?: return@enqueue
+			val target = entityIndex.tileEntityAt(target.entityLocation, WorldObject::class) ?: return@enqueue
+			val source = entityIndex.tileEntityAt(source.entityLocation, WorldObject::class) ?: return@enqueue
 
 			val targetNode = target.outputNodes.getOrNull(this@ConnectWirePacket.target.index) ?: return@enqueue
 			val sourceNode = source.inputNodes.getOrNull(this@ConnectWirePacket.source.index) ?: return@enqueue
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/network/packets/serverbound/DisconnectAllWiresPacket.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/network/packets/serverbound/DisconnectAllWiresPacket.kt
index 000bf3d2..0b6a53e3 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/network/packets/serverbound/DisconnectAllWiresPacket.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/network/packets/serverbound/DisconnectAllWiresPacket.kt
@@ -21,7 +21,7 @@ class DisconnectAllWiresPacket(val pos: Vector2i, val node: WireNode) : IServerP
 
 	override fun play(connection: ServerConnection) {
 		connection.enqueue {
-			val target = entityIndex.tileEntityAt(pos) as? WorldObject ?: return@enqueue
+			val target = entityIndex.tileEntityAt(pos, WorldObject::class) ?: return@enqueue
 			val node = if (node.isInput) target.inputNodes.getOrNull(node.index) else target.outputNodes.getOrNull(node.index)
 			node?.removeAllConnections()
 		}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/LegacyWireProcessor.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/LegacyWireProcessor.kt
index c6f08534..0471f2b8 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/LegacyWireProcessor.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/LegacyWireProcessor.kt
@@ -67,9 +67,9 @@ class LegacyWireProcessor(val world: ServerWorld) {
 						launch {
 							ticket.chunk.await()
 
-							val findEntity = world.entityIndex.tileEntityAt(pos)
+							val findEntity = world.entityIndex.tileEntityAt(pos, WorldObject::class)
 
-							if (findEntity is WorldObject) {
+							if (findEntity != null) {
 								// if entity exists, add it to working entities and find more not loaded entities
 								populateWorking(findEntity)
 							} else {
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerChunk.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerChunk.kt
index d00f27b8..99402ee2 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerChunk.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerChunk.kt
@@ -3,6 +3,7 @@ package ru.dbotthepony.kstarbound.server.world
 import com.google.gson.JsonObject
 import it.unimi.dsi.fastutil.objects.ObjectAVLTreeSet
 import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.future.await
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.suspendCancellableCoroutine
@@ -70,6 +71,7 @@ import kotlin.concurrent.withLock
 import kotlin.coroutines.cancellation.CancellationException
 import kotlin.coroutines.resume
 import kotlin.coroutines.resumeWithException
+import kotlin.math.min
 
 class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk<ServerWorld, ServerChunk>(world, pos) {
 	override var state: ChunkState = ChunkState.FRESH
@@ -731,7 +733,6 @@ class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk<ServerWorld, Server
 			pos.tile + Vector2i(width + CHUNK_SIZE_FF, height + CHUNK_SIZE_FF)
 		)
 
-		val pacer = ExecutionTimePacer(500_000L, 40L)
 		val random = random(staticRandom64(world.template.seed, pos.x, pos.y, "microdungeon placement"))
 
 		for (placement in placements) {
@@ -758,15 +759,10 @@ class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk<ServerWorld, Server
 						if (!bounds.isInside(pos) || !bounds.isInside(pos + anchor.reader.size - Vector2i.POSITIVE_XY))
 							continue
 
-						val collision = anchor.reader.walkTiles<Boolean> { x, y, tile ->
-							if (tile.usesPlaces && world.getCell(pos.x + x, pos.y + y).dungeonId != NO_DUNGEON_ID) {
-								return@walkTiles KOptional(true)
-							}
-
-							return@walkTiles KOptional()
-						}.orElse(false)
-
-						if (!collision && anchor.canPlace(pos.x, pos.y, world)) {
+						// this is quite ugly code flow, but we should try to avoid double-walking
+						// over all dungeon tiles (DungeonTile#canPlace already checks for absence of other dungeons on their place,
+						// so we only need to tell DungeonPart to not force-place)
+						if (anchor.canPlace(pos.x, pos.y, world, false)) {
 							try {
 								dungeon.value!!.build(anchor, world, random, pos.x, pos.y, dungeonID = MICRO_DUNGEON_ID).await()
 							} catch (err: Throwable) {
@@ -778,7 +774,7 @@ class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk<ServerWorld, Server
 
 						// some breathing room for other code, since placement checking is performance intense operation
 						if (!world.isInPreparation && world.clients.isNotEmpty())
-							pacer.measureAndSuspend()
+							delay(min(60L, anchor.reader.size.x * anchor.reader.size.y / 40L))
 					}
 				}
 			}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerUniverse.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerUniverse.kt
index 55d3e594..a4735932 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerUniverse.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerUniverse.kt
@@ -2,7 +2,6 @@ package ru.dbotthepony.kstarbound.server.world
 
 import com.github.benmanes.caffeine.cache.Cache
 import com.github.benmanes.caffeine.cache.Caffeine
-import com.github.benmanes.caffeine.cache.Scheduler
 import it.unimi.dsi.fastutil.objects.ObjectArrayList
 import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet
 import kotlinx.coroutines.future.await
@@ -221,7 +220,7 @@ class ServerUniverse private constructor(marker: Nothing?) : Universe(), Closeab
 		.maximumSize(1024L)
 		.softValues()
 		.scheduler(Starbound)
-		.executor(Starbound.SCREENED_EXECUTOR)
+		.executor(Starbound.EXECUTOR)
 		.build()
 
 	fun getChunkFuture(pos: Vector2i): CompletableFuture<KOptional<UniverseChunk>> {
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorld.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorld.kt
index 3e9f5cd4..18274dee 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorld.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorld.kt
@@ -382,10 +382,11 @@ class ServerWorld private constructor(
 		//}
 
 		val tickets = ArrayList<ServerChunk.ITicket>()
+		val random = if (hint == null) random(template.seed) else random()
 
 		try {
 			LOGGER.info("Trying to find player spawn position...")
-			var pos = hint ?: CompletableFuture.supplyAsync(Supplier { template.findSensiblePlayerStart() }, Starbound.EXECUTOR).await() ?: Vector2d(0.0, template.surfaceLevel().toDouble())
+			var pos = hint ?: CompletableFuture.supplyAsync(Supplier { template.findSensiblePlayerStart(random) }, Starbound.EXECUTOR).await() ?: Vector2d(0.0, template.surfaceLevel().toDouble())
 			var previous = pos
 			LOGGER.info("Trying to find player spawn position near $pos...")
 
@@ -442,7 +443,7 @@ class ServerWorld private constructor(
 					}
 				}
 
-				pos = CompletableFuture.supplyAsync(Supplier { template.findSensiblePlayerStart() }, Starbound.EXECUTOR).await() ?: Vector2d(0.0, template.surfaceLevel().toDouble())
+				pos = CompletableFuture.supplyAsync(Supplier { template.findSensiblePlayerStart(random) }, Starbound.EXECUTOR).await() ?: Vector2d(0.0, template.surfaceLevel().toDouble())
 
 				if (previous != pos) {
 					LOGGER.info("Still trying to find player spawn position near $pos...")
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorldTracker.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorldTracker.kt
index f631a4dd..10fdc5a1 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorldTracker.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorldTracker.kt
@@ -82,7 +82,12 @@ class ServerWorldTracker(val world: ServerWorld, val client: ServerConnection, p
 	private suspend fun damageTilesLoop() {
 		while (true) {
 			val (positions, isBackground, sourcePosition, damage, source) = damageTilesQueue.receive()
-			world.damageTiles(positions, isBackground, sourcePosition, damage, source, tileModificationBudget)
+
+			try {
+				world.damageTiles(positions, isBackground, sourcePosition, damage, source, tileModificationBudget)
+			} catch (err: Throwable) {
+				LOGGER.error("Exception in player damage tiles loop", err)
+			}
 		}
 	}
 
@@ -102,6 +107,9 @@ class ServerWorldTracker(val world: ServerWorld, val client: ServerConnection, p
 				}
 			} catch (err: CancellationException) {
 				client.send(TileModificationFailurePacket(modifications))
+			} catch (err: Throwable) {
+				client.send(TileModificationFailurePacket(modifications))
+				LOGGER.error("Exception in player modify tiles loop", err)
 			}
 		}
 	}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/EntityIndex.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/EntityIndex.kt
index 7c811e8a..4a40c8b4 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/EntityIndex.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/EntityIndex.kt
@@ -15,6 +15,8 @@ import ru.dbotthepony.kstarbound.world.entities.AbstractEntity
 import ru.dbotthepony.kstarbound.world.entities.tile.TileEntity
 import java.util.concurrent.atomic.AtomicInteger
 import java.util.function.Predicate
+import kotlin.reflect.KClass
+import kotlin.reflect.full.isSuperclassOf
 
 // After some thinking, I decided to go with separate spatial index over
 // using chunk/chunkmap as spatial indexing of entities (just like original engine does).
@@ -283,10 +285,18 @@ class EntityIndex(val geometry: WorldGeometry) {
 		return first(AABB(pos.toDoubleVector(), pos.toDoubleVector() + Vector2d.POSITIVE_XY), Predicate { it is TileEntity && pos in it.occupySpaces }) as TileEntity?
 	}
 
+	fun <T : TileEntity> tileEntityAt(pos: Vector2i, type: KClass<T>): T? {
+		return first(AABB(pos.toDoubleVector(), pos.toDoubleVector() + Vector2d.POSITIVE_XY), Predicate { it is TileEntity && pos in it.occupySpaces && type.isSuperclassOf(it::class) }) as T?
+	}
+
 	fun tileEntitiesAt(pos: Vector2i): MutableList<TileEntity> {
 		return query(AABB(pos.toDoubleVector(), pos.toDoubleVector() + Vector2d.POSITIVE_XY), Predicate { it is TileEntity && pos in it.occupySpaces }) as MutableList<TileEntity>
 	}
 
+	fun <T : TileEntity> tileEntitiesAt(pos: Vector2i, type: KClass<T>): MutableList<T> {
+		return query(AABB(pos.toDoubleVector(), pos.toDoubleVector() + Vector2d.POSITIVE_XY), Predicate { it is TileEntity && pos in it.occupySpaces && type.isSuperclassOf(it::class) }) as MutableList<T>
+	}
+
 	fun iterate(rect: AABB, visitor: (AbstractEntity) -> Unit, withEdges: Boolean = true) {
 		walk<Unit>(rect, { visitor(it); KOptional()  }, withEdges)
 	}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/DynamicEntity.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/DynamicEntity.kt
index 9e741812..1ff9bc8d 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/DynamicEntity.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/DynamicEntity.kt
@@ -12,8 +12,6 @@ import ru.dbotthepony.kstarbound.world.World
  * Entities with dynamics (Player, Drops, Projectiles, NPCs, etc)
  */
 abstract class DynamicEntity() : AbstractEntity() {
-	private var forceChunkRepos = false
-
 	override var position
 		get() = movement.position
 		set(value) {
@@ -52,7 +50,6 @@ abstract class DynamicEntity() : AbstractEntity() {
 		super.onJoinWorld(world)
 		world.dynamicEntities.add(this)
 		movement.initialize(world, spatialEntry)
-		forceChunkRepos = true
 		metaFixture = spatialEntry!!.Fixture()
 	}
 
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/MovementController.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/MovementController.kt
index 8f477979..e98ca9fa 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/MovementController.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/MovementController.kt
@@ -553,7 +553,7 @@ open class MovementController() {
 				movement = movement + totalCorrection,
 				correction = totalCorrection,
 				isStuck = false,
-				isOnGround = -totalCorrection.dot(determineGravity()) > SEPARATION_TOLERANCE,
+				isOnGround = totalCorrection.unitVector.dot(determineGravity().unitVector) >= 0.5,
 				movingCollisionId = movingCollisionId,
 				collisionType = maxCollided,
 				// groundSlope = Vector2d.POSITIVE_Y,
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/PlantEntity.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/PlantEntity.kt
index 3e45041d..e315f75a 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/PlantEntity.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/PlantEntity.kt
@@ -2,9 +2,14 @@ package ru.dbotthepony.kstarbound.world.entities.tile
 
 import com.google.common.collect.ImmutableSet
 import com.google.gson.JsonArray
+import com.google.gson.JsonElement
 import com.google.gson.JsonObject
 import com.google.gson.JsonPrimitive
 import com.google.gson.TypeAdapter
+import it.unimi.dsi.fastutil.ints.Int2ObjectAVLTreeMap
+import it.unimi.dsi.fastutil.ints.Int2ObjectArrayMap
+import it.unimi.dsi.fastutil.ints.Int2ObjectFunction
+import it.unimi.dsi.fastutil.ints.IntArrayList
 import it.unimi.dsi.fastutil.io.FastByteArrayInputStream
 import it.unimi.dsi.fastutil.objects.ObjectArraySet
 import org.apache.logging.log4j.LogManager
@@ -29,7 +34,6 @@ import ru.dbotthepony.kstarbound.defs.tile.TileDefinition
 import ru.dbotthepony.kstarbound.defs.tile.isEmptyTile
 import ru.dbotthepony.kstarbound.defs.tile.isMetaTile
 import ru.dbotthepony.kstarbound.defs.tile.isNotEmptyTile
-import ru.dbotthepony.kstarbound.defs.tile.isNullTile
 import ru.dbotthepony.kstarbound.defs.world.BushVariant
 import ru.dbotthepony.kstarbound.defs.world.GrassVariant
 import ru.dbotthepony.kstarbound.defs.world.TreeVariant
@@ -64,13 +68,14 @@ import java.io.DataInputStream
 import java.io.DataOutputStream
 import java.util.*
 import java.util.random.RandomGenerator
+import kotlin.collections.ArrayList
 import kotlin.math.absoluteValue
 
 class PlantEntity() : TileEntity() {
 	@JsonFactory
 	data class Piece(
 		val image: String,
-		val offset: Vector2d,
+		var offset: Vector2d,
 		var segmentIdx: Int,
 		val isStructuralSegment: Boolean,
 		val kind: Kind,
@@ -147,7 +152,7 @@ class PlantEntity() : TileEntity() {
 		isCeiling = data.get("ceiling", false)
 		stemDropConfig = data["stemDropConfig"] as? JsonObject ?: JsonObject()
 		foliageDropConfig = data["foliageDropConfig"] as? JsonObject ?: JsonObject()
-		saplingDropConfig = data["saplingDropConfig"] as? JsonObject ?: JsonObject()
+		saplingDropConfig = data["saplingDropConfig"] ?: JsonObject()
 		descriptions = data["descriptions"] as? JsonObject ?: JsonObject()
 		isEphemeral = data.get("ephemeral", false)
 		fallsWhenDead = data.get("fallsWhenDead", false)
@@ -220,7 +225,7 @@ class PlantEntity() : TileEntity() {
 		private set
 	var foliageDropConfig: JsonObject = JsonObject()
 		private set
-	var saplingDropConfig: JsonObject = JsonObject()
+	var saplingDropConfig: JsonElement = JsonObject()
 		private set
 	var descriptions: JsonObject = JsonObject()
 		private set
@@ -229,6 +234,7 @@ class PlantEntity() : TileEntity() {
 
 	constructor(config: TreeVariant, random: RandomGenerator) : this() {
 		isCeiling = config.ceiling
+		fallsWhenDead = true
 
 		stemDropConfig = (config.stemDropConfig as? JsonObject)?.deepCopy() ?:  JsonObject()
 		foliageDropConfig = (config.foliageDropConfig as? JsonObject)?.deepCopy() ?:  JsonObject()
@@ -532,7 +538,7 @@ class PlantEntity() : TileEntity() {
 		isCeiling = stream.readBoolean()
 		stemDropConfig = stream.readJsonElement() as JsonObject
 		foliageDropConfig = stream.readJsonElement() as JsonObject
-		saplingDropConfig = stream.readJsonElement() as JsonObject
+		saplingDropConfig = stream.readJsonElement()
 		descriptions = stream.readJsonElement() as JsonObject
 
 		isEphemeral = stream.readBoolean()
@@ -640,8 +646,24 @@ class PlantEntity() : TileEntity() {
 	override fun tick(delta: Double) {
 		super.tick(delta)
 
-		if (world.isServer && piecesInternal.isEmpty()) {
-			remove(RemovalReason.REMOVED)
+		if (world.isServer) {
+			if (piecesInternal.isEmpty()) {
+				remove(RemovalReason.REMOVED)
+			} else if (roots.isNotEmpty()) {
+				for (root in roots) {
+					if (world.getCell(root).foreground.material.isEmptyTile) {
+						if (fallsWhenDead) {
+							breakAtPosition(tilePosition, position)
+						} else {
+							remove(RemovalReason.DYING)
+						}
+					}
+				}
+			}
+		}
+
+		if (!isRemote) {
+			health.tick(tileDamageParameters, delta)
 		}
 	}
 
@@ -660,10 +682,123 @@ class PlantEntity() : TileEntity() {
 	}
 
 	override fun damage(damageSpaces: List<Vector2i>, source: Vector2d, damage: TileDamage): Boolean {
-		// TODO
+		if (damageSpaces.isEmpty())
+			return false
+
+		var baseDamagePosition: Vector2i = damageSpaces.first()
+
+		for (piece in pieces) {
+			if (piece.isStructuralSegment) {
+				for (space in piece.spaces) {
+					for (pos in damageSpaces) {
+						if (world.geometry.wrap(space + tilePosition) == pos && baseDamagePosition.y < pos.y == isCeiling) {
+							// if this space is a "better match" for the root of the plant
+							baseDamagePosition = pos
+						}
+					}
+				}
+			}
+		}
+
+		val (x, y) = world.geometry.diff(baseDamagePosition, tilePosition)
+
+		// TODO: this is unnatural solution to tree damage,
+		//  each tree piece should have its own damage status
+		health.damage(tileDamageParameters, source, damage)
+		tileDamageX = x.toDouble()
+		tileDamageY = y.toDouble()
+		tileDamageEvent.trigger()
+
+		if (health.isDead) {
+			if (fallsWhenDead) {
+				health.reset()
+				breakAtPosition(baseDamagePosition, source)
+			} else {
+				remove(RemovalReason.DYING)
+			}
+		}
+
 		return false
 	}
 
+	private fun breakAtPosition(position: Vector2i, source: Vector2d) {
+		val internalPos = world.geometry.diff(position, tilePosition)
+		var breakAtPiece = pieces.lastOrNull { it.isStructuralSegment && internalPos in it.spaces }
+
+		// default to highest structural piece
+		if (breakAtPiece == null) {
+			breakAtPiece = pieces.lastOrNull { it.isStructuralSegment }
+		}
+
+		// plant has no structural segments? this is a terrible fallback because it
+		// prevents destruction
+		breakAtPiece ?: return
+
+		var breakPoint = position.toDoubleVector() - tilePosition
+
+		if (breakAtPiece.spaces.isNotEmpty()) {
+			val bounds = AABB.ofPoints(breakAtPiece.spaces)
+
+			breakPoint = Vector2d(
+				bounds.mins.x + bounds.width / 2.0,
+				if (isCeiling) bounds.maxs.y else bounds.mins.y
+			)
+		}
+
+		val droppedPieces = ArrayList<Piece>()
+
+		var idx = 0
+
+		while (idx < pieces.size) {
+			if (piecesInternal[idx].segmentIdx >= breakAtPiece.segmentIdx) {
+				droppedPieces.add(piecesInternal.removeAt(idx))
+			} else {
+				idx++
+			}
+		}
+
+		val breakPointI = Vector2i((breakPoint.x + 0.5).toInt(), (breakPoint.y + 0.5).toInt())
+
+		// Calculate a new origin for the droppedPieces
+		for (piece in droppedPieces) {
+			piece.offset -= breakPoint
+			piece.spaces = piece.spaces.map { it - breakPointI }.toSet()
+		}
+
+		val worldSpaceBreakPoint = breakPoint + tilePosition
+		val segments = Int2ObjectAVLTreeMap<ArrayList<Piece>>()
+
+		for (piece in droppedPieces) {
+			segments.computeIfAbsent(piece.segmentIdx, Int2ObjectFunction { ArrayList() }).add(piece)
+		}
+
+		val angle = world.random.nextDouble(-0.3, 0.3)
+		val itr = segments.keys.iterator(segments.keys.lastInt())
+		val fallVector = (source - worldSpaceBreakPoint).unitVector
+		var first = true
+
+		while (itr.hasPrevious()) {
+			val index = itr.previousInt()
+			val segment = segments[index]!!
+
+			val entity = PlantPieceEntity(
+				segment,
+				worldSpaceBreakPoint,
+				fallVector,
+				description,
+				isCeiling,
+				stemDropConfig,
+				foliageDropConfig,
+				saplingDropConfig,
+				first,
+				angle
+			)
+
+			entity.joinWorld(world)
+			first = false
+		}
+	}
+
 	override fun toString(): String {
 		return "PlantEntity[at=$tilePosition, pieces=${pieces.size}]"
 	}
@@ -692,7 +827,7 @@ class PlantEntity() : TileEntity() {
 		// First bail out if we can't fit anything we're not adjusting
 		for (space in occupySpaces) {
 			// TODO: conditions seems to be inverted
-			if (withinAdjustments(space, position) && world.entityIndex.tileEntitiesAt(space).any { it is PlantEntity }) {
+			if (withinAdjustments(space, position) && world.entityIndex.tileEntityAt(space, PlantEntity::class) != null) {
 				return false
 			}
 
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/PlantPieceEntity.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/PlantPieceEntity.kt
new file mode 100644
index 00000000..0e0f8785
--- /dev/null
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/PlantPieceEntity.kt
@@ -0,0 +1,285 @@
+package ru.dbotthepony.kstarbound.world.entities.tile
+
+import com.google.common.collect.ImmutableSet
+import com.google.gson.JsonArray
+import com.google.gson.JsonElement
+import com.google.gson.JsonNull
+import com.google.gson.JsonObject
+import it.unimi.dsi.fastutil.objects.ObjectArraySet
+import ru.dbotthepony.kommons.gson.get
+import ru.dbotthepony.kommons.io.readCollection
+import ru.dbotthepony.kommons.io.writeBinaryString
+import ru.dbotthepony.kommons.io.writeCollection
+import ru.dbotthepony.kommons.util.Either
+import ru.dbotthepony.kommons.util.getValue
+import ru.dbotthepony.kommons.util.setValue
+import ru.dbotthepony.kstarbound.defs.EntityType
+import ru.dbotthepony.kstarbound.defs.MovementParameters
+import ru.dbotthepony.kstarbound.defs.image.Image
+import ru.dbotthepony.kstarbound.defs.item.ItemDescriptor
+import ru.dbotthepony.kstarbound.io.readAABB
+import ru.dbotthepony.kstarbound.io.readDouble
+import ru.dbotthepony.kstarbound.io.readEnumStupid
+import ru.dbotthepony.kstarbound.io.readInternedString
+import ru.dbotthepony.kstarbound.io.readVector2d
+import ru.dbotthepony.kstarbound.io.writeAABB
+import ru.dbotthepony.kstarbound.io.writeDouble
+import ru.dbotthepony.kstarbound.io.writeEnumStupid
+import ru.dbotthepony.kstarbound.io.writeStruct2d
+import ru.dbotthepony.kstarbound.json.readJsonElement
+import ru.dbotthepony.kstarbound.json.writeJsonElement
+import ru.dbotthepony.kstarbound.math.AABB
+import ru.dbotthepony.kstarbound.math.vector.Vector2d
+import ru.dbotthepony.kstarbound.math.vector.times
+import ru.dbotthepony.kstarbound.network.syncher.networkedBoolean
+import ru.dbotthepony.kstarbound.util.random.random
+import ru.dbotthepony.kstarbound.world.PIXELS_IN_STARBOUND_UNIT
+import ru.dbotthepony.kstarbound.world.World
+import ru.dbotthepony.kstarbound.world.entities.DynamicEntity
+import ru.dbotthepony.kstarbound.world.entities.ItemDropEntity
+import ru.dbotthepony.kstarbound.world.entities.MovementController
+import ru.dbotthepony.kstarbound.world.physics.Poly
+import java.io.DataInputStream
+import java.io.DataOutputStream
+import java.util.Collections
+import java.util.stream.Collectors
+import kotlin.math.PI
+import kotlin.math.absoluteValue
+import kotlin.math.sign
+
+class PlantPieceEntity() : DynamicEntity() {
+	override val type: EntityType
+		get() = EntityType.PLANT_DROP
+
+	private var calculatedMetaBoundingBox = AABB.ZERO
+	private var calculatedCollisionBox = AABB.ZERO
+	private var calculatedCollisionHull = Poly.EMPTY
+
+	override val metaBoundingBox: AABB
+		get() = calculatedMetaBoundingBox + position
+
+	override val collisionArea: AABB
+		get() = calculatedCollisionBox + position
+
+	override val movement = MovementController().also { networkGroup.upstream.add(it.networkGroup) }
+	var spawnedDrops by networkedBoolean().also { networkGroup.upstream.add(it) }
+		private set
+
+	private val piecesInternal = ArrayList<Piece>()
+
+	val pieces: List<Piece> = Collections.unmodifiableList(piecesInternal)
+
+	var isFirst = false
+		private set
+
+	var stemConfig: JsonObject = JsonObject()
+		private set
+	var foliageConfig: JsonObject = JsonObject()
+		private set
+	var saplingConfig: JsonElement = JsonNull.INSTANCE
+		private set
+
+	var rotationRate = 0.0
+		private set
+	var rotationFallThreshold = 0.0
+		private set
+	var rotationCap = 0.0
+		private set
+
+	var timeToLive = 30.0
+		private set
+
+	data class Piece(
+		val image: String,
+		val offset: Vector2d,
+		val segmentIdx: Int,
+		val flip: Boolean,
+		val kind: PlantEntity.Piece.Kind,
+	) {
+		constructor(piece: PlantEntity.Piece) : this(piece.image, piece.offset, piece.segmentIdx, piece.flip, piece.kind)
+
+		constructor(stream: DataInputStream, isLegacy: Boolean) : this(
+			stream.readInternedString(),
+			stream.readVector2d(isLegacy),
+			0, // stream.readIntStupid(isLegacy),
+			stream.readBoolean(),
+			PlantEntity.Piece.Kind.entries[stream.readEnumStupid(isLegacy)],
+		)
+
+		fun write(stream: DataOutputStream, isLegacy: Boolean) {
+			stream.writeBinaryString(image)
+			stream.writeStruct2d(offset, isLegacy)
+			// stream.writeIntStupid(segmentIdx, isLegacy)
+			stream.writeBoolean(flip)
+			stream.writeEnumStupid(kind.ordinal, isLegacy)
+		}
+	}
+
+	constructor(
+		pieces: List<PlantEntity.Piece>,
+		position: Vector2d,
+		damageSource: Vector2d,
+		description: String,
+		upsideDown: Boolean,
+		stemConfig: JsonObject,
+		foliageConfig: JsonObject,
+		saplingConfig: JsonElement,
+		isFirst: Boolean,
+		angle: Double
+	) : this() {
+		this.stemConfig = stemConfig
+		this.foliageConfig = foliageConfig
+		this.saplingConfig = saplingConfig
+		this.isFirst = isFirst
+
+		this.movement.position = position
+		this.description = description
+
+		if (!upsideDown) {
+			this.rotationRate = 0.00001 * (damageSource.x + angle).sign
+			this.rotationFallThreshold = PI / (3.0 + angle)
+			this.rotationCap = PI - this.rotationFallThreshold
+		}
+
+		val stemSpaces = pieces.stream().filter { it.isStructuralSegment }.flatMap { it.spaces.stream() }.collect(Collectors.toCollection(::ObjectArraySet))
+		val allSpaces = pieces.stream().flatMap { it.spaces.stream() }.collect(Collectors.toCollection(::ObjectArraySet))
+
+		for (piece in pieces) {
+			piecesInternal.add(Piece(piece))
+		}
+
+		calculatedMetaBoundingBox = AABB.ofPoints(allSpaces)
+
+		if (pieces.any { it.isStructuralSegment } && stemSpaces.isNotEmpty()) {
+			calculatedCollisionBox = AABB.ofPoints(stemSpaces)
+
+			if (stemSpaces.size >= 2) {
+				calculatedCollisionHull = Poly.quickhull(stemSpaces.map { it.toDoubleVector() })
+			} else {
+				calculatedCollisionHull = Poly(calculatedCollisionBox)
+			}
+		} else {
+			calculatedCollisionBox = calculatedMetaBoundingBox
+			calculatedCollisionHull = Poly(calculatedMetaBoundingBox)
+		}
+
+		//calculatedCollisionHull = calculatedCollisionHull * 0.5 + calculatedCollisionHull.aabb.centre * 0.5
+	}
+
+	constructor(stream: DataInputStream, isLegacy: Boolean) : this() {
+		timeToLive = stream.readDouble(isLegacy)
+		isFirst = stream.readBoolean()
+		description = stream.readInternedString()
+		calculatedMetaBoundingBox = stream.readAABB(isLegacy)
+		calculatedCollisionBox = stream.readAABB(isLegacy)
+		rotationRate = stream.readDouble(isLegacy)
+
+		piecesInternal.clear()
+		piecesInternal.addAll(stream.readCollection { Piece(this, isLegacy) })
+
+		stemConfig = stream.readJsonElement() as JsonObject
+		foliageConfig = stream.readJsonElement() as JsonObject
+		saplingConfig = stream.readJsonElement()
+	}
+
+	override fun writeNetwork(stream: DataOutputStream, isLegacy: Boolean) {
+		stream.writeDouble(timeToLive, isLegacy)
+		stream.writeBoolean(isFirst)
+		stream.writeBinaryString(description)
+		stream.writeAABB(calculatedMetaBoundingBox, isLegacy)
+		stream.writeAABB(calculatedCollisionBox, isLegacy)
+		stream.writeDouble(rotationRate, isLegacy)
+		stream.writeCollection(piecesInternal) { it.write(this, isLegacy) }
+		stream.writeJsonElement(stemConfig)
+		stream.writeJsonElement(foliageConfig)
+		stream.writeJsonElement(saplingConfig)
+	}
+
+	override fun onJoinWorld(world: World<*, *>) {
+		val parameters = MovementParameters(
+			collisionPoly = Either.left(calculatedCollisionHull),
+			ignorePlatformCollision = true,
+			gravityMultiplier = 0.2,
+			physicsEffectCategories = ImmutableSet.of("plantdrop")
+		)
+
+		movement.applyParameters(parameters)
+		super.onJoinWorld(world)
+	}
+
+	override fun tick(delta: Double) {
+		super.tick(delta)
+
+		timeToLive -= delta
+
+		if (!isRemote) {
+			// TODO: think up a better curve then sin
+			val rotationAcceleration = 0.01 * world.gravityAt(position).length * rotationRate.sign * delta
+
+			if (movement.rotation.absoluteValue > rotationCap)
+				rotationRate -= rotationAcceleration
+			else if (movement.rotation.absoluteValue < rotationFallThreshold)
+				rotationRate += rotationAcceleration
+
+			movement.rotation = rotationRate
+
+			if (timeToLive > 0.0) {
+				movement.applyParameters(MovementParameters(gravityEnabled = rotationRate.absoluteValue >= rotationFallThreshold))
+
+				if (movement.isOnGround) {
+					timeToLive = 0.0
+				}
+			}
+
+			if ((timeToLive <= 0.0 || world.gravityAt(position).lengthSquared == 0.0) && !spawnedDrops) {
+				spawnedDrops = true
+
+				for (piece in piecesInternal) {
+					var dropOptions = JsonArray()
+
+					when (piece.kind) {
+						PlantEntity.Piece.Kind.NONE -> {}
+
+						PlantEntity.Piece.Kind.STEM -> {
+							dropOptions = stemConfig.get("drops", JsonArray())
+						}
+
+						PlantEntity.Piece.Kind.FOLIAGE -> {
+							dropOptions = foliageConfig.get("drops", JsonArray())
+						}
+					}
+
+					if (dropOptions.size() > 0) {
+						val option = dropOptions.random(world.random).asJsonArray
+
+						for (drop in option) {
+							val img = Image.get(piece.image) ?: continue
+
+							var pos = piece.offset + img.size.toDoubleVector() * 0.5 / PIXELS_IN_STARBOUND_UNIT
+							pos = pos.rotate(movement.rotation)
+							pos += Vector2d(world.random.nextDouble(-0.2, 0.2), world.random.nextDouble(-0.2, 0.2))
+							pos += position
+
+							var descriptor = ItemDescriptor(drop)
+
+							if (descriptor.name == "sapling") {
+								descriptor = descriptor.copy(parameters = saplingConfig as? JsonObject ?: JsonObject())
+							}
+
+							val entity = ItemDropEntity(descriptor)
+							entity.position = pos
+							entity.joinWorld(world)
+						}
+					}
+				}
+
+				remove(RemovalReason.DYING)
+				return
+			}
+		}
+
+		if (world.isServer && timeToLive <= 0.0) {
+			remove(RemovalReason.REMOVED)
+		}
+	}
+}
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/WorldObject.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/WorldObject.kt
index a489a225..b4515ba9 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/WorldObject.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/WorldObject.kt
@@ -269,7 +269,7 @@ open class WorldObject(val config: Registry.Entry<ObjectDefinition>) : TileEntit
 			if (connectionsInternal.isNotEmpty()) {
 				// ensure that we disconnect both ends
 				val any = connectionsInternal.removeIf {
-					val otherEntity = world.entityIndex.tileEntityAt(it.entityLocation) as? WorldObject
+					val otherEntity = world.entityIndex.tileEntityAt(it.entityLocation, WorldObject::class)
 					val otherConnections = if (isInput) otherEntity?.outputNodes else otherEntity?.inputNodes
 					val any = otherConnections?.getOrNull(it.index)?.connectionsInternal?.removeIf { it.entityLocation == tilePosition && it.index == index }
 
@@ -547,7 +547,7 @@ open class WorldObject(val config: Registry.Entry<ObjectDefinition>) : TileEntit
 				val itr = node.connectionsInternal.listIterator()
 
 				for (connection in itr) {
-					connection.otherEntity = connection.otherEntity ?: world.entityIndex.tileEntityAt(connection.entityLocation) as? WorldObject
+					connection.otherEntity = connection.otherEntity ?: world.entityIndex.tileEntityAt(connection.entityLocation, WorldObject::class)
 
 					if (connection.otherEntity?.isInWorld == false) {
 						// break connection if other entity got removed
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/KarstCaveTerrainSelector.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/KarstCaveTerrainSelector.kt
index 022384a2..81fbb5b4 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/KarstCaveTerrainSelector.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/KarstCaveTerrainSelector.kt
@@ -1,8 +1,6 @@
 package ru.dbotthepony.kstarbound.world.terrain
 
 import com.github.benmanes.caffeine.cache.Caffeine
-import com.github.benmanes.caffeine.cache.Scheduler
-import ru.dbotthepony.kommons.arrays.Double2DArray
 import ru.dbotthepony.kommons.arrays.Float2DArray
 import ru.dbotthepony.kstarbound.math.vector.Vector2i
 import ru.dbotthepony.kstarbound.Starbound
@@ -63,7 +61,7 @@ class KarstCaveTerrainSelector(data: Data, parameters: TerrainSelectorParameters
 		.softValues()
 		.expireAfterAccess(Duration.ofMinutes(1))
 		.scheduler(Starbound)
-		.executor(Starbound.SCREENED_EXECUTOR)
+		.executor(Starbound.EXECUTOR)
 		.build<Int, Layer>(::Layer)
 
 	private inner class Sector(val sector: Vector2i) {
@@ -132,7 +130,7 @@ class KarstCaveTerrainSelector(data: Data, parameters: TerrainSelectorParameters
 		.softValues()
 		.expireAfterAccess(Duration.ofMinutes(1))
 		.scheduler(Starbound)
-		.executor(Starbound.SCREENED_EXECUTOR)
+		.executor(Starbound.EXECUTOR)
 		.build<Vector2i, Sector>(::Sector)
 
 	override fun get(x: Int, y: Int): Double {
diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/WormCaveTerrainSelector.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/WormCaveTerrainSelector.kt
index d8b2e712..6c4d90b4 100644
--- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/WormCaveTerrainSelector.kt
+++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/WormCaveTerrainSelector.kt
@@ -1,8 +1,6 @@
 package ru.dbotthepony.kstarbound.world.terrain
 
 import com.github.benmanes.caffeine.cache.Caffeine
-import com.github.benmanes.caffeine.cache.Scheduler
-import ru.dbotthepony.kommons.arrays.Double2DArray
 import ru.dbotthepony.kommons.arrays.Float2DArray
 import ru.dbotthepony.kommons.math.linearInterpolation
 import ru.dbotthepony.kstarbound.math.vector.Vector2d
@@ -186,7 +184,7 @@ class WormCaveTerrainSelector(data: Data, parameters: TerrainSelectorParameters)
 		.softValues()
 		.expireAfterAccess(Duration.ofMinutes(1))
 		.scheduler(Starbound)
-		.executor(Starbound.SCREENED_EXECUTOR)
+		.executor(Starbound.EXECUTOR)
 		.build<Vector2i, Sector>(::Sector)
 
 	override fun get(x: Int, y: Int): Double {