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