Deduplicate the per-version source trees (e.g. Stonecutter) #8

Closed
opened 2026-09-25 11:43:42 +00:00 by selimaj-dev · 3 comments
Owner

Problem

Each supported version (versions/1.21.4 … versions/1.21.11) is a full copy of the version-specific code: the impl/ providers, the feature implementations and all three mixin packages. Every fix has to be repeated eight times, the copies drift apart unnoticed (for example the TabListMixin bug), and searching or debugging means working out which of eight copies you're in.

Measured on master (2026-09-27):

Java per version ~3,400–3,600 lines, 27,700 lines in total
Distinct files 51
Identical in all 8 versions 20
Only 1–2 variants across the 8 39 of 51
Lines after removing duplicates ~6,300 at most
Change Files Lines changed
1.21.4 → 1.21.5 11 158
1.21.5 → 1.21.6 11 855 (GUI rendering rewrite; 601 lines are RenderScopeImpl)
1.21.6 → 1.21.7 1 24
1.21.7 → 1.21.8 3 133
1.21.8 → 1.21.9 13 252 (render command queue)
1.21.9 → 1.21.10 0 0: identical copies
1.21.10 → 1.21.11 15 136

Why Stonecutter now

This was closed to wait for smoother tooling. Stonecutter 0.9 has been used in the emotes repo since it started: one src/ with //? if >=1.21.9 { … } comments where the API differs, built for all 8 versions with Loom 1.17, and the Stonecutter IntelliJ plugin to switch the active version. See emotes/src/main/java/org/saturnclient/emotes/fabric/BentCubeRenderer.java and mixin/PlayerModelMixin.java for what the conditionals look like. It works with Yarn, so Saturn keeps its mappings.

Target layout (same flow as emotes)

  • settings.gradle: include("common") as today, plus a Stonecutter tree on the root project with the 8 versions and vcsVersion = "1.21.11".
  • stonecutter.properties.toml: each version's Yarn, Fabric API and loader versions, replacing versions/*/gradle.properties.
  • src/main/: the one copy of the version-specific code, mixin JSONs and fabric.mod.json. versions/<v>/ only holds build output.
  • stonecutter.gradle.kts: swaps (for example the Minecraft version string) and replacements for renames.
  • buildAll still produces one jar per version in build/allJars/.
  • The workflow becomes: edit with 1.21.11 active, switch with Set active project to <v>, and run Reset active project before committing, ideally enforced by a pre-commit check.

Plan

  1. Scaffold. Set up Stonecutter with 1.21.11 only: move its tree into src/, move its properties into the TOML, and check that the jar matches today's (same classes, mixins and resources) and runs.
  2. 1.21.9 and 1.21.10. These are identical to each other and 15 files away from 1.21.11. Add conditionals, then delete their versions/*/src.
  3. 1.21.6–1.21.8. Up to 1.21.9: 17 files, mostly the 1.21.9 render queue (CloakFeatureRenderer, HatFeatureRenderer, ShaderUtils, PlayerEntityRendererMixin).
  4. 1.21.4 and 1.21.5, behind the 1.21.6 GUI rewrite.
  5. The UI files (the hard part). impl/ui/RenderScopeImpl (412 lines), SaturnScreenFabric and EntityDrawerImpl each have 5 variants. Don't scatter conditionals through them. Keep two whole implementations, before and after 1.21.6, each behind one file-wide condition or split into two classes chosen per version, with small conditionals inside for the rest.
  6. Clean up. Remove versions/*/src and the per-version gradle.properties, and update CLAUDE.md and VERSION_GUIDE.md.

Each step is its own PR, and the untouched versions keep building from their old trees until they're folded in.

Testing

Saturn is much bigger than one feature, so every step needs:

  • buildAll builds, and each converted version's jar has the same class list and mixin configs as before (diff the jar listings).
  • A client gametest like emotes' runClientGameTest, checking every version without any input:
    • a mixin audit at startup, so a broken target fails immediately
    • screenshots of the title menu, shift menu, mod menu, HUD editor, cosmetics menus, emote wheel and a player with cloak and hat in a world
  • A manual check in game per version before merging each step.

Not part of this

Moving to Mojang mappings and Java 25 for Minecraft 26.x, since Yarn ends at 1.21.11. That's a separate decision, and it's easier after this, with one tree to convert instead of eight.

