Files
saturnclient/VERSION_GUIDE.md
T
selimaj-devandclaude 46c7bacd04
Check / compile (pull_request) Successful in 16m19s
Build for Minecraft 26.3
Adds 26.3 as a Stonecutter version, the last step of #39.

Input: 26.3 replaces GLFW with SDL, which numbers keys (scancodes),
modifiers and mouse buttons differently. Saturn keeps GLFW's numbers,
which common uses and the config's keybinds store, so saved keybinds
stay valid. The new Keys class converts at the boundary: key events,
modifiers and mouse buttons coming into Saturn's screens, and key codes
going to InputConstants. Its table pairs GLFW's codes with 26.3's
InputConstants of the same name. Key names come from the game on 26.3,
and version-specific code uses InputConstants' key constants rather
than GLFW's.

Rendering: Blaze3D's API moved to renderpearl, which Stonecutter
replacements cover. PoseStack's quaternion mulPose became rotate, and
TextureTarget takes a depth format instead of a flag. 26.3 compiles
shaders through SPIR-V and reorders the transforms block, so glass.fsh
takes explicit locations and the new order behind defines that
GlassRenderer sets, and the unlit cosmetics pipeline declares its
color target, which 26.3 requires.

OptionsScreen no longer takes whether a world is open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 22:12:50 +02:00

8.7 KiB

Version guide

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

Layout

Every supported Minecraft version (1.21.4 to 1.21.11, and 26.1 to 26.3) builds from one copy of the version-specific code with Stonecutter:

  • 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), one copy for every version. Where Minecraft's API differs between versions, the code goes behind version comments (see below).
  • versions/<mc>/: each version's gradle.properties (Minecraft, Fabric Loader and Fabric API versions), plus its build/ and run/ folders. Each version is the Gradle project :<mc>.

settings.gradle.kts lists the versions in stonecutterVersions. The root build.gradle.kts builds each version, and stonecutter.gradle.kts configures the root project (shared repositories, the common wiring and buildAll). Loom's version is loomx.loom_version in the root gradle.properties.

Minecraft is obfuscated up to 1.21.11 and ships with Mojang's names from 26.1. The loom-back-compat plugin (loomx) applies the matching Loom for each version: the remapping one for 1.21.x, whose jar is remapJar's output, and the plain one for 26.x, whose jar is jar's. 26.x also compiles with Java 25 (21 before), set per version in build.gradle.kts; Gradle downloads a missing JDK through the foojay resolver, and CI installs both. An optional minecraft_compat in a version's gradle.properties widens the Minecraft versions its jar loads on, as 26.1's does for its hotfixes.

Mappings

Every version is built with Mojang's official names (loom.officialMojangMappings()), the names Minecraft itself ships from 26.1 on. Yarn isn't used any more: 1.21.11 has the last Yarn release.

Mojang renames things between versions. src/ uses the active version's (1.21.11's) names, and stonecutter.gradle.kts lists replacements that swap in each version's name when Stonecutter processes it: for example Identifier, which was ResourceLocation before 1.21.11, AvatarRenderState, which was PlayerRenderState before 1.21.9, and GuiGraphics, which is GuiGraphicsExtractor from 26.1. Add a replacement there when a rename touches many lines, and only for a name that appears nowhere else in src/: replacements work both ways, so the new name is also turned back into the old one for older versions (a mulPose/rotate replacement would rename MatrixStackRef.rotate too). From 26.2 the open screen and overlay live on Gui rather than Minecraft, so code goes through impl.ui.Screens for them.

From 26.3 Minecraft uses SDL instead of GLFW, with SDL's numbers for keys (scancodes), modifiers and mouse buttons. Saturn keeps GLFW's numbers, which common uses and the config's keybinds store, and impl.provider.Keys converts at the boundary: key events and mouse buttons coming in, and key codes going to the game. Use InputConstants' key constants rather than GLFW's in version-specific code.

26.3 also compiles every shader through SPIR-V, so shader inputs and outputs need an explicit layout(location = …). Saturn's glass.fsh is shared by every version from 1.21.6 and switches its layout with the defines GlassRenderer sets, as it already did for HAS_LINE_WIDTH. A class that only moved package needs just a version comment on its import, and a renamed method a version comment where it's called.

Released 1.21.x jars run on intermediary names (method_1234), not Mojang's, and Loom renames everything that refers to Minecraft when it builds them. So a mixin can't implement one of common's Ref interfaces by relying on a vanilla method that happens to have the same name: the vanilla method is renamed in the jar, and the interface method is left without an implementation (AbstractMethodError). Shadow the vanilla method and implement the Ref method explicitly, as WindowMixin does. For the same reason, Ref methods mustn't share a name and parameters with a method of the class they're mixed into, or the mixin replaces the vanilla method in development.

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 submit(PoseStack matrices, SubmitNodeCollector output, int light, AvatarRenderState state, float limbAngle, float limbDistance) {
//?} else
//public void render(PoseStack matrices, MultiBufferSource output, int light, AvatarRenderState 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.renderer.rendertype.RenderTypes;
import net.minecraft.client.renderer.rendertype.RenderSetup;
//?} else {
/*import net.minecraft.client.renderer.RenderStateShard.MultiTextureStateShard;
*///?}

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. The panorama in SaturnScreenFabric.render is an example. Search for TODO to find what newer versions still lack (tracked in #30), and remove the condition once a version gap is fixed.

A whole class that only exists for some versions (such as MatrixStackMixin, which only makes sense before 1.21.6's GUI rewrite) can keep an empty class body on the other versions. Before splitting a class like that, look for a hook that works on every version: nametags used to hook label rendering, which 1.21.9 changed, and now replace the name while the render state is built (LivingEntityRendererMixin), with no version condition at all. 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. Version blocks can nest (Stonecutter writes an inactive block inside another as /^ … ^/, as in GlassRenderer), but for more than two ranges of the same thing, prefer //?} else if <condition> {, 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.

Checking a change on every version

Since every version shares src/, a change applies to all of them at once. Code that uses the Minecraft API may still only compile on some, so build them all:

./gradlew compileJava

The Check workflow runs the same compile on every pull request, so a version a change doesn't compile on fails before merging. Mixins whose target changed fail at startup rather than at compile time, so launch the versions a change touches with ./gradlew :<mc>:runClient.

For bigger changes, ./gradlew buildAll builds every version's jar into build/allJars/.

Adding a Minecraft version

  1. Create versions/<new>/gradle.properties with minecraft_version, 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 (or when its class first loads, such as in a world), 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.