From 3bfec3033e9d905514d5c1c6c62953c2a1646af0 Mon Sep 17 00:00:00 2001
From: Linnea Gräf <nea@nea.moe>
Date: Fri, 1 Mar 2024 21:31:48 +0100
Subject: Add mob drop viewer to item list

---
 src/main/kotlin/moe/nea/firmament/commands/rome.kt |  41 +-
 .../moe/nea/firmament/gui/entity/EntityModifier.kt |  14 +
 .../moe/nea/firmament/gui/entity/EntityRenderer.kt | 202 +++++++++
 .../moe/nea/firmament/gui/entity/EntityWidget.kt   |  40 ++
 .../moe/nea/firmament/gui/entity/FakeWorld.kt      | 491 +++++++++++++++++++++
 .../moe/nea/firmament/gui/entity/GuiPlayer.kt      |  55 +++
 .../moe/nea/firmament/gui/entity/ModifyAge.kt      |  30 ++
 .../moe/nea/firmament/gui/entity/ModifyCharged.kt  |  19 +
 .../nea/firmament/gui/entity/ModifyEquipment.kt    |  59 +++
 .../moe/nea/firmament/gui/entity/ModifyHorse.kt    |  66 +++
 .../nea/firmament/gui/entity/ModifyInvisible.kt    |  18 +
 .../moe/nea/firmament/gui/entity/ModifyName.kt     |  19 +
 .../nea/firmament/gui/entity/ModifyPlayerSkin.kt   |  32 ++
 .../moe/nea/firmament/gui/entity/ModifyRiding.kt   |  20 +
 .../moe/nea/firmament/gui/entity/ModifyWither.kt   |  25 ++
 .../moe/nea/firmament/rei/FirmamentReiPlugin.kt    |   4 +
 .../moe/nea/firmament/rei/SBItemEntryDefinition.kt |   8 +-
 .../rei/SkyblockCraftingRecipeDynamicGenerator.kt  |   7 +
 .../nea/firmament/rei/recipes/SBMobDropRecipe.kt   | 113 +++++
 .../moe/nea/firmament/repo/RepoModResourcePack.kt  | 101 +++++
 src/main/kotlin/moe/nea/firmament/util/ItemUtil.kt |   2 +
 .../kotlin/moe/nea/firmament/util/LoadResource.kt  |  25 ++
 .../kotlin/moe/nea/firmament/util/assertions.kt    |   1 +
 .../moe/nea/firmament/util/item/SkullItemData.kt   |  40 +-
 .../firmament/util/render/TranslatedScissors.kt    |  27 ++
 25 files changed, 1447 insertions(+), 12 deletions(-)
 create mode 100644 src/main/kotlin/moe/nea/firmament/gui/entity/EntityModifier.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/gui/entity/EntityRenderer.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/gui/entity/EntityWidget.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/gui/entity/FakeWorld.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/gui/entity/GuiPlayer.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/gui/entity/ModifyAge.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/gui/entity/ModifyCharged.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/gui/entity/ModifyEquipment.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/gui/entity/ModifyHorse.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/gui/entity/ModifyInvisible.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/gui/entity/ModifyName.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/gui/entity/ModifyPlayerSkin.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/gui/entity/ModifyRiding.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/gui/entity/ModifyWither.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/rei/recipes/SBMobDropRecipe.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/repo/RepoModResourcePack.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/util/LoadResource.kt
 create mode 100644 src/main/kotlin/moe/nea/firmament/util/render/TranslatedScissors.kt

(limited to 'src/main/kotlin')

diff --git a/src/main/kotlin/moe/nea/firmament/commands/rome.kt b/src/main/kotlin/moe/nea/firmament/commands/rome.kt
index 91bdf47..7df39b3 100644
--- a/src/main/kotlin/moe/nea/firmament/commands/rome.kt
+++ b/src/main/kotlin/moe/nea/firmament/commands/rome.kt
@@ -1,5 +1,6 @@
 /*
  * SPDX-FileCopyrightText: 2023 Linnea Gräf <nea@nea.moe>
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
  *
  * SPDX-License-Identifier: GPL-3.0-or-later
  */
@@ -52,7 +53,12 @@ fun firmamentCommand() = literal("firmament") {
 
                         val configObj = AllConfigsGui.allConfigs.find { it.name == config }
                         if (configObj == null) {
-                            source.sendFeedback(Text.stringifiedTranslatable("firmament.command.toggle.no-config-found", config))
+                            source.sendFeedback(
+                                Text.stringifiedTranslatable(
+                                    "firmament.command.toggle.no-config-found",
+                                    config
+                                )
+                            )
                             return@thenExecute
                         }
                         val propertyObj = configObj.allOptions[property]
@@ -72,9 +78,11 @@ fun firmamentCommand() = literal("firmament") {
                         propertyObj.value = !propertyObj.value
                         configObj.save()
                         source.sendFeedback(
-                            Text.stringifiedTranslatable("firmament.command.toggle.toggled",configObj.labelText,
-                            propertyObj.labelText,
-                            Text.translatable("firmament.toggle.${propertyObj.value}"))
+                            Text.stringifiedTranslatable(
+                                "firmament.command.toggle.toggled", configObj.labelText,
+                                propertyObj.labelText,
+                                Text.translatable("firmament.toggle.${propertyObj.value}")
+                            )
                         )
                     }
                 }
@@ -144,22 +152,37 @@ fun firmamentCommand() = literal("firmament") {
                         Text.stringifiedTranslatable("firmament.price.bazaar.productid", bazaarData.productId.bazaarId)
                     )
                     source.sendFeedback(
-                        Text.stringifiedTranslatable("firmament.price.bazaar.buy.price", FirmFormatters.formatCurrency(bazaarData.quickStatus.buyPrice, 1))
+                        Text.stringifiedTranslatable(
+                            "firmament.price.bazaar.buy.price",
+                            FirmFormatters.formatCurrency(bazaarData.quickStatus.buyPrice, 1)
+                        )
                     )
                     source.sendFeedback(
-                        Text.stringifiedTranslatable("firmament.price.bazaar.buy.order", bazaarData.quickStatus.buyOrders)
+                        Text.stringifiedTranslatable(
+                            "firmament.price.bazaar.buy.order",
+                            bazaarData.quickStatus.buyOrders
+                        )
                     )
                     source.sendFeedback(
-                        Text.stringifiedTranslatable("firmament.price.bazaar.sell.price", FirmFormatters.formatCurrency(bazaarData.quickStatus.sellPrice, 1))
+                        Text.stringifiedTranslatable(
+                            "firmament.price.bazaar.sell.price",
+                            FirmFormatters.formatCurrency(bazaarData.quickStatus.sellPrice, 1)
+                        )
                     )
                     source.sendFeedback(
-                        Text.stringifiedTranslatable("firmament.price.bazaar.sell.order", bazaarData.quickStatus.sellOrders)
+                        Text.stringifiedTranslatable(
+                            "firmament.price.bazaar.sell.order",
+                            bazaarData.quickStatus.sellOrders
+                        )
                     )
                 }
                 val lowestBin = HypixelStaticData.lowestBin[itemName]
                 if (lowestBin != null) {
                     source.sendFeedback(
-                        Text.stringifiedTranslatable("firmament.price.lowestbin", FirmFormatters.formatCurrency(lowestBin, 1))
+                        Text.stringifiedTranslatable(
+                            "firmament.price.lowestbin",
+                            FirmFormatters.formatCurrency(lowestBin, 1)
+                        )
                     )
                 }
             }
