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:
@@ -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()
|
||||
Reference in New Issue
Block a user