diff --git a/.github/workflows/build-1217.yml b/.github/workflows/build-1217.yml index 9b41d68..e1452c5 100644 --- a/.github/workflows/build-1217.yml +++ b/.github/workflows/build-1217.yml @@ -62,8 +62,8 @@ jobs: PARALLELISM=$(($(nproc) * 2)) ./gradlew applyAllPatches --stacktrace --parallel --max-workers=$PARALLELISM --build-cache --no-daemon - - name: Build Paperclip Jar - run: ./gradlew createMojmapPaperclipJar --stacktrace --parallel --no-daemon + - name: Build Shuttle Jar + run: ./gradlew createMojmapShuttleJar --stacktrace --parallel --no-daemon - name: Prepare Release Info run: bash scripts/releaseInfo.sh diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index c074c43..68df3c0 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -26,11 +26,11 @@ jobs: - name: Apply Patches run: ./gradlew applyAllPatches --stacktrace --no-daemon - - name: Build Paperclip Jar - run: ./gradlew createMojmapPaperclipJar --stacktrace --no-daemon + - name: Build Shuttle Jar + run: ./gradlew createMojmapShuttleJar --stacktrace --no-daemon - name: Upload Artifacts uses: actions/upload-artifact@main with: name: DivineMC - path: divinemc-server/build/libs/divinemc-paperclip-*-mojmap.jar + path: divinemc-server/build/libs/divinemc-shuttle-*-mojmap.jar diff --git a/README.md b/README.md index 11fb2fd..eec2e34 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Run the following commands in the root directory: ```bash > ./gradlew applyAllPatches # apply all patches -> ./gradlew createMojmapPaperclipJar # build the server jar +> ./gradlew createMojmapShuttleJar # build the server jar ``` For anything else you can refer to our [contribution guide](https://bxteam.org/docs/divinemc/development/contributing). diff --git a/build.gradle.kts b/build.gradle.kts index 758fe0e..5821d2b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.api.tasks.testing.logging.TestLogEvent +import kotlin.system.measureTimeMillis plugins { java @@ -91,6 +92,7 @@ subprojects { mavenCentral() maven(paperMavenPublicUrl) maven("https://jitpack.io") + maven("https://s01.oss.sonatype.org/content/repositories/snapshots") } extensions.configure { @@ -105,6 +107,76 @@ subprojects { } } +tasks.register("createMojmapShuttleJar") { + dependsOn(":divinemc-server:createMojmapPaperclipJar", "shuttle:shadowJar") + + outputs.upToDateWhen { false } + + val paperclipJarTask = project(":divinemc-server").tasks.getByName("createMojmapPaperclipJar") + val shuttleJarTask = project(":shuttle").tasks.getByName("shadowJar") + + val paperclipJar = paperclipJarTask.outputs.files.singleFile + val shuttleJar = shuttleJarTask.outputs.files.singleFile + val outputDir = paperclipJar.parentFile + val tempDir = File(outputDir, "tempJarWork") + val newJarName = "divinemc-shuttle-${properties["version"]}-mojmap.jar" + + doFirst { + val time = measureTimeMillis { + println("Recompiling Paperclip with Shuttle sources...") + + tempDir.deleteRecursively() + tempDir.mkdirs() + + copy { + from(zipTree(paperclipJar)) + into(tempDir) + } + + val oldPackagePath = "io/papermc/paperclip/" + tempDir.walkTopDown() + .filter { it.isFile && it.relativeTo(tempDir).path.startsWith(oldPackagePath) } + .forEach { it.delete() } + + val shuttlePackagePath = "org/bxteam/shuttle/" + copy { + from(zipTree(shuttleJar)) + include("$shuttlePackagePath**") + into(tempDir) + } + + tempDir.walkBottomUp() + .filter { it.isDirectory && it.listFiles().isNullOrEmpty() } + .forEach { it.delete() } + + val metaInfDir = File(tempDir, "META-INF") + metaInfDir.mkdirs() + File(metaInfDir, "main-class").writeText("net.minecraft.server.Main") + } + println("Finished build in ${time}ms") + } + + archiveFileName.set(newJarName) + destinationDirectory.set(outputDir) + from(tempDir) + + manifest { + attributes( + "Main-Class" to "org.bxteam.shuttle.Shuttle", + "Enable-Native-Access" to "ALL-UNNAMED", + "Premain-Class" to "org.bxteam.shuttle.patch.InstrumentationManager", + "Agent-Class" to "org.bxteam.shuttle.patch.InstrumentationManager", + "Launcher-Agent-Class" to "org.bxteam.shuttle.patch.InstrumentationManager", + "Can-Redefine-Classes" to true, + "Can-Retransform-Classes" to true + ) + } + + doLast { + tempDir.deleteRecursively() + } +} + tasks.register("printMinecraftVersion") { doLast { println(providers.gradleProperty("mcVersion").get().trim()) diff --git a/divinemc-server/minecraft-patches/features/0001-Rebrand.patch b/divinemc-server/minecraft-patches/features/0001-Rebrand.patch index 1b985a5..334fb12 100644 --- a/divinemc-server/minecraft-patches/features/0001-Rebrand.patch +++ b/divinemc-server/minecraft-patches/features/0001-Rebrand.patch @@ -18,10 +18,28 @@ index 394443d00e661715439be1e56dddc129947699a4..480ad57a6b7b74e6b83e9c6ceb69ea1f public CrashReport(String title, Throwable exception) { io.papermc.paper.util.StacktraceDeobfuscator.INSTANCE.deobfuscateThrowable(exception); // Paper diff --git a/net/minecraft/server/Main.java b/net/minecraft/server/Main.java -index b06c2c4aa77edafb374f7cf0406cf4d29c6e7f9f..a476b53e0c5b18d9b0abceb4ffeb5ab4c5d7d6d9 100644 +index b06c2c4aa77edafb374f7cf0406cf4d29c6e7f9f..9d89af099c6e0f50c73a9372e1ef7f1b0ef932c5 100644 --- a/net/minecraft/server/Main.java +++ b/net/minecraft/server/Main.java -@@ -125,7 +125,6 @@ public class Main { +@@ -64,6 +64,17 @@ import org.slf4j.Logger; + public class Main { + private static final Logger LOGGER = LogUtils.getLogger(); + ++ public static void main(String[] arguments) { ++ OptionSet optionSet = org.bxteam.divinemc.DivineBootstrap.bootstrap(arguments); ++ ++ io.papermc.paper.ServerBuildInfo info = io.papermc.paper.ServerBuildInfo.buildInfo(); ++ if (io.papermc.paper.ServerBuildInfoImpl.IS_EXPERIMENTAL) { ++ LOGGER.warn("Running an experimental version of {}, please proceed with caution.", info.brandName()); ++ } ++ ++ main(optionSet); ++ } ++ + @SuppressForbidden( + reason = "System.out needed before bootstrap" + ) +@@ -125,7 +136,6 @@ public class Main { dedicatedServerSettings.forceSave(); RegionFileVersion.configure(dedicatedServerSettings.getProperties().regionFileComression); Path path2 = Paths.get("eula.txt"); @@ -29,7 +47,7 @@ index b06c2c4aa77edafb374f7cf0406cf4d29c6e7f9f..a476b53e0c5b18d9b0abceb4ffeb5ab4 // Paper start - load config files early for access below if needed org.bukkit.configuration.file.YamlConfiguration bukkitConfiguration = io.papermc.paper.configuration.PaperConfigurations.loadLegacyConfigFile((File) optionSet.valueOf("bukkit-settings")); org.bukkit.configuration.file.YamlConfiguration spigotConfiguration = io.papermc.paper.configuration.PaperConfigurations.loadLegacyConfigFile((File) optionSet.valueOf("spigot-settings")); -@@ -148,19 +147,6 @@ public class Main { +@@ -148,19 +158,6 @@ public class Main { return; } diff --git a/divinemc-server/paper-patches/features/0001-Rebrand.patch b/divinemc-server/paper-patches/features/0001-Rebrand.patch index b2b5f4e..3272a05 100644 --- a/divinemc-server/paper-patches/features/0001-Rebrand.patch +++ b/divinemc-server/paper-patches/features/0001-Rebrand.patch @@ -231,18 +231,112 @@ index 62e2d5704c348955bc8284dc2d54c933b7bcdd06..341f13e57896f03058ea3ec68e69b7cb public void executeAsync(final Runnable runnable) { MCUtil.scheduleAsyncTask(this.catching(runnable, "asynchronous")); diff --git a/src/main/java/org/bukkit/craftbukkit/Main.java b/src/main/java/org/bukkit/craftbukkit/Main.java -index c92ae5af5186a7c36f685272a13cfcfad21e8e69..24013949b6646016267132396e61d6cd4af8e374 100644 +index 748bd9650da4a209743b7a5dde584b2e19c5a578..7e90142cb65937103aa99fd011540086449c45c8 100644 --- a/src/main/java/org/bukkit/craftbukkit/Main.java +++ b/src/main/java/org/bukkit/craftbukkit/Main.java -@@ -247,7 +247,7 @@ public class Main { - System.setProperty("library.jansi.version", "Paper"); // Paper - set meaningless jansi version to prevent git builds from crashing on Windows - System.setProperty("jdk.console", "java.base"); // Paper - revert default console provider back to java.base so we can have our own jline +@@ -1,14 +1,8 @@ + package org.bukkit.craftbukkit; -- io.papermc.paper.PaperBootstrap.boot(options); -+ org.bxteam.divinemc.DivineBootstrap.boot(options); // DivineMC - Replace with DivineBootstrap - } catch (Throwable t) { - t.printStackTrace(); + import java.io.File; +-import java.io.IOException; + import java.text.SimpleDateFormat; +-import java.util.Calendar; +-import java.util.Date; +-import java.util.logging.Level; +-import java.util.logging.Logger; + import joptsimple.OptionParser; +-import joptsimple.OptionSet; + import joptsimple.util.PathConverter; + + import static java.util.Arrays.asList; +@@ -24,7 +18,7 @@ public class Main { + } + // Paper end - Reset loggers after shutdown + +- public static void main(String[] args) { ++ public static OptionParser main(String[] args) { // DivineMC - Rebrand + if (System.getProperty("jdk.nio.maxCachedBufferSize") == null) System.setProperty("jdk.nio.maxCachedBufferSize", "262144"); // Paper - cap per-thread NIO cache size; https://www.evanjones.ca/java-bytebuffer-leak.html + OptionParser parser = new OptionParser() { + { +@@ -180,77 +174,6 @@ public class Main { } + }; + +- OptionSet options = null; +- +- try { +- options = parser.parse(args); +- } catch (joptsimple.OptionException ex) { +- Logger.getLogger(Main.class.getName()).log(Level.SEVERE, ex.getLocalizedMessage()); +- } +- +- if ((options == null) || (options.has("?"))) { +- try { +- parser.printHelpOn(System.out); +- } catch (IOException ex) { +- Logger.getLogger(Main.class.getName()).log(Level.SEVERE, null, ex); +- } +- } else if (options.has("v")) { +- System.out.println(CraftServer.class.getPackage().getImplementationVersion()); +- } else { +- // Do you love Java using + and ! as string based identifiers? I sure do! +- String path = new File(".").getAbsolutePath(); +- if (path.contains("!") || path.contains("+")) { +- System.err.println("Cannot run server in a directory with ! or + in the pathname. Please rename the affected folders and try again."); +- return; +- } +- +- // Paper start - Improve java version check +- boolean skip = Boolean.getBoolean("Paper.IgnoreJavaVersion"); +- String javaVersionName = System.getProperty("java.version"); +- // J2SE SDK/JRE Version String Naming Convention +- boolean isPreRelease = javaVersionName.contains("-"); +- if (isPreRelease) { +- if (!skip) { +- System.err.println("Unsupported Java detected (" + javaVersionName + "). You are running an unsupported, non official, version. Only general availability versions of Java are supported. Please update your Java version. See https://docs.papermc.io/paper/faq#unsupported-java-detected-what-do-i-do for more information."); +- return; +- } +- +- System.err.println("Unsupported Java detected ("+ javaVersionName + "), but the check was skipped. Proceed with caution! "); +- } +- // Paper end - Improve java version check +- +- try { +- if (options.has("nojline")) { +- System.setProperty(net.minecrell.terminalconsole.TerminalConsoleAppender.JLINE_OVERRIDE_PROPERTY, "false"); +- useJline = false; +- } +- +- if (options.has("noconsole")) { +- Main.useConsole = false; +- useJline = false; // Paper +- System.setProperty(net.minecrell.terminalconsole.TerminalConsoleAppender.JLINE_OVERRIDE_PROPERTY, "false"); // Paper +- } +- +- if (false && Main.class.getPackage().getImplementationVendor() != null && System.getProperty("IReallyKnowWhatIAmDoingISwear") == null) { // Purpur - Disable outdated build check +- Date buildDate = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").parse(Main.class.getPackage().getImplementationVendor()); // Paper +- +- Calendar deadline = Calendar.getInstance(); +- deadline.add(Calendar.DAY_OF_YEAR, -14); +- if (buildDate.before(deadline.getTime())) { +- // Paper start - This is some stupid bullshit +- System.err.println("*** Warning, you've not updated in a while! ***"); +- System.err.println("*** Please download a new build from https://papermc.io/downloads/paper ***"); +- // Paper end +- } +- } +- +- System.setProperty("library.jansi.version", "Paper"); // Paper - set meaningless jansi version to prevent git builds from crashing on Windows +- System.setProperty("jdk.console", "java.base"); // Paper - revert default console provider back to java.base so we can have our own jline +- +- io.papermc.paper.PaperBootstrap.boot(options); +- } catch (Throwable t) { +- t.printStackTrace(); +- } +- } ++ return parser; // DivineMC - Rebrand + } + } diff --git a/src/main/java/org/bukkit/craftbukkit/scheduler/CraftScheduler.java b/src/main/java/org/bukkit/craftbukkit/scheduler/CraftScheduler.java index 2e7c3d4befeb6256ce81ecaa9ed4e8fbcb21651e..a839dbbb72f48b8f8736d9f4693c528686570732 100644 --- a/src/main/java/org/bukkit/craftbukkit/scheduler/CraftScheduler.java diff --git a/divinemc-server/paper-patches/features/0002-Configuration.patch b/divinemc-server/paper-patches/features/0002-Configuration.patch index 2060404..ff40032 100644 --- a/divinemc-server/paper-patches/features/0002-Configuration.patch +++ b/divinemc-server/paper-patches/features/0002-Configuration.patch @@ -31,10 +31,10 @@ index 592e8a4c04ef5acda9fdfd1405d8ff4952396ada..c8c7fa0304e8eaf0d444fc0c9a36c00b Plugin[] pluginClone = pluginManager.getPlugins().clone(); // Paper diff --git a/src/main/java/org/bukkit/craftbukkit/Main.java b/src/main/java/org/bukkit/craftbukkit/Main.java -index cc4f4fbbe1cacce5f0f5500a8dda199aa0bcdf31..03fe7d98252b93f4625359f50552e3cf60e82b9c 100644 +index 7e90142cb65937103aa99fd011540086449c45c8..7653ba0259ddc930dc4e2af84636641d3dba6e7f 100644 --- a/src/main/java/org/bukkit/craftbukkit/Main.java +++ b/src/main/java/org/bukkit/craftbukkit/Main.java -@@ -172,6 +172,14 @@ public class Main { +@@ -166,6 +166,14 @@ public class Main { .describedAs("Yml file"); // Purpur end - Purpur config files diff --git a/divinemc-server/paper-patches/features/0009-Implement-loading-plugins-from-external-folder.patch b/divinemc-server/paper-patches/features/0009-Implement-loading-plugins-from-external-folder.patch index be8bd84..dfe2382 100644 --- a/divinemc-server/paper-patches/features/0009-Implement-loading-plugins-from-external-folder.patch +++ b/divinemc-server/paper-patches/features/0009-Implement-loading-plugins-from-external-folder.patch @@ -30,10 +30,10 @@ index 70413fddd23ca1165cb5090cce4fddcb1bbca93f..ae70b84e6473fa2ed94416bf4bef8849 @SuppressWarnings("unchecked") java.util.List files = ((java.util.List) optionSet.valuesOf("add-plugin")).stream().map(File::toPath).toList(); diff --git a/src/main/java/org/bukkit/craftbukkit/Main.java b/src/main/java/org/bukkit/craftbukkit/Main.java -index 03fe7d98252b93f4625359f50552e3cf60e82b9c..ff350b5744b586cad7c2f5e0a04770e76f039d39 100644 +index 7653ba0259ddc930dc4e2af84636641d3dba6e7f..1a3a5e8a9119af8ed9d13a61bc5dc7b3ee0b7a65 100644 --- a/src/main/java/org/bukkit/craftbukkit/Main.java +++ b/src/main/java/org/bukkit/craftbukkit/Main.java -@@ -180,6 +180,14 @@ public class Main { +@@ -174,6 +174,14 @@ public class Main { .describedAs("Yml file"); // DivineMC end - Configuration diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/DivineBootstrap.java b/divinemc-server/src/main/java/org/bxteam/divinemc/DivineBootstrap.java index cc9e5dd..52b7f04 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/DivineBootstrap.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/DivineBootstrap.java @@ -1,45 +1,92 @@ package org.bxteam.divinemc; import io.papermc.paper.ServerBuildInfo; +import joptsimple.OptionParser; import joptsimple.OptionSet; import net.minecraft.SharedConstants; import net.minecraft.server.Eula; -import net.minecraft.server.Main; +import org.bukkit.craftbukkit.CraftServer; +import org.bukkit.craftbukkit.Main; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.File; +import java.io.IOException; import java.lang.management.ManagementFactory; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; +@SuppressWarnings("DuplicatedCode") public class DivineBootstrap { private static final Logger LOGGER = LoggerFactory.getLogger("DivineBootstrap"); - public static void boot(final OptionSet options) { - SharedConstants.tryDetectVersion(); + public static OptionSet bootstrap(String[] args) { + OptionParser parser = Main.main(args); + OptionSet options = parser.parse(args); - io.papermc.paper.ServerBuildInfo info = io.papermc.paper.ServerBuildInfo.buildInfo(); - if (io.papermc.paper.ServerBuildInfoImpl.IS_EXPERIMENTAL) { - LOGGER.warn("Running an experimental version of {}, please proceed with caution.", info.brandName()); - } + if ((options == null) || (options.has("?"))) { + try { + parser.printHelpOn(System.out); + } catch (IOException ex) { + LOGGER.warn(ex.getMessage()); + } + } else if (options.has("v")) { + System.out.println(CraftServer.class.getPackage().getImplementationVersion()); + } else { + String path = new File(".").getAbsolutePath(); + if (path.contains("!") || path.contains("+")) { + System.err.println("Cannot run server in a directory with ! or + in the pathname. Please rename the affected folders and try again."); + System.exit(70); + } - Path path2 = Paths.get("eula.txt"); - Eula eula = new Eula(path2); - boolean eulaAgreed = Boolean.getBoolean("com.mojang.eula.agree"); - if (eulaAgreed) { - LOGGER.error("You have used the Spigot command line EULA agreement flag."); - LOGGER.error("By using this setting you are indicating your agreement to Mojang's EULA (https://aka.ms/MinecraftEULA)."); - LOGGER.error("If you do not agree to the above EULA please stop your server and remove this flag immediately."); - } - if (!eula.hasAgreedToEULA() && !eulaAgreed) { - LOGGER.info("You need to agree to the EULA in order to run the server. Go to eula.txt for more info."); - return; - } - System.out.println("Loading libraries, please wait..."); // Restore CraftBukkit log - getStartupVersionMessages().forEach(LOGGER::info); + boolean skip = Boolean.getBoolean("Paper.IgnoreJavaVersion"); + String javaVersionName = System.getProperty("java.version"); + boolean isPreRelease = javaVersionName.contains("-"); + if (isPreRelease) { + if (!skip) { + System.err.println("Unsupported Java detected (" + javaVersionName + "). You are running an unsupported, non official, version. Only general availability versions of Java are supported. Please update your Java version. See https://docs.papermc.io/paper/faq#unsupported-java-detected-what-do-i-do for more information."); + System.exit(70); + } - Main.main(options); + System.err.println("Unsupported Java detected ("+ javaVersionName + "), but the check was skipped. Proceed with caution! "); + } + + try { + if (options.has("nojline")) { + System.setProperty(net.minecrell.terminalconsole.TerminalConsoleAppender.JLINE_OVERRIDE_PROPERTY, "false"); + Main.useJline = false; + } + + if (options.has("noconsole")) { + Main.useConsole = false; + Main.useJline = false; + System.setProperty(net.minecrell.terminalconsole.TerminalConsoleAppender.JLINE_OVERRIDE_PROPERTY, "false"); // Paper + } + + System.setProperty("library.jansi.version", "Paper"); + System.setProperty("jdk.console", "java.base"); + + SharedConstants.tryDetectVersion(); + Path path2 = Paths.get("eula.txt"); + Eula eula = new Eula(path2); + boolean eulaAgreed = Boolean.getBoolean("com.mojang.eula.agree"); + if (eulaAgreed) { + LOGGER.error("You have used the Spigot command line EULA agreement flag."); + LOGGER.error("By using this setting you are indicating your agreement to Mojang's EULA (https://aka.ms/MinecraftEULA)."); + LOGGER.error("If you do not agree to the above EULA please stop your server and remove this flag immediately."); + } + if (!eula.hasAgreedToEULA() && !eulaAgreed) { + LOGGER.info("You need to agree to the EULA in order to run the server. Go to eula.txt for more info."); + System.exit(0); + } + + getStartupVersionMessages().forEach(LOGGER::info); + } catch (Throwable t) { + t.printStackTrace(); + } + } + return options; } private static List getStartupVersionMessages() { diff --git a/gradle.properties b/gradle.properties index 5a829c5..424615a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ mcVersion=1.21.7 purpurRef=c4e5604ca8e4a36d24f4eaa90de7fee768fb60b7 experimental=false -org.gradle.configuration-cache=true +#org.gradle.configuration-cache=true org.gradle.caching = true org.gradle.parallel = true org.gradle.vfs.watch = false diff --git a/scripts/releaseInfo.sh b/scripts/releaseInfo.sh index 3a8fb53..a2ad3a5 100644 --- a/scripts/releaseInfo.sh +++ b/scripts/releaseInfo.sh @@ -28,7 +28,7 @@ make_latest=$([ "$experimental" = "true" ] && echo "false" || echo "true") rm -f $changelog -mv divinemc-server/build/libs/divinemc-paperclip-"$version"-mojmap.jar "$jarName" +mv divinemc-server/build/libs/divinemc-shuttle-"$version"-mojmap.jar "$jarName" { echo "name=$divinemcid" echo "tag=$tagid" diff --git a/settings.gradle.kts b/settings.gradle.kts index 67c5e4d..474fed7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,7 +35,7 @@ if (!file(".git").exists()) { rootProject.name = "DivineMC" -for (name in listOf("divinemc-api", "divinemc-server")) { +for (name in listOf("divinemc-api", "divinemc-server", "shuttle")) { val projName = name.lowercase(Locale.ENGLISH) include(projName) findProject(":$projName")!!.projectDir = file(name) diff --git a/shuttle/build.gradle.kts b/shuttle/build.gradle.kts new file mode 100644 index 0000000..b6ecb04 --- /dev/null +++ b/shuttle/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + id("java") + id("application") + id("maven-publish") + id("com.gradleup.shadow") version "8.3.8" +} + +val mainClass = "org.bxteam.shuttle.Shuttle" + +repositories { + mavenCentral() +} + +dependencies { + implementation("io.sigpipe:jbsdiff:1.0") +} + +java { + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + withSourcesJar() +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + options.release = 21 +} + +tasks.jar { + val jar = tasks.named("shadowJar") + dependsOn(jar) + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from(zipTree(jar.map { it.outputs.files.singleFile })) + + manifest { + attributes( + "Main-Class" to mainClass, + "Enable-Native-Access" to "ALL-UNNAMED", + "Premain-Class" to "org.bxteam.shuttle.patch.InstrumentationManager", + "Agent-Class" to "org.bxteam.shuttle.patch.InstrumentationManager", + "Launcher-Agent-Class" to "org.bxteam.shuttle.patch.InstrumentationManager", + "Can-Redefine-Classes" to true, + "Can-Retransform-Classes" to true + ) + } +} + +project.setProperty("mainClassName", mainClass) + +tasks.shadowJar { + val prefix = "paperclip.libs" + listOf("org.apache", "org.tukaani", "io.sigpipe").forEach { pack -> + relocate(pack, "$prefix.$pack") + } + + exclude("META-INF/LICENSE.txt") + exclude("META-INF/NOTICE.txt") +} diff --git a/shuttle/src/main/java/org/bxteam/shuttle/Shuttle.java b/shuttle/src/main/java/org/bxteam/shuttle/Shuttle.java new file mode 100644 index 0000000..b6358cd --- /dev/null +++ b/shuttle/src/main/java/org/bxteam/shuttle/Shuttle.java @@ -0,0 +1,169 @@ +package org.bxteam.shuttle; + +import org.bxteam.shuttle.logger.Logger; +import org.bxteam.shuttle.patch.LibraryLoader; +import org.bxteam.shuttle.patch.PatchBuilder; + +import java.io.*; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public final class Shuttle { + private static final String MAIN_CLASS_RESOURCE = "/META-INF/main-class"; + private static final String VERSION_RESOURCE = "/version.json"; + private static final String PATCH_ONLY_PROPERTY = "paperclip.patchonly"; + private static final String BUNDLER_MAIN_CLASS_PROPERTY = "bundlerMainClass"; + private static final String BUNDLER_REPO_DIR_PROPERTY = "bundlerRepoDir"; + private static final String MAIN_THREAD_NAME = "main"; + + public static void main(String[] arguments) { + new Shuttle().run(arguments); + } + + private void run(String[] arguments) { + try { + String defaultMainClassName = readMainClass(); + String mainClassName = System.getProperty(BUNDLER_MAIN_CLASS_PROPERTY, defaultMainClassName); + String repoDir = System.getProperty(BUNDLER_REPO_DIR_PROPERTY, ""); + + setupDirectories(repoDir); + + Provider versionProvider = this::readVersionFromResource; + + executePatchingPhase(versionProvider); + + if (shouldExitAfterPatching()) { + System.exit(0); + } + + executeLibraryLoadingPhase(versionProvider); + + startTargetApplication(mainClassName, arguments); + } catch (Exception e) { + Logger.error("Failed to extract server libraries, exiting", e); + System.exit(1); + } + } + + private String readMainClass() throws IOException { + return readResourceContent(MAIN_CLASS_RESOURCE, BufferedReader::readLine); + } + + private String readVersionFromResource() throws IOException { + String jsonContent = readResourceContent(VERSION_RESOURCE, this::readAllLines); + return extractVersionFromJson(jsonContent); + } + + private T readResourceContent(String resourcePath, ResourceParser parser) throws IOException { + try (InputStream inputStream = getClass().getResourceAsStream(resourcePath)) { + if (inputStream == null) { + throw new IOException("Resource not found: " + resourcePath); + } + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + return parser.parse(reader); + } + } + } + + private String readAllLines(BufferedReader reader) throws IOException { + StringBuilder content = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + content.append(line); + } + return content.toString(); + } + + private String extractVersionFromJson(String jsonContent) throws IOException { + String prefix = "\"id\": \""; + int startIndex = jsonContent.indexOf(prefix); + + if (startIndex == -1) { + throw new IOException("Version ID not found in JSON content"); + } + + startIndex += prefix.length(); + int endIndex = jsonContent.indexOf("\"", startIndex); + + if (endIndex == -1) { + throw new IOException("Malformed version ID in JSON content"); + } + + return jsonContent.substring(startIndex, endIndex); + } + + private void setupDirectories(String repoDir) throws IOException { + if (!repoDir.isEmpty()) { + Path outputDir = Paths.get(repoDir); + Files.createDirectories(outputDir); + } + } + private void executePatchingPhase(Provider versionProvider) throws IOException { + new PatchBuilder().start(versionProvider); + } + + private boolean shouldExitAfterPatching() { + return Boolean.getBoolean(PATCH_ONLY_PROPERTY); + } + + private void executeLibraryLoadingPhase(Provider versionProvider) throws IOException { + new LibraryLoader().start(versionProvider); + } + + private void startTargetApplication(String mainClassName, String[] arguments) { + if (mainClassName == null || mainClassName.trim().isEmpty()) { + Logger.warn("No main class specified, exiting"); + return; + } + + Logger.info("Starting " + mainClassName); + + Thread applicationThread = new Thread(() -> { + try { + invokeMainMethod(mainClassName, arguments); + } catch (Throwable e) { + ExceptionHandler.INSTANCE.rethrow(e); + } + }, MAIN_THREAD_NAME); + + applicationThread.start(); + } + + private void invokeMainMethod(String mainClassName, String[] arguments) throws Throwable { + Class mainClass = Class.forName(mainClassName); + MethodHandle mainHandle = MethodHandles.lookup() + .findStatic(mainClass, "main", MethodType.methodType(void.class, String[].class)) + .asFixedArity(); + + mainHandle.invoke((Object) arguments); + } + + @FunctionalInterface + private interface ResourceParser { + T parse(BufferedReader reader) throws IOException; + } + + @FunctionalInterface + public interface Provider { + T get() throws IOException; + } + + private static final class ExceptionHandler { + static final ExceptionHandler INSTANCE = new ExceptionHandler(); + + private ExceptionHandler() { + throw new UnsupportedOperationException("Utility class cannot be instantiated"); + } + + void rethrow(Throwable exception) { + throw new RuntimeException("Exception in target application", exception); + } + } +} diff --git a/shuttle/src/main/java/org/bxteam/shuttle/logger/Logger.java b/shuttle/src/main/java/org/bxteam/shuttle/logger/Logger.java new file mode 100644 index 0000000..fc2765b --- /dev/null +++ b/shuttle/src/main/java/org/bxteam/shuttle/logger/Logger.java @@ -0,0 +1,73 @@ +package org.bxteam.shuttle.logger; + +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.ConsoleHandler; +import java.util.logging.Formatter; + +public class Logger { + private static final java.util.logging.Logger LOGGER = java.util.logging.Logger.getLogger("Shuttle"); + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); + + static { + LOGGER.setUseParentHandlers(false); + + ConsoleHandler consoleHandler = new ConsoleHandler(); + consoleHandler.setFormatter(new ShuttleFormatter()); + consoleHandler.setLevel(Level.ALL); + LOGGER.addHandler(consoleHandler); + + LOGGER.setLevel(Level.INFO); + } + + public static void info(String message) { + LOGGER.info(message); + } + + public static void warn(String message) { + LOGGER.warning(message); + } + + public static void error(String message) { + LOGGER.severe(message); + } + + public static void error(String message, Throwable throwable) { + LOGGER.log(Level.SEVERE, message, throwable); + } + + public static void debug(String message) { + LOGGER.fine(message); + } + + public static void setLevel(Level level) { + LOGGER.setLevel(level); + LOGGER.getHandlers()[0].setLevel(level); + } + + private static class ShuttleFormatter extends Formatter { + @Override + public String format(LogRecord record) { + String time = LocalTime.now().format(TIME_FORMATTER); + String level = record.getLevel().getName(); + String message = record.getMessage(); + + StringBuilder sb = new StringBuilder(); + sb.append("[").append(time).append(" ").append(level).append("]: "); + sb.append("[Shuttle] "); + sb.append(message); + sb.append(System.lineSeparator()); + + if (record.getThrown() != null) { + java.io.StringWriter sw = new java.io.StringWriter(); + java.io.PrintWriter pw = new java.io.PrintWriter(sw); + record.getThrown().printStackTrace(pw); + sb.append(sw.toString()); + } + + return sb.toString(); + } + } +} diff --git a/shuttle/src/main/java/org/bxteam/shuttle/patch/InstrumentationManager.java b/shuttle/src/main/java/org/bxteam/shuttle/patch/InstrumentationManager.java new file mode 100644 index 0000000..6671d5f --- /dev/null +++ b/shuttle/src/main/java/org/bxteam/shuttle/patch/InstrumentationManager.java @@ -0,0 +1,39 @@ +package org.bxteam.shuttle.patch; + +import java.lang.instrument.Instrumentation; + +public final class InstrumentationManager { + private static volatile Instrumentation instrumentation; + private static final Object LOCK = new Object(); + + private InstrumentationManager() { + throw new UnsupportedOperationException("Utility class cannot be instantiated"); + } + + public static void premain(final String agentArgs, final Instrumentation inst) { + agentmain(agentArgs, inst); + } + + public static void agentmain(final String agentArgs, final Instrumentation inst) { + if (inst == null) { + throw new IllegalArgumentException("Instrumentation instance cannot be null"); + } + + synchronized (LOCK) { + if (instrumentation == null) { + instrumentation = inst; + } + } + } + + public static Instrumentation getInstrumentation() { + final Instrumentation inst = instrumentation; + if (inst == null) { + throw new IllegalStateException( + "Instrumentation has not been initialized. " + + "Ensure the agent is properly loaded via -javaagent or attach mechanism." + ); + } + return inst; + } +} diff --git a/shuttle/src/main/java/org/bxteam/shuttle/patch/LibraryLoader.java b/shuttle/src/main/java/org/bxteam/shuttle/patch/LibraryLoader.java new file mode 100644 index 0000000..d466496 --- /dev/null +++ b/shuttle/src/main/java/org/bxteam/shuttle/patch/LibraryLoader.java @@ -0,0 +1,255 @@ +package org.bxteam.shuttle.patch; + +import org.bxteam.shuttle.Shuttle; +import org.bxteam.shuttle.logger.Logger; + +import java.io.*; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLConnection; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Enumeration; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +public class LibraryLoader { + private static final String LIBRARIES_DIR = "libraries"; + private static final String CACHE_DIR = "cache"; + private static final String LIBRARIES_LIST_RESOURCE = "/META-INF/libraries.list"; + private static final String LIBRARIES_PREFIX = "META-INF/libraries/"; + private static final String PATCH_EXTENSION = ".patch"; + private static final int BUFFER_SIZE = 8192; + + private static final List MAVEN_REPOSITORIES = List.of( + "https://repo.papermc.io/repository/maven-public/", + "https://jitpack.io", + "https://s01.oss.sonatype.org/content/repositories/snapshots/", + "https://repo1.maven.org/maven2/", + "https://libraries.minecraft.net/" + ); + + private static void copyStream(InputStream input, OutputStream output) throws IOException { + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = input.read(buffer)) != -1) { + output.write(buffer, 0, bytesRead); + } + } + + public void start(Shuttle.Provider versionProvider) throws IOException { + try { + start(versionProvider.get()); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("Error during library loading process", e); + } + } + + public void start(String mcVersion) throws IOException { + if (mcVersion == null || mcVersion.trim().isEmpty()) { + throw new IllegalArgumentException("Minecraft version cannot be null or empty"); + } + + Logger.info("Unpacking and linking library jars"); + + try { + createLibrariesDirectory(); + + Path currentJar = getCurrentJarPath(); + extractLibrariesFromJar(currentJar); + + Path vanillaBundler = Paths.get(CACHE_DIR, "vanilla-bundler-" + mcVersion + ".jar"); + if (Files.exists(vanillaBundler)) { + extractLibrariesFromJar(vanillaBundler); + } + } catch (Exception e) { + throw new IOException("Failed to load libraries for version " + mcVersion, e); + } + } + + private void createLibrariesDirectory() throws IOException { + Path librariesDir = Paths.get(LIBRARIES_DIR); + if (!Files.exists(librariesDir)) { + Files.createDirectories(librariesDir); + } + } + + private Path getCurrentJarPath() throws IOException { + try { + return Paths.get(LibraryLoader.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()); + } catch (URISyntaxException e) { + throw new IOException("Failed to get current JAR path", e); + } + } + + private void extractLibrariesFromJar(Path jarPath) throws IOException { + try (JarFile jarFile = new JarFile(jarPath.toFile())) { + Enumeration entries = jarFile.entries(); + + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + + if (entry.getName().startsWith(LIBRARIES_PREFIX)) { + processLibraryEntry(jarFile, entry); + } + } + } + } + + private void processLibraryEntry(JarFile jarFile, JarEntry entry) throws IOException { + String relativePath = entry.getName().substring(LIBRARIES_PREFIX.length()); + File extractedFile = new File(LIBRARIES_DIR, relativePath); + + if (entry.isDirectory()) { + Files.createDirectories(extractedFile.toPath()); + } else if (entry.getName().endsWith(PATCH_EXTENSION)) { + processPatchEntry(entry); + } else { + extractLibraryFile(jarFile, entry, extractedFile); + } + } + + private void extractLibraryFile(JarFile jarFile, JarEntry entry, File extractedFile) throws IOException { + File parentDir = extractedFile.getParentFile(); + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs(); + } + + try (InputStream input = jarFile.getInputStream(entry); + FileOutputStream output = new FileOutputStream(extractedFile)) { + copyStream(input, output); + } + + addToClasspath(extractedFile); + } + + private void processPatchEntry(JarEntry entry) throws IOException { + try (InputStream inputStream = LibraryLoader.class.getResourceAsStream(LIBRARIES_LIST_RESOURCE); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + + if (inputStream == null) { + throw new IOException("Libraries list resource not found: " + LIBRARIES_LIST_RESOURCE); + } + + String entryDirectoryName = extractDirectoryName(entry.getName()); + boolean foundArtifact = false; + String line; + + while ((line = reader.readLine()) != null) { + LibraryInfo libraryInfo = parseLibraryLine(line); + if (libraryInfo == null) continue; + + String libraryDirectoryName = extractDirectoryName(libraryInfo.jarPath); + + if (entryDirectoryName.equalsIgnoreCase(libraryDirectoryName)) { + foundArtifact = true; + handleLibraryFromPatch(libraryInfo); + } + } + + if (!foundArtifact) { + String artifactName = extractArtifactName(entry.getName()); + Logger.error("Unable to find library: " + artifactName); + throw new RuntimeException("Missing library: " + artifactName); + } + } + } + + private String extractDirectoryName(String path) { + return path.replaceFirst("^" + LIBRARIES_PREFIX, "") + .replaceFirst("/[^/]+$", ""); + } + + private String extractArtifactName(String entryName) { + String[] parts = entryName.split("/"); + String fileName = parts[parts.length - 1]; + return fileName.replace(PATCH_EXTENSION, ""); + } + + private LibraryInfo parseLibraryLine(String line) { + if (line == null || line.trim().isEmpty()) { + return null; + } + + String[] parts = line.split("\\s+"); + if (parts.length < 3) { + return null; + } + + return new LibraryInfo(parts[1], parts[2]); + } + + private void handleLibraryFromPatch(LibraryInfo libraryInfo) throws IOException { + File libraryFile = new File(LIBRARIES_DIR, libraryInfo.jarPath); + + if (!libraryFile.exists()) { + File downloadedFile = downloadLibrary(libraryInfo.jarPath); + + if (downloadedFile == null) { + throw new IOException("Failed to download missing library: " + libraryInfo.artifact); + } + + libraryFile = downloadedFile; + } + + addToClasspath(libraryFile); + } + + private File downloadLibrary(String artifactPath) { + for (String repository : MAVEN_REPOSITORIES) { + try { + String downloadUrl = repository + artifactPath; + File downloadedFile = new File(LIBRARIES_DIR, artifactPath); + + if (downloadFile(downloadUrl, downloadedFile)) { + return downloadedFile; + } + } catch (Exception e) { + // Continue trying other repositories + } + } + + return null; + } + + private boolean downloadFile(String urlString, File outputFile) { + try { + File parentDir = outputFile.getParentFile(); + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs(); + } + + URL url = new URL(urlString); + URLConnection connection = url.openConnection(); + connection.setConnectTimeout(30000); + connection.setReadTimeout(60000); + + try (InputStream input = connection.getInputStream(); + FileOutputStream output = new FileOutputStream(outputFile)) { + copyStream(input, output); + } + + return outputFile.exists() && outputFile.length() > 0; + + } catch (Exception e) { + if (outputFile.exists()) { + outputFile.delete(); + } + + return false; + } + } + + private void addToClasspath(File jarFile) throws IOException { + try (JarFile jar = new JarFile(jarFile)) { + InstrumentationManager.getInstrumentation().appendToSystemClassLoaderSearch(jar); + } + } + + private record LibraryInfo(String artifact, String jarPath) { } +} diff --git a/shuttle/src/main/java/org/bxteam/shuttle/patch/PatchBuilder.java b/shuttle/src/main/java/org/bxteam/shuttle/patch/PatchBuilder.java new file mode 100644 index 0000000..1a8b74c --- /dev/null +++ b/shuttle/src/main/java/org/bxteam/shuttle/patch/PatchBuilder.java @@ -0,0 +1,248 @@ +package org.bxteam.shuttle.patch; + +import io.sigpipe.jbsdiff.InvalidHeaderException; +import io.sigpipe.jbsdiff.Patch; +import org.apache.commons.compress.compressors.CompressorException; +import org.bxteam.shuttle.Shuttle; +import org.bxteam.shuttle.logger.Logger; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +public class PatchBuilder { + private static final String CACHE_DIR = "cache"; + private static final String VERSIONS_DIR = "versions"; + private static final String META_INF_PREFIX = "/META-INF/"; + private static final int BUFFER_SIZE = 8192; + + private static String readResourceField(int index, String resourceName) { + final String resourcePath = META_INF_PREFIX + resourceName; + + try (InputStream inputStream = PatchBuilder.class.getResourceAsStream(resourcePath); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + + if (inputStream == null) { + throw new IOException("Resource not found: " + resourcePath); + } + + String line = reader.readLine(); + if (line == null) { + throw new IOException("Empty resource file: " + resourcePath); + } + + String[] parts = line.split("\\s+"); + if (parts.length <= index) { + throw new IOException("Invalid resource format or index out of bounds: " + resourcePath); + } + + return parts[index].trim(); + + } catch (IOException e) { + throw new RuntimeException("Unable to read " + resourceName + " at index " + index, e); + } + } + + public static String computeFileSha256(File file) throws NoSuchAlgorithmException, IOException { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + + try (FileInputStream fis = new FileInputStream(file); + BufferedInputStream bis = new BufferedInputStream(fis)) { + + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + + while ((bytesRead = bis.read(buffer)) != -1) { + digest.update(buffer, 0, bytesRead); + } + } + + byte[] hashBytes = digest.digest(); + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + hexString.append(String.format("%02x", b)); + } + + return hexString.toString(); + } + + public void start(Shuttle.Provider versionProvider) throws IOException { + try { + start(versionProvider.get()); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("Error during patch building process", e); + } + } + + public void start(String mcVersion) throws IOException { + if (mcVersion == null || mcVersion.trim().isEmpty()) { + throw new IllegalArgumentException("Minecraft version cannot be null or empty"); + } + + Logger.info("Loading Minecraft version " + mcVersion); + + try { + createDirectories(); + + String sha256Hash = readResourceField(0, "download-context"); + String vanillaUrl = readResourceField(1, "download-context"); + + Path vanillaBundler = downloadVanillaBundler(mcVersion, vanillaUrl, sha256Hash); + String patchedJarName = extractPatchedJarName(); + + applyPatches(mcVersion, vanillaBundler, patchedJarName); + } catch (Exception e) { + throw new IOException("Failed to build patched jar for version " + mcVersion, e); + } + } + + private void createDirectories() throws IOException { + Files.createDirectories(Paths.get(VERSIONS_DIR)); + Files.createDirectories(Paths.get(CACHE_DIR)); + } + + private Path downloadVanillaBundler(String mcVersion, String vanillaUrl, String expectedSha256) throws IOException { + Path vanillaBundler = Paths.get(CACHE_DIR, "vanilla-bundler-" + mcVersion + ".jar"); + + boolean needsDownload = !Files.exists(vanillaBundler); + + if (!needsDownload) { + try { + String actualSha256 = computeFileSha256(vanillaBundler.toFile()); + needsDownload = !expectedSha256.equals(actualSha256); + if (needsDownload) { + Logger.info("SHA-256 mismatch, re-downloading vanilla jar"); + } + } catch (NoSuchAlgorithmException e) { + throw new IOException("SHA-256 algorithm not available", e); + } + } + + if (needsDownload) { + Logger.info("Downloading vanilla jar..."); + downloadFile(vanillaUrl, vanillaBundler); + } + + return vanillaBundler; + } + + /** + * Downloads a file from a URL. + */ + private void downloadFile(String urlString, Path outputPath) throws IOException { + URL url = new URL(urlString); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(30000); + connection.setReadTimeout(60000); + + try (BufferedInputStream in = new BufferedInputStream(connection.getInputStream()); + FileOutputStream fos = new FileOutputStream(outputPath.toFile()); + BufferedOutputStream out = new BufferedOutputStream(fos)) { + + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + } finally { + connection.disconnect(); + } + } + + private String extractPatchedJarName() { + String versionListEntry = readResourceField(2, "versions.list"); + String[] parts = versionListEntry.split("/"); + if (parts.length < 2) { + throw new RuntimeException("Invalid versions.list format"); + } + return parts[1]; + } + + private void applyPatches(String mcVersion, Path vanillaBundler, String patchedJarName) throws IOException { + Logger.info("Applying patches..."); + + Path vanillaJar = extractVanillaJar(mcVersion, vanillaBundler); + + File patchFile = extractPatchFile(mcVersion); + + Path outputJar = Paths.get(VERSIONS_DIR, mcVersion, patchedJarName); + Files.createDirectories(outputJar.getParent()); + + try { + applyPatch(vanillaJar.toFile(), patchFile, outputJar.toFile()); + addToClasspath(outputJar.toFile()); + } catch (Exception e) { + throw new IOException("Failed to apply patch", e); + } + } + + private Path extractVanillaJar(String mcVersion, Path vanillaBundler) throws IOException { + Path vanillaJar = Paths.get(CACHE_DIR, "vanilla-" + mcVersion + ".jar"); + + try (JarFile jarFile = new JarFile(vanillaBundler.toFile())) { + JarEntry entry = jarFile.getJarEntry("META-INF/versions/" + mcVersion + "/server-" + mcVersion + ".jar"); + + if (entry == null) { + throw new IOException("Vanilla jar entry not found in bundler for version " + mcVersion); + } + + try (InputStream inputStream = jarFile.getInputStream(entry)) { + Files.copy(inputStream, vanillaJar, StandardCopyOption.REPLACE_EXISTING); + } + } + + return vanillaJar; + } + + private File extractPatchFile(String mcVersion) throws IOException { + String resourcePath = "/META-INF/versions/" + mcVersion + "/server-" + mcVersion + ".jar.patch"; + return extractResourceToFile(resourcePath, CACHE_DIR); + } + + private File extractResourceToFile(String resourcePath, String outputDir) throws IOException { + try (InputStream resourceStream = PatchBuilder.class.getResourceAsStream(resourcePath)) { + if (resourceStream == null) { + throw new IOException("Resource not found: " + resourcePath); + } + + Path outputDirectory = Paths.get(outputDir); + Files.createDirectories(outputDirectory); + + String fileName = Paths.get(resourcePath).getFileName().toString(); + Path outputPath = outputDirectory.resolve(fileName); + + Files.copy(resourceStream, outputPath, StandardCopyOption.REPLACE_EXISTING); + return outputPath.toFile(); + } + } + + private void applyPatch(File vanillaJar, File patchFile, File outputJar) throws IOException, CompressorException, InvalidHeaderException { + byte[] vanillaBytes = Files.readAllBytes(vanillaJar.toPath()); + byte[] patchBytes = Files.readAllBytes(patchFile.toPath()); + + try (FileOutputStream outputStream = new FileOutputStream(outputJar)) { + Patch.patch(vanillaBytes, patchBytes, outputStream); + } + + if (!outputJar.exists()) { + throw new IOException("Patched jar was not created successfully"); + } + } + + private void addToClasspath(File jarFile) throws IOException { + try (JarFile jar = new JarFile(jarFile)) { + InstrumentationManager.getInstrumentation().appendToSystemClassLoaderSearch(jar); + } + } +}