From ea394b01d25734a509cd5fed8b7776c441718ba3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 8 May 2026 01:06:26 +0000 Subject: [PATCH 1/7] feat: introduce Stonecutter multi-version scaffold (1.21, 1.21.6, 26.1) Migrates the mod from 3 separate branches (main 26.1, support/1.21-1.21.5, support/1.21.6-1.21.11) to a single source tree using Stonecutter 0.9.3. - All variants now use Mojang official mappings (yarn sources for the support branches were converted with loom's migrateMappings task). - Per-version gradle.properties under versions/ pin MC, fabric-api, modmenu, cloth-config, loader, and target Java version. - Single root build.gradle uses stonecutter.eval(...) to switch between fabric-loom (26.1, unobfuscated) and fabric-loom-remap (1.21.x, obfuscated). - Source files use //? if mc directives where the API genuinely differs: HudElementRegistry vs HudRenderCallback, Identifier vs ResourceLocation, GuiGraphicsExtractor vs GuiGraphics, drawContext.text vs .drawString, Holder vs Optional in Biome lookup, FPS via accessor mixin on 1.21.x. The active VCS variant is 26.1 so 'git diff main' stays small. Co-authored-by: Dominic Seung --- .gitignore | 8 + build.gradle | 142 ++++++++++++------ gradle.properties | 18 +-- gradlew | 0 settings.gradle | 26 +++- src/main/java/dsns/betterhud/BetterHUD.java | 17 +++ .../java/dsns/betterhud/BetterHUDGUI.java | 30 ++++ .../mixin/MinecraftClientAccessor.java | 23 +++ src/main/java/dsns/betterhud/mods/Biome.java | 16 ++ src/main/java/dsns/betterhud/mods/FPS.java | 7 + src/main/resources/betterhud.mixins.json | 6 +- src/main/resources/fabric.mod.json | 8 +- stonecutter.gradle | 11 ++ versions/1.21.6/gradle.properties | 14 ++ versions/1.21/gradle.properties | 14 ++ versions/26.1/gradle.properties | 12 ++ 16 files changed, 285 insertions(+), 67 deletions(-) mode change 100644 => 100755 gradlew create mode 100644 src/main/java/dsns/betterhud/mixin/MinecraftClientAccessor.java create mode 100644 stonecutter.gradle create mode 100644 versions/1.21.6/gradle.properties create mode 100644 versions/1.21/gradle.properties create mode 100644 versions/26.1/gradle.properties diff --git a/.gitignore b/.gitignore index c476faf..8c52ed7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,14 @@ bin/ run/ +# stonecutter + +versions/*/.gradle/ +versions/*/build/ +versions/*/run/ +versions/*/src/ +*.stonecutter.tmp + # java hs_err_*.log diff --git a/build.gradle b/build.gradle index 41f2871..979f8dc 100644 --- a/build.gradle +++ b/build.gradle @@ -1,87 +1,133 @@ plugins { - id 'net.fabricmc.fabric-loom' version "${loom_version}" + // Loom 1.14+ splits into two sibling plugins: + // - `net.fabricmc.fabric-loom-remap` handles obfuscated MC (1.21.x with intermediary) + // - `net.fabricmc.fabric-loom` handles unobfuscated MC (26.1+, Mojang names native) + // We declare both with `apply false` and pick the right one for the current variant. + id 'net.fabricmc.fabric-loom-remap' version "${loom_version}" apply false + id 'net.fabricmc.fabric-loom' version "${loom_version}" apply false id 'maven-publish' } -version = project.mod_version +def mcVersion = stonecutter.current.version +def isLegacy = stonecutter.eval(mcVersion, '<26') // 1.21.x family +def hasHudElementRegistry = stonecutter.eval(mcVersion, '>=1.21.6') // 1.21.6+ and 26+ + +apply plugin: isLegacy ? 'net.fabricmc.fabric-loom-remap' : 'net.fabricmc.fabric-loom' + +// `stonecutter` is injected into each version subproject. `stonecutter.current.version` +// is the MC string, e.g. "1.21", "1.21.6", "26.1". +// Per-MC Java version (21 for 1.21.x, 25 for 26.1+). +def javaVersionInt = Integer.parseInt(project.java_version) +def javaLang = JavaVersion.toVersion(javaVersionInt) +def mixinJavaCompat = "JAVA_${javaVersionInt}" +def mixinClientList = isLegacy ? '"MinecraftClientAccessor"' : '' + +version = "${project.mod_version}+mc${mcVersion}" group = project.maven_group +base { + archivesName = project.archives_base_name +} + repositories { - // Add repositories to retrieve artifacts from in here. - // You should only use this when depending on other mods because - // Loom adds the essential maven repositories to download Minecraft and libraries from automatically. - // See https://docs.gradle.org/current/userguide/declaring_repositories.html - // for more information about repositories. - - maven { url "https://maven.shedaniel.me/" } - maven { url "https://maven.terraformersmc.com/releases/" } + maven { url 'https://maven.shedaniel.me/' } + maven { url 'https://maven.terraformersmc.com/releases/' } } -configurations.configureEach { - if (name.toLowerCase().contains('annotationprocessor')) { - exclude group: 'org.ow2.asm' +// MC 26+ uses fabric-rendering-v1's transitive deps but rejects ASM coming from +// annotation processors; matches main's existing build. +if (!isLegacy) { + configurations.configureEach { + if (name.toLowerCase().contains('annotationprocessor')) { + exclude group: 'org.ow2.asm' + } } } +// MC 26+ ships with Mojang official names baked into the runtime, so loom skips +// the intermediary remap entirely - mods link directly via `implementation` +// and `mappings` must NOT be declared. +// 1.21.x uses intermediary at runtime, so deps must go through `modImplementation` +// for loom to remap them, and `mappings loom.officialMojangMappings()` is required. dependencies { - // To change the versions see the gradle.properties file minecraft "com.mojang:minecraft:${project.minecraft_version}" - - implementation "net.fabricmc:fabric-loader:${project.loader_version}" - - // Fabric API. This is technically optional, but you probably want it anyway. - implementation include(fabricApi.module("fabric-api-base", project.fabric_api_version)) - implementation include(fabricApi.module("fabric-rendering-v1", project.fabric_api_version)) - - implementation include("com.terraformersmc:modmenu:${project.modmenu_version}") - implementation include("me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { - exclude(group: "net.fabricmc.fabric-api") - } + + if (isLegacy) { + mappings loom.officialMojangMappings() + + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation include(fabricApi.module('fabric-api-base', project.fabric_api_version)) + modImplementation include(fabricApi.module('fabric-rendering-v1', project.fabric_api_version)) + + // 1.21 (pre-1.21.6) needs lifecycle-events as an explicit module; later versions + // pull it transitively through rendering-v1. + if (!hasHudElementRegistry) { + modImplementation include(fabricApi.module('fabric-lifecycle-events-v1', project.fabric_api_version)) + } + + modImplementation include("com.terraformersmc:modmenu:${project.modmenu_version}") + modImplementation include("me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { + exclude(group: 'net.fabricmc.fabric-api') + } + } else { + implementation "net.fabricmc:fabric-loader:${project.loader_version}" + implementation include(fabricApi.module('fabric-api-base', project.fabric_api_version)) + implementation include(fabricApi.module('fabric-rendering-v1', project.fabric_api_version)) + + implementation include("com.terraformersmc:modmenu:${project.modmenu_version}") + implementation include("me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { + exclude(group: 'net.fabricmc.fabric-api') + } + } } processResources { - inputs.property "version", project.version + def expansion = [ + version : project.version, + minecraftReq : project.mc_dep, + loaderReq : project.loader_dep, + javaReq : ">=${javaVersionInt}", + javaCompat : mixinJavaCompat, + mixinClients : mixinClientList + ] + inputs.properties expansion - filesMatching("fabric.mod.json") { - expand "version": inputs.properties.version + filesMatching('fabric.mod.json') { + expand expansion + } + filesMatching('betterhud.mixins.json') { + expand expansion } } tasks.withType(JavaCompile).configureEach { - it.options.release = 25 + it.options.release = javaVersionInt } java { - // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task - // if it is present. - // If you remove this line, sources will not be generated. withSourcesJar() - - sourceCompatibility = JavaVersion.VERSION_25 - targetCompatibility = JavaVersion.VERSION_25 + sourceCompatibility = javaLang + targetCompatibility = javaLang + toolchain { + languageVersion = JavaLanguageVersion.of(javaVersionInt) + } } jar { - inputs.property "projectName", project.name - - from("LICENSE") { - rename { "${it}_${project.name}"} + def archiveName = base.archivesName.get() + from('../../LICENSE') { + rename { "${it}_${archiveName}" } } } -// configure the maven publication publishing { publications { - create("mavenJava", MavenPublication) { + create('mavenJava', MavenPublication) { + artifactId = "${project.archives_base_name}-mc${mcVersion}" from components.java } } - - // See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing. repositories { - // Add repositories to publish to here. - // Notice: This block does NOT have the same function as the block in the top level. - // The repositories here will be used for publishing your artifact, not for - // retrieving dependencies. + // Add publish repos here if/when needed. } -} \ No newline at end of file +} diff --git a/gradle.properties b/gradle.properties index a7faad0..07e740a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,22 +1,18 @@ # Done to increase the memory available to gradle. -org.gradle.jvmargs=-Xmx1G +org.gradle.jvmargs=-Xmx2G org.gradle.parallel=true # IntelliJ IDEA is not yet fully compatible with configuration cache, see: https://github.com/FabricMC/fabric-loom/issues/1349 org.gradle.configuration-cache=false -# Fabric Properties -# check these on https://fabricmc.net/develop -minecraft_version=26.1 -loader_version=0.19.2 +# Acknowledge using Groovy DSL with Stonecutter (Kotlin DSL is recommended but Groovy works). +dev.kikugie.stonecutter.hard_mode=true + +# Single loom version covers all MC variants. The `fabric-loom-remap` plugin id +# handles obfuscated MC (1.21.x), while `fabric-loom` handles unobfuscated MC (26.1+). loom_version=1.16-SNAPSHOT -# Mod Properties +# Mod Properties (shared) mod_version=2.1.0 maven_group=dsns.betterhud archives_base_name=betterhud - -# Dependencies -fabric_api_version=0.145.1+26.1 -modmenu_version=18.0.0-alpha.8 -cloth_config_version=26.1.154 \ No newline at end of file diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/settings.gradle b/settings.gradle index bcb1c6e..c38a828 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,10 +4,34 @@ pluginManagement { name = 'Fabric' url = 'https://maven.fabricmc.net/' } + maven { + name = 'Stonecutter Releases' + url = 'https://maven.kikugie.dev/releases' + } + maven { + name = 'Stonecutter Snapshots' + url = 'https://maven.kikugie.dev/snapshots' + } mavenCentral() gradlePluginPortal() } } -// Should match your modid +plugins { + id 'dev.kikugie.stonecutter' version '0.9.3' +} + +stonecutter { + create(rootProject) { + // Each entry is one buildable variant. The string is both the subproject name + // and the MC version. These cover support/1.21-1.21.5, support/1.21.6-1.21.11, + // and main 26.1. + versions('1.21', '1.21.6', '26.1') + + // vcsVersion is the variant whose form is used as the canonical (active) version + // in source. We pick 26.1 so `git diff main` stays small. + vcsVersion = '26.1' + } +} + rootProject.name = 'betterhud' diff --git a/src/main/java/dsns/betterhud/BetterHUD.java b/src/main/java/dsns/betterhud/BetterHUD.java index bbf7edb..901d626 100644 --- a/src/main/java/dsns/betterhud/BetterHUD.java +++ b/src/main/java/dsns/betterhud/BetterHUD.java @@ -8,8 +8,16 @@ import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +//? if mc >= "1.21.6" { import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry; +//?} else { +/*import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback;*/ +//?} +//? if mc >= "26" { import net.minecraft.resources.Identifier; +//?} else if mc >= "1.21.6" { +/*import net.minecraft.resources.ResourceLocation;*/ +//?} import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,10 +47,19 @@ public void onInitializeClient() { BetterHUDGUI betterHUDGUI = new BetterHUDGUI(); + //? if mc >= "26" { HudElementRegistry.addLast( Identifier.fromNamespaceAndPath("betterhud", "hud"), betterHUDGUI::onHudRender ); + //?} else if mc >= "1.21.6" { + /*HudElementRegistry.addLast( + ResourceLocation.fromNamespaceAndPath("betterhud", "hud"), + betterHUDGUI::onHudRender + );*/ + //?} else { + /*HudRenderCallback.EVENT.register(betterHUDGUI);*/ + //?} ClientTickEvents.START_CLIENT_TICK.register(betterHUDGUI); } } diff --git a/src/main/java/dsns/betterhud/BetterHUDGUI.java b/src/main/java/dsns/betterhud/BetterHUDGUI.java index 7d2b187..487be82 100644 --- a/src/main/java/dsns/betterhud/BetterHUDGUI.java +++ b/src/main/java/dsns/betterhud/BetterHUDGUI.java @@ -6,11 +6,22 @@ import it.unimi.dsi.fastutil.objects.ObjectArrayList; import java.util.List; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +//? if mc < "1.21.6" { +/*import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback;*/ +//?} import net.minecraft.client.DeltaTracker; import net.minecraft.client.Minecraft; +//? if mc >= "26" { import net.minecraft.client.gui.GuiGraphicsExtractor; +//?} else { +/*import net.minecraft.client.gui.GuiGraphics;*/ +//?} +//? if mc >= "1.21.6" { public class BetterHUDGUI implements ClientTickEvents.StartTick { +//?} else { +/*public class BetterHUDGUI implements HudRenderCallback, ClientTickEvents.StartTick {*/ +//?} public static int verticalPadding = 4; public static int horizontalPadding = 4; @@ -68,7 +79,11 @@ public void onStartTick(Minecraft client) { } public void onHudRender( + //? if mc >= "26" { GuiGraphicsExtractor drawContext, + //?} else { + /*GuiGraphics drawContext,*/ + //?} DeltaTracker tickCounter ) { if (client.getDebugOverlay().showDebugScreen()) return; @@ -145,7 +160,11 @@ public void onHudRender( } private void drawString( + //? if mc >= "26" { GuiGraphicsExtractor drawContext, + //?} else { + /*GuiGraphics drawContext,*/ + //?} CustomText text, int x, int y @@ -160,6 +179,7 @@ private void drawString( text.backgroundColor ); + //? if mc >= "26" { drawContext.text( client.font, text.text, @@ -168,5 +188,15 @@ private void drawString( text.color, true ); + //?} else { + /*drawContext.drawString( + client.font, + text.text, + x + horizontalPadding, + y + verticalPadding, + text.color, + true + );*/ + //?} } } diff --git a/src/main/java/dsns/betterhud/mixin/MinecraftClientAccessor.java b/src/main/java/dsns/betterhud/mixin/MinecraftClientAccessor.java new file mode 100644 index 0000000..71c9418 --- /dev/null +++ b/src/main/java/dsns/betterhud/mixin/MinecraftClientAccessor.java @@ -0,0 +1,23 @@ +package dsns.betterhud.mixin; + +//? if mc < "26" { +/*import net.minecraft.client.Minecraft; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(Minecraft.class) +public interface MinecraftClientAccessor { + @Accessor("currentFps") + static int getCurrentFPS() { + return 0; + } +}*/ +//?} +//? if mc >= "26" { +// On 26.1+ Minecraft#getFps() is publicly accessible, so this mixin is not needed +// and not registered in betterhud.mixins.json. Keep this file as a Stonecutter-gated +// stub so it exists in source for 1.21.x active variants. +final class MinecraftClientAccessor_Disabled { + private MinecraftClientAccessor_Disabled() {} +} +//?} diff --git a/src/main/java/dsns/betterhud/mods/Biome.java b/src/main/java/dsns/betterhud/mods/Biome.java index 73289be..d5ad319 100644 --- a/src/main/java/dsns/betterhud/mods/Biome.java +++ b/src/main/java/dsns/betterhud/mods/Biome.java @@ -5,8 +5,15 @@ import dsns.betterhud.util.ModSettings; import java.util.LinkedHashMap; import java.util.Map; +//? if mc < "26" { +/*import java.util.Optional;*/ +//?} import net.minecraft.client.Minecraft; +//? if mc >= "26" { import net.minecraft.core.Holder; +//?} else { +/*import net.minecraft.resources.ResourceKey;*/ +//?} import net.minecraft.world.entity.player.Player; public class Biome implements BaseMod { @@ -30,11 +37,20 @@ public CustomText onStartTick(Minecraft client) { if (player == null) return null; // have to specify this because name of class is Biome + //? if mc >= "26" { Holder biome = client.level.getBiome(player.blockPosition()); if (!biome.unwrapKey().isPresent()) return null; String biomeString = formatSnakeCase(biome.getRegisteredName().replace("minecraft:", "")); + //?} else { + /*Optional> biome = + client.level.getBiome(player.blockPosition()).unwrapKey(); + + if (!biome.isPresent()) return null; + + String biomeString = formatSnakeCase(biome.get().location().getPath());*/ + //?} // used to maintain order when iterating LinkedHashMap biomeColors = new LinkedHashMap<>(); diff --git a/src/main/java/dsns/betterhud/mods/FPS.java b/src/main/java/dsns/betterhud/mods/FPS.java index b509d2a..1c0d82f 100644 --- a/src/main/java/dsns/betterhud/mods/FPS.java +++ b/src/main/java/dsns/betterhud/mods/FPS.java @@ -1,5 +1,8 @@ package dsns.betterhud.mods; +//? if mc < "26" { +/*import dsns.betterhud.mixin.MinecraftClientAccessor;*/ +//?} import dsns.betterhud.util.BaseMod; import dsns.betterhud.util.CustomText; import dsns.betterhud.util.ModSettings; @@ -21,7 +24,11 @@ public ModSettings getModSettings() { @Override public CustomText onStartTick(Minecraft client) { + //? if mc >= "26" { int currentFPS = client.getFps(); + //?} else { + /*int currentFPS = MinecraftClientAccessor.getCurrentFPS();*/ + //?} return new CustomText(currentFPS + " FPS", getModSettings()); } diff --git a/src/main/resources/betterhud.mixins.json b/src/main/resources/betterhud.mixins.json index 370eecf..a57f566 100644 --- a/src/main/resources/betterhud.mixins.json +++ b/src/main/resources/betterhud.mixins.json @@ -1,12 +1,12 @@ { "required": true, "package": "dsns.betterhud.mixin", - "compatibilityLevel": "JAVA_25", - "client": [], + "compatibilityLevel": "${javaCompat}", + "client": [${mixinClients}], "injectors": { "defaultRequire": 1 }, "overwrites": { "requireAnnotations": true } -} \ No newline at end of file +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 31b270b..b116dc0 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -21,8 +21,8 @@ "betterhud.mixins.json" ], "depends": { - "fabricloader": ">=0.19", - "minecraft": ">=26", - "java": ">=25" + "fabricloader": "${loaderReq}", + "minecraft": "${minecraftReq}", + "java": "${javaReq}" } -} \ No newline at end of file +} diff --git a/stonecutter.gradle b/stonecutter.gradle new file mode 100644 index 0000000..55aa818 --- /dev/null +++ b/stonecutter.gradle @@ -0,0 +1,11 @@ +plugins { + id 'dev.kikugie.stonecutter' +} + +// The "active" version determines which variant the source tree currently shows +// uncommented. Switch with `./gradlew "Set active version to "` (Stonecutter task). +stonecutter.active('26.1') /* [SC] DO NOT EDIT */ + +// Stonecutter 0.7+ no longer needs explicit `registerChiseled` task: running +// `./gradlew build` from the root automatically runs `build` for every version +// subproject. Per-version commands like `./gradlew :1.21:build` also work. diff --git a/versions/1.21.6/gradle.properties b/versions/1.21.6/gradle.properties new file mode 100644 index 0000000..1c59190 --- /dev/null +++ b/versions/1.21.6/gradle.properties @@ -0,0 +1,14 @@ +# Builds against MC 1.21.9 (a stable point in the 1.21.6-1.21.11 range). +# fabric.mod.json declares >=1.21.6 <1.22 so it loads on 1.21.6-1.21.11. +minecraft_version=1.21.9 +loader_version=0.17.3 +java_version=21 + +# fabric.mod.json range +mc_dep=>=1.21.6 <1.22 +loader_dep=>=0.16.10 + +# Fabric API + ecosystem (matches support/1.21.6-1.21.11 branch). +fabric_api_version=0.134.0+1.21.9 +modmenu_version=15.0.2 +cloth_config_version=19.0.147 diff --git a/versions/1.21/gradle.properties b/versions/1.21/gradle.properties new file mode 100644 index 0000000..4d2e6ac --- /dev/null +++ b/versions/1.21/gradle.properties @@ -0,0 +1,14 @@ +# Builds against MC 1.21 itself (lowest in the 1.21-1.21.5 range, for max +# backward compat). fabric.mod.json declares ~1.21 so it loads on 1.21-1.21.5. +minecraft_version=1.21 +loader_version=0.17.3 +java_version=21 + +# fabric.mod.json range +mc_dep=~1.21 +loader_dep=>=0.15.11 + +# Fabric API + ecosystem (matches support/1.21-1.21.5 branch). +fabric_api_version=0.102.0+1.21 +modmenu_version=14.0.2 +cloth_config_version=19.0.147 diff --git a/versions/26.1/gradle.properties b/versions/26.1/gradle.properties new file mode 100644 index 0000000..c5050b1 --- /dev/null +++ b/versions/26.1/gradle.properties @@ -0,0 +1,12 @@ +minecraft_version=26.1 +loader_version=0.19.2 +java_version=25 + +# fabric.mod.json range +mc_dep=>=26 +loader_dep=>=0.19 + +# Fabric API + ecosystem +fabric_api_version=0.145.1+26.1 +modmenu_version=18.0.0-alpha.8 +cloth_config_version=26.1.154 From 664a0049c2f17c419abe993446d38fc25ce02e7e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 8 May 2026 01:11:46 +0000 Subject: [PATCH 2/7] fix: correct Stonecutter directive syntax and remove unused mixin - Stonecutter uses '//? if >=1.21.6 {' (no 'mc' keyword, no quotes around versions) - my previous '//? if mc >= "..." {' format was rejected by the parser. - client.getFps() is public on all Mojang-mapped variants (1.21, 1.21.6+, 26.1), so the MinecraftClientAccessor mixin was redundant. Drop it and the empty mixin/ package; betterhud.mixins.json keeps an empty client array via expansion. - Earlier I'd assumed 26.1 used getDebugHud()/hudHidden (yarn-like) names, but main 26.1 already uses getDebugOverlay()/hideGui (true Mojang) - same as the 1.21.x Mojang-migrated source. Removed the bogus directives that swapped between them. After these fixes, all three variants build successfully: ./gradlew build -> versions/1.21/build/libs/betterhud-2.1.0+mc1.21.jar -> versions/1.21.6/build/libs/betterhud-2.1.0+mc1.21.6.jar -> versions/26.1/build/libs/betterhud-2.1.0+mc26.1.jar CI workflow updated to a 3-way matrix (mc1.21/Java21, mc1.21.6/Java21, mc26.1/Java25) that uploads each variant as a separate artifact. Co-authored-by: Dominic Seung --- .github/workflows/build.yml | 41 ++++++++++++++----- build.gradle | 4 +- src/main/java/dsns/betterhud/BetterHUD.java | 18 ++++---- .../java/dsns/betterhud/BetterHUDGUI.java | 36 ++++++++-------- .../mixin/MinecraftClientAccessor.java | 23 ----------- src/main/java/dsns/betterhud/mods/Biome.java | 18 ++++---- src/main/java/dsns/betterhud/mods/FPS.java | 7 ---- src/main/resources/betterhud.mixins.json | 2 +- 8 files changed, 68 insertions(+), 81 deletions(-) delete mode 100644 src/main/java/dsns/betterhud/mixin/MinecraftClientAccessor.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c2a3f3..479cd29 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,30 +1,49 @@ -# Automatically build the project and run any configured tests for every push -# and submitted pull request. This can help catch issues that only occur on -# certain platforms or Java versions, and provides a first line of defence -# against bad commits. +# Build the mod against every Minecraft variant Stonecutter is configured for. +# Each matrix job builds one variant (1.21, 1.21.6, 26.1) and uploads its jar. +# Running `./gradlew build` from the root would also work but the matrix gives +# clearer failure attribution and parallel execution. name: build on: [pull_request, push] jobs: build: + name: build mc${{ matrix.mc }} runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - mc: '1.21' + java: '21' + - mc: '1.21.6' + java: '21' + - mc: '26.1' + java: '25' steps: - name: checkout repository uses: actions/checkout@v6 + - name: validate gradle wrapper uses: gradle/actions/wrapper-validation@v6 - - name: setup jdk + + - name: setup jdk ${{ matrix.java }} uses: actions/setup-java@v5 with: - java-version: '25' + java-version: ${{ matrix.java }} distribution: 'microsoft' + + # Stonecutter's `build.gradle` uses Java toolchains, so loom can compile + # against an older JDK even when Gradle itself runs on a newer one. We + # still set the matrix Java as the daemon JVM for simplicity. - name: make gradle wrapper executable run: chmod +x ./gradlew - - name: build - run: ./gradlew build - - name: capture build artifacts + + - name: build :${{ matrix.mc }}:build + run: ./gradlew :${{ matrix.mc }}:build --stacktrace + + - name: upload jar uses: actions/upload-artifact@v7 with: - name: Artifacts - path: build/libs/ \ No newline at end of file + name: betterhud-mc${{ matrix.mc }} + path: versions/${{ matrix.mc }}/build/libs/*.jar diff --git a/build.gradle b/build.gradle index 979f8dc..3b8a1b5 100644 --- a/build.gradle +++ b/build.gradle @@ -20,7 +20,6 @@ apply plugin: isLegacy ? 'net.fabricmc.fabric-loom-remap' : 'net.fabricmc.fabric def javaVersionInt = Integer.parseInt(project.java_version) def javaLang = JavaVersion.toVersion(javaVersionInt) def mixinJavaCompat = "JAVA_${javaVersionInt}" -def mixinClientList = isLegacy ? '"MinecraftClientAccessor"' : '' version = "${project.mod_version}+mc${mcVersion}" group = project.maven_group @@ -87,8 +86,7 @@ processResources { minecraftReq : project.mc_dep, loaderReq : project.loader_dep, javaReq : ">=${javaVersionInt}", - javaCompat : mixinJavaCompat, - mixinClients : mixinClientList + javaCompat : mixinJavaCompat ] inputs.properties expansion diff --git a/src/main/java/dsns/betterhud/BetterHUD.java b/src/main/java/dsns/betterhud/BetterHUD.java index 901d626..39bd452 100644 --- a/src/main/java/dsns/betterhud/BetterHUD.java +++ b/src/main/java/dsns/betterhud/BetterHUD.java @@ -8,14 +8,14 @@ import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; -//? if mc >= "1.21.6" { +//? if >=1.21.6 { import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry; //?} else { -/*import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback;*/ -//?} -//? if mc >= "26" { +/*import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback; +*///?} +//? if >=26 { import net.minecraft.resources.Identifier; -//?} else if mc >= "1.21.6" { +//?} else if >=1.21.6 { /*import net.minecraft.resources.ResourceLocation;*/ //?} import org.slf4j.Logger; @@ -47,19 +47,19 @@ public void onInitializeClient() { BetterHUDGUI betterHUDGUI = new BetterHUDGUI(); - //? if mc >= "26" { + //? if >=26 { HudElementRegistry.addLast( Identifier.fromNamespaceAndPath("betterhud", "hud"), betterHUDGUI::onHudRender ); - //?} else if mc >= "1.21.6" { + //?} else if >=1.21.6 { /*HudElementRegistry.addLast( ResourceLocation.fromNamespaceAndPath("betterhud", "hud"), betterHUDGUI::onHudRender );*/ //?} else { - /*HudRenderCallback.EVENT.register(betterHUDGUI);*/ - //?} + /*HudRenderCallback.EVENT.register(betterHUDGUI); + *///?} ClientTickEvents.START_CLIENT_TICK.register(betterHUDGUI); } } diff --git a/src/main/java/dsns/betterhud/BetterHUDGUI.java b/src/main/java/dsns/betterhud/BetterHUDGUI.java index 487be82..aea1e82 100644 --- a/src/main/java/dsns/betterhud/BetterHUDGUI.java +++ b/src/main/java/dsns/betterhud/BetterHUDGUI.java @@ -6,22 +6,22 @@ import it.unimi.dsi.fastutil.objects.ObjectArrayList; import java.util.List; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; -//? if mc < "1.21.6" { -/*import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback;*/ -//?} +//? if <1.21.6 { +/*import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback; +*///?} import net.minecraft.client.DeltaTracker; import net.minecraft.client.Minecraft; -//? if mc >= "26" { +//? if >=26 { import net.minecraft.client.gui.GuiGraphicsExtractor; //?} else { -/*import net.minecraft.client.gui.GuiGraphics;*/ -//?} +/*import net.minecraft.client.gui.GuiGraphics; +*///?} -//? if mc >= "1.21.6" { +//? if >=1.21.6 { public class BetterHUDGUI implements ClientTickEvents.StartTick { //?} else { -/*public class BetterHUDGUI implements HudRenderCallback, ClientTickEvents.StartTick {*/ -//?} +/*public class BetterHUDGUI implements HudRenderCallback, ClientTickEvents.StartTick { +*///?} public static int verticalPadding = 4; public static int horizontalPadding = 4; @@ -79,11 +79,11 @@ public void onStartTick(Minecraft client) { } public void onHudRender( - //? if mc >= "26" { + //? if >=26 { GuiGraphicsExtractor drawContext, //?} else { - /*GuiGraphics drawContext,*/ - //?} + /*GuiGraphics drawContext, + *///?} DeltaTracker tickCounter ) { if (client.getDebugOverlay().showDebugScreen()) return; @@ -160,11 +160,11 @@ public void onHudRender( } private void drawString( - //? if mc >= "26" { + //? if >=26 { GuiGraphicsExtractor drawContext, //?} else { - /*GuiGraphics drawContext,*/ - //?} + /*GuiGraphics drawContext, + *///?} CustomText text, int x, int y @@ -179,7 +179,7 @@ private void drawString( text.backgroundColor ); - //? if mc >= "26" { + //? if >=26 { drawContext.text( client.font, text.text, @@ -196,7 +196,7 @@ private void drawString( y + verticalPadding, text.color, true - );*/ - //?} + ); + *///?} } } diff --git a/src/main/java/dsns/betterhud/mixin/MinecraftClientAccessor.java b/src/main/java/dsns/betterhud/mixin/MinecraftClientAccessor.java deleted file mode 100644 index 71c9418..0000000 --- a/src/main/java/dsns/betterhud/mixin/MinecraftClientAccessor.java +++ /dev/null @@ -1,23 +0,0 @@ -package dsns.betterhud.mixin; - -//? if mc < "26" { -/*import net.minecraft.client.Minecraft; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.gen.Accessor; - -@Mixin(Minecraft.class) -public interface MinecraftClientAccessor { - @Accessor("currentFps") - static int getCurrentFPS() { - return 0; - } -}*/ -//?} -//? if mc >= "26" { -// On 26.1+ Minecraft#getFps() is publicly accessible, so this mixin is not needed -// and not registered in betterhud.mixins.json. Keep this file as a Stonecutter-gated -// stub so it exists in source for 1.21.x active variants. -final class MinecraftClientAccessor_Disabled { - private MinecraftClientAccessor_Disabled() {} -} -//?} diff --git a/src/main/java/dsns/betterhud/mods/Biome.java b/src/main/java/dsns/betterhud/mods/Biome.java index d5ad319..e455125 100644 --- a/src/main/java/dsns/betterhud/mods/Biome.java +++ b/src/main/java/dsns/betterhud/mods/Biome.java @@ -5,15 +5,15 @@ import dsns.betterhud.util.ModSettings; import java.util.LinkedHashMap; import java.util.Map; -//? if mc < "26" { -/*import java.util.Optional;*/ -//?} +//? if <26 { +/*import java.util.Optional; +*///?} import net.minecraft.client.Minecraft; -//? if mc >= "26" { +//? if >=26 { import net.minecraft.core.Holder; //?} else { -/*import net.minecraft.resources.ResourceKey;*/ -//?} +/*import net.minecraft.resources.ResourceKey; +*///?} import net.minecraft.world.entity.player.Player; public class Biome implements BaseMod { @@ -37,7 +37,7 @@ public CustomText onStartTick(Minecraft client) { if (player == null) return null; // have to specify this because name of class is Biome - //? if mc >= "26" { + //? if >=26 { Holder biome = client.level.getBiome(player.blockPosition()); if (!biome.unwrapKey().isPresent()) return null; @@ -49,8 +49,8 @@ public CustomText onStartTick(Minecraft client) { if (!biome.isPresent()) return null; - String biomeString = formatSnakeCase(biome.get().location().getPath());*/ - //?} + String biomeString = formatSnakeCase(biome.get().location().getPath()); + *///?} // used to maintain order when iterating LinkedHashMap biomeColors = new LinkedHashMap<>(); diff --git a/src/main/java/dsns/betterhud/mods/FPS.java b/src/main/java/dsns/betterhud/mods/FPS.java index 1c0d82f..b509d2a 100644 --- a/src/main/java/dsns/betterhud/mods/FPS.java +++ b/src/main/java/dsns/betterhud/mods/FPS.java @@ -1,8 +1,5 @@ package dsns.betterhud.mods; -//? if mc < "26" { -/*import dsns.betterhud.mixin.MinecraftClientAccessor;*/ -//?} import dsns.betterhud.util.BaseMod; import dsns.betterhud.util.CustomText; import dsns.betterhud.util.ModSettings; @@ -24,11 +21,7 @@ public ModSettings getModSettings() { @Override public CustomText onStartTick(Minecraft client) { - //? if mc >= "26" { int currentFPS = client.getFps(); - //?} else { - /*int currentFPS = MinecraftClientAccessor.getCurrentFPS();*/ - //?} return new CustomText(currentFPS + " FPS", getModSettings()); } diff --git a/src/main/resources/betterhud.mixins.json b/src/main/resources/betterhud.mixins.json index a57f566..c35b39c 100644 --- a/src/main/resources/betterhud.mixins.json +++ b/src/main/resources/betterhud.mixins.json @@ -2,7 +2,7 @@ "required": true, "package": "dsns.betterhud.mixin", "compatibilityLevel": "${javaCompat}", - "client": [${mixinClients}], + "client": [], "injectors": { "defaultRequire": 1 }, From fa341897bdebb1868ba449022c450e8a87f5dc45 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 10:46:42 +0000 Subject: [PATCH 3/7] feat: four-variant builds with required deps and unified 26.2 features - Add four Stonecutter variants: 1.21 (1.21-1.21.5), 1.21.6 (1.21.6-1.21.11), 26.1 (>=26.1 <26.2), 26.2 (>=26.2). - All jars now declare fabric-api, modmenu, and cloth-config2 as hard runtime dependencies instead of bundling them (~47 KB jars vs ~2 MB before). - Port 26.2 scaling (Matrix3x2fStack / PoseStack transforms, scaledElement* layout helpers, Scale setting) to every variant via Stonecutter directives. - 26.2 uses client.gui.hud.isHidden(); all other variants use options.hideGui. - Update to mod 2.1.1, loom 1.17-SNAPSHOT, Gradle 9.5.1; CI matrix covers all four variants. Co-authored-by: Dominic Seung --- .github/workflows/build.yml | 10 +- build.gradle | 40 +++--- gradle.properties | 6 +- gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle | 9 +- .../java/dsns/betterhud/BetterHUDGUI.java | 116 ++++++++++-------- .../java/dsns/betterhud/util/CustomText.java | 6 +- .../java/dsns/betterhud/util/ModSettings.java | 6 +- .../java/dsns/betterhud/util/Setting.java | 12 ++ src/main/resources/fabric.mod.json | 2 +- stonecutter.gradle | 2 +- versions/26.1/gradle.properties | 4 +- versions/26.2/gradle.properties | 12 ++ 13 files changed, 128 insertions(+), 99 deletions(-) create mode 100644 versions/26.2/gradle.properties diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 479cd29..387cd8a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,6 @@ # Build the mod against every Minecraft variant Stonecutter is configured for. -# Each matrix job builds one variant (1.21, 1.21.6, 26.1) and uploads its jar. -# Running `./gradlew build` from the root would also work but the matrix gives -# clearer failure attribution and parallel execution. +# Each matrix job builds one variant and uploads its jar. +# Running `./gradlew build` from the root builds all four in one go. name: build on: [pull_request, push] @@ -20,6 +19,8 @@ jobs: java: '21' - mc: '26.1' java: '25' + - mc: '26.2' + java: '25' steps: - name: checkout repository uses: actions/checkout@v6 @@ -33,9 +34,6 @@ jobs: java-version: ${{ matrix.java }} distribution: 'microsoft' - # Stonecutter's `build.gradle` uses Java toolchains, so loom can compile - # against an older JDK even when Gradle itself runs on a newer one. We - # still set the matrix Java as the daemon JVM for simplicity. - name: make gradle wrapper executable run: chmod +x ./gradlew diff --git a/build.gradle b/build.gradle index 3b8a1b5..5a0e259 100644 --- a/build.gradle +++ b/build.gradle @@ -14,8 +14,6 @@ def hasHudElementRegistry = stonecutter.eval(mcVersion, '>=1.21.6') // 1.21.6+ apply plugin: isLegacy ? 'net.fabricmc.fabric-loom-remap' : 'net.fabricmc.fabric-loom' -// `stonecutter` is injected into each version subproject. `stonecutter.current.version` -// is the MC string, e.g. "1.21", "1.21.6", "26.1". // Per-MC Java version (21 for 1.21.x, 25 for 26.1+). def javaVersionInt = Integer.parseInt(project.java_version) def javaLang = JavaVersion.toVersion(javaVersionInt) @@ -43,11 +41,9 @@ if (!isLegacy) { } } -// MC 26+ ships with Mojang official names baked into the runtime, so loom skips -// the intermediary remap entirely - mods link directly via `implementation` -// and `mappings` must NOT be declared. -// 1.21.x uses intermediary at runtime, so deps must go through `modImplementation` -// for loom to remap them, and `mappings loom.officialMojangMappings()` is required. +// Every variant declares fabric-api, modmenu, and cloth-config as runtime deps +// (not bundled). 1.21.x uses modImplementation for intermediary remapping; 26.x +// uses plain implementation against unobfuscated MC. dependencies { minecraft "com.mojang:minecraft:${project.minecraft_version}" @@ -55,38 +51,34 @@ dependencies { mappings loom.officialMojangMappings() modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" - modImplementation include(fabricApi.module('fabric-api-base', project.fabric_api_version)) - modImplementation include(fabricApi.module('fabric-rendering-v1', project.fabric_api_version)) - - // 1.21 (pre-1.21.6) needs lifecycle-events as an explicit module; later versions - // pull it transitively through rendering-v1. - if (!hasHudElementRegistry) { - modImplementation include(fabricApi.module('fabric-lifecycle-events-v1', project.fabric_api_version)) - } - - modImplementation include("com.terraformersmc:modmenu:${project.modmenu_version}") - modImplementation include("me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + modImplementation "com.terraformersmc:modmenu:${project.modmenu_version}" + modImplementation("me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { exclude(group: 'net.fabricmc.fabric-api') } } else { implementation "net.fabricmc:fabric-loader:${project.loader_version}" - implementation include(fabricApi.module('fabric-api-base', project.fabric_api_version)) - implementation include(fabricApi.module('fabric-rendering-v1', project.fabric_api_version)) - - implementation include("com.terraformersmc:modmenu:${project.modmenu_version}") - implementation include("me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { + implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + implementation "com.terraformersmc:modmenu:${project.modmenu_version}" + implementation("me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { exclude(group: 'net.fabricmc.fabric-api') } } } processResources { + def dependsExtra = ''', + "fabric-api": "*", + "modmenu": "*", + "cloth-config2": "*"''' + def expansion = [ version : project.version, minecraftReq : project.mc_dep, loaderReq : project.loader_dep, javaReq : ">=${javaVersionInt}", - javaCompat : mixinJavaCompat + javaCompat : mixinJavaCompat, + dependsExtra : dependsExtra ] inputs.properties expansion diff --git a/gradle.properties b/gradle.properties index 07e740a..0ce008e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,10 +9,10 @@ org.gradle.configuration-cache=false dev.kikugie.stonecutter.hard_mode=true # Single loom version covers all MC variants. The `fabric-loom-remap` plugin id -# handles obfuscated MC (1.21.x), while `fabric-loom` handles unobfuscated MC (26.1+). -loom_version=1.16-SNAPSHOT +# handles obfuscated MC (1.21.x), while `fabric-loom` handles unobfuscated MC (26.2+). +loom_version=1.17-SNAPSHOT # Mod Properties (shared) -mod_version=2.1.0 +mod_version=2.1.1 maven_group=dsns.betterhud archives_base_name=betterhud diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c61a118..5dd3c01 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle b/settings.gradle index c38a828..edfa934 100644 --- a/settings.gradle +++ b/settings.gradle @@ -24,13 +24,12 @@ plugins { stonecutter { create(rootProject) { // Each entry is one buildable variant. The string is both the subproject name - // and the MC version. These cover support/1.21-1.21.5, support/1.21.6-1.21.11, - // and main 26.1. - versions('1.21', '1.21.6', '26.1') + // and the MC version. These cover 1.21-1.21.5, 1.21.6-1.21.11, 26.1, and 26.2. + versions('1.21', '1.21.6', '26.1', '26.2') // vcsVersion is the variant whose form is used as the canonical (active) version - // in source. We pick 26.1 so `git diff main` stays small. - vcsVersion = '26.1' + // in source. We pick 26.2 so `git diff main` stays small. + vcsVersion = '26.2' } } diff --git a/src/main/java/dsns/betterhud/BetterHUDGUI.java b/src/main/java/dsns/betterhud/BetterHUDGUI.java index aea1e82..50b3913 100644 --- a/src/main/java/dsns/betterhud/BetterHUDGUI.java +++ b/src/main/java/dsns/betterhud/BetterHUDGUI.java @@ -7,21 +7,26 @@ import java.util.List; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; //? if <1.21.6 { -/*import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback; -*///?} +/*import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback;*/ +//?} import net.minecraft.client.DeltaTracker; import net.minecraft.client.Minecraft; //? if >=26 { import net.minecraft.client.gui.GuiGraphicsExtractor; //?} else { -/*import net.minecraft.client.gui.GuiGraphics; -*///?} +/*import net.minecraft.client.gui.GuiGraphics;*/ +//?} +//? if >=1.21.6 { +import org.joml.Matrix3x2fStack; +//?} else { +/*import com.mojang.blaze3d.vertex.PoseStack;*/ +//?} //? if >=1.21.6 { public class BetterHUDGUI implements ClientTickEvents.StartTick { //?} else { -/*public class BetterHUDGUI implements HudRenderCallback, ClientTickEvents.StartTick { -*///?} +/*public class BetterHUDGUI implements HudRenderCallback, ClientTickEvents.StartTick {*/ +//?} public static int verticalPadding = 4; public static int horizontalPadding = 4; @@ -82,29 +87,29 @@ public void onHudRender( //? if >=26 { GuiGraphicsExtractor drawContext, //?} else { - /*GuiGraphics drawContext, - *///?} + /*GuiGraphics drawContext,*/ + //?} DeltaTracker tickCounter ) { if (client.getDebugOverlay().showDebugScreen()) return; - if (client.options.hideGui) return; + //? if >=26.2 { + if (client.gui.hud.isHidden()) return; + //?} else { + /*if (client.options.hideGui) return;*/ + //?} int x = horizontalMargin; int y = verticalMargin; for (CustomText text : topLeftText) { drawString(drawContext, text, x, y); - - y += - (client.font.lineHeight - 1) + - (verticalPadding * 2) + - lineHeight; + y += scaledElementHeight(text) + lineHeight; } y = client.getWindow().getGuiScaledHeight() - verticalMargin; for (CustomText text : bottomLeftList) { - y -= (client.font.lineHeight - 1) + (verticalPadding * 2); + y -= scaledElementHeight(text); drawString(drawContext, text, x, y); y -= lineHeight; } @@ -117,25 +122,15 @@ public void onHudRender( horizontalMargin; x = client.getWindow().getGuiScaledWidth() - offset; drawString(drawContext, text, x, y); - - y += - (client.font.lineHeight - 1) + - (verticalPadding * 2) + - lineHeight; + y += scaledElementHeight(text) + lineHeight; } y = client.getWindow().getGuiScaledHeight() - verticalMargin; for (CustomText text : bottomRightText) { - int offset = - (client.font.width(text.text) - 1) + - (horizontalPadding * 2) + - horizontalMargin; + int offset = scaledElementWidth(text) + horizontalMargin; x = client.getWindow().getGuiScaledWidth() - offset; - - y -= (client.font.lineHeight - 1) + (verticalPadding * 2); - + y -= scaledElementHeight(text); drawString(drawContext, text, x, y); - y -= lineHeight; } @@ -144,13 +139,9 @@ public void onHudRender( float yPercent = text.customY / 100.0f; int maxX = - client.getWindow().getGuiScaledWidth() - - (horizontalPadding * 2) - - (client.font.width(text.text) - 1); + client.getWindow().getGuiScaledWidth() - scaledElementWidth(text); int maxY = - client.getWindow().getGuiScaledHeight() - - (verticalPadding * 2) - - (client.font.lineHeight - 1); + client.getWindow().getGuiScaledHeight() - scaledElementHeight(text); int scaledX = (int) (xPercent * maxX); int scaledY = (int) (yPercent * maxY); @@ -163,28 +154,35 @@ private void drawString( //? if >=26 { GuiGraphicsExtractor drawContext, //?} else { - /*GuiGraphics drawContext, - *///?} + /*GuiGraphics drawContext,*/ + //?} CustomText text, int x, int y ) { - drawContext.fill( - x, - y, - x + - (client.font.width(text.text) - 1) + - (horizontalPadding * 2), - y + (client.font.lineHeight - 1) + (verticalPadding * 2), - text.backgroundColor - ); + //? if >=1.21.6 { + Matrix3x2fStack poses = drawContext.pose(); + poses.pushMatrix(); + poses.translate(x, y); + poses.scale(text.scale, text.scale); + //?} else { + /*PoseStack poses = drawContext.pose(); + poses.pushPose(); + poses.translate(x, y, 0); + poses.scale(text.scale, text.scale, 1);*/ + //?} + + int w = (client.font.width(text.text) - 1) + (horizontalPadding * 2); + int h = (client.font.lineHeight - 1) + (verticalPadding * 2); + + drawContext.fill(0, 0, w, h, text.backgroundColor); //? if >=26 { drawContext.text( client.font, text.text, - x + horizontalPadding, - y + verticalPadding, + horizontalPadding, + verticalPadding, text.color, true ); @@ -192,11 +190,27 @@ private void drawString( /*drawContext.drawString( client.font, text.text, - x + horizontalPadding, - y + verticalPadding, + horizontalPadding, + verticalPadding, text.color, true - ); - *///?} + );*/ + //?} + + //? if >=1.21.6 { + poses.popMatrix(); + //?} else { + /*poses.popPose();*/ + //?} + } + + private int scaledElementWidth(CustomText text) { + int w = (client.font.width(text.text) - 1) + (horizontalPadding * 2); + return (int) (w * text.scale); + } + + private int scaledElementHeight(CustomText text) { + int h = (client.font.lineHeight - 1) + (verticalPadding * 2); + return (int) (h * text.scale); } } diff --git a/src/main/java/dsns/betterhud/util/CustomText.java b/src/main/java/dsns/betterhud/util/CustomText.java index c6427f2..b35bf40 100644 --- a/src/main/java/dsns/betterhud/util/CustomText.java +++ b/src/main/java/dsns/betterhud/util/CustomText.java @@ -6,12 +6,14 @@ public class CustomText { public int color; // colors are in ARGB format public int backgroundColor; // colors are in ARGB format public boolean customPosition = false; + public float scale = 1.0f; public int customX = 0; public int customY = 0; - public CustomText(String text, int color, int backgroundColor) { + public CustomText(String text, int color, float scale, int backgroundColor) { this.text = text; this.color = color; + this.scale = scale; this.backgroundColor = backgroundColor; } @@ -19,6 +21,7 @@ public CustomText(String text, ModSettings settings) { this( text, settings.getSetting("Text Color").getColorValue(), + settings.getSetting("Scale").getFloatValue(), settings.getSetting("Background Color").getColorValue() ); } @@ -27,6 +30,7 @@ public CustomText(String text, int color, ModSettings settings) { this( text, color, + settings.getSetting("Scale").getFloatValue(), settings.getSetting("Background Color").getColorValue() ); } diff --git a/src/main/java/dsns/betterhud/util/ModSettings.java b/src/main/java/dsns/betterhud/util/ModSettings.java index 22a75a2..97c2ce3 100644 --- a/src/main/java/dsns/betterhud/util/ModSettings.java +++ b/src/main/java/dsns/betterhud/util/ModSettings.java @@ -4,10 +4,7 @@ public class ModSettings { - private LinkedHashMap settings = new LinkedHashMap< - String, - Setting - >(); + private LinkedHashMap settings = new LinkedHashMap<>(); public ModSettings() { settings.put("Enabled", Setting.createBooleanSetting(true)); @@ -29,6 +26,7 @@ public ModSettings() { settings.put("Custom X", Setting.createIntegerSetting(0, 0, 100)); settings.put("Custom Y", Setting.createIntegerSetting(0, 0, 100)); settings.put("Text Color", Setting.createColorSetting(0xffffffff)); + settings.put("Scale", Setting.createDoubleSetting(1.0, 0.1, 10.0)); settings.put( "Background Color", Setting.createColorSetting(0x88000000) diff --git a/src/main/java/dsns/betterhud/util/Setting.java b/src/main/java/dsns/betterhud/util/Setting.java index a644d9e..6e57864 100644 --- a/src/main/java/dsns/betterhud/util/Setting.java +++ b/src/main/java/dsns/betterhud/util/Setting.java @@ -60,6 +60,14 @@ public static Setting createDoubleSetting( ); } + public static Setting createFloatSetting(float value, float min, float max) { + return new Setting( + String.valueOf(value), + "float", + new String[] { String.valueOf(min), String.valueOf(max) } + ); + } + public String getType() { return type; } @@ -76,6 +84,10 @@ public double getDoubleValue() { return Double.parseDouble(value); } + public float getFloatValue() { + return Float.parseFloat(value); + } + public int getColorValue() { return Integer.parseInt(value); } diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index b116dc0..6900660 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -23,6 +23,6 @@ "depends": { "fabricloader": "${loaderReq}", "minecraft": "${minecraftReq}", - "java": "${javaReq}" + "java": "${javaReq}"${dependsExtra} } } diff --git a/stonecutter.gradle b/stonecutter.gradle index 55aa818..11c695e 100644 --- a/stonecutter.gradle +++ b/stonecutter.gradle @@ -4,7 +4,7 @@ plugins { // The "active" version determines which variant the source tree currently shows // uncommented. Switch with `./gradlew "Set active version to "` (Stonecutter task). -stonecutter.active('26.1') /* [SC] DO NOT EDIT */ +stonecutter.active('26.2') /* [SC] DO NOT EDIT */ // Stonecutter 0.7+ no longer needs explicit `registerChiseled` task: running // `./gradlew build` from the root automatically runs `build` for every version diff --git a/versions/26.1/gradle.properties b/versions/26.1/gradle.properties index c5050b1..dfd7869 100644 --- a/versions/26.1/gradle.properties +++ b/versions/26.1/gradle.properties @@ -3,10 +3,10 @@ loader_version=0.19.2 java_version=25 # fabric.mod.json range -mc_dep=>=26 +mc_dep=>=26.1 <26.2 loader_dep=>=0.19 -# Fabric API + ecosystem +# Fabric API + ecosystem (runtime deps, not bundled) fabric_api_version=0.145.1+26.1 modmenu_version=18.0.0-alpha.8 cloth_config_version=26.1.154 diff --git a/versions/26.2/gradle.properties b/versions/26.2/gradle.properties new file mode 100644 index 0000000..d5bee7a --- /dev/null +++ b/versions/26.2/gradle.properties @@ -0,0 +1,12 @@ +minecraft_version=26.2 +loader_version=0.19.3 +java_version=25 + +# fabric.mod.json range +mc_dep=>=26.2 +loader_dep=>=0.19 + +# Fabric API + ecosystem (26.2 requires these as runtime deps, not bundled) +fabric_api_version=0.154.0+26.2 +modmenu_version=18.0.0-alpha.8 +cloth_config_version=26.1.154 From 0c7a2e1c5178f8edc92ab2e1734d1dbc3fa30a03 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 10:54:34 +0000 Subject: [PATCH 4/7] refactor: deduplicate dependency block in build.gradle Use a single implCfg (modImplementation vs implementation) and only gate mappings loom.officialMojangMappings() behind isLegacy. Co-authored-by: Dominic Seung --- build.gradle | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/build.gradle b/build.gradle index 5a0e259..743424e 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,6 @@ plugins { def mcVersion = stonecutter.current.version def isLegacy = stonecutter.eval(mcVersion, '<26') // 1.21.x family -def hasHudElementRegistry = stonecutter.eval(mcVersion, '>=1.21.6') // 1.21.6+ and 26+ apply plugin: isLegacy ? 'net.fabricmc.fabric-loom-remap' : 'net.fabricmc.fabric-loom' @@ -44,25 +43,20 @@ if (!isLegacy) { // Every variant declares fabric-api, modmenu, and cloth-config as runtime deps // (not bundled). 1.21.x uses modImplementation for intermediary remapping; 26.x // uses plain implementation against unobfuscated MC. +def implCfg = isLegacy ? 'modImplementation' : 'implementation' + dependencies { minecraft "com.mojang:minecraft:${project.minecraft_version}" if (isLegacy) { mappings loom.officialMojangMappings() + } - modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" - modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" - modImplementation "com.terraformersmc:modmenu:${project.modmenu_version}" - modImplementation("me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { - exclude(group: 'net.fabricmc.fabric-api') - } - } else { - implementation "net.fabricmc:fabric-loader:${project.loader_version}" - implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" - implementation "com.terraformersmc:modmenu:${project.modmenu_version}" - implementation("me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { - exclude(group: 'net.fabricmc.fabric-api') - } + add(implCfg, "net.fabricmc:fabric-loader:${project.loader_version}") + add(implCfg, "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}") + add(implCfg, "com.terraformersmc:modmenu:${project.modmenu_version}") + add(implCfg, "me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { + exclude(group: 'net.fabricmc.fabric-api') } } From ff1a457edabd0739f738e012eb60a0f7963bf50c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 10:57:08 +0000 Subject: [PATCH 5/7] Revert "refactor: deduplicate dependency block in build.gradle" This reverts commit 0c7a2e1c5178f8edc92ab2e1734d1dbc3fa30a03. --- build.gradle | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/build.gradle b/build.gradle index 743424e..5a0e259 100644 --- a/build.gradle +++ b/build.gradle @@ -10,6 +10,7 @@ plugins { def mcVersion = stonecutter.current.version def isLegacy = stonecutter.eval(mcVersion, '<26') // 1.21.x family +def hasHudElementRegistry = stonecutter.eval(mcVersion, '>=1.21.6') // 1.21.6+ and 26+ apply plugin: isLegacy ? 'net.fabricmc.fabric-loom-remap' : 'net.fabricmc.fabric-loom' @@ -43,20 +44,25 @@ if (!isLegacy) { // Every variant declares fabric-api, modmenu, and cloth-config as runtime deps // (not bundled). 1.21.x uses modImplementation for intermediary remapping; 26.x // uses plain implementation against unobfuscated MC. -def implCfg = isLegacy ? 'modImplementation' : 'implementation' - dependencies { minecraft "com.mojang:minecraft:${project.minecraft_version}" if (isLegacy) { mappings loom.officialMojangMappings() - } - add(implCfg, "net.fabricmc:fabric-loader:${project.loader_version}") - add(implCfg, "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}") - add(implCfg, "com.terraformersmc:modmenu:${project.modmenu_version}") - add(implCfg, "me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { - exclude(group: 'net.fabricmc.fabric-api') + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + modImplementation "com.terraformersmc:modmenu:${project.modmenu_version}" + modImplementation("me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { + exclude(group: 'net.fabricmc.fabric-api') + } + } else { + implementation "net.fabricmc:fabric-loader:${project.loader_version}" + implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + implementation "com.terraformersmc:modmenu:${project.modmenu_version}" + implementation("me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { + exclude(group: 'net.fabricmc.fabric-api') + } } } From 39989ee8650b95f8283d1191fdd38e6060fec2d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 16:29:08 +0000 Subject: [PATCH 6/7] feat: launch-test every supported Minecraft version concurrently in CI Ports the production client launch test (PR #22) across the multi-version build: 16 concurrent CI jobs each boot a real production Fabric client (1.21-1.21.5, 1.21.6-1.21.11, 26.1-26.1.2, 26.2) with the covering variant's built jar, enter a survival singleplayer world with the HUD active, screenshot it, and fail on any crash. - launchtest/: sourceless Gradle subproject applying Loom with -PtestMcVersion as its Minecraft version (Loom's ClientProductionRunTask can only launch the project's own MC version). Its runtimeMatrix pins, per runtime version, the newest fabric-api / Mod Menu / Cloth Config whose fabric.mod.json actually accepts that version (verified against each jar's declared depends range). - Test driver mod, per version family: * 26.x uses /src/gametest from main unchanged (gametest API 5.x) * 1.21.4-1.21.11 uses a copy with 4.x naming (getClientWorld) * 1.21-1.21.3 has no client gametest API: a fallback mod joins a world pre-generated by that version's dedicated server (generate-test-world.sh) via --quickPlaySingleplayer, screenshots it, and exits - CI: build matrix (4 variants) + launch matrix (16 versions) + a screenshot report job that publishes every version's survival screenshot to the ci-screenshots branch and embeds the full grid in a PR comment and the job summary. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016TdZ9ujK7PCyCmHz6b2P4i --- .github/scripts/generate-test-world.sh | 62 ++++++ .github/scripts/publish-screenshots.sh | 80 ++++++++ .github/workflows/build.yml | 178 ++++++++++++++++- launchtest/build.gradle | 184 ++++++++++++++++++ .../betterhud/gametest/LaunchTestClient.java | 68 +++++++ .../src/fallback/resources/fabric.mod.json | 16 ++ .../gametest/BetterHudClientGameTest.java | 44 +++++ settings.gradle | 5 + 8 files changed, 634 insertions(+), 3 deletions(-) create mode 100755 .github/scripts/generate-test-world.sh create mode 100755 .github/scripts/publish-screenshots.sh create mode 100644 launchtest/build.gradle create mode 100644 launchtest/src/fallback/java/dsns/betterhud/gametest/LaunchTestClient.java create mode 100644 launchtest/src/fallback/resources/fabric.mod.json create mode 100644 launchtest/src/gametest-legacy/java/dsns/betterhud/gametest/BetterHudClientGameTest.java diff --git a/.github/scripts/generate-test-world.sh b/.github/scripts/generate-test-world.sh new file mode 100755 index 0000000..8fcafe1 --- /dev/null +++ b/.github/scripts/generate-test-world.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Generates a vanilla singleplayer world save for the given Minecraft version +# by briefly running that version's dedicated server, and places it where the +# launch test expects it (launchtest/run/saves/ci-world). +# +# Only needed for versions whose Fabric API has no client gametest module +# (before 1.21.4): their launch test cannot create a world in-game, so the +# client auto-joins this save via --quickPlaySingleplayer instead. Using the +# exact same Minecraft version to generate the world avoids any world-upgrade +# prompts when the client opens it. +set -euo pipefail + +MC_VERSION="${1:?usage: generate-test-world.sh }" +DEST="$(pwd)/launchtest/run/saves/ci-world" + +WORK="$(mktemp -d)" +cd "$WORK" + +MANIFEST_URL="$(curl -sSf https://piston-meta.mojang.com/mc/game/version_manifest_v2.json \ + | jq -r --arg v "$MC_VERSION" '.versions[] | select(.id == $v) | .url')" +SERVER_URL="$(curl -sSf "$MANIFEST_URL" | jq -r '.downloads.server.url')" +curl -sSfo server.jar "$SERVER_URL" + +echo "eula=true" > eula.txt +# A flat world keeps generation fast and renders instantly on the CI software +# renderer. The default gamemode (survival) matches the gametest-based runs. +cat > server.properties <<'EOF' +level-name=ci-world +level-type=minecraft\:flat +view-distance=4 +simulation-distance=4 +online-mode=false +spawn-protection=0 +EOF + +mkfifo console +java -Xmx1G -jar server.jar nogui < console > server-log.txt 2>&1 & +SERVER_PID=$! +exec 3> console + +for _ in $(seq 1 300); do + grep -q 'Done (' server-log.txt && break + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + cat server-log.txt + echo "::error::Dedicated server for $MC_VERSION exited before finishing world generation" >&2 + exit 1 + fi + sleep 1 +done +if ! grep -q 'Done (' server-log.txt; then + cat server-log.txt + echo "::error::Dedicated server for $MC_VERSION did not finish starting within 300s" >&2 + exit 1 +fi + +echo "stop" >&3 +wait "$SERVER_PID" +exec 3>&- + +mkdir -p "$(dirname "$DEST")" +cp -r ci-world "$DEST" +echo "Generated test world at $DEST" diff --git a/.github/scripts/publish-screenshots.sh b/.github/scripts/publish-screenshots.sh new file mode 100755 index 0000000..cf77ee5 --- /dev/null +++ b/.github/scripts/publish-screenshots.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Collects the launch-test screenshots uploaded as artifacts by every matrix +# job of the current workflow run, normalizes their names, and commits them to +# the ci-screenshots branch so they can be embedded (as raw.githubusercontent +# URLs) in a PR comment and in the job summary. +# +# Leaves the normalized screenshots in screenshots-publish/ for the comment +# step: /mc--survival.png and /mc--title.png. +# +# Required env: GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA +set -euo pipefail + +api() { + curl -sSf -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/vnd.github+json" "$@" +} + +# --- Download every launch-screenshots-mc* artifact of this run ------------- +mkdir -p artifact-zips shots +api "https://api.github.com/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts?per_page=100" > artifacts.json + +while IFS=: read -r id name; do + mc="${name#launch-screenshots-mc}" + echo "Downloading $name" + curl -sSfL -H "Authorization: Bearer $GITHUB_TOKEN" \ + -o "artifact-zips/$id.zip" \ + "https://api.github.com/repos/$GITHUB_REPOSITORY/actions/artifacts/$id/zip" + mkdir -p "shots/$mc" + unzip -oq "artifact-zips/$id.zip" -d "shots/$mc" +done < <(jq -r '.artifacts[] | select(.name | startswith("launch-screenshots-mc")) | "\(.id):\(.name)"' artifacts.json) + +# --- Normalize names (gametest screenshots carry a timestamp prefix) -------- +PUBLISH="screenshots-publish/$GITHUB_SHA" +mkdir -p "$PUBLISH" + +for dir in shots/*/; do + mc="$(basename "$dir")" + for kind in survival-world title-screen; do + src="$(find "$dir" -name "*betterhud-$kind.png" -print -quit)" + if [ -n "$src" ]; then + cp "$src" "$PUBLISH/mc-$mc-${kind%%-*}.png" + fi + done +done + +count="$(find "$PUBLISH" -name '*.png' | wc -l)" +echo "Collected $count screenshots" +if [ "$count" -eq 0 ]; then + echo "No screenshots to publish, skipping branch update" + exit 0 +fi + +# --- Commit to the ci-screenshots branch ------------------------------------ +REMOTE="https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" +if ! git clone --quiet --depth 1 --branch ci-screenshots "$REMOTE" ci-screenshots-branch 2>/dev/null; then + mkdir -p ci-screenshots-branch + git -C ci-screenshots-branch init --quiet -b ci-screenshots + git -C ci-screenshots-branch remote add origin "$REMOTE" +fi + +mkdir -p "ci-screenshots-branch/$GITHUB_SHA" +cp "$PUBLISH"/*.png "ci-screenshots-branch/$GITHUB_SHA/" +cd ci-screenshots-branch +git add . +if git diff --cached --quiet; then + echo "Screenshots already published for $GITHUB_SHA" + exit 0 +fi +git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit --quiet -m "CI screenshots for $GITHUB_SHA" + +# Retry the push in case a concurrent run updated the branch first. +for _ in 1 2 3; do + if git push --quiet origin ci-screenshots; then + echo "Published screenshots to ci-screenshots/$GITHUB_SHA" + exit 0 + fi + git pull --quiet --rebase origin ci-screenshots || true +done +echo "::error::Could not push screenshots to the ci-screenshots branch" >&2 +exit 1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 24ec160..d367f33 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,10 +1,20 @@ -# Build the mod against every Minecraft variant Stonecutter is configured for. -# Each matrix job builds one variant and uploads its jar. -# Running `./gradlew build` from the root builds all four in one go. +# Build the mod for every Stonecutter variant, then launch-test the built jars +# on EVERY supported Minecraft version concurrently: each launch job boots a +# real production Fabric client under XVFB with the covering variant's jar and +# version-matched runtime mods, enters a survival singleplayer world with the +# HUD active, screenshots it, and fails if anything crashes on the way. +# Finally, the screenshot report job embeds all screenshots in a PR comment +# (via the ci-screenshots branch) and in the job summary. name: build on: push +# contents: write lets the screenshot report push to the ci-screenshots branch, +# pull-requests: write lets it comment on the associated pull request. +permissions: + contents: write + pull-requests: write + jobs: build: name: build mc${{ matrix.mc }} @@ -45,3 +55,165 @@ jobs: with: name: betterhud-mc${{ matrix.mc }} path: versions/${{ matrix.mc }}/build/libs/*.jar + + # One job per supported Minecraft version (not per build variant): the + # 1.21 variant jar is launch-tested on 1.21-1.21.5, the 1.21.6 variant jar + # on 1.21.6-1.21.11, and so on. Keep this matrix in sync with the + # runtimeMatrix table in launchtest/build.gradle. + # + # pregen_world marks versions whose Fabric API has no client gametest module + # (before 1.21.4): for those a vanilla world save is generated first with + # that version's dedicated server, and the client auto-joins it instead of + # creating one through the gametest API. + launch-test: + name: launch mc${{ matrix.mc }} + runs-on: ubuntu-24.04 + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + include: + - { mc: '1.21', java: '21', pregen_world: true } + - { mc: '1.21.1', java: '21', pregen_world: true } + - { mc: '1.21.2', java: '21', pregen_world: true } + - { mc: '1.21.3', java: '21', pregen_world: true } + - { mc: '1.21.4', java: '21' } + - { mc: '1.21.5', java: '21' } + - { mc: '1.21.6', java: '21' } + - { mc: '1.21.7', java: '21' } + - { mc: '1.21.8', java: '21' } + - { mc: '1.21.9', java: '21' } + - { mc: '1.21.10', java: '21' } + - { mc: '1.21.11', java: '21' } + - { mc: '26.1', java: '25' } + - { mc: '26.1.1', java: '25' } + - { mc: '26.1.2', java: '25' } + - { mc: '26.2', java: '25' } + steps: + - name: checkout repository + uses: actions/checkout@v6 + + - name: validate gradle wrapper + uses: gradle/actions/wrapper-validation@v6 + + - name: setup jdk ${{ matrix.java }} + uses: actions/setup-java@v5 + with: + java-version: ${{ matrix.java }} + distribution: 'microsoft' + + - name: make gradle wrapper executable + run: chmod +x ./gradlew + + - name: install xvfb + # Provides a virtual display so the Minecraft client can launch headlessly. + run: sudo apt-get update && sudo apt-get install -y xvfb + + - name: generate singleplayer test world + if: ${{ matrix.pregen_world }} + run: bash .github/scripts/generate-test-world.sh ${{ matrix.mc }} + + - name: launch minecraft ${{ matrix.mc }} with mod + run: ./gradlew :launchtest:runProductionClientGameTest -PtestMcVersion=${{ matrix.mc }} --stacktrace + + - name: check survival world screenshot was taken + run: test -n "$(find launchtest/run/screenshots -name '*betterhud-survival-world.png' -print -quit 2>/dev/null)" + + - name: upload screenshots + # Consumed by the screenshot-report job below. + if: always() + uses: actions/upload-artifact@v7 + with: + name: launch-screenshots-mc${{ matrix.mc }} + path: launchtest/run/screenshots/ + if-no-files-found: ignore + + - name: upload game logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: launch-logs-mc${{ matrix.mc }} + path: launchtest/run/logs/ + if-no-files-found: ignore + + screenshot-report: + name: screenshot report + runs-on: ubuntu-24.04 + needs: launch-test + if: ${{ always() }} + steps: + - name: checkout repository + uses: actions/checkout@v6 + + - name: publish screenshots to the ci-screenshots branch + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash .github/scripts/publish-screenshots.sh + + - name: post screenshot grid to pull request and job summary + uses: actions/github-script@v8 + with: + script: | + const fs = require('fs'); + + // Keep in sync with the launch-test matrix above. + const versions = [ + '1.21', '1.21.1', '1.21.2', '1.21.3', '1.21.4', '1.21.5', + '1.21.6', '1.21.7', '1.21.8', '1.21.9', '1.21.10', '1.21.11', + '26.1', '26.1.1', '26.1.2', '26.2', + ]; + const dir = `screenshots-publish/${context.sha}`; + const base = `https://raw.githubusercontent.com/${context.repo.owner}/${context.repo.repo}/ci-screenshots/${context.sha}`; + + const cells = versions.map(v => { + const file = `mc-${v}-survival.png`; + const ok = fs.existsSync(`${dir}/${file}`); + return ok + ? `${v}
BetterHUD on Minecraft ${v}` + : `${v}
❌ no screenshot`; + }); + let rows = ''; + for (let i = 0; i < cells.length; i += 4) { + rows += `${cells.slice(i, i + 4).join('')}\n`; + } + + const marker = ''; + const body = [ + marker, + '### Client launch test', + `The game launched and entered a survival singleplayer world with the HUD active, on every supported Minecraft version, for \`${context.sha.slice(0, 7)}\`. A ❌ means that version's launch test did not produce a screenshot — check its job. Title-screen screenshots and game logs are in the run artifacts.`, + '', + `\n${rows}
`, + ].join('\n'); + + // Always show the grid in the job summary, even without a PR. + await core.summary.addRaw(body).write(); + + const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.sha, + }); + const pr = prs.find(p => p.state === 'open'); + if (!pr) { + core.info('No open pull request for this commit, skipping comment'); + return; + } + + // Keep a single comment up to date instead of posting one per push. + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, repo: context.repo.repo, + issue_number: pr.number, per_page: 100, + }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, repo: context.repo.repo, + comment_id: existing.id, body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, + issue_number: pr.number, body, + }); + } diff --git a/launchtest/build.gradle b/launchtest/build.gradle new file mode 100644 index 0000000..92a0a44 --- /dev/null +++ b/launchtest/build.gradle @@ -0,0 +1,184 @@ +// Production client launch test harness (multi-version port of PR #22). +// +// Boots a REAL production Fabric client (fabric-loader + vanilla Minecraft + +// the built betterhud jar + its runtime dependency mods) for ANY supported +// Minecraft version, verifies the game reaches the title screen and a survival +// singleplayer world with the HUD active, takes screenshots, and exits. Any +// crash on the way fails the build. The Minecraft version to launch is chosen +// with a Gradle property: +// +// ./gradlew :launchtest:runProductionClientGameTest -PtestMcVersion=1.21.3 +// +// Loom's ClientProductionRunTask is hard-wired to the project's Minecraft +// version, so this project applies Loom with `testMcVersion` as its Minecraft +// version, then launches the jar built by the Stonecutter variant that covers +// that version. On Linux CI (CI env var set) Loom runs the game under XVFB. +// +// The test driver mod compiled by this project comes in three flavours, +// because Fabric's client gametest API only exists from 1.21.4: +// - 26.x: /src/gametest (shared with main, Mojang-style API names) +// - 1.21.4-1.21.11: src/gametest-legacy (same test, Yarn-style API names) +// - 1.21-1.21.3: src/fallback (no gametest API: joins a pre-generated +// world via --quickPlaySingleplayer, screenshots, exits; +// see .github/scripts/generate-test-world.sh) + +plugins { + id 'net.fabricmc.fabric-loom-remap' version "${loom_version}" apply false + id 'net.fabricmc.fabric-loom' version "${loom_version}" apply false +} + +// --------------------------------------------------------------------------- +// Runtime compatibility matrix: one row per launchable Minecraft version. +// - variant: the Stonecutter build variant whose jar covers this version +// - fabricApi: newest Fabric API built for exactly this version +// - modmenu: newest Mod Menu whose fabric.mod.json accepts this version +// - cloth: newest Cloth Config whose fabric.mod.json accepts this version +// - gametest: whether this Fabric API provides fabric-client-gametest-api-v1 +// (absent before 1.21.4; those versions use src/fallback) +// All mod versions were verified against each jar's declared `depends` range. +// Keep in sync with the launch-test matrix in .github/workflows/build.yml. +// --------------------------------------------------------------------------- +def runtimeMatrix = [ + '1.21' : [variant: '1.21', fabricApi: '0.102.0+1.21', modmenu: '11.0.4', cloth: '15.0.140', gametest: false], + '1.21.1' : [variant: '1.21', fabricApi: '0.116.13+1.21.1', modmenu: '11.0.4', cloth: '15.0.140', gametest: false], + '1.21.2' : [variant: '1.21', fabricApi: '0.106.1+1.21.2', modmenu: '12.0.1', cloth: '16.0.143', gametest: false], + '1.21.3' : [variant: '1.21', fabricApi: '0.114.1+1.21.3', modmenu: '12.0.1', cloth: '16.0.143', gametest: false], + '1.21.4' : [variant: '1.21', fabricApi: '0.119.4+1.21.4', modmenu: '13.0.4', cloth: '17.0.144', gametest: true], + '1.21.5' : [variant: '1.21', fabricApi: '0.128.2+1.21.5', modmenu: '14.0.2', cloth: '18.0.145', gametest: true], + '1.21.6' : [variant: '1.21.6', fabricApi: '0.128.2+1.21.6', modmenu: '15.0.2', cloth: '19.0.147', gametest: true], + '1.21.7' : [variant: '1.21.6', fabricApi: '0.129.0+1.21.7', modmenu: '15.0.2', cloth: '19.0.147', gametest: true], + '1.21.8' : [variant: '1.21.6', fabricApi: '0.136.1+1.21.8', modmenu: '15.0.2', cloth: '19.0.147', gametest: true], + '1.21.9' : [variant: '1.21.6', fabricApi: '0.134.1+1.21.9', modmenu: '16.0.1', cloth: '20.0.149', gametest: true], + '1.21.10': [variant: '1.21.6', fabricApi: '0.138.4+1.21.10', modmenu: '16.0.1', cloth: '21.11.153', gametest: true], + '1.21.11': [variant: '1.21.6', fabricApi: '0.141.4+1.21.11', modmenu: '17.0.0', cloth: '21.11.153', gametest: true], + '26.1' : [variant: '26.1', fabricApi: '0.145.1+26.1', modmenu: '18.0.0-beta.1', cloth: '26.1.154', gametest: true], + '26.1.1' : [variant: '26.1', fabricApi: '0.145.4+26.1.1', modmenu: '18.0.0-beta.1', cloth: '26.1.154', gametest: true], + '26.1.2' : [variant: '26.1', fabricApi: '0.154.0+26.1.2', modmenu: '18.0.0-beta.1', cloth: '26.1.154', gametest: true], + '26.2' : [variant: '26.2', fabricApi: '0.154.0+26.2', modmenu: '20.0.0-beta.4', cloth: '26.2.155', gametest: true], +] + +def testMcVersion = providers.gradleProperty('testMcVersion').getOrElse('26.2') +def entry = runtimeMatrix[testMcVersion] +if (entry == null) { + throw new GradleException("Unsupported testMcVersion '${testMcVersion}'. Supported versions: ${runtimeMatrix.keySet().join(', ')}") +} + +// Reuse the covering variant's loader and Java versions so the launch matches +// what that variant is built and shipped for. +def variantProps = new Properties() +file("../versions/${entry.variant}/gradle.properties").withInputStream { variantProps.load(it) } +def loaderVersion = variantProps.getProperty('loader_version') +def javaVersionInt = Integer.parseInt(variantProps.getProperty('java_version')) + +// Same plugin split as the variant builds: obfuscated 1.21.x needs the remap +// plugin (intermediary production namespace), 26.x runs Mojang names natively. +def isLegacy = entry.variant.startsWith('1.') +apply plugin: isLegacy ? 'net.fabricmc.fabric-loom-remap' : 'net.fabricmc.fabric-loom' + +version = '1.0.0' +base { + archivesName = 'betterhud-launchtest' +} + +repositories { + maven { url 'https://maven.shedaniel.me/' } + maven { url 'https://maven.terraformersmc.com/releases/' } +} + +// Matches the variant builds: MC 26+ rejects ASM coming from annotation processors. +if (!isLegacy) { + configurations.configureEach { + if (name.toLowerCase().contains('annotationprocessor')) { + exclude group: 'org.ow2.asm' + } + } +} + +// Pick the test driver source flavour for this Minecraft version (see header). +if (entry.gametest) { + sourceSets.main.java.srcDirs = [isLegacy ? 'src/gametest-legacy/java' : "${rootDir}/src/gametest/java"] + sourceSets.main.resources.srcDirs = ["${rootDir}/src/gametest/resources"] +} else { + sourceSets.main.java.srcDirs = ['src/fallback/java'] + sourceSets.main.resources.srcDirs = ['src/fallback/resources'] +} + +dependencies { + minecraft "com.mojang:minecraft:${testMcVersion}" + + if (isLegacy) { + mappings loom.officialMojangMappings() + modImplementation "net.fabricmc:fabric-loader:${loaderVersion}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${entry.fabricApi}" + if (entry.gametest) { + modImplementation fabricApi.module('fabric-client-gametest-api-v1', entry.fabricApi) + } + } else { + implementation "net.fabricmc:fabric-loader:${loaderVersion}" + implementation "net.fabricmc.fabric-api:fabric-api:${entry.fabricApi}" + implementation fabricApi.module('fabric-client-gametest-api-v1', entry.fabricApi) + } + + // Mods loaded alongside the built betterhud jar by the production run. + // These are production (intermediary/official) jars straight off the mod + // mavens; transitive deps are excluded because each fat jar already nests + // everything it needs via jar-in-jar. + productionRuntimeMods("net.fabricmc.fabric-api:fabric-api:${entry.fabricApi}") { transitive = false } + productionRuntimeMods("com.terraformersmc:modmenu:${entry.modmenu}") { transitive = false } + productionRuntimeMods("me.shedaniel.cloth:cloth-config-fabric:${entry.cloth}") { transitive = false } + + if (entry.gametest) { + // Handles -Dfabric.client.gametest in the production client. Not part + // of the fabric-api fat jar, so it is loaded as its own mod. + productionRuntimeMods(fabricApi.module('fabric-client-gametest-api-v1', entry.fabricApi)) { transitive = false } + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = javaVersionInt +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(javaVersionInt) + } +} + +evaluationDependsOn(":${entry.variant}") +def variantProject = project(":${entry.variant}") +// 1.21.x projects ship the intermediary-remapped jar; 26.x projects ship the +// plain jar (no remapping for unobfuscated Minecraft). +def variantJarTask = variantProject.tasks.names.contains('remapJar') ? 'remapJar' : 'jar' +def ownJarTask = tasks.names.contains('remapJar') ? 'remapJar' : 'jar' + +// Launches a real production Fabric client and runs the launch test: reach the +// title screen, enter a survival singleplayer world with the HUD rendering, +// screenshot it (run/screenshots/), and shut down cleanly. +tasks.register('runProductionClientGameTest', net.fabricmc.loom.task.prod.ClientProductionRunTask) { + mods.setFrom(variantProject.tasks.named(variantJarTask), tasks.named(ownJarTask), configurations.productionRuntimeMods) + + if (entry.gametest) { + // Fabric's client gametest runner drives the test and shuts the game + // down after the registered gametest (src/gametest) finishes. + jvmArgs.add('-Dfabric.client.gametest') + jvmArgs.add('-Dfabric.client.gametest.disableNetworkSynchronizer=true') + } else if (file('run/saves/ci-world').exists()) { + // No gametest API on this version: auto-join the pre-generated world; + // the fallback testmod screenshots it and stops the client. Without a + // pre-generated world the testmod stops at the title screen instead. + jvmArgs.add('-Dbetterhud.launchtest.expectWorld=true') + programArgs.addAll('--quickPlaySingleplayer', 'ci-world') + } + + doFirst { + // On a fresh game dir Minecraft opens the accessibility onboarding + // screen instead of the title screen; disable it so the run always + // reaches the title screen. (The gametest module also forces this via + // mixin; the fallback testmod needs it done here.) + def options = new File(runDir.get().asFile, 'options.txt') + if (!options.exists()) { + options.parentFile.mkdirs() + options.text = 'onboardAccessibility:false\n' + } + } +} diff --git a/launchtest/src/fallback/java/dsns/betterhud/gametest/LaunchTestClient.java b/launchtest/src/fallback/java/dsns/betterhud/gametest/LaunchTestClient.java new file mode 100644 index 0000000..cb21937 --- /dev/null +++ b/launchtest/src/fallback/java/dsns/betterhud/gametest/LaunchTestClient.java @@ -0,0 +1,68 @@ +package dsns.betterhud.gametest; + +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.client.gui.screens.TitleScreen; + +/** + * Fallback launch test driver for Minecraft versions whose Fabric API predates + * the client gametest module (before 1.21.4). + * + *