## Problem Each supported version (`versions/1.21.4` … `versions/1.21.11`) is a full copy of the version-specific code: the `impl/` providers, the feature implementations and all three mixin packages. Every fix has to be repeated eight times, the copies drift apart unnoticed (for example the `TabListMixin` bug), and searching or debugging means working out which of eight copies you're in. Measured on master (2026-09-27): | | | |---|---| | Java per version | ~3,400–3,600 lines, **27,700 lines** in total | | Distinct files | 51 | | Identical in all 8 versions | **20** | | Only 1–2 variants across the 8 | **39 of 51** | | Lines after removing duplicates | ~**6,300** at most | | Change | Files | Lines changed | |---|---|---| | 1.21.4 → 1.21.5 | 11 | 158 | | 1.21.5 → 1.21.6 | 11 | 855 (GUI rendering rewrite; 601 lines are `RenderScopeImpl`) | | 1.21.6 → 1.21.7 | 1 | 24 | | 1.21.7 → 1.21.8 | 3 | 133 | | 1.21.8 → 1.21.9 | 13 | 252 (render command queue) | | 1.21.9 → 1.21.10 | **0** | 0: identical copies | | 1.21.10 → 1.21.11 | 15 | 136 | ## Why Stonecutter now This was closed to wait for smoother tooling. Stonecutter 0.9 has been used in the `emotes` repo since it started: one `src/` with `//? if >=1.21.9 { … }` comments where the API differs, built for all 8 versions with Loom 1.17, and the Stonecutter IntelliJ plugin to switch the active version. See `emotes/src/main/java/org/saturnclient/emotes/fabric/BentCubeRenderer.java` and `mixin/PlayerModelMixin.java` for what the conditionals look like. It works with Yarn, so Saturn keeps its mappings. ## Target layout (same flow as emotes) - `settings.gradle`: `include("common")` as today, plus a Stonecutter tree on the root project with the 8 versions and `vcsVersion = "1.21.11"`. - `stonecutter.properties.toml`: each version's Yarn, Fabric API and loader versions, replacing `versions/*/gradle.properties`. - `src/main/`: the one copy of the version-specific code, mixin JSONs and `fabric.mod.json`. `versions/<v>/` only holds build output. - `stonecutter.gradle.kts`: swaps (for example the Minecraft version string) and replacements for renames. - `buildAll` still produces one jar per version in `build/allJars/`. - The workflow becomes: edit with 1.21.11 active, switch with `Set active project to <v>`, and run `Reset active project` before committing, ideally enforced by a pre-commit check. ## Plan 1. **Scaffold.** Set up Stonecutter with 1.21.11 only: move its tree into `src/`, move its properties into the TOML, and check that the jar matches today's (same classes, mixins and resources) and runs. 2. **1.21.9 and 1.21.10.** These are identical to each other and 15 files away from 1.21.11. Add conditionals, then delete their `versions/*/src`. 3. **1.21.6–1.21.8.** Up to 1.21.9: 17 files, mostly the 1.21.9 render queue (`CloakFeatureRenderer`, `HatFeatureRenderer`, `ShaderUtils`, `PlayerEntityRendererMixin`). 4. **1.21.4 and 1.21.5**, behind the 1.21.6 GUI rewrite. 5. **The UI files** (the hard part). `impl/ui/RenderScopeImpl` (412 lines), `SaturnScreenFabric` and `EntityDrawerImpl` each have 5 variants. Don't scatter conditionals through them. Keep two whole implementations, before and after 1.21.6, each behind one file-wide condition or split into two classes chosen per version, with small conditionals inside for the rest. 6. **Clean up.** Remove `versions/*/src` and the per-version `gradle.properties`, and update `CLAUDE.md` and `VERSION_GUIDE.md`. Each step is its own PR, and the untouched versions keep building from their old trees until they're folded in. ## Testing Saturn is much bigger than one feature, so every step needs: - `buildAll` builds, and each converted version's jar has the same class list and mixin configs as before (diff the jar listings). - A client gametest like `emotes`' `runClientGameTest`, checking every version without any input: - a mixin audit at startup, so a broken target fails immediately - screenshots of the title menu, shift menu, mod menu, HUD editor, cosmetics menus, emote wheel and a player with cloak and hat in a world - A manual check in game per version before merging each step. ## Not part of this Moving to Mojang mappings and Java 25 for Minecraft 26.x, since Yarn ends at 1.21.11. That's a separate decision, and it's easier after this, with one tree to convert instead of eight.
Author
Owner

Closing: after evaluating Stonecutter and alternatives, we're keeping separate version modules.

common already holds ~70% of the client code (mods, UI, cosmetics) as a single codebase. The per-version layer is thin adapters (~3.4k lines each) that change mainly with Minecraft itself. Comment-based preprocessors like Stonecutter hide inactive versions from the IDE and compiler, and that costs more than the duplication they remove. Instead, the duplication will be made cheaper with tooling, tracked separately:

  • a port script: a change made in one version is applied to the others with git apply --3way, so conflicts appear only where a version really differs
  • a PR compile check: all versions must build before a merge

