Date: Fri, 17 Apr 2026 01:45:02 +0200
Subject: [PATCH 08/19] =?UTF-8?q?perf(gltf):=20add=20skinning=20cache=20?=
=?UTF-8?q?=E2=80=94=20skip=20re-skinning=20when=20pose=20is=20unchanged?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../remake/client/gltf/GltfClientSetup.java | 21 +++
.../remake/client/gltf/GltfMeshRenderer.java | 95 ++++++++++++++
.../remake/client/gltf/GltfSkinCache.java | 121 ++++++++++++++++++
3 files changed, 237 insertions(+)
create mode 100644 src/main/java/com/tiedup/remake/client/gltf/GltfSkinCache.java
diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java b/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java
index 7c2b204..8868887 100644
--- a/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java
+++ b/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java
@@ -96,6 +96,7 @@ public final class GltfClientSetup {
ProfilerFiller profiler
) {
GltfCache.clearCache();
+ GltfSkinCache.clearAll();
GltfAnimationApplier.invalidateCache();
GltfMeshRenderer.clearRenderTypeCache();
// Reload context GLB animations from resource packs FIRST,
@@ -120,4 +121,24 @@ public final class GltfClientSetup {
}
}
+ /**
+ * FORGE bus event subscribers for entity lifecycle cleanup.
+ * Removes skin cache entries when entities leave the level, preventing memory leaks.
+ */
+ @Mod.EventBusSubscriber(
+ modid = "tiedup",
+ bus = Mod.EventBusSubscriber.Bus.FORGE,
+ value = Dist.CLIENT
+ )
+ public static class ForgeBusEvents {
+
+ @SubscribeEvent
+ public static void onEntityLeaveLevel(
+ net.minecraftforge.event.entity.EntityLeaveLevelEvent event
+ ) {
+ if (event.getLevel().isClientSide()) {
+ GltfSkinCache.removeEntity(event.getEntity().getId());
+ }
+ }
+ }
}
diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfMeshRenderer.java b/src/main/java/com/tiedup/remake/client/gltf/GltfMeshRenderer.java
index e7a9c5b..d25e706 100644
--- a/src/main/java/com/tiedup/remake/client/gltf/GltfMeshRenderer.java
+++ b/src/main/java/com/tiedup/remake/client/gltf/GltfMeshRenderer.java
@@ -225,6 +225,101 @@ public final class GltfMeshRenderer extends RenderStateShard {
}
}
+ /**
+ * Two-pass skinned renderer with cache support.
+ *
+ * Pass 1 (skippable): if {@code cachedPositions} is null, skin every
+ * unique vertex into flat {@code float[]} arrays (positions and normals).
+ * If cached arrays are provided, Pass 1 is skipped entirely.
+ *
+ * Pass 2 (always): iterate the index buffer, read skinned data from
+ * the arrays, and emit to the {@link VertexConsumer}.
+ *
+ * @param data parsed glTF data
+ * @param jointMatrices computed joint matrices from skinning engine
+ * @param poseStack current pose stack
+ * @param buffer multi-buffer source
+ * @param packedLight packed light value
+ * @param packedOverlay packed overlay value
+ * @param renderType the RenderType to use for rendering
+ * @param cachedPositions previously cached skinned positions, or null to compute fresh
+ * @param cachedNormals previously cached skinned normals, or null to compute fresh
+ * @return {@code new float[][] { positions, normals }} for the caller to cache
+ */
+ public static float[][] renderSkinnedWithCache(
+ GltfData data,
+ Matrix4f[] jointMatrices,
+ PoseStack poseStack,
+ MultiBufferSource buffer,
+ int packedLight,
+ int packedOverlay,
+ RenderType renderType,
+ float[] cachedPositions,
+ float[] cachedNormals
+ ) {
+ int vertexCount = data.vertexCount();
+ float[] positions;
+ float[] normals;
+
+ // -- Pass 1: Skin all unique vertices (skipped when cache hit) --
+ if (cachedPositions != null && cachedNormals != null) {
+ positions = cachedPositions;
+ normals = cachedNormals;
+ } else {
+ positions = new float[vertexCount * 3];
+ normals = new float[vertexCount * 3];
+
+ float[] outPos = new float[3];
+ float[] outNormal = new float[3];
+ Vector4f tmpPos = new Vector4f();
+ Vector4f tmpNorm = new Vector4f();
+
+ for (int v = 0; v < vertexCount; v++) {
+ GltfSkinningEngine.skinVertex(
+ data, v, jointMatrices, outPos, outNormal, tmpPos, tmpNorm
+ );
+ positions[v * 3] = outPos[0];
+ positions[v * 3 + 1] = outPos[1];
+ positions[v * 3 + 2] = outPos[2];
+ normals[v * 3] = outNormal[0];
+ normals[v * 3 + 1] = outNormal[1];
+ normals[v * 3 + 2] = outNormal[2];
+ }
+ }
+
+ // -- Pass 2: Emit vertices from arrays to VertexConsumer --
+ Matrix4f pose = poseStack.last().pose();
+ Matrix3f normalMat = poseStack.last().normal();
+ VertexConsumer vc = buffer.getBuffer(renderType);
+
+ int[] indices = data.indices();
+ float[] texCoords = data.texCoords();
+
+ for (int idx : indices) {
+ float px = positions[idx * 3];
+ float py = positions[idx * 3 + 1];
+ float pz = positions[idx * 3 + 2];
+
+ float nx = normals[idx * 3];
+ float ny = normals[idx * 3 + 1];
+ float nz = normals[idx * 3 + 2];
+
+ float u = texCoords[idx * 2];
+ float v = texCoords[idx * 2 + 1];
+
+ vc
+ .vertex(pose, px, py, pz)
+ .color(255, 255, 255, 255)
+ .uv(u, 1.0f - v)
+ .overlayCoords(packedOverlay)
+ .uv2(packedLight)
+ .normal(normalMat, nx, ny, nz)
+ .endVertex();
+ }
+
+ return new float[][] { positions, normals };
+ }
+
/**
* Render a skinned glTF mesh with per-primitive tint colors.
*
diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfSkinCache.java b/src/main/java/com/tiedup/remake/client/gltf/GltfSkinCache.java
new file mode 100644
index 0000000..5360600
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/client/gltf/GltfSkinCache.java
@@ -0,0 +1,121 @@
+package com.tiedup.remake.client.gltf;
+
+import java.util.HashMap;
+import java.util.Map;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+
+/**
+ * Skinning result cache that avoids re-skinning when a player's pose hasn't changed.
+ *
+ * Uses a dirty-flag approach: each cache entry stores the raw int bits of
+ * every float input (joint euler angles) that drove the last skinning pass.
+ * On the next frame, if all bits match exactly, the cached skinned positions
+ * and normals are reused (skipping the expensive LBS loop).
+ *
+ *
Bit comparison via {@link Float#floatToRawIntBits(float)} avoids epsilon
+ * drift: a pose is "unchanged" only when the inputs are identical down to the
+ * bit (idle, AFK, paused animation frame).
+ */
+@OnlyIn(Dist.CLIENT)
+public final class GltfSkinCache {
+
+ private record CacheKey(int entityId, ResourceLocation modelLoc) {}
+
+ private static final class Entry {
+ int[] lastInputBits;
+ float[] skinnedPositions;
+ float[] skinnedNormals;
+ }
+
+ private static final Map cache = new HashMap<>();
+
+ private GltfSkinCache() {}
+
+ /**
+ * Check whether the pose inputs are bit-identical to the last cached skinning pass.
+ *
+ * @param entityId the entity's numeric ID
+ * @param modelLoc the model ResourceLocation (distinguishes multiple items on one entity)
+ * @param currentInputs flat array of float inputs that drove joint matrix computation
+ * @return true if every input bit matches the cached entry (safe to reuse cached data)
+ */
+ public static boolean isPoseUnchanged(
+ int entityId,
+ ResourceLocation modelLoc,
+ float[] currentInputs
+ ) {
+ CacheKey key = new CacheKey(entityId, modelLoc);
+ Entry entry = cache.get(key);
+ if (entry == null || entry.lastInputBits == null) return false;
+ if (entry.lastInputBits.length != currentInputs.length) return false;
+ for (int i = 0; i < currentInputs.length; i++) {
+ if (
+ entry.lastInputBits[i]
+ != Float.floatToRawIntBits(currentInputs[i])
+ ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Store a skinning result in the cache, replacing any previous entry for the same key.
+ *
+ * @param entityId the entity's numeric ID
+ * @param modelLoc the model ResourceLocation
+ * @param poseInputs the float inputs that produced these results (will be bit-snapshotted)
+ * @param positions skinned vertex positions (will be cloned)
+ * @param normals skinned vertex normals (will be cloned)
+ */
+ public static void store(
+ int entityId,
+ ResourceLocation modelLoc,
+ float[] poseInputs,
+ float[] positions,
+ float[] normals
+ ) {
+ CacheKey key = new CacheKey(entityId, modelLoc);
+ Entry entry = cache.computeIfAbsent(key, k -> new Entry());
+ entry.lastInputBits = new int[poseInputs.length];
+ for (int i = 0; i < poseInputs.length; i++) {
+ entry.lastInputBits[i] = Float.floatToRawIntBits(poseInputs[i]);
+ }
+ entry.skinnedPositions = positions.clone();
+ entry.skinnedNormals = normals.clone();
+ }
+
+ /**
+ * Retrieve cached skinned positions, or null if no cache entry exists.
+ */
+ public static float[] getCachedPositions(
+ int entityId,
+ ResourceLocation modelLoc
+ ) {
+ Entry entry = cache.get(new CacheKey(entityId, modelLoc));
+ return entry != null ? entry.skinnedPositions : null;
+ }
+
+ /**
+ * Retrieve cached skinned normals, or null if no cache entry exists.
+ */
+ public static float[] getCachedNormals(
+ int entityId,
+ ResourceLocation modelLoc
+ ) {
+ Entry entry = cache.get(new CacheKey(entityId, modelLoc));
+ return entry != null ? entry.skinnedNormals : null;
+ }
+
+ /** Clear the entire cache (called on resource reload). */
+ public static void clearAll() {
+ cache.clear();
+ }
+
+ /** Remove all cache entries for a specific entity (called on entity leave). */
+ public static void removeEntity(int entityId) {
+ cache.entrySet().removeIf(e -> e.getKey().entityId == entityId);
+ }
+}
From ca4cbcad12f4967d6d568409813468b577598bd5 Mon Sep 17 00:00:00 2001
From: NotEvil
Date: Fri, 17 Apr 2026 01:47:27 +0200
Subject: [PATCH 09/19] =?UTF-8?q?feat(validation):=20add=20GlbValidationRe?=
=?UTF-8?q?loadListener=20=E2=80=94=20validates=20GLBs=20on=20resource=20r?=
=?UTF-8?q?eload?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../remake/client/gltf/GltfClientSetup.java | 4 +
.../GlbValidationReloadListener.java | 138 ++++++++++++++++++
2 files changed, 142 insertions(+)
create mode 100644 src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidationReloadListener.java
diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java b/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java
index 8868887..4fa21f6 100644
--- a/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java
+++ b/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java
@@ -118,6 +118,10 @@ public final class GltfClientSetup {
LOGGER.info(
"[GltfPipeline] Data-driven item reload listener registered"
);
+
+ // GLB structural validation (runs after item definitions are loaded)
+ event.registerReloadListener(new com.tiedup.remake.client.gltf.diagnostic.GlbValidationReloadListener());
+ LOGGER.info("[GltfPipeline] GLB validation reload listener registered");
}
}
diff --git a/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidationReloadListener.java b/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidationReloadListener.java
new file mode 100644
index 0000000..bf724b4
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidationReloadListener.java
@@ -0,0 +1,138 @@
+package com.tiedup.remake.client.gltf.diagnostic;
+
+import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
+import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
+import java.io.InputStream;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.packs.resources.Resource;
+import net.minecraft.server.packs.resources.ResourceManager;
+import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
+import net.minecraft.util.profiling.ProfilerFiller;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+/**
+ * Reload listener that validates all data-driven item GLB models on resource
+ * reload (F3+T or startup).
+ *
+ * Must be registered AFTER {@link com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemReloadListener}
+ * so that the item registry is populated before validation runs.
+ *
+ * On each reload:
+ *
+ * - Clears the {@link GlbDiagnosticRegistry}
+ * - Iterates all {@link DataDrivenItemDefinition}s
+ * - For each definition with a model location, attempts to open the GLB
+ * resource and runs {@link GlbValidator#validate} on it
+ * - Records results into the diagnostic registry and logs a summary
+ *
+ */
+@OnlyIn(Dist.CLIENT)
+public class GlbValidationReloadListener
+ extends SimplePreparableReloadListener
+{
+
+ private static final Logger LOGGER = LogManager.getLogger("GltfValidation");
+
+ @Override
+ protected Void prepare(
+ ResourceManager resourceManager,
+ ProfilerFiller profiler
+ ) {
+ return null;
+ }
+
+ @Override
+ protected void apply(
+ Void nothing,
+ ResourceManager resourceManager,
+ ProfilerFiller profiler
+ ) {
+ GlbDiagnosticRegistry.clear();
+
+ Collection definitions =
+ DataDrivenItemRegistry.getAll();
+
+ if (definitions.isEmpty()) {
+ LOGGER.warn(
+ "[GltfValidation] No data-driven item definitions found — skipping GLB validation"
+ );
+ return;
+ }
+
+ int passed = 0;
+ int withWarnings = 0;
+ int withErrors = 0;
+
+ for (DataDrivenItemDefinition def : definitions) {
+ ResourceLocation modelLoc = def.modelLocation();
+ if (modelLoc == null) {
+ continue;
+ }
+
+ Optional resourceOpt =
+ resourceManager.getResource(modelLoc);
+
+ if (resourceOpt.isEmpty()) {
+ // GLB file not found in any resource pack
+ GlbValidationResult missingResult = GlbValidationResult.of(
+ modelLoc,
+ List.of(new GlbDiagnostic(
+ modelLoc,
+ def.id(),
+ GlbDiagnostic.Severity.ERROR,
+ "MISSING_GLB",
+ "GLB file not found: " + modelLoc
+ + " (referenced by item " + def.id() + ")"
+ ))
+ );
+ GlbDiagnosticRegistry.addResult(missingResult);
+ withErrors++;
+ continue;
+ }
+
+ try (InputStream stream = resourceOpt.get().open()) {
+ GlbValidationResult result =
+ GlbValidator.validate(stream, modelLoc);
+ GlbDiagnosticRegistry.addResult(result);
+
+ if (!result.passed()) {
+ withErrors++;
+ } else if (hasWarnings(result)) {
+ withWarnings++;
+ } else {
+ passed++;
+ }
+ } catch (Exception e) {
+ GlbValidationResult errorResult = GlbValidationResult.of(
+ modelLoc,
+ List.of(new GlbDiagnostic(
+ modelLoc,
+ def.id(),
+ GlbDiagnostic.Severity.ERROR,
+ "GLB_READ_ERROR",
+ "Failed to read GLB file: " + e.getMessage()
+ ))
+ );
+ GlbDiagnosticRegistry.addResult(errorResult);
+ withErrors++;
+ }
+ }
+
+ int total = passed + withWarnings + withErrors;
+ LOGGER.info(
+ "[GltfValidation] Validated {} GLBs: {} passed, {} with warnings, {} with errors",
+ total, passed, withWarnings, withErrors
+ );
+ }
+
+ private static boolean hasWarnings(GlbValidationResult result) {
+ return result.diagnostics().stream()
+ .anyMatch(d -> d.severity() == GlbDiagnostic.Severity.WARNING);
+ }
+}
From 3f6e04edb0e9197ceeeeb6cd188b6bb9ab2e0ff0 Mon Sep 17 00:00:00 2001
From: NotEvil
Date: Fri, 17 Apr 2026 01:51:58 +0200
Subject: [PATCH 10/19] feat(validation): add /tiedup validate client command
for GLB diagnostics
---
.../remake/client/gltf/GltfClientSetup.java | 12 ++
.../remake/commands/ValidateGlbCommand.java | 133 ++++++++++++++++++
2 files changed, 145 insertions(+)
create mode 100644 src/main/java/com/tiedup/remake/commands/ValidateGlbCommand.java
diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java b/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java
index 4fa21f6..c8625aa 100644
--- a/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java
+++ b/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java
@@ -144,5 +144,17 @@ public final class GltfClientSetup {
GltfSkinCache.removeEntity(event.getEntity().getId());
}
}
+
+ @SubscribeEvent
+ public static void onRegisterClientCommands(
+ net.minecraftforge.client.event.RegisterClientCommandsEvent event
+ ) {
+ com.tiedup.remake.commands.ValidateGlbCommand.register(
+ event.getDispatcher()
+ );
+ LOGGER.info(
+ "[GltfPipeline] Client command /tiedup validate registered"
+ );
+ }
}
}
diff --git a/src/main/java/com/tiedup/remake/commands/ValidateGlbCommand.java b/src/main/java/com/tiedup/remake/commands/ValidateGlbCommand.java
new file mode 100644
index 0000000..66d9be7
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/commands/ValidateGlbCommand.java
@@ -0,0 +1,133 @@
+package com.tiedup.remake.commands;
+
+import com.mojang.brigadier.CommandDispatcher;
+import com.mojang.brigadier.arguments.StringArgumentType;
+import com.tiedup.remake.client.gltf.diagnostic.GlbDiagnostic;
+import com.tiedup.remake.client.gltf.diagnostic.GlbDiagnosticRegistry;
+import com.tiedup.remake.client.gltf.diagnostic.GlbValidationResult;
+import net.minecraft.ChatFormatting;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.commands.Commands;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+
+/**
+ * Client-only command: /tiedup validate [item_id]
+ *
+ * Displays GLB validation diagnostics in chat.
+ * Registered via {@link net.minecraftforge.client.event.RegisterClientCommandsEvent}
+ * on the FORGE bus (never on the server).
+ */
+@OnlyIn(Dist.CLIENT)
+public final class ValidateGlbCommand {
+
+ private ValidateGlbCommand() {}
+
+ public static void register(CommandDispatcher dispatcher) {
+ dispatcher.register(
+ Commands.literal("tiedup")
+ .then(Commands.literal("validate")
+ .executes(ctx -> validateAll(ctx.getSource()))
+ .then(Commands.argument("item_id", StringArgumentType.string())
+ .executes(ctx -> validateOne(
+ ctx.getSource(),
+ StringArgumentType.getString(ctx, "item_id")
+ ))
+ )
+ )
+ );
+ }
+
+ private static int validateAll(CommandSourceStack source) {
+ var all = GlbDiagnosticRegistry.getAll();
+ if (all.isEmpty()) {
+ source.sendSuccess(
+ () -> Component.literal(
+ "[TiedUp] No GLB validation results. "
+ + "Try reloading resources (F3+T)."
+ ).withStyle(ChatFormatting.YELLOW),
+ false
+ );
+ return 0;
+ }
+
+ int totalDiags = 0;
+ for (GlbValidationResult result : all) {
+ if (result.diagnostics().isEmpty()) continue;
+ source.sendSuccess(
+ () -> Component.literal("--- " + result.source() + " ---")
+ .withStyle(
+ result.passed()
+ ? ChatFormatting.GREEN
+ : ChatFormatting.RED
+ ),
+ false
+ );
+ for (GlbDiagnostic d : result.diagnostics()) {
+ source.sendSuccess(() -> formatDiagnostic(d), false);
+ totalDiags++;
+ }
+ }
+
+ int count = totalDiags;
+ source.sendSuccess(
+ () -> Component.literal(
+ "[TiedUp] " + count + " diagnostic(s) across "
+ + GlbDiagnosticRegistry.size() + " GLBs"
+ ).withStyle(ChatFormatting.GRAY),
+ false
+ );
+ return 1;
+ }
+
+ private static int validateOne(CommandSourceStack source, String itemId) {
+ ResourceLocation loc = ResourceLocation.tryParse(itemId);
+ if (loc == null) {
+ source.sendFailure(
+ Component.literal("Invalid resource location: " + itemId)
+ );
+ return 0;
+ }
+
+ for (GlbValidationResult result : GlbDiagnosticRegistry.getAll()) {
+ boolean match = result.source().equals(loc);
+ if (!match) {
+ match = result.diagnostics().stream()
+ .anyMatch(d -> loc.equals(d.itemDef()));
+ }
+ if (match) {
+ source.sendSuccess(
+ () -> Component.literal("--- " + result.source() + " ---")
+ .withStyle(
+ result.passed()
+ ? ChatFormatting.GREEN
+ : ChatFormatting.RED
+ ),
+ false
+ );
+ for (GlbDiagnostic d : result.diagnostics()) {
+ source.sendSuccess(() -> formatDiagnostic(d), false);
+ }
+ return 1;
+ }
+ }
+
+ source.sendFailure(
+ Component.literal("No validation results for: " + itemId)
+ );
+ return 0;
+ }
+
+ private static Component formatDiagnostic(GlbDiagnostic d) {
+ ChatFormatting color = switch (d.severity()) {
+ case ERROR -> ChatFormatting.RED;
+ case WARNING -> ChatFormatting.YELLOW;
+ case INFO -> ChatFormatting.GRAY;
+ };
+ return Component.literal(
+ " [" + d.severity() + "] " + d.code() + ": " + d.message()
+ ).withStyle(color);
+ }
+}
From 9dfd2d172446d8fb1805313d1687a0dbf53c76ac Mon Sep 17 00:00:00 2001
From: NotEvil
Date: Fri, 17 Apr 2026 02:12:47 +0200
Subject: [PATCH 11/19] fix(validation): reduce log spam + add OOM guard +
check correct mesh for WEIGHTS_0
- DataDrivenItemParser: downgrade animation_bones absence log from INFO to
DEBUG (was spamming for every item without the optional field)
- GlbValidator: read 12-byte GLB header first and reject files declaring
totalLength > 50 MB before allocating (prevents OOM on malformed GLBs)
- GlbValidator: WEIGHTS_0 check now uses the same mesh selection logic as
GlbParser (prefer "Item", fallback to last non-Player) instead of
blindly checking the first mesh
---
docs/ARTIST_GUIDE.md | 25 +++--
.../client/gltf/diagnostic/GlbValidator.java | 97 ++++++++++++++-----
.../datadriven/DataDrivenItemParser.java | 2 +-
3 files changed, 88 insertions(+), 36 deletions(-)
diff --git a/docs/ARTIST_GUIDE.md b/docs/ARTIST_GUIDE.md
index aa1eb79..cb6f14b 100644
--- a/docs/ARTIST_GUIDE.md
+++ b/docs/ARTIST_GUIDE.md
@@ -183,7 +183,8 @@ Weight paint your mesh to the skeleton bones it should follow.
### Rules
-- **Only paint to the 11 standard bones.** Any other bones in your Blender file will be ignored by the mod.
+- **Paint to the 11 standard bones** for parts that should follow the player's body.
+- **You can also use custom bones** for decorative elements like chains, ribbons, pendants, or twist bones. Custom bones follow their parent in the bone hierarchy (rest pose) — they won't animate independently, but they move with the body part they're attached to. This is useful for better weight painting and mesh deformation.
- **Paint to the bones of your regions.** Handcuffs (ARMS region) should be weighted to `rightUpperArm`, `rightLowerArm`, `leftUpperArm`, `leftLowerArm`.
- **You can paint to bones outside your regions** for smooth deformation. For example, handcuffs might have small weights on `body` near the shoulder area for smoother bending. This is fine — the weight painting is about mesh deformation, not animation control.
- **Normalize your weights.** Each vertex's total weights across all bones must sum to 1.0. Blender does this by default.
@@ -193,6 +194,7 @@ Weight paint your mesh to the skeleton bones it should follow.
- For rigid items (metal cuffs), use hard weights — each vertex fully assigned to one bone.
- For flexible items (rope, leather), blend weights between adjacent bones for smooth bending.
- The chain between handcuffs? Weight it 50/50 to both arms, or use a separate mesh element weighted to `body`.
+- Custom bones are great for chains, dangling locks, or decorative straps — add a bone parented to a standard bone, weight your mesh to it, and it'll follow the parent's movement automatically.
---
@@ -630,7 +632,8 @@ In your JSON definition, separate the mesh from the animations:
- [ ] Actions are named `PlayerArmature|Idle`, `PlayerArmature|Struggle`, etc.
- [ ] Mesh is weight-painted to skeleton bones only
- [ ] Weights are normalized
-- [ ] No orphan bones (extra bones not in the standard 11 are ignored but add file size)
+- [ ] Custom bones (if any) are parented to a standard bone in the hierarchy
+- [ ] Your item mesh is named `Item` in Blender (recommended — ensures the mod picks the correct mesh if your file has multiple objects)
- [ ] Materials/textures are applied (the GLB bakes them in)
- [ ] Scale is correct (1 Blender unit = 1 Minecraft block = 16 pixels)
@@ -763,14 +766,16 @@ The `movement_style` changes how the player physically moves — slower speed, d
| `movement_style` | string | No | Movement restriction: `"waddle"`, `"shuffle"`, `"hop"`, or `"crawl"` |
| `movement_modifier` | object | No | Override speed/jump for the movement style (requires `movement_style`) |
| `creator` | string | No | Author/creator name, shown in the item tooltip |
-| `animation_bones` | object | Yes | Per-animation bone whitelist (see below) |
+| `animation_bones` | object | No | Per-animation bone whitelist (see below). If omitted, all owned bones are enabled for all animations. |
| `components` | object | No | Gameplay behavior components (see [Components](#components-gameplay-behaviors) below) |
-### animation_bones (required)
+### animation_bones (optional)
-Declares which bones each named animation is allowed to control for this item. This enables fine-grained per-animation bone filtering: an item might own `body` via its regions but only want the "idle" animation to affect the arms.
+Fine-grained control over which bones each animation is allowed to affect. Most items don't need this — if omitted, all owned bones (from your `regions`) are enabled for all animations automatically.
-**Format:** A JSON object where each key is an animation name (matching the GLB animation names) and each value is an array of bone names.
+**When to use it:** When your item owns bones via its regions but you only want specific animations to affect specific bones. For example, an item owning ARMS + TORSO might only want the "idle" pose to affect the arms, and only the "struggle" animation to also move the body.
+
+**Format:** A JSON object where each key is an animation name and each value is an array of bone names.
**Valid bone names:** `head`, `body`, `rightArm`, `leftArm`, `rightLeg`, `leftLeg`
@@ -784,7 +789,7 @@ Declares which bones each named animation is allowed to control for this item. T
At runtime, the effective bones for a given animation clip are computed as the **intersection** of `animation_bones[clipName]` and the item's owned parts (from region conflict resolution). If the clip name is not listed in `animation_bones`, the item falls back to using all its owned parts.
-This field is **required**. Items without `animation_bones` will be rejected by the parser.
+**If omitted entirely:** All owned bones are enabled for all animations. This is the correct default for most items — you only need `animation_bones` for advanced per-animation filtering.
### Components (Gameplay Behaviors)
@@ -1020,8 +1025,8 @@ Use your own namespace (e.g., `mycreator`) to avoid conflicts with the base mod
| Mistake | Symptom | Fix |
|---------|---------|-----|
-| Bone name typo (`RightUpperArm` instead of `rightUpperArm`) | Mesh doesn't follow that bone | Names are **camelCase**, not PascalCase. Check exact spelling. |
-| Extra bones in the armature | No visible issue (ignored), larger file | Delete non-standard bones before export |
+| Bone name typo (`RightUpperArm` instead of `rightUpperArm`) | Warning in log: "did you mean 'rightUpperArm'?" — bone treated as custom | Names are **camelCase**, not PascalCase. Check exact spelling. Run `/tiedup validate` to see warnings. |
+| Extra bones in the armature | Custom bones follow their parent in rest pose | Intentional custom bones are fine (chains, decorations). Unintentional ones add file size — delete them. |
| Missing `PlayerArmature` root | Mesh renders at wrong position | Rename your armature root to `PlayerArmature` |
| Animating `body` bone without TORSO region | Body keyframes used only if `body` is free (no other item owns it) | Declare TORSO/WAIST region if you always want to control body, or use `Full` animations for free-bone effects |
@@ -1041,7 +1046,7 @@ Use your own namespace (e.g., `mycreator`) to avoid conflicts with the base mod
|---------|---------|-----|
| Vertices not weighted to any bone | Part of mesh stays frozen in space | Weight paint everything to at least one bone |
| Weights not normalized | Mesh stretches or compresses oddly | Blender > Weights > Normalize All |
-| Weighted to a non-standard bone | That part of mesh stays frozen | Only weight to the 11 standard bones |
+| Weighted to a non-standard bone | Mesh follows parent bone in rest pose | This is OK if intentional (custom bones). If not, re-weight to a standard bone. |
### JSON Issues
diff --git a/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidator.java b/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidator.java
index 1df9c45..6b649aa 100644
--- a/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidator.java
+++ b/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidator.java
@@ -92,6 +92,9 @@ public final class GlbValidator {
// Header + JSON chunk extraction //
// ------------------------------------------------------------------ //
+ /** Maximum GLB file size the validator will accept (50 MB). */
+ private static final long MAX_GLB_SIZE = 50L * 1024 * 1024;
+
/**
* Parse the GLB header and extract the JSON chunk root object.
* Returns null and adds ERROR diagnostics on failure.
@@ -101,20 +104,21 @@ public final class GlbValidator {
ResourceLocation source,
List diagnostics
) throws Exception {
- byte[] allBytes = input.readAllBytes();
-
- if (allBytes.length < 12) {
+ // Read the 12-byte header first to check totalLength before
+ // committing to readAllBytes (OOM guard for malformed GLBs).
+ byte[] header = input.readNBytes(12);
+ if (header.length < 12) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
- "File too small to contain a GLB header (" + allBytes.length + " bytes)"
+ "File too small to contain a GLB header (" + header.length + " bytes)"
));
return null;
}
- ByteBuffer buf = ByteBuffer.wrap(allBytes).order(ByteOrder.LITTLE_ENDIAN);
+ ByteBuffer headerBuf = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN);
// -- Header (12 bytes) --
- int magic = buf.getInt();
+ int magic = headerBuf.getInt();
if (magic != GLB_MAGIC) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
@@ -123,7 +127,7 @@ public final class GlbValidator {
return null;
}
- int version = buf.getInt();
+ int version = headerBuf.getInt();
if (version != GLB_VERSION) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
@@ -132,8 +136,31 @@ public final class GlbValidator {
return null;
}
- @SuppressWarnings("unused")
- int totalLength = buf.getInt();
+ int totalLength = headerBuf.getInt();
+
+ // OOM guard: reject files that declare a size exceeding the cap.
+ // totalLength is a signed int, so treat negative values as > 2 GB.
+ if (totalLength < 0 || Integer.toUnsignedLong(totalLength) > MAX_GLB_SIZE) {
+ diagnostics.add(new GlbDiagnostic(
+ source, null, Severity.ERROR, "GLB_TOO_LARGE",
+ "GLB declares totalLength=" + Integer.toUnsignedLong(totalLength)
+ + " bytes which exceeds the " + (MAX_GLB_SIZE / (1024 * 1024))
+ + " MB safety cap — aborting validation"
+ ));
+ return null;
+ }
+
+ // Now read the remainder (totalLength includes the 12-byte header)
+ int remaining = totalLength - 12;
+ if (remaining < 0) {
+ diagnostics.add(new GlbDiagnostic(
+ source, null, Severity.ERROR, "INVALID_GLB_HEADER",
+ "GLB totalLength (" + totalLength + ") is smaller than the header itself"
+ ));
+ return null;
+ }
+ byte[] restBytes = input.readNBytes(remaining);
+ ByteBuffer buf = ByteBuffer.wrap(restBytes).order(ByteOrder.LITTLE_ENDIAN);
// -- First chunk must be JSON --
if (buf.remaining() < 8) {
@@ -285,22 +312,42 @@ public final class GlbValidator {
));
}
- // Check WEIGHTS_0 on the first mesh's first primitive
- if (meshes.size() > 0) {
- JsonObject firstMesh = meshes.get(0).getAsJsonObject();
- if (firstMesh.has("primitives")) {
- JsonArray primitives = firstMesh.getAsJsonArray("primitives");
- if (primitives.size() > 0) {
- JsonObject firstPrimitive = primitives.get(0).getAsJsonObject();
- if (firstPrimitive.has("attributes")) {
- JsonObject attributes = firstPrimitive.getAsJsonObject("attributes");
- if (!attributes.has("WEIGHTS_0")) {
- diagnostics.add(new GlbDiagnostic(
- source, null, Severity.WARNING, "NO_WEIGHTS",
- "First mesh's first primitive has no WEIGHTS_0 attribute "
- + "— skinning will not work correctly"
- ));
- }
+ // Check WEIGHTS_0 on the mesh that GlbParser would actually select:
+ // 1) mesh named "Item", 2) last non-Player mesh.
+ JsonObject targetMesh = null;
+ String targetMeshName = null;
+ for (int mi = 0; mi < meshes.size(); mi++) {
+ JsonObject mesh = meshes.get(mi).getAsJsonObject();
+ String meshName = mesh.has("name")
+ ? mesh.get("name").getAsString()
+ : "";
+ if ("Item".equals(meshName)) {
+ targetMesh = mesh;
+ targetMeshName = meshName;
+ break; // Convention match — same as GlbParser
+ }
+ if (!"Player".equals(meshName)) {
+ targetMesh = mesh;
+ targetMeshName = meshName;
+ }
+ }
+
+ if (targetMesh != null && targetMesh.has("primitives")) {
+ JsonArray primitives = targetMesh.getAsJsonArray("primitives");
+ if (primitives.size() > 0) {
+ JsonObject firstPrimitive = primitives.get(0).getAsJsonObject();
+ if (firstPrimitive.has("attributes")) {
+ JsonObject attributes = firstPrimitive.getAsJsonObject("attributes");
+ if (!attributes.has("WEIGHTS_0")) {
+ String meshLabel = targetMeshName != null
+ ? "'" + targetMeshName + "'"
+ : "(unnamed)";
+ diagnostics.add(new GlbDiagnostic(
+ source, null, Severity.WARNING, "NO_WEIGHTS",
+ "Selected mesh " + meshLabel
+ + " first primitive has no WEIGHTS_0 attribute "
+ + "— skinning will not work correctly"
+ ));
}
}
}
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java
index a8b9a03..2415cac 100644
--- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java
+++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java
@@ -267,7 +267,7 @@ public final class DataDrivenItemParser {
if (!root.has("animation_bones")) {
// Not an error — absent means "permissive" (all owned bones, all animations)
animationBones = null;
- LOGGER.info(
+ LOGGER.debug(
"[DataDrivenItems] {}: animation_bones not declared — all owned bones enabled for all animations",
fileId
);
From b0766fecc6fb735728d666775ac1bc1ba3c352e3 Mon Sep 17 00:00:00 2001
From: NotEvil
Date: Fri, 17 Apr 2026 02:20:08 +0200
Subject: [PATCH 12/19] feat(validation): show toast notification when GLB
errors are detected on reload
---
docs/ARTIST_GUIDE.md | 28 ++++++++++++++++++-
.../GlbValidationReloadListener.java | 18 ++++++++++++
2 files changed, 45 insertions(+), 1 deletion(-)
diff --git a/docs/ARTIST_GUIDE.md b/docs/ARTIST_GUIDE.md
index cb6f14b..602abcb 100644
--- a/docs/ARTIST_GUIDE.md
+++ b/docs/ARTIST_GUIDE.md
@@ -1019,6 +1019,28 @@ Use your own namespace (e.g., `mycreator`) to avoid conflicts with the base mod
---
+## Validation & Debugging
+
+The mod includes built-in tools to help you catch issues with your GLB files.
+
+### `/tiedup validate` Command
+
+Run `/tiedup validate` in-game (client-side command) to see a diagnostic report for all loaded GLBs:
+
+- **RED** errors — item won't work (missing GLB, invalid file, no skin)
+- **YELLOW** warnings — item works but something looks wrong (bone typo, multiple meshes, no Idle animation)
+- **GRAY** info — informational (custom bones detected, vertex count)
+
+Filter by item: `/tiedup validate tiedup:leather_armbinder`
+
+The validation runs automatically on every resource reload (F3+T). Check your game log for a summary line: `[GltfValidation] Validated N GLBs: X passed, Y with warnings, Z with errors`.
+
+### Mesh Naming Convention
+
+If your GLB contains multiple meshes, name your item mesh `Item` in Blender. The mod prioritizes a mesh named `Item` over other meshes. If no `Item` mesh is found, the last non-`Player` mesh is used (backward compatible, but may pick the wrong one in multi-mesh files).
+
+---
+
## Common Mistakes
### Skeleton Issues
@@ -1034,7 +1056,7 @@ Use your own namespace (e.g., `mycreator`) to avoid conflicts with the base mod
| Mistake | Symptom | Fix |
|---------|---------|-----|
-| Action not prefixed with `PlayerArmature\|` | Animation not found, falls back to first clip | Rename: `Idle` → `PlayerArmature\|Idle` |
+| Action not prefixed with `PlayerArmature\|` | Animation not found, falls back to first clip | Rename: `Idle` → `PlayerArmature\|Idle`. Note: the mod strips any `ArmatureName\|` prefix, so custom armature names also work. |
| Wrong case (`idle` instead of `Idle`) | Animation not found | Use exact PascalCase: `Idle`, `SitIdle`, `KneelStruggle` |
| Variant gap (`.1`, `.2`, `.4` — missing `.3`) | Only .1 and .2 are used | Number sequentially with no gaps |
| Animating bones outside your regions | Keyframes silently ignored | Only animate bones in your declared regions |
@@ -1553,11 +1575,15 @@ NEVER DO:
GOOD TO KNOW:
→ Only Idle is required. Everything else has fallbacks.
+ → animation_bones is optional. Omit it and all owned bones work for all animations.
→ Templates let you skip animation entirely.
+ → Custom bones are supported — add chain/ribbon/twist bones parented to standard bones.
→ Free bones (not owned by any item) CAN be animated by your GLB.
→ Bones owned by another equipped item are always ignored.
→ The mod handles sitting, sneaking, walking — you don't have to.
→ Context GLBs in tiedup_contexts/ replace default postures.
+ → Name your item mesh "Item" in Blender for explicit selection in multi-mesh files.
+ → Run /tiedup validate in-game to check your GLBs for issues.
→ Slim model is optional. Steve mesh works on Alex (minor clipping).
→ Textures bake into the GLB. No separate file needed.
```
diff --git a/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidationReloadListener.java b/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidationReloadListener.java
index bf724b4..346303d 100644
--- a/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidationReloadListener.java
+++ b/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidationReloadListener.java
@@ -6,6 +6,9 @@ import java.io.InputStream;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.components.toasts.SystemToast;
+import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.Resource;
import net.minecraft.server.packs.resources.ResourceManager;
@@ -129,6 +132,21 @@ public class GlbValidationReloadListener
"[GltfValidation] Validated {} GLBs: {} passed, {} with warnings, {} with errors",
total, passed, withWarnings, withErrors
);
+
+ // Show toast notification for errors so artists don't have to check logs
+ if (withErrors > 0) {
+ int errorCount = withErrors;
+ Minecraft.getInstance().tell(() ->
+ Minecraft.getInstance().getToasts().addToast(
+ SystemToast.multiline(
+ Minecraft.getInstance(),
+ SystemToast.SystemToastIds.PACK_LOAD_FAILURE,
+ Component.literal("TiedUp! GLB Validation"),
+ Component.literal(errorCount + " model(s) have errors. Run /tiedup validate")
+ )
+ )
+ );
+ }
}
private static boolean hasWarnings(GlbValidationResult result) {
From 229fc66340ab9e99381e131472d107bf33b7c0b9 Mon Sep 17 00:00:00 2001
From: NotEvil
Date: Fri, 17 Apr 2026 02:49:34 +0200
Subject: [PATCH 13/19] fix(animation): free bones only enabled for
Full-prefixed animations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Previously, any GLB with keyframes on free bones would animate them,
even for standard animations like Idle. This caused accidental bone
hijacking — e.g., handcuffs freezing the player's head because the
artist keyframed all bones in Blender.
Now the Full prefix (FullIdle, FullStruggle, FullWalk) is enforced:
only Full-prefixed animations can animate free bones. Standard
animations (Idle, Struggle, Walk) only animate owned bones.
This aligns the code with the documented convention in ARTIST_GUIDE.md.
---
.../remake/client/gltf/GltfPoseConverter.java | 32 +++++++++++++++----
1 file changed, 25 insertions(+), 7 deletions(-)
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 c35b3eb..d19b831 100644
--- a/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java
+++ b/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java
@@ -208,12 +208,14 @@ public final class GltfPoseConverter {
}
}
- // Selective: enable owned parts always, free parts only if they have keyframes
+ // Selective: enable owned parts always, free parts only for "Full" animations
+ // that explicitly opt into full-body control.
enableSelectiveParts(
builder,
ownedParts,
enabledParts,
- partsWithKeyframes
+ partsWithKeyframes,
+ animName
);
KeyframeAnimation anim = builder.build();
@@ -554,22 +556,35 @@ public final class GltfPoseConverter {
*
*
* - Owned parts: always enabled (the item controls these bones)
- * - Free parts WITH keyframes: enabled (the GLB has animation data for them)
- * - Free parts WITHOUT keyframes: disabled (no data to animate, pass through to context)
+ * - Free parts WITH keyframes AND "Full" animation: enabled (explicit opt-in to full-body)
+ * - Free parts without "Full" prefix: disabled (prevents accidental bone hijacking)
* - Other items' parts: disabled (pass through to their own layer)
*
*
+ * The "Full" prefix convention (FullIdle, FullStruggle, FullWalk) is the artist's
+ * explicit declaration that this animation is designed to control the entire body,
+ * not just the item's owned regions. Without this prefix, free bones are never enabled,
+ * even if the GLB contains keyframes for them. This prevents accidental bone hijacking
+ * when an artist keyframes all bones in Blender by default.
+ *
* @param builder the animation builder with keyframes already added
* @param ownedParts parts the item explicitly owns (always enabled)
* @param enabledParts parts the item may animate (owned + free)
* @param partsWithKeyframes parts that received actual animation data from the GLB
+ * @param animName resolved animation name (checked for "Full" prefix)
*/
private static void enableSelectiveParts(
KeyframeAnimation.AnimationBuilder builder,
Set ownedParts,
Set enabledParts,
- Set partsWithKeyframes
+ Set partsWithKeyframes,
+ String animName
) {
+ // Free bones are only enabled for "Full" animations (FullIdle, FullStruggle, etc.)
+ // The "gltf_" prefix is added by convertClipSelective, so check for "gltf_Full"
+ boolean isFullBodyAnimation = animName != null &&
+ animName.startsWith("gltf_Full");
+
String[] allParts = {
"head",
"body",
@@ -588,13 +603,16 @@ public final class GltfPoseConverter {
// Always enable owned parts — the item controls these bones
part.fullyEnablePart(false);
} else if (
+ isFullBodyAnimation &&
enabledParts.contains(partName) &&
partsWithKeyframes.contains(partName)
) {
- // Free part WITH keyframes: enable so the GLB animation drives it
+ // Full-body animation: free part WITH keyframes — enable.
+ // The "Full" prefix is the artist's explicit opt-in to animate
+ // bones outside their declared regions.
part.fullyEnablePart(false);
} else {
- // Other item's part, or free part without keyframes: disable.
+ // Non-Full animation, other item's part, or free part without keyframes.
// Disabled parts pass through to the lower-priority context layer.
part.setEnabled(false);
}
From 3d57d83a5b66a1c6d76add5411e2bb4f085ca761 Mon Sep 17 00:00:00 2001
From: NotEvil
Date: Fri, 17 Apr 2026 02:52:29 +0200
Subject: [PATCH 14/19] =?UTF-8?q?fix(animation):=20preserve=20head=20track?=
=?UTF-8?q?ing=20in=20Full=20animations=20=E2=80=94=20head=20never=20enabl?=
=?UTF-8?q?ed=20as=20free=20bone?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../com/tiedup/remake/client/gltf/GltfPoseConverter.java | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
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 d19b831..3946f1d 100644
--- a/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java
+++ b/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java
@@ -605,11 +605,15 @@ public final class GltfPoseConverter {
} else if (
isFullBodyAnimation &&
enabledParts.contains(partName) &&
- partsWithKeyframes.contains(partName)
+ partsWithKeyframes.contains(partName) &&
+ !"head".equals(partName)
) {
// Full-body animation: free part WITH keyframes — enable.
// The "Full" prefix is the artist's explicit opt-in to animate
// bones outside their declared regions.
+ // EXCEPTION: head is never enabled as a free bone — vanilla head
+ // tracking (mouse look) is always preserved unless the item
+ // explicitly owns a head region (HEAD, EYES, EARS, MOUTH).
part.fullyEnablePart(false);
} else {
// Non-Full animation, other item's part, or free part without keyframes.
From 806a1e732d9190524c5de95c44c362c4da707a1d Mon Sep 17 00:00:00 2001
From: NotEvil
Date: Fri, 17 Apr 2026 03:01:43 +0200
Subject: [PATCH 15/19] =?UTF-8?q?feat(animation):=20add=20FullHead=20conve?=
=?UTF-8?q?ntion=20=E2=80=94=20opt-in=20head=20animation=20in=20Full=20ani?=
=?UTF-8?q?mations?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
FullStruggle, FullWalk etc. animate body+legs but preserve head tracking.
FullHeadStruggle, FullHeadWalk etc. also animate the head.
The 'Head' keyword in the animation name is the opt-in signal.
---
.../tiedup/remake/client/gltf/GltfPoseConverter.java | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
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 3946f1d..052b104 100644
--- a/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java
+++ b/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java
@@ -584,6 +584,10 @@ public final class GltfPoseConverter {
// The "gltf_" prefix is added by convertClipSelective, so check for "gltf_Full"
boolean isFullBodyAnimation = animName != null &&
animName.startsWith("gltf_Full");
+ // 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");
String[] allParts = {
"head",
@@ -606,14 +610,14 @@ public final class GltfPoseConverter {
isFullBodyAnimation &&
enabledParts.contains(partName) &&
partsWithKeyframes.contains(partName) &&
- !"head".equals(partName)
+ (!"head".equals(partName) || allowFreeHead)
) {
// Full-body animation: free part WITH keyframes — enable.
// The "Full" prefix is the artist's explicit opt-in to animate
// bones outside their declared regions.
- // EXCEPTION: head is never enabled as a free bone — vanilla head
- // tracking (mouse look) is always preserved unless the item
- // explicitly owns a head region (HEAD, EYES, EARS, MOUTH).
+ // Head is protected by default (preserves vanilla head tracking).
+ // Use "Head" in the animation name (e.g., FullHeadStruggle) to
+ // explicitly opt-in to head control for that animation.
part.fullyEnablePart(false);
} else {
// Non-Full animation, other item's part, or free part without keyframes.
From e56e6dd551cf87218f3d2be0bff5f5b034f4a7dc Mon Sep 17 00:00:00 2001
From: NotEvil
Date: Fri, 17 Apr 2026 03:06:33 +0200
Subject: [PATCH 16/19] fix(animation): extend resolver fallback chain to
include FullHead variants
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The resolver now tries FullHead* before Full* at each fallback step.
Example: FullHeadStruggle → FullStruggle → Struggle → FullHeadIdle → FullIdle → Idle
Previously FullHead* names were dead — the resolver never constructed them,
so animations named FullHeadStruggle were unreachable.
---
.../context/GlbAnimationResolver.java | 25 +++++++++++++------
1 file changed, 17 insertions(+), 8 deletions(-)
diff --git a/src/main/java/com/tiedup/remake/client/animation/context/GlbAnimationResolver.java b/src/main/java/com/tiedup/remake/client/animation/context/GlbAnimationResolver.java
index 45d9651..d2b79ce 100644
--- a/src/main/java/com/tiedup/remake/client/animation/context/GlbAnimationResolver.java
+++ b/src/main/java/com/tiedup/remake/client/animation/context/GlbAnimationResolver.java
@@ -72,34 +72,43 @@ public final class GlbAnimationResolver {
String prefix = context.getGlbContextPrefix(); // "Sit", "Kneel", "Sneak", "Walk", ""
String variant = context.getGlbVariant(); // "Idle" or "Struggle"
- // 1. Exact match: "FullSitIdle" then "SitIdle" (with variants)
+ // 1. Exact match: "FullHeadSitIdle" then "FullSitIdle" then "SitIdle" (with variants)
+ // FullHead variants opt-in to head animation (see GltfPoseConverter.enableSelectiveParts)
String exact = prefix + variant;
if (!exact.isEmpty()) {
- String picked = pickWithVariants(data, "Full" + exact);
+ String picked = pickWithVariants(data, "FullHead" + exact);
+ if (picked != null) return picked;
+ picked = pickWithVariants(data, "Full" + exact);
if (picked != null) return picked;
picked = pickWithVariants(data, exact);
if (picked != null) return picked;
}
- // 2. For struggles: try "FullStruggle" then "Struggle" (with variants)
+ // 2. For struggles: try "FullHeadStruggle" then "FullStruggle" then "Struggle" (with variants)
if (context.isStruggling()) {
- String picked = pickWithVariants(data, "FullStruggle");
+ String picked = pickWithVariants(data, "FullHeadStruggle");
+ if (picked != null) return picked;
+ picked = pickWithVariants(data, "FullStruggle");
if (picked != null) return picked;
picked = pickWithVariants(data, "Struggle");
if (picked != null) return picked;
}
- // 3. Context-only: "FullSit" then "Sit" (with variants)
+ // 3. Context-only: "FullHead{prefix}" then "Full{prefix}" then "{prefix}" (with variants)
if (!prefix.isEmpty()) {
- String picked = pickWithVariants(data, "Full" + prefix);
+ String picked = pickWithVariants(data, "FullHead" + prefix);
+ if (picked != null) return picked;
+ picked = pickWithVariants(data, "Full" + prefix);
if (picked != null) return picked;
picked = pickWithVariants(data, prefix);
if (picked != null) return picked;
}
- // 4. Variant-only: "FullIdle" then "Idle" (with variants)
+ // 4. Variant-only: "FullHeadIdle" then "FullIdle" then "Idle" (with variants)
{
- String picked = pickWithVariants(data, "Full" + variant);
+ String picked = pickWithVariants(data, "FullHead" + variant);
+ if (picked != null) return picked;
+ picked = pickWithVariants(data, "Full" + variant);
if (picked != null) return picked;
picked = pickWithVariants(data, variant);
if (picked != null) return picked;
From a3287b7db861544e1f0610c873875bdf659096d0 Mon Sep 17 00:00:00 2001
From: NotEvil
Date: Fri, 17 Apr 2026 03:11:29 +0200
Subject: [PATCH 17/19] 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",
From 168c0675bba5debaf982e0e878ca216ad63b7e7b Mon Sep 17 00:00:00 2001
From: NotEvil
Date: Fri, 17 Apr 2026 03:15:54 +0200
Subject: [PATCH 18/19] =?UTF-8?q?fix(animation):=20fix=20variant=20caching?=
=?UTF-8?q?=20bug=20in=20multi-item=20path=20=E2=80=94=20same=20fix=20as?=
=?UTF-8?q?=20single-item?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../client/gltf/GltfAnimationApplier.java | 76 +++++++++++--------
1 file changed, 43 insertions(+), 33 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 b095ba6..79850a9 100644
--- a/src/main/java/com/tiedup/remake/client/gltf/GltfAnimationApplier.java
+++ b/src/main/java/com/tiedup/remake/client/gltf/GltfAnimationApplier.java
@@ -7,6 +7,7 @@ import com.tiedup.remake.client.animation.context.GlbAnimationResolver;
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
import java.util.HashSet;
+import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -251,7 +252,40 @@ public final class GltfAnimationApplier {
}
// === Layer 2: Composite item animation ===
- String compositeCacheKey = "multi#" + stateKey;
+ // Pre-resolve animation data and variant names for all items BEFORE cache lookup.
+ // This ensures random variant selection happens fresh on each context change,
+ // and each variant combination gets its own cache entry.
+ record ResolvedItem(
+ GltfData animData,
+ String glbAnimName,
+ RegionBoneMapper.V2ItemAnimInfo info
+ ) {}
+
+ List resolvedItems = new ArrayList<>();
+ StringBuilder variantKeyBuilder = new StringBuilder("multi#").append(stateKey);
+
+ for (RegionBoneMapper.V2ItemAnimInfo item : items) {
+ ResourceLocation animSource =
+ item.animSource() != null ? item.animSource() : item.modelLoc();
+
+ GltfData animData = GlbAnimationResolver.resolveAnimationData(
+ item.modelLoc(), item.animSource()
+ );
+ if (animData == null) {
+ LOGGER.warn(
+ "[GltfPipeline] Failed to load GLB for multi-item: {}",
+ animSource
+ );
+ continue;
+ }
+
+ String glbAnimName = GlbAnimationResolver.resolve(animData, context);
+ resolvedItems.add(new ResolvedItem(animData, glbAnimName, item));
+ variantKeyBuilder.append('#')
+ .append(glbAnimName != null ? glbAnimName : "default");
+ }
+
+ String compositeCacheKey = variantKeyBuilder.toString();
if (failedLoadKeys.contains(compositeCacheKey)) {
activeStateKeys.put(entity.getUUID(), stateKey);
@@ -273,29 +307,13 @@ public final class GltfAnimationApplier {
boolean anyLoaded = false;
- for (int i = 0; i < items.size(); i++) {
- RegionBoneMapper.V2ItemAnimInfo item = items.get(i);
+ for (ResolvedItem resolved : resolvedItems) {
+ RegionBoneMapper.V2ItemAnimInfo item = resolved.info();
+ GltfData animData = resolved.animData();
+ String glbAnimName = resolved.glbAnimName();
ResourceLocation animSource =
- item.animSource() != null
- ? item.animSource()
- : item.modelLoc();
+ item.animSource() != null ? item.animSource() : item.modelLoc();
- GltfData animData = GlbAnimationResolver.resolveAnimationData(
- item.modelLoc(),
- item.animSource()
- );
- if (animData == null) {
- LOGGER.warn(
- "[GltfPipeline] Failed to load GLB for multi-item: {}",
- animSource
- );
- continue;
- }
-
- String glbAnimName = GlbAnimationResolver.resolve(
- animData,
- context
- );
GltfData.AnimationClip rawClip;
if (glbAnimName != null) {
rawClip = animData.getRawAnimation(glbAnimName);
@@ -310,9 +328,7 @@ public final class GltfAnimationApplier {
// if the item declares per-animation bone filtering.
Set effectiveParts = item.ownedParts();
if (glbAnimName != null && !item.animationBones().isEmpty()) {
- Set override = item
- .animationBones()
- .get(glbAnimName);
+ Set override = item.animationBones().get(glbAnimName);
if (override != null) {
Set filtered = new HashSet<>(override);
filtered.retainAll(item.ownedParts());
@@ -323,19 +339,13 @@ public final class GltfAnimationApplier {
}
GltfPoseConverter.addBonesToBuilder(
- builder,
- animData,
- rawClip,
- effectiveParts
+ builder, animData, rawClip, effectiveParts
);
anyLoaded = true;
LOGGER.debug(
"[GltfPipeline] Multi-item: {} -> owned={}, effective={}, anim={}",
- animSource,
- item.ownedParts(),
- effectiveParts,
- glbAnimName
+ animSource, item.ownedParts(), effectiveParts, glbAnimName
);
}
From f37600783aa0f488de8ae575d9f00a90e50b2697 Mon Sep 17 00:00:00 2001
From: NotEvil
Date: Fri, 17 Apr 2026 04:05:55 +0200
Subject: [PATCH 19/19] docs(artist-guide): update for Full/FullHead
conventions, custom bones, frame 0 behavior, validation tools
---
docs/ARTIST_GUIDE.md | 108 +++++++++++++++++++++----------------------
1 file changed, 53 insertions(+), 55 deletions(-)
diff --git a/docs/ARTIST_GUIDE.md b/docs/ARTIST_GUIDE.md
index 602abcb..dad2dc5 100644
--- a/docs/ARTIST_GUIDE.md
+++ b/docs/ARTIST_GUIDE.md
@@ -96,16 +96,17 @@ PlayerArmature ← armature root object (never keyframe this)
**Never animate:** `PlayerArmature` — it's the armature root object, not a bone.
**Everything else follows this rule:**
-- Your item **always** controls bones in its declared regions.
-- Your item **can also** animate free bones (not owned by any other equipped item).
+- **Standard animations** (`Idle`, `Struggle`, `Walk`): your item controls ONLY bones in its declared regions. Keyframes on other bones are ignored.
+- **`Full` animations** (`FullIdle`, `FullStruggle`, `FullWalk`): your item also controls free bones (body, legs — not owned by another item). See [Full-Body Animations](#full-body-animations-naming-convention).
+- **Head is protected by default**: vanilla head tracking is preserved unless your item owns a head region (HEAD, EYES, EARS, MOUTH). In `Full` animations, head stays protected. Use `FullHead` prefix (e.g., `FullHeadStruggle`) to explicitly opt into head animation as a free bone.
- Your item **cannot** override bones owned by another equipped item.
| Bone | Who Controls It |
|------|----------------|
-| `body` / `torso` | Context layer by default. Your item if it owns TORSO or WAIST, or if `body` is free. |
-| `head` | Vanilla head tracking by default. Your item if it owns HEAD, EYES, EARS, or MOUTH. |
-| Arms (`*UpperArm`, `*LowerArm`) | Vanilla by default. Your item if it owns ARMS or HANDS. |
-| Legs (`*UpperLeg`, `*LowerLeg`) | Context layer by default. Your item if it owns LEGS or FEET. |
+| `body` / `torso` | Context layer by default. Your item if it owns TORSO or WAIST. Also available as a free bone in `Full` animations. |
+| `head` | **Vanilla head tracking by default.** Your item if it owns HEAD, EYES, EARS, or MOUTH. Available as a free bone ONLY in `FullHead` animations (e.g., `FullHeadStruggle`). |
+| Arms (`*UpperArm`, `*LowerArm`) | Vanilla by default. Your item if it owns ARMS or HANDS. Also available as free bones in `Full` animations. |
+| Legs (`*UpperLeg`, `*LowerLeg`) | Context layer by default. Your item if it owns LEGS or FEET. Also available as free bones in `Full` animations. |
**Note:** `torso` and `body` both map to the same internal part. Prefer animating `body` — using `torso` produces the same result but is less intuitive.
@@ -160,7 +161,7 @@ Three regions are **global** — they encompass sub-regions:
Use the **TiedUp! Blender Template** (provided with the mod). It contains:
- The correct `PlayerArmature` skeleton with all 11 bones
-- A reference player mesh (Steve + Alex) for scale — toggle visibility as needed
+- A reference player mesh (Slime model) for scale — toggle visibility as needed
- Pre-named Action slots
### Guidelines
@@ -260,9 +261,6 @@ Declare default colors per channel in your item JSON:
"tintable_1": "#FF0000",
"tintable_2": "#C0C0C0"
},
- "animation_bones": {
- "idle": []
- },
"pose_priority": 10,
"escape_difficulty": 3
}
@@ -311,19 +309,19 @@ The `PlayerArmature|` prefix is Blender's convention for armature-scoped actions
| Gag (MOUTH) | *(none)* | No pose change — mesh only |
| Straitjacket (ARMS+TORSO) | Arms + body (+ legs if free) | Arms crossed, slight forward lean, optional waddle |
-**Why only your bones?** The mod's 2-layer system activates your keyframes for bones in your declared regions. But there's a nuance: **free bones** (bones not owned by any equipped item) can also be animated by your item.
+**Why only your bones?** In standard animations (`Idle`, `Struggle`), the mod only uses keyframes for bones in your declared regions. Keyframes on other bones are ignored — safe to leave them in your GLB.
-For example: if a player wears only a straitjacket (ARMS+TORSO), the legs are "free" — no item claims them. If your straitjacket's GLB has leg keyframes (e.g., a waddle walk), the mod will use them. But if the player also wears ankle cuffs (LEGS), those leg keyframes are ignored — the ankle cuffs take over.
+To animate free bones (body, legs not owned by another item), use the `Full` prefix — see [Full-Body Animations](#full-body-animations-naming-convention). For example, a straitjacket's `FullWalk` can animate legs for a waddle, but only if no ankle cuffs are equipped.
-**The rule:** Your item always controls its own bones. It can also animate free bones if your GLB has keyframes for them. It can never override another item's bones.
+**The rule:** Standard animations = owned bones only. `Full` animations = owned + free bones. `FullHead` animations = owned + free + head. Your item can never override another item's bones.
-### Idle is a Single-Frame Pose
+### Animation Frames
-`Idle` should be a **static pose** — one keyframe at frame 0. The mod loops it as a held position.
+**Frame 0 is the base pose** — the minimum every animation must have. The mod always has a valid pose to display from frame 0.
-```
-Frame 0: Pose all owned bones → done.
-```
+**Multi-frame animations** (loops, transitions) are supported by the GLB parser — all frames are parsed and stored. However, **multi-frame playback is not yet implemented** in the item animation converter (`GltfPoseConverter`). Currently only frame 0 is read at runtime. Multi-frame playback is a high-priority feature — see `docs/TODO.md`.
+
+**What this means for artists now:** Design your animations with full multi-frame loops (struggle thrashing, walk cycles, breathing idles). Put the most representative pose at frame 0. When multi-frame playback ships, your animations will work immediately without re-exporting.
### Optional Animations
@@ -331,13 +329,13 @@ Beyond `Idle`, you can provide animations for specific contexts. All are optiona
| Animation Name | Context | Notes |
|----------------|---------|-------|
-| `Idle` | Standing still | **Required.** Single-frame pose. |
-| `Struggle` | Player is struggling | Multi-frame loop. 20-40 frames recommended. |
+| `Idle` | Standing still | **Required.** Single-frame or looped. Frame 0 = base pose. |
+| `Struggle` | Player is struggling | Multi-frame loop recommended. 20-40 frames. |
| `Walk` | Player is walking | Multi-frame loop synced to walk speed. |
| `Sneak` | Player is sneaking | Single-frame or short loop. |
-| `SitIdle` | Sitting (chair, minecart) | Single-frame pose. |
+| `SitIdle` | Sitting (chair, minecart) | Single-frame or looped. |
| `SitStruggle` | Sitting + struggling | Multi-frame loop. |
-| `KneelIdle` | Kneeling | Single-frame pose. |
+| `KneelIdle` | Kneeling | Single-frame or looped. |
| `KneelStruggle` | Kneeling + struggling | Multi-frame loop. |
| `Crawl` | Crawling (dog pose) | Multi-frame loop. |
@@ -352,21 +350,27 @@ If an animation doesn't exist in your GLB, the mod looks for alternatives. At ea
```
SIT + STRUGGLE:
- FullSitStruggle → SitStruggle → FullStruggle → Struggle
- → FullSit → Sit → FullStruggle → Struggle → FullIdle → Idle
+ FullHeadSitStruggle → FullSitStruggle → SitStruggle
+ → FullHeadStruggle → FullStruggle → Struggle
+ → FullHeadSit → FullSit → Sit
+ → FullHeadIdle → FullIdle → Idle
KNEEL + STRUGGLE:
- FullKneelStruggle → KneelStruggle → FullStruggle → Struggle
- → FullKneel → Kneel → FullStruggle → Struggle → FullIdle → Idle
+ FullHeadKneelStruggle → FullKneelStruggle → KneelStruggle
+ → FullHeadStruggle → FullStruggle → Struggle
+ → FullHeadKneel → FullKneel → Kneel
+ → FullHeadIdle → FullIdle → Idle
-SIT + IDLE: FullSitIdle → SitIdle → FullSit → Sit → FullIdle → Idle
-KNEEL + IDLE: FullKneelIdle → KneelIdle → FullKneel → Kneel → FullIdle → Idle
-SNEAK: FullSneak → Sneak → FullIdle → Idle
-WALK: FullWalk → Walk → FullIdle → Idle
-STAND STRUGGLE: FullStruggle → Struggle → FullIdle → Idle
-STAND IDLE: FullIdle → Idle
+SIT + IDLE: FullHeadSitIdle → FullSitIdle → SitIdle → ... → FullHeadIdle → FullIdle → Idle
+KNEEL + IDLE: FullHeadKneelIdle → FullKneelIdle → KneelIdle → ... → FullHeadIdle → FullIdle → Idle
+SNEAK: FullHeadSneak → FullSneak → Sneak → FullHeadIdle → FullIdle → Idle
+WALK: FullHeadWalk → FullWalk → Walk → FullHeadIdle → FullIdle → Idle
+STAND STRUGGLE: FullHeadStruggle → FullStruggle → Struggle → FullHeadIdle → FullIdle → Idle
+STAND IDLE: FullHeadIdle → FullIdle → Idle
```
+At each step, `FullHead` is tried first (full body + head), then `Full` (full body, head preserved), then standard (owned bones only).
+
In practice, most items only need `Idle`. Add `FullWalk` or `FullStruggle` when your item changes how the whole body moves.
**Practical impact:** If you only provide `Idle`, your item works in every context. The player will hold the Idle pose while sitting, kneeling, sneaking, etc. It won't look perfect, but it will work. Add more animations over time to polish the experience.
@@ -396,18 +400,19 @@ Some items affect the entire body — not just their declared regions. A straitj
**Convention:** Prefix the animation name with `Full`.
-| Standard Name | Full-Body Name | What Changes |
-|--------------|---------------|-------------|
-| `Idle` | `FullIdle` | Owned bones + body lean, leg stance |
-| `Walk` | `FullWalk` | Owned bones + leg waddle, body sway |
-| `Struggle` | `FullStruggle` | Owned bones + full-body thrashing |
-| `Sneak` | `FullSneak` | Owned bones + custom sneak posture |
+| Standard Name | Full-Body Name | Full + Head | What Changes |
+|--------------|---------------|-------------|-------------|
+| `Idle` | `FullIdle` | `FullHeadIdle` | Owned bones + body lean, leg stance (+ head if Head variant) |
+| `Walk` | `FullWalk` | `FullHeadWalk` | Owned bones + leg waddle, body sway (+ head if Head variant) |
+| `Struggle` | `FullStruggle` | `FullHeadStruggle` | Owned bones + full-body thrashing (+ head if Head variant) |
+| `Sneak` | `FullSneak` | `FullHeadSneak` | Owned bones + custom sneak posture (+ head if Head variant) |
**In Blender:**
```
-PlayerArmature|Idle ← region-only: just arms for handcuffs
-PlayerArmature|FullWalk ← full-body: arms + legs waddle + body bob
-PlayerArmature|FullStruggle ← full-body: everything moves
+PlayerArmature|Idle ← region-only: just arms for handcuffs
+PlayerArmature|FullWalk ← full-body: arms + legs waddle + body bob
+PlayerArmature|FullStruggle ← full-body: body + legs thrash (head free)
+PlayerArmature|FullHeadStruggle ← full-body + head: everything moves including head
```
**How the mod resolves this:**
@@ -425,7 +430,9 @@ PlayerArmature|FullStruggle ← full-body: everything moves
| `Sneak` | Default sneak lean is fine | Your item changes how sneaking looks |
**Key points:**
-- `Full` animations include keyframes for ALL bones you want to control (owned + free).
+- **Standard animations** (`Idle`, `Struggle`, `Walk`) only animate your item's **owned bones** (from `regions`). Any keyframes on other bones are ignored. This is safe — you can keyframe everything in Blender without worrying about side effects.
+- **`Full` animations** (`FullIdle`, `FullStruggle`, `FullWalk`) additionally enable **free bones** (body, legs — bones not owned by any other equipped item). This is how you create waddle walks, full-body struggles, etc.
+- **Head is protected by default.** In `Full` animations, the `head` bone is NOT enabled as a free bone — vanilla head tracking (mouse look) is preserved. To animate the head in a Full animation, add `Head` to the animation name: `FullHeadStruggle`, `FullHeadIdle`, etc. Items that own a head region (HEAD, EYES, EARS, MOUTH) always control head regardless of naming.
- Free bones in `Full` animations are only used when no other item owns them.
- You can provide BOTH: `Idle` (region-only) and `FullWalk` (full-body). The mod picks the right one per context.
- `FullIdle` is rarely needed — most items only need a full-body version for movement animations.
@@ -651,9 +658,6 @@ Every item needs a JSON file that declares its gameplay properties. The mod scan
"display_name": "Rope Gag",
"model": "mycreator:models/gltf/rope_gag.glb",
"regions": ["MOUTH"],
- "animation_bones": {
- "idle": []
- },
"pose_priority": 10,
"escape_difficulty": 2,
"lockable": false
@@ -762,7 +766,6 @@ The `movement_style` changes how the player physically moves — slower speed, d
| `supports_color` | bool | No | Whether this item has tintable zones. Default: `false` |
| `tint_channels` | object | No | Default colors per tintable zone: `{"tintable_1": "#FF0000"}` |
| `icon` | string | No | Inventory sprite model (see [Inventory Icons](#inventory-icons) below) |
-| `animations` | string/object | No | `"auto"` (default) or explicit name mapping |
| `movement_style` | string | No | Movement restriction: `"waddle"`, `"shuffle"`, `"hop"`, or `"crawl"` |
| `movement_modifier` | object | No | Override speed/jump for the movement style (requires `movement_style`) |
| `creator` | string | No | Author/creator name, shown in the item tooltip |
@@ -828,9 +831,6 @@ Items without the `"components"` field work normally — components are entirely
"display_name": "GPS Shock Collar",
"model": "mycreator:models/gltf/gps_shock_collar.glb",
"regions": ["NECK"],
- "animation_bones": {
- "idle": []
- },
"pose_priority": 10,
"escape_difficulty": 5,
"components": {
@@ -1047,7 +1047,7 @@ If your GLB contains multiple meshes, name your item mesh `Item` in Blender. The
| Mistake | Symptom | Fix |
|---------|---------|-----|
-| Bone name typo (`RightUpperArm` instead of `rightUpperArm`) | Warning in log: "did you mean 'rightUpperArm'?" — bone treated as custom | Names are **camelCase**, not PascalCase. Check exact spelling. Run `/tiedup validate` to see warnings. |
+| Bone name typo (`RightUpperArm` instead of `rightUpperArm`) | WARN in log with suggestion: "did you mean 'rightUpperArm'?" — bone treated as custom | Names are **camelCase**, not PascalCase. Check exact spelling. Run `/tiedup validate` to see warnings. |
| Extra bones in the armature | Custom bones follow their parent in rest pose | Intentional custom bones are fine (chains, decorations). Unintentional ones add file size — delete them. |
| Missing `PlayerArmature` root | Mesh renders at wrong position | Rename your armature root to `PlayerArmature` |
| Animating `body` bone without TORSO region | Body keyframes used only if `body` is free (no other item owns it) | Declare TORSO/WAIST region if you always want to control body, or use `Full` animations for free-bone effects |
@@ -1060,7 +1060,7 @@ If your GLB contains multiple meshes, name your item mesh `Item` in Blender. The
| Wrong case (`idle` instead of `Idle`) | Animation not found | Use exact PascalCase: `Idle`, `SitIdle`, `KneelStruggle` |
| Variant gap (`.1`, `.2`, `.4` — missing `.3`) | Only .1 and .2 are used | Number sequentially with no gaps |
| Animating bones outside your regions | Keyframes silently ignored | Only animate bones in your declared regions |
-| Multi-frame Idle | Works but wastes resources | Idle should be a single keyframe at frame 0 |
+| Multi-frame animations play as static pose | Multi-frame playback not yet implemented — only frame 0 is used | Design full animations now (they'll work when playback ships). Ensure frame 0 is a good base pose. |
### Weight Painting Issues
@@ -1100,9 +1100,6 @@ A collar sits on the neck. It doesn't change the player's pose.
"display_name": "Leather Collar",
"model": "mycreator:models/gltf/leather_collar.glb",
"regions": ["NECK"],
- "animation_bones": {
- "idle": []
- },
"pose_priority": 5,
"escape_difficulty": 3,
"lockable": true
@@ -1578,7 +1575,8 @@ GOOD TO KNOW:
→ animation_bones is optional. Omit it and all owned bones work for all animations.
→ Templates let you skip animation entirely.
→ Custom bones are supported — add chain/ribbon/twist bones parented to standard bones.
- → Free bones (not owned by any item) CAN be animated by your GLB.
+ → Free bones (body, legs) can be animated using Full-prefixed animations (FullWalk, FullStruggle).
+ → Head is protected — use FullHead prefix (FullHeadStruggle) to also animate the head.
→ Bones owned by another equipped item are always ignored.
→ The mod handles sitting, sneaking, walking — you don't have to.
→ Context GLBs in tiedup_contexts/ replace default postures.