Read binary Emotecraft emotes and fix the torso's bend direction

tools/emotecraft/convert.py turns Emotecraft's binary .emotecraft files (its network packet, with
the animation in player-animator's legacy format) into the Emotecraft JSON EmotecraftLoader reads.

Converting a few community emotes showed the loader bent the torso the wrong way: Get the Griddy,
which hunches forward, arched back. Emotecraft bends limbs the other way from us, but not the torso,
whose bend is anchored at the hips here and so already turned round. The loader now keeps the
torso's bend sign, and gives the whole player's bend to the torso, as Emotecraft does. fit.py writes
torso bends to match, so re-exported captures look the same.

Bumps the version to 0.1.1 for the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-29 17:37:18 +02:00
co-authored by claude
parent 0518a2793a
commit 970e9aef51
7 changed files with 249 additions and 9 deletions
@@ -31,7 +31,11 @@ import com.fasterxml.jackson.databind.ObjectMapper;
* subtracted to get an offset.</li>
* <li>Whole-player positions are in blocks with Y up, and its rotations are around the world axes
* before Minecraft flips the model, so X, Y, pitch and yaw change sign.</li>
* <li>Emotecraft bends the other way, so {@code bend} changes sign.</li>
* <li>Emotecraft bends limbs the other way, so a limb's {@code bend} changes sign. The torso's doesn't:
* ours is anchored at the hips, which already turns it round, so the two agree (a positive bend with
* axis 0 leans forward in both).</li>
* <li>The whole player can't bend. In Emotecraft its {@code bend} and {@code axis} bend the torso,
* so they go to {@link Bone#BODY}.</li>
* <li>Angles are in degrees unless {@code "degrees": false}.</li>
* <li>{@code turn} adds whole turns to the move's angles, for spins and flips.</li>
* <li>Looping emotes jump back to {@code returnTick} after {@code endTick}, and only loop when they
@@ -112,8 +116,11 @@ public final class EmotecraftLoader {
for (Map.Entry<String, Channel> axis : AXES.entrySet()) {
JsonNode value = axes.get(axis.getKey());
if (value != null && value.isNumber()) {
float converted = convert(bone, axis.getValue(), value.floatValue(), degrees, turn);
animation.keyframe(bone, axis.getValue(), tick, converted, easing);
Channel channel = axis.getValue();
boolean bend = channel == Channel.BEND || channel == Channel.BEND_AXIS;
Bone target = bone == Bone.ROOT && bend ? Bone.BODY : bone;
float converted = convert(target, channel, value.floatValue(), degrees, turn);
animation.keyframe(target, channel, tick, converted, easing);
}
}
}
@@ -146,7 +153,7 @@ public final class EmotecraftLoader {
float radians = angle(value, degrees, turn);
yield bone == Bone.ROOT && channel != Channel.ROLL ? -radians : radians;
}
case BEND -> -angle(value, degrees, turn);
case BEND -> bone == Bone.BODY ? angle(value, degrees, turn) : -angle(value, degrees, turn);
case BEND_AXIS -> angle(value, degrees, turn);
};
}
@@ -78,6 +78,22 @@ class EmotecraftLoaderTest {
assertEquals(0.96f, bone(load("cry"), Bone.RIGHT_ARM, 30).bend(), 0.01);
}
@Test
void keepsTheTorsoBendAndGivesItTheWholeBodysBend() throws IOException {
// A positive torso bend at axis 0 leans forward in both, so it keeps its sign (Get the Griddy
// leans forward with a bend of +0.4).
Emote torso = EmotecraftLoader.load("torso",
"{\"version\": 3, \"emote\": {\"degrees\": false, \"moves\": [{\"tick\": 0, \"torso\": {\"bend\": 0.4}}]}}");
assertEquals(0.4f, bone(torso, Bone.BODY, 0).bend(), 1e-6);
// Emotecraft's whole-body bend bends the torso; the whole player can't bend.
Emote body = inline("{\"tick\": 0, \"body\": {\"bend\": 0.4, \"axis\": 0.2, \"pitch\": 0.1}}", "\"degrees\": false, ");
assertEquals(0.4f, bone(body, Bone.BODY, 0).bend(), 1e-6);
assertEquals(0.2f, bone(body, Bone.BODY, 0).bendAxis(), 1e-6);
assertEquals(0, bone(body, Bone.ROOT, 0).bend(), 1e-6);
assertEquals(-0.1f, bone(body, Bone.ROOT, 0).pitch(), 1e-6);
}
@Test
void mapsTheWholeBodyToRoot() throws IOException {
// front_flip turns "body" pitch to -2π by tick 15, which is a forward flip: +2π around model X.