Reopen when a single-codebase tool exists that meets all of:

  1. Structured conditionals. They attach to Java items (e.g. @Cfg("mc >= 1.21.6") on classes, methods and fields), not to text in comments. All code stays syntactically valid Java.
  2. IDE awareness. An IDE plugin evaluates conditions for the active version, greys out inactive items without reporting errors in them (like rust-analyzer does for #[cfg]), and switching versions doesn't rewrite source files.
  3. Build support. It produces every version from one tree, handles imports used only by inactive items, and generates the per-version mixin configs.
  4. Proven safe. A migration of this repo gives the same bytecode per version as the split modules, ignoring line numbers.

…or if the per-version layer grows larger than common, or ports regularly go wrong despite the port tooling.

Closing: after evaluating Stonecutter and alternatives, we're keeping separate version modules. `common` already holds ~70% of the client code (mods, UI, cosmetics) as a single codebase. The per-version layer is thin adapters (~3.4k lines each) that change mainly with Minecraft itself. Comment-based preprocessors like Stonecutter hide inactive versions from the IDE and compiler, and that costs more than the duplication they remove. Instead, the duplication will be made cheaper with tooling, tracked separately: - **a port script:** a change made in one version is applied to the others with `git apply --3way`, so conflicts appear only where a version really differs - **a PR compile check:** all versions must build before a merge **Reopen when** a single-codebase tool exists that meets all of: 1. **Structured conditionals.** They attach to Java items (e.g. `@Cfg("mc >= 1.21.6")` on classes, methods and fields), not to text in comments. All code stays syntactically valid Java. 2. **IDE awareness.** An IDE plugin evaluates conditions for the active version, greys out inactive items without reporting errors in them (like rust-analyzer does for `#[cfg]`), and switching versions doesn't rewrite source files. 3. **Build support.** It produces every version from one tree, handles imports used only by inactive items, and generates the per-version mixin configs. 4. **Proven safe.** A migration of this repo gives the same bytecode per version as the split modules, ignoring line numbers. …or if the per-version layer grows larger than `common`, or ports regularly go wrong despite the port tooling.
Author
Owner

The tooling mentioned above is tracked in #17 (port script) and #18 (compile check on pull requests).

The tooling mentioned above is tracked in #17 (port script) and #18 (compile check on pull requests).
selimaj-dev added the area/buildarea/versions
type
refactor
labels 2026-09-27 01:54:51 +00:00
selimaj-dev added this to the Saturn Client project 2026-09-27 01:58:54 +00:00
selimaj-dev moved this to To Do in Saturn Client on 2026-09-27 01:59:10 +00:00
selimaj-dev moved this to In Progress in Saturn Client on 2026-09-27 02:01:13 +00:00
Author
Owner

Progress and working rules:

  • Branches: every step is a PR into the stonecutter integration branch. stonecutter goes to master in one PR once every version is folded in and buildAll, CI and the in-game checks pass, so master never has a half-migrated build.
  • Step 1 (1.21.11 scaffold, Kotlin build scripts): #24.
  • Found on the way: buildAll ran all eight remapJar tasks at once, which doesn't fit in the 3 GB Gradle heap. A cold build stalled in garbage collection, and earlier runs failed. #24 limits it to two at a time, and a cold build now takes about 2 minutes.
  • When folding in a version: don't take 1.21.11 as the reference; 1.21.4 is the most complete implementation. Diff each version for logic, not just API changes. Where an older version implements something a newer one leaves out or comments out, bring it into src/, behind version comments if the API differs, so no version loses a feature.
Progress and working rules: - **Branches:** every step is a PR into the `stonecutter` integration branch. `stonecutter` goes to `master` in one PR once every version is folded in and `buildAll`, CI and the in-game checks pass, so `master` never has a half-migrated build. - **Step 1** (1.21.11 scaffold, Kotlin build scripts): #24. - **Found on the way:** `buildAll` ran all eight `remapJar` tasks at once, which doesn't fit in the 3 GB Gradle heap. A cold build stalled in garbage collection, and earlier runs failed. #24 limits it to two at a time, and a cold build now takes about 2 minutes. - **When folding in a version:** don't take 1.21.11 as the reference; 1.21.4 is the most complete implementation. Diff each version for logic, not just API changes. Where an older version implements something a newer one leaves out or comments out, bring it into `src/`, behind version comments if the API differs, so no version loses a feature.
selimaj-dev moved this to Done in Saturn Client on 2026-09-27 04:51:46 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: saturnclientmc/saturnclient#8