From 3649eb78043797b8e9b679aec3a0cbc10c12b866 Mon Sep 17 00:00:00 2001 From: OmarZiadeh Date: Thu, 13 Aug 2026 15:37:12 -0400 Subject: [PATCH] fix: preserve custom head items in player emotes Preserve custom helmet components and synthesize a private HEAD-transformed child beneath existing ph_ player heads without changing public bone tags or bundled player models. --- .../toxicity/model/api/armor/PlayerArmor.java | 20 ++ core/build.gradle.kts | 2 + .../model/manager/ModelManagerImpl.kt | 64 ++++- .../toxicity/model/manager/SkinManagerImpl.kt | 5 +- .../model/manager/PlayerModelRendererTest.kt | 238 ++++++++++++++++++ .../bukkit/nms/v1_21_R3/PlayerArmorImpl.kt | 13 + .../bukkit/nms/v1_21_R4/PlayerArmorImpl.kt | 13 + .../bukkit/nms/v1_21_R5/PlayerArmorImpl.kt | 13 + .../bukkit/nms/v1_21_R6/PlayerArmorImpl.kt | 13 + nms/v1_21_R7/build.gradle.kts | 2 + .../bukkit/nms/v1_21_R7/PlayerArmorImpl.kt | 13 + .../nms/v1_21_R7/PlayerArmorImplTest.kt | 96 +++++++ .../bukkit/nms/v26_R1/PlayerArmorImpl.kt | 13 + .../bukkit/nms/v26_R2/PlayerArmorImpl.kt | 13 + .../impl/fabric/armor/PlayerArmorImpl.kt | 12 + 15 files changed, 527 insertions(+), 3 deletions(-) create mode 100644 core/src/test/kotlin/kr/toxicity/model/manager/PlayerModelRendererTest.kt create mode 100644 nms/v1_21_R7/src/test/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R7/PlayerArmorImplTest.kt diff --git a/api/src/main/java/kr/toxicity/model/api/armor/PlayerArmor.java b/api/src/main/java/kr/toxicity/model/api/armor/PlayerArmor.java index 23e103da9..b212c2c40 100644 --- a/api/src/main/java/kr/toxicity/model/api/armor/PlayerArmor.java +++ b/api/src/main/java/kr/toxicity/model/api/armor/PlayerArmor.java @@ -7,6 +7,7 @@ package kr.toxicity.model.api.armor; +import kr.toxicity.model.api.util.TransformedItemStack; import org.jetbrains.annotations.Nullable; /** @@ -45,6 +46,25 @@ public interface PlayerArmor { */ @Nullable ArmorItem helmet(); + /** + * Gets the custom item-model stack worn in the helmet slot. + *

+ * A {@code null} value means the helmet should use BetterModel's generated + * vanilla armor layer. For example: + *

{@code
+     * TransformedItemStack customHelmet = armor.helmetItem();
+     * if (customHelmet != null) {
+     *     // Render the original item stack with its custom item model.
+     * }
+     * }
+ * + * @return the custom helmet item, or {@code null} for vanilla armor rendering + * @since 3.4.1 + */ + default @Nullable TransformedItemStack helmetItem() { + return null; + } + /** * Gets chestplate * @return chestplate diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 0fefb45f9..2228673f1 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -10,4 +10,6 @@ dependencies { compileOnly(libs.bundles.core) compileOnly(libs.cloud.core) + + testImplementation(libs.bundles.minecraft) } diff --git a/core/src/main/kotlin/kr/toxicity/model/manager/ModelManagerImpl.kt b/core/src/main/kotlin/kr/toxicity/model/manager/ModelManagerImpl.kt index 9affed466..8ebd8e828 100644 --- a/core/src/main/kotlin/kr/toxicity/model/manager/ModelManagerImpl.kt +++ b/core/src/main/kotlin/kr/toxicity/model/manager/ModelManagerImpl.kt @@ -10,6 +10,11 @@ package kr.toxicity.model.manager import com.google.gson.JsonArray import com.google.gson.JsonObject import kr.toxicity.model.api.bone.BoneItemMapper +import kr.toxicity.model.api.bone.BoneName +import kr.toxicity.model.api.bone.BoneRenderContext +import kr.toxicity.model.api.bone.BoneTag +import kr.toxicity.model.api.bone.BoneTags +import kr.toxicity.model.api.data.Float3 import kr.toxicity.model.api.data.ModelAsset import kr.toxicity.model.api.data.blueprint.BlueprintElement import kr.toxicity.model.api.data.blueprint.BlueprintJson @@ -19,12 +24,17 @@ import kr.toxicity.model.api.data.renderer.RendererGroup import kr.toxicity.model.api.event.ModelAssetsEvent import kr.toxicity.model.api.event.ModelImportedEvent import kr.toxicity.model.api.manager.ModelManager +import kr.toxicity.model.api.nms.Profiled import kr.toxicity.model.api.pack.PackBuilder import kr.toxicity.model.api.pack.PackZipper +import kr.toxicity.model.api.platform.PlatformItemTransform import kr.toxicity.model.api.platform.PlatformNamespace +import kr.toxicity.model.api.util.TransformedItemStack import kr.toxicity.model.util.* import net.kyori.adventure.text.format.NamedTextColor.* import java.io.File +import java.nio.charset.StandardCharsets +import java.util.UUID import java.util.concurrent.ConcurrentHashMap import kotlin.io.path.extension @@ -36,6 +46,24 @@ object ModelManagerImpl : ModelManager, GlobalManager { private val playerModelView = playerModelMap.toImmutableView() private val modelExtensions = setOf("bbmodel", "ajmodel") + private val customHeadItemMapper = object : BoneItemMapper { + override fun apply( + context: BoneRenderContext, + transformedItemStack: TransformedItemStack + ): TransformedItemStack = (context.source() as? Profiled) + ?.armors() + ?.helmetItem() + ?: TransformedItemStack.empty() + + override fun transform(): PlatformItemTransform = PlatformItemTransform.HEAD + } + + private val customHeadItemTag = object : BoneTag { + override fun name(): String = INTERNAL_HEAD_ITEM_NAME + override fun itemMapper(): BoneItemMapper = customHeadItemMapper + override fun tags(): List = emptyList() + } + private fun importModels( type: ModelRenderer.Type, pipeline: ReloadPipeline, @@ -231,15 +259,45 @@ object ModelManagerImpl : ModelManager, GlobalManager { fun Collection.toBoneMap(mapper: (BlueprintElement.Bone) -> T) = filterIsInstance().let { bone -> bone.associateTo(sequencedAddressingMapOf(bone.size)) { it.name() to mapper(it) } }.toImmutableView() + + fun BlueprintElement.Bone.customHeadItemRenderer( + existingChildren: Map + ): RendererGroup { + val baseName = "$INTERNAL_HEAD_ITEM_NAME:${uuid()}" + var rawName = baseName + var collision = 0 + while (existingChildren.keys.any { it.rawName() == rawName }) { + rawName = "$baseName:${++collision}" + } + val internalBone = BlueprintElement.Group( + UUID.nameUUIDFromBytes(rawName.toByteArray(StandardCharsets.UTF_8)), + BoneName(setOf(customHeadItemTag), rawName, rawName), + origin().invertXZ(), + Float3.ZERO, + emptyList(), + true + ) + return RendererGroup(1.0F, null, internalBone, emptySequencedMap(), null) + } + fun BlueprintElement.Bone.parse(): RendererGroup { - if (this !is BlueprintElement.Group) return RendererGroup(1.0F, null, this, emptySequencedMap(), null) + val childRenderers = if (this is BlueprintElement.Group) { + children.toBoneMap { it.parse() } + } else emptySequencedMap() + val renderedChildren = if (type == ModelRenderer.Type.PLAYER && name().tagged(BoneTags.PLAYER_HEAD)) { + sequencedAddressingMapOf(childRenderers.size + 1).apply { + putAll(childRenderers) + customHeadItemRenderer(childRenderers).let { put(it.name(), it) } + }.toImmutableView() + } else childRenderers + if (this !is BlueprintElement.Group) return RendererGroup(1.0F, null, this, renderedChildren, null) return RendererGroup( scale(), if (name.toItemMapper() !== BoneItemMapper.EMPTY) null else builder(this)?.let { itemNamespace -> CONFIG.item().get().itemModel(PlatformNamespace(CONFIG.namespace(), itemNamespace)) }, this, - children.toBoneMap { it.parse() }, + renderedChildren, hitBox(), ) } @@ -267,4 +325,6 @@ object ModelManagerImpl : ModelManager, GlobalManager { override fun limb(name: String): ModelRenderer? = playerModelView[name] override fun limbs(): Collection = playerModelView.values override fun limbKeys(): Set = playerModelView.keys + + private const val INTERNAL_HEAD_ITEM_NAME = "bettermodel:internal_player_head_item" } diff --git a/core/src/main/kotlin/kr/toxicity/model/manager/SkinManagerImpl.kt b/core/src/main/kotlin/kr/toxicity/model/manager/SkinManagerImpl.kt index 05fe1fb1e..caf111c02 100644 --- a/core/src/main/kotlin/kr/toxicity/model/manager/SkinManagerImpl.kt +++ b/core/src/main/kotlin/kr/toxicity/model/manager/SkinManagerImpl.kt @@ -793,7 +793,10 @@ object SkinManagerImpl : SkinManager, GlobalManager { capeImage?.let { CAPE.asModelData(it).asItem() } ) override fun profile(): ModelProfile = profile - override fun head(armor: PlayerArmor): TransformedItemStack = head.asItem(ArmorResource.HELMET, armor.helmet()) + override fun head(armor: PlayerArmor): TransformedItemStack = head.asItem( + ArmorResource.HELMET, + armor.helmet().takeIf { armor.helmetItem() == null } + ) override fun hip(armor: PlayerArmor): TransformedItemStack = hip.asItem(ArmorResource.HIP, armor.leggings()) override fun waist(armor: PlayerArmor): TransformedItemStack = waist.asItem(ArmorResource.WAIST, armor.chestplate()) override fun chest(armor: PlayerArmor): TransformedItemStack = chest.asItem(ArmorResource.CHEST, armor.chestplate()) diff --git a/core/src/test/kotlin/kr/toxicity/model/manager/PlayerModelRendererTest.kt b/core/src/test/kotlin/kr/toxicity/model/manager/PlayerModelRendererTest.kt new file mode 100644 index 000000000..5f2e6d8ea --- /dev/null +++ b/core/src/test/kotlin/kr/toxicity/model/manager/PlayerModelRendererTest.kt @@ -0,0 +1,238 @@ +/* + * This source file is part of BetterModel. + * Copyright (c) 2026 toxicity188 + * Licensed under the MIT License. + * See LICENSE.md file for full license text. + */ + +package kr.toxicity.model.manager + +import kr.toxicity.model.api.BetterModel +import kr.toxicity.model.api.BetterModelPlatform +import kr.toxicity.model.api.armor.ArmorItem +import kr.toxicity.model.api.armor.PlayerArmor +import kr.toxicity.model.api.bone.BoneName +import kr.toxicity.model.api.bone.BoneRenderContext +import kr.toxicity.model.api.bone.BoneTag +import kr.toxicity.model.api.data.Float3 +import kr.toxicity.model.api.data.blueprint.BlueprintElement +import kr.toxicity.model.api.data.blueprint.ModelBlueprint +import kr.toxicity.model.api.data.raw.ModelResolution +import kr.toxicity.model.api.data.renderer.ModelRenderer +import kr.toxicity.model.api.data.renderer.RenderSource +import kr.toxicity.model.api.data.renderer.RendererGroup +import kr.toxicity.model.api.entity.BasePlayer +import kr.toxicity.model.api.platform.PlatformAdapter +import kr.toxicity.model.api.platform.PlatformItemStack +import kr.toxicity.model.api.platform.PlatformItemTransform +import kr.toxicity.model.api.platform.PlatformNamespace +import kr.toxicity.model.api.skin.SkinData +import kr.toxicity.model.api.util.TransformedItemStack +import java.lang.reflect.Method +import java.lang.reflect.Proxy +import java.util.UUID +import sun.misc.Unsafe +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Player-renderer regression coverage for BetterModel issue #410. + */ +class PlayerModelRendererTest { + + private var previousPlatform: Any? = null + + @BeforeTest + fun installPlatform() { + platformField().run { + previousPlatform = get(null) + set(null, platform()) + } + } + + @AfterTest + fun restorePlatform() { + platformField().set(null, previousPlatform) + } + + @Test + fun `player head receives an internal custom-item child`() { + val first = playerRenderer() + val second = playerRenderer() + val firstHead = assertNotNull(first.groupByTree(headName())) + val secondHead = assertNotNull(second.groupByTree(headName())) + val firstHeadItem = assertNotNull(firstHead.customHeadItem()) + val secondHeadItem = assertNotNull(secondHead.customHeadItem()) + + assertNotNull(firstHead.children[authoredChildName()]) + assertNotNull(firstHead.children[collidingChildName()]) + assertEquals(firstHead.position, firstHeadItem.position) + assertTrue(firstHeadItem.parent.visibility()) + assertEquals(firstHeadItem.uuid(), secondHeadItem.uuid()) + assertEquals(firstHeadItem.name(), secondHeadItem.name()) + assertTrue(firstHeadItem.name() != collidingChildName()) + assertTrue(BoneName.of(firstHeadItem.name().rawName()).tags().isEmpty()) + assertNull(BoneTag.REGISTRY.byTagNameOrNull("phi")) + } + + @Test + fun `internal custom-item child renders the preserved helmet with head transform`() { + val customItem = TransformedItemStack.of(TestItem(false)) + val mapper = assertNotNull( + assertNotNull(playerRenderer().groupByTree(headName())).customHeadItem() + ).itemMapper + + val mapped = mapper.apply(context(armor(customItem)), TransformedItemStack.empty()) + + assertSame(customItem, mapped) + assertEquals(PlatformItemTransform.HEAD, mapper.transform()) + } + + @Test + fun `internal custom-item child renders air when helmet is not customized`() { + val mapper = assertNotNull( + assertNotNull(playerRenderer().groupByTree(headName())).customHeadItem() + ).itemMapper + + val mapped = mapper.apply(context(armor(null)), TransformedItemStack.empty()) + + assertTrue(mapped.isAir) + } + + @Test + fun `general models do not receive the internal player child`() { + val head = assertNotNull(toRenderer(ModelRenderer.Type.GENERAL).groupByTree(headName())) + + assertNull(head.customHeadItem()) + } + + private fun playerRenderer() = toRenderer(ModelRenderer.Type.PLAYER) + + private fun toRenderer(type: ModelRenderer.Type): ModelRenderer { + val pipelineClass = ModelManagerImpl::class.java.declaredClasses.single { + it.simpleName == "ModelPipeline" + } + val converter = pipelineClass.declaredMethods.single { + it.name == "toRenderer" && + it.parameterTypes.firstOrNull() == ModelBlueprint::class.java + } + converter.isAccessible = true + val itemBuilder: (BlueprintElement.Group) -> String? = { null } + val pipeline = UNSAFE.allocateInstance(pipelineClass) + return converter.invoke(pipeline, blueprint(), type, itemBuilder) as ModelRenderer + } + + private fun blueprint(): ModelBlueprint { + val authoredChild = BlueprintElement.Locator( + AUTHORED_CHILD_UUID, + authoredChildName(), + Float3(4F, 24F, -2F) + ) + val collidingChild = BlueprintElement.Locator( + COLLIDING_CHILD_UUID, + collidingChildName(), + Float3(4F, 24F, -2F) + ) + val head = BlueprintElement.Group( + HEAD_UUID, + headName(), + Float3(4F, 24F, -2F), + Float3.ZERO, + listOf(authoredChild, collidingChild), + true + ) + return ModelBlueprint( + "existing_player_rig", + ModelResolution(64, 64), + emptyList(), + listOf(head), + emptyMap() + ) + } + + private fun RendererGroup.customHeadItem(): RendererGroup? = children.values.singleOrNull { + it.itemMapper.transform() == PlatformItemTransform.HEAD + } + + private fun headName() = BoneName.of("ph_head") + + private fun authoredChildName() = BoneName.of("authored_child") + + private fun collidingChildName(): BoneName { + val rawName = "bettermodel:internal_player_head_item:$HEAD_UUID" + return BoneName(emptySet(), rawName, rawName) + } + + private fun context(armor: PlayerArmor): BoneRenderContext { + val player = proxy { method, _ -> + if (method.name == "armors") armor else defaultValue(method.returnType) + } + val skin = proxy { method, _ -> defaultValue(method.returnType) } + return BoneRenderContext(RenderSource.of(player), skin) + } + + private fun armor(helmetItem: TransformedItemStack?) = object : PlayerArmor { + override fun helmet(): ArmorItem? = null + override fun helmetItem(): TransformedItemStack? = helmetItem + override fun chestplate(): ArmorItem? = null + override fun leggings(): ArmorItem? = null + override fun boots(): ArmorItem? = null + } + + private fun platform(): BetterModelPlatform { + val adapter = proxy { method, _ -> + if (method.name == "air") AIR else defaultValue(method.returnType) + } + return proxy { method, _ -> + if (method.name == "adapter") adapter else defaultValue(method.returnType) + } + } + + private fun platformField() = BetterModel::class.java.getDeclaredField("instance").apply { + isAccessible = true + } + + private inline fun proxy( + noinline invocation: (Method, Array?) -> Any? + ): T = Proxy.newProxyInstance( + T::class.java.classLoader, + arrayOf(T::class.java) + ) { _, method, arguments -> invocation(method, arguments) } as T + + private fun defaultValue(type: Class<*>): Any? = when { + !type.isPrimitive -> null + type == Boolean::class.javaPrimitiveType -> false + type == Char::class.javaPrimitiveType -> '\u0000' + type == Byte::class.javaPrimitiveType -> 0.toByte() + type == Short::class.javaPrimitiveType -> 0.toShort() + type == Int::class.javaPrimitiveType -> 0 + type == Long::class.javaPrimitiveType -> 0L + type == Float::class.javaPrimitiveType -> 0F + type == Double::class.javaPrimitiveType -> 0.0 + else -> error("Unknown primitive type: $type") + } + + private data class TestItem(private val air: Boolean) : PlatformItemStack { + override fun isAir(): Boolean = air + override fun enchant(enchant: Boolean): PlatformItemStack = this + override fun itemModel(namespace: PlatformNamespace?): PlatformItemStack = this + public override fun clone(): PlatformItemStack = this + } + + private companion object { + private val AIR = TestItem(true) + private val UNSAFE = Unsafe::class.java.getDeclaredField("theUnsafe").run { + isAccessible = true + get(null) as Unsafe + } + private val HEAD_UUID = UUID.fromString("a3172126-8b18-4bee-bfae-410000000001") + private val AUTHORED_CHILD_UUID = UUID.fromString("a3172126-8b18-4bee-bfae-410000000002") + private val COLLIDING_CHILD_UUID = UUID.fromString("a3172126-8b18-4bee-bfae-410000000003") + } +} diff --git a/nms/v1_21_R3/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R3/PlayerArmorImpl.kt b/nms/v1_21_R3/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R3/PlayerArmorImpl.kt index 16424acd2..ef5e4c68a 100644 --- a/nms/v1_21_R3/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R3/PlayerArmorImpl.kt +++ b/nms/v1_21_R3/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R3/PlayerArmorImpl.kt @@ -9,6 +9,7 @@ package kr.toxicity.model.bukkit.nms.v1_21_R3 import kr.toxicity.model.api.armor.ArmorItem import kr.toxicity.model.api.armor.PlayerArmor +import kr.toxicity.model.api.util.TransformedItemStack import net.minecraft.core.component.DataComponents import net.minecraft.world.entity.EquipmentSlot import net.minecraft.world.item.component.DyedItemColor @@ -23,6 +24,10 @@ internal data class PlayerArmorImpl( return player.handle.getItemBySlot(EquipmentSlot.HEAD).toArmorItem() } + override fun helmetItem(): TransformedItemStack? { + return player.handle.getItemBySlot(EquipmentSlot.HEAD).toCustomItem() + } + override fun leggings(): ArmorItem? { return player.handle.getItemBySlot(EquipmentSlot.LEGS).toArmorItem() } @@ -44,4 +49,12 @@ internal data class PlayerArmorImpl( trim?.material?.value()?.assetName ) }?.orElse(null) + + private fun VanillaItemStack.toCustomItem(): TransformedItemStack? { + val defaultComponents = item.components() + if (get(DataComponents.ITEM_MODEL) == defaultComponents.get(DataComponents.ITEM_MODEL) && + get(DataComponents.CUSTOM_MODEL_DATA) == defaultComponents.get(DataComponents.CUSTOM_MODEL_DATA) + ) return null + return TransformedItemStack.of(copy().asBukkit().wrap()) + } } diff --git a/nms/v1_21_R4/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R4/PlayerArmorImpl.kt b/nms/v1_21_R4/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R4/PlayerArmorImpl.kt index 328be680f..d29bc0ef2 100644 --- a/nms/v1_21_R4/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R4/PlayerArmorImpl.kt +++ b/nms/v1_21_R4/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R4/PlayerArmorImpl.kt @@ -9,6 +9,7 @@ package kr.toxicity.model.bukkit.nms.v1_21_R4 import kr.toxicity.model.api.armor.ArmorItem import kr.toxicity.model.api.armor.PlayerArmor +import kr.toxicity.model.api.util.TransformedItemStack import net.minecraft.core.component.DataComponents import net.minecraft.world.entity.EquipmentSlot import net.minecraft.world.item.component.DyedItemColor @@ -23,6 +24,10 @@ internal data class PlayerArmorImpl( return player.handle.getItemBySlot(EquipmentSlot.HEAD).toArmorItem() } + override fun helmetItem(): TransformedItemStack? { + return player.handle.getItemBySlot(EquipmentSlot.HEAD).toCustomItem() + } + override fun leggings(): ArmorItem? { return player.handle.getItemBySlot(EquipmentSlot.LEGS).toArmorItem() } @@ -44,4 +49,12 @@ internal data class PlayerArmorImpl( trim?.material?.value()?.assets?.base?.suffix ) }?.orElse(null) + + private fun VanillaItemStack.toCustomItem(): TransformedItemStack? { + val defaultComponents = item.components() + if (get(DataComponents.ITEM_MODEL) == defaultComponents.get(DataComponents.ITEM_MODEL) && + get(DataComponents.CUSTOM_MODEL_DATA) == defaultComponents.get(DataComponents.CUSTOM_MODEL_DATA) + ) return null + return TransformedItemStack.of(copy().asBukkit().wrap()) + } } diff --git a/nms/v1_21_R5/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R5/PlayerArmorImpl.kt b/nms/v1_21_R5/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R5/PlayerArmorImpl.kt index 0c3933f44..0a6ceabee 100644 --- a/nms/v1_21_R5/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R5/PlayerArmorImpl.kt +++ b/nms/v1_21_R5/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R5/PlayerArmorImpl.kt @@ -9,6 +9,7 @@ package kr.toxicity.model.bukkit.nms.v1_21_R5 import kr.toxicity.model.api.armor.ArmorItem import kr.toxicity.model.api.armor.PlayerArmor +import kr.toxicity.model.api.util.TransformedItemStack import net.minecraft.core.component.DataComponents import net.minecraft.world.entity.EquipmentSlot import net.minecraft.world.item.component.DyedItemColor @@ -23,6 +24,10 @@ internal data class PlayerArmorImpl( return player.handle.getItemBySlot(EquipmentSlot.HEAD).toArmorItem() } + override fun helmetItem(): TransformedItemStack? { + return player.handle.getItemBySlot(EquipmentSlot.HEAD).toCustomItem() + } + override fun leggings(): ArmorItem? { return player.handle.getItemBySlot(EquipmentSlot.LEGS).toArmorItem() } @@ -44,4 +49,12 @@ internal data class PlayerArmorImpl( trim?.material?.value()?.assets?.base?.suffix ) }?.orElse(null) + + private fun VanillaItemStack.toCustomItem(): TransformedItemStack? { + val defaultComponents = item.components() + if (get(DataComponents.ITEM_MODEL) == defaultComponents.get(DataComponents.ITEM_MODEL) && + get(DataComponents.CUSTOM_MODEL_DATA) == defaultComponents.get(DataComponents.CUSTOM_MODEL_DATA) + ) return null + return TransformedItemStack.of(copy().asBukkit().wrap()) + } } diff --git a/nms/v1_21_R6/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R6/PlayerArmorImpl.kt b/nms/v1_21_R6/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R6/PlayerArmorImpl.kt index f859db910..c3a21f855 100644 --- a/nms/v1_21_R6/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R6/PlayerArmorImpl.kt +++ b/nms/v1_21_R6/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R6/PlayerArmorImpl.kt @@ -9,6 +9,7 @@ package kr.toxicity.model.bukkit.nms.v1_21_R6 import kr.toxicity.model.api.armor.ArmorItem import kr.toxicity.model.api.armor.PlayerArmor +import kr.toxicity.model.api.util.TransformedItemStack import net.minecraft.core.component.DataComponents import net.minecraft.world.entity.EquipmentSlot import net.minecraft.world.item.component.DyedItemColor @@ -23,6 +24,10 @@ internal data class PlayerArmorImpl( return player.handle.getItemBySlot(EquipmentSlot.HEAD).toArmorItem() } + override fun helmetItem(): TransformedItemStack? { + return player.handle.getItemBySlot(EquipmentSlot.HEAD).toCustomItem() + } + override fun leggings(): ArmorItem? { return player.handle.getItemBySlot(EquipmentSlot.LEGS).toArmorItem() } @@ -44,4 +49,12 @@ internal data class PlayerArmorImpl( trim?.material?.value()?.assets?.base?.suffix ) }?.orElse(null) + + private fun VanillaItemStack.toCustomItem(): TransformedItemStack? { + val defaultComponents = item.components() + if (get(DataComponents.ITEM_MODEL) == defaultComponents.get(DataComponents.ITEM_MODEL) && + get(DataComponents.CUSTOM_MODEL_DATA) == defaultComponents.get(DataComponents.CUSTOM_MODEL_DATA) + ) return null + return TransformedItemStack.of(copy().asBukkit().wrap()) + } } diff --git a/nms/v1_21_R7/build.gradle.kts b/nms/v1_21_R7/build.gradle.kts index d1245d7af..107a4dcd3 100644 --- a/nms/v1_21_R7/build.gradle.kts +++ b/nms/v1_21_R7/build.gradle.kts @@ -6,6 +6,8 @@ plugins { dependencies { paperweight.paperDevBundle("1.21.11-R0.1-SNAPSHOT") + testImplementation(project(":bettermodel-api")) + testImplementation(project(":bettermodel-api:bettermodel-bukkit-api")) } tasks { diff --git a/nms/v1_21_R7/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R7/PlayerArmorImpl.kt b/nms/v1_21_R7/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R7/PlayerArmorImpl.kt index 02cf31564..5b8fe2541 100644 --- a/nms/v1_21_R7/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R7/PlayerArmorImpl.kt +++ b/nms/v1_21_R7/src/main/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R7/PlayerArmorImpl.kt @@ -9,6 +9,7 @@ package kr.toxicity.model.bukkit.nms.v1_21_R7 import kr.toxicity.model.api.armor.ArmorItem import kr.toxicity.model.api.armor.PlayerArmor +import kr.toxicity.model.api.util.TransformedItemStack import net.minecraft.core.component.DataComponents import net.minecraft.world.entity.EquipmentSlot import net.minecraft.world.item.component.DyedItemColor @@ -23,6 +24,10 @@ internal data class PlayerArmorImpl( return player.handle.getItemBySlot(EquipmentSlot.HEAD).toArmorItem() } + override fun helmetItem(): TransformedItemStack? { + return player.handle.getItemBySlot(EquipmentSlot.HEAD).toCustomItem() + } + override fun leggings(): ArmorItem? { return player.handle.getItemBySlot(EquipmentSlot.LEGS).toArmorItem() } @@ -44,4 +49,12 @@ internal data class PlayerArmorImpl( trim?.material?.value()?.assets?.base?.suffix ) }?.orElse(null) + + private fun VanillaItemStack.toCustomItem(): TransformedItemStack? { + val defaultComponents = item.components() + if (get(DataComponents.ITEM_MODEL) == defaultComponents.get(DataComponents.ITEM_MODEL) && + get(DataComponents.CUSTOM_MODEL_DATA) == defaultComponents.get(DataComponents.CUSTOM_MODEL_DATA) + ) return null + return TransformedItemStack.of(copy().asBukkit().wrap()) + } } diff --git a/nms/v1_21_R7/src/test/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R7/PlayerArmorImplTest.kt b/nms/v1_21_R7/src/test/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R7/PlayerArmorImplTest.kt new file mode 100644 index 000000000..c05690c1b --- /dev/null +++ b/nms/v1_21_R7/src/test/kotlin/kr/toxicity/model/bukkit/nms/v1_21_R7/PlayerArmorImplTest.kt @@ -0,0 +1,96 @@ +/* + * This source file is part of BetterModel. + * Copyright (c) 2026 toxicity188 + * Licensed under the MIT License. + * See LICENSE.md file for full license text. + */ + +package kr.toxicity.model.bukkit.nms.v1_21_R7 + +import kr.toxicity.model.api.bukkit.platform.BukkitItemStack +import kr.toxicity.model.api.util.TransformedItemStack +import net.minecraft.SharedConstants +import net.minecraft.core.component.DataComponents +import net.minecraft.resources.Identifier +import net.minecraft.server.Bootstrap +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.Items +import net.minecraft.world.item.component.CustomModelData +import org.bukkit.craftbukkit.inventory.CraftItemStack +import sun.misc.Unsafe +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Acceptance criteria from BetterModel issue #410: + * 1. A head item with an item-model component is preserved as a separate custom head item when + * BetterModel converts it for player-emote rendering. + * 2. A head item with custom-model-data is preserved as a separate custom head item when BetterModel + * converts it for player-emote rendering. + */ +class PlayerArmorImplTest { + + @BeforeTest + fun bootstrapRegistries() { + SharedConstants.tryDetectVersion() + Bootstrap.bootStrap() + } + + @Test + fun `preserves item model when converting head equipment`() { + val plain = Items.IRON_HELMET.defaultInstance + val itemModel = Identifier.parse("bettermodel:issue_410_hat") + val customized = plain.copy().apply { + set(DataComponents.ITEM_MODEL, itemModel) + } + + assertEquals(itemModel, customized.get(DataComponents.ITEM_MODEL)) + assertNull(convertCustomItem(plain)) + val converted = assertNotNull(convertCustomItem(customized)) + assertEquals(itemModel, unwrap(converted).get(DataComponents.ITEM_MODEL)) + } + + @Test + fun `preserves custom model data when converting head equipment`() { + val plain = Items.IRON_HELMET.defaultInstance + val customModelData = CustomModelData( + listOf(410F), + emptyList(), + emptyList(), + emptyList() + ) + val customized = plain.copy().apply { + set(DataComponents.CUSTOM_MODEL_DATA, customModelData) + } + + assertEquals(customModelData, customized.get(DataComponents.CUSTOM_MODEL_DATA)) + assertNull(convertCustomItem(plain)) + val converted = assertNotNull(convertCustomItem(customized)) + assertEquals(customModelData, unwrap(converted).get(DataComponents.CUSTOM_MODEL_DATA)) + } + + private fun convertCustomItem(itemStack: ItemStack): TransformedItemStack? { + val converter = PlayerArmorImpl::class.java.declaredMethods.singleOrNull { + it.parameterTypes.contentEquals(arrayOf(ItemStack::class.java)) && + it.returnType == TransformedItemStack::class.java + } + assertNotNull(converter, "PlayerArmorImpl must preserve custom head-item model data") + converter.isAccessible = true + return converter.invoke(converterOwner, itemStack) as TransformedItemStack? + } + + private fun unwrap(itemStack: TransformedItemStack): ItemStack = CraftItemStack.asNMSCopy( + (itemStack.itemStack as BukkitItemStack).source() + ) + + private companion object { + private val unsafe = Unsafe::class.java.getDeclaredField("theUnsafe").run { + isAccessible = true + get(null) as Unsafe + } + private val converterOwner = unsafe.allocateInstance(PlayerArmorImpl::class.java) + } +} diff --git a/nms/v26_R1/src/main/kotlin/kr/toxicity/model/bukkit/nms/v26_R1/PlayerArmorImpl.kt b/nms/v26_R1/src/main/kotlin/kr/toxicity/model/bukkit/nms/v26_R1/PlayerArmorImpl.kt index 6bec07d8f..f3481fdd5 100644 --- a/nms/v26_R1/src/main/kotlin/kr/toxicity/model/bukkit/nms/v26_R1/PlayerArmorImpl.kt +++ b/nms/v26_R1/src/main/kotlin/kr/toxicity/model/bukkit/nms/v26_R1/PlayerArmorImpl.kt @@ -9,6 +9,7 @@ package kr.toxicity.model.bukkit.nms.v26_R1 import kr.toxicity.model.api.armor.ArmorItem import kr.toxicity.model.api.armor.PlayerArmor +import kr.toxicity.model.api.util.TransformedItemStack import net.minecraft.core.component.DataComponents import net.minecraft.world.entity.EquipmentSlot import net.minecraft.world.item.component.DyedItemColor @@ -23,6 +24,10 @@ internal data class PlayerArmorImpl( return player.handle.getItemBySlot(EquipmentSlot.HEAD).toArmorItem() } + override fun helmetItem(): TransformedItemStack? { + return player.handle.getItemBySlot(EquipmentSlot.HEAD).toCustomItem() + } + override fun leggings(): ArmorItem? { return player.handle.getItemBySlot(EquipmentSlot.LEGS).toArmorItem() } @@ -44,4 +49,12 @@ internal data class PlayerArmorImpl( trim?.material?.value()?.assets?.base?.suffix ) }?.orElse(null) + + private fun VanillaItemStack.toCustomItem(): TransformedItemStack? { + val defaultComponents = item.components() + if (get(DataComponents.ITEM_MODEL) == defaultComponents.get(DataComponents.ITEM_MODEL) && + get(DataComponents.CUSTOM_MODEL_DATA) == defaultComponents.get(DataComponents.CUSTOM_MODEL_DATA) + ) return null + return TransformedItemStack.of(copy().asBukkit().wrap()) + } } diff --git a/nms/v26_R2/src/main/kotlin/kr/toxicity/model/bukkit/nms/v26_R2/PlayerArmorImpl.kt b/nms/v26_R2/src/main/kotlin/kr/toxicity/model/bukkit/nms/v26_R2/PlayerArmorImpl.kt index 93869b1ed..c9a7d281e 100644 --- a/nms/v26_R2/src/main/kotlin/kr/toxicity/model/bukkit/nms/v26_R2/PlayerArmorImpl.kt +++ b/nms/v26_R2/src/main/kotlin/kr/toxicity/model/bukkit/nms/v26_R2/PlayerArmorImpl.kt @@ -9,6 +9,7 @@ package kr.toxicity.model.bukkit.nms.v26_R2 import kr.toxicity.model.api.armor.ArmorItem import kr.toxicity.model.api.armor.PlayerArmor +import kr.toxicity.model.api.util.TransformedItemStack import net.minecraft.core.component.DataComponents import net.minecraft.world.entity.EquipmentSlot import net.minecraft.world.item.component.DyedItemColor @@ -23,6 +24,10 @@ internal data class PlayerArmorImpl( return player.handle.getItemBySlot(EquipmentSlot.HEAD).toArmorItem() } + override fun helmetItem(): TransformedItemStack? { + return player.handle.getItemBySlot(EquipmentSlot.HEAD).toCustomItem() + } + override fun leggings(): ArmorItem? { return player.handle.getItemBySlot(EquipmentSlot.LEGS).toArmorItem() } @@ -44,4 +49,12 @@ internal data class PlayerArmorImpl( trim?.material?.value()?.assets?.base?.suffix ) }?.orElse(null) + + private fun VanillaItemStack.toCustomItem(): TransformedItemStack? { + val defaultComponents = item.components() + if (get(DataComponents.ITEM_MODEL) == defaultComponents.get(DataComponents.ITEM_MODEL) && + get(DataComponents.CUSTOM_MODEL_DATA) == defaultComponents.get(DataComponents.CUSTOM_MODEL_DATA) + ) return null + return TransformedItemStack.of(copy().asBukkit().wrap()) + } } diff --git a/platform/fabric/src/main/kotlin/kr/toxicity/model/impl/fabric/armor/PlayerArmorImpl.kt b/platform/fabric/src/main/kotlin/kr/toxicity/model/impl/fabric/armor/PlayerArmorImpl.kt index 579be337b..519ad77eb 100644 --- a/platform/fabric/src/main/kotlin/kr/toxicity/model/impl/fabric/armor/PlayerArmorImpl.kt +++ b/platform/fabric/src/main/kotlin/kr/toxicity/model/impl/fabric/armor/PlayerArmorImpl.kt @@ -9,6 +9,8 @@ package kr.toxicity.model.impl.fabric.armor import kr.toxicity.model.api.armor.ArmorItem import kr.toxicity.model.api.armor.PlayerArmor +import kr.toxicity.model.api.util.TransformedItemStack +import kr.toxicity.model.impl.fabric.wrap import net.minecraft.core.component.DataComponents import net.minecraft.server.network.ServerPlayerConnection import net.minecraft.world.entity.EquipmentSlot @@ -24,6 +26,8 @@ class PlayerArmorImpl(private val connection: ServerPlayerConnection) : PlayerAr override fun helmet(): ArmorItem? = player.getItemBySlot(EquipmentSlot.HEAD).toArmorItem() + override fun helmetItem(): TransformedItemStack? = player.getItemBySlot(EquipmentSlot.HEAD).toCustomItem() + override fun leggings(): ArmorItem? = player.getItemBySlot(EquipmentSlot.LEGS).toArmorItem() override fun chestplate(): ArmorItem? = player.getItemBySlot(EquipmentSlot.CHEST).toArmorItem() @@ -45,6 +49,14 @@ class PlayerArmorImpl(private val connection: ServerPlayerConnection) : PlayerAr ) } + private fun ItemStack.toCustomItem(): TransformedItemStack? { + val defaultComponents = item.components() + if (get(DataComponents.ITEM_MODEL) == defaultComponents.get(DataComponents.ITEM_MODEL) && + get(DataComponents.CUSTOM_MODEL_DATA) == defaultComponents.get(DataComponents.CUSTOM_MODEL_DATA) + ) return null + return TransformedItemStack.of(copy().wrap()) + } + private fun ArmorTrim.getPath() = pattern.value().assetId.path private fun ArmorTrim.getPalette() = material.value().assets.base.suffix