- PlayerModelMixin: after translateToHand, move the item from the hand of the straight arm to the hand of the bent one (BendTransforms.attachAt, the same rigid move as the ring at the hand). PlayerModel overrides translateToHand for slim arms without calling HumanoidModel's, so the hook has to be on PlayerModel. - FirstPersonHandMixin: the first-person arm is drawn from the shared model without setupAnim, so clear any bend left by the last third-person player. - LimbBend.start()/end() getters. - testmod: a hold animation, and gametest shots holding a sword and in first person. Co-Authored-By: Claude Opus 5.5 <[email protected]>
emotes
A player emote library for Saturn Client, with limb bending.
Layout
core/: Minecraft-independent animation model (Bone,BoneTransform,Pose,Animation,AnimationPlayer). Plain Java 21 with JUnit tests. Published asorg.saturnclient:emotes-core.src/main/: the Fabric mod that puts poses on player models. It's built once per Minecraft version with Stonecutter and published asorg.saturnclient:emotes-fabric:<version>+<mc>, withcorebundled inside the jar.src/testmod/: a dev-only tester mod that is never published. It adds/emotes play <name>,/emotes stopand/emotes info. Press F5 to see yourself. It also has a Fabric client gametest that creates a world and screenshots each animation from the front and side, intoversions/<mc>/build/run/clientGameTest/screenshots/.
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.
How it hooks in
LivingEntityRendererMixinsamples the entity's current pose into its render state (players only).HumanoidModelMixinapplies that pose at the end ofHumanoidModel.setupAnim.PlayerModeland the armor layers both go through this method, so armor follows the pose.
Both hook points exist unchanged in every supported version. Version-specific code goes behind
Stonecutter comments, like the vertex accessors in BentCubeRenderer:
//? if >=1.21.9 {
return new float[] { vertex.x(), vertex.y(), vertex.z(), vertex.u(), vertex.v() };
//?} else
/*return new float[] { vertex.pos().x(), vertex.pos().y(), vertex.pos().z(), vertex.u(), vertex.v() };*/
Bending
Each limb bends along a single axis (BoneTransform.bend, bendAxis). It works like the cloak
renderer: build a spine, put a ring of shared vertices along it, and draw the faces between the rings.
LimbBend(core) runs the spine along a cubic Bézier (Bezier). The control points are the rigid "upper half, joint, lower half" shape, andsharpnessgoes from a round curve (0) to a tight joint (1). The spine is cut into one ring per texture row (12 per limb), spaced evenly by arc length, so the limb keeps its length. Each ring moves its cross-section rigidly, so neighbouring pieces share vertices and the mesh stays closed.QuadSplitter(core) cuts each vanilla face into strips at the rings and splits its UVs to match. The texture is unchanged apart from the cuts.ModelPartMixinswaps inBentCubeRendererfor any part with a bend. That renderer deforms vanilla's own cubes, so slim arms, sleeves, pants and armor all work. Overlays and armor use the limb's spine, not their own inflated box, so they stay attached.
Axis 0 folds towards the front (-Z) and PI/2 towards +X, in the limb's own space. Knees fold back, so they use axis PI.
The torso is anchored at the hips instead of its pivot (LimbBend.anchoredAtEnd), so the legs stay
attached and the chest leans. The head and arms are siblings of the body in the vanilla model, so
PoseApplier carries them: their pivots follow the bent torso, and they turn with its ring there
(LimbBend.angleAt). An arm carried by a forward lean swings back like a diver's, so emotes that want
hanging arms tilt them forward by about the lean (see the twerk test animation).
Held items still attach to the unbent arm, and the cape doesn't follow the torso yet.
Commands
./gradlew :core:test # core unit tests
./gradlew :1.21.11:runTestmodClient # launch the game with the tester (run dir: ./run)
./gradlew :1.21.11:runClientGameTest # screenshot each test animation, then quit
./gradlew :1.21.4:compileTestmodJava # quick compile check for one version
./gradlew buildAll # every version's jar → build/libs/<version>/
GITEA_TOKEN=... ./gradlew publish # publish core + every version to Gitea
The active version is set in stonecutter.gradle.kts (stonecutter active "..."), and it's the
version whose code is uncommented in src/. Switch versions with the Stonecutter IntelliJ plugin
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.
Publishing
Artifacts go to the saturnclientmc organization's Maven registry:
https://git.selimaj.dev/api/packages/saturnclientmc/maven. Use a token with package:write, set
as GITEA_TOKEN or -PgiteaToken=....