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
+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()