diff --git a/src/main/kotlin/moe/nea/firmament/gui/entity/EntityModifier.kt b/src/main/kotlin/moe/nea/firmament/gui/entity/EntityModifier.kt
new file mode 100644
index 0000000..fae3a4b
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/gui/entity/EntityModifier.kt
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.gui.entity
+
+import com.google.gson.JsonObject
+import net.minecraft.entity.LivingEntity
+
+fun interface EntityModifier {
+    fun apply(entity: LivingEntity, info: JsonObject): LivingEntity
+}
diff --git a/src/main/kotlin/moe/nea/firmament/gui/entity/EntityRenderer.kt b/src/main/kotlin/moe/nea/firmament/gui/entity/EntityRenderer.kt
new file mode 100644
index 0000000..d645e5b
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/gui/entity/EntityRenderer.kt
@@ -0,0 +1,202 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.gui.entity
+
+import com.google.gson.Gson
+import com.google.gson.JsonArray
+import com.google.gson.JsonObject
+import org.apache.logging.log4j.LogManager
+import org.joml.Quaternionf
+import org.joml.Vector3f
+import kotlin.math.atan
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.client.gui.screen.ingame.InventoryScreen
+import net.minecraft.entity.Entity
+import net.minecraft.entity.EntityType
+import net.minecraft.entity.LivingEntity
+import net.minecraft.util.Identifier
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.assertNotNullOr
+import moe.nea.firmament.util.iterate
+import moe.nea.firmament.util.openFirmamentResource
+import moe.nea.firmament.util.render.enableScissorWithTranslation
+
+object EntityRenderer {
+    val fakeWorld = FakeWorld()
+    private fun <T : Entity> t(entityType: EntityType<T>): () -> T {
+        return { entityType.create(fakeWorld)!! }
+    }
+
+    val entityIds: Map<String, () -> LivingEntity> = mapOf(
+        "Zombie" to t(EntityType.ZOMBIE),
+        "Chicken" to t(EntityType.CHICKEN),
+        "Slime" to t(EntityType.SLIME),
+        "Wolf" to t(EntityType.WOLF),
+        "Skeleton" to t(EntityType.SKELETON),
+        "Creeper" to t(EntityType.CREEPER),
+        "Ocelot" to t(EntityType.OCELOT),
+        "Blaze" to t(EntityType.BLAZE),
+        "Rabbit" to t(EntityType.RABBIT),
+        "Sheep" to t(EntityType.SHEEP),
+        "Horse" to t(EntityType.HORSE),
+        "Eisengolem" to t(EntityType.IRON_GOLEM),
+        "Silverfish" to t(EntityType.SILVERFISH),
+        "Witch" to t(EntityType.WITCH),
+        "Endermite" to t(EntityType.ENDERMITE),
+        "Snowman" to t(EntityType.SNOW_GOLEM),
+        "Villager" to t(EntityType.VILLAGER),
+        "Guardian" to t(EntityType.GUARDIAN),
+        "ArmorStand" to t(EntityType.ARMOR_STAND),
+        "Squid" to t(EntityType.SQUID),
+        "Bat" to t(EntityType.BAT),
+        "Spider" to t(EntityType.SPIDER),
+        "CaveSpider" to t(EntityType.CAVE_SPIDER),
+        "Pigman" to t(EntityType.ZOMBIFIED_PIGLIN),
+        "Ghast" to t(EntityType.GHAST),
+        "MagmaCube" to t(EntityType.MAGMA_CUBE),
+        "Wither" to t(EntityType.WITHER),
+        "Enderman" to t(EntityType.ENDERMAN),
+        "Mooshroom" to t(EntityType.MOOSHROOM),
+        "WitherSkeleton" to t(EntityType.WITHER_SKELETON),
+        "Cow" to t(EntityType.COW),
+        "Dragon" to t(EntityType.ENDER_DRAGON),
+        "Player" to { makeGuiPlayer(fakeWorld) },
+        "Pig" to t(EntityType.PIG),
+        "Giant" to t(EntityType.GIANT),
+    )
+    val entityModifiers: Map<String, EntityModifier> = mapOf(
+        "playerdata" to ModifyPlayerSkin,
+        "equipment" to ModifyEquipment,
+        "riding" to ModifyRiding,
+        "charged" to ModifyCharged,
+        "witherdata" to ModifyWither,
+        "invisible" to ModifyInvisible,
+        "age" to ModifyAge,
+        "horse" to ModifyHorse,
+        "name" to ModifyName,
+    )
+
+    val logger = LogManager.getLogger("Firmament.Entity")
+    fun applyModifiers(entityId: String, modifiers: List<JsonObject>): LivingEntity? {
+        val entityType = assertNotNullOr(entityIds[entityId]) {
+            logger.error("Could not create entity with id $entityId")
+            return null
+        }
+        var entity = entityType()
+        for (modifierJson in modifiers) {
+            val modifier = assertNotNullOr(modifierJson["type"]?.asString?.let(entityModifiers::get)) {
+                logger.error("Unknown modifier $modifierJson")
+                return null
+            }
+            entity = modifier.apply(entity, modifierJson)
+        }
+        return entity
+    }
+
+    fun constructEntity(info: JsonObject): LivingEntity? {
+        val modifiers = (info["modifiers"] as JsonArray?)?.map { it.asJsonObject } ?: emptyList()
+        val entityType = assertNotNullOr(info["entity"]?.asString) {
+            logger.error("Missing entity type on entity object")
+            return null
+        }
+        return applyModifiers(entityType, modifiers)
+    }
+
+    private val gson = Gson()
+    fun constructEntity(location: Identifier): LivingEntity? {
+        return constructEntity(
+            gson.fromJson(
+                location.openFirmamentResource().bufferedReader(), JsonObject::class.java
+            )
+        )
+    }
+
+    fun renderEntity(
+        entity: LivingEntity,
+        renderContext: DrawContext,
+        posX: Int,
+        posY: Int,
+        mouseX: Float,
+        mouseY: Float
+    ) {
+        var bottomOffset = 0.0F
+        var currentEntity = entity
+        val maxSize = entity.iterate { it.firstPassenger as? LivingEntity }
+            .map { it.height }
+            .sum()
+        while (true) {
+            currentEntity.age = MC.player?.age ?: 0
+            drawEntity(
+                renderContext,
+                posX,
+                posY,
+                posX + 50,
+                posY + 80,
+                (2F / maxSize * 30).toInt(),
+                -bottomOffset,
+                mouseX,
+                mouseY,
+                currentEntity
+            )
+            val next = currentEntity.firstPassenger as? LivingEntity ?: break
+            bottomOffset += currentEntity.getPassengerRidingPos(next).y.toFloat() * 0.75F
+            currentEntity = next
+        }
+    }
+
+
+    fun drawEntity(
+        context: DrawContext,
+        x1: Int,
+        y1: Int,
+        x2: Int,
+        y2: Int,
+        size: Int,
+        bottomOffset: Float,
+        mouseX: Float,
+        mouseY: Float,
+        entity: LivingEntity
+    ) {
+        context.enableScissorWithTranslation(x1.toFloat(), y1.toFloat(), x2.toFloat(), y2.toFloat())
+        val centerX = (x1 + x2) / 2f
+        val centerY = (y1 + y2) / 2f
+        val targetYaw = atan(((centerX - mouseX) / 40.0f).toDouble()).toFloat()
+        val targetPitch = atan(((centerY - mouseY) / 40.0f).toDouble()).toFloat()
+        val rotateToFaceTheFront = Quaternionf().rotateZ(Math.PI.toFloat())
+        val rotateToFaceTheCamera = Quaternionf().rotateX(targetPitch * 20.0f * (Math.PI.toFloat() / 180))
+        rotateToFaceTheFront.mul(rotateToFaceTheCamera)
+        val oldBodyYaw = entity.bodyYaw
+        val oldYaw = entity.yaw
+        val oldPitch = entity.pitch
+        val oldPrevHeadYaw = entity.prevHeadYaw
+        val oldHeadYaw = entity.headYaw
+        entity.bodyYaw = 180.0f + targetYaw * 20.0f
+        entity.yaw = 180.0f + targetYaw * 40.0f
+        entity.pitch = -targetPitch * 20.0f
+        entity.headYaw = entity.yaw
+        entity.prevHeadYaw = entity.yaw
+        val vector3f = Vector3f(0.0f, entity.height / 2.0f + bottomOffset, 0.0f)
+        InventoryScreen.drawEntity(
+            context,
+            centerX,
+            centerY,
+            size,
+            vector3f,
+            rotateToFaceTheFront,
+            rotateToFaceTheCamera,
+            entity
+        )
+        entity.bodyYaw = oldBodyYaw
+        entity.yaw = oldYaw
+        entity.pitch = oldPitch
+        entity.prevHeadYaw = oldPrevHeadYaw
+        entity.headYaw = oldHeadYaw
+        context.disableScissor()
+    }
+
+
+}
diff --git a/src/main/kotlin/moe/nea/firmament/gui/entity/EntityWidget.kt b/src/main/kotlin/moe/nea/firmament/gui/entity/EntityWidget.kt
new file mode 100644
index 0000000..42fa485
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/gui/entity/EntityWidget.kt
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.gui.entity
+
+import me.shedaniel.math.Dimension
+import me.shedaniel.math.Point
+import me.shedaniel.math.Rectangle
+import me.shedaniel.rei.api.client.gui.widgets.WidgetWithBounds
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.client.gui.Element
+import net.minecraft.entity.LivingEntity
+
+class EntityWidget(val entity: LivingEntity, val point: Point) : WidgetWithBounds() {
+    override fun children(): List<Element> {
+        return emptyList()
+    }
+
+    var hasErrored = false
+
+    override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) {
+        try {
+            if (!hasErrored)
+                EntityRenderer.renderEntity(entity, context, point.x, point.y, mouseX.toFloat(), mouseY.toFloat())
+        } catch (ex: Exception) {
+            EntityRenderer.logger.error("Failed to render constructed entity: $entity", ex)
+            hasErrored = true
+        }
+        if (hasErrored) {
+            context.fill(point.x, point.y, point.x + 50, point.y + 80, 0xFFAA2222.toInt())
+        }
+    }
+
+    override fun getBounds(): Rectangle {
+        return Rectangle(point, Dimension(50, 80))
+    }
+}
diff --git a/src/main/kotlin/moe/nea/firmament/gui/entity/FakeWorld.kt b/src/main/kotlin/moe/nea/firmament/gui/entity/FakeWorld.kt
new file mode 100644
index 0000000..4cdfc45
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/gui/entity/FakeWorld.kt
@@ -0,0 +1,491 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.gui.entity
+
+import com.mojang.datafixers.util.Pair
+import com.mojang.serialization.Lifecycle
+import java.util.*
+import java.util.function.BooleanSupplier
+import java.util.function.Consumer
+import java.util.stream.Stream
+import kotlin.jvm.optionals.getOrNull
+import kotlin.streams.asSequence
+import net.minecraft.block.Block
+import net.minecraft.block.BlockState
+import net.minecraft.client.world.ClientWorld
+import net.minecraft.entity.Entity
+import net.minecraft.entity.player.PlayerEntity
+import net.minecraft.fluid.Fluid
+import net.minecraft.item.map.MapState
+import net.minecraft.recipe.RecipeManager
+import net.minecraft.registry.BuiltinRegistries
+import net.minecraft.registry.DynamicRegistryManager
+import net.minecraft.registry.Registry
+import net.minecraft.registry.RegistryKey
+import net.minecraft.registry.RegistryKeys
+import net.minecraft.registry.RegistryWrapper
+import net.minecraft.registry.entry.RegistryEntry
+import net.minecraft.registry.entry.RegistryEntryList
+import net.minecraft.registry.entry.RegistryEntryOwner
+import net.minecraft.registry.tag.TagKey
+import net.minecraft.resource.featuretoggle.FeatureFlag
+import net.minecraft.resource.featuretoggle.FeatureFlags
+import net.minecraft.resource.featuretoggle.FeatureSet
+import net.minecraft.scoreboard.Scoreboard
+import net.minecraft.sound.SoundCategory
+import net.minecraft.sound.SoundEvent
+import net.minecraft.util.Identifier
+import net.minecraft.util.TypeFilter
+import net.minecraft.util.function.LazyIterationConsumer
+import net.minecraft.util.math.BlockPos
+import net.minecraft.util.math.Box
+import net.minecraft.util.math.ChunkPos
+import net.minecraft.util.math.Direction
+import net.minecraft.util.math.Vec3d
+import net.minecraft.util.math.random.Random
+import net.minecraft.util.profiler.DummyProfiler
+import net.minecraft.world.BlockView
+import net.minecraft.world.Difficulty
+import net.minecraft.world.GameRules
+import net.minecraft.world.MutableWorldProperties
+import net.minecraft.world.World
+import net.minecraft.world.biome.Biome
+import net.minecraft.world.biome.BiomeKeys
+import net.minecraft.world.chunk.Chunk
+import net.minecraft.world.chunk.ChunkManager
+import net.minecraft.world.chunk.ChunkStatus
+import net.minecraft.world.chunk.EmptyChunk
+import net.minecraft.world.chunk.light.LightingProvider
+import net.minecraft.world.entity.EntityLookup
+import net.minecraft.world.event.GameEvent
+import net.minecraft.world.tick.OrderedTick
+import net.minecraft.world.tick.QueryableTickScheduler
+import net.minecraft.world.tick.TickManager
+
+fun <T> makeRegistry(registryWrapper: RegistryWrapper.Impl<T>, key: RegistryKey<out Registry<T>>): Registry<T> {
+    val inverseLookup = registryWrapper.streamEntries()
+        .asSequence().map { it.value() to it.registryKey() }
+        .toMap()
+    val idLookup = registryWrapper.streamEntries()
+        .asSequence()
+        .map { it.registryKey() }
+        .withIndex()
+        .associate { it.value to it.index }
+    val map = registryWrapper.streamEntries().asSequence().map { it.registryKey() to it.value() }.toMap(mutableMapOf())
+    val inverseIdLookup = idLookup.asIterable().associate { (k, v) -> v to k }
+    return object : Registry<T> {
+        override fun get(key: RegistryKey<T>?): T? {
+            return registryWrapper.getOptional(key).getOrNull()?.value()
+        }
+
+        override fun iterator(): MutableIterator<T> {
+            return object : MutableIterator<T> {
+                val iterator = registryWrapper.streamEntries().iterator()
+                override fun hasNext(): Boolean {
+                    return iterator.hasNext()
+                }
+
+                override fun next(): T {
+                    return iterator.next().value()
+                }
+
+                override fun remove() {
+                    TODO("Not yet implemented")
+                }
+            }
+        }
+
+        override fun getRawId(value: T?): Int {
+            return idLookup[inverseLookup[value ?: return -1] ?: return -1] ?: return -1
+        }
+
+        override fun get(id: Identifier?): T? {
+            return get(RegistryKey.of(key, id))
+        }
+
+        override fun get(index: Int): T? {
+            return get(inverseIdLookup[index] ?: return null)
+        }
+
+        override fun size(): Int {
+            return idLookup.size
+        }
+
+        override fun getKey(): RegistryKey<out Registry<T>> {
+            return key
+        }
+
+        override fun getLifecycle(): Lifecycle {
+            return Lifecycle.stable()
+        }
+
+        override fun getIds(): MutableSet<Identifier> {
+            return idLookup.keys.mapTo(mutableSetOf()) { it.value }
+        }
+
+        override fun getEntrySet(): MutableSet<MutableMap.MutableEntry<RegistryKey<T>, T>> {
+            return map.entries
+        }
+
+        override fun getKeys(): MutableSet<RegistryKey<T>> {
+            return map.keys
+        }
+
+        override fun getRandom(random: Random?): Optional<RegistryEntry.Reference<T>> {
+            return registryWrapper.streamEntries().findFirst()
+        }
+
+        override fun containsId(id: Identifier?): Boolean {
+            return idLookup.containsKey(RegistryKey.of(key, id ?: return false))
+        }
+
+        override fun freeze(): Registry<T> {
+            return this
+        }
+
+        override fun getEntry(rawId: Int): Optional<RegistryEntry.Reference<T>> {
+            val x = inverseIdLookup[rawId] ?: return Optional.empty()
+            return Optional.of(RegistryEntry.Reference.standAlone(registryWrapper, x))
+        }
+
+        override fun streamEntries(): Stream<RegistryEntry.Reference<T>> {
+            return registryWrapper.streamEntries()
+        }
+
+        override fun streamTagsAndEntries(): Stream<Pair<TagKey<T>, RegistryEntryList.Named<T>>> {
+            return streamTags().map { Pair(it, getOrCreateEntryList(it)) }
+        }
+
+        override fun streamTags(): Stream<TagKey<T>> {
+            return registryWrapper.streamTagKeys()
+        }
+
+        override fun clearTags() {
+        }
+
+        override fun getEntryOwner(): RegistryEntryOwner<T> {
+            return registryWrapper
+        }
+
+        override fun getReadOnlyWrapper(): RegistryWrapper.Impl<T> {
+            return registryWrapper
+        }
+
+        override fun populateTags(tagEntries: MutableMap<TagKey<T>, MutableList<RegistryEntry<T>>>?) {
+        }
+
+        override fun getOrCreateEntryList(tag: TagKey<T>?): RegistryEntryList.Named<T> {
+            return getEntryList(tag).orElseGet { RegistryEntryList.of(registryWrapper, tag) }
+        }
+
+        override fun getEntryList(tag: TagKey<T>?): Optional<RegistryEntryList.Named<T>> {
+            return registryWrapper.getOptional(tag ?: return Optional.empty())
+        }
+
+        override fun getEntry(value: T): RegistryEntry<T> {
+            return registryWrapper.getOptional(inverseLookup[value]!!).get()
+        }
+
+        override fun getEntry(key: RegistryKey<T>?): Optional<RegistryEntry.Reference<T>> {
+            return registryWrapper.getOptional(key ?: return Optional.empty())
+        }
+
+        override fun createEntry(value: T): RegistryEntry.Reference<T> {
+            TODO()
+        }
+
+        override fun contains(key: RegistryKey<T>?): Boolean {
+            return getEntry(key).isPresent
+        }
+
+        override fun getEntryLifecycle(entry: T): Lifecycle {
+            return Lifecycle.stable()
+        }
+
+        override fun getId(value: T): Identifier? {
+            return (inverseLookup[value] ?: return null).value
+        }
+
+        override fun getKey(entry: T): Optional<RegistryKey<T>> {
+            return Optional.ofNullable(inverseLookup[entry ?: return Optional.empty()])
+        }
+    }
+}
+
+fun createDynamicRegistry(): DynamicRegistryManager.Immutable {
+    val wrapperLookup = BuiltinRegistries.createWrapperLookup()
+    return object : DynamicRegistryManager.Immutable {
+        override fun <E : Any?> getOptional(key: RegistryKey<out Registry<out E>>): Optional<Registry<E>> {
+            val lookup = wrapperLookup.getOptionalWrapper(key).getOrNull() ?: return Optional.empty()
+            val registry = makeRegistry(lookup, key as RegistryKey<out Registry<E>>)
+            return Optional.of(registry)
+        }
+
+        fun <T> entry(reg: RegistryKey<out Registry<T>>): DynamicRegistryManager.Entry<T> {
+            return DynamicRegistryManager.Entry(reg, getOptional(reg).get())
+        }
+
+        override fun streamAllRegistries(): Stream<DynamicRegistryManager.Entry<*>> {
+            return wrapperLookup.streamAllRegistryKeys()
+                .map { entry(it as RegistryKey<out Registry<Any>>) }
+        }
+    }
+}
+
+class FakeWorld(registries: DynamicRegistryManager.Immutable = createDynamicRegistry()) : World(
+    Properties,
+    RegistryKey.of(RegistryKeys.WORLD, Identifier.of("firmament", "fakeworld")),
+    registries,
+    registries[RegistryKeys.DIMENSION_TYPE].entryOf(
+        RegistryKey.of(
+            RegistryKeys.DIMENSION_TYPE,
+            Identifier("minecraft", "overworld")
+        )
+    ),
+    { DummyProfiler.INSTANCE },
+    true,
+    false,
+    0, 0
+) {
+    object Properties : MutableWorldProperties {
+        override fun getSpawnX(): Int {
+            return 0
+        }
+
+        override fun getSpawnY(): Int {
+            return 0
+        }
+
+        override fun getSpawnZ(): Int {
+            return 0
+        }
+
+        override fun getSpawnAngle(): Float {
+            return 0F
+        }
+
+        override fun getTime(): Long {
+            return 0
+        }
+
+        override fun getTimeOfDay(): Long {
+            return 0
+        }
+
+        override fun isThundering(): Boolean {
+            return false
+        }
+
+        override fun isRaining(): Boolean {
+            return false
+        }
+
+        override fun setRaining(raining: Boolean) {
+        }
+
+        override fun isHardcore(): Boolean {
+            return false
+        }
+
+        override fun getGameRules(): GameRules {
+            return GameRules()
+        }
+
+        override fun getDifficulty(): Difficulty {
+            return Difficulty.HARD
+        }
+
+        override fun isDifficultyLocked(): Boolean {
+            return false
+        }
+
+        override fun setSpawnX(spawnX: Int) {
+        }
+
+        override fun setSpawnY(spawnY: Int) {
+        }
+
+        override fun setSpawnZ(spawnZ: Int) {
+        }
+
+        override fun setSpawnAngle(spawnAngle: Float) {
+        }
+    }
+
+    override fun getPlayers(): List<PlayerEntity> {
+        return emptyList()
+    }
+
+    override fun getBrightness(direction: Direction?, shaded: Boolean): Float {
+        return 1f
+    }
+
+    override fun getGeneratorStoredBiome(biomeX: Int, biomeY: Int, biomeZ: Int): RegistryEntry<Biome> {
+        return registryManager.get(RegistryKeys.BIOME).entryOf(BiomeKeys.PLAINS)
+    }
+
+    override fun getEnabledFeatures(): FeatureSet {
+        return FeatureFlags.VANILLA_FEATURES
+    }
+
+    class FakeTickScheduler<T> : QueryableTickScheduler<T> {
+        override fun scheduleTick(orderedTick: OrderedTick<T>?) {
+        }
+
+        override fun isQueued(pos: BlockPos?, type: T): Boolean {
+            return true
+        }
+
+        override fun getTickCount(): Int {
+            return 0
+        }
+
+        override fun isTicking(pos: BlockPos?, type: T): Boolean {
+            return true
+        }
+
+    }
+
+    override fun getBlockTickScheduler(): QueryableTickScheduler<Block> {
+        return FakeTickScheduler()
+    }
+
+    override fun getFluidTickScheduler(): QueryableTickScheduler<Fluid> {
+        return FakeTickScheduler()
+    }
+
+
+    class FakeChunkManager(val world: FakeWorld) : ChunkManager() {
+        override fun getChunk(x: Int, z: Int, leastStatus: ChunkStatus?, create: Boolean): Chunk {
+            return EmptyChunk(world, ChunkPos(x,z), world.registryManager.get(RegistryKeys.BIOME).entryOf(BiomeKeys.PLAINS))
+        }
+
+        override fun getWorld(): BlockView {
+            return world
+        }
+
+        override fun tick(shouldKeepTicking: BooleanSupplier?, tickChunks: Boolean) {
+        }
+
+        override fun getDebugString(): String {
+            return "FakeChunkManager"
+        }
+
+        override fun getLoadedChunkCount(): Int {
+            return 0
+        }
+
+        override fun getLightingProvider(): LightingProvider {
+            return FakeLightingProvider(this)
+        }
+    }
+
+    class FakeLightingProvider(chunkManager: FakeChunkManager) : LightingProvider(chunkManager, false, false)
+
+    override fun getChunkManager(): ChunkManager {
+        return FakeChunkManager(this)
+    }
+
+    override fun playSound(
+        source: PlayerEntity?,
+        x: Double,
+        y: Double,
+        z: Double,
+        sound: RegistryEntry<SoundEvent>?,
+        category: SoundCategory?,
+        volume: Float,
+        pitch: Float,
+        seed: Long
+    ) {
+    }
+
+    override fun syncWorldEvent(player: PlayerEntity?, eventId: Int, pos: BlockPos?, data: Int) {
+    }
+
+    override fun emitGameEvent(event: GameEvent?, emitterPos: Vec3d?, emitter: GameEvent.Emitter?) {
+    }
+
+    override fun updateListeners(pos: BlockPos?, oldState: BlockState?, newState: BlockState?, flags: Int) {
+    }
+
+    override fun playSoundFromEntity(
+        source: PlayerEntity?,
+        entity: Entity?,
+        sound: RegistryEntry<SoundEvent>?,
+        category: SoundCategory?,
+        volume: Float,
+        pitch: Float,
+        seed: Long
+    ) {
+    }
+
+    override fun asString(): String {
+        return "FakeWorld"
+    }
+
+    override fun getEntityById(id: Int): Entity? {
+        return null
+    }
+
+    override fun getTickManager(): TickManager {
+        return TickManager()
+    }
+
+    override fun getMapState(id: String?): MapState? {
+        return null
+    }
+
+    override fun putMapState(id: String?, state: MapState?) {
+    }
+
+    override fun getNextMapId(): Int {
+        return 0
+    }
+
+    override fun setBlockBreakingInfo(entityId: Int, pos: BlockPos?, progress: Int) {
+    }
+
+    override fun getScoreboard(): Scoreboard {
+        return Scoreboard()
+    }
+
+    override fun getRecipeManager(): RecipeManager {
+        return RecipeManager()
+    }
+
+    object FakeEntityLookup : EntityLookup<Entity> {
+        override fun get(id: Int): Entity? {
+            return null
+        }
+
+        override fun get(uuid: UUID?): Entity? {
+            return null
+        }
+
+        override fun iterate(): MutableIterable<Entity> {
+            return mutableListOf()
+        }
+
+        override fun <U : Entity?> forEachIntersects(
+            filter: TypeFilter<Entity, U>?,
+            box: Box?,
+            consumer: LazyIterationConsumer<U>?
+        ) {
+        }
+
+        override fun forEachIntersects(box: Box?, action: Consumer<Entity>?) {
+        }
+
+        override fun <U : Entity?> forEach(filter: TypeFilter<Entity, U>?, consumer: LazyIterationConsumer<U>?) {
+        }
+
+    }
+
+    override fun getEntityLookup(): EntityLookup<Entity> {
+        return FakeEntityLookup
+    }
+}
diff --git a/src/main/kotlin/moe/nea/firmament/gui/entity/GuiPlayer.kt b/src/main/kotlin/moe/nea/firmament/gui/entity/GuiPlayer.kt
new file mode 100644
index 0000000..5f88098
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/gui/entity/GuiPlayer.kt
@@ -0,0 +1,55 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.gui.entity
+
+import com.mojang.authlib.GameProfile
+import java.util.*
+import net.minecraft.client.network.AbstractClientPlayerEntity
+import net.minecraft.client.util.DefaultSkinHelper
+import net.minecraft.client.util.SkinTextures
+import net.minecraft.client.util.SkinTextures.Model
+import net.minecraft.client.world.ClientWorld
+import net.minecraft.util.Identifier
+import net.minecraft.util.math.BlockPos
+import net.minecraft.world.World
+
+/**
+ * @see moe.nea.firmament.init.EarlyRiser
+ */
+fun makeGuiPlayer(world: FakeWorld): GuiPlayer {
+    val constructor = GuiPlayer::class.java.getDeclaredConstructor(
+        World::class.java,
+        BlockPos::class.java,
+        Float::class.javaPrimitiveType,
+        GameProfile::class.java
+    )
+    return constructor.newInstance(world, BlockPos.ORIGIN, 0F, GameProfile(UUID.randomUUID(), "Linnea"))
+}
+
+class GuiPlayer(world: ClientWorld?, profile: GameProfile?) : AbstractClientPlayerEntity(world, profile) {
+    override fun isSpectator(): Boolean {
+        return false
+    }
+
+    override fun isCreative(): Boolean {
+        return false
+    }
+
+    var skinTexture: Identifier = DefaultSkinHelper.getSkinTextures(this.getUuid()).texture
+    var capeTexture: Identifier? = null
+    var model: Model = Model.WIDE
+    override fun getSkinTextures(): SkinTextures {
+        return SkinTextures(
+            skinTexture,
+            null,
+            capeTexture,
+            null,
+            model,
+            true
+        )
+    }
+}
diff --git a/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyAge.kt b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyAge.kt
new file mode 100644
index 0000000..7b80e88
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyAge.kt
@@ -0,0 +1,30 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.gui.entity
+
+import com.google.gson.JsonObject
+import net.minecraft.entity.LivingEntity
+import net.minecraft.entity.decoration.ArmorStandEntity
+import net.minecraft.entity.mob.ZombieEntity
+import net.minecraft.entity.passive.PassiveEntity
+
+object ModifyAge : EntityModifier {
+    override fun apply(entity: LivingEntity, info: JsonObject): LivingEntity {
+        val isBaby = info["baby"]?.asBoolean ?: false
+        if (entity is PassiveEntity) {
+            entity.breedingAge = if (isBaby) -1 else 1
+        } else if (entity is ZombieEntity) {
+            entity.isBaby = isBaby
+        } else if (entity is ArmorStandEntity) {
+            entity.isSmall = isBaby
+        } else {
+            error("Cannot set age for $entity")
+        }
+        return entity
+    }
+
+}
diff --git a/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyCharged.kt b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyCharged.kt
new file mode 100644
index 0000000..225a8ca
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyCharged.kt
@@ -0,0 +1,19 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.gui.entity
+
+import com.google.gson.JsonObject
+import net.minecraft.entity.LivingEntity
+import net.minecraft.entity.mob.CreeperEntity
+
+object ModifyCharged : EntityModifier {
+    override fun apply(entity: LivingEntity, info: JsonObject): LivingEntity {
+        require(entity is CreeperEntity)
+        entity.dataTracker.set(CreeperEntity.CHARGED, true)
+        return entity
+    }
+}
diff --git a/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyEquipment.kt b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyEquipment.kt
new file mode 100644
index 0000000..e438f59
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyEquipment.kt
@@ -0,0 +1,59 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.gui.entity
+
+import com.google.gson.JsonObject
+import net.minecraft.entity.EquipmentSlot
+import net.minecraft.entity.LivingEntity
+import net.minecraft.item.DyeableArmorItem
+import net.minecraft.item.Item
+import net.minecraft.item.ItemStack
+import net.minecraft.item.Items
+import moe.nea.firmament.rei.SBItemStack
+import moe.nea.firmament.util.SkyblockId
+import moe.nea.firmament.util.item.setEncodedSkullOwner
+import moe.nea.firmament.util.item.zeroUUID
+
+object ModifyEquipment : EntityModifier {
+    val names = mapOf(
+        "hand" to EquipmentSlot.MAINHAND,
+        "helmet" to EquipmentSlot.HEAD,
+        "chestplate" to EquipmentSlot.CHEST,
+        "leggings" to EquipmentSlot.LEGS,
+        "feet" to EquipmentSlot.FEET,
+    )
+
+    override fun apply(entity: LivingEntity, info: JsonObject): LivingEntity {
+        names.forEach { (key, slot) ->
+            info[key]?.let {
+                entity.equipStack(slot, createItem(it.asString))
+            }
+        }
+        return entity
+    }
+
+    private fun createItem(item: String): ItemStack {
+        val split = item.split("#")
+        if (split.size != 2) return SBItemStack(SkyblockId(item)).asImmutableItemStack()
+        val (type, data) = split
+        return when (type) {
+            "SKULL" -> ItemStack(Items.PLAYER_HEAD).also { it.setEncodedSkullOwner(zeroUUID, data) }
+            "LEATHER_LEGGINGS" -> coloredLeatherArmor(Items.LEATHER_LEGGINGS, data)
+            "LEATHER_BOOTS" -> coloredLeatherArmor(Items.LEATHER_BOOTS, data)
+            "LEATHER_HELMET" -> coloredLeatherArmor(Items.LEATHER_HELMET, data)
+            "LEATHER_CHESTPLATE" -> coloredLeatherArmor(Items.LEATHER_CHESTPLATE, data)
+            else -> error("Unknown leather piece: $type")
+        }
+    }
+
+    private fun coloredLeatherArmor(leatherArmor: Item, data: String): ItemStack {
+        require(leatherArmor is DyeableArmorItem)
+        val stack = ItemStack(leatherArmor)
+        leatherArmor.setColor(stack, data.toInt(16))
+        return stack
+    }
+}
diff --git a/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyHorse.kt b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyHorse.kt
new file mode 100644
index 0000000..4c49510
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyHorse.kt
@@ -0,0 +1,66 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.gui.entity
+
+import com.google.gson.JsonNull
+import com.google.gson.JsonObject
+import kotlin.experimental.and
+import kotlin.experimental.inv
+import kotlin.experimental.or
+import net.minecraft.entity.EntityType
+import net.minecraft.entity.LivingEntity
+import net.minecraft.entity.passive.AbstractHorseEntity
+import net.minecraft.item.ItemStack
+import net.minecraft.item.Items
+import moe.nea.firmament.gui.entity.EntityRenderer.fakeWorld
+
+object ModifyHorse : EntityModifier {
+    override fun apply(entity: LivingEntity, info: JsonObject): LivingEntity {
+        require(entity is AbstractHorseEntity)
+        var entity: AbstractHorseEntity = entity
+        info["kind"]?.let {
+            entity = when (it.asString) {
+                "skeleton" -> EntityType.SKELETON_HORSE.create(fakeWorld)!!
+                "zombie" -> EntityType.ZOMBIE_HORSE.create(fakeWorld)!!
+                "mule" -> EntityType.MULE.create(fakeWorld)!!
+                "donkey" -> EntityType.DONKEY.create(fakeWorld)!!
+                "horse" -> EntityType.HORSE.create(fakeWorld)!!
+                else -> error("Unknown horse kind $it")
+            }
+        }
+        info["armor"]?.let {
+            if (it is JsonNull) {
+                entity.setHorseArmor(ItemStack.EMPTY)
+            } else {
+                when (it.asString) {
+                    "iron" -> entity.setHorseArmor(ItemStack(Items.IRON_HORSE_ARMOR))
+                    "golden" -> entity.setHorseArmor(ItemStack(Items.GOLDEN_HORSE_ARMOR))
+                    "diamond" -> entity.setHorseArmor(ItemStack(Items.DIAMOND_HORSE_ARMOR))
+                    else -> error("Unknown horse armor $it")
+                }
+            }
+        }
+        info["saddled"]?.let {
+            entity.setIsSaddled(it.asBoolean)
+        }
+        return entity
+    }
+
+}
+
+fun AbstractHorseEntity.setIsSaddled(shouldBeSaddled: Boolean) {
+    val oldFlag = dataTracker.get(AbstractHorseEntity.HORSE_FLAGS)
+    dataTracker.set(
+        AbstractHorseEntity.HORSE_FLAGS,
+        if (shouldBeSaddled) oldFlag or AbstractHorseEntity.SADDLED_FLAG.toByte()
+        else oldFlag and AbstractHorseEntity.SADDLED_FLAG.toByte().inv()
+    )
+}
+
+fun AbstractHorseEntity.setHorseArmor(itemStack: ItemStack) {
+    items.setStack(1, itemStack)
+}
diff --git a/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyInvisible.kt b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyInvisible.kt
new file mode 100644
index 0000000..07c6617
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyInvisible.kt
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.gui.entity
+
+import com.google.gson.JsonObject
+import net.minecraft.entity.LivingEntity
+
+object ModifyInvisible : EntityModifier {
+    override fun apply(entity: LivingEntity, info: JsonObject): LivingEntity {
+        entity.isInvisible = info.get("invisible")?.asBoolean ?: true
+        return entity
+    }
+
+}
diff --git a/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyName.kt b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyName.kt
new file mode 100644
index 0000000..c74e2e5
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyName.kt
@@ -0,0 +1,19 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.gui.entity
+
+import com.google.gson.JsonObject
+import net.minecraft.entity.LivingEntity
+import net.minecraft.text.Text
+
+object ModifyName : EntityModifier {
+    override fun apply(entity: LivingEntity, info: JsonObject): LivingEntity {
+        entity.customName = Text.literal(info.get("name").asString)
+        return entity
+    }
+
+}
diff --git a/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyPlayerSkin.kt b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyPlayerSkin.kt
new file mode 100644
index 0000000..886a17e
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyPlayerSkin.kt
@@ -0,0 +1,32 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.gui.entity
+
+import com.google.gson.JsonObject
+import net.minecraft.client.util.SkinTextures
+import net.minecraft.entity.LivingEntity
+import net.minecraft.util.Identifier
+
+object ModifyPlayerSkin : EntityModifier {
+    override fun apply(entity: LivingEntity, info: JsonObject): LivingEntity {
+        require(entity is GuiPlayer)
+        info["cape"]?.let {
+            entity.capeTexture = Identifier(it.asString)
+        }
+        info["skin"]?.let {
+            entity.skinTexture = Identifier(it.asString)
+        }
+        info["slim"]?.let {
+            entity.model = if (it.asBoolean) SkinTextures.Model.SLIM else SkinTextures.Model.WIDE
+        }
+        info["parts"]?.let {
+            // TODO: support parts
+        }
+        return entity
+    }
+
+}
diff --git a/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyRiding.kt b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyRiding.kt
new file mode 100644
index 0000000..b9c462e
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyRiding.kt
@@ -0,0 +1,20 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.gui.entity
+
+import com.google.gson.JsonObject
+import net.minecraft.entity.LivingEntity
+
+object ModifyRiding : EntityModifier {
+    override fun apply(entity: LivingEntity, info: JsonObject): LivingEntity {
+        val newEntity = EntityRenderer.constructEntity(info)
+        require(newEntity != null)
+        newEntity.startRiding(entity, true)
+        return entity
+    }
+
+}
diff --git a/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyWither.kt b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyWither.kt
new file mode 100644
index 0000000..a0ddcdd
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/gui/entity/ModifyWither.kt
@@ -0,0 +1,25 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.gui.entity
+
+import com.google.gson.JsonObject
+import net.minecraft.entity.LivingEntity
+import net.minecraft.entity.boss.WitherEntity
+
+object ModifyWither : EntityModifier {
+    override fun apply(entity: LivingEntity, info: JsonObject): LivingEntity {
+        require(entity is WitherEntity)
+        info["tiny"]?.let {
+            entity.setInvulTimer(if (it.asBoolean) 800 else 0)
+        }
+        info["armored"]?.let {
+            entity.health = if (it.asBoolean) 1F else entity.maxHealth
+        }
+        return entity
+    }
+
+}
diff --git a/src/main/kotlin/moe/nea/firmament/rei/FirmamentReiPlugin.kt b/src/main/kotlin/moe/nea/firmament/rei/FirmamentReiPlugin.kt
index d643cbf..7e22a1e 100644
--- a/src/main/kotlin/moe/nea/firmament/rei/FirmamentReiPlugin.kt
+++ b/src/main/kotlin/moe/nea/firmament/rei/FirmamentReiPlugin.kt
@@ -1,5 +1,6 @@
 /*
  * SPDX-FileCopyrightText: 2023 Linnea Gräf <nea@nea.moe>
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
  *
  * SPDX-License-Identifier: GPL-3.0-or-later
  */
