Files
saturnclient/VERSION_GUIDE.md
T
selimaj-devandclaude 6f122b5237 Build 1.21.7 with Stonecutter from the shared src/
Step 5 of #8. versions/1.21.7/src is gone; 1.21.7 builds from src/ as
:1.21.7.

1.21.7's copy differed from 1.21.8's in the player render state: Fabric
API's RenderStateDataKey only exists from 1.21.8 (fabric-rendering-v1
12.6.0), so 1.21.7 had no updateRenderState hook, no SaturnRenderState,
and looked players up by name for the cloak and hat. That stays the case:
- The cloak and hat use getData on >=1.21.8 and SaturnPlayer.get(name)
  before that.
- PlayerEntityRendererMixin's updateRenderState exists only on >=1.21.8,
  as flat ranges (>=1.21.9, else if >=1.21.8) calling a shared
  saturn$applyPlayer helper, instead of nested version blocks.
- SaturnRenderState's key only exists on >=1.21.8; the class is empty
  before.
- TODO: 1.21.6 and 1.21.7 still don't show the role icon in names.

1.21.7's NameTagMixin isn't carried over: it wasn't registered in any
mixin config, so it never ran.

The 1.21.7 jar differs from the old one only as 1.21.8's did in step 4
(entityAlpha and drawGroup helpers, getIconId, and the cloak getting its
buffer at the draw call), plus the empty SaturnRenderState and the
missing dead NameTagMixin. 1.21.8-1.21.11 only differ in
PlayerEntityRendererMixin (the helper).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 05:46:41 +02:00

6.3 KiB

Version guide

How Saturn Client supports several Minecraft versions, and how to change or add one.

Layout

Saturn is moving from one full copy of the version-specific code per Minecraft version to a single copy built with Stonecutter (issue #8). During the move there are two kinds of version:

  • common/: all version-independent code (mods, UI, cosmetics, the server client). It has no Minecraft dependency and reaches the game only through the interfaces in org.saturnclient.common.
  • src/: the version-specific code (providers, refs, mixins and the three mixin configs) for the versions built by Stonecutter, currently 1.21.7 to 1.21.11. It's one copy: where Minecraft's API differs between them, the code goes behind version comments (see below). Each of these versions is the Gradle project :<mc>, and versions/<mc>/ holds only its gradle.properties (Minecraft, Yarn, Fabric Loader and Fabric API versions), build/ and run/.
  • versions/<mc>/src/: the versions not moved yet (1.21.4 to 1.21.6), each a full copy, as the Gradle project :mc-<mc> with its own build.gradle.

settings.gradle.kts lists the Stonecutter versions in stonecutterVersions and includes every other folder under versions/ as :mc-<mc>. The root build.gradle.kts builds each Stonecutter version, and stonecutter.gradle.kts configures the root project (shared repositories, the common wiring and buildAll). Loom's version for the Stonecutter versions is loom_version in the root gradle.properties.

Version-specific code with Stonecutter

The code in src/ is always plain Java for the active version (1.21.11, set in stonecutter.gradle.kts). Code for other versions sits in comments that Stonecutter swaps before building each version:

//? if >=1.21.9 {
public void render(MatrixStack matrices, OrderedRenderCommandQueue queue, int light, PlayerEntityRenderState state, float limbAngle, float limbDistance) {
//?} else
//public void render(MatrixStack matrices, VertexConsumerProvider consumers, int light, PlayerEntityRenderState state, float limbAngle, float limbDistance) {

A condition without braces covers the next line only. For several lines, use braces and wrap the inactive block in /* … */:

//? if >=1.21.11 {
import net.minecraft.client.render.RenderLayers;
import net.minecraft.client.render.RenderSetup;
//?} else {
/*import net.minecraft.client.render.RenderPhase.Textures;
*///?}

Stonecutter rewrites inactive code into its own layout when switching versions (a single inactive line becomes //code), so write it that way to begin with, or run a switch and a reset before committing.

When a newer version leaves something out that an older one implements, keep the older version's code behind a condition and mark the newer branch with a TODO, rather than dropping it for every version. WorldFeatureImpl.getWorldAge and SaturnScreenFabric.resize are examples. Search for TODO to find what newer versions still lack.

A whole class that only exists for some versions (such as NametagsMixin, which hooks a method that 1.21.9 changed) can keep an empty class body on the other versions. An empty mixin changes nothing, and it keeps the mixin configs the same for every version, since JSON can't hold version comments.

Inactive code sits inside a /* … */ comment, so it can't contain /* … */ comments itself: use // comments in code that's only active for some versions. For the same reason, don't nest multi-line version blocks; use //?} else if <condition> { for more than two version ranges, as in PlayerEntityRendererMixin and SaturnRenderState.

  • ./gradlew "Set active project to <mc>" (or the Stonecutter IntelliJ plugin) rewrites src/ so that version's code is the uncommented one, for editing it in the IDE.
  • ./gradlew "Reset active project" switches back to 1.21.11. Run it before committing, so src/ is always committed with 1.21.11 active.
  • Each version's processed copy is compiled from versions/<mc>/build/generated/stonecutter/, which is also where to look when a non-active version fails to compile.

The emotes repository uses the same setup, and its BentCubeRenderer and PlayerModelMixin are small examples of version comments.

Porting a change to the other versions

Make the change in one version, usually the newest, then run:

scripts/port.sh                        # port uncommitted changes to every other version
scripts/port.sh --dry-run              # see what would apply cleanly first
scripts/port.sh --to 1.21.5,1.21.6     # only some versions
scripts/port.sh --commit <rev>         # port what a commit changed instead

The script applies the diff under one version's source to each other version with git apply --3way. The Stonecutter src/ counts as one version (1.21.11), so changes port between it and the old copies in both directions:

  • applied: the files matched, and nothing else is needed.
  • CONFLICTS: that version's code really differs. The file gets normal conflict markers, with the version's own code in ours and the ported change in theirs. Adapt the change to that version's API and resolve.
  • already has this change: nothing to do, which is safe to see when re-running.

Only source is ported. build.gradle and gradle.properties are version-specific on purpose. Then build everything:

./gradlew compileJava

The Check workflow runs the same compile on every pull request, so a version that was missed or mis-ported fails before merging.

Adding a Minecraft version

Add new versions to Stonecutter rather than copying a folder:

  1. Create versions/<new>/gradle.properties with minecraft_version, yarn_mappings, loader_version and fabric_api_version (copy 1.21.11's and change the values; they're listed at https://fabricmc.net/develop). Loom's version is shared, in the root gradle.properties.
  2. Add <new> to stonecutterVersions in settings.gradle.kts.
  3. Build it with ./gradlew :<new>:compileJava and put what Minecraft changed behind version comments. Check the mixin configs in src/main/resources/*.mixins.json as well: a mixin whose target changed fails at startup, not at compile time, so launch it with ./gradlew :<new>:runClient.
  4. The release workflow builds everything through buildAll, and "Publish to Modrinth" takes the game version from each jar's name, so neither needs changing.