├── test-project ├── gradle.properties ├── fabric │ ├── gradle.properties │ └── src │ │ ├── test │ │ └── java │ │ │ └── TestTest.java │ │ └── main │ │ └── java │ │ └── com │ │ └── example │ │ └── test │ │ └── fabric │ │ └── utils │ │ └── WindowUtils.java ├── neoforge │ ├── gradle.properties │ └── src │ │ ├── test │ │ └── java │ │ │ └── TestTest.java │ │ └── main │ │ └── java │ │ └── com │ │ └── example │ │ └── test │ │ └── neoforge │ │ └── utils │ │ └── WindowUtils.java ├── forge │ ├── gradle.properties │ └── src │ │ └── main │ │ └── java │ │ └── com │ │ └── example │ │ └── test │ │ └── forge │ │ └── utils │ │ └── WindowUtils.java ├── vanilla │ └── gradle.properties ├── root.gradle.kts ├── src │ └── main │ │ ├── resources │ │ └── test.mixins.json │ │ ├── java │ │ └── com │ │ │ └── example │ │ │ └── test │ │ │ └── mixin │ │ │ └── MinecraftMixin.java │ │ └── templates │ │ ├── fabric.mod.json │ │ └── META-INF │ │ ├── mods.toml │ │ └── neoforge.mods.toml ├── modstitch.accesswidener ├── settings.gradle.kts └── build.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── settings.gradle.kts ├── src ├── main │ └── kotlin │ │ └── dev │ │ └── isxander │ │ └── modstitch │ │ ├── util │ │ ├── Side.kt │ │ ├── RegisteredGradlePlugin.kt │ │ ├── Version.kt │ │ ├── DummyExtensions.kt │ │ ├── StringUtils.kt │ │ ├── DelegateUtils.kt │ │ ├── Platform.kt │ │ ├── LineNumberReaderUtils.kt │ │ ├── ProjectUtils.kt │ │ ├── MinecraftVersion.kt │ │ ├── MappingOperation.kt │ │ └── AccessWidener.kt │ │ ├── base │ │ ├── moddevgradle │ │ │ ├── MDGType.kt │ │ │ ├── AppendNeoForgeMetadataTask.kt │ │ │ ├── GenerateAccessTransformerTask.kt │ │ │ ├── CreateMinecraftMappingsTask.kt │ │ │ ├── BaseModDevGradleExtension.kt │ │ │ └── BaseModDevGradleImpl.kt │ │ ├── BasePlugin.kt │ │ ├── FutureNamedDomainObjectProvider.kt │ │ ├── AppendModMetadataTask.kt │ │ ├── extensions │ │ │ ├── Parchment.kt │ │ │ ├── RunConfig.kt │ │ │ ├── Metadata.kt │ │ │ ├── Mixin.kt │ │ │ └── Modstitch.kt │ │ ├── loom │ │ │ ├── BaseLoomExtension.kt │ │ │ ├── AppendFabricMetadataTask.kt │ │ │ └── BaseLoomImpl.kt │ │ └── BaseCommonImpl.kt │ │ ├── publishing │ │ ├── loom │ │ │ └── PublishingLoomImpl.kt │ │ ├── PublishingPlugin.kt │ │ ├── moddevgradle │ │ │ └── PublishingModdevgradleImpl.kt │ │ ├── PublishingCommonImpl.kt │ │ └── PublishingExtension.kt │ │ ├── PlatformPlugin.kt │ │ ├── shadow │ │ ├── ShadowPlugin.kt │ │ ├── loom │ │ │ └── ShadowLoomImpl.kt │ │ ├── ShadowCommonImpl.kt │ │ ├── extensions │ │ │ └── Shadow.kt │ │ └── moddevgradle │ │ │ └── ShadowModdevgradleImpl.kt │ │ └── ModstitchExtensionPlugin.kt └── test │ └── kotlin │ └── dev │ └── isxander │ └── modstitch │ ├── integration │ ├── MinimalIntegration.kt │ ├── ModManifestTests.kt │ ├── AutoJavaVersionTests.kt │ ├── BaseFunctionalTest.kt │ └── AccessWidenerIntegration.kt │ └── unit │ └── AccessWidenerTest.kt ├── .gitignore ├── README.md ├── .github └── workflows │ └── test.yml ├── gradlew.bat └── gradlew /test-project/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.configuration-cache=true 2 | 3 | modstitch.platform=parent -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isXander/modstitch/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /test-project/fabric/gradle.properties: -------------------------------------------------------------------------------- 1 | modstitch.platform=loom 2 | 3 | minecraftVersion=1.21.4 4 | fabricLoaderVersion=0.16.10 5 | -------------------------------------------------------------------------------- /test-project/neoforge/gradle.properties: -------------------------------------------------------------------------------- 1 | modstitch.platform=moddevgradle 2 | 3 | minecraftVersion=1.21.8 4 | neoForgeVersion=21.8.27 -------------------------------------------------------------------------------- /test-project/forge/gradle.properties: -------------------------------------------------------------------------------- 1 | modstitch.platform=moddevgradle-legacy 2 | 3 | minecraftVersion=1.20.1 4 | forgeVersion=1.20.1-47.3.0 -------------------------------------------------------------------------------- /test-project/vanilla/gradle.properties: -------------------------------------------------------------------------------- 1 | modstitch.platform=moddevgradle 2 | 3 | minecraftVersion=1.21.4 4 | neoFormVersion=1.21.4-20241203.161809 -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | org.gradle.jvmargs=-Xmx4G 3 | 4 | deps.loom=1.11.8 5 | deps.moddevgradle=2.0.110 6 | deps.mpp=0.8.4 7 | deps.shadow=8.3.6 8 | -------------------------------------------------------------------------------- /test-project/root.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("dev.isxander.modstitch.base") 3 | id("dev.isxander.modstitch.publishing") 4 | id("dev.isxander.modstitch.shadow") 5 | } -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" 3 | } 4 | rootProject.name = "modstitch" 5 | 6 | includeBuild("test-project") 7 | 8 | -------------------------------------------------------------------------------- /test-project/src/main/resources/test.mixins.json: -------------------------------------------------------------------------------- 1 | { 2 | "package": "com.example.test.mixin", 3 | "required": true, 4 | "compatibilityLevel": "JAVA_17", 5 | "client": [ 6 | "MinecraftMixin" 7 | ] 8 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/util/Side.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.util 2 | 3 | import java.io.Serializable 4 | 5 | enum class Side : Serializable { 6 | Both, 7 | Client, 8 | Server, 9 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/util/RegisteredGradlePlugin.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.util 2 | 3 | @Target(AnnotationTarget.CLASS) 4 | @Retention(AnnotationRetention.SOURCE) 5 | annotation class RegisteredGradlePlugin 6 | -------------------------------------------------------------------------------- /test-project/fabric/src/test/java/TestTest.java: -------------------------------------------------------------------------------- 1 | import net.minecraft.client.Minecraft; 2 | import org.junit.jupiter.api.Test; 3 | 4 | public class TestTest { 5 | @Test 6 | public void test() { 7 | Minecraft.getInstance(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test-project/src/main/java/com/example/test/mixin/MinecraftMixin.java: -------------------------------------------------------------------------------- 1 | package com.example.test.mixin; 2 | 3 | import net.minecraft.client.Minecraft; 4 | import org.spongepowered.asm.mixin.Mixin; 5 | 6 | @Mixin(Minecraft.class) 7 | public class MinecraftMixin { 8 | } 9 | -------------------------------------------------------------------------------- /test-project/src/main/templates/fabric.mod.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 1, 3 | "id": "${mod_id}", 4 | "name": "${mod_name}", 5 | "version": "${mod_version}", 6 | "description": "${mod_description}", 7 | "environment": "*", 8 | "entrypoints": { 9 | 10 | } 11 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/moddevgradle/MDGType.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base.moddevgradle 2 | 3 | import dev.isxander.modstitch.util.Platform 4 | 5 | enum class MDGType(val platform: Platform) { 6 | Regular(Platform.MDG), 7 | Legacy(Platform.MDGLegacy), 8 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /test-project/neoforge/src/test/java/TestTest.java: -------------------------------------------------------------------------------- 1 | import org.junit.jupiter.api.Test; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | 5 | public class TestTest { 6 | @Test 7 | public void test() { 8 | net.minecraft.client.Minecraft.getInstance(); 9 | assertEquals(1, 2, "This test is intentionally failing to demonstrate the test setup."); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/util/Version.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.util 2 | 3 | import dev.isxander.modstitch.ModstitchExtensionPlugin 4 | import org.gradle.api.Project 5 | 6 | val MODSTITCH_VERSION: String = ModstitchExtensionPlugin::class.java.`package`.implementationVersion ?: "unknown" 7 | 8 | fun printVersion(suffix: String, project: Project) { 9 | project.logger.lifecycle("Modstitch/$suffix $MODSTITCH_VERSION") 10 | } 11 | -------------------------------------------------------------------------------- /test-project/modstitch.accesswidener: -------------------------------------------------------------------------------- 1 | accessWidener v1 named 2 | 3 | accessible field com/mojang/blaze3d/platform/Window windowedX I 4 | accessible field com/mojang/blaze3d/platform/Window windowedY I 5 | accessible field com/mojang/blaze3d/platform/Window windowedWidth I 6 | accessible field com/mojang/blaze3d/platform/Window windowedHeight I 7 | accessible field com/mojang/blaze3d/platform/Window __nop__ Z 8 | accessible method com/mojang/blaze3d/platform/Window refreshFramebufferSize ()V 9 | -------------------------------------------------------------------------------- /test-project/src/main/templates/META-INF/mods.toml: -------------------------------------------------------------------------------- 1 | modLoader = "javafml" 2 | loaderVersion = "[46,)" 3 | license = "${mod_license}" 4 | 5 | [[mods]] 6 | modId = "${mod_id}" 7 | version = "${mod_version}" 8 | displayName = "${mod_name}" 9 | description = '''${mod_description}''' 10 | 11 | [["dependencies.${mod_id}"]] 12 | modId = "forge" 13 | type = "required" 14 | ordering = "NONE" 15 | side = "BOTH" 16 | 17 | [["dependencies.${mod_id}"]] 18 | modId = "minecraft" 19 | type = "required" 20 | -------------------------------------------------------------------------------- /test-project/src/main/templates/META-INF/neoforge.mods.toml: -------------------------------------------------------------------------------- 1 | modLoader = "javafml" 2 | loaderVersion = "[2,)" 3 | license = "${mod_license}" 4 | 5 | [[mods]] 6 | modId = "${mod_id}" 7 | version = "${mod_version}" 8 | displayName = "${mod_name}" 9 | description = '''${mod_description}''' 10 | 11 | [["dependencies.${mod_id}"]] 12 | modId = "neoforge" 13 | type = "required" 14 | ordering = "NONE" 15 | side = "BOTH" 16 | 17 | [["dependencies.${mod_id}"]] 18 | modId = "minecraft" 19 | type = "required" 20 | -------------------------------------------------------------------------------- /test-project/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "test-project" 2 | rootProject.buildFileName = "root.gradle.kts" 3 | 4 | pluginManagement { 5 | includeBuild("..") 6 | 7 | repositories { 8 | mavenCentral() 9 | gradlePluginPortal() 10 | maven("https://maven.fabricmc.net") 11 | } 12 | } 13 | 14 | fun createVersion(name: String) { 15 | include(name) 16 | val project = project(":$name") 17 | project.buildFileName = "../build.gradle.kts" 18 | } 19 | 20 | createVersion("fabric") 21 | createVersion("neoforge") 22 | //createVersion("forge") 23 | //createVersion("vanilla") -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/util/DummyExtensions.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.util 2 | 3 | import kotlin.reflect.KClass 4 | 5 | data class PlatformExtensionInfo( 6 | val name: String, 7 | val api: Class, 8 | val realImpl: Class, 9 | val dummyImpl: Class 10 | ) { 11 | constructor(name: String, api: KClass, realImpl: KClass, dummyImpl: KClass) 12 | : this(name, api.java, realImpl.java, dummyImpl.java) 13 | 14 | constructor(name: String, api: KClass, realImpl: KClass) 15 | : this(name, api.java, realImpl.java, realImpl.java) 16 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/publishing/loom/PublishingLoomImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.publishing.loom 2 | 3 | import dev.isxander.modstitch.publishing.PublishingCommonImpl 4 | import dev.isxander.modstitch.publishing.msPublishing 5 | import net.fabricmc.loom.task.RemapJarTask 6 | import org.gradle.api.Project 7 | import org.gradle.kotlin.dsl.assign 8 | import org.gradle.kotlin.dsl.named 9 | 10 | class PublishingLoomImpl : PublishingCommonImpl() { 11 | override fun apply(target: Project) { 12 | super.apply(target) 13 | 14 | target.msPublishing.mpp { 15 | modLoaders.add("fabric") 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | run 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### IntelliJ IDEA ### 9 | .idea 10 | *.iws 11 | *.iml 12 | *.ipr 13 | out/ 14 | !**/src/main/**/out/ 15 | !**/src/test/**/out/ 16 | 17 | ### Kotlin ### 18 | .kotlin 19 | 20 | ### Eclipse ### 21 | .apt_generated 22 | .classpath 23 | .factorypath 24 | .project 25 | .settings 26 | .springBeans 27 | .sts4-cache 28 | bin/ 29 | !**/src/main/**/bin/ 30 | !**/src/test/**/bin/ 31 | 32 | ### NetBeans ### 33 | /nbproject/private/ 34 | /nbbuild/ 35 | /dist/ 36 | /nbdist/ 37 | /.nb-gradle/ 38 | 39 | ### VS Code ### 40 | .vscode/ 41 | 42 | ### Mac OS ### 43 | .DS_Store -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/PlatformPlugin.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch 2 | 3 | import dev.isxander.modstitch.util.PlatformExtensionInfo 4 | import org.gradle.api.Plugin 5 | import org.gradle.api.Project 6 | 7 | abstract class PlatformPlugin : Plugin { 8 | open val platformExtensionInfo: PlatformExtensionInfo? = null 9 | 10 | var platformExtension: T? = null 11 | private set 12 | 13 | protected fun createRealPlatformExtension(target: Project, vararg constructionArguments: Any): T? { 14 | return platformExtensionInfo?.let { 15 | target.extensions.create(it.api, it.name, it.realImpl, *constructionArguments) 16 | }.also { platformExtension = it } 17 | } 18 | } -------------------------------------------------------------------------------- /test-project/forge/src/main/java/com/example/test/forge/utils/WindowUtils.java: -------------------------------------------------------------------------------- 1 | package com.example.test.forge.utils; 2 | 3 | import com.mojang.blaze3d.platform.Window; 4 | 5 | public class WindowUtils { 6 | public static int getWindowedX(Window window) { 7 | return window.windowedX; 8 | } 9 | 10 | public static int getWindowedY(Window window) { 11 | return window.windowedY; 12 | } 13 | 14 | public static int getWindowedWidth(Window window) { 15 | return window.windowedWidth; 16 | } 17 | 18 | public static int getWindowedHeight(Window window) { 19 | return window.windowedHeight; 20 | } 21 | 22 | public static void refreshFramebufferSize(Window window) { 23 | window.refreshFramebufferSize(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test-project/fabric/src/main/java/com/example/test/fabric/utils/WindowUtils.java: -------------------------------------------------------------------------------- 1 | package com.example.test.fabric.utils; 2 | 3 | import com.mojang.blaze3d.platform.Window; 4 | 5 | public class WindowUtils { 6 | public static int getWindowedX(Window window) { 7 | return window.windowedX; 8 | } 9 | 10 | public static int getWindowedY(Window window) { 11 | return window.windowedY; 12 | } 13 | 14 | public static int getWindowedWidth(Window window) { 15 | return window.windowedWidth; 16 | } 17 | 18 | public static int getWindowedHeight(Window window) { 19 | return window.windowedHeight; 20 | } 21 | 22 | public static void refreshFramebufferSize(Window window) { 23 | window.refreshFramebufferSize(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test-project/neoforge/src/main/java/com/example/test/neoforge/utils/WindowUtils.java: -------------------------------------------------------------------------------- 1 | package com.example.test.neoforge.utils; 2 | 3 | import com.mojang.blaze3d.platform.Window; 4 | 5 | public class WindowUtils { 6 | public static int getWindowedX(Window window) { 7 | return window.windowedX; 8 | } 9 | 10 | public static int getWindowedY(Window window) { 11 | return window.windowedY; 12 | } 13 | 14 | public static int getWindowedWidth(Window window) { 15 | return window.windowedWidth; 16 | } 17 | 18 | public static int getWindowedHeight(Window window) { 19 | return window.windowedHeight; 20 | } 21 | 22 | public static void refreshFramebufferSize(Window window) { 23 | window.refreshFramebufferSize(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/BasePlugin.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base 2 | 3 | import dev.isxander.modstitch.ModstitchExtensionPlugin 4 | import dev.isxander.modstitch.util.Platform 5 | import dev.isxander.modstitch.PlatformPlugin 6 | import dev.isxander.modstitch.base.loom.BaseLoomImpl 7 | import dev.isxander.modstitch.base.moddevgradle.BaseModDevGradleImpl 8 | import dev.isxander.modstitch.base.moddevgradle.MDGType 9 | import dev.isxander.modstitch.util.RegisteredGradlePlugin 10 | 11 | @RegisteredGradlePlugin 12 | class BasePlugin : ModstitchExtensionPlugin("base", platforms) { 13 | companion object { 14 | val platforms = mapOf>( 15 | Platform.Loom to BaseLoomImpl(), 16 | Platform.MDG to BaseModDevGradleImpl(MDGType.Regular), 17 | Platform.MDGLegacy to BaseModDevGradleImpl(MDGType.Legacy), 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/shadow/ShadowPlugin.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.shadow 2 | 3 | import dev.isxander.modstitch.ModstitchExtensionPlugin 4 | import dev.isxander.modstitch.PlatformPlugin 5 | import dev.isxander.modstitch.base.moddevgradle.MDGType 6 | import dev.isxander.modstitch.shadow.loom.ShadowLoomImpl 7 | import dev.isxander.modstitch.shadow.moddevgradle.ShadowModdevgradleImpl 8 | import dev.isxander.modstitch.util.Platform 9 | import dev.isxander.modstitch.util.RegisteredGradlePlugin 10 | 11 | @RegisteredGradlePlugin 12 | class ShadowPlugin : ModstitchExtensionPlugin("shadow", platforms) { 13 | companion object { 14 | val platforms = mapOf>( 15 | Platform.Loom to ShadowLoomImpl(), 16 | Platform.MDG to ShadowModdevgradleImpl(MDGType.Regular), 17 | Platform.MDGLegacy to ShadowModdevgradleImpl(MDGType.Legacy), 18 | ) 19 | } 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/publishing/PublishingPlugin.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.publishing 2 | 3 | import dev.isxander.modstitch.ExtensionPlatforms 4 | import dev.isxander.modstitch.ModstitchExtensionPlugin 5 | import dev.isxander.modstitch.base.moddevgradle.MDGType 6 | import dev.isxander.modstitch.publishing.loom.PublishingLoomImpl 7 | import dev.isxander.modstitch.publishing.moddevgradle.PublishingModdevgradleImpl 8 | import dev.isxander.modstitch.util.Platform 9 | import dev.isxander.modstitch.util.RegisteredGradlePlugin 10 | 11 | @RegisteredGradlePlugin 12 | class PublishingPlugin : ModstitchExtensionPlugin("publishing", platforms) { 13 | companion object { 14 | val platforms: ExtensionPlatforms = mapOf( 15 | Platform.Loom to PublishingLoomImpl(), 16 | Platform.MDG to PublishingModdevgradleImpl(MDGType.Regular), 17 | Platform.MDGLegacy to PublishingModdevgradleImpl(MDGType.Legacy), 18 | ) 19 | } 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/util/StringUtils.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.util 2 | 3 | /** 4 | * Adds a given [prefix] to the beginning of the string and capitalizes the first character 5 | * of the original string to form a camel-case-like identifier. 6 | * 7 | * @param prefix The string to prepend to the original string. 8 | * @return A new string with the prefix added and the first character of the original string capitalized. 9 | */ 10 | internal fun String.addCamelCasePrefix(prefix: String): String = 11 | replaceFirstChar { prefix + it.uppercaseChar() } 12 | 13 | /** 14 | * Splits the given [CharSequence] into a list of words using whitespace as the delimiter. 15 | * 16 | * @param limit An optional limit on the number of words to return. 17 | * @return A list of words extracted from the string. 18 | */ 19 | internal fun CharSequence.words(limit: Int = 0): List = 20 | trim().split(WHITESPACE, limit) 21 | 22 | private val WHITESPACE = Regex("\\s+") 23 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/util/DelegateUtils.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.util 2 | 3 | import org.gradle.api.plugins.ExtensionAware 4 | import org.gradle.api.plugins.ExtensionContainer 5 | import org.gradle.kotlin.dsl.getByType 6 | import kotlin.properties.ReadOnlyProperty 7 | import kotlin.properties.ReadWriteProperty 8 | import kotlin.reflect.KClass 9 | import kotlin.reflect.KProperty 10 | 11 | class NotExistsDelegate : ReadWriteProperty { 12 | override fun getValue(thisRef: Any, property: KProperty<*>): T { 13 | error("Property ${property.name} does not exist") 14 | } 15 | 16 | override fun setValue(thisRef: Any, property: KProperty<*>, value: T) { 17 | error("Property ${property.name} does not exist") 18 | } 19 | } 20 | 21 | class NotExistsNullableDelegate : ReadWriteProperty { 22 | override fun getValue(thisRef: Any, property: KProperty<*>): T? { 23 | return null 24 | } 25 | 26 | override fun setValue(thisRef: Any, property: KProperty<*>, value: T?) { 27 | error("Property ${property.name} does not exist") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/publishing/moddevgradle/PublishingModdevgradleImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.publishing.moddevgradle 2 | 3 | import dev.isxander.modstitch.base.extensions.modstitch 4 | import dev.isxander.modstitch.base.moddevgradle.MDGType 5 | import dev.isxander.modstitch.publishing.PublishingCommonImpl 6 | import dev.isxander.modstitch.publishing.msPublishing 7 | import net.neoforged.moddevgradle.legacyforge.tasks.RemapJar 8 | import org.gradle.api.Project 9 | import org.gradle.jvm.tasks.Jar 10 | import org.gradle.kotlin.dsl.* 11 | 12 | class PublishingModdevgradleImpl(private val type: MDGType) : PublishingCommonImpl() { 13 | override fun apply(target: Project) { 14 | super.apply(target) 15 | 16 | target.modstitch.onEnable { 17 | target.msPublishing.mpp { 18 | modLoaders.add( 19 | when (this@PublishingModdevgradleImpl.type) { 20 | MDGType.Regular -> "neoforge" 21 | MDGType.Legacy -> "forge" 22 | } 23 | ) 24 | } 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/FutureNamedDomainObjectProvider.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base 2 | 3 | import org.gradle.api.Named 4 | import org.gradle.api.NamedDomainObjectContainer 5 | 6 | sealed interface FutureNamedDomainObjectProvider { 7 | val name: String 8 | 9 | fun get(): T 10 | 11 | companion object { 12 | fun from(container: NamedDomainObjectContainer, name: String): FutureNamedDomainObjectProvider { 13 | return FutureNamedDomainObjectProviderImpl(container, name) 14 | } 15 | 16 | fun from(value: T): FutureNamedDomainObjectProvider { 17 | return ResolvedNamedDomainObjectProviderImpl(value) 18 | } 19 | } 20 | } 21 | 22 | class FutureNamedDomainObjectProviderImpl( 23 | private val container: NamedDomainObjectContainer, 24 | override val name: String 25 | ) : FutureNamedDomainObjectProvider { 26 | override fun get(): T { 27 | return container.getByName(name) 28 | } 29 | } 30 | 31 | class ResolvedNamedDomainObjectProviderImpl( 32 | private val value: T 33 | ) : FutureNamedDomainObjectProvider { 34 | override val name = value.name 35 | 36 | override fun get(): T = value 37 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/AppendModMetadataTask.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base 2 | 3 | import dev.isxander.modstitch.base.extensions.FinalMixinConfigurationSettings 4 | import org.gradle.api.provider.ListProperty 5 | import org.gradle.api.tasks.Input 6 | import org.gradle.api.tasks.SourceTask 7 | import org.gradle.api.tasks.TaskAction 8 | import java.io.File 9 | 10 | /** 11 | * A Gradle task that appends metadata entries to the provided mod manifest file(s). 12 | */ 13 | abstract class AppendModMetadataTask : SourceTask() { 14 | /** A list of mixin configurations to be appended to the metadata. */ 15 | @get:Input 16 | abstract val mixins: ListProperty 17 | 18 | /** A list of access widener file paths to be included in the metadata. */ 19 | @get:Input 20 | abstract val accessWideners: ListProperty 21 | 22 | /** 23 | * Appends metadata entries to the [source] file(s). 24 | */ 25 | @TaskAction 26 | fun appendModMetadata() = source.visit { appendModMetadata(file) } 27 | 28 | /** 29 | * Appends metadata entries to the specified file. 30 | * 31 | * @param file The file to which metadata entries should be appended. 32 | */ 33 | protected abstract fun appendModMetadata(file: File) 34 | } 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Modstitch 2 | 3 | Modstitch is an abstraction layer upon first-party Minecraft mod loader tooling to provide a unified developer experience. 4 | 5 | ```kts 6 | plugins { 7 | id("dev.isxander.modstitch.base") version "0.6.0-unstable" 8 | } 9 | 10 | modstitch { 11 | minecraftVersion = "1.21.8" 12 | 13 | loom { 14 | fabricLoaderVersion = "0.16.19" 15 | } 16 | 17 | moddevgradle { 18 | neoForgeVersion = "21.8.0-beta" 19 | } 20 | 21 | parchment { 22 | version = "2025.12.12" 23 | } 24 | 25 | metadata { 26 | modId = "my_mod" 27 | modVersion = "1.0.0" 28 | modName = "My Mod" 29 | modGroup = "com.example" 30 | } 31 | 32 | mixins.register("my_mod") 33 | } 34 | ``` 35 | 36 | Modstitch provides a DSL to interact with both [Fabric Loom](https://github.com/fabricmc/fabric-loom) and [ModDevGradle](https://github.com/neoforged/ModDevGradle) with the same buildscript. It is commonly used with Preprocessor plugins such as [Stonecutter](https://stonecutter.kikugie.dev/) which call for a single buildscript for all targets. 37 | 38 | Modstitch also provides other DX utilities not found in either Fabric Loom or ModDevGradle, such as automatic 39 | mixin registration in mod manifests, transpiling of AWs/ATs to the correct format for the platform, and sensible defaults to make your build-scripts super concise. -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/util/Platform.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.util 2 | 3 | import org.gradle.api.Project 4 | 5 | enum class Platform(val friendlyName: String, val modManifest: String) { 6 | Loom("loom", "fabric.mod.json"), 7 | MDG("moddevgradle", "META-INF/neoforge.mods.toml"), 8 | MDGLegacy("moddevgradle-legacy", "META-INF/mods.toml"); 9 | 10 | val isModDevGradle: Boolean 11 | get() = this in listOf(MDG, MDGLegacy) 12 | val isModDevGradleRegular: Boolean 13 | get() = this == MDG 14 | val isModDevGradleLegacy: Boolean 15 | get() = this == MDGLegacy 16 | val isLoom: Boolean 17 | get() = this == Loom 18 | 19 | companion object { 20 | val allModManifests = values().map { it.modManifest } 21 | 22 | fun fromFriendlyName(name: String): Platform? { 23 | return values().firstOrNull { it.friendlyName == name } 24 | } 25 | } 26 | } 27 | 28 | val Project.platformOrNull: Platform? 29 | get() = project.extensions.extraProperties.has("appliedPlatform") 30 | .let { if (it) Platform.fromFriendlyName(project.extensions.extraProperties["appliedPlatform"].toString()) else null } 31 | var Project.platform: Platform 32 | get() = platformOrNull ?: throw IllegalStateException("Loader not set") 33 | internal set(value) { 34 | project.extensions.extraProperties["appliedPlatform"] = value.friendlyName 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/shadow/loom/ShadowLoomImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.shadow.loom 2 | 3 | import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar 4 | import dev.isxander.modstitch.base.extensions.modstitch 5 | import dev.isxander.modstitch.shadow.ShadowCommonImpl 6 | import dev.isxander.modstitch.shadow.devlib 7 | import net.fabricmc.loom.task.RemapJarTask 8 | import org.gradle.api.NamedDomainObjectProvider 9 | import org.gradle.api.Project 10 | import org.gradle.api.artifacts.Configuration 11 | import org.gradle.api.tasks.TaskProvider 12 | import org.gradle.jvm.tasks.Jar 13 | import org.gradle.kotlin.dsl.* 14 | 15 | class ShadowLoomImpl : ShadowCommonImpl() { 16 | override fun configureShadowTask( 17 | target: Project, 18 | shadowTask: TaskProvider, 19 | shadeConfiguration: NamedDomainObjectProvider, 20 | ) { 21 | super.configureShadowTask(target, shadowTask, shadeConfiguration) 22 | 23 | target.tasks.named("remapJar") { 24 | dependsOn(shadowTask) 25 | 26 | // change the input from jar to shadowJar 27 | inputFile = shadowTask.flatMap { it.archiveFile } 28 | 29 | // this is the final jar, so this should have no classifier 30 | archiveClassifier = "" 31 | } 32 | 33 | shadowTask { 34 | archiveClassifier = "dev-fat" 35 | devlib() 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/util/LineNumberReaderUtils.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.util 2 | 3 | import java.io.LineNumberReader 4 | 5 | /** 6 | * Reads the next non-blank, non-commented line from the [LineNumberReader]. 7 | * 8 | * If a line contains an inline comment, only the part before the `#` is returned. 9 | * 10 | * @return The uncommented portion of the next relevant line, or `null` if end of stream is reached. 11 | */ 12 | internal fun LineNumberReader.readUncommentedLine(): CharSequence? { 13 | while (true) { 14 | val line = readLine() 15 | if (line == null) { 16 | return null 17 | } 18 | 19 | val i = line.indexOfFirst { !it.isWhitespace() } 20 | if (i < 0 || line[i] == '#') { 21 | continue 22 | } 23 | 24 | val j = line.indexOf('#', i) 25 | return if (j < 0) line else line.subSequence(0, j) 26 | } 27 | } 28 | 29 | /** 30 | * Throws a [FormatException] with the given [message]. 31 | * 32 | * @param message The error message to include in the exception. 33 | * @throws FormatException Always thrown with the provided message and current line number. 34 | */ 35 | internal fun LineNumberReader.error(message: String): Nothing = 36 | throw FormatException(message, lineNumber) 37 | 38 | /** 39 | * Exception thrown when a format error occurs while reading a text stream. 40 | * 41 | * @param message The error message. 42 | * @param lineNumber The line number where the error was encountered. 43 | */ 44 | private class FormatException(message: String, val lineNumber: Int): Exception("$message:line $lineNumber") 45 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/util/ProjectUtils.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.util 2 | 3 | import org.gradle.api.Action 4 | import org.gradle.api.Project 5 | import org.gradle.api.provider.Provider 6 | import org.gradle.api.tasks.SourceSet 7 | import org.gradle.api.tasks.SourceSetContainer 8 | 9 | /** 10 | * Gets the [SourceSetContainer] from the project's extensions, if available. 11 | */ 12 | internal val Project.sourceSets: SourceSetContainer? 13 | get() = extensions.findByName("sourceSets") as SourceSetContainer? 14 | 15 | /** 16 | * Gets the `main` [SourceSet] from the project's source sets, if it exists. 17 | */ 18 | internal val Project.mainSourceSet: SourceSet? 19 | get() = sourceSets?.getByName(SourceSet.MAIN_SOURCE_SET_NAME) 20 | 21 | /** 22 | * Gets the full project chain, starting with the current project. 23 | */ 24 | internal val Project.projectChain: Sequence 25 | get() = generateSequence(this) { it.parent } 26 | 27 | /** 28 | * Executes the given [action] after the project has been successfully evaluated. 29 | * 30 | * @param action The action to be executed after successful project evaluation. 31 | */ 32 | internal fun Project.afterSuccessfulEvaluate(action: Action) = project.afterEvaluate { 33 | if (state.failure == null) { 34 | action.execute(this) 35 | } 36 | } 37 | 38 | /** 39 | * Zips three [Provider]s together, rather than the usual two. 40 | */ 41 | internal fun zip(a: Provider, b: Provider, c: Provider, combiner: (A, B, C) -> T): Provider { 42 | return a.zip(b) { av, bv -> av to bv }.zip(c) { (av, bv), cv -> combiner(av, bv, cv) } 43 | } 44 | -------------------------------------------------------------------------------- /src/test/kotlin/dev/isxander/modstitch/integration/MinimalIntegration.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.integration 2 | 3 | import org.gradle.testkit.runner.TaskOutcome 4 | import org.junit.jupiter.api.Tag 5 | import kotlin.test.Test 6 | import kotlin.test.assertEquals 7 | 8 | class MinimalIntegration : BaseFunctionalTest() { 9 | @Test @Tag("loom") 10 | fun `minimal loom succeeds`() { 11 | setupMinimalLoom() 12 | 13 | val result = run { 14 | withArguments("build", "--stacktrace") 15 | } 16 | 17 | assertEquals( 18 | TaskOutcome.SUCCESS, 19 | result.task(":build")?.outcome, 20 | "Expected build task to succeed, but it failed with outcome: ${result.task(":build")?.outcome}" 21 | ) 22 | } 23 | 24 | @Test @Tag("mdg") 25 | fun `minimal mdg succeeds`() { 26 | setupMinimalMdg() 27 | 28 | val result = run { 29 | withArguments("build", "--stacktrace") 30 | } 31 | 32 | assertEquals( 33 | TaskOutcome.SUCCESS, 34 | result.task(":build")?.outcome, 35 | "Expected build task to succeed, but it failed with outcome: ${result.task(":build")?.outcome}" 36 | ) 37 | } 38 | 39 | @Test @Tag("mdgl") 40 | fun `minimal mdgl succeeds`() { 41 | setupMinimalMdgl() 42 | 43 | val result = run { 44 | withArguments("build", "--stacktrace") 45 | } 46 | 47 | assertEquals( 48 | TaskOutcome.SUCCESS, 49 | result.task(":build")?.outcome, 50 | "Expected build task to succeed, but it failed with outcome: ${result.task(":build")?.outcome}" 51 | ) 52 | } 53 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/extensions/Parchment.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base.extensions 2 | 3 | import org.gradle.api.model.ObjectFactory 4 | import org.gradle.api.provider.Property 5 | import org.gradle.kotlin.dsl.property 6 | import javax.inject.Inject 7 | 8 | interface ParchmentBlock { 9 | /** 10 | * The Minecraft version to use for Parchment mappings. 11 | * By default, this is the same as the Minecraft version of the project. 12 | */ 13 | val minecraftVersion: Property 14 | 15 | /** 16 | * The mappings version to use for Parchment mappings. 17 | * E.g. 2024.12.29 18 | */ 19 | val mappingsVersion: Property 20 | 21 | /** 22 | * The artifact to use for Parchment mappings. 23 | * By default, this is `org.parchmentmc.data:parchment-$minecraftVersion:$mappingsVersion@zip`. 24 | */ 25 | val parchmentArtifact: Property 26 | 27 | /** 28 | * Whether Parchment is enabled. 29 | * This is true if [parchmentArtifact] is not empty. 30 | */ 31 | val enabled: Property 32 | } 33 | @Suppress("LeakingThis") // Extension must remain open for Gradle to inject the implementation. This is safe. 34 | open class ParchmentBlockImpl @Inject constructor(objects: ObjectFactory) : ParchmentBlock { 35 | override val minecraftVersion = objects.property() 36 | override val mappingsVersion = objects.property() 37 | override val parchmentArtifact = objects.property().convention(minecraftVersion.zip(mappingsVersion) { mc, mappings -> "org.parchmentmc.data:parchment-$mc:$mappings@zip" }) 38 | override val enabled = objects.property().convention(parchmentArtifact.map { it.isNotEmpty() }.orElse(false)) 39 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/loom/BaseLoomExtension.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base.loom 2 | 3 | /** 4 | * The extension for configuring Fabric-specific settings. 5 | */ 6 | 7 | import dev.isxander.modstitch.util.NotExistsDelegate 8 | import net.fabricmc.loom.api.LoomGradleExtensionAPI 9 | import org.gradle.api.Action 10 | import org.gradle.api.Project 11 | import org.gradle.api.model.ObjectFactory 12 | import org.gradle.api.provider.Property 13 | import org.gradle.kotlin.dsl.* 14 | import javax.inject.Inject 15 | 16 | interface BaseLoomExtension { 17 | /** 18 | * The version of Fabric Loader to use. 19 | */ 20 | val fabricLoaderVersion: Property 21 | 22 | /** 23 | * The underlying platform-specific extension: `loom` 24 | */ 25 | val loomExtension: LoomGradleExtensionAPI 26 | 27 | /** 28 | * Configures the Loom extension. 29 | * This action will only be executed if the current platform is Loom. 30 | */ 31 | fun configureLoom(action: Action) 32 | } 33 | 34 | open class BaseLoomExtensionImpl @Inject constructor( 35 | objects: ObjectFactory, 36 | @Transient private val project: Project 37 | ) : BaseLoomExtension { 38 | override val fabricLoaderVersion: Property = objects.property() 39 | 40 | override val loomExtension: LoomGradleExtensionAPI 41 | get() = project.extensions.getByType() 42 | 43 | override fun configureLoom(action: Action) = 44 | action.execute(loomExtension) 45 | } 46 | 47 | open class BaseLoomExtensionDummy : BaseLoomExtension { 48 | override val fabricLoaderVersion: Property by NotExistsDelegate() 49 | override val loomExtension: LoomGradleExtensionAPI by NotExistsDelegate() 50 | override fun configureLoom(action: Action) {} 51 | } 52 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/moddevgradle/AppendNeoForgeMetadataTask.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base.moddevgradle 2 | 3 | import com.electronwill.nightconfig.core.Config 4 | import com.electronwill.nightconfig.core.file.FileNotFoundAction 5 | import com.electronwill.nightconfig.core.io.WritingMode 6 | import com.electronwill.nightconfig.toml.TomlFormat 7 | import dev.isxander.modstitch.base.AppendModMetadataTask 8 | import java.io.File 9 | 10 | /** 11 | * A Gradle task that appends metadata entries to (Neo)Forge's `mods.toml` files. 12 | */ 13 | abstract class AppendNeoForgeMetadataTask : AppendModMetadataTask() { 14 | override fun appendModMetadata(file: File) { 15 | val config = TomlFormat.instance().createParser().parse(file, FileNotFoundAction.THROW_ERROR) 16 | appendNewEntries(config, "mixins", "config", mixins.get().map { it.config }) 17 | appendNewEntries(config, "accessTransformers", "file", accessWideners.get()) 18 | 19 | TomlFormat.instance().createWriter().write(config, file, WritingMode.REPLACE) 20 | } 21 | 22 | /** 23 | * Appends provided values to a TOML array under the specified key and field name. 24 | * 25 | * The values are added only if they are not already present. 26 | * 27 | * @param config The parsed TOML configuration. 28 | * @param key The TOML table to update. 29 | * @param name The field within the table to append to. 30 | * @param values The values to append. 31 | */ 32 | private fun appendNewEntries(config: Config, key: String, name: String, values: Iterable) { 33 | val entries = config.getOptional>(key).orElseGet { 34 | mutableListOf().also { config.set>(key, it) } 35 | } 36 | val existingEntries = entries.map { it.getOptional(name).orElse("") } 37 | 38 | for (value in values) { 39 | if (existingEntries.contains(value)) { 40 | continue 41 | } 42 | 43 | val entry = Config.inMemory() 44 | entry.set(name, value) 45 | entries.add(entry) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/moddevgradle/GenerateAccessTransformerTask.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base.moddevgradle 2 | 3 | import dev.isxander.modstitch.util.AccessWidener 4 | import dev.isxander.modstitch.util.AccessWidenerFormat 5 | import org.gradle.api.DefaultTask 6 | import org.gradle.api.file.RegularFileProperty 7 | import org.gradle.api.tasks.InputFile 8 | import org.gradle.api.tasks.Optional 9 | import org.gradle.api.tasks.OutputFile 10 | import org.gradle.api.tasks.TaskAction 11 | 12 | /** 13 | * A Gradle task that converts the provided [accessWidener] to an [accessTransformer], 14 | * optionally remapping it using the specified [mappings], if available. 15 | */ 16 | abstract class GenerateAccessTransformerTask : DefaultTask() 17 | { 18 | /** The access widener file to convert into an access transformer. */ 19 | @get:InputFile 20 | @get:Optional 21 | abstract val accessWidener: RegularFileProperty 22 | 23 | /** The mappings file used to remap the resulting access transformer, if available. */ 24 | @get:InputFile 25 | @get:Optional 26 | abstract val mappings: RegularFileProperty 27 | 28 | /** The output file for the generated access transformer. */ 29 | @get:OutputFile 30 | @get:Optional 31 | abstract val accessTransformer: RegularFileProperty 32 | 33 | /** 34 | * Generates an access transformer based on the provided [accessWidener], 35 | * optionally remapping it using the supplied [mappings]. 36 | */ 37 | @TaskAction 38 | fun generateAccessTransformer() { 39 | if (!accessWidener.isPresent) { 40 | return 41 | } 42 | 43 | val parsedAccessWidener = accessWidener.get().asFile.reader().use { AccessWidener.parse(it) } 44 | val convertedAccessTransformer = parsedAccessWidener.convert(AccessWidenerFormat.AT) 45 | val remappedAccessTransformer = when { 46 | mappings.isPresent -> mappings.get().asFile.reader().use { convertedAccessTransformer.remap(it) } 47 | else -> convertedAccessTransformer 48 | } 49 | 50 | accessTransformer.get().asFile.writer().use { remappedAccessTransformer.write(it) } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/shadow/ShadowCommonImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.shadow 2 | 3 | import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar 4 | import dev.isxander.modstitch.PlatformPlugin 5 | import dev.isxander.modstitch.shadow.extensions.* 6 | import dev.isxander.modstitch.util.printVersion 7 | import org.gradle.api.NamedDomainObjectProvider 8 | import org.gradle.api.Project 9 | import org.gradle.api.artifacts.Configuration 10 | import org.gradle.api.tasks.TaskProvider 11 | import org.gradle.api.tasks.bundling.AbstractArchiveTask 12 | import org.gradle.api.tasks.bundling.Jar 13 | import org.gradle.kotlin.dsl.* 14 | 15 | abstract class ShadowCommonImpl : PlatformPlugin() { 16 | override fun apply(target: Project) { 17 | printVersion("Shadow", target) 18 | 19 | val extension = target.extensions.create( 20 | ShadowExtension::class.java, 21 | "msShadow", 22 | ShadowExtensionImpl::class.java 23 | ) 24 | 25 | target.pluginManager.apply("com.gradleup.shadow") 26 | 27 | val modstitchShadow by target.configurations.registering { 28 | isCanBeResolved = true 29 | isCanBeConsumed = false 30 | isTransitive = false 31 | } 32 | 33 | target.tasks.named("jar") { 34 | // shadowJar does not use jar as an input, so bundling jar is a waste of time 35 | enabled = false 36 | } 37 | 38 | target.pluginManager.withPlugin("com.gradleup.shadow") { 39 | val shadowJar = target.tasks.named("shadowJar") 40 | configureShadowTask(target, shadowJar, modstitchShadow) 41 | } 42 | } 43 | 44 | protected open fun configureShadowTask( 45 | target: Project, 46 | shadowTask: TaskProvider, 47 | shadeConfiguration: NamedDomainObjectProvider, 48 | ) { 49 | shadowTask { 50 | configurations = listOf(shadeConfiguration.get()) 51 | archiveClassifier = "" 52 | } 53 | } 54 | } 55 | 56 | fun AbstractArchiveTask.devlib() { 57 | destinationDirectory = project.layout.buildDirectory.map { it.dir("devlibs") } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/loom/AppendFabricMetadataTask.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base.loom 2 | 3 | import com.google.gson.GsonBuilder 4 | import com.google.gson.JsonArray 5 | import com.google.gson.JsonObject 6 | import dev.isxander.modstitch.base.AppendModMetadataTask 7 | import dev.isxander.modstitch.util.Side 8 | import java.io.File 9 | 10 | /** 11 | * A Gradle task that appends metadata entries to Fabric's `fabric.mod.json` files. 12 | */ 13 | abstract class AppendFabricMetadataTask : AppendModMetadataTask() { 14 | override fun appendModMetadata(file: File) { 15 | val gson = GsonBuilder().setPrettyPrinting().create() 16 | val json = file.reader().use { gson.fromJson(it, JsonObject::class.java) } 17 | 18 | val accessWidener = accessWideners.get().let { if (it.isEmpty()) null else it.single() } 19 | val existingAccessWidener = json["accessWidener"]?.asString 20 | if (existingAccessWidener != null && existingAccessWidener != accessWidener) { 21 | error("An access widener has already been specified: '$existingAccessWidener'.") 22 | } 23 | if (accessWidener != null) { 24 | json.addProperty("accessWidener", accessWidener) 25 | } 26 | 27 | val mixinConfigs = json.getAsJsonArray("mixins") ?: JsonArray().also { json.add("mixins", it) } 28 | val existingMixinConfigs = mixinConfigs.map { when { 29 | it.isJsonObject -> it.asJsonObject.get("config")?.asString ?: "" 30 | it.isJsonPrimitive && it.asJsonPrimitive.isString -> it.asString 31 | else -> it.toString() 32 | }} 33 | for (mixin in mixins.get()) { 34 | if (existingMixinConfigs.contains(mixin.config)) { 35 | continue 36 | } 37 | 38 | val mixinConfig = JsonObject() 39 | mixinConfig.addProperty("config", mixin.config) 40 | mixinConfig.addProperty("environment", when (mixin.side) { 41 | Side.Both -> "*" 42 | Side.Client -> "client" 43 | Side.Server -> "server" 44 | }) 45 | mixinConfigs.add(mixinConfig) 46 | } 47 | 48 | return file.writer().use { gson.toJson(json, it) } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Gradle Tests 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | types: [ opened, synchronize, reopened ] 9 | 10 | jobs: 11 | # Job 1: Build the code and run tests 12 | # This job runs in the context of the pull request and has read-only permissions. 13 | build-and-test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up JDK 21 20 | uses: actions/setup-java@v4 21 | with: 22 | java-version: '21' 23 | distribution: 'temurin' 24 | 25 | - name: Setup Gradle 26 | uses: gradle/actions/setup-gradle@v4 27 | 28 | - name: Run tests with Gradle 29 | # The 'continue-on-error' flag ensures that the workflow proceeds to the 30 | # artifact upload step even if the tests fail. 31 | continue-on-error: true 32 | run: ./gradlew test 33 | 34 | - name: Upload test results 35 | # This step always runs to ensure the report is available for the next job. 36 | if: always() 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: test-results 40 | path: build/test-results/test/*.xml 41 | retention-days: 1 42 | 43 | # Job 2: Publish the test report 44 | # This job runs in the context of the base repository 45 | # and has the necessary write permissions to create a check run. 46 | report-tests: 47 | # It requires the 'build-and-test' job to complete first. 48 | needs: build-and-test 49 | runs-on: ubuntu-latest 50 | permissions: 51 | # This grants the job permission to post check runs. 52 | checks: write 53 | steps: 54 | - name: Checkout repository 55 | uses: actions/checkout@v4 56 | 57 | - name: Download test results 58 | uses: actions/download-artifact@v5 59 | with: 60 | name: test-results 61 | 62 | - name: Publish Test Report 63 | uses: dorny/test-reporter@v2 64 | with: 65 | name: Test Results 66 | # The path now points to the XML files downloaded by the artifact step. 67 | path: '*.xml' 68 | reporter: java-junit 69 | fail-on-error: 'false' -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/publishing/PublishingCommonImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.publishing 2 | 3 | import dev.isxander.modstitch.PlatformPlugin 4 | import dev.isxander.modstitch.base.extensions.modstitch 5 | import dev.isxander.modstitch.util.printVersion 6 | import org.gradle.api.Project 7 | import org.gradle.api.publish.maven.MavenPublication 8 | import org.gradle.api.tasks.bundling.AbstractArchiveTask 9 | import org.gradle.kotlin.dsl.* 10 | 11 | abstract class PublishingCommonImpl : PlatformPlugin() { 12 | override fun apply(target: Project) { 13 | printVersion("Publishing", target) 14 | 15 | val msPublishing = target.extensions.create( 16 | PublishingExtension::class.java, 17 | "msPublishing", 18 | PublishingExtensionImpl::class.java, 19 | ) 20 | 21 | val publishMod by target.tasks.registering { 22 | group = "modstitch/publishing" 23 | } 24 | 25 | target.pluginManager.apply("maven-publish") 26 | target.pluginManager.withPlugin("maven-publish") { 27 | msPublishing.maven { 28 | publications { 29 | register("mod") { 30 | from(target.components["java"]) 31 | 32 | msPublishing.additionalArtifacts.whenObjectAdded obj@{ 33 | artifact(this@obj) 34 | } 35 | 36 | target.afterEvaluate { 37 | groupId = target.modstitch.metadata.modGroup.get() 38 | artifactId = target.modstitch.metadata.modId.get() 39 | } 40 | } 41 | } 42 | } 43 | 44 | publishMod { dependsOn(target.tasks.named("publish")) } 45 | } 46 | 47 | target.pluginManager.apply("me.modmuss50.mod-publish-plugin") 48 | target.pluginManager.withPlugin("me.modmuss50.mod-publish-plugin") { 49 | msPublishing.mpp { 50 | displayName = target.modstitch.metadata.modName 51 | version = target.modstitch.metadata.modVersion 52 | 53 | file.set(target.provider { target.modstitch.finalJarTask.flatMap { it.archiveFile } }.flatMap { it }) 54 | 55 | msPublishing.additionalArtifacts.whenObjectAdded obj@{ 56 | additionalFiles.from(if (this@obj is AbstractArchiveTask) this@obj.archiveFile else this@obj) 57 | } 58 | } 59 | 60 | publishMod { dependsOn(target.tasks.named("publishMods")) } 61 | } 62 | } 63 | 64 | 65 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/shadow/extensions/Shadow.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.shadow.extensions 2 | 3 | import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar 4 | import dev.isxander.modstitch.base.extensions.modstitch 5 | import org.gradle.api.Action 6 | import org.gradle.api.Project 7 | import org.gradle.api.model.ObjectFactory 8 | import org.gradle.api.provider.Property 9 | import org.gradle.kotlin.dsl.* 10 | 11 | interface ShadowExtension { 12 | /** 13 | * The package to relocate dependencies to. 14 | * This is the base package that all relocations will be relative to. 15 | * 16 | * If you called `dependency("something", "com.example" to "example"), 17 | * the relocated package would be `$relocatePackage.example`. 18 | */ 19 | val relocatePackage: Property 20 | 21 | /** 22 | * Add a dependency to shadow jar, with a map of relocations. 23 | */ 24 | fun dependency(dependencyNotation: Any, relocations: Map) 25 | /** 26 | * Add a dependency to shadow jar, with a map of relocations. 27 | * `relocations` must not be empty. 28 | */ 29 | fun dependency(dependencyNotation: Any, vararg relocations: Pair) 30 | /** 31 | * Add a dependency to shadow jar, with a map of relocations. 32 | */ 33 | fun dependency(dependencyNotation: Any, action: Action>) 34 | } 35 | 36 | @Suppress("LeakingThis") 37 | open class ShadowExtensionImpl( 38 | objects: ObjectFactory, 39 | @Transient private val target: Project 40 | ) : ShadowExtension { 41 | override val relocatePackage = objects.property() 42 | 43 | override fun dependency(dependencyNotation: Any, relocations: Map) { 44 | require(relocations.isNotEmpty()) { "At least one relocation must be provided." } 45 | 46 | target.dependencies.add("modstitchShadow", dependencyNotation) 47 | target.tasks.named("shadowJar") { 48 | relocations.forEach { (pkg, id) -> 49 | relocate(pkg, "${relocatePackage.get()}.$id") 50 | } 51 | } 52 | } 53 | 54 | override fun dependency(dependencyNotation: Any, action: Action>) { 55 | dependency(dependencyNotation, mutableMapOf().also(action::execute)) 56 | } 57 | 58 | override fun dependency(dependencyNotation: Any, vararg relocations: Pair) { 59 | dependency(dependencyNotation, relocations.toMap()) 60 | } 61 | } 62 | 63 | typealias PackageName = String 64 | typealias RelocateId = String 65 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/publishing/PublishingExtension.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.publishing 2 | 3 | import dev.isxander.modstitch.base.extensions.modstitch 4 | import me.modmuss50.mpp.ModPublishExtension 5 | import org.gradle.api.Action 6 | import org.gradle.api.DomainObjectSet 7 | import org.gradle.api.Project 8 | import org.gradle.api.model.ObjectFactory 9 | import org.gradle.api.provider.Property 10 | import org.gradle.api.publish.PublishingExtension as MavenPublishingExtension 11 | import org.gradle.kotlin.dsl.* 12 | import javax.inject.Inject 13 | 14 | interface PublishingExtension { 15 | /** 16 | * The Maven group ID to use for publishing. 17 | * By default, this is the same as the mod group ID. 18 | */ 19 | val mavenGroup: Property 20 | 21 | /** 22 | * The Maven artifact ID to use for publishing. 23 | * By default, this is the same as the mod ID, but with underscores replaced with hyphens. 24 | */ 25 | val mavenArtifact: Property 26 | 27 | /** 28 | * Additional artifacts to publish. 29 | */ 30 | val additionalArtifacts: DomainObjectSet 31 | 32 | /** 33 | * The `me.modmuss50.mod-publish-plugin` plugin extension. 34 | */ 35 | val mpp: ModPublishExtension 36 | /** 37 | * Configures the `me.modmuss50.mod-publish-plugin` plugin extension. 38 | */ 39 | fun mpp(action: Action) = action.execute(mpp) 40 | 41 | /** 42 | * The `maven-publish` plugin extension. 43 | */ 44 | val maven: MavenPublishingExtension 45 | /** 46 | * Configures the `maven-publish` plugin extension. 47 | */ 48 | fun maven(action: Action) = action.execute(maven) 49 | } 50 | 51 | open class PublishingExtensionImpl @Inject constructor(objects: ObjectFactory, private val project: Project) : PublishingExtension { 52 | override val mavenGroup = objects.property().convention(project.provider { project.modstitch.metadata.modGroup }.flatMap { it }) 53 | override val mavenArtifact = objects.property().convention(project.provider { project.modstitch.metadata.modId.map { it.replace('_', '-') } }.flatMap { it }) 54 | override val additionalArtifacts = objects.domainObjectSet(Any::class) 55 | 56 | override val mpp: ModPublishExtension 57 | get() = project.extensions.getByType() 58 | override val maven: MavenPublishingExtension 59 | get() = project.extensions.getByType() 60 | } 61 | 62 | val Project.msPublishing: PublishingExtension 63 | get() = extensions.getByType() 64 | fun Project.msPublishing(action: Action) = action.execute(msPublishing) -------------------------------------------------------------------------------- /test-project/build.gradle.kts: -------------------------------------------------------------------------------- 1 | modstitch { 2 | minecraftVersion = findProperty("minecraftVersion") as String? 3 | javaVersion = 21 4 | 5 | unitTesting() 6 | 7 | loom { 8 | fabricLoaderVersion = findProperty("fabricLoaderVersion") as String? 9 | } 10 | 11 | moddevgradle { 12 | neoForgeVersion = findProperty("neoForgeVersion") as String? 13 | forgeVersion = findProperty("forgeVersion") as String? 14 | mcpVersion = findProperty("mcpVersion") as String? 15 | neoFormVersion = findProperty("neoFormVersion") as String? 16 | } 17 | 18 | runs { 19 | register("funny") { 20 | client() 21 | } 22 | } 23 | 24 | println(modLoaderManifest.getOrElse("'modLoaderManifest' is not set.")) 25 | println(javaVersion.map { "Java version: $it" }.getOrElse("'javaVersion' is not set.")) 26 | 27 | metadata { 28 | modId = "test_project" 29 | modGroup = "dev.isxander" 30 | modVersion = "1.0.0" 31 | modLicense = "ARR" 32 | modName = "Test Project" 33 | modDescription = "A test project for ModStitch" 34 | } 35 | 36 | moddevgradle { 37 | defaultRuns() 38 | } 39 | 40 | mixin { 41 | configs.create("test") 42 | 43 | addMixinsToModManifest = true 44 | } 45 | } 46 | 47 | dependencies { 48 | modstitch.loom { 49 | modstitchModImplementation("net.fabricmc.fabric-api:fabric-api:0.112.0+1.21.4") 50 | } 51 | 52 | "org.commonmark:commonmark:0.21.0".let { 53 | modstitchImplementation(it) 54 | // msShadow.dependency(it, mapOf( 55 | // "org.commonmark" to "commonmark" 56 | // )) 57 | modstitchJiJ(it) 58 | } 59 | } 60 | 61 | sourceSets.main { 62 | java.srcDir("../src/main/java") 63 | resources.srcDir("../src/main/resources") 64 | 65 | modstitch.templatesSourceDirectorySet.srcDir("../src/main/templates") 66 | } 67 | 68 | val clientSourceSet = sourceSets.create("client") { 69 | java.srcDir("../src/client/java") 70 | resources.srcDir("../src/client/resources") 71 | } 72 | 73 | modstitch.createProxyConfigurations(clientSourceSet) 74 | 75 | msShadow { 76 | relocatePackage = "dev.isxander.test.libs" 77 | } 78 | 79 | java { 80 | withSourcesJar() 81 | } 82 | 83 | msPublishing { 84 | maven { 85 | repositories { 86 | mavenLocal() 87 | } 88 | } 89 | 90 | mpp { 91 | type = STABLE 92 | 93 | modrinth { 94 | accessToken = findProperty("pub.modrinth.token") as String? 95 | projectId = "12345678" 96 | } 97 | 98 | dryRun = true 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/test/kotlin/dev/isxander/modstitch/integration/ModManifestTests.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.integration 2 | 3 | import dev.isxander.modstitch.util.Platform 4 | import org.gradle.testkit.runner.TaskOutcome 5 | import org.junit.jupiter.api.Tag 6 | import kotlin.test.Test 7 | import kotlin.test.assertEquals 8 | import kotlin.test.assertTrue 9 | 10 | class ModManifestTests : BaseFunctionalTest() { 11 | @Test @Tag("loom") 12 | fun `mod manifest set correctly loom`() { 13 | setupMinimalLoom() 14 | testModManifest(Platform.Loom.modManifest) 15 | } 16 | 17 | @Test @Tag("mdg") 18 | fun `mod manifest set correctly mdg new`() { 19 | setupMinimalMdg() 20 | testModManifest(Platform.MDG.modManifest) 21 | } 22 | 23 | @Test @Tag("mdg") 24 | fun `mod manifest set correctly mdg old`() { 25 | setupMinimalMdg( 26 | minecraftVersion = "1.20.4", 27 | neoForgeVersion = "20.4.249", 28 | ) 29 | testModManifest(Platform.MDGLegacy.modManifest) 30 | } 31 | 32 | @Test @Tag("mdgl") 33 | fun `mod manifest set correctly mdgl`() { 34 | setupMinimalMdgl() 35 | testModManifest("META-INF/mods.toml") 36 | } 37 | 38 | @Test @Tag("mdgv") 39 | fun `mod manifest empty on vanilla mode`() { 40 | gradlePropertiesFile.appendText("modstitch.platform=moddevgradle\n") 41 | 42 | // language=kotlin 43 | buildFile.appendText(""" 44 | plugins { 45 | id("dev.isxander.modstitch.base") 46 | } 47 | 48 | modstitch { 49 | minecraftVersion = "1.21.8" 50 | moddevgradle { 51 | neoFormVersion = "20250717.133445" 52 | } 53 | } 54 | 55 | """.trimIndent()) 56 | 57 | testModManifest("null") 58 | } 59 | 60 | private fun testModManifest(expected: String) { 61 | // language=kotlin 62 | buildFile.appendText(""" 63 | tasks.register("printModManifest") { 64 | doLast { 65 | println("Mod Manifest: ${'$'}{modstitch.modLoaderManifest.orNull}") 66 | } 67 | } 68 | 69 | """.trimIndent()) 70 | 71 | val result = run { 72 | withArguments("printModManifest") 73 | } 74 | 75 | assertEquals( 76 | TaskOutcome.SUCCESS, 77 | result.task(":printModManifest")?.outcome, 78 | "Expected printModManifest task to succeed, but it failed with outcome: ${result.task(":printModManifest")?.outcome}" 79 | ) 80 | 81 | assertTrue( 82 | result.output.contains("Mod Manifest: $expected"), 83 | "The output should contain the mod manifest path $expected, but it does not." 84 | ) 85 | } 86 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/util/MinecraftVersion.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.util 2 | 3 | /** 4 | * Represents a semantic version without pre-release or build metadata components. 5 | * 6 | * @property major The major version component. 7 | * @property minor The minor version component. 8 | * @property patch The patch version component. 9 | */ 10 | internal data class ReleaseVersion(val major: Int, val minor: Int, val patch: Int) : Comparable { 11 | /** 12 | * Compares this version to another [ReleaseVersion]. 13 | * 14 | * @param other The version to compare against. 15 | * @return A negative integer if this version is lower than [other], zero if equal, or a positive integer if greater. 16 | */ 17 | override fun compareTo(other: ReleaseVersion): Int = 18 | compareValuesBy(this, other, ReleaseVersion::major, ReleaseVersion::minor, ReleaseVersion::patch) 19 | 20 | /** 21 | * Returns a string representation of this version in the `major.minor.patch` format. 22 | */ 23 | override fun toString(): String = "$major.$minor.$patch" 24 | 25 | companion object { 26 | /** 27 | * A pattern used to match release version strings. 28 | */ 29 | private val PATTERN = Regex("^\\s*(\\d+)\\.(\\d+)(?:\\.(\\d+))?") 30 | 31 | /** 32 | * Attempts to parse a dot-separated version string into a [ReleaseVersion]. 33 | * 34 | * Accepts two or three numeric components (e.g., "1.2" or "1.2.3"). 35 | * Missing patch component defaults to zero. 36 | * 37 | * @param value The input string to parse. 38 | * @return A [ReleaseVersion] instance, or `null` if parsing fails. 39 | */ 40 | fun parseOrNull(value: CharSequence): ReleaseVersion? = 41 | PATTERN.find(value)?.groupValues?.map { it.toIntOrNull() ?: 0 }?.let { ReleaseVersion(it[1], it[2], it[3]) } 42 | } 43 | } 44 | 45 | internal data class SnapshotVersion(val year: Int, val week: Int, val patch: Int) : Comparable { 46 | constructor(year: Int, week: Int, patch: Char) : this(year, week, charToPatch(patch)) 47 | 48 | override fun compareTo(other: SnapshotVersion): Int = 49 | compareValuesBy(this, other, SnapshotVersion::year, SnapshotVersion::week, SnapshotVersion::patch) 50 | 51 | override fun toString(): String = "${year}w$week${patchToChar(patch)}" 52 | 53 | companion object { 54 | private val PATTERN = Regex("^(\\d{2})w(\\d{2})([a-z])") 55 | 56 | private fun charToPatch(c: Char): Int = c - 'a' 57 | private fun patchToChar(patch: Int): Char = 'a' + patch 58 | 59 | fun parseOrNull(value: CharSequence): SnapshotVersion? = 60 | PATTERN.find(value)?.groupValues 61 | ?.let { SnapshotVersion(it[1].toIntOrNull() ?: 0, it[2].toIntOrNull() ?: 0, charToPatch(it[3][0])) } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/test/kotlin/dev/isxander/modstitch/integration/AutoJavaVersionTests.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.integration 2 | 3 | import org.gradle.testkit.runner.BuildResult 4 | import org.gradle.testkit.runner.TaskOutcome 5 | import org.junit.jupiter.api.Tag 6 | import org.junit.jupiter.api.assertNotNull 7 | import kotlin.test.Test 8 | import kotlin.test.assertEquals 9 | import kotlin.test.assertTrue 10 | 11 | class AutoJavaVersionTests : BaseFunctionalTest() { 12 | @Test @Tag("loom") 13 | fun `auto java version 12108`() { 14 | setupMinimalLoom(minecraftVersion = "1.21.8") 15 | testJavaVersion("21") 16 | } 17 | 18 | @Test @Tag("loom") 19 | fun `auto java version loom 12005`() { 20 | setupMinimalLoom(minecraftVersion = "1.20.5") 21 | testJavaVersion("21") 22 | } 23 | 24 | @Test @Tag("loom") 25 | fun `auto java version loom 12004`() { 26 | setupMinimalLoom(minecraftVersion = "1.20.4") 27 | testJavaVersion("17") 28 | } 29 | 30 | @Test @Tag("loom") 31 | fun `auto java version loom 11800`() { 32 | setupMinimalLoom(minecraftVersion = "1.18") 33 | testJavaVersion("17") 34 | } 35 | 36 | @Test @Tag("loom") 37 | fun `auto java version loom 11700`() { 38 | setupMinimalLoom(minecraftVersion = "1.17") 39 | testJavaVersion("16") 40 | } 41 | 42 | @Test @Tag("loom") 43 | fun `auto java version loom 11600`() { 44 | setupMinimalLoom(minecraftVersion = "1.16") 45 | testJavaVersion("8") 46 | } 47 | 48 | @Test @Tag("loom") 49 | fun `auto java version loom 25w31a`() { 50 | setupMinimalLoom(minecraftVersion = "25w31a") 51 | testJavaVersion("21") 52 | } 53 | 54 | private fun testJavaVersion(expected: String): BuildResult { 55 | // language=kotlin 56 | buildFile.appendText(""" 57 | tasks.register("printJavaVersion") { 58 | doLast { 59 | val javaVersion = modstitch.javaVersion.orNull.toString() 60 | println("Java version: ${'$'}javaVersion") 61 | } 62 | } 63 | """.trimIndent()) 64 | 65 | val result = run { 66 | withArguments("printJavaVersion") 67 | } 68 | 69 | assertEquals( 70 | TaskOutcome.SUCCESS, 71 | result.task(":printJavaVersion")?.outcome, 72 | "Expected printJavaVersion task to succeed, but it failed with outcome: ${result.task(":printJavaVersion")?.outcome}" 73 | ) 74 | 75 | val regex = Regex("Java version: (?.+)\n") 76 | val matchResult = regex.find(result.output) 77 | val actualVersion = matchResult?.groupValues?.get(1) 78 | assertNotNull(actualVersion, "Expected output to contain 'Java version: ' but it was not found.") 79 | 80 | assertEquals(expected, actualVersion, "Expected Java version to be '$expected', but got '$actualVersion'.") 81 | 82 | return result 83 | } 84 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/extensions/RunConfig.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base.extensions 2 | 3 | import dev.isxander.modstitch.util.Side 4 | import dev.isxander.modstitch.util.sourceSets 5 | import org.gradle.api.Named 6 | import org.gradle.api.Project 7 | import org.gradle.api.file.DirectoryProperty 8 | import org.gradle.api.model.ObjectFactory 9 | import org.gradle.api.provider.ListProperty 10 | import org.gradle.api.provider.MapProperty 11 | import org.gradle.api.provider.Property 12 | import org.gradle.api.tasks.SourceSet 13 | import org.gradle.kotlin.dsl.* 14 | import org.jetbrains.annotations.ApiStatus 15 | import javax.inject.Inject 16 | 17 | open class RunConfig @Inject constructor(private val namekt: String, project: Project, objects: ObjectFactory) : Named { 18 | 19 | val CLIENT = Side.Client 20 | val SERVER = Side.Server 21 | 22 | val gameDirectory: DirectoryProperty = objects.directoryProperty() 23 | .convention(project.layout.projectDirectory.dir("run")) 24 | 25 | val mainClass: Property = objects.property() 26 | 27 | val jvmArgs: ListProperty = objects.listProperty() 28 | 29 | val programArgs: ListProperty = objects.listProperty() 30 | 31 | val environmentVariables: MapProperty = objects.mapProperty() 32 | 33 | val ideRunName: Property = objects.property() 34 | .convention(run { 35 | val isSubProject = project.rootProject != project 36 | var ideName = name.replaceFirstChar { it.uppercaseChar() } 37 | if (isSubProject) { 38 | ideName = "${project.name} - $ideName" 39 | } 40 | ideName 41 | }) 42 | 43 | val ideRun: Property = objects.property() 44 | .convention(ideRunName.map { it.isNotEmpty() }.orElse(false)) 45 | 46 | val sourceSet: Property = objects.property() 47 | val side: Property = objects.property() 48 | 49 | @ApiStatus.Experimental 50 | val datagen: Property = objects.property().convention(false) 51 | 52 | init { 53 | when (name) { 54 | "client" -> client() 55 | "server" -> server() 56 | } 57 | } 58 | 59 | fun client(datagen: Boolean? = null) { 60 | side.set(CLIENT) 61 | datagen?.let { this.datagen = it } 62 | } 63 | 64 | fun server(datagen: Boolean? = null) { 65 | side.set(SERVER) 66 | datagen?.let { this.datagen = it } 67 | } 68 | 69 | fun inherit(other: RunConfig) { 70 | gameDirectory.set(other.gameDirectory) 71 | mainClass.set(other.mainClass) 72 | jvmArgs.set(other.jvmArgs) 73 | programArgs.set(other.programArgs) 74 | environmentVariables.set(other.environmentVariables) 75 | ideRunName.set(other.ideRunName) 76 | ideRun.set(other.ideRun) 77 | sourceSet.set(other.sourceSet) 78 | side.set(other.side) 79 | datagen.set(other.datagen) 80 | } 81 | 82 | override fun getName(): String = namekt 83 | 84 | } 85 | 86 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/ModstitchExtensionPlugin.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch 2 | 3 | import dev.isxander.modstitch.util.* 4 | import org.gradle.api.Plugin 5 | import org.gradle.api.Project 6 | 7 | typealias ExtensionPlatforms = Map> 8 | 9 | /** 10 | * A plugin that handles applying its platform-specific logic to a project 11 | */ 12 | open class ModstitchExtensionPlugin( 13 | private val name: String, 14 | private val platforms: ExtensionPlatforms 15 | ) : Plugin { 16 | override fun apply(target: Project) { 17 | if (target.name == "gradle-kotlin-dsl-accessors") return 18 | 19 | val platformStr = getPlatformStrFromProperty(target) 20 | 21 | if (platformStr == "parent") { 22 | applyToChildren(target) 23 | } else { 24 | apply(target, parsePlatformStr(platformStr, target)) 25 | } 26 | } 27 | 28 | /** 29 | * Allow applying the plugin to a parent project and having it apply to all children 30 | */ 31 | private fun applyToChildren(target: Project) { 32 | target.childProjects.forEach { (_, child) -> 33 | apply(child, parsePlatformStr(getPlatformStrFromProperty(child), child)) 34 | } 35 | } 36 | 37 | private fun apply(target: Project, selectedPlatform: Platform) { 38 | val platformPlugin = platforms[selectedPlatform] 39 | ?: error("This plugin does not support the platform `$selectedPlatform`. Supported platforms: ${platforms.keys.joinToString(", ") { it.friendlyName }}") 40 | val unselectedPlatforms = platforms.values - platformPlugin 41 | 42 | if (target.platformOrNull != null && target.platformOrNull != selectedPlatform) { 43 | error("Modstitch: ${target.name} has already been assigned platform `${target.platformOrNull}` but extension `$name` is trying to assign platform `$selectedPlatform`") 44 | } 45 | target.platform = selectedPlatform 46 | 47 | // apply the real plugin for the correct platform 48 | platformPlugin.apply(target) 49 | 50 | // create dud extensions for all other platforms 51 | // to generate type safety so even when the platform is not applied, the script can be compiled 52 | unselectedPlatforms.forEach { unselectedPlatform -> 53 | unselectedPlatform.platformExtensionInfo?.let { 54 | createDummyExtension(target, it) 55 | } 56 | } 57 | } 58 | 59 | private fun createDummyExtension(target: Project, extension: PlatformExtensionInfo) { 60 | // multiple platforms may use the same extension, so only create a dummy if it doesn't already exist 61 | // the real platform is always applied first 62 | val alreadyExists = target.extensions.extensionsSchema 63 | .find { it.name == extension.name } != null 64 | 65 | if (!alreadyExists) { 66 | target.extensions.create(extension.api, extension.name, extension.dummyImpl) 67 | } 68 | } 69 | 70 | private fun getPlatformStrFromProperty(target: Project): String { 71 | return target.findProperty("modstitch.platform")?.toString() 72 | ?: error("Project `${target.name}` is missing 'modstitch.platform' property. Cannot apply `$name`") 73 | } 74 | 75 | private fun parsePlatformStr(platform: String, project: Project): Platform { 76 | return Platform.fromFriendlyName(platform) 77 | ?: error("Unknown platform on project `${project.name}`: '$platform'. Options are: ${platforms.keys.joinToString(", ") { it.friendlyName }}") 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/test/kotlin/dev/isxander/modstitch/integration/BaseFunctionalTest.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.integration 2 | 3 | import org.gradle.testkit.runner.BuildResult 4 | import org.gradle.testkit.runner.GradleRunner 5 | import org.junit.jupiter.api.BeforeEach 6 | import org.junit.jupiter.api.io.TempDir 7 | import java.io.File 8 | 9 | abstract class BaseFunctionalTest { 10 | @TempDir 11 | lateinit var projectDir: File 12 | 13 | protected lateinit var buildFile: File 14 | protected lateinit var settingsFile: File 15 | protected lateinit var gradlePropertiesFile: File 16 | 17 | protected lateinit var templatesDir: File 18 | protected lateinit var fabricModJson: File 19 | protected lateinit var neoModsToml: File 20 | protected lateinit var modsToml: File 21 | 22 | @BeforeEach 23 | fun setupBase() { 24 | // Initialise file handles before each test 25 | buildFile = projectDir.resolve("build.gradle.kts") 26 | settingsFile = projectDir.resolve("settings.gradle.kts") 27 | gradlePropertiesFile = projectDir.resolve("gradle.properties") 28 | 29 | templatesDir = projectDir.resolve("src/main/templates") 30 | templatesDir.mkdirs() 31 | fabricModJson = templatesDir.resolve("fabric.mod.json") 32 | neoModsToml = templatesDir.resolve("META-INF/neoforge.mods.toml") 33 | neoModsToml.parentFile.mkdirs() 34 | modsToml = templatesDir.resolve("META-INF/mods.toml") 35 | 36 | // You can even create a default settings file automatically 37 | settingsFile.appendText(""" 38 | rootProject.name = "test-project" 39 | """.trimIndent()) 40 | } 41 | 42 | protected fun run(block: GradleRunner.() -> Unit = {}): BuildResult { 43 | return GradleRunner.create() 44 | .withProjectDir(projectDir) 45 | .withPluginClasspath() 46 | .apply(block) 47 | .build() 48 | } 49 | 50 | protected fun setupMinimalLoom( 51 | minecraftVersion: String = "1.21.8", 52 | fabricLoaderVersion: String = "0.16.14" 53 | ) { 54 | gradlePropertiesFile.appendText("modstitch.platform=loom\n") 55 | 56 | // language=kotlin 57 | buildFile.appendText(""" 58 | plugins { 59 | id("dev.isxander.modstitch.base") 60 | } 61 | 62 | modstitch { 63 | minecraftVersion = "$minecraftVersion" 64 | loom { 65 | fabricLoaderVersion = "$fabricLoaderVersion" 66 | } 67 | } 68 | 69 | """.trimIndent()) 70 | } 71 | 72 | protected fun setupMinimalMdg( 73 | minecraftVersion: String = "1.21.8", 74 | neoForgeVersion: String = "21.8.26" 75 | ) { 76 | gradlePropertiesFile.appendText("modstitch.platform=moddevgradle\n") 77 | 78 | // language=kotlin 79 | buildFile.appendText(""" 80 | plugins { 81 | id("dev.isxander.modstitch.base") 82 | } 83 | 84 | modstitch { 85 | minecraftVersion = "$minecraftVersion" 86 | moddevgradle { 87 | neoForgeVersion = "$neoForgeVersion" 88 | } 89 | } 90 | 91 | """.trimIndent()) 92 | } 93 | 94 | protected fun setupMinimalMdgl( 95 | minecraftVersion: String = "1.20.1", 96 | forgeVersion: String = "1.20.1-47.4.6" 97 | ) { 98 | gradlePropertiesFile.appendText("modstitch.platform=moddevgradle-legacy\n") 99 | 100 | // language=kotlin 101 | buildFile.appendText(""" 102 | plugins { 103 | id("dev.isxander.modstitch.base") 104 | } 105 | 106 | modstitch { 107 | minecraftVersion = "$minecraftVersion" 108 | moddevgradle { 109 | forgeVersion = "$forgeVersion" 110 | } 111 | } 112 | 113 | """.trimIndent()) 114 | } 115 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/extensions/Metadata.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base.extensions 2 | 3 | import org.gradle.api.model.ObjectFactory 4 | import org.gradle.api.provider.MapProperty 5 | import org.gradle.api.provider.Property 6 | import org.gradle.kotlin.dsl.mapProperty 7 | import org.gradle.kotlin.dsl.property 8 | import javax.inject.Inject 9 | 10 | interface MetadataBlock { 11 | /** 12 | * Your mod's ID. Mods should use a `lower_snake_case` mod-id to obey the conventions of both mod loaders. 13 | * Resources in the `templates` directory will be expanded with this value via `${mod_id}`. 14 | * Other gradle configuration will also use this value. 15 | */ 16 | val modId: Property 17 | 18 | /** 19 | * The friendly name of your mod. 20 | * Resources in the `templates` directory will be expanded with this value via `${mod_name}`. 21 | * Other gradle configuration may also use this value. 22 | * Publications via modstitch/publishing may use this value. 23 | */ 24 | val modName: Property 25 | 26 | /** 27 | * The version of your mod. 28 | * Resources in the `templates` directory will be expanded with this value via `${mod_version}`. 29 | * Other gradle configuration may also use this value. 30 | * Publications via modstitch/publishing may use this value. 31 | */ 32 | val modVersion: Property 33 | 34 | /** 35 | * A description of your mod. 36 | * Resources in the `templates` directory will be expanded with this value via `${mod_description}`. 37 | */ 38 | val modDescription: Property 39 | 40 | /** 41 | * The license name of your mod, for example "MIT" or "All Rights Reserved". 42 | * Resources in the `templates` directory will be expanded with this value via `${mod_license}`. 43 | */ 44 | val modLicense: Property 45 | 46 | /** 47 | * The group of your mod, for example "com.example". 48 | * Resources in the `templates` directory will be expanded with this value via `${mod_group}`. 49 | * Other gradle configuration may also use this value. 50 | */ 51 | val modGroup: Property 52 | 53 | /** 54 | * The author of your mod. 55 | * Resources in the `templates` directory will be expanded with this value via `${mod_author}`. 56 | */ 57 | val modAuthor: Property 58 | 59 | /** 60 | * The credits for your mod. 61 | * Resources in the `templates` directory will be expanded with this value via `${mod_credits}`. 62 | */ 63 | val modCredits: Property 64 | 65 | /** 66 | * A map of additional properties to replace in the templates directory. 67 | * Resources in the `templates` directory will be expanded with these values. 68 | */ 69 | val replacementProperties: MapProperty 70 | 71 | /** 72 | * Defines whether Modstitch should overwrite `project.group` and `project.version` 73 | * with [modGroup] and [modVersion] respectively, the former of which is typical for Gradle buildscripts. 74 | * 75 | * Defaults to `true`. 76 | */ 77 | val overwriteProjectVersionAndGroup: Property 78 | } 79 | open class MetadataBlockImpl @Inject constructor(objects: ObjectFactory) : MetadataBlock { 80 | /** Mods should use a `lower_snake_case` mod-id to obey the conventions of both mod loaders. */ 81 | override val modId = objects.property().convention("unnamed_mod") 82 | override val modName = objects.property().convention("Unnamed Mod") 83 | override val modVersion = objects.property().convention("1.0.0") 84 | override val modDescription = objects.property().convention("") 85 | override val modLicense = objects.property().convention("All Rights Reserved") 86 | override val modGroup = objects.property().convention("com.example") 87 | override val modAuthor = objects.property().convention("") 88 | override val modCredits = objects.property().convention("") 89 | override val replacementProperties = objects.mapProperty().convention(emptyMap()) 90 | override val overwriteProjectVersionAndGroup = objects.property().convention(true) 91 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/moddevgradle/CreateMinecraftMappingsTask.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base.moddevgradle 2 | 3 | import net.neoforged.nfrtgradle.NeoFormRuntimeTask 4 | import org.gradle.api.file.RegularFileProperty 5 | import org.gradle.api.provider.Property 6 | import org.gradle.api.tasks.* 7 | import org.gradle.work.DisableCachingByDefault 8 | 9 | /** 10 | * A lightweight variant of the `createMinecraftArtifacts` task that focuses solely on generating mappings. 11 | */ 12 | @DisableCachingByDefault(because = "Implements its own caching") 13 | abstract class CreateMinecraftMappingsTask : NeoFormRuntimeTask() { 14 | init { 15 | outputs.upToDateWhen { (it as CreateMinecraftMappingsTask).enableCache.get() } 16 | enableCache.convention(true) 17 | analyzeCacheMisses.convention(false) 18 | } 19 | 20 | /** 21 | * The path to the Java installation to use when running external tools. 22 | */ 23 | @get:Input 24 | @get:Optional 25 | abstract val toolsJavaExecutable: Property 26 | 27 | /** 28 | * The Gradle dependency notation for the NeoForge userdev artifact. 29 | * 30 | * Either this or [neoFormArtifact] must be specified. 31 | */ 32 | @get:Input 33 | @get:Optional 34 | abstract val neoForgeArtifact: Property 35 | 36 | /** 37 | * The Gradle dependency notation for the NeoForm data artifact. 38 | * 39 | * Either this or [neoForgeArtifact] must be specified. 40 | */ 41 | @get:Input 42 | @get:Optional 43 | abstract val neoFormArtifact: Property 44 | 45 | /** 46 | * Enables the use of the NeoForm Runtime cache. 47 | * 48 | * Defaults to `true`. 49 | */ 50 | @get:Internal 51 | abstract val enableCache: Property 52 | 53 | /** 54 | * When the cache is enabled, and this is set to `true`, additional information 55 | * will be printed to the console when a cache miss occurs. 56 | * 57 | * Defaults to `false`. 58 | */ 59 | @get:Internal 60 | abstract val analyzeCacheMisses: Property 61 | 62 | /** 63 | * The output file for the generated Named-to-Intermediary mappings. 64 | */ 65 | @get:OutputFile 66 | @get:Optional 67 | abstract val namedToIntermediaryMappings: RegularFileProperty 68 | 69 | /** 70 | * The output file for the generated Intermediary-to-Named mappings. 71 | */ 72 | @get:OutputFile 73 | @get:Optional 74 | abstract val intermediaryToNamedMappings: RegularFileProperty 75 | 76 | /** 77 | * Generates the requested mapping files for the provided artifacts and settings. 78 | */ 79 | @TaskAction 80 | fun createMappings() { 81 | if (!namedToIntermediaryMappings.isPresent && !intermediaryToNamedMappings.isPresent) { 82 | return 83 | } 84 | 85 | val args = mutableListOf() 86 | args.add("run") 87 | 88 | if (toolsJavaExecutable.isPresent) { 89 | args.add("--java-executable") 90 | args.add(toolsJavaExecutable.get()) 91 | } 92 | 93 | if (!enableCache.get()) { 94 | args.add("--disable-cache") 95 | } 96 | 97 | if (analyzeCacheMisses.get()) { 98 | args.add("--analyze-cache-misses") 99 | } 100 | 101 | if (neoForgeArtifact.isPresent) { 102 | args.add("--neoforge") 103 | args.add(neoForgeArtifact.get()) 104 | } 105 | if (neoFormArtifact.isPresent) { 106 | args.add("--neoform") 107 | args.add(neoFormArtifact.get()) 108 | } 109 | 110 | if (namedToIntermediaryMappings.isPresent) { 111 | args.add("--write-result") 112 | args.add("namedToIntermediaryMapping:${namedToIntermediaryMappings.get().asFile.absolutePath}") 113 | } 114 | if (intermediaryToNamedMappings.isPresent) { 115 | args.add("--write-result") 116 | args.add("intermediaryToNamedMapping:${intermediaryToNamedMappings.get().asFile.absolutePath}") 117 | } 118 | 119 | args.add("--dist") 120 | args.add("joined") 121 | 122 | run(args) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/test/kotlin/dev/isxander/modstitch/unit/AccessWidenerTest.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.unit 2 | 3 | import dev.isxander.modstitch.util.AccessModifier 4 | import dev.isxander.modstitch.util.AccessModifierType 5 | import dev.isxander.modstitch.util.AccessWidener 6 | import dev.isxander.modstitch.util.AccessWidenerEntry 7 | import dev.isxander.modstitch.util.AccessWidenerEntryType 8 | import dev.isxander.modstitch.util.AccessWidenerFormat 9 | import java.io.StringReader 10 | import kotlin.test.Test 11 | import kotlin.test.assertEquals 12 | 13 | class AccessWidenerTest { 14 | companion object { 15 | internal fun sampleAW(format: AccessWidenerFormat, namespace: String = "named"): AccessWidener { 16 | val at = format == AccessWidenerFormat.AT 17 | return AccessWidener( 18 | format = format, 19 | entries = listOf( 20 | AccessWidenerEntry( 21 | AccessWidenerEntryType.CLASS, 22 | AccessModifier(AccessModifierType.PUBLIC, isTransitive = false, isFinal = null), 23 | "class/Name", 24 | "class/Name", 25 | "class/Name" 26 | ), 27 | AccessWidenerEntry( 28 | AccessWidenerEntryType.METHOD, 29 | AccessModifier(AccessModifierType.PROTECTED, isTransitive = false, isFinal = false), 30 | "class/Name", 31 | "methodName", 32 | "()Lreturn/Type;" 33 | ), 34 | AccessWidenerEntry( 35 | AccessWidenerEntryType.FIELD, 36 | AccessModifier(AccessModifierType.PUBLIC, isTransitive = false, isFinal = null), 37 | "class/Name", 38 | "fieldName", 39 | if (at) "" else "Lfield/Type;" 40 | ) 41 | ), 42 | namespace = namespace 43 | ) 44 | } 45 | } 46 | 47 | @Test 48 | fun `AW parsing`() { 49 | // language=access widener 50 | val input = """ 51 | accessWidener v1 named 52 | accessible class class/Name 53 | # this is a valid AW comment 54 | extendable method class/Name methodName ()Lreturn/Type; #comment 55 | accessible field class/Name fieldName Lfield/Type; 56 | """.trimIndent() 57 | 58 | val parsed = AccessWidener.Companion.parse(StringReader(input)) 59 | 60 | assertEquals( 61 | sampleAW(AccessWidenerFormat.AW_V1), 62 | parsed, 63 | "AccessWidener parsed from AW format should match the expected sample" 64 | ) 65 | } 66 | 67 | @Test 68 | fun `AT parsing`() { 69 | // language=access transformers 70 | val input = """ 71 | # test comment 72 | public class.Name 73 | protected-f class.Name methodName()Lreturn/Type; #another comment 74 | public class.Name fieldName 75 | """.trimIndent() 76 | 77 | val parsed = AccessWidener.Companion.parse(StringReader(input)) 78 | 79 | assertEquals( 80 | sampleAW(AccessWidenerFormat.AT), 81 | parsed, 82 | "AccessWidener parsed from AT format should match the expected sample" 83 | ) 84 | } 85 | 86 | @Test 87 | fun `AT reproducibility`() { 88 | val expected = sampleAW(AccessWidenerFormat.AT) 89 | val stringified = expected.toString() 90 | val parsed = AccessWidener.Companion.parse(StringReader(stringified)) 91 | 92 | assertEquals(expected, parsed, "AccessWidener parsed from stringified version should match the original") 93 | } 94 | 95 | @Test 96 | fun `AW_V1 reproducibility`() { 97 | val expected = sampleAW(AccessWidenerFormat.AW_V1) 98 | val stringified = expected.toString() 99 | val parsed = AccessWidener.Companion.parse(StringReader(stringified)) 100 | 101 | assertEquals(expected, parsed, "AccessWidener parsed from stringified version should match the original") 102 | } 103 | 104 | @Test 105 | fun `AW_V2 reproducibility`() { 106 | val expected = sampleAW(AccessWidenerFormat.AW_V2) 107 | val stringified = expected.toString() 108 | val parsed = AccessWidener.Companion.parse(StringReader(stringified)) 109 | 110 | assertEquals(expected, parsed, "AccessWidener parsed from stringified version should match the original") 111 | } 112 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/shadow/moddevgradle/ShadowModdevgradleImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.shadow.moddevgradle 2 | 3 | import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar 4 | import dev.isxander.modstitch.base.extensions.modstitch 5 | import dev.isxander.modstitch.base.moddevgradle.MDGType 6 | import dev.isxander.modstitch.shadow.ShadowCommonImpl 7 | import dev.isxander.modstitch.shadow.devlib 8 | import net.neoforged.moddevgradle.legacyforge.dsl.ObfuscationExtension 9 | import net.neoforged.moddevgradle.legacyforge.tasks.RemapJar 10 | import org.gradle.api.NamedDomainObjectProvider 11 | import org.gradle.api.Project 12 | import org.gradle.api.artifacts.Configuration 13 | import org.gradle.api.artifacts.PublishArtifact 14 | import org.gradle.api.plugins.JavaPlugin 15 | import org.gradle.api.tasks.SourceSetContainer 16 | import org.gradle.api.tasks.TaskProvider 17 | import org.gradle.jvm.tasks.Jar 18 | import org.gradle.kotlin.dsl.* 19 | 20 | class ShadowModdevgradleImpl(private val type: MDGType) : ShadowCommonImpl() { 21 | override fun configureShadowTask( 22 | target: Project, 23 | shadowTask: TaskProvider, 24 | shadeConfiguration: NamedDomainObjectProvider 25 | ) { 26 | super.configureShadowTask(target, shadowTask, shadeConfiguration) 27 | 28 | /** 29 | * Creates a new Jar task that adds the jarJar output to the shadowJar output. 30 | * You cannot directly source jarJar into shadow as it would flatten all jars into the main one, 31 | * breaking jarJar. 32 | */ 33 | val jijJar = target.tasks.register("jijJar") { 34 | val shadowJar = target.tasks.named("shadowJar") 35 | from(target.zipTree(shadowJar.map { it.archiveFile })) 36 | dependsOn(shadowJar) 37 | 38 | val jarJar = target.tasks.named("jarJar") 39 | from(jarJar) 40 | dependsOn(jarJar) 41 | }.also { 42 | addToArtifacts(target, it) 43 | } 44 | target.tasks["assemble"].dependsOn(jijJar) 45 | 46 | shadowTask { 47 | archiveClassifier = "dev-fat" 48 | devlib() 49 | } 50 | 51 | target.modstitch._namedJarTaskName = "jijJar" 52 | when (type) { 53 | MDGType.Regular -> { 54 | target.modstitch._finalJarTaskName = "jijJar" 55 | } 56 | MDGType.Legacy -> { 57 | target.modstitch.onEnable { 58 | target.tasks.named("reobfJar") { 59 | // reobfJar takes jar, which is disabled 60 | enabled = false 61 | } 62 | } 63 | 64 | target.extensions.configure { 65 | val reobfJar = reobfuscate( 66 | jijJar, 67 | target.extensions.getByType()["main"] 68 | ).also { 69 | addToArtifacts(target, it) 70 | } 71 | 72 | reobfJar { 73 | archiveClassifier = "" 74 | } 75 | 76 | target.modstitch._finalJarTaskName = reobfJar.name 77 | } 78 | } 79 | } 80 | 81 | target.afterEvaluate { 82 | // Removes the original jar from the configurations so it doesn't get published. 83 | for (configurationName in arrayOf( 84 | JavaPlugin.API_ELEMENTS_CONFIGURATION_NAME, 85 | JavaPlugin.RUNTIME_ELEMENTS_CONFIGURATION_NAME 86 | )) { 87 | val configuration: Configuration = configurations.getByName(configurationName) 88 | val jarTask = tasks.getByName(JavaPlugin.JAR_TASK_NAME) as Jar 89 | configuration.artifacts.removeIf { artifact: PublishArtifact? -> 90 | (artifact!!.file.absolutePath == jarTask.archiveFile.get().asFile.absolutePath 91 | && artifact.buildDependencies.getDependencies(null).contains(jarTask)) 92 | .also { if (it) println("Removed ${artifact.name} from $configurationName") } 93 | } 94 | } 95 | } 96 | } 97 | 98 | private fun addToArtifacts(target: Project, task: TaskProvider) { 99 | target.artifacts.add(JavaPlugin.API_ELEMENTS_CONFIGURATION_NAME, task) 100 | target.artifacts.add(JavaPlugin.RUNTIME_ELEMENTS_CONFIGURATION_NAME, task) 101 | 102 | target.artifacts { 103 | add("archives", task) 104 | } 105 | } 106 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/extensions/Mixin.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base.extensions 2 | 3 | import dev.isxander.modstitch.util.Side 4 | import org.gradle.api.Action 5 | import org.gradle.api.DomainObjectSet 6 | import org.gradle.api.Named 7 | import org.gradle.api.NamedDomainObjectContainer 8 | import org.gradle.api.model.ObjectFactory 9 | import org.gradle.api.provider.Property 10 | import org.gradle.api.provider.Provider 11 | import org.gradle.api.tasks.SourceSet 12 | import org.gradle.kotlin.dsl.domainObjectContainer 13 | import org.gradle.kotlin.dsl.domainObjectSet 14 | import org.gradle.kotlin.dsl.newInstance 15 | import org.gradle.kotlin.dsl.property 16 | import org.jetbrains.annotations.ApiStatus 17 | import javax.inject.Inject 18 | 19 | interface MixinBlock { 20 | /** 21 | * A container for all your mixin configs. 22 | * Modstitch will automatically configure the underlying platform plugin to respect these 23 | * mixin configurations, including adding it to your mod manifest if [addMixinsToModManifest] is true. 24 | * 25 | * The name of the objects should be the prefix to your mixin config file, unless you configure the object otherwise. 26 | * `configs.register("mymod")` will be associated to `mymod.mixins.json`, for example. 27 | */ 28 | val configs: NamedDomainObjectContainer 29 | fun configs(action: Action>) = action.execute(configs) 30 | 31 | /** 32 | * Automatically appends your configured mixins into your mod manifest, like 33 | * `fabric.mod.json` or `neoforge.mods.toml`. 34 | * Defaults to false for backwards compatibility reasons. 35 | * 36 | * Ensure that your mod manifest is in the `templates` directory in order for this to work. 37 | */ 38 | val addMixinsToModManifest: Property 39 | 40 | /** 41 | * Registers additional source sets to be processed by Mixin AP. 42 | */ 43 | fun registerSourceSet(sourceSet: SourceSet, refmapName: Provider) 44 | 45 | /** 46 | * Registers additional source sets to be processed by Mixin AP. 47 | */ 48 | fun registerSourceSet(sourceSet: SourceSet, refmapName: String) 49 | 50 | @get:ApiStatus.Internal 51 | val mixinSourceSets: DomainObjectSet 52 | } 53 | open class MixinBlockImpl @Inject constructor(private val objects: ObjectFactory) : MixinBlock { 54 | override val configs = objects.domainObjectContainer(MixinConfigurationSettings::class) 55 | override val addMixinsToModManifest = objects.property().convention(false) 56 | override val mixinSourceSets = objects.domainObjectSet(MixinSourceSet::class) 57 | override fun registerSourceSet(sourceSet: SourceSet, refmapName: Provider) { 58 | objects.newInstance().apply { 59 | this.sourceSet = sourceSet 60 | this.refmapName.set(refmapName) 61 | }.also { mixinSourceSets.add(it) } 62 | } 63 | override fun registerSourceSet(sourceSet: SourceSet, refmapName: String) { 64 | objects.newInstance().apply { 65 | this.sourceSet = sourceSet 66 | this.refmapName.set(refmapName) 67 | }.also { mixinSourceSets.add(it) } 68 | } 69 | } 70 | 71 | open class MixinConfigurationSettings @Inject constructor(private val namekt: String, objects: ObjectFactory) : Named { 72 | // Removes the need to import in the buildscript 73 | val BOTH = Side.Both 74 | val CLIENT = Side.Client 75 | val SERVER = Side.Server 76 | 77 | /** 78 | * The name of the mixin configuration file. 79 | * E.g. `mymod.mixins.json`. 80 | * If unset, defaults to `${name}.mixins.json`. 81 | */ 82 | val config: Property = objects.property().convention("$namekt.mixins.json") 83 | 84 | /** 85 | * The side of the game this mixin configuration is for. 86 | * Defaults to [Side.Both]. 87 | * 88 | * This is not valid for moddevgradle platforms and will produce a warning. 89 | */ 90 | val side: Property = objects.property().convention(Side.Both) 91 | 92 | override fun getName(): String = namekt 93 | 94 | internal fun resolved(): FinalMixinConfigurationSettings { 95 | return FinalMixinConfigurationSettings(config.get(), side.getOrElse(Side.Both)) 96 | } 97 | } 98 | 99 | open class MixinSourceSet @Inject constructor(objects: ObjectFactory) { 100 | val sourceSetName = objects.property() 101 | val refmapName = objects.property() 102 | 103 | var sourceSet: SourceSet 104 | get() = throw UnsupportedOperationException() 105 | set(value) { sourceSetName.set(value.name) } 106 | } 107 | 108 | data class FinalMixinConfigurationSettings(val config: String, val side: Side) 109 | -------------------------------------------------------------------------------- /src/test/kotlin/dev/isxander/modstitch/integration/AccessWidenerIntegration.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.integration 2 | 3 | import com.google.gson.JsonObject 4 | import dev.isxander.modstitch.unit.AccessWidenerTest 5 | import dev.isxander.modstitch.util.AccessWidener 6 | import dev.isxander.modstitch.util.AccessWidenerFormat 7 | import org.gradle.testkit.runner.BuildResult 8 | import org.gradle.testkit.runner.TaskOutcome 9 | import org.junit.jupiter.api.Tag 10 | import java.io.File 11 | import java.util.jar.JarFile 12 | import kotlin.test.Test 13 | import kotlin.test.assertEquals 14 | import kotlin.test.assertNotNull 15 | 16 | class AccessWidenerIntegration : BaseFunctionalTest() { 17 | // all valid searched paths for access widener files 18 | val `modstitch dot accessWidener` by lazy { projectDir.resolve("modstitch.accessWidener") } 19 | val `dot accessWidener` by lazy { projectDir.resolve(".accessWidener") } 20 | val `accesstransformer dot cfg` by lazy { projectDir.resolve("accesstransformer.cfg") } 21 | 22 | @Test @Tag("mdg") 23 | fun `AT appear in JAR`() { 24 | setupMinimalMdg() 25 | 26 | // create AW file in project root for modstitch to find 27 | createAWFile(`modstitch dot accessWidener`, AccessWidenerFormat.AW_V1) 28 | 29 | // run gradle build to produce the JAR 30 | val result = run { 31 | withArguments("build", "--stacktrace") 32 | } 33 | 34 | // assert that the build was successful 35 | assertEquals( 36 | TaskOutcome.SUCCESS, 37 | result.task(":build")?.outcome, 38 | "Expected build task to succeed, but it failed with outcome: ${result.task(":build")?.outcome}" 39 | ) 40 | 41 | confirmAWInJar(AccessWidenerFormat.AT) 42 | } 43 | 44 | @Test @Tag("loom") 45 | fun `AW appear in JAR`() { 46 | setupMinimalLoomAW() 47 | 48 | // run gradle build to produce the JAR 49 | val result = run { 50 | withArguments("build", "--stacktrace") 51 | } 52 | 53 | // assert that the build was successful 54 | assertEquals( 55 | TaskOutcome.SUCCESS, 56 | result.task(":build")?.outcome, 57 | "Expected build task to succeed, but it failed with outcome: ${result.task(":build")?.outcome}" 58 | ) 59 | 60 | confirmAWInJar(AccessWidenerFormat.AW_V2) 61 | } 62 | 63 | @Test @Tag("loom") 64 | fun `AW appear in JAR with configuration cache`() { 65 | setupMinimalLoomAW() 66 | 67 | repeat(2) { 68 | val result = run { 69 | withArguments("clean", "build", "--stacktrace", "--configuration-cache") 70 | } 71 | assertEquals( 72 | TaskOutcome.SUCCESS, 73 | result.task(":build")?.outcome, 74 | "Expected build task to succeed, but it failed with outcome: ${result.task(":build")?.outcome}" 75 | ) 76 | } 77 | 78 | confirmAWInJar(AccessWidenerFormat.AW_V2) 79 | } 80 | 81 | private fun setupMinimalLoomAW() { 82 | setupMinimalLoom() 83 | 84 | // create AW file in project root for modstitch to find 85 | createAWFile(`modstitch dot accessWidener`, AccessWidenerFormat.AW_V1) 86 | 87 | // create FMJ so we can check that we're putting the AW in the FMJ 88 | val fmj = JsonObject().apply { 89 | // required for loom to not die 90 | addProperty("schemaVersion", 1) 91 | addProperty("id", "unnamed_mod") 92 | } 93 | fabricModJson.writeText(fmj.toString()) 94 | } 95 | 96 | private fun createAWFile(file: File, format: AccessWidenerFormat) { 97 | file.writeText(AccessWidenerTest.sampleAW(format).toString()) 98 | } 99 | 100 | private fun confirmAWInJar(format: AccessWidenerFormat) { 101 | val awLocation = when (format) { 102 | AccessWidenerFormat.AT -> "META-INF/accesstransformer.cfg" 103 | AccessWidenerFormat.AW_V1, AccessWidenerFormat.AW_V2 -> "unnamed_mod.accessWidener" 104 | } 105 | 106 | // Get the JAR file and check if it exists 107 | val jarFile = projectDir.resolve("build/libs/unnamed_mod-1.0.0.jar") 108 | assert(jarFile.exists()) { "Expected JAR file to exist at ${jarFile.absolutePath}. Libs are: ${projectDir.resolve("build/libs").listFiles()?.joinToString { it.name }}" } 109 | 110 | // Check if the AT file is present in the JAR 111 | JarFile(jarFile).use { jar -> 112 | val awFile = jar.getJarEntry(awLocation) 113 | assertNotNull(awFile) { "Expected AW/AT file to be present within JAR but is not." } 114 | 115 | jar.getInputStream(awFile).use { ins -> 116 | val parsedAW = AccessWidener.parse(ins.bufferedReader()) 117 | 118 | assertEquals( 119 | AccessWidenerTest.sampleAW(format, namespace = when (format) { 120 | AccessWidenerFormat.AT -> "named" 121 | AccessWidenerFormat.AW_V1, AccessWidenerFormat.AW_V2 -> "intermediary" 122 | }), 123 | parsedAW, 124 | "Parsed AT does not match expected sample AT" 125 | ) 126 | } 127 | } 128 | } 129 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/moddevgradle/BaseModDevGradleExtension.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base.moddevgradle 2 | 3 | import dev.isxander.modstitch.base.extensions.modstitch 4 | import dev.isxander.modstitch.util.NotExistsDelegate 5 | import net.neoforged.moddevgradle.dsl.ModDevExtension 6 | import org.gradle.api.Action 7 | import net.neoforged.moddevgradle.dsl.NeoForgeExtension 8 | import net.neoforged.moddevgradle.dsl.RunModel 9 | import net.neoforged.moddevgradle.legacyforge.dsl.MixinExtension 10 | import net.neoforged.moddevgradle.legacyforge.dsl.ObfuscationExtension 11 | import org.gradle.api.Project 12 | import org.gradle.api.model.ObjectFactory 13 | import org.gradle.api.provider.Property 14 | import javax.inject.Inject 15 | import org.gradle.kotlin.dsl.* 16 | 17 | interface BaseModDevGradleExtension { 18 | /** 19 | * The version of NeoForge to use. 20 | * 21 | * This property is applicable on both `moddevgradle` and `moddevgradle-legacy` platforms. 22 | */ 23 | val neoForgeVersion: Property 24 | 25 | /** 26 | * The version of Forge to use. 27 | * 28 | * This property is applicable only on `moddevgradle-legacy` platform. 29 | */ 30 | val forgeVersion: Property 31 | 32 | /** 33 | * The version of NeoForm to use. 34 | * 35 | * This property is applicable only on `moddevgradle` platform. 36 | */ 37 | val neoFormVersion: Property 38 | 39 | /** 40 | * The version of MCP to use. 41 | * 42 | * This property is applicable only on `moddevgradle-legacy` platofmr. 43 | */ 44 | val mcpVersion: Property 45 | 46 | /** 47 | * The underlying platform-specific extension: `neoForge` 48 | * 49 | * Attempting to access this property on `moddevgradle-legacy` will throw an error as it is 50 | * only valid on `moddevgradle` platform. 51 | * Use [configureNeoForge] for conditional execution. 52 | */ 53 | val neoForgeExtension: ModDevExtension 54 | 55 | /** 56 | * Configures the underlying platform-specific extension `neoForge`. 57 | * 58 | * This action will only be executed on `moddevgradle` platform. 59 | */ 60 | fun configureNeoForge(action: Action) 61 | 62 | /** 63 | * The underlying platform-specific extension: `obfuscation` 64 | * 65 | * Attempting to access this property on `moddevgradle` will throw an error as it is 66 | * only valid on `moddevgradle-legacy` platform. 67 | * Use [configureObfuscation] for conditional execution. 68 | */ 69 | val obfuscationExtension: ObfuscationExtension 70 | 71 | /** 72 | * Configures the Obfuscation extension. 73 | * 74 | * This action will only be executed on `moddevgradle-legacy` platform. 75 | */ 76 | fun configureObfuscation(action: Action) 77 | 78 | /** 79 | * The underlying platform-specific extension: `mixin` 80 | * 81 | * Attempting to access this property on `moddevgradle` will throw an error as it is 82 | * only valid on `moddevgradle-legacy` platform. 83 | Use [configureMixin] for conditional execution. 84 | */ 85 | val mixinExtension: MixinExtension 86 | 87 | /** 88 | * Configures the Mixin extension. 89 | * 90 | * This action will only be executed on `moddevgradle-legacy` platform. 91 | */ 92 | fun configureMixin(action: Action) 93 | 94 | /** 95 | * Creates two run configurations: one for the client and one for the server. 96 | * 97 | * [namingConvention] is a function that takes two strings: 98 | * the platform name ("Forge", "NeoForge", or "Minecraft") and the side ("Client" or "Server"), 99 | * and returns the IDE name of the run configuration (e.g., "NeoForge Client", "Forge Server"). 100 | */ 101 | fun defaultRuns(client: Boolean = true, server: Boolean = true, namingConvention: (String, String) -> String = { name, side -> "$name $side" }) 102 | } 103 | 104 | open class BaseModDevGradleExtensionImpl @Inject constructor( 105 | objects: ObjectFactory, 106 | @Transient private val project: Project, 107 | val type: MDGType, 108 | ) : BaseModDevGradleExtension { 109 | override val neoForgeVersion: Property = objects.property() 110 | override val forgeVersion: Property = objects.property() 111 | override val neoFormVersion: Property = objects.property() 112 | override val mcpVersion: Property = objects.property() 113 | 114 | override val neoForgeExtension: ModDevExtension 115 | get() = project.extensions.getByType() 116 | override fun configureNeoForge(action: Action) = 117 | action(neoForgeExtension) 118 | 119 | override val mixinExtension: MixinExtension 120 | get() = if (type == MDGType.Legacy) project.extensions.getByType() 121 | else error("Mixin extension is not available on moddevgradle platform") 122 | override fun configureMixin(action: Action) = 123 | if (type == MDGType.Legacy) action(mixinExtension) else {} 124 | 125 | override val obfuscationExtension: ObfuscationExtension 126 | get() = if (type == MDGType.Legacy) project.extensions.getByType() 127 | else error("Obfuscation extension is not available on moddevgradle platform") 128 | override fun configureObfuscation(action: Action) = 129 | if (type == MDGType.Legacy) action(obfuscationExtension) else {} 130 | 131 | override fun defaultRuns(client: Boolean, server: Boolean, namingConvention: (String, String) -> String) { 132 | val project = project 133 | val name = when { 134 | neoForgeVersion.isPresent -> "NeoForge" 135 | forgeVersion.isPresent -> "Forge" 136 | else -> "Minecraft" 137 | } 138 | configureNeoForge { 139 | runs { 140 | fun registerOrConfigure(name: String, action: Action) = action(maybeCreate(name)) 141 | 142 | if (client) { 143 | registerOrConfigure("client") { 144 | client() 145 | ideName = "${namingConvention(name, "Client")} (${project.path})" 146 | } 147 | } 148 | if (server) { 149 | registerOrConfigure("server") { 150 | server() 151 | ideName = "${namingConvention(name, "Server")} (${project.path})" 152 | } 153 | } 154 | } 155 | } 156 | } 157 | } 158 | 159 | open class BaseModDevGradleExtensionDummy : BaseModDevGradleExtension { 160 | override val neoForgeVersion: Property by NotExistsDelegate() 161 | override val forgeVersion: Property by NotExistsDelegate() 162 | override val neoFormVersion: Property by NotExistsDelegate() 163 | override val mcpVersion: Property by NotExistsDelegate() 164 | override val neoForgeExtension: NeoForgeExtension by NotExistsDelegate() 165 | override val mixinExtension: MixinExtension by NotExistsDelegate() 166 | override val obfuscationExtension: ObfuscationExtension by NotExistsDelegate() 167 | 168 | override fun configureNeoForge(action: Action) {} 169 | override fun configureMixin(action: Action) {} 170 | override fun configureObfuscation(action: Action) {} 171 | override fun defaultRuns(client: Boolean, server: Boolean, namingConvention: (String, String) -> String) {} 172 | } 173 | 174 | val Project.msModdevgradle: BaseModDevGradleExtension 175 | get() = extensions.getByType() 176 | fun Project.msModdevgradle(block: BaseModDevGradleExtension.() -> Unit) { 177 | if (project.modstitch.isModDevGradle) { 178 | msModdevgradle.block() 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH="\\\"\\\"" 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/BaseCommonImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base 2 | 3 | import dev.isxander.modstitch.* 4 | import dev.isxander.modstitch.base.extensions.* 5 | import dev.isxander.modstitch.util.Platform 6 | import dev.isxander.modstitch.util.afterSuccessfulEvaluate 7 | import dev.isxander.modstitch.util.mainSourceSet 8 | import dev.isxander.modstitch.util.platform 9 | import dev.isxander.modstitch.util.printVersion 10 | import org.gradle.api.Action 11 | import org.gradle.api.JavaVersion 12 | import org.gradle.api.Project 13 | import org.gradle.api.artifacts.Configuration 14 | import org.gradle.api.artifacts.dsl.RepositoryHandler 15 | import org.gradle.api.plugins.BasePluginExtension 16 | import org.gradle.api.plugins.JavaPluginExtension 17 | import org.gradle.api.provider.Provider 18 | import org.gradle.api.tasks.SourceSet 19 | import org.gradle.api.tasks.SourceSetContainer 20 | import org.gradle.api.tasks.TaskProvider 21 | import org.gradle.api.tasks.compile.JavaCompile 22 | import org.gradle.api.tasks.testing.junitplatform.JUnitPlatformOptions 23 | import org.gradle.kotlin.dsl.* 24 | import org.gradle.language.jvm.tasks.ProcessResources 25 | import org.gradle.plugins.ide.idea.model.IdeaModel 26 | 27 | abstract class BaseCommonImpl( 28 | val platform: Platform, 29 | private val appendModMetadataTask: Class, 30 | ) : PlatformPlugin() { 31 | override fun apply(target: Project) { 32 | printVersion("Common", target) 33 | 34 | // Set properties that don't support lazy evaluation. 35 | target.afterSuccessfulEvaluate(this::finalize) 36 | 37 | // Set the property for use elsewhere 38 | target.platform = platform 39 | 40 | // Apply the necessary plugins 41 | applyPlugins(target) 42 | 43 | // Create our plugin extension 44 | val msExt = target.extensions.create( 45 | ModstitchExtension::class.java, 46 | "modstitch", 47 | ModstitchExtensionImpl::class.java, 48 | this 49 | ) 50 | 51 | // Ensure the archivesBaseName is our mod-id 52 | target.pluginManager.withPlugin("base") { 53 | target.extensions.configure { 54 | archivesName.set(msExt.metadata.modId) 55 | } 56 | } 57 | 58 | // IDEA no longer automatically downloads sources and javadocs. 59 | target.pluginManager.withPlugin("idea") { 60 | target.configure { 61 | module { 62 | isDownloadJavadoc = true 63 | isDownloadSources = true 64 | } 65 | } 66 | } 67 | 68 | // Add the necessary repositories. 69 | applyDefaultRepositories(target.repositories) 70 | 71 | // Apply a default java plugin configuration 72 | applyJavaSettings(target) 73 | 74 | // Setup processResources to replace metadata strings 75 | applyMetadataStringReplacements(target) 76 | 77 | // Create modstitch remap configurations 78 | createProxyConfigurations(target, target.extensions.getByType().getByName(SourceSet.MAIN_SOURCE_SET_NAME)) 79 | 80 | // Jar-in-jar support 81 | target.configurations.create("modstitchJiJ") { 82 | isTransitive = false 83 | 84 | configureJiJConfiguration(target, this) 85 | } 86 | 87 | // Append custom mod metadata registered via Modstitch 88 | target.tasks.register("appendModMetadata", appendModMetadataTask) { 89 | group = "modstitch/internal" 90 | dependsOn("processResources") 91 | 92 | source(msExt.modLoaderManifest.map { listOf(project.mainSourceSet!!.output.resourcesDir!!.resolve(it)) }.orElse(listOf())) 93 | mixins.value(target.provider { msExt.mixin.configs.map { it.resolved() } }.zip(msExt.mixin.addMixinsToModManifest) { configs, addToManifest -> if (!addToManifest) emptyList() else configs }) 94 | accessWideners.value(msExt.accessWidenerName.zip(msExt.accessWidener) { n, _ -> listOf(n) }.orElse(listOf())) 95 | }.also { target.tasks["processResources"].finalizedBy(it) } 96 | } 97 | 98 | /** 99 | * Finalizes pending configuration actions after the project has been successfully evaluated. 100 | * 101 | * @param target The target project. 102 | */ 103 | protected open fun finalize(target: Project) { 104 | if (target.modstitch.metadata.overwriteProjectVersionAndGroup.get()) { 105 | target.group = target.modstitch.metadata.modGroup.get() 106 | target.version = target.modstitch.metadata.modVersion.get() 107 | } 108 | 109 | applyAccessWidener(target) 110 | } 111 | 112 | /** 113 | * Applies the access widener configuration to the specified [target] project. 114 | * 115 | * @param target The target project. 116 | */ 117 | protected abstract fun applyAccessWidener(target: Project) 118 | 119 | /** 120 | * Add all repositories necessary for the platform in here. 121 | * For example, Fabric will add fabric-maven to this. 122 | */ 123 | protected open fun applyDefaultRepositories(repositories: RepositoryHandler) { 124 | repositories.mavenCentral() 125 | repositories.maven("https://maven.parchmentmc.org") { name = "ParchmentMC" } 126 | repositories.exclusiveContent { 127 | forRepository { 128 | repositories.maven("https://api.modrinth.com/maven") { name = "Modrinth" } 129 | } 130 | filter { 131 | includeGroup("maven.modrinth") 132 | } 133 | } 134 | repositories.exclusiveContent { 135 | forRepository { 136 | repositories.maven("https://cursemaven.com") { name = "Cursemaven" } 137 | } 138 | filter { 139 | includeGroup("curse.maven") 140 | } 141 | } 142 | } 143 | 144 | protected open fun applyJavaSettings(target: Project) { 145 | target.tasks.withType { 146 | options.encoding = "UTF-8" 147 | } 148 | 149 | target.extensions.configure { 150 | target.afterSuccessfulEvaluate { 151 | val requestedJavaVersion = target.modstitch.javaVersion.orNull 152 | 153 | if (requestedJavaVersion != null) { 154 | JavaVersion.toVersion(requestedJavaVersion).let { 155 | targetCompatibility = it 156 | sourceCompatibility = it 157 | } 158 | } else { 159 | target.logger.warn("No Java version specified in modstitch configuration. Not applying any Java version settings.") 160 | } 161 | } 162 | } 163 | } 164 | 165 | abstract fun applyUnitTesting(target: Project, testFrameworkConfigure: Action) 166 | 167 | /** 168 | * Ensures templates in files like fabric.mod.json are replaced. 169 | * e.g. ${mod_id} -> my_mod 170 | */ 171 | protected open fun applyMetadataStringReplacements(target: Project): TaskProvider { 172 | val mainSourceSet = target.extensions.getByType()["main"] 173 | 174 | // Create a new `templates` directory set in the main sourceSet 175 | val templates = target.objects.sourceDirectorySet("templates", "Mod metadata resource templates") 176 | templates.srcDir("src/main/templates") 177 | mainSourceSet.extensions.add("templates", templates) 178 | 179 | val modstitch = target.modstitch 180 | 181 | // An alternative to the traditional `processResources` setup that is compatible with 182 | // IDE-managed runs (e.g. IntelliJ non-delegated build) 183 | val generateModMetadata by target.tasks.registering(ProcessResources::class) { 184 | group = "modstitch/internal" 185 | 186 | val manifest = modstitch.metadata 187 | 188 | val baseProperties = mapOf>( 189 | "minecraft_version" to modstitch.minecraftVersion, 190 | "mod_version" to manifest.modVersion, 191 | "mod_name" to manifest.modName, 192 | "mod_id" to manifest.modId, 193 | "mod_license" to manifest.modLicense, 194 | "mod_description" to manifest.modDescription, 195 | "mod_group" to manifest.modGroup, 196 | "mod_author" to manifest.modAuthor, 197 | "mod_credits" to manifest.modCredits, 198 | ) 199 | 200 | // Combine the lazy-valued base properties with the replacement properties, lazily 201 | inputs.property( 202 | "allProperties", 203 | manifest.replacementProperties.map { replacementProperties -> 204 | baseProperties.mapValues { (_, value) -> value.get() } + replacementProperties 205 | } 206 | ) 207 | // Expand lazily resolved properties only during execution 208 | doFirst { 209 | expand(inputs.properties["allProperties"] as Map) 210 | } 211 | 212 | from(templates) 213 | into("build/generated/sources/modMetadata") 214 | 215 | // Set the manifest as an input so configuration cache is happy and can detect changes 216 | inputs.property("modLoaderManifest", modstitch.modLoaderManifest) 217 | // Exclude all mod loader manifests except the one currently being processed 218 | exclude { fileTreeElement -> 219 | val currentManifest = inputs.properties["modLoaderManifest"] as? String? 220 | // Now build the set of manifests to exclude dynamically 221 | val manifestsToExclude = Platform.allModManifests - currentManifest 222 | // Return true if the file should be excluded, false otherwise 223 | fileTreeElement.relativePath.pathString in manifestsToExclude 224 | } 225 | } 226 | // Include the output of "generateModMetadata" as an input directory for the build 227 | // This allows the funny dest dir (`generated/sources/modMetadata`) to be included in the root of the build 228 | mainSourceSet.resources.srcDir(generateModMetadata) 229 | 230 | return generateModMetadata 231 | } 232 | 233 | open fun createProxyConfigurations(target: Project, sourceSet: SourceSet) { 234 | fun mainOnly(configurationName: String): String? = 235 | configurationName.takeIf { sourceSet.name == SourceSet.MAIN_SOURCE_SET_NAME } 236 | 237 | listOfNotNull( 238 | mainOnly(sourceSet.apiConfigurationName), 239 | sourceSet.implementationConfigurationName, 240 | sourceSet.compileOnlyConfigurationName, 241 | sourceSet.runtimeOnlyConfigurationName, 242 | mainOnly(sourceSet.compileOnlyApiConfigurationName), 243 | ).forEach { 244 | createProxyConfigurations(target, FutureNamedDomainObjectProvider.from(target.configurations, it)) 245 | } 246 | } 247 | abstract fun createProxyConfigurations(target: Project, configuration: FutureNamedDomainObjectProvider, defer: Boolean = false) 248 | 249 | abstract fun configureJiJConfiguration(target: Project, configuration: Configuration) 250 | 251 | open fun applyPlugins(target: Project) { 252 | target.pluginManager.apply("java-library") 253 | target.pluginManager.apply("idea") 254 | } 255 | 256 | open fun onEnable(target: Project, action: Action) { 257 | action.execute(target) 258 | } 259 | 260 | protected val Project.sourceSets: SourceSetContainer 261 | get() = extensions.getByType() 262 | } 263 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/loom/BaseLoomImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base.loom 2 | 3 | import dev.isxander.modstitch.base.BaseCommonImpl 4 | import dev.isxander.modstitch.base.FutureNamedDomainObjectProvider 5 | import dev.isxander.modstitch.base.extensions.modstitch 6 | import dev.isxander.modstitch.util.* 7 | import net.fabricmc.loom.api.LoomGradleExtensionAPI 8 | import net.fabricmc.loom.util.Constants 9 | import net.neoforged.moddevgradle.dsl.ModDevExtension 10 | import org.gradle.api.Action 11 | import org.gradle.api.Project 12 | import org.gradle.api.artifacts.Configuration 13 | import org.gradle.api.artifacts.dsl.RepositoryHandler 14 | import org.gradle.api.provider.Property 15 | import org.gradle.api.provider.Provider 16 | import org.gradle.api.tasks.SourceSet 17 | import org.gradle.api.tasks.TaskProvider 18 | import org.gradle.api.tasks.testing.Test 19 | import org.gradle.api.tasks.testing.junitplatform.JUnitPlatformOptions 20 | import org.gradle.kotlin.dsl.* 21 | import org.gradle.kotlin.dsl.assign 22 | import org.gradle.kotlin.dsl.getByType 23 | import org.gradle.language.jvm.tasks.ProcessResources 24 | 25 | class BaseLoomImpl : BaseCommonImpl( 26 | Platform.Loom, 27 | AppendFabricMetadataTask::class.java, 28 | ) { 29 | override val platformExtensionInfo = PlatformExtensionInfo( 30 | "msLoom", 31 | BaseLoomExtension::class, 32 | BaseLoomExtensionImpl::class, 33 | BaseLoomExtensionDummy::class 34 | ) 35 | 36 | override fun apply(target: Project) { 37 | super.apply(target) 38 | 39 | val fabricExt = createRealPlatformExtension(target)!! 40 | 41 | target.dependencies { 42 | "minecraft"(target.modstitch.minecraftVersion.map { "com.mojang:minecraft:$it" }) 43 | 44 | val parchment = target.modstitch.parchment 45 | val loom = fabricExt.loomExtension 46 | "mappings"(parchment.enabled.zip(parchment.parchmentArtifact.orElse("")) { enabled, parchmentArtifact -> 47 | loom.layered { 48 | officialMojangMappings() 49 | if (enabled && parchmentArtifact.isNotEmpty()) { 50 | parchment(parchmentArtifact) 51 | } 52 | } 53 | }) 54 | 55 | "modImplementation"(fabricExt.fabricLoaderVersion.map { "net.fabricmc:fabric-loader:$it" }) 56 | } 57 | 58 | target.modstitch.modLoaderManifest.convention(Platform.Loom.modManifest) 59 | 60 | target.modstitch._finalJarTaskName = "remapJar" 61 | target.modstitch._namedJarTaskName = "jar" 62 | 63 | target.loom.mixin { 64 | target.modstitch.mixin.mixinSourceSets.whenObjectAdded { 65 | val sourceSetName = this@whenObjectAdded.sourceSetName 66 | val refmapName = this@whenObjectAdded.refmapName 67 | 68 | if (sourceSetName.get() == SourceSet.MAIN_SOURCE_SET_NAME) { 69 | defaultRefmapName = refmapName 70 | } else { 71 | add(sourceSetName.get(), refmapName.get()) 72 | } 73 | } 74 | } 75 | target.modstitch.mixin.registerSourceSet( 76 | target.sourceSets["main"], 77 | target.modstitch.metadata.modId.map { "$it.refmap.json" }, 78 | ) 79 | 80 | applyRuns(target) 81 | } 82 | 83 | override fun applyAccessWidener(target: Project) { 84 | // (Un)fortunately, Loom doesn't fully utilize Gradle's task system and performs much of its logic 85 | // during the configuration phase - including applying an access widener to the Minecraft sources. 86 | // Thus, we need to generate it eagerly, right here and right now. 87 | val modstitch = target.modstitch 88 | val loom = target.loom 89 | 90 | // Loom doesn't offer a way to configure whether access widener validation should be enabled. 91 | // Fortunately, it uses a separate task for this purpose, so we can simply disable it when needed. 92 | if (!modstitch.validateAccessWidener.get()) { 93 | target.tasks["validateAccessWidener"].enabled = false 94 | } 95 | 96 | // If no access widener is specified, there's nothing else for us to do. 97 | val accessWidenerFile = modstitch.accessWidener.orNull?.asFile ?: return 98 | 99 | // Read the access widener from the specified path, convert it to the `accessWidener v2` format, 100 | // save it to a static location, and point Loom to it. If the specified file does not exist, 101 | // allow it to throw - we don't want to silently ignore a potential misconfiguration. 102 | // 103 | // Also note: we intentionally avoid using the user-provided name here to prevent leaving behind 104 | // stale cached files when the user changes the name of their access widener. 105 | val generateAccessWidenerTask = target.tasks.register("generateAccessWidener") { 106 | group = "modstitch/internal" 107 | description = "Generates the access widener file for Loom" 108 | 109 | inputs.file(accessWidenerFile) 110 | val tmpAccessWidenerFile = target.layout.buildDirectory.file("modstitch/modstitch.accessWidener").get().asFile 111 | outputs.file(tmpAccessWidenerFile) 112 | 113 | doLast { 114 | // Read the access widener from the specified path, convert it to the `accessWidener v2` format, 115 | // save it to a static location. If the specified file does not exist, 116 | // allow it to throw - we don't want to silently ignore a potential misconfiguration. 117 | val accessWidener = accessWidenerFile.reader().use { AccessWidener.parse(it) }.convert(AccessWidenerFormat.AW_V2) 118 | tmpAccessWidenerFile.parentFile.mkdirs() 119 | tmpAccessWidenerFile.writer().use { accessWidener.write(it) } 120 | } 121 | } 122 | 123 | // For Loom's configuration phase, we still need to provide the file path immediately 124 | // Create the file during configuration for Loom, but ensure it gets regenerated properly at execution time 125 | val tmpAccessWidenerFile = target.layout.buildDirectory.file("modstitch/modstitch.accessWidener").get().asFile 126 | 127 | // Generate the file immediately for Loom's configuration phase 128 | val accessWidener = accessWidenerFile.reader().use { AccessWidener.parse(it) }.convert(AccessWidenerFormat.AW_V2) 129 | tmpAccessWidenerFile.parentFile.mkdirs() 130 | tmpAccessWidenerFile.writer().use { accessWidener.write(it) } 131 | loom.accessWidenerPath = tmpAccessWidenerFile 132 | 133 | // Finally, include the generated access widener in the final JAR. 134 | val defaultAccessWidenerName = modstitch.metadata.modId.map { "$it.accessWidener" } 135 | val accessWidenerName = modstitch.accessWidenerName.convention(defaultAccessWidenerName).get() 136 | val accessWidenerPath = accessWidenerName.split('\\', '/') 137 | target.tasks.named("processResources") { 138 | dependsOn(generateAccessWidenerTask) 139 | from(tmpAccessWidenerFile) { 140 | rename { accessWidenerPath.last() } 141 | into(accessWidenerPath.dropLast(1).joinToString("/")) 142 | } 143 | } 144 | } 145 | 146 | private fun applyRuns(target: Project) { 147 | target.modstitch.runs.whenObjectAdded modstitch@{ 148 | val modstitch = this@modstitch 149 | 150 | // loom run configs does not support gradle lazy evaluation 151 | target.afterSuccessfulEvaluate { 152 | target.extensions.getByType().runs.register(modstitch.name) loom@{ 153 | val loom = this@loom 154 | 155 | modstitch.gameDirectory.orNull?.let { loom.runDir = it.asFile.absolutePath } 156 | modstitch.mainClass.orNull?.let { loom.mainClass = it } 157 | modstitch.jvmArgs.orNull?.let { loom.vmArgs.addAll(it) } 158 | modstitch.programArgs.orNull?.let { loom.programArgs.addAll(it) } 159 | modstitch.environmentVariables.orNull?.let { loom.environmentVariables.putAll(it) } 160 | modstitch.ideRunName.orNull?.let { loom.configName = it } 161 | modstitch.ideRun.orNull?.let { loom.isIdeConfigGenerated = it } 162 | modstitch.sourceSet.orNull?.let { loom.source(it) } 163 | modstitch.side.orNull?.let { 164 | when (it) { 165 | Side.Client -> loom.client() 166 | Side.Server -> loom.server() 167 | else -> error("Unknown side: $side") 168 | } 169 | } 170 | modstitch.datagen.orNull?.let { if (it) throw UnsupportedOperationException("Loom platform does not currently support creating datagen run configs") } 171 | } 172 | 173 | target.tasks.named("run${modstitch.name.replaceFirstChar { it.uppercaseChar() }}") { 174 | group = "modstitch/runs" 175 | } 176 | } 177 | } 178 | } 179 | 180 | override fun applyPlugins(target: Project) { 181 | super.applyPlugins(target) 182 | target.plugins.apply("fabric-loom") 183 | } 184 | 185 | override fun applyDefaultRepositories(repositories: RepositoryHandler) { 186 | super.applyDefaultRepositories(repositories) 187 | repositories.maven("https://maven.fabricmc.net") { name = "FabricMC" } 188 | } 189 | 190 | override fun applyMetadataStringReplacements(target: Project): TaskProvider { 191 | val generateModMetadata = super.applyMetadataStringReplacements(target) 192 | 193 | target.tasks.named("ideaSyncTask") { 194 | dependsOn(generateModMetadata) 195 | } 196 | 197 | return generateModMetadata 198 | } 199 | 200 | override fun applyUnitTesting(target: Project, testFrameworkConfigure: Action) { 201 | val fabricExt = target.extensions.getByType() 202 | 203 | target.tasks.named("test") { 204 | useJUnitPlatform(testFrameworkConfigure); 205 | } 206 | 207 | target.dependencies { 208 | "testImplementation"(fabricExt.fabricLoaderVersion.map { "net.fabricmc:fabric-loader-junit:$it" }) 209 | } 210 | } 211 | 212 | override fun createProxyConfigurations(target: Project, sourceSet: SourceSet) { 213 | if (sourceSet.name != SourceSet.MAIN_SOURCE_SET_NAME) { 214 | target.loom.createRemapConfigurations(sourceSet) 215 | } else { 216 | createProxyConfigurations(target, FutureNamedDomainObjectProvider.from(target.configurations, Constants.Configurations.LOCAL_RUNTIME)) 217 | } 218 | 219 | super.createProxyConfigurations(target, sourceSet) 220 | } 221 | 222 | override fun createProxyConfigurations(target: Project, configuration: FutureNamedDomainObjectProvider, defer: Boolean) { 223 | if (defer) error("Cannot defer proxy configuration creation in Loom") 224 | 225 | val remapConfiguration = target.loom.remapConfigurations 226 | .find { it.targetConfigurationName.get() == configuration.name } 227 | ?: error("Loom has not created a remap configuration for ${configuration.name}, modstitch cannot proxy it.") 228 | 229 | val proxyModConfigurationName = configuration.name.addCamelCasePrefix("modstitchMod") 230 | val proxyRegularConfigurationName = configuration.name.addCamelCasePrefix("modstitch") 231 | 232 | target.configurations.create(proxyModConfigurationName) proxy@{ 233 | target.configurations.named(remapConfiguration.name) { 234 | extendsFrom(this@proxy) 235 | } 236 | } 237 | target.configurations.create(proxyRegularConfigurationName) proxy@{ 238 | configuration.get().extendsFrom(this@proxy) 239 | } 240 | } 241 | 242 | override fun configureJiJConfiguration(target: Project, configuration: Configuration) { 243 | target.configurations.named(Constants.Configurations.INCLUDE) { 244 | extendsFrom(configuration) 245 | } 246 | } 247 | 248 | private val Project.loom: LoomGradleExtensionAPI 249 | get() = extensions.getByType() 250 | } 251 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/util/MappingOperation.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.util 2 | 3 | import java.io.LineNumberReader 4 | import java.io.Reader 5 | import java.util.function.Function 6 | 7 | /** 8 | * A configurable transformation pipeline that applies class, field, and method mappings to an input value of type [T]. 9 | * 10 | * Typical usage: 11 | * ``` 12 | * val foo = ... 13 | * val remappedFoo = MappingOperation() 14 | * .remapClass("com/example/MyClass") { foo, newName -> ... } 15 | * .remapField("com/example/MyClass", "myField") { foo, newOwner, newName -> ... } 16 | * .remapMember("com/example/MyClass", "myMethod", "(Ljava/lang/String;)V") { foo, newOwner, newName, newDesc -> ... } 17 | * .loadMappings(reader) 18 | * .apply(foo) 19 | * ``` 20 | * 21 | * @param T The type of input this operation is applied to. 22 | */ 23 | internal class MappingOperation : Function { 24 | /** The loaded mappings, if any. */ 25 | internal var mappings: Map? = null 26 | 27 | /** The set of class names for which mappings are requested. */ 28 | private val requestedMappings: MutableSet = mutableSetOf() 29 | 30 | /** The list of individual mapping operations to apply to an input. */ 31 | private val atomicOperations: MutableList> = mutableListOf() 32 | 33 | /** 34 | * Registers a remapping operation for a class. 35 | * 36 | * @param className The name of the class to be remapped. 37 | * @param result A function receiving the input and the mapped class name. 38 | * @return This instance, for fluent chaining. 39 | */ 40 | fun remapClass(className: String, result: (T, String) -> T) : MappingOperation { 41 | requestedMappings.add(className) 42 | atomicOperations.add(ClassMappingOperation(className, result)) 43 | return this 44 | } 45 | 46 | /** 47 | * Registers a remapping operation for a field. 48 | * 49 | * @param className The class owning the field. 50 | * @param name The name of the field. 51 | * @param result A function receiving the input, the mapped class name, and the mapped field name. 52 | * @return This instance, for fluent chaining. 53 | */ 54 | fun remapField(className: String, name: String, result: (T, String, String) -> T) : MappingOperation { 55 | requestedMappings.add(className) 56 | atomicOperations.add(FieldMappingOperation(className, name, result)) 57 | return this 58 | } 59 | 60 | /** 61 | * Registers a remapping operation for a method or a field with descriptor information. 62 | * 63 | * @param className The class owning the member. 64 | * @param name The name of the member. 65 | * @param desc The JVM descriptor of the member. 66 | * @param result A function receiving the input, the mapped class name, the mapped member name, and the mapped descriptor. 67 | * @return This instance, for fluent chaining. 68 | */ 69 | fun remapMember(className: String, name: String, desc: String, result: (T, String, String, String) -> T) : MappingOperation { 70 | requestedMappings.add(className) 71 | for (referencedClassName in splitDescriptor(desc).filter { it.startsWith('L') && it.endsWith(';') }) { 72 | requestedMappings.add(referencedClassName.subSequence(1, referencedClassName.length - 1)) 73 | } 74 | atomicOperations.add(MemberMappingOperation(className, name, desc, this, result)) 75 | return this 76 | } 77 | 78 | /** 79 | * Loads relevant mappings from the given [Reader]. 80 | * 81 | * This method must be called **before** [apply]. 82 | * 83 | * @param reader The source of mapping data. 84 | * @return This instance, for fluent chaining. 85 | */ 86 | fun loadMappings(reader: Reader): MappingOperation { 87 | mappings = loadMappings(reader, requestedMappings) 88 | return this 89 | } 90 | 91 | /** 92 | * Applies all registered mapping operations to the input. 93 | * 94 | * If no mapping data has been loaded, the input is returned unchanged. 95 | * 96 | * @param input The value to transform. 97 | * @return The result after applying all registered mapping operations. 98 | */ 99 | override fun apply(input: T): T { 100 | val currentMappings = mappings 101 | if (currentMappings == null || currentMappings.isEmpty()) { 102 | return input 103 | } 104 | 105 | var result = input 106 | for (atomicOperation in atomicOperations) { 107 | result = atomicOperation.apply(result, currentMappings[atomicOperation.className]) 108 | } 109 | return result 110 | } 111 | } 112 | 113 | /** 114 | * Represents the mapping information for a single class. 115 | * 116 | * @property className The original class name (e.g., `com/example/MyClass`). 117 | * @property mappedClassName The mapped class name. 118 | * @property fields The list of field mappings belonging to this class. 119 | * @property methods The list of method mappings belonging to this class. 120 | */ 121 | internal data class ClassMappingInfo( 122 | val className: String, 123 | val mappedClassName: String, 124 | val fields: List, 125 | val methods: List, 126 | ) 127 | 128 | /** 129 | * Represents the mapping of a single class member (either a field or a method). 130 | * 131 | * @property name The original member name. 132 | * @property mappedName The mapped member name. 133 | * @property descriptor The original descriptor, if any. 134 | * @property mappedDescriptor The mapped descriptor, if any. 135 | */ 136 | internal data class MemberMappingInfo( 137 | val name: String, 138 | val mappedName: String, 139 | val descriptor: String? = null, 140 | val mappedDescriptor: String? = null, 141 | ) 142 | 143 | /** 144 | * A single remapping operation targeting a specific class. 145 | */ 146 | private interface AtomicMappingOperation { 147 | /** The class this operation targets. */ 148 | val className: String 149 | 150 | /** 151 | * Applies the remapping operation using the available class mapping info. 152 | * 153 | * @param input The value to transform. 154 | * @param mappings The mapping info for the target class, or `null` if unavailable. 155 | * @return The transformed result. 156 | */ 157 | fun apply(input: T, mappings: ClassMappingInfo?) : T 158 | } 159 | 160 | /** 161 | * A class remapping operation that updates the class name. 162 | */ 163 | private class ClassMappingOperation( 164 | override val className: String, 165 | private val selector: (T, String) -> T 166 | ) : AtomicMappingOperation { 167 | override fun apply(input: T, mappings: ClassMappingInfo?): T = 168 | selector(input, mappings?.mappedClassName ?: className) 169 | } 170 | 171 | /** 172 | * A field remapping operation that updates the field name and the owner class. 173 | */ 174 | private class FieldMappingOperation( 175 | override val className: String, 176 | val name: String, 177 | private val selector: (T, String, String) -> T 178 | ) : AtomicMappingOperation { 179 | override fun apply(input: T, mappings: ClassMappingInfo?): T { 180 | val field = mappings?.fields?.firstOrNull { it.name == name } 181 | return selector(input, mappings?.mappedClassName ?: className, field?.mappedName ?: name) 182 | } 183 | } 184 | 185 | /** 186 | * A member remapping operation that updates its name, its descriptor, and the owner class. 187 | */ 188 | private class MemberMappingOperation( 189 | override val className: String, 190 | val name: String, 191 | val descriptor: String, 192 | private val root: MappingOperation, 193 | private val selector: (T, String, String, String) -> T 194 | ) : AtomicMappingOperation { 195 | override fun apply(input: T, mappings: ClassMappingInfo?): T { 196 | val member = when { 197 | descriptor.startsWith('(') -> mappings?.methods?.firstOrNull { it.name == name && it.descriptor == descriptor } 198 | else -> mappings?.fields?.firstOrNull { it.name == name } 199 | } 200 | val mappedDesc = member?.mappedDescriptor ?: splitDescriptor(descriptor).map { when { 201 | it.startsWith('L') && it.endsWith(';') -> { 202 | val className = it.subSequence(1, it.length - 1) 203 | "L${root.mappings?.get(className)?.mappedClassName ?: className};" 204 | } 205 | else -> it 206 | }}.joinToString("") 207 | return selector(input, mappings?.mappedClassName ?: className, member?.mappedName ?: name, mappedDesc) 208 | } 209 | } 210 | 211 | /** 212 | * Splits a JVM descriptor into a sequence of components. 213 | * 214 | * @param desc The character sequence representing the descriptor. 215 | * @return A sequence of parts from the provided descriptor. 216 | */ 217 | private fun splitDescriptor(desc: CharSequence): Sequence = sequence { 218 | var start = 0 219 | var i = 0 220 | while (i < desc.length) { 221 | val j = if (desc[i] == 'L') desc.indexOf(';', i) + 1 else 0 222 | if (j == 0) { 223 | i++ 224 | continue 225 | } 226 | 227 | yield(desc.subSequence(start, i)) 228 | yield(desc.subSequence(i, j)) 229 | start = j 230 | i = j 231 | } 232 | 233 | yield(desc.subSequence(start, desc.length)) 234 | } 235 | 236 | /** 237 | * Attempts to load class mappings from a text-based mapping file. 238 | * 239 | * @param reader A character stream from which mappings will be read. 240 | * @param requestedMappings A set of class names to load mappings for. If empty, all mappings are returned. 241 | * @return A map of class names to their corresponding [ClassMappingInfo] representations. 242 | */ 243 | private fun loadMappings(reader: Reader, requestedMappings: Set = setOf()): Map { 244 | val lineReader = LineNumberReader(reader) 245 | val header = lineReader.readUncommentedLine() 246 | return when { 247 | header == null -> mapOf() 248 | header.contains(" -> ") -> error("ProGuard mappings are not supported") 249 | header.startsWith("v1\t") -> error("TinyV1 mappings are not supported") 250 | header.startsWith("tiny\t") -> error("Tiny mappings are not supported") 251 | header.startsWith("tsrg2 ") -> error("TSRG2 mappings are not supported") 252 | header.startsWith("PK:") || header.startsWith("CL:") || header.startsWith("FD:") || header.startsWith("MD:") -> error("SRG mappings are not supported") 253 | else -> loadTSRG(lineReader, header, requestedMappings) 254 | } 255 | } 256 | 257 | /** 258 | * Loads mappings written in the TSRG format: 259 | * 260 | * ``` 261 | * OldPackage/ NewPackage/ 262 | * OldClass NewClass 263 | * OldField NewField 264 | * OldMethod OldDescriptor NewMethod 265 | * OldClass2 OldField2 NewField2 266 | * OldClass2 OldMethod2 OldDescriptor2 NewMethod2 267 | * ``` 268 | * 269 | * @param reader The reader to read mappings from. 270 | * @param firstLine The first line of the file, already read. 271 | * @param requestedMappings A set of class names to load mappings for. If empty, all mappings are returned. 272 | * @return A map of class names to their corresponding [ClassMappingInfo] representations. 273 | */ 274 | private fun loadTSRG(reader: LineNumberReader, firstLine: CharSequence?, requestedMappings: Set): Map { 275 | fun LineNumberReader.tsrgError(message: String): Nothing = 276 | error("Failed to parse TSRG mappings: $message") 277 | 278 | val loadAll = requestedMappings.isEmpty() 279 | val limit = if (loadAll) Int.MAX_VALUE else (requestedMappings.size + 1) 280 | val mappings = mutableMapOf() 281 | 282 | var currentClass: ClassMappingInfo? = null 283 | var line = firstLine 284 | while (line != null && mappings.size < limit) { 285 | val words = line.words(limit = 5) 286 | when { 287 | // We should get between 2 and 4 columns, depending on the entry type. 288 | words.size < 2 || words.size > 4 -> reader.tsrgError("Unexpected amount of columns: ${words.size}") 289 | 290 | // The current line depends on the previous class mapping entry. 291 | line[0].isWhitespace() -> when { 292 | // There is nothing we can do if the class has not been specified. 293 | currentClass == null -> reader.tsrgError("Missing class") 294 | 295 | // OldField NewField 296 | words.size == 2 -> (currentClass.fields as MutableList).add(MemberMappingInfo(words[0], words[1])) 297 | 298 | // OldMethod OldDesc NewMethod 299 | words.size == 3 -> (currentClass.methods as MutableList).add(MemberMappingInfo(words[0], words[2], words[1])) 300 | 301 | else -> reader.tsrgError("Unexpected amount of columns: ${words.size}") 302 | } 303 | 304 | // Since we don't remap packages, just skip 'em. 305 | words.size == 2 && words[0].endsWith('/') -> {} 306 | 307 | // Skip classes that we don't need. 308 | !loadAll && !requestedMappings.contains(words[0]) -> { 309 | line = reader.readUncommentedLine() 310 | while (!line.isNullOrEmpty() && line[0].isWhitespace()) { 311 | line = reader.readUncommentedLine() 312 | } 313 | continue 314 | } 315 | 316 | else -> when (words.size) { 317 | // OldClass NewClass 318 | 2 -> currentClass = ClassMappingInfo(words[0], words[1], mutableListOf(), mutableListOf()).also { 319 | mappings[it.className] = it 320 | } 321 | 322 | // OldClass OldField NewField 323 | 3 -> (mappings.computeIfAbsent(words[0]) { 324 | ClassMappingInfo(words[0], words[0], mutableListOf(), mutableListOf()) 325 | }.fields as MutableList).add(MemberMappingInfo(words[1], words[2])) 326 | 327 | // OldClass OldMethod OldDesc NewMethod 328 | 4 -> (mappings.computeIfAbsent(words[0]) { 329 | ClassMappingInfo(words[0], words[0], mutableListOf(), mutableListOf()) 330 | }.methods as MutableList).add(MemberMappingInfo(words[1], words[3], words[2])) 331 | } 332 | } 333 | line = reader.readUncommentedLine() 334 | } 335 | return mappings 336 | } 337 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/extensions/Modstitch.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base.extensions 2 | 3 | import dev.isxander.modstitch.base.* 4 | import dev.isxander.modstitch.base.loom.BaseLoomExtension 5 | import dev.isxander.modstitch.base.moddevgradle.BaseModDevGradleExtension 6 | import dev.isxander.modstitch.util.* 7 | import org.gradle.api.Action 8 | import org.gradle.api.NamedDomainObjectContainer 9 | import org.gradle.api.Project 10 | import org.gradle.api.artifacts.Configuration 11 | import org.gradle.api.file.RegularFileProperty 12 | import org.gradle.api.file.SourceDirectorySet 13 | import org.gradle.api.model.ObjectFactory 14 | import org.gradle.api.provider.Property 15 | import org.gradle.api.tasks.SourceSet 16 | import org.gradle.api.tasks.SourceSetContainer 17 | import org.gradle.api.tasks.TaskProvider 18 | import org.gradle.api.tasks.testing.junitplatform.JUnitPlatformOptions 19 | import org.gradle.jvm.tasks.Jar 20 | import org.gradle.kotlin.dsl.* 21 | import javax.inject.Inject 22 | 23 | interface ModstitchExtension { 24 | /** 25 | * The mod loader version to target. 26 | * 27 | * - When target platform is `loom`, this property is equivalent to `loom.fabricLoaderVersion`. 28 | * - When target platform is `moddevgradle`, this property is equivalent to `moddevgradle.neoForgeVersion`. 29 | * - When target platform is `moddevgradle-legacy`, this property is equivalent to `moddevgradle.forgeVersion`. 30 | */ 31 | val modLoaderVersion: Property 32 | 33 | /** 34 | * The version of Minecraft to target by this build. 35 | */ 36 | val minecraftVersion: Property 37 | 38 | /** 39 | * The Java version to target. 40 | * 41 | * - Defaults to `21` if [minecraftVersion] >= `1.20.5`. 42 | * - Defaults to `17` if [minecraftVersion] >= `1.18`. 43 | * - Defaults to `16` if [minecraftVersion] >= `1.17`. 44 | * - Defaults to `8` if [minecraftVersion] <= `1.16.5`. 45 | * - Has no default value if [minecraftVersion] denotes a snapshot version. 46 | * 47 | * Modstitch configures the java plugin's source and target versions. Expands to: 48 | * ```kt 49 | * java { 50 | * targetCompatibility = modstitch.javaVersion 51 | * sourceCompatibility = modstitch.javaVersion 52 | * } 53 | * ``` 54 | */ 55 | val javaVersion: Property 56 | 57 | /** 58 | * The Parchment configuration block. 59 | * Parchment is a parameter name mapping to compliment Official Mojang Mappings. 60 | */ 61 | val parchment: ParchmentBlock 62 | 63 | /** 64 | * The Parchment configuration block. 65 | * Parchment is a parameter name mapping to compliment Official Mojang Mappings. 66 | */ 67 | fun parchment(action: Action) = action.execute(parchment) 68 | 69 | /** 70 | * The metadata block. 71 | * Configures mod necessary and optional metadata about your mod. 72 | */ 73 | val metadata: MetadataBlock 74 | fun metadata(action: Action) = action.execute(metadata) 75 | 76 | /** 77 | * The mixin configuration block. 78 | * Configures Mixin settings for your mod, including registration of mixin configs. 79 | */ 80 | val mixin: MixinBlock 81 | fun mixin(action: Action) = action.execute(mixin) 82 | 83 | val runs: NamedDomainObjectContainer 84 | fun runs(action: Action>) = action.execute(runs) 85 | 86 | /** 87 | * The access widener to be applied to the Minecraft source code. 88 | * 89 | * Despite the name of this property, Modstitch supports both Fabric's Access Widener 90 | * and (Neo)Forge's Access Transformer. Modstitch will automatically detect which you have 91 | * specified and convert accordingly. 92 | * 93 | * It is recommended that you write your loader-agnostic access widener file in 94 | * [Fabric's AW v1 format](https://wiki.fabricmc.net/tutorial:accesswideners) 95 | * format, since it's the lowest common denominator: both ATs and AW(v2)s supports all of AW(v1)'s features. 96 | * 97 | * By default, Modstitch looks for the following files (case-insensitive) in the specified order: 98 | * - `modstitch.accessWidener` 99 | * - `.accessWidener` 100 | * - `accesstransformer.cfg` 101 | * 102 | * Modstitch looks deeply within your Gradle project structure. It will first check 103 | * within the root directory of this subproject, then the root directory of the parent project, and so on. 104 | */ 105 | val accessWidener: RegularFileProperty 106 | 107 | /** 108 | * The path, relative to the root of resulting JAR's resources, where [accessWidener] will be copied. 109 | * 110 | * - On Loom, this defaults to `${metadata.modId}.accessWidener`. 111 | * - On ModDevGradle, this defaults to `META-INF/accesstransformer.cfg`. 112 | * 113 | * In most cases you don't need to change this value. 114 | */ 115 | val accessWidenerName: Property 116 | 117 | /** 118 | * Indicates whether [accessWidener] should be validated. 119 | * 120 | * Validation fails with a fatal error if any of the targeted members do not exist. 121 | * If the [accessWidener] is syntactically invalid, the build will fail regardless of the set value. 122 | * 123 | * Defaults to `false`. 124 | */ 125 | val validateAccessWidener: Property 126 | 127 | /** 128 | * Configures JUnit testing that includes the Minecraft sources. 129 | */ 130 | fun unitTesting(testFrameworkConfigure: Action) 131 | 132 | /** 133 | * Configures JUnit testing that includes the Minecraft sources. 134 | */ 135 | fun unitTesting(testFrameworkConfigure: JUnitPlatformOptions.() -> Unit = {}) = 136 | unitTesting(Action { testFrameworkConfigure() }) 137 | 138 | /** 139 | * The mod loader manifest to use. 140 | * - On Loom, this defaults to `fabric.mod.json`. 141 | * - On ModDevGradle (>=1.20.5), this defaults to `META-INF/neoforge.mods.toml`. 142 | * - On ModDevGradle (<1.20.5), this defaults to `META-INF/mods.toml`. 143 | * - On ModDevGradle Legacy, this defaults to `META-INF/mods.toml`. 144 | * - In environments without a mod loader manifest, this property has no value. 145 | */ 146 | val modLoaderManifest: Property 147 | 148 | /** 149 | * Creates proxy configurations for the given configuration. 150 | * This is used to abstract the differences in platforms, where some may require 151 | * additional logic when the dependency is NOT a mod, and some may require additional logic 152 | * when the dependency IS a mod. 153 | * 154 | * By calling this function, you create two configurations. 155 | * For example, if you use `createProxyConfigurations(configurations.compile)`, 156 | * it will create `modstitchCompile` and `modstitchModCompile`. 157 | */ 158 | fun createProxyConfigurations(configuration: Configuration) 159 | 160 | /** 161 | * Creates proxy configurations for common configurations in the given source set. 162 | * This is used to abstract the differences in platforms, where some may require 163 | * additional logic when the dependency is NOT a mod, and some may require additional logic 164 | * when the dependency IS a mod. 165 | * 166 | * This method is a shorthand for calling `createProxyConfigurations` on the following configurations: 167 | * - `compileOnly` (`modstitchCompileOnly` and `modstitchModCompileOnly`) 168 | * - `implementation` (`modstitchImplementation` and `modstitchModImplementation`) 169 | * - `runtimeOnly` (`modstitchRuntimeOnly` and `modstitchModRuntimeOnly`) 170 | * - `compileOnlyApi` (`modstitchCompileOnlyApi` and `modstitchModCompileOnlyApi`) 171 | * - `api` (`modstitchApi` and `modstitchModApi`) 172 | * 173 | * @see createProxyConfigurations 174 | */ 175 | fun createProxyConfigurations(sourceSet: SourceSet) 176 | 177 | /** The active platform for this project. */ 178 | val platform: Platform 179 | 180 | /** Whether the active platform is Loom. */ 181 | val isLoom: Boolean 182 | /** Whether the active platform is ModDevGradle or ModDevGradle Legacy. */ 183 | val isModDevGradle: Boolean 184 | /** Whether the active platform is ModDevGradle. */ 185 | val isModDevGradleRegular: Boolean 186 | /** Whether the active platform is ModDevGradle Legacy. */ 187 | val isModDevGradleLegacy: Boolean 188 | 189 | /** 190 | * The jar task that will produce the final, production jar file for the platform. 191 | * This may be modified by extensions, like `shadow`. 192 | */ 193 | val finalJarTask: TaskProvider 194 | 195 | val namedJarTask: TaskProvider 196 | 197 | /** 198 | * Configures the Loom extension. 199 | * The action is only executed if the active platform is Loom. 200 | */ 201 | fun loom(action: Action) {} 202 | 203 | /** 204 | * Configures the ModDevGradle extension. 205 | * The action is only executed if the active platform is ModDevGradle or ModDevGradle Legacy. 206 | */ 207 | fun moddevgradle(action: Action) {} 208 | 209 | val templatesSourceDirectorySet: SourceDirectorySet 210 | 211 | /** 212 | * Called when the underlying platform plugin is fully applied. 213 | */ 214 | fun onEnable(action: Action) 215 | } 216 | 217 | @Suppress("LeakingThis") // Extension must remain open for Gradle to inject the implementation. This is safe. 218 | open class ModstitchExtensionImpl @Inject constructor( 219 | objects: ObjectFactory, 220 | @Transient private val project: Project, 221 | @Transient private val plugin: BaseCommonImpl<*>, 222 | ) : ModstitchExtension { 223 | // General setup for the mod environment. 224 | override val modLoaderVersion: Property get() = when (plugin.platform) { 225 | Platform.Loom -> project.extensions.getByType().fabricLoaderVersion 226 | Platform.MDG -> project.extensions.getByType().neoForgeVersion 227 | Platform.MDGLegacy -> project.extensions.getByType().forgeVersion 228 | } 229 | 230 | override val minecraftVersion = objects.property() 231 | 232 | override val javaVersion = objects.property().convention(minecraftVersion.map { v -> 233 | // https://minecraft.wiki/w/Tutorial:Update_Java 234 | ReleaseVersion.parseOrNull(v)?.let { return@map when { 235 | it >= ReleaseVersion(1, 20, 5) -> 21 236 | it >= ReleaseVersion(1, 18, 0) -> 17 237 | it >= ReleaseVersion(1, 17, 0) -> 16 238 | else -> 8 239 | }} 240 | SnapshotVersion.parseOrNull(v)?.let { return@map when { 241 | it >= SnapshotVersion(24, 14, 'a') -> 21 242 | it >= SnapshotVersion(21, 44, 'a') -> 17 243 | it >= SnapshotVersion(21, 19, 'a') -> 16 244 | else -> 8 245 | }} 246 | }) 247 | 248 | override val parchment = objects.newInstance(objects) 249 | init { parchment.minecraftVersion.convention(minecraftVersion) } 250 | 251 | override val metadata = objects.newInstance(objects) 252 | 253 | override val mixin = objects.newInstance(objects) 254 | 255 | override val runs = objects.domainObjectContainer(RunConfig::class) 256 | 257 | override val accessWidener = objects.fileProperty().convention(project.layout.file(project.provider { 258 | val fileNames = listOf("modstitch.accessWidener", ".accessWidener", "accesstransformer.cfg") 259 | project.projectChain.flatMap { p -> fileNames.map { p.projectDir to it } }.firstNotNullOfOrNull { 260 | it.first.listFiles().firstOrNull { f -> it.second.equals(f.name, ignoreCase = true) }?.absoluteFile 261 | } 262 | })) 263 | 264 | override val accessWidenerName = objects.property() 265 | 266 | override val validateAccessWidener = objects.property().convention(false) 267 | 268 | override fun unitTesting(testFrameworkConfigure: Action) { 269 | plugin.applyUnitTesting(project, testFrameworkConfigure) 270 | } 271 | 272 | override val modLoaderManifest = objects.property() 273 | 274 | override fun createProxyConfigurations(configuration: Configuration) = 275 | plugin.createProxyConfigurations(project, FutureNamedDomainObjectProvider.from(configuration), defer = false) 276 | override fun createProxyConfigurations(sourceSet: SourceSet) = 277 | plugin.createProxyConfigurations(project, sourceSet) 278 | 279 | override val platform: Platform 280 | get() = plugin.platform 281 | override val isLoom: Boolean 282 | get() = platform.isLoom 283 | override val isModDevGradle: Boolean 284 | get() = platform.isModDevGradle 285 | override val isModDevGradleRegular: Boolean 286 | get() = platform.isModDevGradleRegular 287 | override val isModDevGradleLegacy: Boolean 288 | get() = platform.isModDevGradleLegacy 289 | 290 | override fun loom(action: Action) = platformExtension(action) 291 | override fun moddevgradle(action: Action) = platformExtension(action) 292 | 293 | private inline fun platformExtension(action: Action) { 294 | val platformExtension = plugin.platformExtension 295 | if (platformExtension is T) { 296 | action.execute(platformExtension) 297 | } 298 | } 299 | 300 | internal var _finalJarTaskName: String? = null 301 | set(value) { 302 | field = value 303 | println("Final jar task set to $value") 304 | } 305 | override val finalJarTask: TaskProvider 306 | get() = _finalJarTaskName?.let { project.tasks.named(it) } ?: error("Final jar task not set") 307 | internal var _namedJarTaskName: String? = null 308 | set(value) { 309 | field = value 310 | println("Named jar task set to $value") 311 | } 312 | override val namedJarTask: TaskProvider 313 | get() = _namedJarTaskName?.let { project.tasks.named(it) } ?: error("Named jar task not set") 314 | 315 | override val templatesSourceDirectorySet: SourceDirectorySet 316 | get() = project.extensions.getByType()["main"].extensions.getByName("templates") 317 | 318 | override fun onEnable(action: Action) { 319 | plugin.onEnable(project, action) 320 | } 321 | } 322 | 323 | operator fun ModstitchExtension.invoke(block: ModstitchExtension.() -> Unit) = block() 324 | internal val Project.modstitch: ModstitchExtensionImpl 325 | get() = extensions.getByType() as ModstitchExtensionImpl 326 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/base/moddevgradle/BaseModDevGradleImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.base.moddevgradle 2 | 3 | import dev.isxander.modstitch.base.BaseCommonImpl 4 | import dev.isxander.modstitch.base.FutureNamedDomainObjectProvider 5 | import dev.isxander.modstitch.base.extensions.modstitch 6 | import dev.isxander.modstitch.util.Platform 7 | import dev.isxander.modstitch.util.PlatformExtensionInfo 8 | import dev.isxander.modstitch.util.ReleaseVersion 9 | import dev.isxander.modstitch.util.Side 10 | import dev.isxander.modstitch.util.SnapshotVersion 11 | import dev.isxander.modstitch.util.addCamelCasePrefix 12 | import dev.isxander.modstitch.util.afterSuccessfulEvaluate 13 | import dev.isxander.modstitch.util.mainSourceSet 14 | import dev.isxander.modstitch.util.zip 15 | import net.neoforged.moddevgradle.dsl.ModDevExtension 16 | import net.neoforged.moddevgradle.dsl.NeoForgeExtension 17 | import net.neoforged.moddevgradle.legacyforge.dsl.LegacyForgeExtension 18 | import net.neoforged.moddevgradle.legacyforge.dsl.MixinExtension 19 | import net.neoforged.moddevgradle.legacyforge.dsl.ObfuscationExtension 20 | import net.neoforged.nfrtgradle.CreateMinecraftArtifacts 21 | import org.gradle.api.Action 22 | import org.gradle.api.Project 23 | import org.gradle.api.artifacts.Configuration 24 | import org.gradle.api.tasks.SourceSet 25 | import org.gradle.api.tasks.TaskProvider 26 | import org.gradle.api.tasks.testing.Test 27 | import org.gradle.api.tasks.testing.junitplatform.JUnitPlatformOptions 28 | import org.gradle.kotlin.dsl.* 29 | import org.gradle.kotlin.dsl.assign 30 | import org.gradle.language.jvm.tasks.ProcessResources 31 | import org.gradle.testing.base.TestingExtension 32 | import org.semver4j.Semver 33 | import org.slf4j.event.Level 34 | import kotlin.collections.dropLast 35 | import kotlin.collections.last 36 | 37 | class BaseModDevGradleImpl( 38 | private val type: MDGType 39 | ) : BaseCommonImpl( 40 | type.platform, 41 | AppendNeoForgeMetadataTask::class.java, 42 | ) { 43 | private lateinit var remapConfiguration: Configuration 44 | 45 | override val platformExtensionInfo = PlatformExtensionInfo( 46 | "msModdevgradle", 47 | BaseModDevGradleExtension::class, 48 | BaseModDevGradleExtensionImpl::class, 49 | BaseModDevGradleExtensionDummy::class, 50 | ) 51 | 52 | override fun apply(target: Project) { 53 | val ext = createRealPlatformExtension(target, type)!! 54 | super.apply(target) 55 | 56 | val modstitch = target.modstitch 57 | modstitch._namedJarTaskName = "jar" 58 | modstitch._finalJarTaskName = if (type == MDGType.Legacy) "reobfJar" else "jar" 59 | modstitch.modLoaderManifest.convention(ext.neoForgeVersion.map { when (Semver.coerce(it)?.satisfies("<20.5")) { 60 | true -> Platform.MDGLegacy.modManifest 61 | else -> Platform.MDG.modManifest 62 | }}.orElse(ext.forgeVersion.map { 63 | Platform.MDGLegacy.modManifest 64 | })) 65 | 66 | val moddev = target.extensions.getByType() 67 | moddev.parchment.parchmentArtifact = modstitch.parchment.parchmentArtifact 68 | moddev.parchment.enabled = modstitch.parchment.enabled 69 | moddev.runs.configureEach { logLevel = Level.DEBUG } // recommended by NeoForge MDK 70 | moddev.mods.register("mod") { sourceSet(target.sourceSets["main"]) } 71 | 72 | target.configurations.create("localRuntime") localRuntime@{ 73 | target.configurations.named("runtimeOnly") { 74 | extendsFrom(this@localRuntime) 75 | } 76 | } 77 | 78 | val obfuscation = target.extensions.findByType() 79 | if (obfuscation != null) { 80 | // Proxy configurations will add remap configurations to this. 81 | val parent = target.configurations.create("modstitchMdgRemap") 82 | remapConfiguration = obfuscation.createRemappingConfiguration(parent) 83 | } 84 | 85 | val mixin = target.extensions.findByType() 86 | if (mixin != null) { 87 | configureLegacyMixin(target, mixin) 88 | } 89 | 90 | applyRuns(target) 91 | } 92 | 93 | override fun finalize(target: Project) { 94 | enable(target) 95 | super.finalize(target) 96 | } 97 | 98 | override fun applyAccessWidener(target: Project) { 99 | val modstitch = target.modstitch 100 | val moddev = target.extensions.getByType() 101 | val obf = target.extensions.findByType() 102 | @Suppress("UnstableApiUsage") val namedToSrgMappings = obf?.namedToSrgMappings ?: target.provider { null } 103 | @Suppress("UnstableApiUsage") val srgToNamedMappings = obf?.srgToNamedMappings ?: target.provider { null } 104 | 105 | val defaultAccessTransformerName = "META-INF/accesstransformer.cfg" 106 | val generatedAccessTransformer = target.layout.buildDirectory.file("modstitch/$defaultAccessTransformerName").zip(modstitch.accessWidener) { x, _ -> x } 107 | val generatedAccessTransformers = generatedAccessTransformer.map { listOf(it) }.orElse(listOf()) 108 | val accessWidenerName = modstitch.accessWidenerName.convention(defaultAccessTransformerName).map { when { 109 | type == MDGType.Regular || it == defaultAccessTransformerName -> it 110 | else -> error("Forge does not support custom access transformer paths.") 111 | }} 112 | val accessWidenerPath = accessWidenerName.map { it.split('\\', '/') } 113 | 114 | // Finalize our properties so that no further changes can be made to them after they've been read. 115 | modstitch.accessWidener.finalizeValueOnRead() 116 | modstitch.accessWidenerName.finalizeValueOnRead() 117 | modstitch.validateAccessWidener.finalizeValueOnRead() 118 | 119 | // Wire MDG to use our properties. 120 | moddev.validateAccessTransformers = modstitch.validateAccessWidener 121 | moddev.accessTransformers.from(generatedAccessTransformers) 122 | 123 | val createMinecraftArtifacts = target.tasks.getByName("createMinecraftArtifacts") 124 | val createMinecraftMappings = target.tasks.register("createMinecraftMappings") { 125 | group = "modstitch/internal" 126 | description = "Creates Minecraft mappings by invoking NFRT." 127 | project.configurations.filter { it.name.startsWith("neoFormRuntimeDependencies") }.forEach(this::addArtifactsToManifest) 128 | 129 | enableCache.set(createMinecraftArtifacts.enableCache) 130 | analyzeCacheMisses.set(createMinecraftArtifacts.analyzeCacheMisses) 131 | toolsJavaExecutable.set(createMinecraftArtifacts.toolsJavaExecutable) 132 | neoForgeArtifact.set(createMinecraftArtifacts.neoForgeArtifact) 133 | neoFormArtifact.set(createMinecraftArtifacts.neoFormArtifact) 134 | namedToIntermediaryMappings.set(namedToSrgMappings) 135 | intermediaryToNamedMappings.set(srgToNamedMappings) 136 | } 137 | 138 | val generateAccessTransformer = target.tasks.register("generateAccessTransformer") { 139 | group = "modstitch/internal" 140 | description = "Generates an access transformer." 141 | dependsOn(createMinecraftMappings) 142 | 143 | accessWidener.set(modstitch.accessWidener) 144 | mappings.set(createMinecraftMappings.flatMap { it.namedToIntermediaryMappings }) 145 | accessTransformer.set(generatedAccessTransformer) 146 | } 147 | 148 | createMinecraftArtifacts.dependsOn(generateAccessTransformer) 149 | 150 | target.tasks.named("processResources") { 151 | dependsOn(generateAccessTransformer) 152 | from(generatedAccessTransformers) { 153 | rename { accessWidenerPath.get().last() } 154 | into(accessWidenerPath.map { it.dropLast(1).joinToString("/") }) 155 | } 156 | } 157 | } 158 | 159 | override fun applyUnitTesting(target: Project, testFrameworkConfigure: Action) { 160 | if (type == MDGType.Legacy) { 161 | throw UnsupportedOperationException("Unit testing is not supported on moddevgradle-legacy platform.") 162 | } 163 | 164 | target.modstitch.onEnable { 165 | target.dependencies { 166 | "testImplementation"(platform("org.junit:junit-bom:5.13.4")) 167 | "testImplementation"("org.junit.jupiter:junit-jupiter") 168 | "testRuntimeOnly"("org.junit.platform:junit-platform-launcher") 169 | } 170 | 171 | target.tasks.named("test") { 172 | useJUnitPlatform(testFrameworkConfigure) 173 | } 174 | 175 | target.extensions.getByType().apply { 176 | unitTest { 177 | enable() 178 | testedMod = mods["mod"] 179 | } 180 | } 181 | } 182 | } 183 | 184 | private fun applyRuns(target: Project) { 185 | val supportsSidedDatagen = target.modstitch.minecraftVersion.map { v -> 186 | ReleaseVersion.parseOrNull(v)?.let { 187 | return@map it >= ReleaseVersion(1, 21, 4) 188 | } 189 | SnapshotVersion.parseOrNull(v)?.let { 190 | return@map it >= SnapshotVersion(24, 45, 'a') 191 | } 192 | return@map false 193 | }.orElse(false) 194 | 195 | target.modstitch.runs.whenObjectAdded modstitch@{ 196 | val modstitch = this@modstitch 197 | target.extensions.getByType().runs.register(modstitch.name) moddev@{ 198 | val moddev = this@moddev 199 | moddev.gameDirectory = modstitch.gameDirectory 200 | moddev.mainClass = modstitch.mainClass 201 | moddev.jvmArguments = modstitch.jvmArgs 202 | moddev.programArguments = modstitch.programArgs 203 | moddev.environment = modstitch.environmentVariables 204 | moddev.ideName = modstitch.ideRunName 205 | .zip(modstitch.ideRun) { name, enabled -> name to enabled } 206 | .filter { (_, enabled) -> enabled } 207 | .map { (name, _) -> name } 208 | moddev.sourceSet = modstitch.sourceSet.convention(target.mainSourceSet) 209 | moddev.type = zip(modstitch.side, modstitch.datagen, supportsSidedDatagen) { side, datagen, sidedDatagen -> 210 | if (datagen && !sidedDatagen) { 211 | return@zip "data" 212 | } 213 | return@zip when (side) { 214 | Side.Client -> if (datagen) "clientData" else "client" 215 | Side.Server -> if (datagen) "serverData" else "server" 216 | else -> error("Unknown side: $side") 217 | } 218 | } 219 | } 220 | 221 | target.modstitch.onEnable { 222 | target.tasks.named("run${modstitch.name.replaceFirstChar { it.uppercaseChar() }}") { 223 | group = "modstitch/runs" 224 | } 225 | } 226 | } 227 | } 228 | 229 | /** 230 | * Enables the underlying [ModDevExtension]. 231 | * 232 | * @param target The project for which the ModDev extension is to be enabled. 233 | */ 234 | private fun enable(target: Project) { 235 | val moddev = target.extensions.getByType() 236 | val neoForge = target.extensions.findByType() 237 | val legacyForge = target.extensions.findByType() 238 | 239 | moddev.neoForgeVersion.finalizeValueOnRead() 240 | moddev.neoFormVersion.finalizeValueOnRead() 241 | moddev.forgeVersion.finalizeValueOnRead() 242 | moddev.mcpVersion.finalizeValueOnRead() 243 | 244 | neoForge?.enable { 245 | version = moddev.neoForgeVersion.orNull 246 | neoFormVersion = moddev.neoFormVersion.orNull 247 | moddev.forgeVersion.map { error("Property 'forgeVersion' does not exist.") }.orNull 248 | moddev.mcpVersion.map { error("Property 'mcpVersion' does not exist.") }.orNull 249 | } 250 | legacyForge?.enable { 251 | forgeVersion = moddev.forgeVersion.orNull 252 | neoForgeVersion = moddev.neoForgeVersion.orNull 253 | mcpVersion = moddev.mcpVersion.orNull 254 | moddev.neoForgeVersion.map { error("Property 'neoForgeVersion' does not exist.") }.orNull 255 | } 256 | } 257 | 258 | override fun applyPlugins(target: Project) { 259 | super.applyPlugins(target) 260 | target.pluginManager.apply(when (type) { 261 | MDGType.Regular -> "net.neoforged.moddev" 262 | MDGType.Legacy -> "net.neoforged.moddev.legacyforge" 263 | }) 264 | } 265 | 266 | override fun applyMetadataStringReplacements(target: Project): TaskProvider { 267 | val generateModMetadata = super.applyMetadataStringReplacements(target) 268 | 269 | // Generate mod metadata every project reload, instead of manually 270 | // (see `generateModMetadata` task in `common.gradle.kts`) 271 | target.msModdevgradle.configureNeoForge { 272 | ideSyncTask(generateModMetadata) 273 | } 274 | 275 | return generateModMetadata 276 | } 277 | 278 | override fun createProxyConfigurations(target: Project, configuration: FutureNamedDomainObjectProvider, defer: Boolean) { 279 | val proxyModConfigurationName = configuration.name.addCamelCasePrefix("modstitchMod") 280 | val proxyRegularConfigurationName = configuration.name.addCamelCasePrefix("modstitch") 281 | 282 | // already created 283 | if (target.configurations.find { it.name == proxyModConfigurationName } != null) { 284 | return 285 | } 286 | 287 | fun deferred(action: (Configuration) -> Unit) { 288 | if (!defer) return action(configuration.get()) 289 | return target.afterSuccessfulEvaluate { action(configuration.get()) } 290 | } 291 | 292 | target.configurations.create(proxyModConfigurationName) proxy@{ 293 | deferred { 294 | it.extendsFrom(this@proxy) 295 | } 296 | 297 | target.afterSuccessfulEvaluate { 298 | if (type == MDGType.Legacy) { 299 | remapConfiguration.extendsFrom(this@proxy) 300 | } 301 | } 302 | } 303 | 304 | target.configurations.create(proxyRegularConfigurationName) proxy@{ 305 | deferred { 306 | it.extendsFrom(this@proxy) 307 | } 308 | 309 | target.afterSuccessfulEvaluate { 310 | target.configurations.named("additionalRuntimeClasspath") { 311 | extendsFrom(this@proxy) 312 | } 313 | } 314 | } 315 | } 316 | 317 | override fun configureJiJConfiguration(target: Project, configuration: Configuration) { 318 | target.afterSuccessfulEvaluate { 319 | target.configurations.named("jarJar") { 320 | extendsFrom(configuration) 321 | } 322 | } 323 | } 324 | 325 | override fun createProxyConfigurations(target: Project, sourceSet: SourceSet) { 326 | super.createProxyConfigurations(target, sourceSet) 327 | 328 | if (sourceSet.name == SourceSet.MAIN_SOURCE_SET_NAME) { 329 | createProxyConfigurations(target, FutureNamedDomainObjectProvider.from(target.configurations, "localRuntime"), defer = true) 330 | } 331 | } 332 | 333 | /** 334 | * Configures mixins for Legacy Forge. 335 | * 336 | * @param target The project for which mixins are to be configured. 337 | * @param mixin The mixin extension used to generate refmaps. 338 | */ 339 | private fun configureLegacyMixin(target: Project, mixin: MixinExtension) { 340 | val modstitch = target.modstitch 341 | val stitchedMixin = modstitch.mixin 342 | 343 | target.dependencies { 344 | "annotationProcessor"("org.spongepowered:mixin:0.8.5:processor") 345 | } 346 | 347 | stitchedMixin.mixinSourceSets.whenObjectAdded obj@{ 348 | mixin.add(target.sourceSets[this@obj.sourceSetName.get()], this@obj.refmapName.get()) 349 | } 350 | stitchedMixin.configs.whenObjectAdded obj@{ mixin.configs.add(this@obj.config) } 351 | 352 | target.afterEvaluate { 353 | stitchedMixin.registerSourceSet(target.mainSourceSet!!, modstitch.metadata.modId.map { "$it.refmap.json" }) 354 | 355 | modstitch.namedJarTask { 356 | manifest.attributes["MixinConfigs"] = stitchedMixin.configs.joinToString(",") { it.config.get() } 357 | } 358 | } 359 | } 360 | 361 | override fun onEnable(target: Project, action: Action) { 362 | target.afterSuccessfulEvaluate(action) 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/isxander/modstitch/util/AccessWidener.kt: -------------------------------------------------------------------------------- 1 | package dev.isxander.modstitch.util 2 | 3 | import java.io.LineNumberReader 4 | import java.io.Reader 5 | import java.io.StringWriter 6 | import java.io.Writer 7 | import kotlin.math.min 8 | 9 | /** 10 | * Represents an access widener which allows modification of class/method/field access levels. 11 | * 12 | * @property format The format of the access widener. 13 | * @property entries The list of access modifications. 14 | * @property namespace The mapping namespace (e.g., "named", "intermediary"). 15 | */ 16 | internal data class AccessWidener( 17 | val format: AccessWidenerFormat, 18 | val entries: List, 19 | val namespace: String = "named", 20 | ) { 21 | /** 22 | * Converts this access widener to the specified format. 23 | * 24 | * @param targetFormat The desired format to convert to. 25 | * @return A new [AccessWidener] in the target format. 26 | * @throws IllegalStateException If this instance uses a feature unsupported by the target format. 27 | */ 28 | fun convert(targetFormat: AccessWidenerFormat): AccessWidener { 29 | // Only AWv2 supports transitive entries. 30 | // Throw if the current instance contains any of those. 31 | if (targetFormat != AccessWidenerFormat.AW_V2 && entries.any { it.accessModifier.isTransitive }) { 32 | error("${targetFormat.name} does not support transitive access modifiers") 33 | } 34 | 35 | if (targetFormat != AccessWidenerFormat.AT) { 36 | // Only AT supports making members less accessible than they already are. 37 | // Why would anyone need that? 38 | if (entries.map { it.accessModifier }.any { 39 | it.isFinal == true || it.isFinal == null && it.type != AccessModifierType.PUBLIC && it.type != AccessModifierType.NONE 40 | }) { 41 | error("${targetFormat.name} does not support restricting member accessibility") 42 | } 43 | 44 | // AT doesn't require a field descriptor for field entries. 45 | // But for some reason, AW does. 46 | if (entries.any { it.type == AccessWidenerEntryType.FIELD && it.descriptor.isBlank() }) { 47 | error("${targetFormat.name} requires a field descriptor to be specified") 48 | } 49 | } 50 | 51 | // AT allows making a member public and non-final via a single entry. 52 | // AW - not so much. 53 | val convertedEntries = if (targetFormat == AccessWidenerFormat.AT) compactEntries(entries) else entries 54 | return AccessWidener(targetFormat, convertedEntries, namespace) 55 | } 56 | 57 | /** 58 | * Remaps this access widener using the provided mappings. 59 | * 60 | * @param mappings A reader to load the mapping data from. 61 | * @param targetNamespace The mapping namespace to remap this instance to. 62 | * @return A new [AccessWidener] with remapped names. 63 | */ 64 | fun remap(mappings: Reader, targetNamespace: String = namespace): AccessWidener { 65 | val op = MappingOperation>() 66 | for (entry in entries) { 67 | when { 68 | entry.type == AccessWidenerEntryType.CLASS -> op.remapClass(entry.className) { acc, x -> 69 | acc.also { acc.add(AccessWidenerEntry(entry.type, entry.accessModifier, x, x, x)) } 70 | } 71 | 72 | entry.type == AccessWidenerEntryType.FIELD && entry.descriptor.isBlank() -> op.remapField( 73 | entry.className, 74 | entry.name 75 | ) { acc, cls, x -> 76 | acc.also { acc.add(AccessWidenerEntry(entry.type, entry.accessModifier, cls, x, "")) } 77 | } 78 | 79 | else -> op.remapMember(entry.className, entry.name, entry.descriptor) { acc, cls, x, desc -> 80 | acc.also { acc.add(AccessWidenerEntry(entry.type, entry.accessModifier, cls, x, desc)) } 81 | } 82 | } 83 | } 84 | 85 | val remappedEntries = op.loadMappings(mappings).apply(mutableListOf()) 86 | return AccessWidener(format, remappedEntries, targetNamespace) 87 | } 88 | 89 | /** 90 | * Writes the access widener to the given writer using the current [format]. 91 | * 92 | * @param writer A [Writer] to output to. 93 | */ 94 | fun write(writer: Writer) { 95 | when (format) { 96 | AccessWidenerFormat.AT -> writeAT(writer) 97 | else -> writeAW(writer) 98 | } 99 | } 100 | 101 | /** 102 | * Writes entries in the Access Transformer format. 103 | * 104 | * @param writer The output writer. 105 | */ 106 | private fun writeAT(writer: Writer) { 107 | for (entry in entries) { 108 | writer.append(when (entry.accessModifier.type) { 109 | AccessModifierType.PROTECTED -> "protected" 110 | AccessModifierType.DEFAULT -> "default" 111 | AccessModifierType.PRIVATE -> "private" 112 | else -> "public" 113 | }).append(when (entry.accessModifier.isFinal) { 114 | true -> "+f" 115 | false -> "-f" 116 | null -> "" 117 | }).append(' ') 118 | 119 | // AT uses dots instead of slashes here (and only here!) for some reason. 120 | for (c in entry.className) { 121 | writer.append(if (c == '/') '.' else c) 122 | } 123 | 124 | when (entry.type) { 125 | AccessWidenerEntryType.CLASS -> writer.appendLine() 126 | AccessWidenerEntryType.FIELD -> writer.append(' ').appendLine(entry.name) 127 | AccessWidenerEntryType.METHOD -> writer.append(' ').append(entry.name).appendLine(entry.descriptor) 128 | } 129 | } 130 | } 131 | 132 | /** 133 | * Writes entries in the Access Widener format. 134 | * 135 | * @param writer The output writer. 136 | */ 137 | private fun writeAW(writer: Writer) { 138 | // Write the header, e.g.: 139 | // accessWidener v2 named 140 | // 141 | // Note: we use tabs instead of spaces here because Fabric's AW parser was very brittle 142 | // at the time of its inception. For example, I vividly remember crashing it by introducing 143 | // an empty trailing line in one of my AW files. So, since it was built around tabs, it's 144 | // better to stick with them to maintain compatibility with older versions of Fabric. 145 | writer.append("accessWidener").append('\t') 146 | .append(if (format == AccessWidenerFormat.AW_V1) "v1" else "v2").append('\t') 147 | .appendLine(namespace) 148 | 149 | for (entry in entries) { 150 | if (entry.accessModifier.isFinal == false) { 151 | writeAWEntry(writer, entry, if (entry.type == AccessWidenerEntryType.FIELD) "mutable" else "extendable") 152 | 153 | // An "extendable" class becomes public by default, 154 | // so there's no need to execute the next block. 155 | if (entry.type == AccessWidenerEntryType.CLASS) { 156 | continue 157 | } 158 | } 159 | 160 | if (entry.accessModifier.type == AccessModifierType.PUBLIC) { 161 | writeAWEntry(writer, entry, "accessible") 162 | } 163 | } 164 | } 165 | 166 | /** 167 | * Writes a single entry in the Access Widener format. 168 | * 169 | * @param writer The output writer. 170 | * @param entry The entry to write. 171 | * @param modifier The string representing the access modifier (i.e., "mutable", "extendable", or "accessible"). 172 | */ 173 | private fun writeAWEntry(writer: Writer, entry: AccessWidenerEntry, modifier: String) { 174 | if (entry.accessModifier.isTransitive) { 175 | writer.write("transitive-") 176 | } 177 | writer.append(modifier).append('\t') 178 | 179 | writer.append(when (entry.type) { 180 | AccessWidenerEntryType.CLASS -> "class" 181 | AccessWidenerEntryType.METHOD -> "method" 182 | AccessWidenerEntryType.FIELD -> "field" 183 | }).append('\t') 184 | 185 | writer.append(entry.className) 186 | if (entry.type == AccessWidenerEntryType.CLASS) { 187 | writer.appendLine() 188 | } else { 189 | writer.append('\t').append(entry.name).append('\t').appendLine(entry.descriptor) 190 | } 191 | } 192 | 193 | /** 194 | * Returns a string representation of this access widener in its current [format]. 195 | */ 196 | override fun toString() = StringWriter().use { write(it); it.toString() } 197 | 198 | companion object { 199 | /** 200 | * Parses an access widener from the given [Reader] and automatically detects its format. 201 | * 202 | * @param reader A reader containing access widener contents. 203 | * @return A parsed [AccessWidener] instance. 204 | */ 205 | fun parse(reader: Reader): AccessWidener { 206 | val lineReader = LineNumberReader(reader) 207 | val header = lineReader.readUncommentedLine() 208 | 209 | // An empty file is a perfectly valid access transformer. 210 | if (header == null) { 211 | return AccessWidener(AccessWidenerFormat.AT, listOf()) 212 | } 213 | 214 | // AW headers begin with the word "accessWidener". 215 | // However, I'm not entirely sure if Loom ignores its case or not. 216 | if (header.startsWith("accessWidener", ignoreCase = true)) { 217 | return parseAW(lineReader, header) 218 | } 219 | 220 | // ATs don't have a header. 221 | return parseAT(lineReader, header) 222 | } 223 | 224 | /** 225 | * Parses a Fabric-style access widener. 226 | * 227 | * @param reader A reader containing access widener contents. 228 | * @param header The header line, already read. 229 | * @return A parsed [AccessWidener] instance. 230 | * 231 | * @see FabricMC: Access Wideners 232 | */ 233 | private fun parseAW(reader: LineNumberReader, header: CharSequence): AccessWidener { 234 | fun LineNumberReader.awError(message: String): Nothing = 235 | error("Failed to parse access widener: $message") 236 | 237 | // Usually, an AW header should look like this: 238 | // accessWidener v2 named 239 | val headerData = header.words(limit = 3) 240 | val format = when (if (headerData.size > 1) headerData[1] else null) { 241 | "v1" -> AccessWidenerFormat.AW_V1 242 | "v2" -> AccessWidenerFormat.AW_V2 243 | else -> null 244 | } 245 | val namespace = if (headerData.size > 2) headerData[2].trimEnd() else null 246 | 247 | // Assume that the "accessWidener" part has already been checked by the caller. 248 | if (headerData.size != 3 || format == null || namespace.isNullOrBlank()) { 249 | reader.awError("Invalid header: '$header'") 250 | } 251 | 252 | val entries = mutableListOf() 253 | var line = reader.readUncommentedLine() 254 | while (line != null) { 255 | // We should get exactly 3 or 5 columns, depending on the entry type. 256 | val parts = line.words(limit = 6) 257 | if (parts.size < 3 || parts.size != if (parts[1] == "class") 3 else 5) { 258 | reader.awError("Unexpected amount of columns: ${parts.size}") 259 | } 260 | 261 | // The entry type must be one of: "class", "field", or "method": 262 | // class 263 | // field 264 | // method 265 | val className = parts[2] 266 | val (entryType, name, descriptor) = when(parts[1]) { 267 | "class" -> Triple(AccessWidenerEntryType.CLASS, className, className) 268 | "field" -> Triple(AccessWidenerEntryType.FIELD, parts[3], parts[4]) 269 | "method" -> Triple(AccessWidenerEntryType.METHOD, parts[3], parts[4]) 270 | else -> reader.awError("Unknown member type: '${parts[1]}'") 271 | } 272 | 273 | // There are 3 access modifiers available in AW: 274 | // - "accessible" - makes a member public. 275 | // - "extendable" - makes a class public and non-final; makes a method protected and non-final 276 | // - "mutable" - makes a field non-final. 277 | // 278 | // Since v2, all of them can optionally be prefixed with "transitive-". 279 | val mod = parts[0] 280 | val modDelimiter = mod.indexOf('-') + 1 281 | val modTransitive = mod.subSequence(0, modDelimiter) 282 | val modAccess = mod.subSequence(modDelimiter, mod.length) 283 | val isTransitive = when { 284 | modTransitive == "transitive-" && format == AccessWidenerFormat.AW_V2 -> true 285 | modTransitive == "" -> false 286 | else -> reader.awError("Unknown access modifier: '$mod'") 287 | } 288 | val (accessType, isFinal) = when { 289 | modAccess == "accessible" -> Pair(AccessModifierType.PUBLIC, null) 290 | modAccess == "extendable" && entryType == AccessWidenerEntryType.CLASS -> Pair(AccessModifierType.PUBLIC, false) 291 | modAccess == "extendable" && entryType == AccessWidenerEntryType.METHOD -> Pair(AccessModifierType.PROTECTED, false) 292 | modAccess == "mutable" && entryType == AccessWidenerEntryType.FIELD -> Pair(AccessModifierType.NONE, false) 293 | else -> reader.awError("Unknown access modifier: '$mod'") 294 | } 295 | 296 | entries.add(AccessWidenerEntry(entryType, AccessModifier(accessType, isTransitive, isFinal), className, name, descriptor)) 297 | line = reader.readUncommentedLine() 298 | } 299 | 300 | return AccessWidener(format, entries, namespace) 301 | } 302 | 303 | /** 304 | * Parses a (Neo)Forge-style access transformer. 305 | * 306 | * @param reader A reader containing access widener contents. 307 | * @param firstLine The first line of the file, already read. 308 | * @return A parsed [AccessWidener] instance. 309 | * 310 | * @see NeoForge: Access Transformers 311 | */ 312 | private fun parseAT(reader: LineNumberReader, firstLine: CharSequence?): AccessWidener { 313 | val accessModifierDelimiters = charArrayOf('-', '+') 314 | fun LineNumberReader.atError(message: String): Nothing = 315 | error("Failed to parse access transformer: $message") 316 | 317 | val entries = mutableListOf() 318 | var line = firstLine 319 | while (line != null) { 320 | // We should get from 2 to 4 columns, depending on the entry type. 321 | val parts = line.words(limit = 5) 322 | if (parts.size < 2 || parts.size > 4) { 323 | reader.atError("Unexpected amount of columns: ${parts.size}") 324 | } 325 | 326 | // There are 4 access levels: "public", "protected", "default", and "private". 327 | // Each may optionally include a "-f" or "+f" modifier, indicating whether 328 | // the target member should lose or gain the final modifier, respectively. 329 | val mod = parts[0] 330 | val modDelimiter = min(mod.indexOfAny(accessModifierDelimiters).toUInt(), mod.length.toUInt()).toInt() 331 | val accessType = when (mod.subSequence(0, modDelimiter)) { 332 | "public" -> AccessModifierType.PUBLIC 333 | "protected" -> AccessModifierType.PROTECTED 334 | "default" -> AccessModifierType.DEFAULT 335 | "private" -> AccessModifierType.PRIVATE 336 | else -> reader.atError("Unknown access modifier: '$mod'") 337 | } 338 | val isFinal = when (mod.subSequence(modDelimiter, mod.length)) { 339 | "+f" -> true 340 | "-f" -> false 341 | "" -> null 342 | else -> reader.atError("Unknown access modifier: '$mod'") 343 | } 344 | 345 | // There are 4 types of entries: 346 | // 347 | // 348 | // 349 | // 350 | // 351 | // We can differentiate between them based on the number of columns and whether 352 | // the descriptor starts with '(' (indicating a method descriptor) or not 353 | // (indicating a field descriptor, if any). 354 | val className = parts[1].replace('.', '/') 355 | val descStart = if (parts.size == 3) parts[2].indexOf('(') else -1 356 | val (entryType, name, descriptor) = when { 357 | parts.size == 4 -> Triple(if (parts[3].startsWith('(')) AccessWidenerEntryType.METHOD else AccessWidenerEntryType.FIELD, parts[2], parts[3]) 358 | parts.size == 3 && descStart < 0 -> Triple(AccessWidenerEntryType.FIELD, parts[2], "") 359 | parts.size == 3 -> Triple(AccessWidenerEntryType.METHOD, parts[2].substring(0, descStart), parts[2].substring(descStart)) 360 | else -> Triple(AccessWidenerEntryType.CLASS, className, className) 361 | } 362 | 363 | entries.add(AccessWidenerEntry(entryType, AccessModifier(accessType, isFinal = isFinal), className, name, descriptor)) 364 | line = reader.readUncommentedLine() 365 | } 366 | 367 | return AccessWidener(AccessWidenerFormat.AT, entries) 368 | } 369 | 370 | /** 371 | * Merges and deduplicates provided entries. 372 | * 373 | * @param entries The list of access widener entries to compact. 374 | * @return A compacted list of deduplicated entries. 375 | */ 376 | private fun compactEntries(entries: List): List { 377 | val entryMap = mutableMapOf() 378 | for (entry in entries) { 379 | val identity = AccessWidenerEntryIdentity(entry.type, entry.className, entry.name, entry.descriptor) 380 | val curMod = entry.accessModifier 381 | val oldMod = entryMap[identity] ?: curMod 382 | val newMod = AccessModifier( 383 | if (curMod.type > oldMod.type) curMod.type else oldMod.type, 384 | curMod.isTransitive || oldMod.isTransitive, 385 | when { 386 | curMod.isFinal == false || oldMod.isFinal == false -> false 387 | curMod.isFinal == true || oldMod.isFinal == true -> true 388 | else -> null 389 | }, 390 | ) 391 | entryMap[identity] = newMod 392 | } 393 | return entryMap.map { AccessWidenerEntry(it.key.type, it.value, it.key.className, it.key.name, it.key.descriptor) } 394 | } 395 | } 396 | } 397 | 398 | /** 399 | * Represents the supported access widener formats. 400 | */ 401 | internal enum class AccessWidenerFormat { 402 | /** (Neo)Forge's `accesstransformer.cfg`. */ 403 | AT, 404 | 405 | /** Fabric's `accessWidener v1`. */ 406 | AW_V1, 407 | 408 | /** Fabric's `accessWidener v2`. */ 409 | AW_V2, 410 | } 411 | 412 | /** 413 | * Defines the types of access modifiers that can be applied to classes, methods, or fields. 414 | */ 415 | internal enum class AccessModifierType { 416 | /** The target keeps its original visibility modifier. */ 417 | NONE, 418 | 419 | /** The target is made private. */ 420 | PRIVATE, 421 | 422 | /** The target is made package-private. */ 423 | DEFAULT, 424 | 425 | /** The target is made protected. */ 426 | PROTECTED, 427 | 428 | /** The target is made public. */ 429 | PUBLIC, 430 | } 431 | 432 | /** 433 | * Represents an access modifier with additional attributes. 434 | * 435 | * @property type The type of access modifier. 436 | * @property isTransitive Indicates whether the access change should be applied transitively. 437 | * @property isFinal Specifies whether the target element should be marked as `final`. `null` indicates no explicit change. 438 | */ 439 | internal data class AccessModifier( 440 | val type: AccessModifierType, 441 | val isTransitive: Boolean = false, 442 | val isFinal: Boolean? = null 443 | ) 444 | 445 | /** 446 | * Represents the type of access widener entry. 447 | */ 448 | internal enum class AccessWidenerEntryType { 449 | /** A class-level access widener entry. */ 450 | CLASS, 451 | 452 | /** A method-level access widener entry. */ 453 | METHOD, 454 | 455 | /** A field-level access widener entry. */ 456 | FIELD, 457 | } 458 | 459 | /** 460 | * Represents a single access widener rule entry that modifies a target's visibility or attributes. 461 | * 462 | * @property type The type of the entry (class, method, or field). 463 | * @property accessModifier The access modifier to apply. 464 | * @property className The internal name of the class being modified (e.g., `com/example/MyClass`). 465 | * @property name The name of the method or field being modified. 466 | * @property descriptor The descriptor of the method or field. 467 | */ 468 | internal data class AccessWidenerEntry( 469 | val type: AccessWidenerEntryType, 470 | val accessModifier: AccessModifier, 471 | val className: String, 472 | val name: String, 473 | val descriptor: String 474 | ) 475 | 476 | /** 477 | * Represents the identity of an access widener entry. 478 | * 479 | * Used for comparing or grouping entries by their structural identity. 480 | * 481 | * @property type The type of the entry (class, method, or field). 482 | * @property className The internal name of the class being modified (e.g., `com/example/MyClass`). 483 | * @property name The name of the method or field being modified. 484 | * @property descriptor The descriptor of the method or field. 485 | */ 486 | private data class AccessWidenerEntryIdentity( 487 | val type: AccessWidenerEntryType, 488 | val className: String, 489 | val name: String, 490 | val descriptor: String 491 | ) 492 | --------------------------------------------------------------------------------