Fight enemies back through grass and other similar obstructions!
Point at a mob standing in tall grass, in flowers, behind a torch — anything without real collision — and CleanCut hits the mob instead of swinging at the plant. The same applies to right-clicking: you can shear the sheep standing in the wheat. It leaves alone the things you didn't mean to hit: your tamed pets, anything on your team, and whatever you're currently riding.
Client-side only. It does nothing on a server and doesn't need to be installed on one.
Every loader and every Minecraft version is built from this one branch.
Every Minecraft release each loader ever shipped for, from 1.14.4 to the current snapshot — 95 jars in all.
| Loader | Minecraft versions |
|---|---|
| Fabric | 1.14.4, 1.15–1.15.2, 1.16–1.16.5, 1.17–1.17.1, 1.18–1.18.2, 1.19–1.19.4, 1.20–1.20.6, 1.21–1.21.11, 26.1–26.1.2, 26.2, 26.3-snapshot-7 |
| Quilt | same jars as Fabric |
| Forge | 1.14.4, 1.15–1.15.2, 1.16.3–1.16.5, 1.17.1, 1.18–1.18.2, 1.19–1.19.4, 1.20–1.20.4, 1.20.6, 1.21, 1.21.1, 1.21.3–1.21.11 |
| NeoForge | 1.20.2–1.20.6, 1.21–1.21.9, 26.1–26.1.2, 26.2 |
Quilt Loader reads fabric.mod.json directly, so the Fabric jar is the Quilt
jar — there's nothing extra to build, and releases are tagged for both.
Most gaps are releases that loader never shipped for: Forge went straight from 1.16 to 1.16.1 and from 1.17 to 1.17.1, and skipped 1.20.5 and 1.21.2 entirely.
Forge 1.20.3 is also missing: its recommended build asks for
net.minecraftforge:bootstrap-dev:2.0.0, which was never published. A later
build off the 49 line may well fix it. NeoForge covers 1.20.3 either way.
Forge 1.16.1 and 1.16.2 are the exception — Forge shipped them, but the Architectury Loom this build uses cannot produce them on any Forge build: 1.16.1 dies remapping Minecraft on a name conflict, and on 1.16.2 the mixin annotation processor is given no SRG mappings and so can't write a refmap. Fabric covers both versions.
Nothing here ships a refmap any more. Current Loom defaults to
useLegacyMixinAp = false: instead of running Mixin's annotation processor to
emit a name map alongside the jar, it rewrites the mixin annotations themselves
into SRG inside remapJar. The jars are equivalent — the mapping is in the
class files rather than in a JSON file next to them — but it means
cleancut.mixins.json must not declare a refmap, and no build script here
may add Mixin as an annotationProcessor. Doing either points the build at
mapping data that is never generated.
On Forge 1.14.4, 1.15 and 1.15.1 the mod carries its own copy of Mixin, because
Forge only started bundling Mixin partway through 1.15.2. See
forge/src/main/java/zone/rong/cleancut/bootstrap/.
Shipping Mixin inside the mod jar rather than as a second file — the job
MixinBootstrap usually does — means doing by hand the three things a jar on the
class path gets for free. ModLauncher builds its launch plugin list before it
looks at mods/, so Mixin's plugin is registered afterwards; Forge treats a jar
declaring a transformation service as a transformer and not a mod, so the jar
takes itself back off that exclusion list; and Mixin resolves its own platform
classes through the context class loader, which this early can't see mods/.
asm-util is shaded alongside for the same reason — Mixin needs it and these
Forge versions don't ship it.
None of this is compiled into 1.15.2 or later, where Forge starts Mixin itself. It is reflection into ModLauncher and FML internals, so these three targets are worth launching after a toolchain change; building them only proves they compile.
NeoForge starts at 1.20.2 because that is its first release. Its 1.20.1 build is
the Forge fork from before the rename and still lives under
net.minecraftforge, so the Forge 1.20.1 jar is the NeoForge 1.20.1 jar —
it's the same API, and building it twice would produce the same mod.
Only one snapshot is carried at a time, the current one. When the next lands, the row moves rather than accumulating — a snapshot nobody can download any more is not worth a build job.
The ceilings differ per loader — 1.21.11 on Forge, 26.2 on NeoForge, the current snapshot on Fabric — for the reasons described under Newer Minecraft.
settings.gradle the version/loader matrix
stonecutter.gradle which version the checked-out sources are written for
fabric/ build script, mod metadata and Yarn-mapped sources
forge/ build script, mod metadata and Mojang-mapped sources
neoforge/ same, for NeoForge
.github/targets.json the same matrix again, for CI
Stonecutter turns one source tree into every Minecraft version. Where an API moved between versions, the alternatives sit next to each other and Stonecutter comments out the ones that don't apply:
//? if >=1.17 {
for (Entity entity : world.getOtherEntities(player, searchBox, predicate)) {
//?} else {
/*for (Entity entity : world.getEntities(player, searchBox, predicate)) {
*///?}Fabric builds against Yarn mappings and Forge/NeoForge against Mojang's own, so
those two source trees are separate copies of the same behaviour rather than one
shared file full of mapping conditionals. Each is small: a CleanCut class with
the logic, and a mixin that decides where vanilla gets interrupted.
The exception is Minecraft 26 on Fabric, where there is no Yarn to build
against and the same source tree has to speak both. There the Yarn and Mojang
spellings sit next to each other member by member, in the same //? if <26
form as everything else. Those blocks are never nested inside one another: where
a member also varies by version within Yarn, the alternatives are written as one
flat if/elif/else chain instead, so each arm stays a whole method you can
read.
./gradlew build # everything
./gradlew :fabric:1.20.1:build # one target
./gradlew :neoforge:1.21.4:buildJars land in <loader>/versions/<version>/build/libs/. Build with JDK 21,
except for the Minecraft 26 targets, which need JDK 25:
./gradlew :neoforge:26.2:build # needs JAVA_HOME on a JDK 25That is the JVM the Gradle daemon runs on, not what the mod compiles against —
each target sets its own options.release, so one daemon covers every release
level from 8 upwards. Loom refuses to set Minecraft 26 up under anything below
25 (Minecraft 26.1 requires Java 25 but Gradle is using 21), and the older
targets have only been exercised on 21. .github/targets.json carries the
version each target wants, and CI installs it per job. Set JAVA_HOME to
switch locally.
./gradlew build with no arguments builds every target in sequence, which takes
a while — the per-version Minecraft decompile dominates. CI builds them in
parallel instead, one job per target.
The sources in src/ are written for one version at a time — 1.20.2, the
version every branch has in common — and that's the version an IDE resolves
them against. To read and edit them as another version instead:
./gradlew "Set active project to 1.19.2"
./gradlew "Reset active project" # back to 1.20.2Building doesn't need either: each target's sources are generated into its own
build/generated/stonecutter/ and src/ is left alone, so building any number
of versions leaves git status clean. Note that both tasks above configure
every target, which is slow — they're for IDE work, not part of building.
This build is on Gradle 9, Stonecutter 0.9 and current Loom, because Minecraft 26 needs a Java 25 daemon and Gradle 8 cannot run on Java 25 at all — it rejects the class files. The wrapper and the Stonecutter version are properties of the whole build rather than of one branch, so that migration was all-or-nothing.
The obvious worry about moving the whole build forward is that current Loom
drops the oldest targets, and it very nearly did. Forge 1.14.4, 1.15 and 1.15.1
— the three that shade their own Mixin — failed with Unable to locate obfuscation mapping for @Redirect target. The cause was on this side, not
Loom's: those three added Mixin as an annotationProcessor by hand, and current
Loom no longer passes the processor any mapping arguments because it no longer
uses the processor. Dropping those lines fixes all three. See the note on
refmaps under Supported versions, and don't reintroduce
them.
Every other target from 1.14.4 up builds unchanged, so there is one build here, not a legacy one and a modern one.
What is left is per-loader, and none of it is a toolchain problem:
NeoForge skips 1.21.10 and 1.21.11. From 21.10 its published artifact no
longer carries data/server.lzma where Architectury Loom expects it. The
packaging moved out from under Loom, so a version bump on this side doesn't fix
it — building those two means NeoForge's own toolchain instead of Loom, which is
a different build rather than a different number. It picks back up at 26.1.
Fabric and Forge cover 1.21.10 and 1.21.11.
Forge stops at 1.21.11. On 26.x Loom throws an NPE setting Minecraft up on the Forge platform. NeoForge 26.x goes through the same Loom and works, so this is Forge-platform-specific.
Fabric goes all the way, including the current snapshot. 26.x has no Yarn —
intermediary exists, Yarn does not — because the game ships unobfuscated and the
names in the jar are Mojang's. So the Fabric sources carry both spellings, as
described under How the repository is laid out,
and the build drops the mappings dependency and switches Loom to its no-remap
plugin for those targets. The jar off the compiler is the jar that ships:
remapJar has nothing to remap.
Fabric Loader normalises Minecraft's version ids into its own semver, and
snapshots don't survive that intact — 26.3-snapshot-7 is 26.3-alpha.7 to the
loader. fabric.mod.json gets the loader's spelling, or the mod would refuse to
load on the version it was built for.
Yarn's unpick v3 is not a wall, despite an earlier note here saying so.
NeoForge stops at 26.2, because that is as far as NeoForge goes. There is no
26.3 build on their Maven, snapshot or otherwise, so there is nothing to build
against — this one is upstream, not here. Run the Loader builds workflow
against 26.3-snapshot-7 to see whether that has changed; if it has, adding it
is the three steps below.
- Add it to the right
branch(...)insettings.gradle. - Add a row to that loader's
versionDatatable in<loader>/build.gradlewith the mappings or loader build and the Java release level. The dependency ranges that go into the mod metadata are derived from those, not written out. - Add it to
.github/targets.jsonso CI builds it, with thejavathe Gradle daemon needs for it — 21 for everything so far except Minecraft 26, which needs 25.
The build number step 2 wants is a fact about someone else's repository, so don't guess it: run the Loader builds workflow from the Actions tab with the Minecraft version you're adding, and it prints what Mojang, Fabric, NeoForge and Forge have actually published for it.
Then build it. If an API moved, the compiler will say so, and the fix is another
//? if block around the two alternatives.
Releases go to Modrinth and CurseForge from .github/workflows/publish.yml,
driven by mod-publish-plugin.
Both are wired up: the project IDs are in gradle.properties (modrinth_id,
curseforge_id) and the MODRINTH_TOKEN and CURSEFORGE_TOKEN secrets are set
on the repository. Clearing an ID makes that platform get skipped rather than
fail.
To cut a release:
- Bump
mod_versioningradle.propertiesand push it. - Run the workflow manually with
dry_runticked. That builds and validates every upload without sending anything. - Tag and publish a GitHub release as
v<mod_version>. The release body becomes the changelog on both platforms.
Snapshot targets go to Modrinth only, marked alpha. CurseForge has no game version to file them under.
The tag has to match mod_version, and the workflow stops before uploading
anything if it doesn't — a jar carries the version it was built with, and a
file published to CurseForge can't be replaced afterwards.
MIT.