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
+7 -2
View File
@@ -19,6 +19,8 @@ A player emote library for Saturn Client, with limb bending.
`versions/<mc>/build/run/clientGameTest/screenshots/`.
- `tools/capture/`: a Python tool that measures an emote from a store page's 3D preview and writes
it as Emotecraft JSON. See its README.
- `tools/emotecraft/convert.py`: converts Emotecraft's binary `.emotecraft` files to Emotecraft JSON
(standard-library Python).
Supported versions: 1.21.4 to 1.21.11, the same range as Saturn Client. Each version's Fabric API
version is set in `stonecutter.properties.toml`. The code uses Mojang mappings.
@@ -47,8 +49,11 @@ pitch/yaw/roll in radians, bend and bend axis) for any of `ROOT` (the whole play
`EmotecraftLoader` follows player-animation-lib's reader: absolute part pivots become offsets,
`body` (and `torso` before format version 3) is the whole player in blocks, angles are degrees unless
`"degrees": false`, `turn` adds whole turns, and loops jump back to `returnTick` after `endTick`. Scale
channels, `easingArg` and blending with the vanilla pose aren't supported yet (issue #4).
`"degrees": false`, `turn` adds whole turns, and loops jump back to `returnTick` after `endTick`.
Emotecraft's two-segment bends become our curved ones: a limb's bend changes sign, the torso's
doesn't, and a bend on the whole player bends the torso. Scale channels, `easingArg` and blending
with the vanilla pose aren't supported yet (issue #4). Binary `.emotecraft` files can be converted to
JSON first with `tools/emotecraft/convert.py`.
## How it hooks in
@@ -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.
@@ -19,7 +19,7 @@ import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
/** Client-side entry point: which player is playing which animation. */
public final class Emotes implements ClientModInitializer {
public static final String MOD_ID = "saturn_emotes";
public static final String VERSION = /*$ mod_version*/ "0.1.0";
public static final String VERSION = /*$ mod_version*/ "0.1.1";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
private static final EmoteRegistry REGISTRY = new EmoteRegistry();
+1 -1
View File
@@ -2,7 +2,7 @@
mod.id = "saturn_emotes"
mod.name = "Saturn Emotes"
mod.group = "org.saturnclient"
mod.version = "0.1.0"
mod.version = "0.1.1"
deps.fabric_loader = "0.19.5"
loomx.loom_version = "1.17.21"
+2 -1
View File
@@ -28,7 +28,8 @@ def to_file(bone, channel, value):
return value + REST.get(bone, (0, 0, 0))["xyz".index(channel)]
degrees = np.degrees(value)
if channel == "bend":
return -degrees
# Limbs bend the other way in Emotecraft; the torso agrees with ours.
return degrees if bone == "body" else -degrees
if bone == "root" and channel in ("pitch", "yaw"):
return -degrees
return degrees
+211
View File
@@ -0,0 +1,211 @@
"""Converts Emotecraft's binary .emotecraft files to the Emotecraft JSON our EmotecraftLoader reads.
python3 convert.py <file.emotecraft>... [--out-dir DIR]
Writes <id>.json next to each file (or into DIR), with the id made from the file's name, and prints
what each file holds. Standard library only.
The binary is Emotecraft's network packet (EmotePacket): an int version, a purpose byte and a count of
sub-packets, each an id byte, a version byte, an int size and its data. Sub-packet 0 is the animation
in player-animator's legacy format (PlayerAnimationLibrary's LegacyAnimationBinary), whose values are
the same raw values Emotecraft JSON holds (radians, absolute pivots, the whole player in blocks), so
they're written out as they are with "degrees": false. Sub-packet 17 is the name, description and
author (header version 1 and up). Anything else (the icon, the scale channels, easing arguments) is left out, since our loader
doesn't read it.
One thing moves: in the legacy format the whole-player part "body" also carries the torso's bend.
In JSON, "body" is only the whole player, so its bend goes to "torso", as PlayerAnimationLibrary does.
"""
import argparse
import json
import re
import struct
import sys
from pathlib import Path
ANIMATION, HEADER = 0, 17
AXES = ["x", "y", "z", "pitch", "yaw", "roll"]
BEND_AXES = ["axis", "bend"]
SCALE_AXES = ["scaleX", "scaleY", "scaleZ"]
# The fixed part order before version 2 named its parts.
V1_PARTS = ["head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"]
# PlayerAnimationLibrary's EasingType ids. Our Easing.fromName reads the same names.
EASINGS = {
0: "LINEAR", 1: "CONSTANT", 6: "EASEINSINE", 7: "EASEOUTSINE", 8: "EASEINOUTSINE",
9: "EASEINCUBIC", 10: "EASEOUTCUBIC", 11: "EASEINOUTCUBIC", 12: "EASEINQUAD", 13: "EASEOUTQUAD",
14: "EASEINOUTQUAD", 15: "EASEINQUART", 16: "EASEOUTQUART", 17: "EASEINOUTQUART",
18: "EASEINQUINT", 19: "EASEOUTQUINT", 20: "EASEINOUTQUINT", 21: "EASEINEXPO", 22: "EASEOUTEXPO",
23: "EASEINOUTEXPO", 24: "EASEINCIRC", 25: "EASEOUTCIRC", 26: "EASEINOUTCIRC", 27: "EASEINBACK",
28: "EASEOUTBACK", 29: "EASEINOUTBACK", 30: "EASEINELASTIC", 31: "EASEOUTELASTIC",
32: "EASEINOUTELASTIC", 33: "EASEINBOUNCE", 34: "EASEOUTBOUNCE", 35: "EASEINOUTBOUNCE",
}
class Reader:
def __init__(self, data):
self.data, self.pos = data, 0
def take(self, fmt):
values = struct.unpack_from(">" + fmt, self.data, self.pos)
self.pos += struct.calcsize(">" + fmt)
return values[0] if len(values) == 1 else values
def bool(self):
return self.take("b") != 0
def string(self):
size = self.take("i")
text = self.data[self.pos:self.pos + size].decode("utf-8")
self.pos += size
return text
def read_channel(r, version, keyframe_size):
"""[(tick, value, easing id)], or None when the channel is off."""
if version >= 2:
enabled, count = r.bool(), r.take("i")
else:
count = r.take("i")
enabled = count >= 0
if not enabled:
r.pos += max(count, 0) * keyframe_size
return None
keys = []
for _ in range(count):
start = r.pos
tick, value, easing = r.take("ifb")
keys.append((tick, value, easing))
r.pos = start + keyframe_size
return keys
def read_part(r, name, version, keyframe_size):
channels = {axis: read_channel(r, version, keyframe_size) for axis in AXES}
# Every part but the head (and held items) has bend channels.
if name not in ("head", "left_item", "right_item", "leftItem", "rightItem"):
for axis in BEND_AXES:
channels[axis] = read_channel(r, version, keyframe_size)
if version >= 3:
for axis in SCALE_AXES:
channels[axis] = read_channel(r, version, keyframe_size)
return {axis: keys for axis, keys in channels.items() if keys}
def read_animation(data, version):
r = Reader(data)
r.take("i") # the tick the packet was sent at
begin, end, stop = r.take("iii")
loop = r.bool()
return_tick = r.take("i")
ease_before = r.bool()
r.bool() # a tag nothing reads
keyframe_size = r.take("b")
parts = {}
if version >= 2:
for _ in range(r.take("i")):
name = r.string()
parts[name] = read_part(r, name, version, keyframe_size)
else:
for name in V1_PARTS:
parts[name] = read_part(r, name, version, keyframe_size)
return {"begin": begin, "end": end, "stop": stop, "loop": loop, "return": return_tick,
"ease_before": ease_before, "parts": {n: p for n, p in parts.items() if p}}
def plain_text(component):
"""The header's strings are Minecraft text components in JSON: a quoted string, or an object
with "text", or a translation with a "fallback"."""
try:
value = json.loads(component)
except ValueError:
return component
if isinstance(value, dict):
return value.get("text") or value.get("fallback") or value.get("translate", "")
return str(value) if value is not None else ""
def read_file(path):
r = Reader(Path(path).read_bytes())
r.take("i") # network version
r.take("b") # purpose
info, animation, skipped = {}, None, []
for _ in range(r.take("B")):
packet, version, size = r.take("bbi")
data = r.data[r.pos:r.pos + size]
r.pos += size
if packet == ANIMATION:
animation = read_animation(data, version)
elif packet == HEADER:
h = Reader(data)
info = {key: plain_text(h.string()) for key in ("name", "description", "author")}
else:
skipped.append(packet)
if animation is None:
raise ValueError(f"{path}: no legacy animation sub-packet (maybe the newer format)")
return info, animation, skipped
def to_json(info, animation):
parts = {name: dict(channels) for name, channels in animation["parts"].items()}
body = parts.get("body", {})
for axis in BEND_AXES:
if axis in body:
parts.setdefault("torso", {})[axis] = body.pop(axis)
moves = {}
for name, channels in parts.items():
for axis, keys in channels.items():
if axis in SCALE_AXES:
continue
for tick, value, easing in keys:
move = moves.setdefault((tick, EASINGS.get(easing, "LINEAR")), {})
move.setdefault(name, {})[axis] = round(value, 6)
return {
"version": 3,
"name": info.get("name", ""),
"description": info.get("description", ""),
"author": info.get("author", ""),
"emote": {
"beginTick": animation["begin"],
"endTick": animation["end"],
"stopTick": animation["stop"],
"isLoop": animation["loop"],
"returnTick": animation["return"],
"easeBeforeKeyframe": animation["ease_before"],
"degrees": False,
"moves": [{"tick": tick, "easing": easing, **move} for (tick, easing), move in sorted(moves.items())],
},
}
def emote_id(name):
return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "emote"
def main():
parser = argparse.ArgumentParser()
parser.add_argument("files", nargs="+")
parser.add_argument("--out-dir")
args = parser.parse_args()
failed = False
for path in args.files:
try:
info, animation, skipped = read_file(path)
except Exception as e: # report and go on to the next file
print(f"{path}: {e}", file=sys.stderr)
failed = True
continue
name = info.get("name") or Path(path).stem
# The file's name, since emote names can be in any language.
out = Path(args.out_dir or Path(path).parent) / f"{emote_id(Path(path).stem)}.json"
out.write_text(json.dumps(to_json(info, animation), indent=2))
bends = sorted(n for n, c in animation["parts"].items() if "bend" in c)
scaled = sorted(n for n, c in animation["parts"].items() if any(a in c for a in SCALE_AXES))
print(f"{Path(path).name} -> {out.name}: '{name}' by {info.get('author', '?')}, "
f"ticks {animation['begin']}-{animation['end']} (stop {animation['stop']}), "
f"{'loops' if animation['loop'] else 'once'}, parts {sorted(animation['parts'])}, "
f"bends {bends or 'none'}" + (f", scale on {scaled} (dropped)" if scaled else ""))
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()