diff --git a/MODULE.bazel b/MODULE.bazel index fb4be9c7..1097ec7d 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -48,6 +48,7 @@ maven.install( "org.antlr:antlr4-runtime:4.13.1", "io.github.douira:glsl-transformer:2.0.1", "org.anarres:jcpp:1.4.14", + "com.illposed.osc:javaosc-core:0.9", # test dependencies "org.jetbrains.kotlin:kotlin-test-junit5:2.1.10", diff --git a/blazerod/model/model-base/src/main/kotlin/top/fifthlight/blazerod/model/Expression.kt b/blazerod/model/model-base/src/main/kotlin/top/fifthlight/blazerod/model/Expression.kt index b55fe1d6..6b9270e7 100644 --- a/blazerod/model/model-base/src/main/kotlin/top/fifthlight/blazerod/model/Expression.kt +++ b/blazerod/model/model-base/src/main/kotlin/top/fifthlight/blazerod/model/Expression.kt @@ -90,6 +90,29 @@ sealed class Expression { vrm0Name = "LookRight", vrm1Name = "lookRight", ); + + companion object { + private val vrm0NameMap = entries + .mapNotNull { it.takeIf { it.vrm0Name != null } } + .associateBy { it.vrm0Name!!.lowercase() } + + private val vrm1NameMap = entries + .mapNotNull { it.takeIf { it.vrm1Name != null } } + .associateBy { it.vrm1Name!!.lowercase() } + + private val pmxJapaneseMap = entries + .mapNotNull { it.takeIf { it.pmxJapanese != null } } + .associateBy { it.pmxJapanese!!.lowercase() } + + private val pmxEnglishMap = entries + .mapNotNull { it.takeIf { it.pmxEnglish != null } } + .associateBy { it.pmxEnglish!!.lowercase() } + + fun fromVrm0Name(name: String): Tag? = vrm0NameMap[name.lowercase()] + fun fromVrm1Name(name: String): Tag? = vrm1NameMap[name.lowercase()] + fun fromPmxJapanese(name: String): Tag? = pmxJapaneseMap[name.lowercase()] + fun fromPmxEnglish(name: String): Tag? = pmxEnglishMap[name.lowercase()] + } } data class Target( @@ -97,19 +120,19 @@ sealed class Expression { override val tag: Tag? = null, val isBinary: Boolean = false, val bindings: List, - ): Expression() { + ) : Expression() { sealed class Binding { data class NodeMorphTarget( val nodeId: NodeId, val index: Int, val weight: Float, - ): Binding() + ) : Binding() data class MeshMorphTarget( val meshId: MeshId, val index: Int, val weight: Float, - ): Binding() + ) : Binding() } } @@ -117,7 +140,7 @@ sealed class Expression { override val name: String? = null, override val tag: Tag? = null, val targets: List, - ): Expression() { + ) : Expression() { data class TargetItem( val target: Target, val influence: Float, diff --git a/blazerod/model/model-base/src/main/kotlin/top/fifthlight/blazerod/model/HumanoidTag.kt b/blazerod/model/model-base/src/main/kotlin/top/fifthlight/blazerod/model/HumanoidTag.kt index 815ac59e..49bd3dd8 100644 --- a/blazerod/model/model-base/src/main/kotlin/top/fifthlight/blazerod/model/HumanoidTag.kt +++ b/blazerod/model/model-base/src/main/kotlin/top/fifthlight/blazerod/model/HumanoidTag.kt @@ -1,12 +1,98 @@ package top.fifthlight.blazerod.model +// VRM spec: https://github.com/vrm-c/vrm-specification/blob/master/specification/VRMC_vrm-1.0/humanoid.md#list-of-humanoid-bones +// PMX reference: https://vrstormlab.com/dancexr/features/bones enum class HumanoidTag( val vrmName: String? = null, val pmxEnglish: String? = null, val pmxJapanese: String? = null, ) { - CENTER(vrmName = "hips", pmxEnglish = "Root", pmxJapanese = "センター"), - HEAD(vrmName = "head", pmxEnglish = "Head", pmxJapanese = "頭"); + // Torso + HIPS(vrmName = "hips", pmxEnglish = "Center", pmxJapanese = "センター"), + SPINE(vrmName = "spine", pmxEnglish = "Torso", pmxJapanese = "上半身"), + CHEST(vrmName = "chest", pmxEnglish = "Torso2", pmxJapanese = "上半身2"), + UPPER_CHEST(vrmName = "upperChest"), + NECK(vrmName = "neck", pmxEnglish = "Neck", pmxJapanese = "首"), + + // Head + HEAD(vrmName = "head", pmxEnglish = "Head", pmxJapanese = "頭"), + + // Disable them because we have no correct VRM gaze control + // LEFT_EYE(vrmName = "leftEye", pmxEnglish = "LeftEye", pmxJapanese = "左目"), + // RIGHT_EYE(vrmName = "rightEye", pmxEnglish = "RightEye", pmxJapanese = "右目"), + JAW(vrmName = "jaw"), + + // Leg + LEFT_UPPER_LEG(vrmName = "leftUpperLeg", pmxEnglish = "LeftLeg", pmxJapanese = "左足"), + LEFT_LOWER_LEG(vrmName = "leftLowerLeg", pmxEnglish = "LeftKnee", pmxJapanese = "左ひざ"), + LEFT_FOOT(vrmName = "leftFoot", pmxEnglish = "LeftAnkle", pmxJapanese = "左足首"), + LEFT_TOES(vrmName = "leftToes", pmxEnglish = "LeftToe", pmxJapanese = "左つま先"), + RIGHT_UPPER_LEG(vrmName = "rightUpperLeg", pmxEnglish = "RightLeg", pmxJapanese = "右足"), + RIGHT_LOWER_LEG(vrmName = "rightLowerLeg", pmxEnglish = "RightKnee", pmxJapanese = "右ひざ"), + RIGHT_FOOT(vrmName = "rightFoot", pmxEnglish = "RightAnkle", pmxJapanese = "右足首"), + RIGHT_TOES(vrmName = "rightToes", pmxEnglish = "RightToe", pmxJapanese = "右つま先"), + + // Arm + LEFT_SHOULDER(vrmName = "leftShoulder", pmxEnglish = "LeftShoulder", pmxJapanese = "左肩"), + LEFT_UPPER_ARM(vrmName = "leftUpperArm", pmxEnglish = "LeftArm", pmxJapanese = "左腕"), + LEFT_LOWER_ARM(vrmName = "leftLowerArm", pmxEnglish = "LeftElbow", pmxJapanese = "左ひじ"), + LEFT_HAND(vrmName = "leftHand", pmxEnglish = "LeftWrist", pmxJapanese = "左手首"), + RIGHT_SHOULDER(vrmName = "rightShoulder", pmxEnglish = "RightShoulder", pmxJapanese = "右肩"), + RIGHT_UPPER_ARM(vrmName = "rightUpperArm", pmxEnglish = "RightArm", pmxJapanese = "右腕"), + RIGHT_LOWER_ARM(vrmName = "rightLowerArm", pmxEnglish = "RightElbow", pmxJapanese = "右ひじ"), + RIGHT_HAND(vrmName = "rightHand", pmxEnglish = "RightWrist", pmxJapanese = "右手首"), + + // Finger + LEFT_THUMB_METACARPAL(vrmName = "leftThumbMetacarpal", pmxEnglish = "LeftThumb1", pmxJapanese = "左親指1"), + LEFT_THUMB_PROXIMAL(vrmName = "leftThumbProximal", pmxEnglish = "LeftThumb2", pmxJapanese = "左親指2"), + LEFT_THUMB_DISTAL(vrmName = "leftThumbDistal", pmxEnglish = "LeftThumb3", pmxJapanese = "左親指3"), + LEFT_INDEX_PROXIMAL(vrmName = "leftIndexProximal", pmxEnglish = "LeftIndexFinger1", pmxJapanese = "左人指1"), + LEFT_INDEX_INTERMEDIATE( + vrmName = "leftIndexIntermediate", + pmxEnglish = "LeftIndexFinger2", + pmxJapanese = "左人指2" + ), + LEFT_INDEX_DISTAL(vrmName = "leftIndexDistal", pmxEnglish = "LeftIndexFinger3", pmxJapanese = "左人指3"), + LEFT_MIDDLE_PROXIMAL(vrmName = "leftMiddleProximal", pmxEnglish = "LeftMiddleFinger1", pmxJapanese = "左中指1"), + LEFT_MIDDLE_INTERMEDIATE( + vrmName = "leftMiddleIntermediate", + pmxEnglish = "LeftMiddleFinger2", + pmxJapanese = "左中指2" + ), + LEFT_MIDDLE_DISTAL(vrmName = "leftMiddleDistal", pmxEnglish = "LeftMiddleFinger3", pmxJapanese = "左中指3"), + LEFT_RING_PROXIMAL(vrmName = "leftRingProximal", pmxEnglish = "LeftRingFinger1", pmxJapanese = "左薬指1"), + LEFT_RING_INTERMEDIATE(vrmName = "leftRingIntermediate", pmxEnglish = "LeftRingFinger2", pmxJapanese = "左薬指2"), + LEFT_RING_DISTAL(vrmName = "leftRingDistal", pmxEnglish = "LeftRingFinger3", pmxJapanese = "左薬指3"), + LEFT_LITTLE_PROXIMAL(vrmName = "leftLittleProximal", pmxEnglish = "LeftPinky1", pmxJapanese = "左小指1"), + LEFT_LITTLE_INTERMEDIATE(vrmName = "leftLittleIntermediate", pmxEnglish = "LeftPinky2", pmxJapanese = "左小指2"), + LEFT_LITTLE_DISTAL(vrmName = "leftLittleDistal", pmxEnglish = "LeftPinky3", pmxJapanese = "左小指3"), + RIGHT_THUMB_METACARPAL(vrmName = "rightThumbMetacarpal", pmxEnglish = "RightThumb1", pmxJapanese = "右親指1"), + RIGHT_THUMB_PROXIMAL(vrmName = "rightThumbProximal", pmxEnglish = "RightThumb2", pmxJapanese = "右親指2"), + RIGHT_THUMB_DISTAL(vrmName = "rightThumbDistal", pmxEnglish = "RightThumb3", pmxJapanese = "右親指3"), + RIGHT_INDEX_PROXIMAL(vrmName = "rightIndexProximal", pmxEnglish = "RightIndexFinger1", pmxJapanese = "右人指1"), + RIGHT_INDEX_INTERMEDIATE( + vrmName = "rightIndexIntermediate", + pmxEnglish = "RightIndexFinger2", + pmxJapanese = "右人指2" + ), + RIGHT_INDEX_DISTAL(vrmName = "rightIndexDistal", pmxEnglish = "RightIndexFinger3", pmxJapanese = "右人指3"), + RIGHT_MIDDLE_PROXIMAL(vrmName = "rightMiddleProximal", pmxEnglish = "RightMiddleFinger1", pmxJapanese = "右中指1"), + RIGHT_MIDDLE_INTERMEDIATE( + vrmName = "rightMiddleIntermediate", + pmxEnglish = "RightMiddleFinger2", + pmxJapanese = "右中指2" + ), + RIGHT_MIDDLE_DISTAL(vrmName = "rightMiddleDistal", pmxEnglish = "RightMiddleFinger3", pmxJapanese = "右中指3"), + RIGHT_RING_PROXIMAL(vrmName = "rightRingProximal", pmxEnglish = "RightRingFinger1", pmxJapanese = "右薬指1"), + RIGHT_RING_INTERMEDIATE( + vrmName = "rightRingIntermediate", + pmxEnglish = "RightRingFinger2", + pmxJapanese = "右薬指2" + ), + RIGHT_RING_DISTAL(vrmName = "rightRingDistal", pmxEnglish = "RightRingFinger3", pmxJapanese = "右薬指3"), + RIGHT_LITTLE_PROXIMAL(vrmName = "rightLittleProximal", pmxEnglish = "RightPinky1", pmxJapanese = "右小指1"), + RIGHT_LITTLE_INTERMEDIATE(vrmName = "rightLittleIntermediate", pmxEnglish = "RightPinky2", pmxJapanese = "右小指2"), + RIGHT_LITTLE_DISTAL(vrmName = "rightLittleDistal", pmxEnglish = "RightPinky3", pmxJapanese = "右小指3"); companion object { private val vrmNameMap = entries.associateBy { it.vrmName } diff --git a/blazerod/model/model-gltf/src/main/kotlin/top/fifthlight/blazerod/model/gltf/GltfLoader.kt b/blazerod/model/model-gltf/src/main/kotlin/top/fifthlight/blazerod/model/gltf/GltfLoader.kt index 4f7f3a09..4edc2f6d 100644 --- a/blazerod/model/model-gltf/src/main/kotlin/top/fifthlight/blazerod/model/gltf/GltfLoader.kt +++ b/blazerod/model/model-gltf/src/main/kotlin/top/fifthlight/blazerod/model/gltf/GltfLoader.kt @@ -565,9 +565,7 @@ internal class GltfLoader( expressions = vrmV0.map { expression -> Expression.Target( name = expression.name, - tag = Expression.Tag.entries.firstOrNull { - it.vrm0Name.equals(expression.presetName, ignoreCase = true) - }, + tag = expression.presetName?.let(Expression.Tag::fromVrm0Name), bindings = expression.binds?.map { bind -> Expression.Target.Binding.MeshMorphTarget( meshId = MeshId(uuid, bind.mesh), @@ -587,9 +585,7 @@ internal class GltfLoader( Expression.Target( name = name, isBinary = value.isBinary ?: false, - tag = Expression.Tag.entries.firstOrNull { - it.vrm1Name.equals(name, ignoreCase = true) - }, + tag = Expression.Tag.fromVrm1Name(name), bindings = value.morphTargetBinds?.map { bind -> Expression.Target.Binding.NodeMorphTarget( nodeId = NodeId(uuid, bind.node), diff --git a/blazerod/model/model-pmx/src/main/kotlin/top/fifthlight/blazerod/model/pmx/PmxLoader.kt b/blazerod/model/model-pmx/src/main/kotlin/top/fifthlight/blazerod/model/pmx/PmxLoader.kt index 39a79c16..ba799101 100644 --- a/blazerod/model/model-pmx/src/main/kotlin/top/fifthlight/blazerod/model/pmx/PmxLoader.kt +++ b/blazerod/model/model-pmx/src/main/kotlin/top/fifthlight/blazerod/model/pmx/PmxLoader.kt @@ -739,8 +739,7 @@ class PmxLoader : ModelFileLoader { val nameLocal = loadString(buffer) val nameUniversal = loadString(buffer) val expressionTag = - Expression.Tag.entries.firstOrNull { it.pmxJapanese.equals(nameLocal, ignoreCase = true) } - ?: Expression.Tag.entries.firstOrNull { it.pmxEnglish.equals(nameUniversal, ignoreCase = true) } + Expression.Tag.fromPmxJapanese(nameLocal) ?: Expression.Tag.fromPmxEnglish(nameUniversal) val panelType = buffer.get().toInt() .let { type -> PmxMorphPanelType.entries.firstOrNull { it.value == type } } ?: throw PmxLoadException("Unknown panel type") diff --git a/maven_install.json b/maven_install.json index 658f79c4..fd21e1de 100755 --- a/maven_install.json +++ b/maven_install.json @@ -1,7 +1,7 @@ { "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL", - "__INPUT_ARTIFACTS_HASH": 668299150, - "__RESOLVED_ARTIFACTS_HASH": -119811499, + "__INPUT_ARTIFACTS_HASH": -570601380, + "__RESOLVED_ARTIFACTS_HASH": -144996448, "conflict_resolution": { "com.google.code.gson:gson:2.8.9": "com.google.code.gson:gson:2.10.1", "com.google.errorprone:error_prone_annotations:2.23.0": "com.google.errorprone:error_prone_annotations:2.24.1", @@ -179,6 +179,12 @@ }, "version": "72.1" }, + "com.illposed.osc:javaosc-core": { + "shasums": { + "jar": "9d2b8011ca8046cd8685911ae5d1b989c4fe424cbf372303f6af41e62ae770ec" + }, + "version": "0.9" + }, "com.squareup.okhttp:okhttp": { "shasums": { "jar": "88ac9fd1bb51f82bcc664cc1eb9c225c90dc4389d660231b4cc737bebfe7d0aa" @@ -967,9 +973,15 @@ }, "org.slf4j:slf4j-api": { "shasums": { - "jar": "18c4a0095d5c1da6b817592e767bb23d29dd2f560ad74df75ff3961dbde25b79" + "jar": "e7c2a48e8515ba1f49fa637d57b4e2f590b3f5bd97407ac699c3aa5efb1204a9" }, - "version": "1.7.25" + "version": "2.0.13" + }, + "org.slf4j:slf4j-ext": { + "shasums": { + "jar": "9963ed4a45df1ad7d197f6940e99b8063edab645370d427bea4901bad5846c95" + }, + "version": "2.0.13" }, "org.vineflower:vineflower": { "shasums": { @@ -1100,6 +1112,10 @@ "org.checkerframework:checker-qual", "org.ow2.asm:asm" ], + "com.illposed.osc:javaosc-core": [ + "org.slf4j:slf4j-api", + "org.slf4j:slf4j-ext" + ], "com.squareup.okhttp:okhttp": [ "com.squareup.okio:okio" ], @@ -1477,6 +1493,9 @@ "org.ow2.asm:asm", "org.ow2.asm:asm-analysis", "org.ow2.asm:asm-tree" + ], + "org.slf4j:slf4j-ext": [ + "org.slf4j:slf4j-api" ] }, "packages": { @@ -1885,6 +1904,16 @@ "com.ibm.icu.text", "com.ibm.icu.util" ], + "com.illposed.osc:javaosc-core": [ + "com.illposed.osc", + "com.illposed.osc.argument", + "com.illposed.osc.argument.handler", + "com.illposed.osc.messageselector", + "com.illposed.osc.transport", + "com.illposed.osc.transport.channel", + "com.illposed.osc.transport.tcp", + "com.illposed.osc.transport.udp" + ], "com.squareup.okhttp:okhttp": [ "com.squareup.okhttp", "com.squareup.okhttp.internal", @@ -3180,6 +3209,14 @@ "org.slf4j.helpers", "org.slf4j.spi" ], + "org.slf4j:slf4j-ext": [ + "org.slf4j", + "org.slf4j.agent", + "org.slf4j.cal10n", + "org.slf4j.ext", + "org.slf4j.instrumentation", + "org.slf4j.profiler" + ], "org.vineflower:vineflower": [ "net.fabricmc.fernflower.api", "org.jetbrains.java.decompiler.api", @@ -3248,6 +3285,7 @@ "com.google.truth:truth", "com.h2database:h2", "com.ibm.icu:icu4j", + "com.illposed.osc:javaosc-core", "com.squareup.okhttp:okhttp", "com.squareup.okio:okio", "com.sun.jersey:jersey-client", @@ -3380,6 +3418,7 @@ "org.ow2.asm:asm-tree", "org.ow2.asm:asm-util", "org.slf4j:slf4j-api", + "org.slf4j:slf4j-ext", "org.vineflower:vineflower" ], "https://maven.fabricmc.net/": [ @@ -3411,6 +3450,7 @@ "com.google.truth:truth", "com.h2database:h2", "com.ibm.icu:icu4j", + "com.illposed.osc:javaosc-core", "com.squareup.okhttp:okhttp", "com.squareup.okio:okio", "com.sun.jersey:jersey-client", @@ -3543,6 +3583,7 @@ "org.ow2.asm:asm-tree", "org.ow2.asm:asm-util", "org.slf4j:slf4j-api", + "org.slf4j:slf4j-ext", "org.vineflower:vineflower" ], "https://maven.terraformersmc.com/": [ @@ -3574,6 +3615,7 @@ "com.google.truth:truth", "com.h2database:h2", "com.ibm.icu:icu4j", + "com.illposed.osc:javaosc-core", "com.squareup.okhttp:okhttp", "com.squareup.okio:okio", "com.sun.jersey:jersey-client", @@ -3706,6 +3748,7 @@ "org.ow2.asm:asm-tree", "org.ow2.asm:asm-util", "org.slf4j:slf4j-api", + "org.slf4j:slf4j-ext", "org.vineflower:vineflower" ], "https://maven.nucleoid.xyz/": [ @@ -3737,6 +3780,7 @@ "com.google.truth:truth", "com.h2database:h2", "com.ibm.icu:icu4j", + "com.illposed.osc:javaosc-core", "com.squareup.okhttp:okhttp", "com.squareup.okio:okio", "com.sun.jersey:jersey-client", @@ -3869,6 +3913,7 @@ "org.ow2.asm:asm-tree", "org.ow2.asm:asm-util", "org.slf4j:slf4j-api", + "org.slf4j:slf4j-ext", "org.vineflower:vineflower" ], "https://masa.dy.fi/maven/": [ @@ -3900,6 +3945,7 @@ "com.google.truth:truth", "com.h2database:h2", "com.ibm.icu:icu4j", + "com.illposed.osc:javaosc-core", "com.squareup.okhttp:okhttp", "com.squareup.okio:okio", "com.sun.jersey:jersey-client", @@ -4032,6 +4078,7 @@ "org.ow2.asm:asm-tree", "org.ow2.asm:asm-util", "org.slf4j:slf4j-api", + "org.slf4j:slf4j-ext", "org.vineflower:vineflower" ], "https://api.modrinth.com/maven/": [ @@ -4063,6 +4110,7 @@ "com.google.truth:truth", "com.h2database:h2", "com.ibm.icu:icu4j", + "com.illposed.osc:javaosc-core", "com.squareup.okhttp:okhttp", "com.squareup.okio:okio", "com.sun.jersey:jersey-client", @@ -4195,6 +4243,7 @@ "org.ow2.asm:asm-tree", "org.ow2.asm:asm-util", "org.slf4j:slf4j-api", + "org.slf4j:slf4j-ext", "org.vineflower:vineflower" ] }, diff --git a/mod/BUILD.bazel b/mod/BUILD.bazel index 945059d1..5b7b7243 100644 --- a/mod/BUILD.bazel +++ b/mod/BUILD.bazel @@ -132,6 +132,7 @@ kt_jvm_library( "//:kotlin_serialization", "//blazerod:blazerod_without_jij", "@maven//:com_h2database_h2", + "@maven//:com_illposed_osc_javaosc_core", "@maven//:io_github_llamalad7_mixinextras_fabric", "@maven//:net_fabricmc_fabric_language_kotlin", "@maven//:net_fabricmc_fabric_loader", @@ -172,6 +173,7 @@ merge_jij( visibility = ["//visibility:public"], deps = { "@maven//:com_h2database_h2": "com_h2database_h2:2.3.232", + "@maven//:com_illposed_osc_javaosc_core": "javaosc-core:0.9", "//blazerod:blazerod_without_jij": "blazerod:=", "//blazerod/model/model-base": "blazerod-model-base:=", "//blazerod/model/model-formats": "blazerod-model-formats:=", @@ -239,6 +241,7 @@ java_binary( "//game:remapped_iris", "//game:remapped_sodium", "@maven//:com_h2database_h2", + "@maven//:com_illposed_osc_javaosc_core", "@maven//:io_github_douira_glsl_transformer", "@maven//:io_github_llamalad7_mixinextras_fabric", "@maven//:net_fabricmc_fabric_language_kotlin", diff --git a/mod/src/client/kotlin/top/fifthlight/armorstand/config/GlobalConfig.kt b/mod/src/client/kotlin/top/fifthlight/armorstand/config/GlobalConfig.kt index 2a40edfc..83ad96ac 100644 --- a/mod/src/client/kotlin/top/fifthlight/armorstand/config/GlobalConfig.kt +++ b/mod/src/client/kotlin/top/fifthlight/armorstand/config/GlobalConfig.kt @@ -31,6 +31,7 @@ data class GlobalConfig( val thirdPersonDistanceScale: Float = 1f, val invertHeadDirection: Boolean = false, val renderer: RendererKey = RendererKey.VERTEX_SHADER_TRANSFORM, + val vmcUdpPort: Int = 9000, ) { companion object { private val logger = LoggerFactory.getLogger(GlobalConfig::class.java) diff --git a/mod/src/client/kotlin/top/fifthlight/armorstand/state/ModelController.kt b/mod/src/client/kotlin/top/fifthlight/armorstand/state/ModelController.kt index 9baa4e71..e4a2ea9d 100644 --- a/mod/src/client/kotlin/top/fifthlight/armorstand/state/ModelController.kt +++ b/mod/src/client/kotlin/top/fifthlight/armorstand/state/ModelController.kt @@ -9,6 +9,7 @@ import top.fifthlight.armorstand.config.ConfigHolder import top.fifthlight.armorstand.extension.internal.PlayerEntityRenderStateExtInternal import top.fifthlight.armorstand.ui.model.AnimationViewModel import top.fifthlight.armorstand.util.toRadian +import top.fifthlight.armorstand.vmc.VmcMarionetteManager import top.fifthlight.blazerod.animation.AnimationItem import top.fifthlight.blazerod.animation.Timeline import top.fifthlight.blazerod.model.* @@ -28,6 +29,10 @@ sealed class ModelController { fun update(instance: ModelInstance, func: NodeTransform.Decomposed.() -> Unit) { instance.setTransformDecomposed(nodeIndex, TransformId.RELATIVE_ANIMATION, func) } + + fun updateAbsolute(instance: ModelInstance, func: NodeTransform.Decomposed.() -> Unit) { + instance.setTransformDecomposed(nodeIndex, TransformId.ABSOLUTE, func) + } } private sealed class ExpressionItem { @@ -115,7 +120,7 @@ sealed class ModelController { private var blinkProgress: Float = 0f constructor(scene: RenderScene) : this( - center = scene.getBone(HumanoidTag.CENTER), + center = scene.getBone(HumanoidTag.HIPS), head = scene.getBone(HumanoidTag.HEAD), blinkExpression = scene.getExpression(Expression.Tag.BLINK), ) @@ -333,4 +338,36 @@ sealed class ModelController { blinkExpression?.apply(instance, blinkProgress) } } + + class Vmc( + private val scene: RenderScene, + ) : ModelController() { + private val bones = mutableMapOf>() + private val expressions = mutableMapOf>() + + override fun apply(instance: ModelInstance) { + val state = VmcMarionetteManager.getState() ?: return + state.rootTransform?.let { + instance.setTransformDecomposed(scene.rootNode.nodeIndex, TransformId.ABSOLUTE) { + translation.set(it.position) + rotation.set(it.rotation) + } + } + state.boneTransforms.forEach { (bone, value) -> + val item = bones.getOrPut(bone) { Optional.ofNullable(scene.getBone(bone)) } + item.ifPresent { + it.updateAbsolute(instance) { + translation.set(value.position) + rotation.set(value.rotation) + } + } + } + state.blendShapes.forEach { (tag, value) -> + val item = expressions.getOrPut(tag) { Optional.ofNullable(scene.getExpression(tag)) } + item.ifPresent { + it.apply(instance, value) + } + } + } + } } diff --git a/mod/src/client/kotlin/top/fifthlight/armorstand/state/ModelInstanceManager.kt b/mod/src/client/kotlin/top/fifthlight/armorstand/state/ModelInstanceManager.kt index dc15c74e..6c44e41d 100644 --- a/mod/src/client/kotlin/top/fifthlight/armorstand/state/ModelInstanceManager.kt +++ b/mod/src/client/kotlin/top/fifthlight/armorstand/state/ModelInstanceManager.kt @@ -7,6 +7,7 @@ import net.fabricmc.loader.api.FabricLoader import net.minecraft.client.MinecraftClient import top.fifthlight.armorstand.ArmorStand import top.fifthlight.armorstand.config.ConfigHolder +import top.fifthlight.armorstand.vmc.VmcMarionetteManager import top.fifthlight.blazerod.animation.AnimationItem import top.fifthlight.blazerod.animation.AnimationLoader import top.fifthlight.blazerod.model.Metadata @@ -141,10 +142,7 @@ object ModelInstanceManager { return null } - val path = ClientModelPathManager.getPath(uuid) - if (path == null) { - return null - } + val path = ClientModelPathManager.getPath(uuid) ?: return null val lastAccessTime = if (isSelf) { -1 @@ -188,9 +186,11 @@ object ModelInstanceManager { lastAccessTime = lastAccessTime, instance = ModelInstance(scene), controller = run { + val vmcRunning = VmcMarionetteManager.state.value is VmcMarionetteManager.State.Running val animationSet = FullAnimationSet.from(cache.animationSet) val animation = cache.animations.firstOrNull() when { + isSelf && vmcRunning -> ModelController.Vmc(scene) animationSet != null -> ModelController.LiveSwitched(scene, animationSet) animation != null -> ModelController.Predefined(animation) else -> ModelController.LiveUpdated(scene) diff --git a/mod/src/client/kotlin/top/fifthlight/armorstand/ui/model/VmcConfigScreenModel.kt b/mod/src/client/kotlin/top/fifthlight/armorstand/ui/model/VmcConfigScreenModel.kt new file mode 100644 index 00000000..53a00988 --- /dev/null +++ b/mod/src/client/kotlin/top/fifthlight/armorstand/ui/model/VmcConfigScreenModel.kt @@ -0,0 +1,39 @@ +package top.fifthlight.armorstand.ui.model + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import top.fifthlight.armorstand.config.ConfigHolder +import top.fifthlight.armorstand.ui.state.VmcConfigScreenState +import top.fifthlight.armorstand.vmc.VmcMarionetteManager + +class VmcConfigScreenModel(scope: CoroutineScope) : ViewModel(scope) { + private val _uiState = MutableStateFlow(VmcConfigScreenState()) + val uiState = _uiState.asStateFlow() + + init { + scope.apply { + launch { + ConfigHolder.config.collect { + _uiState.value = _uiState.value.copy( + portNumber = it.vmcUdpPort, + ) + } + } + launch { + VmcMarionetteManager.state.collect { + _uiState.value = _uiState.value.copy(state = it) + } + } + } + } + + fun startVmcClient() { + VmcMarionetteManager.start(uiState.value.portNumber) + } + + fun stopVmcClient() { + VmcMarionetteManager.stop() + } +} \ No newline at end of file diff --git a/mod/src/client/kotlin/top/fifthlight/armorstand/ui/screen/ConfigScreen.kt b/mod/src/client/kotlin/top/fifthlight/armorstand/ui/screen/ConfigScreen.kt index 96537e74..423f2a26 100644 --- a/mod/src/client/kotlin/top/fifthlight/armorstand/ui/screen/ConfigScreen.kt +++ b/mod/src/client/kotlin/top/fifthlight/armorstand/ui/screen/ConfigScreen.kt @@ -242,6 +242,10 @@ class ConfigScreen(parent: Screen? = null) : ArmorStandScreen( + title = Text.translatable("armorstand.vmc"), + parent = parent, + viewModelFactory = ::VmcConfigScreenModel, +) { + private val topBar by lazy { + TextWidget(width, 32, title, currentClient.textRenderer) + } + private val closeButton = ButtonWidget.builder(ScreenTexts.BACK) { close() }.build() + + private val statusLabel by lazy { + TextWidget(Text.empty(), currentClient.textRenderer).apply { + scope.launch { + viewModel.uiState.collect { + message = when (it.state) { + is VmcMarionetteManager.State.Running -> Text.translatable( + "armorstand.vmc.running", + it.state.port + ) + + is VmcMarionetteManager.State.Stopped -> Text.translatable("armorstand.vmc.stopped") + + is VmcMarionetteManager.State.Failed -> Text.translatable( + "armorstand.vmc.failed", + it.state.exception.message + ) + } + } + } + } + } + + private val portLabel by lazy { + TextWidget(Text.translatable("armorstand.vmc.port"), currentClient.textRenderer) + } + + private val portField by lazy { + val textContent = MutableStateFlow(viewModel.uiState.value.portNumber.toString()) + scope.launch { + textContent.collect { portStr -> + portStr.toIntOrNull()?.let { port -> + ConfigHolder.update { + copy(vmcUdpPort = port) + } + } + } + } + textField( + text = textContent, + onChanged = { + textContent.value = it + }, + ) + } + + private val startButton = ButtonWidget.builder(Text.translatable("armorstand.vmc.start")) { + when (viewModel.uiState.value.state) { + VmcMarionetteManager.State.Stopped, is VmcMarionetteManager.State.Failed -> viewModel.startVmcClient() + is VmcMarionetteManager.State.Running -> viewModel.stopVmcClient() + } + }.build().apply { + scope.launch { + viewModel.uiState.collect { + message = when (it.state) { + VmcMarionetteManager.State.Stopped, is VmcMarionetteManager.State.Failed -> + Text.translatable("armorstand.vmc.start") + + is VmcMarionetteManager.State.Running -> Text.translatable("armorstand.vmc.stop") + } + } + } + } + + override fun init() { + val rootLayout = BorderLayout( + width = width, + height = height, + direction = BorderLayout.Direction.VERTICAL, + ) + rootLayout.setFirstElement(topBar) { topBar, width, height -> topBar.width = width } + rootLayout.setCenterElement( + widget = LinearLayout( + width = 192, + direction = LinearLayout.Direction.VERTICAL, + padding = Insets(8), + gap = 8, + surface = Surface.listBackgroundWithSeparator(), + ).apply { + add(statusLabel, expand = true) + add( + widget = BorderLayout( + width = 192, + height = portField.height, + direction = BorderLayout.Direction.HORIZONTAL, + ).apply { + setFirstElement(portLabel, Positioner.create().marginRight(8)) + setCenterElement(portField) + }, + expand = true, + ) + add(startButton, expand = true) + pack() + addDrawable(this) + }, + positioner = Positioner.create().alignVerticalCenter().alignHorizontalCenter(), + onSizeChanged = { _, _, _ -> }, + ) + rootLayout.setSecondElement( + LinearLayout( + width = width, + height = 32, + direction = LinearLayout.Direction.HORIZONTAL, + align = LinearLayout.Align.CENTER, + gap = 8, + ).apply { + add(closeButton, Positioner.create().apply { alignVerticalCenter() }) + }) + rootLayout.refreshPositions() + rootLayout.forEachChild { addDrawableChild(it) } + } +} \ No newline at end of file diff --git a/mod/src/client/kotlin/top/fifthlight/armorstand/ui/state/VmcConfigScreenState.kt b/mod/src/client/kotlin/top/fifthlight/armorstand/ui/state/VmcConfigScreenState.kt new file mode 100644 index 00000000..4f329a1a --- /dev/null +++ b/mod/src/client/kotlin/top/fifthlight/armorstand/ui/state/VmcConfigScreenState.kt @@ -0,0 +1,8 @@ +package top.fifthlight.armorstand.ui.state + +import top.fifthlight.armorstand.vmc.VmcMarionetteManager + +data class VmcConfigScreenState( + val state: VmcMarionetteManager.State = VmcMarionetteManager.State.Stopped, + val portNumber: Int = 9000, +) diff --git a/mod/src/client/kotlin/top/fifthlight/armorstand/vmc/VmcMarionetteClient.kt b/mod/src/client/kotlin/top/fifthlight/armorstand/vmc/VmcMarionetteClient.kt new file mode 100644 index 00000000..b2f4171b --- /dev/null +++ b/mod/src/client/kotlin/top/fifthlight/armorstand/vmc/VmcMarionetteClient.kt @@ -0,0 +1,131 @@ +package top.fifthlight.armorstand.vmc + +import com.illposed.osc.OSCBundle +import com.illposed.osc.OSCMessage +import com.illposed.osc.OSCPacket +import com.illposed.osc.transport.Transport +import com.illposed.osc.transport.udp.UDPTransport +import kotlinx.coroutines.* +import net.minecraft.client.MinecraftClient +import org.slf4j.LoggerFactory +import top.fifthlight.armorstand.util.ThreadExecutorDispatcher +import top.fifthlight.blazerod.model.Expression +import top.fifthlight.blazerod.model.HumanoidTag + +class VmcMarionetteClient( + private val transport: Transport, +) : AutoCloseable { + companion object { + private val logger = LoggerFactory.getLogger(VmcMarionetteClient::class.java) + } + + private val scope = CoroutineScope(ThreadExecutorDispatcher(MinecraftClient.getInstance()) + SupervisorJob()) + + @Volatile + private var closed = false + + private val stateHolder: VmcMarionetteStateHolder = VmcMarionetteStateHolder() + + fun getState() = stateHolder.read() + + private fun handlePacket(message: OSCPacket) { + when (message) { + is OSCBundle -> { + message.packets.forEach(::handlePacket) + } + + is OSCMessage -> handleMessage(message) + } + } + + private val pendingBlendValues = mutableMapOf() + private fun handleMessage(message: OSCMessage) { + when (message.address) { + "/VMC/Ext/Root/Pos" -> { + val name = (message.arguments.getOrNull(0) as? String) ?: return + if (name != "root") { + return + } + val posX = (message.arguments.getOrNull(1) as? Float) ?: return + val posY = (message.arguments.getOrNull(2) as? Float) ?: return + val posZ = (message.arguments.getOrNull(3) as? Float) ?: return + val rotX = (message.arguments.getOrNull(4) as? Float) ?: return + val rotY = (message.arguments.getOrNull(5) as? Float) ?: return + val rotZ = (message.arguments.getOrNull(6) as? Float) ?: return + val rotW = (message.arguments.getOrNull(7) as? Float) ?: return + stateHolder.write { + setRootTransform(posX, posY, posZ, rotX, rotY, rotZ, rotW) + } + } + + "/VMC/Ext/Bone/Pos" -> { + val name = (message.arguments.getOrNull(0) as? String) ?: return + val tag = HumanoidTag.fromVrmName(name.replaceFirstChar { it.lowercase() }) ?: return + val posX = (message.arguments.getOrNull(1) as? Float) ?: return + val posY = (message.arguments.getOrNull(2) as? Float) ?: return + val posZ = (message.arguments.getOrNull(3) as? Float) ?: return + val rotX = (message.arguments.getOrNull(4) as? Float) ?: return + val rotY = (message.arguments.getOrNull(5) as? Float) ?: return + val rotZ = (message.arguments.getOrNull(6) as? Float) ?: return + val rotW = (message.arguments.getOrNull(7) as? Float) ?: return + stateHolder.write { + setBoneTransform(tag, posX, posY, posZ, rotX, rotY, rotZ, rotW) + } + } + + "/VMC/Ext/Blend/Val" -> { + val tag = (message.arguments.getOrNull(0) as? String) ?: return + val expressionTag = Expression.Tag.fromVrm0Name(tag) ?: Expression.Tag.fromVrm1Name(tag) ?: return + val value = (message.arguments.getOrNull(1) as? Float) ?: return + pendingBlendValues[expressionTag] = value + } + + "/VMC/Ext/Blend/Apply" -> { + stateHolder.write { + pendingBlendValues.forEach { + setBlendShape(it.key, it.value) + } + } + pendingBlendValues.clear() + } + } + } + + init { + if (transport !is UDPTransport) { + transport.connect() + } + require(transport.isBlocking) { "Require blocking transport" } + scope.launch(Dispatchers.IO) { + try { + while (true) { + val message = try { + transport.receive() + } catch (ex: Exception) { + if (!scope.isActive) { + // throw CancellationException + ensureActive() + } + logger.warn("Failed to receive OSC message", ex) + continue + } + handlePacket(message) + ensureActive() + } + } finally { + if (transport !is UDPTransport) { + transport.disconnect() + } + transport.close() + } + } + } + + override fun close() { + if (closed) { + return + } + closed = true + scope.cancel() + } +} diff --git a/mod/src/client/kotlin/top/fifthlight/armorstand/vmc/VmcMarionetteManager.kt b/mod/src/client/kotlin/top/fifthlight/armorstand/vmc/VmcMarionetteManager.kt new file mode 100644 index 00000000..b9ba2b3d --- /dev/null +++ b/mod/src/client/kotlin/top/fifthlight/armorstand/vmc/VmcMarionetteManager.kt @@ -0,0 +1,61 @@ +package top.fifthlight.armorstand.vmc + +import com.illposed.osc.transport.udp.UDPTransport +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.slf4j.LoggerFactory +import java.net.InetAddress +import java.net.InetSocketAddress + +object VmcMarionetteManager { + private val logger = LoggerFactory.getLogger(VmcMarionetteManager::class.java) + + sealed class State { + data object Stopped : State() + + data class Running(val port: Int) : State() + + data class Failed(val exception: Exception) : State() + } + + private val _state = MutableStateFlow(State.Stopped) + val state = _state.asStateFlow() + + private var client: VmcMarionetteClient? = null + + fun getState() = client?.getState() + + fun start(port: Int) = synchronized(this) { + val state = state.value + if (state is State.Running) { + return@synchronized + } + try { + require(port in 0..65535) { "Invalid UDP port number: $port" } + // We never send message + val transport = UDPTransport( + InetSocketAddress(InetAddress.getByAddress(byteArrayOf(0, 0, 0, 0)), port), + InetSocketAddress(InetAddress.getByAddress(byteArrayOf(0, 0, 0, 0)), 0), + ) + val client = VmcMarionetteClient(transport) + this.client = client + _state.value = State.Running(port) + } catch (ex: Exception) { + logger.warn("Failed to start VMC client", ex) + _state.value = State.Failed(ex) + } + } + + fun stop() = synchronized(this) { + if (state.value !is State.Running) { + return + } + try { + client?.close() + client = null + _state.value = State.Stopped + } catch (ex: Exception) { + _state.value = State.Failed(ex) + } + } +} \ No newline at end of file diff --git a/mod/src/client/kotlin/top/fifthlight/armorstand/vmc/VmcMarionetteState.kt b/mod/src/client/kotlin/top/fifthlight/armorstand/vmc/VmcMarionetteState.kt new file mode 100644 index 00000000..27a8f948 --- /dev/null +++ b/mod/src/client/kotlin/top/fifthlight/armorstand/vmc/VmcMarionetteState.kt @@ -0,0 +1,162 @@ +package top.fifthlight.armorstand.vmc + +import kotlinx.atomicfu.locks.withLock +import org.joml.Quaternionf +import org.joml.Quaternionfc +import org.joml.Vector3f +import org.joml.Vector3fc +import top.fifthlight.blazerod.model.Expression +import top.fifthlight.blazerod.model.HumanoidTag +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.locks.ReentrantLock + +sealed class VmcMarionetteStateView { + abstract val rootTransform: RootTransform? + abstract val boneTransforms: Map + abstract val blendShapes: Map + + abstract class RootTransform { + abstract val position: Vector3fc + abstract val rotation: Quaternionfc + } + + abstract class BoneTransform { + abstract val position: Vector3fc + abstract val rotation: Quaternionfc + } + + abstract fun snapshot(): VmcMarionetteStateView +} + +class ImmutableVmcMarionetteStateView( + rootTransform: VmcMarionetteStateView.RootTransform? = null, + boneTransforms: Map = mapOf(), + blendShapes: Map = mapOf(), +) : VmcMarionetteStateView() { + override val rootTransform = rootTransform?.let { RootTransform(it) } + override val boneTransforms = boneTransforms.mapValues { BoneTransform(it.value) } + override val blendShapes = blendShapes.toMap() + + class RootTransform( + position: Vector3fc, + rotation: Quaternionfc, + ) : VmcMarionetteStateView.RootTransform() { + constructor(transform: VmcMarionetteStateView.RootTransform) : this( + position = transform.position, + rotation = transform.rotation, + ) + + override val position: Vector3fc = Vector3f(position) + override val rotation: Quaternionfc = Quaternionf(rotation) + } + + class BoneTransform( + position: Vector3fc, + rotation: Quaternionfc, + ) : VmcMarionetteStateView.BoneTransform() { + constructor(transform: VmcMarionetteStateView.BoneTransform) : this( + position = transform.position, + rotation = transform.rotation, + ) + + override val position: Vector3fc = Vector3f(position) + override val rotation: Quaternionfc = Quaternionf(rotation) + } + + override fun snapshot() = this +} + +interface VmcMarionetteStateWriter { + fun setRootTransform( + posX: Float, + posY: Float, + posZ: Float, + rotX: Float, + rotY: Float, + rotZ: Float, + rotW: Float, + ) + + fun setBoneTransform( + tag: HumanoidTag, + posX: Float, + posY: Float, + posZ: Float, + rotX: Float, + rotY: Float, + rotZ: Float, + rotW: Float, + ) + + fun setBlendShape(tag: Expression.Tag, weight: Float) +} + +sealed class MutableVmcMarionetteStateView : VmcMarionetteStateView(), VmcMarionetteStateWriter { + abstract override val rootTransform: RootTransform? + abstract override val boneTransforms: MutableMap + abstract override val blendShapes: MutableMap +} + +private class MutableVmcMarionetteStateViewImpl : MutableVmcMarionetteStateView() { + override var rootTransform: RootTransform? = null + override val boneTransforms: MutableMap = mutableMapOf() + override val blendShapes: MutableMap = mutableMapOf() + + class RootTransform : VmcMarionetteStateView.RootTransform() { + override var position = Vector3f() + override var rotation = Quaternionf() + } + + class BoneTransform : VmcMarionetteStateView.BoneTransform() { + override var position = Vector3f() + override var rotation = Quaternionf() + } + + override fun setRootTransform( + posX: Float, + posY: Float, + posZ: Float, + rotX: Float, + rotY: Float, + rotZ: Float, + rotW: Float, + ) { + val rootTransform = rootTransform ?: RootTransform().also { this.rootTransform = it } + rootTransform.position.set(posX, posY, posZ) + rootTransform.rotation.set(rotX, rotY, rotZ, rotW) + } + + override fun setBoneTransform( + tag: HumanoidTag, + posX: Float, + posY: Float, + posZ: Float, + rotX: Float, + rotY: Float, + rotZ: Float, + rotW: Float, + ) { + val boneTransforms = boneTransforms.getOrPut(tag) { BoneTransform() } + boneTransforms.position.set(posX, posY, posZ) + boneTransforms.rotation.set(rotX, rotY, rotZ, rotW) + } + + override fun setBlendShape(tag: Expression.Tag, weight: Float) { + blendShapes[tag] = weight + } + + override fun snapshot() = ImmutableVmcMarionetteStateView(rootTransform, boneTransforms, blendShapes) +} + +class VmcMarionetteStateHolder { + private val writeLock = ReentrantLock() + private val writeBuffer = MutableVmcMarionetteStateViewImpl() + private val readBuffer = AtomicReference(writeBuffer.snapshot()) + + fun read(): VmcMarionetteStateView = readBuffer.get() + + fun write(block: VmcMarionetteStateWriter.() -> Unit) = writeLock.withLock { + block(writeBuffer) + readBuffer.set(writeBuffer.snapshot()) + } +} diff --git a/mod/src/client/resources/assets/armorstand/lang/en_us.json b/mod/src/client/resources/assets/armorstand/lang/en_us.json index 74875602..962fb38b 100644 --- a/mod/src/client/resources/assets/armorstand/lang/en_us.json +++ b/mod/src/client/resources/assets/armorstand/lang/en_us.json @@ -8,6 +8,7 @@ "armorstand.config.invert_head_direction": "Invert head direction", "armorstand.config.open_model_directory": "Open Model Folder", "armorstand.config.renderer_select": "Select renderer", + "armorstand.config.vmc": "OSC/VMC", "armorstand.config.sort.name.asc": "Sort: A-Z", "armorstand.config.sort.name.desc": "Sort: Z-A", "armorstand.config.sort.last_changed.asc": "Sort: First changed", @@ -93,5 +94,12 @@ "armorstand.renderer.speed.fast": "Fast", "armorstand.renderer.shader_compatible": "Shader compatible", "armorstand.renderer.is_available": "Available", - "armorstand.renderer.is_current": "Current" + "armorstand.renderer.is_current": "Current", + "armorstand.vmc": "OSC/VMC", + "armorstand.vmc.running": "Running, port: %s", + "armorstand.vmc.stopped": "Not running", + "armorstand.vmc.failed": "Start failed: %s", + "armorstand.vmc.start": "Start", + "armorstand.vmc.stop": "Stop", + "armorstand.vmc.port": "Port:" } \ No newline at end of file diff --git a/mod/src/client/resources/assets/armorstand/lang/zh_cn.json b/mod/src/client/resources/assets/armorstand/lang/zh_cn.json index b4302196..42681a47 100644 --- a/mod/src/client/resources/assets/armorstand/lang/zh_cn.json +++ b/mod/src/client/resources/assets/armorstand/lang/zh_cn.json @@ -8,6 +8,7 @@ "armorstand.config.invert_head_direction": "反转抬头方向", "armorstand.config.open_model_directory": "打开模型文件夹", "armorstand.config.renderer_select": "选择渲染器", + "armorstand.config.vmc": "OSC/VMC", "armorstand.config.sort.name.asc": "排序:名称顺序", "armorstand.config.sort.name.desc": "排序:名称逆序", "armorstand.config.sort.last_changed.asc": "排序:时间顺序", @@ -93,5 +94,12 @@ "armorstand.renderer.speed.fast": "快", "armorstand.renderer.shader_compatible": "光影兼容", "armorstand.renderer.is_available": "可用性", - "armorstand.renderer.is_current": "当前使用" + "armorstand.renderer.is_current": "当前使用", + "armorstand.vmc": "OSC/VMC", + "armorstand.vmc.running": "运行中,端口:%s", + "armorstand.vmc.stopped": "未运行", + "armorstand.vmc.failed": "启动失败: %s", + "armorstand.vmc.start": "启动", + "armorstand.vmc.stop": "停止", + "armorstand.vmc.port": "端口:" } \ No newline at end of file