Compare commits

...
5 Commits
Author SHA1 Message Date
selimaj-dev 2aa740ba6c Merge pull request 'Build for Minecraft 26.1, 26.2 and 26.3' (#8) from mc-26 into master
Reviewed-on: #8
2026-09-30 16:41:02 +00:00
selimaj-devandclaude c92abd4401 Bump the version to 0.1.2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 18:40:11 +02:00
selimaj-devandclaude 9d0fe6fb6b Build for Minecraft 26.1, 26.2 and 26.3
Adds the three drops as Stonecutter versions (26.1's jar also accepts
its hotfixes). loom-back-compat already picks the plain Loom for 26.x;
the testmod's remap configurations are only created where there's
something to remap, and the foojay resolver downloads the Java 25
toolchain these versions need.

- 26.3 renamed PoseStack.mulPose(Quaternionfc) to rotate.
- The testmod follows Fabric API's renames: ClientCommands (26.1),
  getClientLevel (26.1) and getConnection (26.2) for waiting on chunks.
- 26.2 replaced Options.hideGui with the HUD's toggle, so the gametest
  toggles it through a helper.

The screenshot gametest passes on 26.1, 26.2 and 26.3, and the bent
limbs match 1.21.11's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 18:40:11 +02:00
selimaj-dev 8794f3fb65 Merge pull request 'Read binary Emotecraft emotes and fix the torso's bend direction' (#7) from emotecraft-binary into master
Reviewed-on: #7
2026-09-29 15:41:20 +00:00
selimaj-devandclaude 970e9aef51 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]>
2026-09-29 17:37:18 +02:00
12 changed files with 302 additions and 19 deletions
+13 -6
View File
@@ -19,9 +19,13 @@ 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.
Supported versions: 1.21.4 to 1.21.11, 26.1 (and its hotfixes), 26.2 and 26.3. Each version's
Fabric API version is set in `stonecutter.properties.toml`. The code uses Mojang mappings, which
26.1+ ships as its own names, so `loom-back-compat` applies the remapping Loom for 1.21.x and the
plain one for 26.x. 26.x builds need Java 25; Gradle downloads it when it isn't installed.
## Using it
@@ -47,8 +51,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
@@ -109,8 +116,8 @@ version whose code is uncommented in `src/`. Switch versions with the Stonecutte
or the `Set active project to <v>` Gradle task. Run `Reset active project` before committing, so
the tree is committed with `vcsVersion` (1.21.11) active.
Loom 1.18 needs a Java 25 JVM to run Gradle, so the build is pinned to Loom 1.17 until the
toolchain moves. Minecraft 26.x will need that upgrade.
Loom 1.18 needs a Java 25 JVM to run Gradle, so the build is pinned to Loom 1.17, which builds
26.x as well (with a Java 25 toolchain for those versions only).
## Publishing
+2 -1
View File
@@ -22,7 +22,8 @@ val testmod: SourceSet = sourceSets.create("testmod") {
}
loom {
createRemapConfigurations(testmod)
// The testmod's mod* configurations; 26.1+ has nothing to remap, and loomx aliases them there.
if (!loomx.isUnobfuscated) createRemapConfigurations(testmod)
mods {
register("saturn_emotes") { sourceSet(sourceSets.main.get()) }
@@ -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.
+3 -1
View File
@@ -9,6 +9,8 @@ pluginManagement {
}
plugins {
// Downloads the Java toolchain a version needs (Java 25 for 26.1+) when it isn't installed.
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
id("dev.kikugie.stonecutter") version "0.9.8"
// Picks the right Loom variant per Minecraft version (remapping for 1.21.x, plain for 26.1+).
id("dev.kikugie.loom-back-compat") version "0.4.2"
@@ -17,7 +19,7 @@ plugins {
stonecutter {
create(rootProject) {
// Same range as Saturn Client. Per-version dependencies live in stonecutter.properties.toml.
versions("1.21.4", "1.21.5", "1.21.6", "1.21.7", "1.21.8", "1.21.9", "1.21.10", "1.21.11")
versions("1.21.4", "1.21.5", "1.21.6", "1.21.7", "1.21.8", "1.21.9", "1.21.10", "1.21.11", "26.1", "26.2", "26.3")
vcsVersion = "1.21.11"
}
}
@@ -22,7 +22,11 @@ public final class BendTransforms {
bend.bendPoint(0, y, 0, center);
Vec3 axis = bend.axis();
poseStack.translate(center[0] / 16, center[1] / 16, center[2] / 16);
poseStack.mulPose(new Quaternionf().rotationAxis(bend.angleAt(y), axis.x(), axis.y(), axis.z()));
Quaternionf rotation = new Quaternionf().rotationAxis(bend.angleAt(y), axis.x(), axis.y(), axis.z());
//? if >=26.3 {
/*poseStack.rotate(rotation);
*///?} else
poseStack.mulPose(rotation);
poseStack.translate(0, -y / 16, 0);
}
}
@@ -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.2";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
private static final EmoteRegistry REGISTRY = new EmoteRegistry();
@@ -7,6 +7,7 @@ import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestSingleplayerContext;
import net.minecraft.client.CameraType;
import net.minecraft.client.Minecraft;
/**
* Screenshots each test animation at set points, from the front, the side and behind.
@@ -50,9 +51,14 @@ public final class EmotesGameTest implements FabricClientGameTest {
@Override
public void runTest(ClientGameTestContext context) {
try (TestSingleplayerContext singleplayer = context.worldBuilder().create()) {
//? if >=26.2 {
/*singleplayer.getConnection().waitForChunksRender();
*///?} else if >=26.1 {
/*singleplayer.getClientLevel().waitForChunksRender();
*///?} else
singleplayer.getClientWorld().waitForChunksRender();
context.runOnClient(client -> {
client.options.hideGui = true;
setHudHidden(client, true);
client.options.setCameraType(CameraType.THIRD_PERSON_FRONT);
});
context.waitTicks(20);
@@ -80,7 +86,7 @@ public final class EmotesGameTest implements FabricClientGameTest {
singleplayer.getServer().runCommand("clear @a");
context.waitTicks(2);
context.runOnClient(client -> {
client.options.hideGui = false;
setHudHidden(client, false);
client.options.setCameraType(CameraType.FIRST_PERSON);
});
// Give the hand time to come back up after the item change.
@@ -88,11 +94,21 @@ public final class EmotesGameTest implements FabricClientGameTest {
context.takeScreenshot(MINECRAFT + "-hold-firstperson");
context.runOnClient(client -> {
Emotes.stop(client.player.getUUID());
client.options.hideGui = true;
setHudHidden(client, true);
});
}
}
private static void setHudHidden(Minecraft client, boolean hidden) {
// From 26.2 the HUD can only be toggled.
//? if >=26.2 {
/*if (client.gui.hud.isHidden() != hidden) {
client.gui.hud.toggle();
}
*///?} else
client.options.hideGui = hidden;
}
/** Turns the body (not the camera) by {@code turn} degrees, then takes a screenshot. */
private static void screenshot(ClientGameTestContext context, String name, float turn) {
context.runOnClient(client -> {
@@ -1,7 +1,12 @@
package org.saturnclient.emotes.testmod;
//? if >=26.1 {
/*import static net.fabricmc.fabric.api.client.command.v2.ClientCommands.argument;
import static net.fabricmc.fabric.api.client.command.v2.ClientCommands.literal;
*///?} else {
import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument;
import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal;
//?}
import java.io.IOException;
import java.io.InputStream;
+14 -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.2"
deps.fabric_loader = "0.19.5"
loomx.loom_version = "1.17.21"
@@ -38,3 +38,16 @@ deps.fabric_api = "0.138.4+1.21.10"
["1.21.11"]
mod.mc_compat = "1.21.11"
deps.fabric_api = "0.141.6+1.21.11"
# Built against 26.1 and allowed on its hotfixes (26.1.1, 26.1.2).
["26.1"]
mod.mc_compat = "~26.1"
deps.fabric_api = "0.145.1+26.1"
["26.2"]
mod.mc_compat = "26.2"
deps.fabric_api = "0.161.0+26.2"
["26.3"]
mod.mc_compat = "26.3"
deps.fabric_api = "0.161.0+26.3"
+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()