When the run is started with {@code --quickPlaySingleplayer ci-world} and + * {@code -Dbetterhud.launchtest.expectWorld=true} (see the launchtest Gradle + * project), the game joins the pre-generated survival world; once it has ticked + * long enough for chunks to render with the HUD active, this mod screenshots it + * and schedules a clean shutdown, making the client exit with code 0. Without a + * pre-generated world it screenshots the title screen and shuts down there + * instead. A crash anywhere on the way fails the run. + */ +public class LaunchTestClient implements ClientModInitializer { + private static final boolean EXPECT_WORLD = Boolean.getBoolean("betterhud.launchtest.expectWorld"); + + // 200 ticks (10s) gives the software renderer on CI time to draw chunks. + private static final int WORLD_SCREENSHOT_TICK = 200; + private static final int WORLD_STOP_TICK = 240; + private static final int TITLE_SCREENSHOT_TICK = 40; + private static final int TITLE_STOP_TICK = 80; + // 2400 ticks (2min) is plenty to join the pre-generated flat world; bail + // out with a failure instead of hanging until the CI job timeout. + private static final int WORLD_JOIN_TIMEOUT_TICKS = 2400; + + private int totalTicks; + private int worldTicks = -1; + private int titleTicks = -1; + + @Override + public void onInitializeClient() { + ClientTickEvents.END_CLIENT_TICK.register(client -> { + totalTicks++; + + if (EXPECT_WORLD && worldTicks < 0 && totalTicks >= WORLD_JOIN_TIMEOUT_TICKS) { + System.err.println("betterhud launch test: the game never joined the pre-generated world"); + System.exit(1); + } + + if (client.player != null && client.level != null) { + worldTicks++; + if (worldTicks == WORLD_SCREENSHOT_TICK) { + takeScreenshot(client, "betterhud-survival-world"); + } else if (worldTicks == WORLD_STOP_TICK) { + client.stop(); + } + } else if (!EXPECT_WORLD && client.screen instanceof TitleScreen) { + titleTicks++; + if (titleTicks == TITLE_SCREENSHOT_TICK) { + takeScreenshot(client, "betterhud-title-screen"); + } else if (titleTicks == TITLE_STOP_TICK) { + client.stop(); + } + } + }); + } + + private static void takeScreenshot(Minecraft client, String name) { + Screenshot.grab(client.gameDirectory, name + ".png", client.getMainRenderTarget(), message -> {}); + } +} diff --git a/launchtest/src/fallback/resources/fabric.mod.json b/launchtest/src/fallback/resources/fabric.mod.json new file mode 100644 index 0000000..ec2bc93 --- /dev/null +++ b/launchtest/src/fallback/resources/fabric.mod.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "id": "betterhud-gametest", + "version": "1.0.0", + "name": "BetterHUD Launch Test", + "description": "Fallback launch test for BetterHUD on Minecraft versions without Fabric's client gametest module: screenshots the (pre-generated) survival world or the title screen, then exits.", + "license": "AGPL-3.0", + "environment": "client", + "entrypoints": { + "client": ["dsns.betterhud.gametest.LaunchTestClient"] + }, + "depends": { + "betterhud": "*", + "fabric-lifecycle-events-v1": "*" + } +} diff --git a/launchtest/src/gametest-legacy/java/dsns/betterhud/gametest/BetterHudClientGameTest.java b/launchtest/src/gametest-legacy/java/dsns/betterhud/gametest/BetterHudClientGameTest.java new file mode 100644 index 0000000..071f168 --- /dev/null +++ b/launchtest/src/gametest-legacy/java/dsns/betterhud/gametest/BetterHudClientGameTest.java @@ -0,0 +1,44 @@ +package dsns.betterhud.gametest; + +// Copy of /src/gametest/.../BetterHudClientGameTest.java for Minecraft +// 1.21.4-1.21.11, whose fabric-client-gametest-api-v1 (4.x) names the client +// world accessor getClientWorld() instead of getClientLevel() (5.x, 26.x). +// Keep the two files in sync when changing the test. + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.fabricmc.fabric.api.client.gametest.v1.context.TestSingleplayerContext; +import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState; + +@SuppressWarnings("UnstableApiUsage") +public class BetterHudClientGameTest implements FabricClientGameTest { + @Override + public void runTest(ClientGameTestContext context) { + assertScreenshotSaved(context.takeScreenshot("betterhud-title-screen")); + + try (TestSingleplayerContext singleplayer = context.worldBuilder() + .adjustSettings(creator -> creator.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL)) + .create()) { + singleplayer.getClientWorld().waitForChunksRender(); + + // Let the world tick a little with the HUD rendering before capturing evidence. + context.waitTicks(40); + assertScreenshotSaved(context.takeScreenshot("betterhud-survival-world")); + } + } + + private static void assertScreenshotSaved(Path screenshot) { + try { + if (!Files.isRegularFile(screenshot) || Files.size(screenshot) == 0) { + throw new AssertionError("Screenshot was not saved: " + screenshot); + } + } catch (IOException e) { + throw new UncheckedIOException("Could not verify screenshot " + screenshot, e); + } + } +} diff --git a/settings.gradle b/settings.gradle index edfa934..dea63f4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,3 +34,8 @@ stonecutter { } rootProject.name = 'betterhud' + +// Not a Stonecutter variant: a sourceless harness that launches a production +// Fabric client for any supported MC version (-PtestMcVersion) with the +// matching variant's built jar. Used by the CI launch-test matrix. +include 'launchtest' From 3fa349e8ffba8ed2e0d50b903cb0c44b74732b70 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 16:57:39 +0000 Subject: [PATCH 7/7] refactor: single source of truth for supported versions + Modrinth upload guide - supported-versions.json now drives everything: the Stonecutter variant list (settings.gradle), the launch tests' per-version runtime mods, both CI matrices (generated by a small setup job), and the screenshot report. Adding a new Minecraft version is now a one-file change unless it needs a new build variant (documented in the README). - New root task modrinthBundle collects the release jars into build/modrinth/ with UPLOAD.md listing exactly which Minecraft versions to select for each jar on Modrinth; CI publishes it as the modrinth-upload artifact on every push. - Removed comments referencing the pre-Stonecutter layout (support/* branches, the old single-version main build). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016TdZ9ujK7PCyCmHz6b2P4i --- .github/scripts/generate-matrices.sh | 34 ++++++++++ .github/workflows/build.yml | 95 +++++++++++++++++----------- README.md | 44 +++++++++++++ build.gradle | 2 +- launchtest/build.gradle | 55 ++++++---------- settings.gradle | 19 ++++-- stonecutter.gradle | 54 ++++++++++++++++ supported-versions.json | 18 ++++++ versions/1.21.6/gradle.properties | 3 +- versions/1.21/gradle.properties | 3 +- 10 files changed, 246 insertions(+), 81 deletions(-) create mode 100755 .github/scripts/generate-matrices.sh create mode 100644 supported-versions.json diff --git a/.github/scripts/generate-matrices.sh b/.github/scripts/generate-matrices.sh new file mode 100755 index 0000000..ff6a526 --- /dev/null +++ b/.github/scripts/generate-matrices.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Derives the CI job matrices from supported-versions.json (the single source +# of truth for supported Minecraft versions) and writes them to GITHUB_OUTPUT: +# - build: one entry per build variant -> { mc, java } +# - launch: one entry per launchable version -> { mc, java, pregen_world } +# pregen_world marks versions whose Fabric API has no client gametest module; +# their launch test needs a pre-generated world (see generate-test-world.sh). +set -euo pipefail + +JSON=supported-versions.json + +java_for_variant() { + grep -oP '^java_version=\K.+' "versions/$1/gradle.properties" +} + +launch="[]" +while IFS=$'\t' read -r mc variant gametest; do + java="$(java_for_variant "$variant")" + pregen=$([ "$gametest" = "false" ] && echo true || echo false) + launch="$(jq -c --arg mc "$mc" --arg java "$java" --argjson pregen "$pregen" \ + '. + [{mc: $mc, java: $java, pregen_world: $pregen}]' <<<"$launch")" +done < <(jq -r 'to_entries[] | [.key, .value.variant, (.value.clientGametest | tostring)] | @tsv' "$JSON") + +build="[]" +while read -r variant; do + java="$(java_for_variant "$variant")" + build="$(jq -c --arg mc "$variant" --arg java "$java" \ + '. + [{mc: $mc, java: $java}]' <<<"$build")" +done < <(jq -r '[.[].variant] | reduce .[] as $v ([]; if index($v) then . else . + [$v] end) | .[]' "$JSON") + +echo "build=$build" >> "$GITHUB_OUTPUT" +echo "launch=$launch" >> "$GITHUB_OUTPUT" +echo "build matrix: $build" +echo "launch matrix: $launch" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d367f33..ad149ae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,8 +3,12 @@ # real production Fabric client under XVFB with the covering variant's jar and # version-matched runtime mods, enters a survival singleplayer world with the # HUD active, screenshots it, and fails if anything crashes on the way. -# Finally, the screenshot report job embeds all screenshots in a PR comment -# (via the ci-screenshots branch) and in the job summary. +# The screenshot report job embeds all screenshots in a PR comment (via the +# ci-screenshots branch) and in the job summary, and the modrinth bundle job +# packages the release jars with their per-jar Minecraft version lists. +# +# Both job matrices are generated from supported-versions.json — add a version +# there (see README) and every job picks it up. name: build on: push @@ -16,21 +20,28 @@ permissions: pull-requests: write jobs: + matrices: + name: compute matrices + runs-on: ubuntu-24.04 + outputs: + build: ${{ steps.gen.outputs.build }} + launch: ${{ steps.gen.outputs.launch }} + steps: + - name: checkout repository + uses: actions/checkout@v6 + + - name: generate matrices from supported-versions.json + id: gen + run: bash .github/scripts/generate-matrices.sh + build: name: build mc${{ matrix.mc }} runs-on: ubuntu-24.04 + needs: matrices strategy: fail-fast: false matrix: - include: - - mc: '1.21' - java: '21' - - mc: '1.21.6' - java: '21' - - mc: '26.1' - java: '25' - - mc: '26.2' - java: '25' + include: ${{ fromJson(needs.matrices.outputs.build) }} steps: - name: checkout repository uses: actions/checkout@v6 @@ -58,8 +69,7 @@ jobs: # One job per supported Minecraft version (not per build variant): the # 1.21 variant jar is launch-tested on 1.21-1.21.5, the 1.21.6 variant jar - # on 1.21.6-1.21.11, and so on. Keep this matrix in sync with the - # runtimeMatrix table in launchtest/build.gradle. + # on 1.21.6-1.21.11, and so on. # # pregen_world marks versions whose Fabric API has no client gametest module # (before 1.21.4): for those a vanilla world save is generated first with @@ -68,27 +78,12 @@ jobs: launch-test: name: launch mc${{ matrix.mc }} runs-on: ubuntu-24.04 + needs: matrices timeout-minutes: 40 strategy: fail-fast: false matrix: - include: - - { mc: '1.21', java: '21', pregen_world: true } - - { mc: '1.21.1', java: '21', pregen_world: true } - - { mc: '1.21.2', java: '21', pregen_world: true } - - { mc: '1.21.3', java: '21', pregen_world: true } - - { mc: '1.21.4', java: '21' } - - { mc: '1.21.5', java: '21' } - - { mc: '1.21.6', java: '21' } - - { mc: '1.21.7', java: '21' } - - { mc: '1.21.8', java: '21' } - - { mc: '1.21.9', java: '21' } - - { mc: '1.21.10', java: '21' } - - { mc: '1.21.11', java: '21' } - - { mc: '26.1', java: '25' } - - { mc: '26.1.1', java: '25' } - - { mc: '26.1.2', java: '25' } - - { mc: '26.2', java: '25' } + include: ${{ fromJson(needs.matrices.outputs.launch) }} steps: - name: checkout repository uses: actions/checkout@v6 @@ -156,12 +151,7 @@ jobs: script: | const fs = require('fs'); - // Keep in sync with the launch-test matrix above. - const versions = [ - '1.21', '1.21.1', '1.21.2', '1.21.3', '1.21.4', '1.21.5', - '1.21.6', '1.21.7', '1.21.8', '1.21.9', '1.21.10', '1.21.11', - '26.1', '26.1.1', '26.1.2', '26.2', - ]; + const versions = Object.keys(JSON.parse(fs.readFileSync('supported-versions.json', 'utf8'))); const dir = `screenshots-publish/${context.sha}`; const base = `https://raw.githubusercontent.com/${context.repo.owner}/${context.repo.repo}/ci-screenshots/${context.sha}`; @@ -217,3 +207,36 @@ jobs: issue_number: pr.number, body, }); } + + # Builds every variant and bundles the release jars together with UPLOAD.md, + # which lists exactly which Minecraft versions to select for each jar when + # uploading to Modrinth. Download the "modrinth-upload" artifact and follow it. + modrinth-bundle: + name: modrinth bundle + runs-on: ubuntu-24.04 + steps: + - name: checkout repository + uses: actions/checkout@v6 + + - name: validate gradle wrapper + uses: gradle/actions/wrapper-validation@v6 + + - name: setup jdk 21 and 25 + uses: actions/setup-java@v5 + with: + java-version: | + 21 + 25 + distribution: 'microsoft' + + - name: make gradle wrapper executable + run: chmod +x ./gradlew + + - name: build all variants and write the upload guide + run: ./gradlew modrinthBundle --stacktrace "-Porg.gradle.java.installations.paths=$JAVA_HOME_21_X64,$JAVA_HOME_25_X64" + + - name: upload modrinth bundle + uses: actions/upload-artifact@v7 + with: + name: modrinth-upload + path: build/modrinth/ diff --git a/README.md b/README.md index 92ce8ba..04533a2 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,47 @@ A simple HUD with essential information easily accessible for Minecraft Fabric. 1. Ensure you have Fabric Mod Loader installed. 2. Download the BetterHUD mod file. 3. Place the downloaded file in your Minecraft `mods` folder. + +## Development + +The mod is built for several Minecraft families from one codebase with +[Stonecutter](https://stonecutter.kikugie.dev/). Each build variant +(`versions//gradle.properties`) produces one jar covering a range of +Minecraft versions. + +[`supported-versions.json`](supported-versions.json) is the single source of +truth for every supported Minecraft version. It drives: + +- the Stonecutter variant list (`settings.gradle`), +- the CI matrices — every push builds all variants and **launch-tests the mod + on every supported version concurrently** (a real production client boots + under XVFB, enters a survival world with the HUD active, screenshots it, + and fails on any crash; the screenshots are posted as a grid on the PR), +- the per-version runtime mods used by those launch tests, +- the Modrinth upload guide (see below). + +### Adding a new Minecraft version + +1. Add an entry to `supported-versions.json` (keep the order oldest → newest): + - `variant`: the build variant whose jar covers the new version. Reuse the + newest variant if the mod still compiles and runs on it; create a new + variant only when the new Minecraft breaks the current code. + - `fabricApi`, `modmenu`, `cloth`: the newest releases built for that + Minecraft version (check each project's maven or Modrinth page — the + launch test will catch runtime-incompatible picks). + - `clientGametest`: `true` (every version since 1.21.4 has the module). +2. Only if a new variant is needed: create `versions//gradle.properties` + (copy the newest one and adjust the versions and `mc_dep` range), cap the + previous variant's `mc_dep`, and add any `//?` Stonecutter guards the new + Minecraft requires in the sources. +3. Push. CI picks the new version up automatically and launch-tests it. + +To launch-test one version locally: +`./gradlew :launchtest:runProductionClientGameTest -PtestMcVersion=` + +### Releasing to Modrinth + +Run `./gradlew modrinthBundle` (or download the `modrinth-upload` artifact +from any CI run). It produces `build/modrinth/` containing every release jar +plus `UPLOAD.md`, which lists exactly which Minecraft versions to select for +each jar when creating the Modrinth versions. diff --git a/build.gradle b/build.gradle index 5a0e259..9972fe4 100644 --- a/build.gradle +++ b/build.gradle @@ -32,7 +32,7 @@ repositories { } // MC 26+ uses fabric-rendering-v1's transitive deps but rejects ASM coming from -// annotation processors; matches main's existing build. +// annotation processors. if (!isLegacy) { configurations.configureEach { if (name.toLowerCase().contains('annotationprocessor')) { diff --git a/launchtest/build.gradle b/launchtest/build.gradle index 92a0a44..49d52e8 100644 --- a/launchtest/build.gradle +++ b/launchtest/build.gradle @@ -1,4 +1,4 @@ -// Production client launch test harness (multi-version port of PR #22). +// Production client launch test harness. // // Boots a REAL production Fabric client (fabric-loader + vanilla Minecraft + // the built betterhud jar + its runtime dependency mods) for ANY supported @@ -16,7 +16,7 @@ // // The test driver mod compiled by this project comes in three flavours, // because Fabric's client gametest API only exists from 1.21.4: -// - 26.x: /src/gametest (shared with main, Mojang-style API names) +// - 26.x: /src/gametest (Mojang-style gametest API names, 5.x) // - 1.21.4-1.21.11: src/gametest-legacy (same test, Yarn-style API names) // - 1.21-1.21.3: src/fallback (no gametest API: joins a pre-generated // world via --quickPlaySingleplayer, screenshots, exits; @@ -28,39 +28,22 @@ plugins { } // --------------------------------------------------------------------------- -// Runtime compatibility matrix: one row per launchable Minecraft version. -// - variant: the Stonecutter build variant whose jar covers this version -// - fabricApi: newest Fabric API built for exactly this version -// - modmenu: newest Mod Menu whose fabric.mod.json accepts this version -// - cloth: newest Cloth Config whose fabric.mod.json accepts this version -// - gametest: whether this Fabric API provides fabric-client-gametest-api-v1 -// (absent before 1.21.4; those versions use src/fallback) +// supported-versions.json (repo root) is the single source of truth for the +// launchable Minecraft versions. Per version it pins: +// - variant: the Stonecutter build variant whose jar covers it +// - fabricApi: newest Fabric API built for exactly this version +// - modmenu: newest Mod Menu whose fabric.mod.json accepts this version +// - cloth: newest Cloth Config whose fabric.mod.json accepts this version +// - clientGametest: whether this Fabric API provides fabric-client-gametest-api-v1 +// (absent before 1.21.4; those versions use src/fallback) // All mod versions were verified against each jar's declared `depends` range. -// Keep in sync with the launch-test matrix in .github/workflows/build.yml. // --------------------------------------------------------------------------- -def runtimeMatrix = [ - '1.21' : [variant: '1.21', fabricApi: '0.102.0+1.21', modmenu: '11.0.4', cloth: '15.0.140', gametest: false], - '1.21.1' : [variant: '1.21', fabricApi: '0.116.13+1.21.1', modmenu: '11.0.4', cloth: '15.0.140', gametest: false], - '1.21.2' : [variant: '1.21', fabricApi: '0.106.1+1.21.2', modmenu: '12.0.1', cloth: '16.0.143', gametest: false], - '1.21.3' : [variant: '1.21', fabricApi: '0.114.1+1.21.3', modmenu: '12.0.1', cloth: '16.0.143', gametest: false], - '1.21.4' : [variant: '1.21', fabricApi: '0.119.4+1.21.4', modmenu: '13.0.4', cloth: '17.0.144', gametest: true], - '1.21.5' : [variant: '1.21', fabricApi: '0.128.2+1.21.5', modmenu: '14.0.2', cloth: '18.0.145', gametest: true], - '1.21.6' : [variant: '1.21.6', fabricApi: '0.128.2+1.21.6', modmenu: '15.0.2', cloth: '19.0.147', gametest: true], - '1.21.7' : [variant: '1.21.6', fabricApi: '0.129.0+1.21.7', modmenu: '15.0.2', cloth: '19.0.147', gametest: true], - '1.21.8' : [variant: '1.21.6', fabricApi: '0.136.1+1.21.8', modmenu: '15.0.2', cloth: '19.0.147', gametest: true], - '1.21.9' : [variant: '1.21.6', fabricApi: '0.134.1+1.21.9', modmenu: '16.0.1', cloth: '20.0.149', gametest: true], - '1.21.10': [variant: '1.21.6', fabricApi: '0.138.4+1.21.10', modmenu: '16.0.1', cloth: '21.11.153', gametest: true], - '1.21.11': [variant: '1.21.6', fabricApi: '0.141.4+1.21.11', modmenu: '17.0.0', cloth: '21.11.153', gametest: true], - '26.1' : [variant: '26.1', fabricApi: '0.145.1+26.1', modmenu: '18.0.0-beta.1', cloth: '26.1.154', gametest: true], - '26.1.1' : [variant: '26.1', fabricApi: '0.145.4+26.1.1', modmenu: '18.0.0-beta.1', cloth: '26.1.154', gametest: true], - '26.1.2' : [variant: '26.1', fabricApi: '0.154.0+26.1.2', modmenu: '18.0.0-beta.1', cloth: '26.1.154', gametest: true], - '26.2' : [variant: '26.2', fabricApi: '0.154.0+26.2', modmenu: '20.0.0-beta.4', cloth: '26.2.155', gametest: true], -] - -def testMcVersion = providers.gradleProperty('testMcVersion').getOrElse('26.2') -def entry = runtimeMatrix[testMcVersion] +def supportedVersions = new groovy.json.JsonSlurper().parse(new File(rootDir, 'supported-versions.json')) + +def testMcVersion = providers.gradleProperty('testMcVersion').getOrElse(supportedVersions.keySet().last()) +def entry = supportedVersions[testMcVersion] if (entry == null) { - throw new GradleException("Unsupported testMcVersion '${testMcVersion}'. Supported versions: ${runtimeMatrix.keySet().join(', ')}") + throw new GradleException("Unsupported testMcVersion '${testMcVersion}'. Supported versions: ${supportedVersions.keySet().join(', ')}") } // Reuse the covering variant's loader and Java versions so the launch matches @@ -95,7 +78,7 @@ if (!isLegacy) { } // Pick the test driver source flavour for this Minecraft version (see header). -if (entry.gametest) { +if (entry.clientGametest) { sourceSets.main.java.srcDirs = [isLegacy ? 'src/gametest-legacy/java' : "${rootDir}/src/gametest/java"] sourceSets.main.resources.srcDirs = ["${rootDir}/src/gametest/resources"] } else { @@ -110,7 +93,7 @@ dependencies { mappings loom.officialMojangMappings() modImplementation "net.fabricmc:fabric-loader:${loaderVersion}" modImplementation "net.fabricmc.fabric-api:fabric-api:${entry.fabricApi}" - if (entry.gametest) { + if (entry.clientGametest) { modImplementation fabricApi.module('fabric-client-gametest-api-v1', entry.fabricApi) } } else { @@ -127,7 +110,7 @@ dependencies { productionRuntimeMods("com.terraformersmc:modmenu:${entry.modmenu}") { transitive = false } productionRuntimeMods("me.shedaniel.cloth:cloth-config-fabric:${entry.cloth}") { transitive = false } - if (entry.gametest) { + if (entry.clientGametest) { // Handles -Dfabric.client.gametest in the production client. Not part // of the fabric-api fat jar, so it is loaded as its own mod. productionRuntimeMods(fabricApi.module('fabric-client-gametest-api-v1', entry.fabricApi)) { transitive = false } @@ -157,7 +140,7 @@ def ownJarTask = tasks.names.contains('remapJar') ? 'remapJar' : 'jar' tasks.register('runProductionClientGameTest', net.fabricmc.loom.task.prod.ClientProductionRunTask) { mods.setFrom(variantProject.tasks.named(variantJarTask), tasks.named(ownJarTask), configurations.productionRuntimeMods) - if (entry.gametest) { + if (entry.clientGametest) { // Fabric's client gametest runner drives the test and shuts the game // down after the registered gametest (src/gametest) finishes. jvmArgs.add('-Dfabric.client.gametest') diff --git a/settings.gradle b/settings.gradle index dea63f4..9b53c5b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -21,15 +21,22 @@ plugins { id 'dev.kikugie.stonecutter' version '0.9.3' } +// Every supported Minecraft version, and the build variant that covers it, +// lives in supported-versions.json — the single source of truth also used by +// the launch tests, the CI matrices, and the modrinthBundle task. +def supportedVersions = new groovy.json.JsonSlurper().parse(file('supported-versions.json')) +def variants = supportedVersions.values()*.variant.unique() + stonecutter { create(rootProject) { - // Each entry is one buildable variant. The string is both the subproject name - // and the MC version. These cover 1.21-1.21.5, 1.21.6-1.21.11, 26.1, and 26.2. - versions('1.21', '1.21.6', '26.1', '26.2') + // Each variant is one buildable subproject; the string is both the + // subproject name and the MC version it compiles against (see + // versions//gradle.properties). + versions(*variants) - // vcsVersion is the variant whose form is used as the canonical (active) version - // in source. We pick 26.2 so `git diff main` stays small. - vcsVersion = '26.2' + // vcsVersion is the variant whose form the sources are committed in + // (the "active" version when nothing else is selected). + vcsVersion = variants.last() } } diff --git a/stonecutter.gradle b/stonecutter.gradle index 11c695e..755ca07 100644 --- a/stonecutter.gradle +++ b/stonecutter.gradle @@ -9,3 +9,57 @@ stonecutter.active('26.2') /* [SC] DO NOT EDIT */ // Stonecutter 0.7+ no longer needs explicit `registerChiseled` task: running // `./gradlew build` from the root automatically runs `build` for every version // subproject. Per-version commands like `./gradlew :1.21:build` also work. + +// Groups every supported Minecraft version (supported-versions.json) by the +// build variant whose jar covers it, e.g. '1.21' -> ['1.21', ..., '1.21.5']. +def supportedVersions = new groovy.json.JsonSlurper().parse(file('supported-versions.json')) +def gameVersionsByVariant = [:] +supportedVersions.each { mc, info -> + gameVersionsByVariant.computeIfAbsent(info.variant) { [] } << mc +} + +// Builds every variant and collects the release jars in build/modrinth/ along +// with UPLOAD.md, which lists exactly which Minecraft versions to select for +// each jar when uploading to Modrinth. CI publishes the same directory as the +// "modrinth-upload" artifact on every push. +tasks.register('modrinthBundle') { + group = 'publishing' + description = 'Collects the release jars and the per-jar Minecraft version lists for Modrinth.' + gameVersionsByVariant.keySet().each { dependsOn(":${it}:build") } + + doLast { + def outDir = file('build/modrinth') + outDir.deleteDir() + outDir.mkdirs() + + def rows = [] + gameVersionsByVariant.each { variant, gameVersions -> + def jar = file("versions/${variant}/build/libs").listFiles().find { + it.name.endsWith('.jar') && !it.name.contains('-sources') + } + if (jar == null) { + throw new GradleException("No release jar found for variant ${variant} in versions/${variant}/build/libs") + } + java.nio.file.Files.copy(jar.toPath(), new File(outDir, jar.name).toPath()) + rows << "| `${jar.name}` | ${gameVersions.join(', ')} |" + } + + def manifest = new File(outDir, 'UPLOAD.md') + manifest.text = [ + "# Modrinth upload — betterhud ${project.mod_version}", + '', + 'Upload each jar below as its own Modrinth version, selecting exactly', + 'the listed Minecraft versions for it. Loader: Fabric. Required', + 'dependencies: Fabric API, Cloth Config, Mod Menu.', + '', + '| File | Minecraft versions |', + '| --- | --- |', + *rows, + '' + ].join('\n') + + println() + println manifest.text + println "Bundle written to ${outDir}" + } +} diff --git a/supported-versions.json b/supported-versions.json new file mode 100644 index 0000000..6ef4606 --- /dev/null +++ b/supported-versions.json @@ -0,0 +1,18 @@ +{ + "1.21": { "variant": "1.21", "fabricApi": "0.102.0+1.21", "modmenu": "11.0.4", "cloth": "15.0.140", "clientGametest": false }, + "1.21.1": { "variant": "1.21", "fabricApi": "0.116.13+1.21.1", "modmenu": "11.0.4", "cloth": "15.0.140", "clientGametest": false }, + "1.21.2": { "variant": "1.21", "fabricApi": "0.106.1+1.21.2", "modmenu": "12.0.1", "cloth": "16.0.143", "clientGametest": false }, + "1.21.3": { "variant": "1.21", "fabricApi": "0.114.1+1.21.3", "modmenu": "12.0.1", "cloth": "16.0.143", "clientGametest": false }, + "1.21.4": { "variant": "1.21", "fabricApi": "0.119.4+1.21.4", "modmenu": "13.0.4", "cloth": "17.0.144", "clientGametest": true }, + "1.21.5": { "variant": "1.21", "fabricApi": "0.128.2+1.21.5", "modmenu": "14.0.2", "cloth": "18.0.145", "clientGametest": true }, + "1.21.6": { "variant": "1.21.6", "fabricApi": "0.128.2+1.21.6", "modmenu": "15.0.2", "cloth": "19.0.147", "clientGametest": true }, + "1.21.7": { "variant": "1.21.6", "fabricApi": "0.129.0+1.21.7", "modmenu": "15.0.2", "cloth": "19.0.147", "clientGametest": true }, + "1.21.8": { "variant": "1.21.6", "fabricApi": "0.136.1+1.21.8", "modmenu": "15.0.2", "cloth": "19.0.147", "clientGametest": true }, + "1.21.9": { "variant": "1.21.6", "fabricApi": "0.134.1+1.21.9", "modmenu": "16.0.1", "cloth": "20.0.149", "clientGametest": true }, + "1.21.10": { "variant": "1.21.6", "fabricApi": "0.138.4+1.21.10", "modmenu": "16.0.1", "cloth": "21.11.153", "clientGametest": true }, + "1.21.11": { "variant": "1.21.6", "fabricApi": "0.141.4+1.21.11", "modmenu": "17.0.0", "cloth": "21.11.153", "clientGametest": true }, + "26.1": { "variant": "26.1", "fabricApi": "0.145.1+26.1", "modmenu": "18.0.0-beta.1", "cloth": "26.1.154", "clientGametest": true }, + "26.1.1": { "variant": "26.1", "fabricApi": "0.145.4+26.1.1", "modmenu": "18.0.0-beta.1", "cloth": "26.1.154", "clientGametest": true }, + "26.1.2": { "variant": "26.1", "fabricApi": "0.154.0+26.1.2", "modmenu": "18.0.0-beta.1", "cloth": "26.1.154", "clientGametest": true }, + "26.2": { "variant": "26.2", "fabricApi": "0.154.0+26.2", "modmenu": "20.0.0-beta.4", "cloth": "26.2.155", "clientGametest": true } +} diff --git a/versions/1.21.6/gradle.properties b/versions/1.21.6/gradle.properties index 1c59190..45a0a36 100644 --- a/versions/1.21.6/gradle.properties +++ b/versions/1.21.6/gradle.properties @@ -8,7 +8,8 @@ java_version=21 mc_dep=>=1.21.6 <1.22 loader_dep=>=0.16.10 -# Fabric API + ecosystem (matches support/1.21.6-1.21.11 branch). +# Fabric API + ecosystem (compile-time versions for this variant; the launch +# tests pin per-version runtime versions in supported-versions.json). fabric_api_version=0.134.0+1.21.9 modmenu_version=15.0.2 cloth_config_version=19.0.147 diff --git a/versions/1.21/gradle.properties b/versions/1.21/gradle.properties index 4d2e6ac..7e06a69 100644 --- a/versions/1.21/gradle.properties +++ b/versions/1.21/gradle.properties @@ -8,7 +8,8 @@ java_version=21 mc_dep=~1.21 loader_dep=>=0.15.11 -# Fabric API + ecosystem (matches support/1.21-1.21.5 branch). +# Fabric API + ecosystem (compile-time versions for this variant; the launch +# tests pin per-version runtime versions in supported-versions.json). fabric_api_version=0.102.0+1.21 modmenu_version=14.0.2 cloth_config_version=19.0.147