From a3287b7db861544e1f0610c873875bdf659096d0 Mon Sep 17 00:00:00 2001 From: NotEvil Date: Fri, 17 Apr 2026 03:11:29 +0200 Subject: [PATCH] fix(animation): variant randomness no longer permanently cached + fix FullHead false-positive Two fixes from the animation audit: 1. Variant cache key now includes the resolved animation name (e.g., Struggle.2). Previously, the cache key only used context+parts, so the first random variant pick was reused forever. Now each variant gets its own cache entry, and a fresh random pick happens each time the context changes. 2. FullHead check changed from contains("Head") to startsWith("gltf_FullHead") to prevent false positives on names like FullOverhead or FullAhead. --- .../client/gltf/GltfAnimationApplier.java | 56 +++++++++++-------- .../remake/client/gltf/GltfPoseConverter.java | 5 +- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfAnimationApplier.java b/src/main/java/com/tiedup/remake/client/gltf/GltfAnimationApplier.java index e83d879..b095ba6 100644 --- a/src/main/java/com/tiedup/remake/client/gltf/GltfAnimationApplier.java +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfAnimationApplier.java @@ -126,11 +126,13 @@ public final class GltfAnimationApplier { String enabledKey = canonicalPartsKey(ownership.enabledParts()); String partsKey = ownedKey + ";" + enabledKey; - // Build composite state key to avoid redundant updates + // Build composite state key to detect context changes. + // NOTE: This key does NOT include the variant name — that is resolved fresh + // each time the context changes, enabling random variant selection. String stateKey = animSource + "|" + context.name() + "|" + partsKey; String currentKey = activeStateKeys.get(entity.getUUID()); if (stateKey.equals(currentKey)) { - return true; // Already active, no-op + return true; // Same context, same parts — no need to re-resolve } // === Layer 1: Context animation (base body posture) === @@ -153,26 +155,36 @@ public final class GltfAnimationApplier { return false; } - KeyframeAnimation itemAnim = itemAnimCache.get(itemCacheKey); + // Resolve animation data first (needed for variant resolution) + GltfData animData = GlbAnimationResolver.resolveAnimationData( + modelLoc, + animationSource + ); + if (animData == null) { + LOGGER.warn( + "[GltfPipeline] Failed to load animation GLB: {}", + animSource + ); + failedLoadKeys.add(itemCacheKey); + activeStateKeys.put(entity.getUUID(), stateKey); + return false; + } + + // Resolve which named animation to use (with fallback chain + variant selection). + // This must happen BEFORE the cache lookup because variant selection is random — + // we want a fresh random pick each time the context changes, not a permanently + // cached first pick. + String glbAnimName = GlbAnimationResolver.resolve( + animData, + context + ); + + // Include the resolved animation name in the cache key so different variants + // (Struggle.1 vs Struggle.2) get separate cache entries. + String variantCacheKey = itemCacheKey + "#" + (glbAnimName != null ? glbAnimName : "default"); + + KeyframeAnimation itemAnim = itemAnimCache.get(variantCacheKey); if (itemAnim == null) { - GltfData animData = GlbAnimationResolver.resolveAnimationData( - modelLoc, - animationSource - ); - if (animData == null) { - LOGGER.warn( - "[GltfPipeline] Failed to load animation GLB: {}", - animSource - ); - failedLoadKeys.add(itemCacheKey); - activeStateKeys.put(entity.getUUID(), stateKey); - return false; - } - // Resolve which named animation to use (with fallback chain + variant selection) - String glbAnimName = GlbAnimationResolver.resolve( - animData, - context - ); // Pass both owned parts and enabled parts (owned + free) for selective enabling itemAnim = GltfPoseConverter.convertSelective( animData, @@ -180,7 +192,7 @@ public final class GltfAnimationApplier { ownership.thisParts(), ownership.enabledParts() ); - itemAnimCache.put(itemCacheKey, itemAnim); + itemAnimCache.put(variantCacheKey, itemAnim); } BondageAnimationManager.playDirect(entity, itemAnim); diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java b/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java index 052b104..0af4e7a 100644 --- a/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java @@ -587,7 +587,10 @@ public final class GltfPoseConverter { // Head is protected by default — only enabled as a free bone when the animation // name contains "Head" (e.g., FullHeadStruggle, FullHeadIdle). // This lets artists opt-in per animation without affecting the item's regions. - boolean allowFreeHead = isFullBodyAnimation && animName.contains("Head"); + // FullHead prefix (e.g., FullHeadStruggle) opts into head as a free bone. + // Use startsWith to avoid false positives (e.g., FullOverhead, FullAhead). + boolean allowFreeHead = isFullBodyAnimation && + animName.startsWith("gltf_FullHead"); String[] allParts = { "head",