@@ -31,6 +32,7 @@ import net.minecraft.client.gui.screen.ingame.HandledScreen
 import net.minecraft.item.ItemStack
 import net.minecraft.text.Text
 import net.minecraft.util.Identifier
+import moe.nea.firmament.rei.recipes.SBMobDropRecipe
 
 
 class FirmamentReiPlugin : REIClientPlugin {
@@ -62,6 +64,7 @@ class FirmamentReiPlugin : REIClientPlugin {
     override fun registerCategories(registry: CategoryRegistry) {
         registry.add(SBCraftingRecipe.Category)
         registry.add(SBForgeRecipe.Category)
+        registry.add(SBMobDropRecipe.Category)
     }
 
     override fun registerExclusionZones(zones: ExclusionZones) {
@@ -77,6 +80,7 @@ class FirmamentReiPlugin : REIClientPlugin {
             SBForgeRecipe.Category.categoryIdentifier,
             SkyblockForgeRecipeDynamicGenerator
         )
+        registry.registerDisplayGenerator(SBMobDropRecipe.Category.categoryIdentifier, SkyblockMobDropRecipeDynamicGenerator)
     }
 
     override fun registerCollapsibleEntries(registry: CollapsibleEntryRegistry) {
diff --git a/src/main/kotlin/moe/nea/firmament/rei/SBItemEntryDefinition.kt b/src/main/kotlin/moe/nea/firmament/rei/SBItemEntryDefinition.kt
index 77e329e..0cdb17e 100644
--- a/src/main/kotlin/moe/nea/firmament/rei/SBItemEntryDefinition.kt
+++ b/src/main/kotlin/moe/nea/firmament/rei/SBItemEntryDefinition.kt
@@ -1,5 +1,6 @@
 /*
  * SPDX-FileCopyrightText: 2023 Linnea Gräf <nea@nea.moe>
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
  *
  * SPDX-License-Identifier: GPL-3.0-or-later
  */
@@ -30,6 +31,7 @@ import moe.nea.firmament.repo.RepoManager
 import moe.nea.firmament.util.FirmFormatters
 import moe.nea.firmament.util.HypixelPetInfo
 import moe.nea.firmament.util.SkyblockId
+import moe.nea.firmament.util.appendLore
 import moe.nea.firmament.util.petData
 import moe.nea.firmament.util.skyBlockId
 
@@ -54,6 +56,7 @@ data class SBItemStack(
     val neuItem: NEUItem?,
     val stackSize: Int,
     val petData: PetData?,
+    val extraLore: List<Text> = emptyList(),
 ) {
     constructor(skyblockId: SkyblockId, petData: PetData) : this(
         skyblockId,
@@ -102,12 +105,13 @@ data class SBItemStack(
         }
     }
 
-    private val itemStack by lazy(LazyThreadSafetyMode.NONE) {
+    private val itemStack: ItemStack by lazy(LazyThreadSafetyMode.NONE) {
         if (skyblockId == SkyblockId.COINS)
-            return@lazy ItemCache.coinItem(stackSize)
+            return@lazy ItemCache.coinItem(stackSize).also { it.appendLore(extraLore) }
         val replacementData = mutableMapOf<String, String>()
         injectReplacementDataForPets(replacementData)
         return@lazy neuItem.asItemStack(idHint = skyblockId, replacementData).copyWithCount(stackSize)
+            .also { it.appendLore(extraLore) }
     }
 
     fun asImmutableItemStack(): ItemStack {
diff --git a/src/main/kotlin/moe/nea/firmament/rei/SkyblockCraftingRecipeDynamicGenerator.kt b/src/main/kotlin/moe/nea/firmament/rei/SkyblockCraftingRecipeDynamicGenerator.kt
index 7bff82b..ac5a1fc 100644
--- a/src/main/kotlin/moe/nea/firmament/rei/SkyblockCraftingRecipeDynamicGenerator.kt
+++ b/src/main/kotlin/moe/nea/firmament/rei/SkyblockCraftingRecipeDynamicGenerator.kt
@@ -1,5 +1,6 @@
 /*
  * SPDX-FileCopyrightText: 2023 Linnea Gräf <nea@nea.moe>
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
  *
  * SPDX-License-Identifier: GPL-3.0-or-later
  */
@@ -8,6 +9,7 @@ package moe.nea.firmament.rei
 
 import io.github.moulberry.repo.data.NEUCraftingRecipe
 import io.github.moulberry.repo.data.NEUForgeRecipe
+import io.github.moulberry.repo.data.NEUMobDropRecipe
 import io.github.moulberry.repo.data.NEURecipe
 import java.util.*
 import me.shedaniel.rei.api.client.registry.display.DynamicDisplayGenerator
@@ -16,6 +18,7 @@ import me.shedaniel.rei.api.common.display.Display
 import me.shedaniel.rei.api.common.entry.EntryStack
 import moe.nea.firmament.rei.recipes.SBCraftingRecipe
 import moe.nea.firmament.rei.recipes.SBForgeRecipe
+import moe.nea.firmament.rei.recipes.SBMobDropRecipe
 import moe.nea.firmament.repo.RepoManager
 
 
@@ -27,6 +30,10 @@ val SkyblockForgeRecipeDynamicGenerator = neuDisplayGenerator<SBForgeRecipe, NEU
     SBForgeRecipe(it)
 }
 
+val SkyblockMobDropRecipeDynamicGenerator = neuDisplayGenerator<SBMobDropRecipe, NEUMobDropRecipe> {
+    SBMobDropRecipe(it)
+}
+
 inline fun <D : Display, reified T : NEURecipe> neuDisplayGenerator(noinline mapper: (T) -> D) =
     object : DynamicDisplayGenerator<D> {
         override fun getRecipeFor(entry: EntryStack<*>): Optional<List<D>> {
diff --git a/src/main/kotlin/moe/nea/firmament/rei/recipes/SBMobDropRecipe.kt b/src/main/kotlin/moe/nea/firmament/rei/recipes/SBMobDropRecipe.kt
new file mode 100644
index 0000000..5af1f9e
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/rei/recipes/SBMobDropRecipe.kt
@@ -0,0 +1,113 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.rei.recipes
+
+import io.github.moulberry.repo.data.NEUMobDropRecipe
+import me.shedaniel.math.Point
+import me.shedaniel.math.Rectangle
+import me.shedaniel.rei.api.client.gui.Renderer
+import me.shedaniel.rei.api.client.gui.widgets.Widget
+import me.shedaniel.rei.api.client.gui.widgets.Widgets
+import me.shedaniel.rei.api.client.registry.display.DisplayCategory
+import me.shedaniel.rei.api.common.category.CategoryIdentifier
+import me.shedaniel.rei.api.common.util.EntryStacks
+import net.minecraft.block.Blocks
+import net.minecraft.text.Text
+import net.minecraft.util.Identifier
+import moe.nea.firmament.Firmament
+import moe.nea.firmament.gui.entity.EntityRenderer
+import moe.nea.firmament.gui.entity.EntityWidget
+import moe.nea.firmament.rei.SBItemEntryDefinition
+
+class SBMobDropRecipe(override val neuRecipe: NEUMobDropRecipe) : SBRecipe() {
+    override fun getCategoryIdentifier(): CategoryIdentifier<*> = Category.categoryIdentifier
+
+    object Category : DisplayCategory<SBMobDropRecipe> {
+        override fun getCategoryIdentifier(): CategoryIdentifier<SBMobDropRecipe> =
+            CategoryIdentifier.of(Firmament.MOD_ID, "mob_drop_recipe")
+
+        override fun getTitle(): Text = Text.literal("Mob Drops")
+        override fun getDisplayHeight(): Int {
+            return 100
+        }
+
+        override fun getIcon(): Renderer = EntryStacks.of(Blocks.ANVIL)
+        override fun setupDisplay(display: SBMobDropRecipe, bounds: Rectangle): List<Widget> {
+            return buildList {
+                add(Widgets.createRecipeBase(bounds))
+                val source = display.neuRecipe.render
+                val entity = if (source.startsWith("@")) {
+                    EntityRenderer.constructEntity(Identifier(source.substring(1)))
+                } else {
+                    EntityRenderer.applyModifiers(source, listOf())
+                }
+                if (entity != null) {
+                    val level = display.neuRecipe.level
+                    val fullMobName =
+                        if (level > 0) Text.translatable("firmament.recipe.mobs.name", level, display.neuRecipe.name)
+                        else Text.translatable("firmament.recipe.mobs.name.nolevel", display.neuRecipe.name)
+                    val tt = mutableListOf<Text>()
+                    tt.add((fullMobName))
+                    tt.add(Text.literal(""))
+                    if (display.neuRecipe.coins > 0) {
+                        tt.add(Text.stringifiedTranslatable("firmament.recipe.mobs.coins", display.neuRecipe.coins))
+                    }
+                    if (display.neuRecipe.combatExperience > 0) {
+                        tt.add(
+                            Text.stringifiedTranslatable(
+                                "firmament.recipe.mobs.combat",
+                                display.neuRecipe.combatExperience
+                            )
+                        )
+                    }
+                    if (display.neuRecipe.enchantingExperience > 0) {
+                        tt.add(
+                            Text.stringifiedTranslatable(
+                                "firmament.recipe.mobs.exp",
+                                display.neuRecipe.enchantingExperience
+                            )
+                        )
+                    }
+                    if (display.neuRecipe.extra != null)
+                        display.neuRecipe.extra.mapTo(tt) { Text.literal(it) }
+                    if (tt.size == 2)
+                        tt.removeAt(1)
+                    add(
+                        Widgets.withTooltip(
+                            EntityWidget(entity, Point(bounds.minX + 5, bounds.minY + 15)),
+                            tt
+                        )
+                    )
+                }
+                add(
+                    Widgets.createLabel(Point(bounds.minX + 15, bounds.minY + 5), Text.literal(display.neuRecipe.name))
+                        .leftAligned()
+                )
+                var x = bounds.minX + 60
+                var y = bounds.minY + 20
+                for (drop in display.neuRecipe.drops) {
+                    val lore = drop.extra.mapTo(mutableListOf()) { Text.literal(it) }
+                    if (drop.chance != null) {
+                        lore += listOf(Text.translatable("firmament.recipe.mobs.drops", drop.chance))
+                    }
+                    val item = SBItemEntryDefinition.getEntry(drop.dropItem)
+                        .value.copy(extraLore = lore)
+                    add(
+                        Widgets.createSlot(Point(x, y)).markOutput()
+                            .entries(listOf(SBItemEntryDefinition.getEntry(item)))
+                    )
+                    x += 18
+                    if (x > bounds.maxX - 30) {
+                        x = bounds.minX + 60
+                        y += 18
+                    }
+                }
+            }
+        }
+    }
+
+}
diff --git a/src/main/kotlin/moe/nea/firmament/repo/RepoModResourcePack.kt b/src/main/kotlin/moe/nea/firmament/repo/RepoModResourcePack.kt
new file mode 100644
index 0000000..c511c90
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/repo/RepoModResourcePack.kt
@@ -0,0 +1,101 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.repo
+
+import java.io.InputStream
+import java.nio.file.Files
+import java.nio.file.Path
+import net.fabricmc.fabric.api.resource.ModResourcePack
+import net.fabricmc.loader.api.FabricLoader
+import net.fabricmc.loader.api.metadata.ModMetadata
+import kotlin.io.path.exists
+import kotlin.io.path.isRegularFile
+import kotlin.io.path.relativeTo
+import kotlin.streams.asSequence
+import net.minecraft.resource.AbstractFileResourcePack
+import net.minecraft.resource.InputSupplier
+import net.minecraft.resource.ResourcePack
+import net.minecraft.resource.ResourceType
+import net.minecraft.resource.metadata.ResourceMetadataReader
+import net.minecraft.util.Identifier
+import net.minecraft.util.PathUtil
+
+class RepoModResourcePack(val basePath: Path) : ModResourcePack {
+    companion object {
+        fun append(packs: MutableList<in ModResourcePack>) {
+            packs.add(RepoModResourcePack(RepoDownloadManager.repoSavedLocation))
+        }
+    }
+
+    override fun close() {
+    }
+
+    override fun openRoot(vararg segments: String): InputSupplier<InputStream>? {
+        return getFile(segments)?.let { InputSupplier.create(it) }
+    }
+
+    fun getFile(segments: Array<out String>): Path? {
+        PathUtil.validatePath(*segments)
+        val path = segments.fold(basePath, Path::resolve)
+        if (!path.isRegularFile()) return null
+        return path
+    }
+
+    override fun open(type: ResourceType?, id: Identifier): InputSupplier<InputStream>? {
+        if (type != ResourceType.CLIENT_RESOURCES) return null
+        if (id.namespace != "neurepo") return null
+        val file = getFile(id.path.split("/").toTypedArray())
+        return file?.let { InputSupplier.create(it) }
+    }
+
+    override fun findResources(
+        type: ResourceType?,
+        namespace: String,
+        prefix: String,
+        consumer: ResourcePack.ResultConsumer
+    ) {
+        if (namespace != "neurepo") return
+        if (type != ResourceType.CLIENT_RESOURCES) return
+
+        val prefixPath = basePath.resolve(prefix)
+        if (!prefixPath.exists())
+            return
+        Files.walk(prefixPath)
+            .asSequence()
+            .map { it.relativeTo(basePath) }
+            .forEach {
+                consumer.accept(Identifier.of("neurepo", it.toString()), InputSupplier.create(it))
+            }
+    }
+
+    override fun getNamespaces(type: ResourceType?): Set<String> {
+        if (type != ResourceType.CLIENT_RESOURCES) return emptySet()
+        return setOf("neurepo")
+    }
+
+    override fun <T> parseMetadata(metaReader: ResourceMetadataReader<T>): T? {
+        return AbstractFileResourcePack.parseMetadata(
+            metaReader, """
+{
+    "pack": {
+        "pack_format": 12,
+        "description": "NEU Repo Resources"
+    }
+}
+""".trimIndent().byteInputStream()
+        )
+    }
+
+    override fun getName(): String {
+        return "NEU Repo Resources"
+    }
+
+    override fun getFabricModMetadata(): ModMetadata {
+        return FabricLoader.getInstance().getModContainer("firmament")
+            .get().metadata
+    }
+}
diff --git a/src/main/kotlin/moe/nea/firmament/util/ItemUtil.kt b/src/main/kotlin/moe/nea/firmament/util/ItemUtil.kt
index 4f8c90c..5a7a116 100644
--- a/src/main/kotlin/moe/nea/firmament/util/ItemUtil.kt
+++ b/src/main/kotlin/moe/nea/firmament/util/ItemUtil.kt
@@ -1,5 +1,6 @@
 /*
  * SPDX-FileCopyrightText: 2023 Linnea Gräf <nea@nea.moe>
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
  *
  * SPDX-License-Identifier: GPL-3.0-or-later
  */
@@ -14,6 +15,7 @@ import net.minecraft.text.Text
 
 
 fun ItemStack.appendLore(args: List<Text>) {
+    if (args.isEmpty()) return
     val compoundTag = getOrCreateSubNbt("display")
     val loreList = compoundTag.getOrCreateList("Lore", NbtString.STRING_TYPE)
     for (arg in args) {
diff --git a/src/main/kotlin/moe/nea/firmament/util/LoadResource.kt b/src/main/kotlin/moe/nea/firmament/util/LoadResource.kt
new file mode 100644
index 0000000..5a8bfbf
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/util/LoadResource.kt
@@ -0,0 +1,25 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.util
+
+import java.io.InputStream
+import kotlin.io.path.inputStream
+import kotlin.jvm.optionals.getOrNull
+import net.minecraft.util.Identifier
+import moe.nea.firmament.repo.RepoDownloadManager
+
+
+fun Identifier.openFirmamentResource(): InputStream {
+    val resource = MC.resourceManager.getResource(this).getOrNull()
+    if (resource == null) {
+        if (namespace == "neurepo")
+            return RepoDownloadManager.repoSavedLocation.resolve(path).inputStream()
+        error("Could not read resource $this")
+    }
+    return resource.inputStream
+}
+
diff --git a/src/main/kotlin/moe/nea/firmament/util/assertions.kt b/src/main/kotlin/moe/nea/firmament/util/assertions.kt
index 5505422..7f06955 100644
--- a/src/main/kotlin/moe/nea/firmament/util/assertions.kt
+++ b/src/main/kotlin/moe/nea/firmament/util/assertions.kt
@@ -1,5 +1,6 @@
 /*
  * SPDX-FileCopyrightText: 2023 Linnea Gräf <nea@nea.moe>
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
  *
  * SPDX-License-Identifier: GPL-3.0-or-later
  */
diff --git a/src/main/kotlin/moe/nea/firmament/util/item/SkullItemData.kt b/src/main/kotlin/moe/nea/firmament/util/item/SkullItemData.kt
index 4b72c5e..a061ee4 100644
--- a/src/main/kotlin/moe/nea/firmament/util/item/SkullItemData.kt
+++ b/src/main/kotlin/moe/nea/firmament/util/item/SkullItemData.kt
@@ -1,5 +1,6 @@
 /*
  * SPDX-FileCopyrightText: 2023 Linnea Gräf <nea@nea.moe>
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
  *
  * SPDX-License-Identifier: GPL-3.0-or-later
  */
@@ -18,8 +19,13 @@ import kotlinx.serialization.Serializable
 import kotlinx.serialization.UseSerializers
 import kotlinx.serialization.decodeFromString
 import kotlinx.serialization.encodeToString
-import net.minecraft.client.texture.PlayerSkinProvider
+import net.minecraft.block.entity.SkullBlockEntity
+import net.minecraft.item.ItemStack
+import net.minecraft.item.Items
+import net.minecraft.nbt.NbtCompound
+import net.minecraft.nbt.NbtHelper
 import moe.nea.firmament.Firmament
+import moe.nea.firmament.repo.set
 import moe.nea.firmament.util.assertTrueOr
 import moe.nea.firmament.util.json.DashlessUUIDSerializer
 import moe.nea.firmament.util.json.InstantAsLongSerializer
@@ -46,6 +52,38 @@ fun GameProfile.setTextures(textures: MinecraftTexturesPayloadKt) {
 }
 
 private val propertyTextures = "textures"
+fun String.padBase64(): String {
+    return this + "=".repeat((4 - (this.length % 4)) % 4)
+}
+
+fun ItemStack.setEncodedSkullOwner(uuid: UUID, encodedData: String) {
+    assert(this.item == Items.PLAYER_HEAD)
+    val gameProfile = GameProfile(uuid, "LameGuy123")
+    gameProfile.properties.put(propertyTextures, Property(propertyTextures, encodedData.padBase64()))
+    val nbt: NbtCompound = this.orCreateNbt
+    nbt[SkullBlockEntity.SKULL_OWNER_KEY] = NbtHelper.writeGameProfile(
+        NbtCompound(),
+        gameProfile
+    )
+}
+
+val zeroUUID = UUID.fromString("d3cb85e2-3075-48a1-b213-a9bfb62360c1")
+fun ItemStack.setSkullOwner(uuid: UUID, url: String) {
+    assert(this.item == Items.PLAYER_HEAD)
+    val gameProfile = GameProfile(uuid, "LameGuy123")
+    gameProfile.setTextures(
+        MinecraftTexturesPayloadKt(
+            mapOf(MinecraftProfileTexture.Type.SKIN to MinecraftProfileTextureKt(url))
+        )
+    )
+    val nbt: NbtCompound = this.orCreateNbt
+    nbt[SkullBlockEntity.SKULL_OWNER_KEY] = NbtHelper.writeGameProfile(
+        NbtCompound(),
+        gameProfile
+    )
+
+}
+
 
 fun decodeProfileTextureProperty(property: Property): MinecraftTexturesPayloadKt? {
     assertTrueOr(property.name == propertyTextures) { return null }
diff --git a/src/main/kotlin/moe/nea/firmament/util/render/TranslatedScissors.kt b/src/main/kotlin/moe/nea/firmament/util/render/TranslatedScissors.kt
new file mode 100644
index 0000000..8f80f1b
--- /dev/null
+++ b/src/main/kotlin/moe/nea/firmament/util/render/TranslatedScissors.kt
@@ -0,0 +1,27 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Linnea Gräf <nea@nea.moe>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package moe.nea.firmament.util.render
+
+import org.joml.Vector4f
+import net.minecraft.client.gui.DrawContext
+
+fun DrawContext.enableScissorWithTranslation(x1: Float, y1: Float, x2: Float, y2: Float) {
+    val pMat = matrices.peek().positionMatrix
+    val target = Vector4f()
+
+    target.set(x1, y1, 0f, 1f)
+    target.mul(pMat)
+    val scissorX1 = target.x
+    val scissorY1 = target.y
+
+    target.set(x2, y2, 0f, 1f)
+    target.mul(pMat)
+    val scissorX2 = target.x
+    val scissorY2 = target.y
+
+    enableScissor(scissorX1.toInt(), scissorY1.toInt(), scissorX2.toInt(), scissorY2.toInt())
+}
-- 
cgit