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/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 6c0a129..ad149ae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,58 +1,184 @@ -# 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 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. +# 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 -# contents: write lets the screenshot step push to the ci-screenshots branch, +# 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: + 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: ${{ fromJson(needs.matrices.outputs.build) }} steps: - - name: Check out repository + - name: checkout repository uses: actions/checkout@v6 - - name: Validate Gradle wrapper + + - name: validate gradle wrapper uses: gradle/actions/wrapper-validation@v6 - - name: Set up JDK 25 + + - name: setup jdk ${{ matrix.java }} uses: actions/setup-java@v5 with: - java-version: '25' + java-version: ${{ matrix.java }} distribution: 'microsoft' - - name: Make Gradle wrapper executable + + - name: make gradle wrapper executable run: chmod +x ./gradlew - - name: Build mod - run: ./gradlew build - - name: Install Xvfb + + - name: build :${{ matrix.mc }}:build + run: ./gradlew :${{ matrix.mc }}:build --stacktrace + + - name: upload jar + uses: actions/upload-artifact@v7 + 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. + # + # 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 + needs: matrices + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.matrices.outputs.launch) }} + 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: Run Minecraft client launch test - # Boots a production Fabric client with the built jar and all dependency - # mods, and fails the build if the game does not reach the title screen. - run: ./gradlew runProductionClientGameTest - - name: Post launch screenshot to pull request - # Publishes the in-world screenshot to the ci-screenshots branch and - # embeds it in a PR comment as a visual sanity check of the HUD. + + - 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'); - const path = require('path'); - const dir = 'run/screenshots'; - const shot = fs.existsSync(dir) - && fs.readdirSync(dir).find(f => f.endsWith('_betterhud-survival-world.png')); - if (!shot) { - core.setFailed('The survival world screenshot was not created'); - return; + 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}`; + + 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, @@ -64,49 +190,6 @@ jobs: return; } - // Make sure the ci-screenshots branch exists so the contents API can commit to it. - const branch = 'ci-screenshots'; - try { - await github.rest.git.getRef({ - owner: context.repo.owner, repo: context.repo.repo, ref: `heads/${branch}`, - }); - } catch (e) { - if (e.status !== 404) throw e; - await github.rest.git.createRef({ - owner: context.repo.owner, repo: context.repo.repo, - ref: `refs/heads/${branch}`, sha: context.sha, - }); - } - - // Commit the screenshot under the commit sha so old PR comments keep working. - const dest = `${context.sha}/betterhud-survival-world.png`; - let existingFileSha; - try { - const { data: existingFile } = await github.rest.repos.getContent({ - owner: context.repo.owner, repo: context.repo.repo, path: dest, ref: branch, - }); - existingFileSha = existingFile.sha; - } catch (e) { - if (e.status !== 404) throw e; - } - await github.rest.repos.createOrUpdateFileContents({ - owner: context.repo.owner, repo: context.repo.repo, branch, - path: dest, - message: `CI screenshot for ${context.sha}`, - content: fs.readFileSync(path.join(dir, shot)).toString('base64'), - ...(existingFileSha && { sha: existingFileSha }), - }); - - const url = `https://raw.githubusercontent.com/${context.repo.owner}/${context.repo.repo}/${branch}/${dest}`; - const marker = ''; - const body = [ - marker, - '### Client launch test', - `The game launched and entered a survival singleplayer world on \`${context.sha.slice(0, 7)}\`:`, - '', - `![BetterHUD in a survival world](${url})`, - ].join('\n'); - // 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, @@ -124,17 +207,36 @@ jobs: issue_number: pr.number, body, }); } - - name: Upload game logs and screenshots - if: always() - uses: actions/upload-artifact@v7 + + # 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: - name: client-launch-test - path: | - run/logs/ - run/screenshots/ - if-no-files-found: ignore - - name: Upload build artifacts + 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: Artifacts - path: build/libs/ + name: modrinth-upload + path: build/modrinth/ 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/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 74726d9..9972fe4 100644 --- a/build.gradle +++ b/build.gradle @@ -1,118 +1,123 @@ 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' + +// 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}" + +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. +if (!isLegacy) { + configurations.configureEach { + if (name.toLowerCase().contains('annotationprocessor')) { + exclude group: 'org.ow2.asm' + } } } +// 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 { - // To change the versions see the gradle.properties file minecraft "com.mojang:minecraft:${project.minecraft_version}" - - implementation "net.fabricmc:fabric-loader:${project.loader_version}" - implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + if (isLegacy) { + mappings loom.officialMojangMappings() - implementation "com.terraformersmc:modmenu:${project.modmenu_version}" - implementation("me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { - exclude(group: "net.fabricmc.fabric-api") - } - - // Mods loaded alongside the built jar by runProductionClientGameTest. - productionRuntimeMods "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" - productionRuntimeMods "com.terraformersmc:modmenu:${project.modmenu_version}" - productionRuntimeMods "me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}" -} - -fabricApi { - configureTests { - createSourceSet = true - modId = "betterhud-gametest" - enableGameTests = false - enableClientGameTests = true - eula = true + 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') + } } } -// Packages the gametest source set as a mod so the client gametests can also run -// inside the production client launched by runProductionClientGameTest. -tasks.register("gametestJar", Jar) { - from sourceSets.gametest.output - archiveClassifier = "gametest" -} - -// Launches a real production Fabric client (fabric-loader + the built mod jar + -// productionRuntimeMods + the gametest mod) and exits once the client gametests -// have run: the game must reach the title screen, generate and enter a survival -// singleplayer world, and shut down without crashing. -// On Linux CI (CI env var set) Loom automatically runs the game under XVFB. -tasks.register("runProductionClientGameTest", net.fabricmc.loom.task.prod.ClientProductionRunTask) { - jvmArgs.add("-Dfabric.client.gametest") - jvmArgs.add("-Dfabric.client.gametest.disableNetworkSynchronizer=true") - mods.from(tasks.named("gametestJar")) -} - processResources { - inputs.property "version", project.version - - filesMatching("fabric.mod.json") { - expand "version": inputs.properties.version + def dependsExtra = ''', + "fabric-api": "*", + "modmenu": "*", + "cloth-config2": "*"''' + + def expansion = [ + version : project.version, + minecraftReq : project.mc_dep, + loaderReq : project.loader_dep, + javaReq : ">=${javaVersionInt}", + javaCompat : mixinJavaCompat, + dependsExtra : dependsExtra + ] + inputs.properties expansion + + 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 ea9ea3c..0ce008e 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.2 -loader_version=0.19.3 +# 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.2+). loom_version=1.17-SNAPSHOT -# Mod Properties +# Mod Properties (shared) mod_version=2.1.1 maven_group=dsns.betterhud archives_base_name=betterhud - -# Dependencies -fabric_api_version=0.154.0+26.2 -modmenu_version=20.0.0-alpha.1 -cloth_config_version=26.2.155 \ No newline at end of file diff --git a/launchtest/build.gradle b/launchtest/build.gradle new file mode 100644 index 0000000..49d52e8 --- /dev/null +++ b/launchtest/build.gradle @@ -0,0 +1,167 @@ +// 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 +// 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 (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; +// 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 +} + +// --------------------------------------------------------------------------- +// 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. +// --------------------------------------------------------------------------- +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: ${supportedVersions.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.clientGametest) { + 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.clientGametest) { + 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.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 } + } +} + +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.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') + 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 bcb1c6e..9b53c5b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,10 +4,45 @@ 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' +} + +// 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 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 the sources are committed in + // (the "active" version when nothing else is selected). + vcsVersion = variants.last() + } +} + 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' diff --git a/src/main/java/dsns/betterhud/BetterHUD.java b/src/main/java/dsns/betterhud/BetterHUD.java index bbf7edb..39bd452 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 >=1.21.6 { import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry; +//?} else { +/*import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback; +*///?} +//? if >=26 { import net.minecraft.resources.Identifier; +//?} else if >=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 >=26 { HudElementRegistry.addLast( Identifier.fromNamespaceAndPath("betterhud", "hud"), betterHUDGUI::onHudRender ); + //?} else if >=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 169db3d..50b3913 100644 --- a/src/main/java/dsns/betterhud/BetterHUDGUI.java +++ b/src/main/java/dsns/betterhud/BetterHUDGUI.java @@ -5,15 +5,28 @@ import dsns.betterhud.util.ModSettings; import it.unimi.dsi.fastutil.objects.ObjectArrayList; import java.util.List; - -import org.joml.Matrix3x2fStack; - 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.minecraft.client.DeltaTracker; import net.minecraft.client.Minecraft; +//? if >=26 { import net.minecraft.client.gui.GuiGraphicsExtractor; +//?} else { +/*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 static int verticalPadding = 4; public static int horizontalPadding = 4; @@ -40,25 +53,23 @@ public void onStartTick(Minecraft client) { for (BaseMod mod : BetterHUD.mods) { ModSettings modSettings = mod.getModSettings(); - if (!modSettings.getSetting("Enabled").getBooleanValue()) - continue; + if (!modSettings.getSetting("Enabled").getBooleanValue()) continue; CustomText modText = mod.onStartTick(client); - if (modText == null) - continue; + if (modText == null) continue; String orientation = modSettings - .getSetting("Orientation") - .getStringValue(); + .getSetting("Orientation") + .getStringValue(); if (modSettings.getSetting("Custom Position").getBooleanValue()) { modText.customPosition = true; modText.customX = modSettings - .getSetting("Custom X") - .getIntValue(); + .getSetting("Custom X") + .getIntValue(); modText.customY = modSettings - .getSetting("Custom Y") - .getIntValue(); + .getSetting("Custom Y") + .getIntValue(); this.customPositionText.add(modText); } else if (orientation.equals("top-left")) { this.topLeftText.add(modText); @@ -73,19 +84,25 @@ public void onStartTick(Minecraft client) { } public void onHudRender( - GuiGraphicsExtractor drawContext, - DeltaTracker tickCounter) { - if (client.getDebugOverlay().showDebugScreen()) - return; - if (client.gui.hud.isHidden()) - return; + //? if >=26 { + GuiGraphicsExtractor drawContext, + //?} else { + /*GuiGraphics drawContext,*/ + //?} + DeltaTracker tickCounter + ) { + if (client.getDebugOverlay().showDebugScreen()) 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 += scaledElementHeight(text) + lineHeight; } @@ -99,11 +116,12 @@ public void onHudRender( y = verticalMargin; for (CustomText text : topRightText) { - int offset = (client.font.width(text.text) - 1) + (horizontalPadding * 2) + horizontalMargin; - + int offset = + (client.font.width(text.text) - 1) + + (horizontalPadding * 2) + + horizontalMargin; x = client.getWindow().getGuiScaledWidth() - offset; drawString(drawContext, text, x, y); - y += scaledElementHeight(text) + lineHeight; } @@ -112,7 +130,6 @@ public void onHudRender( int offset = scaledElementWidth(text) + horizontalMargin; x = client.getWindow().getGuiScaledWidth() - offset; y -= scaledElementHeight(text); - drawString(drawContext, text, x, y); y -= lineHeight; } @@ -121,8 +138,10 @@ public void onHudRender( float xPercent = text.customX / 100.0f; float yPercent = text.customY / 100.0f; - int maxX = client.getWindow().getGuiScaledWidth() - scaledElementWidth(text); - int maxY = client.getWindow().getGuiScaledHeight() - scaledElementHeight(text); + int maxX = + client.getWindow().getGuiScaledWidth() - scaledElementWidth(text); + int maxY = + client.getWindow().getGuiScaledHeight() - scaledElementHeight(text); int scaledX = (int) (xPercent * maxX); int scaledY = (int) (yPercent * maxY); @@ -132,29 +151,57 @@ public void onHudRender( } private void drawString( - GuiGraphicsExtractor drawContext, - CustomText text, - int x, - int y) { + //? if >=26 { + GuiGraphicsExtractor drawContext, + //?} else { + /*GuiGraphics drawContext,*/ + //?} + CustomText text, + int x, + int y + ) { + //? 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, - horizontalPadding, - verticalPadding, - text.color, - true); - + client.font, + text.text, + horizontalPadding, + verticalPadding, + text.color, + true + ); + //?} else { + /*drawContext.drawString( + client.font, + text.text, + horizontalPadding, + verticalPadding, + text.color, + true + );*/ + //?} + + //? if >=1.21.6 { poses.popMatrix(); + //?} else { + /*poses.popPose();*/ + //?} } private int scaledElementWidth(CustomText text) { diff --git a/src/main/java/dsns/betterhud/mods/Biome.java b/src/main/java/dsns/betterhud/mods/Biome.java index 73289be..e455125 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 <26 { +/*import java.util.Optional; +*///?} import net.minecraft.client.Minecraft; +//? if >=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 >=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/util/CustomText.java b/src/main/java/dsns/betterhud/util/CustomText.java index 61cbe61..b35bf40 100644 --- a/src/main/java/dsns/betterhud/util/CustomText.java +++ b/src/main/java/dsns/betterhud/util/CustomText.java @@ -19,17 +19,19 @@ public CustomText(String text, int color, float scale, int backgroundColor) { public CustomText(String text, ModSettings settings) { this( - text, - settings.getSetting("Text Color").getColorValue(), - settings.getSetting("Scale").getFloatValue(), - settings.getSetting("Background Color").getColorValue()); + text, + settings.getSetting("Text Color").getColorValue(), + settings.getSetting("Scale").getFloatValue(), + settings.getSetting("Background Color").getColorValue() + ); } public CustomText(String text, int color, ModSettings settings) { this( - text, - color, - settings.getSetting("Scale").getFloatValue(), - settings.getSetting("Background Color").getColorValue()); + 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 baa1224..97c2ce3 100644 --- a/src/main/java/dsns/betterhud/util/ModSettings.java +++ b/src/main/java/dsns/betterhud/util/ModSettings.java @@ -4,21 +4,23 @@ public class ModSettings { - private LinkedHashMap settings = new LinkedHashMap(); + private LinkedHashMap settings = new LinkedHashMap<>(); public ModSettings() { settings.put("Enabled", Setting.createBooleanSetting(true)); settings.put( - "Orientation", - Setting.createStringSetting( - "top-left", - new String[] { - "top-left", - "top-right", - "bottom-left", - "bottom-right", - })); + "Orientation", + Setting.createStringSetting( + "top-left", + new String[] { + "top-left", + "top-right", + "bottom-left", + "bottom-right", + } + ) + ); settings.put("Custom Position", Setting.createBooleanSetting(false)); settings.put("Custom X", Setting.createIntegerSetting(0, 0, 100)); @@ -26,22 +28,25 @@ public ModSettings() { 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)); + "Background Color", + Setting.createColorSetting(0x88000000) + ); } public ModSettings(String orientation) { this(); settings.replace( - "Orientation", - Setting.createStringSetting( - orientation, - new String[] { - "top-left", - "top-right", - "bottom-left", - "bottom-right", - })); + "Orientation", + Setting.createStringSetting( + orientation, + new String[] { + "top-left", + "top-right", + "bottom-left", + "bottom-right", + } + ) + ); } public LinkedHashMap getSettings() { diff --git a/src/main/java/dsns/betterhud/util/Setting.java b/src/main/java/dsns/betterhud/util/Setting.java index 7bf08ec..51b0b3e 100644 --- a/src/main/java/dsns/betterhud/util/Setting.java +++ b/src/main/java/dsns/betterhud/util/Setting.java @@ -56,9 +56,10 @@ 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) }); + String.valueOf(value), + "float", + new String[] { String.valueOf(min), String.valueOf(max) } + ); } public String getType() { diff --git a/src/main/resources/betterhud.mixins.json b/src/main/resources/betterhud.mixins.json index 370eecf..c35b39c 100644 --- a/src/main/resources/betterhud.mixins.json +++ b/src/main/resources/betterhud.mixins.json @@ -1,7 +1,7 @@ { "required": true, "package": "dsns.betterhud.mixin", - "compatibilityLevel": "JAVA_25", + "compatibilityLevel": "${javaCompat}", "client": [], "injectors": { "defaultRequire": 1 @@ -9,4 +9,4 @@ "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 7b95892..6900660 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -21,11 +21,8 @@ "betterhud.mixins.json" ], "depends": { - "fabricloader": ">=0.19", - "minecraft": ">=26", - "java": ">=25", - "fabric-api": "*", - "modmenu": "*", - "cloth-config2": "*" + "fabricloader": "${loaderReq}", + "minecraft": "${minecraftReq}", + "java": "${javaReq}"${dependsExtra} } -} \ No newline at end of file +} diff --git a/stonecutter.gradle b/stonecutter.gradle new file mode 100644 index 0000000..755ca07 --- /dev/null +++ b/stonecutter.gradle @@ -0,0 +1,65 @@ +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.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 new file mode 100644 index 0000000..45a0a36 --- /dev/null +++ b/versions/1.21.6/gradle.properties @@ -0,0 +1,15 @@ +# 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 (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 new file mode 100644 index 0000000..7e06a69 --- /dev/null +++ b/versions/1.21/gradle.properties @@ -0,0 +1,15 @@ +# 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 (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 diff --git a/versions/26.1/gradle.properties b/versions/26.1/gradle.properties new file mode 100644 index 0000000..dfd7869 --- /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.1 <26.2 +loader_dep=>=0.19 + +# 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