diff --git a/README.md b/README.md index 54b960f..dbfd86e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ A player emote library for Saturn Client, with limb bending. `versions//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 diff --git a/core/src/main/java/org/saturnclient/emotes/core/format/EmotecraftLoader.java b/core/src/main/java/org/saturnclient/emotes/core/format/EmotecraftLoader.java index 4ac34e6..35bd82b 100644 --- a/core/src/main/java/org/saturnclient/emotes/core/format/EmotecraftLoader.java +++ b/core/src/main/java/org/saturnclient/emotes/core/format/EmotecraftLoader.java @@ -31,7 +31,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; * subtracted to get an offset. *
  • 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.
  • - *
  • Emotecraft bends the other way, so {@code bend} changes sign.
  • + *
  • 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).
  • + *
  • The whole player can't bend. In Emotecraft its {@code bend} and {@code axis} bend the torso, + * so they go to {@link Bone#BODY}.
  • *
  • Angles are in degrees unless {@code "degrees": false}.
  • *
  • {@code turn} adds whole turns to the move's angles, for spins and flips.
  • *
  • 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 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); }; } diff --git a/core/src/test/java/org/saturnclient/emotes/core/format/EmotecraftLoaderTest.java b/core/src/test/java/org/saturnclient/emotes/core/format/EmotecraftLoaderTest.java index 44c276c..28f53bf 100644 --- a/core/src/test/java/org/saturnclient/emotes/core/format/EmotecraftLoaderTest.java +++ b/core/src/test/java/org/saturnclient/emotes/core/format/EmotecraftLoaderTest.java @@ -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. diff --git a/src/main/java/org/saturnclient/emotes/fabric/Emotes.java b/src/main/java/org/saturnclient/emotes/fabric/Emotes.java index b5e2ca0..e0a9e6a 100644 --- a/src/main/java/org/saturnclient/emotes/fabric/Emotes.java +++ b/src/main/java/org/saturnclient/emotes/fabric/Emotes.java @@ -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(); diff --git a/stonecutter.properties.toml b/stonecutter.properties.toml index 1dc699e..baaea76 100644 --- a/stonecutter.properties.toml +++ b/stonecutter.properties.toml @@ -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" diff --git a/tools/capture/fit.py b/tools/capture/fit.py index 6759d09..7fe3a6e 100644 --- a/tools/capture/fit.py +++ b/tools/capture/fit.py @@ -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 diff --git a/tools/emotecraft/convert.py b/tools/emotecraft/convert.py new file mode 100644 index 0000000..e038586 --- /dev/null +++ b/tools/emotecraft/convert.py @@ -0,0 +1,211 @@ +"""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()