"""Converts Emotecraft's binary .emotecraft files to the Emotecraft JSON our EmotecraftLoader reads. python3 convert.py ... [--out-dir DIR] Writes .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()