diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b9dbf06 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "daily" + + - package-ecosystem: "github-actions" + # Directory should be `/` instead of `/.github/workflows` according to the docs. + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/auto_snapshot_update.yml b/.github/workflows/auto_snapshot_update.yml new file mode 100644 index 0000000..96bb764 --- /dev/null +++ b/.github/workflows/auto_snapshot_update.yml @@ -0,0 +1,113 @@ +# Experimental workflow to automate updating to a new Minecraft snapshot. +name: Auto Snapshot Update + +on: + workflow_dispatch: + inputs: + mc_version: + description: "Minecraft version to update to" + required: true + yarn_mappings: + description: "Yarn mappings version" + required: true + fabric_loader: + description: "Fabric Loader version" + required: true + fapi_version: + description: "Fabric API version" + required: true + cf_game_version: + description: "CurseForge GameVersion" + required: true + +permissions: + # To push changes to the new snapshot branch. + contents: write + # To trigger the CI workflow. + actions: write + +jobs: + update: + runs-on: ubuntu-latest + steps: + + - name: Checkout repository + uses: actions/checkout@v4 + with: + # Include all branches in case the new snapshot branch already exists. + fetch-depth: 0 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + java-version: "21" + distribution: "microsoft" + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + build-scan-publish: true + build-scan-terms-of-use-url: "https://gradle.com/help/legal-terms-of-use" + build-scan-terms-of-use-agree: "yes" + + - name: Create and checkout new snapshot branch + run: | + BRANCH_NAME="${{ github.event.inputs.mc_version }}" + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + + if [ "$CURRENT_BRANCH" = "$BRANCH_NAME" ]; then + echo "Already on branch $BRANCH_NAME. Skipping branch creation." + elif git show-ref --quiet refs/heads/$BRANCH_NAME; then + echo "Branch $BRANCH_NAME already exists but is not currently checked out. Failing the workflow." + exit 1 + else + git checkout -b $BRANCH_NAME + echo "Created and checked out new branch: $BRANCH_NAME" + fi + shell: bash + + - name: Run migrateMappings task + run: | + ./gradlew migrateMappings --mappings ${{ github.event.inputs.yarn_mappings }} + shell: bash + + - name: Replace src/main/java with remapped files + run: | + rm -rf ./src/main/java + mv ./remappedSrc ./src/main/java + shell: bash + + - name: Update version constants + run: | + python scripts/update_version_constants.py "${{ github.event.inputs.mc_version }}" "${{ github.event.inputs.yarn_mappings }}" "${{ github.event.inputs.fabric_loader }}" "${{ github.event.inputs.fapi_version }}" "${{ github.event.inputs.cf_game_version }}" + shell: bash + + # To fix any style issues that the migration scripts might cause + - name: Run spotlessApply task + run: ./gradlew spotlessApply + + - name: Commit and push changes + run: | + git config --global user.name "Wurst-Bot" + git config --global user.email "contact.wurstimperium@gmail.com" + git add . + git commit -m "[Wurst-Bot] Update to ${{ github.event.inputs.mc_version }}" + git push --set-upstream origin ${{ github.event.inputs.mc_version }} + shell: bash + + # For some reason the commit above doesn't automatically trigger the CI + # workflow, so we need to explicitly start it here. + - name: Trigger CI on the new branch + env: + GH_TOKEN: ${{ github.token }} + run: | + gh workflow run gradle.yml --ref ${{ github.event.inputs.mc_version }} + shell: bash diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 90a1c04..9582747 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -2,8 +2,8 @@ name: Java CI with Gradle on: push: - branches: - - "**" + branches-ignore: + - "dependabot/**" tags-ignore: - "**" paths: @@ -57,7 +57,7 @@ jobs: run: ./gradlew spotlessJsonCheck || (echo "::error::JSON validation failed! Run './gradlew spotlessApply' to fix style issues, or check the full error message for syntax errors." && exit 1) - name: Validate Java code style - run: ./gradlew spotlessCheck || (echo "::error::Java code style validation failed! To fix, run 'Clean Up' and then 'Format' in Eclipse, or './gradlew spotlessApply' in the terminal." && exit 1) + run: ./gradlew spotlessJavaCheck || (echo "::error::Java code style validation failed! To fix, run 'Clean Up' and then 'Format' in Eclipse, or './gradlew spotlessApply' in the terminal." && exit 1) - name: Run unit tests run: ./gradlew test --stacktrace --warning-mode=fail @@ -92,3 +92,52 @@ jobs: echo "- [$filename]($url)" >> $GITHUB_STEP_SUMMARY done echo "" >> $GITHUB_STEP_SUMMARY + + - name: Run the mod and take screenshots + uses: modmuss50/xvfb-action@c56c7da0c8fc9a7cb5df2e50dd2a43a80b64c5cb + with: + run: ./gradlew runEndToEndTest --stacktrace --warning-mode=fail + + # Needed because the screenshot gallery won't be created on pull requests. + # Also useful if Imgur uploads fail. + - name: Upload Test Screenshots.zip artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: Test Screenshots + path: runs/client/screenshots + + - name: Create test screenshot gallery + if: ${{ env.IMGUR_CLIENT_ID && (success() || failure()) }} + shell: bash + run: | + echo "
" >> $GITHUB_STEP_SUMMARY + echo "📸 Test Screenshots" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + for img in runs/client/screenshots/*.png; do + if [ -f "$img" ]; then + filename=$(basename "$img") + name_without_ext="${filename%.*}" + + # Upload to Imgur + response=$(curl -s -X POST \ + -H "Authorization: Client-ID $IMGUR_CLIENT_ID" \ + -F "image=@$img" \ + https://api.imgur.com/3/image) + + # Extract the URL from the response + url=$(echo $response | grep -o '"link":"[^"]*"' | cut -d'"' -f4) + + if [ ! -z "$url" ]; then + # Convert underscores to spaces and capitalize first letter of each word + title=$(echo "$name_without_ext" | tr '_' ' ' | awk '{for(i=1;i<=NF;i++)sub(/./,toupper(substr($i,1,1)),$i)}1') + echo "### $title" >> $GITHUB_STEP_SUMMARY + echo "![${name_without_ext}]($url)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + else + echo "Failed to upload $filename" >> $GITHUB_STEP_SUMMARY + fi + fi + done + echo "
" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5aa3ade..506a9b7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,9 @@ name: Publish Release +run-name: "Publish release from ${{ github.ref_name }} branch" + +permissions: + # Needed to push the tag. + contents: write on: workflow_dispatch: @@ -28,6 +33,11 @@ on: required: true type: boolean default: true + update_website: + description: "Update wimods.net post (only works if there already is one and publish_curseforge is true)" + required: false + type: boolean + default: false jobs: publish: @@ -37,9 +47,13 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }} MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }} - steps: - - uses: actions/checkout@v4 + + - name: Checkout repository + uses: actions/checkout@v4 + with: + # Include all tags in case the new tag already exists. + fetch-tags: true - name: Set up Java 21 uses: actions/setup-java@v4 @@ -58,11 +72,11 @@ jobs: - name: Create and push tag run: | - MOD_VERSION=$(grep "mod_version" gradle.properties | cut -d'=' -f2 | tr -d ' ') + MOD_VERSION=$(grep "mod_version" gradle.properties | cut -d'=' -f2 | tr -d ' \r') git config --global user.name "Wurst-Bot" git config --global user.email "contact.wurstimperium@gmail.com" - git tag v$MOD_VERSION - git push origin v$MOD_VERSION + git tag "v$MOD_VERSION" + git push origin "v$MOD_VERSION" - name: Close milestone if: ${{ inputs.close_milestone }} @@ -82,6 +96,31 @@ jobs: if: ${{ inputs.publish_curseforge }} run: ./gradlew publishCurseforge --stacktrace + - name: Get CurseForge file ID + id: cf_file_id + if: ${{ inputs.publish_curseforge }} + run: | + file_id=$(./gradlew getCurseforgeId | grep -o 'CURSEFORGE_FILE_ID=[0-9]*' | grep -o '[0-9]*') + echo "file_id=$file_id" >> "$GITHUB_OUTPUT" + echo "CurseForge file ID: `$file_id`" >> $GITHUB_STEP_SUMMARY + - name: Publish to Modrinth if: ${{ inputs.publish_modrinth }} run: ./gradlew publishModrinth --stacktrace + + - name: Trigger website update + if: ${{ inputs.update_website && inputs.publish_curseforge }} + env: + GH_TOKEN: ${{ secrets.WIMODS_NET_PUBLISH_TOKEN }} + run: | + MOD_VERSION=$(grep "mod_version" gradle.properties | cut -d'=' -f2 | tr -d ' \r' | sed 's/-MC.*$//') + MC_VERSION=$(grep "minecraft_version" gradle.properties | cut -d'=' -f2 | tr -d ' \r') + FAPI_VERSION=$(grep "fabric_version" gradle.properties | cut -d'=' -f2 | tr -d ' \r') + gh workflow run add_mod_port.yml \ + -R Wurst-Imperium/wimods.net \ + -f mod="mo-glass" \ + -f modloader="fabric" \ + -f mod_version="$MOD_VERSION" \ + -f mc_version="$MC_VERSION" \ + -f fapi_version="$FAPI_VERSION" \ + -f file_id="${{ steps.cf_file_id.outputs.file_id }}" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index e0a1612..e7b27b7 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,8 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - name: Run stale bot + uses: actions/stale@v9 with: stale-issue-message: | This issue has been open for a while with no recent activity. If this issue is still important to you, please add a comment within the next 7 days to keep it open. Otherwise, the issue will be automatically closed to free up time for other tasks. diff --git a/README.md b/README.md index 41c4fb0..592cde1 100644 --- a/README.md +++ b/README.md @@ -4,24 +4,25 @@ Mo Glass is a Minecraft mod that adds glass stairs and glass slabs, including st ![A Minecraft house with its roof made out of glass stairs, powered by the Mo Glass mod](https://user-images.githubusercontent.com/10100202/69939492-ab78a480-14e8-11ea-8aa7-c351657b334b.jpg) -## Downloads (for users) +## Downloads [![Download Mo Glass](https://user-images.githubusercontent.com/10100202/214880552-859aa2ed-b4bc-4f8d-9ee7-bdd8c7fb33a2.png)](https://www.wimods.net/mo-glass/download/?utm_source=GitHub&utm_medium=Mo+Glass&utm_content=Mo+Glass+GitHub+repo+download+button) -## Setup (for developers) +## Installation -(This assumes that you are using Windows with [Eclipse](https://www.eclipse.org/downloads/) and [Java Development Kit 21](https://adoptium.net/?variant=openjdk21&jvmVariant=hotspot) already installed.) +> [!IMPORTANT] +> Always make sure that your modloader and all of your mods are made for the same Minecraft version. Your game will crash if you mix different versions. -1. Clone / download the repository. +### Installation using Fabric -2. Run these two commands in PowerShell: +1. Install [Fabric Loader](https://fabricmc.net/use/installer/). +2. Add [Fabric API](https://modrinth.com/mod/fabric-api) to your mods folder. +3. Add Mo Glass to your mods folder. - ```powershell - ./gradlew.bat --stop - ./gradlew.bat eclipse - ``` +### Installation using NeoForge -3. In Eclipse, go to `Import...` > `Existing Projects into Workspace` and select this project. +1. Install [NeoForge](https://neoforged.net/). +2. Add Mo Glass to your mods folder. ## Features @@ -59,43 +60,53 @@ That's a lot of effort just to add two new blocks to the game - and a lot of opp
*Here's how I got those numbers: (click to expand) - possible variations of stairs: + possible variations of stairs: pvStairs = 4 * 2 * 5 = 40 - possible variations of slabs: + possible variations of slabs: pvSlabs = 3 - possible variations of glass blocks: + possible variations of glass blocks: pvGlass = 1 - possible variations of non-transparent blocks: + possible variations of non-transparent blocks: pvBlocks = 1 (because any variations would be ignored when calculating transparency) - possible combinations combined: - pvAll = pvStairs + pvSlabs + pvGlass + pvBlocks = 40 + 3 + 1 + 1 = 45 + possible combinations combined: + pvAll = pvStairs + pvSlabs + pvGlass + pvBlocks = 40 + 3 + 1 + 1 = 45 - possibly transparent faces of a block (including stairs, even though they have more faces): + possibly transparent faces of a block (including stairs, even though they have more faces): f = 6 - possible scenarios for transparency of stairs: + possible scenarios for transparency of stairs: psStairs = pvAll * f * pvStairs = 45 * 6 * 40 = 10800 - possible scenarios for transparency of slabs: + possible scenarios for transparency of slabs: psSlabs = pvAll * f * pvSlabs = 45 * 6 * 3 = 810 - possible scenarios for transparency of glass blocks: + possible scenarios for transparency of glass blocks: psGlass = pvAll * f * pvGlass = 45 * 6 * 1 = 270 - possible scenarios for transparency of glass blocks if glass stairs and slabs don't exist: - psGlassVanilla = (pvGlass + pvBlocks) * f * pvGlass = (1 + 1) * 6 * 1 = 12  + possible scenarios for transparency of glass blocks if glass stairs and slabs don't exist: + psGlassVanilla = (pvGlass + pvBlocks) * f * pvGlass = (1 + 1) * 6 * 1 = 12
+## What about connected textures mods? + +~~So far, all connected textures mods that I've seen only seem to work on full blocks. They don't generate connected textures for stairs or slabs, which makes using them with Mo Glass impossible.~~ + +~~It's not that Mo Glass doesn't have support for connected textures, it's that connected textures mods don't have support for Mo Glass (or any other mod that adds stairs/slabs).~~ + +~~This might change one day as people make new mods all the time, so do let me know if there is a connected texture mod that supports stairs now. I'd be happy to add the extra texture files needed (if any) to make that work with Mo Glass.~~ + +This has changed and support for connected textures is currently being worked on. Please be patient. + ## Crafting Recipes
Glass Slab: (click to expand) - ![glass slab crafting recipse](https://user-images.githubusercontent.com/10100202/69957444-5a2ddc80-150b-11ea-8c8c-e2afc5d72fb7.png) + ![glass slab crafting recipe](https://user-images.githubusercontent.com/10100202/69957444-5a2ddc80-150b-11ea-8c8c-e2afc5d72fb7.png) ![glass slab stonecutter recipe](https://user-images.githubusercontent.com/10100202/70445670-2a974b00-1a9c-11ea-9a09-46c304cd167b.png)
@@ -106,7 +117,7 @@ That's a lot of effort just to add two new blocks to the game - and a lot of opp ![glass stairs stonecutter recipe](https://user-images.githubusercontent.com/10100202/70445677-2c610e80-1a9c-11ea-8e1b-108863b47124.png) -## Supported Languages +## Supported languages - Chinese (Simplified) (since v1.2) - Chinese (Traditional) (since v1.2) @@ -126,3 +137,72 @@ That's a lot of effort just to add two new blocks to the game - and a lot of opp - Spanish (Mexico) (since v1.4) - Spanish (Uruguay) (since v1.4) - Spanish (Venezuela) (since v1.4) + +## Development Setup + +> [!IMPORTANT] +> Make sure you have [Java Development Kit 21](https://adoptium.net/?variant=openjdk21&jvmVariant=hotspot) installed. It won't work with other versions. + +### Development using Eclipse + +1. Clone the repository: + + ```pwsh + git clone https://github.com/Wurst-Imperium/Mo-Glass.git + cd Mo-Glass + ``` + +2. Generate the sources: + + In Fabric versions: + ```pwsh + ./gradlew genSources eclipse + ``` + + In NeoForge versions: + ```pwsh + ./gradlew eclipse + ``` + +3. In Eclipse, go to `Import...` > `Existing Projects into Workspace` and select this project. + +4. **Optional:** Right-click on the project and select `Properties` > `Java Code Style`. Then under `Clean Up`, `Code Templates`, `Formatter`, import the respective files in the `codestyle` folder. + +### Development using VSCode / Cursor + +> [!TIP] +> You'll probably want to install the [Extension Pack for Java](https://marketplace.visualstudio.com/items?itemName=vscjava.vscode-java-pack) to make development easier. + +1. Clone the repository: + + ```pwsh + git clone https://github.com/Wurst-Imperium/Mo-Glass.git + cd Mo-Glass + ``` + +2. Generate the sources: + + In Fabric versions: + ```pwsh + ./gradlew genSources vscode + ``` + + In NeoForge versions: + ```pwsh + ./gradlew eclipse + ``` + (That's not a typo. NeoForge doesn't have `vscode`, but `eclipse` works fine.) + +3. Open the `Mo-Glass` folder in VSCode / Cursor. + +4. **Optional:** In the VSCode settings, set `java.format.settings.url` to `https://raw.githubusercontent.com/Wurst-Imperium/Mo-Glass/master/codestyle/formatter.xml` and `java.format.settings.profile` to `Wurst-Imperium`. + +### Development using IntelliJ IDEA + +I don't use or recommend IntelliJ, but the commands to run would be: + +```pwsh +git clone https://github.com/Wurst-Imperium/Mo-Glass.git +cd Mo-Glass +./gradlew genSources idea +``` diff --git a/build.gradle b/build.gradle index 0b87604..ac592f1 100644 --- a/build.gradle +++ b/build.gradle @@ -8,9 +8,9 @@ plugins { id "java-library" id "eclipse" id "idea" - id "net.neoforged.gradle.userdev" version "7.0.146" - id "net.neoforged.gradle.mixin" version "7.0.146" - id "me.modmuss50.mod-publish-plugin" version "0.5.2" + id "net.neoforged.gradle.userdev" version "7.0.171" + id "net.neoforged.gradle.mixin" version "7.0.171" + id "me.modmuss50.mod-publish-plugin" version "0.8.1" id "com.diffplug.spotless" version "6.25.0" } @@ -38,7 +38,7 @@ mixin { config 'mo-glass.mixins.json' } -//minecraft.accessTransformers.file rootProject.file('src/main/resources/META-INF/accesstransformer.cfg') +minecraft.accessTransformers.file rootProject.file('src/main/resources/META-INF/accesstransformer.cfg') //minecraft.accessTransformers.entry public net.minecraft.client.Minecraft textureManager # textureManager // Default run configurations. @@ -176,6 +176,27 @@ tasks.withType(ProcessResources).configureEach { } } +tasks.register('runEndToEndTest') { + group = 'NeoGradle/Runs' + description = 'Runs the game with end-to-end tests enabled' + dependsOn 'runClient' +} + +// Modify runClient if it's running as a dependency of runEndToEndTest. +// Since NeoGradle doesn't allow custom run types yet, this is the only way to +// make runEndToEndTest work for now. +tasks.withType(JavaExec).configureEach { + doFirst { + if (gradle.startParameter.taskNames.contains('runEndToEndTest')) { + jvmArgs += [ + '-Dmo_glass.e2eTest', + '-Dmixin.debug.verify=true', + '-Dmixin.debug.countInjections=true' + ] + } + } +} + tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' // Use the UTF-8 charset for Java compilation } @@ -240,6 +261,16 @@ publishMods { } } +import groovy.json.JsonSlurper + +tasks.register("getCurseforgeId") { + inputs.file publishCurseforge.result + doLast { + def result = new JsonSlurper().parseText(publishCurseforge.result.get().asFile.text) + println "CURSEFORGE_FILE_ID=${result.fileId}" + } +} + tasks.named("publishMods").configure { dependsOn(tasks.named("build")) } @@ -252,6 +283,10 @@ tasks.named("publishModrinth").configure { dependsOn(tasks.named("build")) } +tasks.named("getCurseforgeId").configure { + dependsOn(tasks.named("publishCurseforge")) +} + import org.kohsuke.github.GHReleaseBuilder import org.kohsuke.github.GitHub diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 79eb9d0..c1d5e01 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-8.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/scripts/update_version_constants.py b/scripts/update_version_constants.py new file mode 100644 index 0000000..1a36c3a --- /dev/null +++ b/scripts/update_version_constants.py @@ -0,0 +1,45 @@ +import argparse + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("mc_version", help="Minecraft version") + parser.add_argument("yarn_mappings", help="Yarn mappings version") + parser.add_argument("fabric_loader", help="Fabric Loader version") + parser.add_argument("fapi_version", help="Fabric API version") + parser.add_argument("cf_game_version", help="CurseForge GameVersion") + args = parser.parse_args() + + # Read gradle.properties + print("Updating gradle.properties...") + with open("gradle.properties", "r") as f: + lines = f.readlines() + + # Define replacements + replacements = { + "minecraft_version": lambda v: args.mc_version, + "yarn_mappings": lambda v: args.yarn_mappings, + "loader_version": lambda v: args.fabric_loader, + "fabric_version": lambda v: args.fapi_version, + "mod_version": lambda v: v[: v.index("MC") + 2] + args.mc_version, + "cf_game_version": lambda v: args.cf_game_version, + } + + # Update lines + for i, line in enumerate(lines): + if line.startswith("#"): + continue + parts = line.split("=") + if len(parts) != 2: + continue + key = parts[0] + if key.strip() not in replacements: + continue + old_value = parts[1] + new_value = replacements[key.strip()](old_value) + print(f"{key}={old_value} -> {new_value}") + lines[i] = f"{key}={new_value}\n" + + # Save modified gradle.properties + with open("gradle.properties", "w") as f: + f.writelines(lines) + print("gradle.properties updated.") diff --git a/src/main/java/net/wurstclient/glass/MoGlass.java b/src/main/java/net/wurstclient/glass/MoGlass.java index 79fd14f..50ef31a 100644 --- a/src/main/java/net/wurstclient/glass/MoGlass.java +++ b/src/main/java/net/wurstclient/glass/MoGlass.java @@ -15,6 +15,7 @@ import net.neoforged.fml.common.Mod; import net.neoforged.neoforge.event.BuildCreativeModeTabContentsEvent; import net.neoforged.neoforge.registries.DeferredBlock; +import net.wurstclient.glass.test.MoGlassTestClient; @Mod(MoGlass.MODID) @EventBusSubscriber(modid = MoGlass.MODID, bus = EventBusSubscriber.Bus.MOD) @@ -32,6 +33,10 @@ public MoGlass(IEventBus modBus, ModContainer container) System.out.println("Starting Mo Glass..."); MoGlassBlocks.BLOCKS.register(modBus); MoGlassBlocks.ITEMS.register(modBus); + + // Run end-to-end test, if enabled + if(System.getProperty("mo_glass.e2eTest") != null) + MoGlassTestClient.start(); } @SubscribeEvent diff --git a/src/main/java/net/wurstclient/glass/MoGlassBlocks.java b/src/main/java/net/wurstclient/glass/MoGlassBlocks.java index 643dfb3..b6932ba 100644 --- a/src/main/java/net/wurstclient/glass/MoGlassBlocks.java +++ b/src/main/java/net/wurstclient/glass/MoGlassBlocks.java @@ -7,7 +7,8 @@ */ package net.wurstclient.glass; -import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.function.Function; import net.minecraft.core.BlockPos; @@ -37,7 +38,6 @@ public enum MoGlassBlocks registerBlock("glass_slab", GlassSlabBlock::new, BlockBehaviour.Properties.of().instrument(NoteBlockInstrument.HAT) .strength(0.3F).sound(SoundType.GLASS).noOcclusion() - .sound(SoundType.GLASS).noOcclusion() .isValidSpawn(MoGlassBlocks::never) .isRedstoneConductor(MoGlassBlocks::never) .isSuffocating(MoGlassBlocks::never) @@ -47,7 +47,6 @@ public enum MoGlassBlocks registerBlock("glass_stairs", GlassStairsBlock::new, BlockBehaviour.Properties.of().instrument(NoteBlockInstrument.HAT) .strength(0.3F).sound(SoundType.GLASS).noOcclusion() - .sound(SoundType.GLASS).noOcclusion() .isValidSpawn(MoGlassBlocks::never) .isRedstoneConductor(MoGlassBlocks::never) .isSuffocating(MoGlassBlocks::never) @@ -72,77 +71,13 @@ public enum MoGlassBlocks .isSuffocating(MoGlassBlocks::never) .isViewBlocking(MoGlassBlocks::never)); - public static final ArrayList> STAINED_GLASS_SLABS = - new ArrayList<>(); - - public static final ArrayList> STAINED_GLASS_STAIRS = - new ArrayList<>(); + public static final List> STAINED_GLASS_SLABS = + Arrays.stream(DyeColor.values()) + .map(color -> createStainedGlassSlab(color)).toList(); - public static final DeferredBlock WHITE_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.WHITE); - public static final DeferredBlock ORANGE_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.ORANGE); - public static final DeferredBlock MAGENTA_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.MAGENTA); - public static final DeferredBlock LIGHT_BLUE_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.LIGHT_BLUE); - public static final DeferredBlock YELLOW_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.YELLOW); - public static final DeferredBlock LIME_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.LIME); - public static final DeferredBlock PINK_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.PINK); - public static final DeferredBlock GRAY_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.GRAY); - public static final DeferredBlock LIGHT_GRAY_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.LIGHT_GRAY); - public static final DeferredBlock CYAN_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.CYAN); - public static final DeferredBlock PURPLE_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.PURPLE); - public static final DeferredBlock BLUE_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.BLUE); - public static final DeferredBlock BROWN_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.BROWN); - public static final DeferredBlock GREEN_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.GREEN); - public static final DeferredBlock RED_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.RED); - public static final DeferredBlock BLACK_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.BLACK); - - public static final DeferredBlock WHITE_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.WHITE); - public static final DeferredBlock ORANGE_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.ORANGE); - public static final DeferredBlock MAGENTA_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.MAGENTA); - public static final DeferredBlock LIGHT_BLUE_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.LIGHT_BLUE); - public static final DeferredBlock YELLOW_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.YELLOW); - public static final DeferredBlock LIME_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.LIME); - public static final DeferredBlock PINK_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.PINK); - public static final DeferredBlock GRAY_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.GRAY); - public static final DeferredBlock LIGHT_GRAY_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.LIGHT_GRAY); - public static final DeferredBlock CYAN_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.CYAN); - public static final DeferredBlock PURPLE_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.PURPLE); - public static final DeferredBlock BLUE_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.BLUE); - public static final DeferredBlock BROWN_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.BROWN); - public static final DeferredBlock GREEN_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.GREEN); - public static final DeferredBlock RED_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.RED); - public static final DeferredBlock BLACK_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.BLACK); + public static final List> STAINED_GLASS_STAIRS = + Arrays.stream(DyeColor.values()) + .map(color -> createStainedGlassStairs(color)).toList(); private static DeferredBlock registerBlock( String idPath, Function blockBuilder, @@ -159,37 +94,29 @@ private static DeferredBlock registerBlock( private static DeferredBlock createStainedGlassSlab( DyeColor color) { - DeferredBlock result = - registerBlock(color + "_stained_glass_slab", - props -> new StainedGlassSlabBlock(color, props), - BlockBehaviour.Properties.of().mapColor(color) - .instrument(NoteBlockInstrument.HAT).strength(0.3F) - .sound(SoundType.GLASS).noOcclusion() - .isValidSpawn(MoGlassBlocks::never) - .isRedstoneConductor(MoGlassBlocks::never) - .isSuffocating(MoGlassBlocks::never) - .isViewBlocking(MoGlassBlocks::never)); - - STAINED_GLASS_SLABS.add(result); - return result; + return registerBlock(color + "_stained_glass_slab", + props -> new StainedGlassSlabBlock(color, props), + BlockBehaviour.Properties.of().mapColor(color) + .instrument(NoteBlockInstrument.HAT).strength(0.3F) + .sound(SoundType.GLASS).noOcclusion() + .isValidSpawn(MoGlassBlocks::never) + .isRedstoneConductor(MoGlassBlocks::never) + .isSuffocating(MoGlassBlocks::never) + .isViewBlocking(MoGlassBlocks::never)); } private static DeferredBlock createStainedGlassStairs( DyeColor color) { - DeferredBlock result = - registerBlock(color + "_stained_glass_stairs", - props -> new StainedGlassStairsBlock(color, props), - BlockBehaviour.Properties.of().mapColor(color) - .instrument(NoteBlockInstrument.HAT).strength(0.3F) - .sound(SoundType.GLASS).noOcclusion() - .isValidSpawn(MoGlassBlocks::never) - .isRedstoneConductor(MoGlassBlocks::never) - .isSuffocating(MoGlassBlocks::never) - .isViewBlocking(MoGlassBlocks::never)); - - STAINED_GLASS_STAIRS.add(result); - return result; + return registerBlock(color + "_stained_glass_stairs", + props -> new StainedGlassStairsBlock(color, props), + BlockBehaviour.Properties.of().mapColor(color) + .instrument(NoteBlockInstrument.HAT).strength(0.3F) + .sound(SoundType.GLASS).noOcclusion() + .isValidSpawn(MoGlassBlocks::never) + .isRedstoneConductor(MoGlassBlocks::never) + .isSuffocating(MoGlassBlocks::never) + .isViewBlocking(MoGlassBlocks::never)); } // Copies of the Blocks.never() methods because the originals are not diff --git a/src/main/java/net/wurstclient/glass/test/MoGlassTestClient.java b/src/main/java/net/wurstclient/glass/test/MoGlassTestClient.java new file mode 100644 index 0000000..b2b9fbb --- /dev/null +++ b/src/main/java/net/wurstclient/glass/test/MoGlassTestClient.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2019-2024 Wurst-Imperium and contributors. + * + * This source code is subject to the terms of the GNU General Public + * License, version 3. If a copy of the GPL was not distributed with this + * file, You can obtain one at: https://www.gnu.org/licenses/gpl-3.0.txt + */ +package net.wurstclient.glass.test; + +import static net.wurstclient.glass.test.WiModsTestHelper.*; + +import java.time.Duration; + +import org.spongepowered.asm.mixin.MixinEnvironment; + +import net.minecraft.SharedConstants; +import net.minecraft.client.gui.screens.AccessibilityOnboardingScreen; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen; +import net.minecraft.client.gui.screens.worldselection.SelectWorldScreen; + +public final class MoGlassTestClient +{ + public static void start() + { + Thread.ofVirtual().name("Mo Glass End-to-End Test") + .uncaughtExceptionHandler((t, e) -> { + e.printStackTrace(); + System.exit(1); + }).start(new MoGlassTestClient()::runTests); + } + + private void runTests() + { + System.out.println("Starting Mo Glass End-to-End Test"); + waitForResourceLoading(); + + if(submitAndGet(mc -> mc.options.onboardAccessibility)) + { + System.out.println("Onboarding is enabled. Waiting for it"); + waitForScreen(AccessibilityOnboardingScreen.class); + System.out.println("Reached onboarding screen"); + clickButton("gui.continue"); + } + + waitForScreen(TitleScreen.class); + waitForTitleScreenFade(); + System.out.println("Reached title screen"); + takeScreenshot("title_screen", Duration.ZERO); + + System.out.println("Clicking singleplayer button"); + clickButton("menu.singleplayer"); + + if(submitAndGet( + mc -> !mc.getLevelSource().findLevelCandidates().isEmpty())) + { + System.out.println("World list is not empty. Waiting for it"); + waitForScreen(SelectWorldScreen.class); + System.out.println("Reached select world screen"); + takeScreenshot("select_world_screen"); + clickButton("selectWorld.create"); + } + + waitForScreen(CreateWorldScreen.class); + System.out.println("Reached create world screen"); + + // Set MC version as world name + setTextFieldText(0, + "E2E Test " + SharedConstants.getCurrentVersion().getName()); + // Select creative mode + clickButton("selectWorld.gameMode"); + clickButton("selectWorld.gameMode"); + takeScreenshot("create_world_screen"); + + System.out.println("Creating test world"); + clickButton("selectWorld.create"); + + waitForWorldLoad(); + dismissTutorialToasts(); + waitForWorldTicks(200); + runChatCommand("seed"); + System.out.println("Reached singleplayer world"); + takeScreenshot("in_game", Duration.ZERO); + clearChat(); + + System.out.println("Opening debug menu"); + toggleDebugHud(); + takeScreenshot("debug_menu"); + + System.out.println("Closing debug menu"); + toggleDebugHud(); + + System.out.println("Checking for broken mixins"); + MixinEnvironment.getCurrentEnvironment().audit(); + + System.out.println("Opening inventory"); + openInventory(); + takeScreenshot("inventory"); + + System.out.println("Closing inventory"); + closeScreen(); + + // Build a test platform and clear out the space above it + runChatCommand("fill ~-5 ~-1 ~-5 ~5 ~-1 ~5 stone"); + runChatCommand("fill ~-5 ~ ~-5 ~5 ~30 ~5 air"); + + // Clear inventory and chat before running tests + runChatCommand("clear"); + clearChat(); + + // TODO: Add Mo Glass-specific test code here + + System.out.println("Opening game menu"); + openGameMenu(); + takeScreenshot("game_menu"); + + System.out.println("Returning to title screen"); + clickButton("menu.returnToMenu"); + waitForScreen(TitleScreen.class); + + System.out.println("Stopping the game"); + clickButton("menu.quit"); + } +} diff --git a/src/main/java/net/wurstclient/glass/test/WiModsTestHelper.java b/src/main/java/net/wurstclient/glass/test/WiModsTestHelper.java new file mode 100644 index 0000000..0eca168 --- /dev/null +++ b/src/main/java/net/wurstclient/glass/test/WiModsTestHelper.java @@ -0,0 +1,394 @@ +/* + * Copyright (c) 2019-2024 Wurst-Imperium and contributors. + * + * This source code is subject to the terms of the GNU General Public + * License, version 3. If a copy of the GPL was not distributed with this + * file, You can obtain one at: https://www.gnu.org/licenses/gpl-3.0.txt + */ +package net.wurstclient.glass.test; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; + +import com.mojang.brigadier.ParseResults; +import com.mojang.brigadier.exceptions.CommandSyntaxException; + +import net.minecraft.client.CameraType; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Renderable; +import net.minecraft.client.gui.screens.LevelLoadingScreen; +import net.minecraft.client.gui.screens.PauseScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.gui.screens.inventory.InventoryScreen; +import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.client.resources.language.I18n; +import net.minecraft.client.tutorial.TutorialSteps; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +public enum WiModsTestHelper +{ + ; + + private static final AtomicInteger screenshotCounter = new AtomicInteger(0); + + /** + * Runs the given consumer on Minecraft's main thread and waits for it to + * complete. + */ + public static void submitAndWait(Consumer consumer) + { + Minecraft mc = Minecraft.getInstance(); + mc.submit(() -> consumer.accept(mc)).join(); + } + + /** + * Runs the given function on Minecraft's main thread, waits for it to + * complete, and returns the result. + */ + public static T submitAndGet(Function function) + { + Minecraft mc = Minecraft.getInstance(); + return mc.submit(() -> function.apply(mc)).join(); + } + + /** + * Waits for the given duration. + */ + public static void wait(Duration duration) + { + try + { + Thread.sleep(duration.toMillis()); + + }catch(InterruptedException e) + { + throw new RuntimeException(e); + } + } + + /** + * Waits until the given condition is true, or fails if the timeout is + * reached. + */ + public static void waitUntil(String event, Predicate condition, + Duration maxDuration) + { + LocalDateTime startTime = LocalDateTime.now(); + LocalDateTime timeout = startTime.plus(maxDuration); + System.out.println("Waiting until " + event); + + while(true) + { + if(submitAndGet(condition::test)) + { + double seconds = + Duration.between(startTime, LocalDateTime.now()).toMillis() + / 1000.0; + System.out.println( + "Waiting until " + event + " took " + seconds + "s"); + break; + } + + if(startTime.isAfter(timeout)) + throw new RuntimeException( + "Waiting until " + event + " took too long"); + + wait(Duration.ofMillis(50)); + } + } + + /** + * Waits until the given condition is true, or fails after 10 seconds. + */ + public static void waitUntil(String event, Predicate condition) + { + waitUntil(event, condition, Duration.ofSeconds(10)); + } + + /** + * Waits until the given screen is open, or fails after 10 seconds. + */ + public static void waitForScreen(Class screenClass) + { + waitUntil("screen " + screenClass.getName() + " is open", + mc -> screenClass.isInstance(mc.screen)); + } + + /** + * Waits for the fading animation of the title screen to finish, or fails + * after 10 seconds. + */ + public static void waitForTitleScreenFade() + { + waitUntil("title screen fade is complete", mc -> { + if(!(mc.screen instanceof TitleScreen titleScreen)) + return false; + + return !titleScreen.fading; + }); + } + + /** + * Waits until the red overlay with the Mojang logo and progress bar goes + * away, or fails after 5 minutes. + */ + public static void waitForResourceLoading() + { + waitUntil("loading is complete", mc -> mc.getOverlay() == null, + Duration.ofMinutes(5)); + } + + public static void waitForWorldLoad() + { + waitUntil("world is loaded", + mc -> mc.level != null + && !(mc.screen instanceof LevelLoadingScreen), + Duration.ofMinutes(30)); + } + + public static void waitForWorldTicks(int ticks) + { + long startTicks = submitAndGet(mc -> mc.level.getGameTime()); + waitUntil(ticks + " world ticks have passed", + mc -> mc.level.getGameTime() >= startTicks + ticks, + Duration.ofMillis(ticks * 100).plusMinutes(5)); + } + + /** + * Waits for 50ms and then takes a screenshot with the given name. + */ + public static void takeScreenshot(String name) + { + takeScreenshot(name, Duration.ofMillis(50)); + } + + /** + * Waits for the given delay and then takes a screenshot with the given + * name. + */ + public static void takeScreenshot(String name, Duration delay) + { + wait(delay); + + String count = + String.format("%02d", screenshotCounter.incrementAndGet()); + String filename = count + "_" + name + ".png"; + + submitAndWait(mc -> Screenshot.grab(mc.gameDirectory, filename, + mc.getMainRenderTarget(), message -> {})); + } + + /** + * Returns the first button on the current screen that has the given + * translation key, or fails if not found. + * + *

+ * For non-translated buttons, the translationKey parameter should be the + * raw button text instead. + */ + public static Button findButton(Minecraft mc, String translationKey) + { + String message = I18n.get(translationKey); + + for(Renderable drawable : mc.screen.renderables) + if(drawable instanceof Button button + && button.getMessage().getString().equals(message)) + return button; + + throw new RuntimeException(message + " button could not be found"); + } + + /** + * Looks for the given button at the given coordinates and fails if it is + * not there. + */ + public static void checkButtonPosition(Button button, int expectedX, + int expectedY) + { + String buttonName = button.getMessage().getString(); + + if(button.getX() != expectedX) + throw new RuntimeException(buttonName + + " button is at the wrong X coordinate. Expected X: " + + expectedX + ", actual X: " + button.getX()); + + if(button.getY() != expectedY) + throw new RuntimeException(buttonName + + " button is at the wrong Y coordinate. Expected Y: " + + expectedY + ", actual Y: " + button.getY()); + } + + /** + * Clicks the button with the given translation key, or fails after 10 + * seconds. + * + *

+ * For non-translated buttons, the translationKey parameter should be the + * raw button text instead. + */ + public static void clickButton(String translationKey) + { + String buttonText = I18n.get(translationKey); + + waitUntil("button saying " + buttonText + " is visible", mc -> { + Screen screen = mc.screen; + if(screen == null) + return false; + + for(Renderable drawable : screen.renderables) + { + if(!(drawable instanceof AbstractWidget widget)) + continue; + + if(widget instanceof Button button + && buttonText.equals(button.getMessage().getString())) + { + button.onPress(); + return true; + } + + if(widget instanceof CycleButton button + && buttonText.equals(button.name.getString())) + { + button.onPress(); + return true; + } + } + + return false; + }); + } + + /** + * Types the given text into the nth text field on the current screen, or + * fails after 10 seconds. + */ + public static void setTextFieldText(int index, String text) + { + waitUntil("text field #" + index + " is visible", mc -> { + Screen screen = mc.screen; + if(screen == null) + return false; + + int i = 0; + for(Renderable drawable : screen.renderables) + { + if(!(drawable instanceof EditBox textField)) + continue; + + if(i == index) + { + textField.setValue(text); + return true; + } + + i++; + } + + return false; + }); + } + + public static void closeScreen() + { + submitAndWait(mc -> mc.setScreen(null)); + } + + public static void openGameMenu() + { + submitAndWait(mc -> mc.setScreen(new PauseScreen(true))); + } + + public static void openInventory() + { + submitAndWait(mc -> mc.setScreen(new InventoryScreen(mc.player))); + } + + public static void toggleDebugHud() + { + submitAndWait(mc -> mc.gui.getDebugOverlay().toggleOverlay()); + } + + public static void setPerspective(CameraType perspective) + { + submitAndWait(mc -> mc.options.setCameraType(perspective)); + } + + public static void dismissTutorialToasts() + { + submitAndWait(mc -> mc.getTutorial().setStep(TutorialSteps.NONE)); + } + + public static void clearChat() + { + submitAndWait(mc -> mc.gui.getChat().clearMessages(true)); + } + + /** + * Runs the given chat command and waits one tick for the action to + * complete. + * + *

+ * Do not put a / at the start of the command. + */ + public static void runChatCommand(String command) + { + System.out.println("Running command: /" + command); + submitAndWait(mc -> { + ClientPacketListener netHandler = mc.getConnection(); + + // Validate command using client-side command dispatcher + ParseResults results = netHandler.getCommands().parse(command, + netHandler.getSuggestionsProvider()); + + // Command is invalid, fail the test + if(!results.getExceptions().isEmpty()) + { + StringBuilder errors = + new StringBuilder("Invalid command: " + command); + for(CommandSyntaxException e : results.getExceptions().values()) + errors.append("\n").append(e.getMessage()); + + throw new RuntimeException(errors.toString()); + } + + // Command is valid, send it + netHandler.sendCommand(command); + }); + waitForWorldTicks(1); + } + + public static void assertOneItemInSlot(int slot, Item item) + { + submitAndWait(mc -> { + ItemStack stack = mc.player.getInventory().getItem(slot); + if(!stack.is(item) || stack.getCount() != 1) + throw new RuntimeException( + "Expected 1 " + item.getName().getString() + " at slot " + + slot + ", found " + stack.getCount() + " " + + stack.getItem().getName().getString() + " instead"); + }); + } + + public static void assertNoItemInSlot(int slot) + { + submitAndWait(mc -> { + ItemStack stack = mc.player.getInventory().getItem(slot); + if(!stack.isEmpty()) + throw new RuntimeException("Expected no item in slot " + slot + + ", found " + stack.getCount() + " " + + stack.getItem().getName().getString() + " instead"); + }); + } +} diff --git a/src/main/resources/META-INF/accesstransformer.cfg b/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 0000000..2eab375 --- /dev/null +++ b/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1,3 @@ +public net.minecraft.client.gui.screens.Screen renderables # renderables +public net.minecraft.client.gui.screens.TitleScreen fading # fading +public net.minecraft.client.gui.components.CycleButton name # name diff --git a/src/main/resources/mo-glass.accesswidener b/src/main/resources/mo-glass.accesswidener new file mode 100644 index 0000000..a9e23b8 --- /dev/null +++ b/src/main/resources/mo-glass.accesswidener @@ -0,0 +1,4 @@ +accessWidener v1 named +accessible field net/minecraft/client/gui/screen/Screen drawables Ljava/util/List; +accessible field net/minecraft/client/gui/screen/TitleScreen doBackgroundFade Z +accessible field net/minecraft/client/gui/widget/CyclingButtonWidget optionText Lnet/minecraft/text/Text;