diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..f91f6460 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + +# Binary files should be left untouched +*.jar binary + diff --git a/.github/workflows/ReadMe.md b/.github/workflows/ReadMe.md deleted file mode 100644 index d9d0e536..00000000 --- a/.github/workflows/ReadMe.md +++ /dev/null @@ -1,9 +0,0 @@ -# GitHub Releaser - -Every time you make+push a new git tag, it'll create a GitHub release for you. - -## Steps - -1. Commit and push some changes, then create and push a new tag. - -Optionally, you may edit "blacklist.txt", which contains regex expressions of files that will NOT be included in the released mod folder (eg psd files) diff --git a/.github/workflows/blacklist.txt b/.github/workflows/blacklist.txt deleted file mode 100644 index e66294ce..00000000 --- a/.github/workflows/blacklist.txt +++ /dev/null @@ -1,15 +0,0 @@ -.psd$ -.ai$ -.afphoto$ -^.circleci -DO_NOT_USE -.gradle -^gradle -.flac$ -banner.png$ -.github/ -^media/ -^release_checklist.md$ -^docs/ -^dokka/ -^lib/ \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 8c9e2544..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: GitHub Release - -on: - # Triggers the workflow on pushing a tag only - push: - tags: "*" - branches: - - "!*" - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -jobs: - build: - # The type of runner that the job will run on - runs-on: ubuntu-latest - - steps: - # Checks-out repository under $GITHUB_WORKSPACE, so your job can access it - # GitHub Actions by default doesn't check out tags, so make sure those are included - - name: Checkout - uses: actions/checkout@v3 - - - name: Fetch Tags - run: git fetch --prune --unshallow --tags - - # Tags with unstable, dev, qa in them will be marked as prerelease. - - name: Set Variables - run: | - echo "VERSION=$(git describe --tags)" >> $GITHUB_ENV - git log --format=%B -n 1 $(git log -1 --pretty=format:"%h") | cat - > changes.txt - if [[ "$(git describe --tags)" =~ .*"dev"|"qa"|"unstable".* ]]; then - echo "IS_PRERELEASE=true" >> $GITHUB_ENV; - else - echo "IS_PRERELEASE=false" >> $GITHUB_ENV; - fi - - - name: Create Mod Zipfile - run: | - cd ./.github/workflows/ - chmod +x ./runner.sh - ./runner.sh - - - name: Publish Release on GitHub - uses: "ncipollo/release-action@v1.12.0" - with: - name: ${{ env.VERSION }} - tag: ${{ env.VERSION }} - bodyFile: changes.txt - draft: false - prerelease: '${{ env.IS_PRERELEASE }}' - token: ${{ secrets.GITHUB_TOKEN }} - artifacts: ".github/workflows/artifacts/*" diff --git a/.github/workflows/runner.sh b/.github/workflows/runner.sh deleted file mode 100644 index 8dc54542..00000000 --- a/.github/workflows/runner.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh - -### Edit -# Your mod name. The version number will be attached to this to form "My-Mod" -MOD_FOLDER_NAME="MagicLib" -echo "Folder name will be $MOD_FOLDER_NAME" -### - - -chmod +x ./zipMod.sh -sh ./zipMod.sh "./../.." "$MOD_FOLDER_NAME" \ No newline at end of file diff --git a/.github/workflows/zipMod.sh b/.github/workflows/zipMod.sh deleted file mode 100644 index c34b0454..00000000 --- a/.github/workflows/zipMod.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/sh - -# USAGE -# Run from within the -# ./zipMod.sh "folderToZip" "outputFolderName" -# arg folderToZip: The folder containing mod_info.json. -# arg outputFolderName - -folderToZip=$1 -outputFolderName=$2 -startingDir=$(pwd) - -echo "Executing dir: $startingDir" -echo "Location of folder to zip:" $(realpath $1) - -cd $folderToZip -zipName=$outputFolderName.zip - - -# Recreate the temp folder if it happens to be present. -rm -rf "./$outputFolderName" -mkdir "$outputFolderName" - -# 1. List all files in git, which uses gitignore. -# 2. Remove any file matching the blacklist (eg afphoto files). -# 3. Copy to a new folder with the mod name. -echo "Reading blacklist...\n$(cat $startingDir/blacklist.txt)" -git ls-files | grep -Evf $startingDir/blacklist.txt | while read file; do cp --parents "$file" "$outputFolderName"; done - -# Zip the folder, then clean it up. -zip -r $zipName "./$outputFolderName" -echo "Created zip file at $(realpath $zipName)" -rm -rf "./$outputFolderName" - -# Move the zip to the artifacts folder in the executing directory. -mkdir -p $startingDir/artifacts -mv ./$zipName $startingDir/artifacts/ -echo "Moved zip file to $(realpath $startingDir/artifacts/$zipName)" - -# Return to the executing directory. -cd $startingDir \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0a0c0777..89dfc954 100644 --- a/.gitignore +++ b/.gitignore @@ -1,87 +1,11 @@ -# Created by https://www.gitignore.io/api/java,kotlin,intellij -# Edit at https://www.gitignore.io/?templates=java,kotlin,intellij - -### Intellij ### -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf -.idea/kotlinScripting.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# IntelliJ -out/ - -### Java ### -# Compiled class file -*.class - -# Log file -*.log - -# BlueJ files -*.ctxt - -# Mobile Tools for Java (J2ME) -.mtj.tmp/ - -# Package Files # -#*.jar -*.war -*.nar -*.ear -*.zip -*.tar.gz -*.rar - -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* - -# Created by https://www.gitignore.io/api/gradle -# Edit at https://www.gitignore.io/?templates=gradle - -### Gradle ### -.gradle -/build/ - -# Ignore Gradle GUI config -gradle-app.setting - -# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) -!gradle-wrapper.jar - -# Cache of project -.gradletasknamecache - -# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 -# gradle/wrapper/gradle-wrapper.properties - -### Gradle Patch ### -**/build/ - -# End of https://www.gitignore.io/api/gradle - - -# Custom for MagicLib -.idea/csv-plugin.xml -.idea/csv-editor.xml +.gradle + +/.kotlin/ +/.idea/ +/jars/ +/build/ + +/out/ +*.iml + +*.zip diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 1370e297..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -csv-plugin.xml# GitHub Copilot persisted chat sessions -/copilot/chatSessions diff --git a/.idea/MagicLib.iml b/.idea/MagicLib.iml deleted file mode 100644 index d6ebd480..00000000 --- a/.idea/MagicLib.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/ant.xml b/.idea/ant.xml deleted file mode 100644 index a2a47698..00000000 --- a/.idea/ant.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/artifacts/MagicLib.xml b/.idea/artifacts/MagicLib.xml deleted file mode 100644 index f90a74ff..00000000 --- a/.idea/artifacts/MagicLib.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - $PROJECT_DIR$/jars - - - - - - \ No newline at end of file diff --git a/.idea/artifacts/MagicLib_Kotlin.xml b/.idea/artifacts/MagicLib_Kotlin.xml deleted file mode 100644 index 3f7a0689..00000000 --- a/.idea/artifacts/MagicLib_Kotlin.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - $PROJECT_DIR$/jars - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 1bec35e5..00000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index 79ee123c..00000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/icon.svg b/.idea/icon.svg deleted file mode 100644 index c345755d..00000000 --- a/.idea/icon.svg +++ /dev/null @@ -1,2732 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index cf247ce4..00000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml deleted file mode 100644 index dc83faa5..00000000 --- a/.idea/kotlinc.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 0773fa01..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 01c8b270..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Run_Dokka_for_MagicLib.xml b/.idea/runConfigurations/Run_Dokka_for_MagicLib.xml deleted file mode 100644 index e52730a1..00000000 --- a/.idea/runConfigurations/Run_Dokka_for_MagicLib.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Run_Starsector_Java23.xml b/.idea/runConfigurations/Run_Starsector_Java23.xml deleted file mode 100644 index 8161db9d..00000000 --- a/.idea/runConfigurations/Run_Starsector_Java23.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Update_Dokka_config.xml b/.idea/runConfigurations/Update_Dokka_config.xml deleted file mode 100644 index 88b3f439..00000000 --- a/.idea/runConfigurations/Update_Dokka_config.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 63e6ac6b..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.run/Run Starsector w_o Launcher.run.xml b/.run/Run Starsector w_o Launcher.run.xml new file mode 100644 index 00000000..48e93f75 --- /dev/null +++ b/.run/Run Starsector w_o Launcher.run.xml @@ -0,0 +1,27 @@ + + + + + + + false + true + false + false + false + false + false + + + \ No newline at end of file diff --git a/.run/Run Starsector.run.xml b/.run/Run Starsector.run.xml index bf77367a..795ce0d4 100644 --- a/.run/Run Starsector.run.xml +++ b/.run/Run Starsector.run.xml @@ -1,18 +1,27 @@ - - \ No newline at end of file diff --git a/.run/Run_Starsector_w_o_Launcher.run.xml b/.run/Run_Starsector_w_o_Launcher.run.xml deleted file mode 100644 index dbdef965..00000000 --- a/.run/Run_Starsector_w_o_Launcher.run.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - \ No newline at end of file diff --git a/CommunityApiDocs b/CommunityApiDocs deleted file mode 160000 index 697dd59d..00000000 --- a/CommunityApiDocs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 697dd59d2483222eefe96097c47f0afc4fd29fa0 diff --git a/GitHub-Actions-Starsector.zip b/GitHub-Actions-Starsector.zip deleted file mode 100644 index a836c333..00000000 Binary files a/GitHub-Actions-Starsector.zip and /dev/null differ diff --git a/MagicLib-Kotlin/MagicLib-Kotlin.iml b/MagicLib-Kotlin/MagicLib-Kotlin.iml deleted file mode 100644 index 143b91ab..00000000 --- a/MagicLib-Kotlin/MagicLib-Kotlin.iml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/MagicLib.iml b/MagicLib.iml deleted file mode 100644 index b6f66237..00000000 --- a/MagicLib.iml +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..dd512e2e --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,658 @@ +import java.util.zip.ZipFile + +/* + * ------------------------------------------------------------ + * USER CONFIGURATION lives in settings.gradle.kts. + * Edit the values there, not here. settings.gradle.kts publishes them onto `gradle.extra`, and we just read them back out below + * so the rest of this file (the build pipeline) doesn't need to change or know where the config actually lives. + * ------------------------------------------------------------ + */ +val starsectorPath = gradle.extra["starsectorPath"] as String +val jarName = gradle.extra["jarName"] as String +val zipName = gradle.extra["zipName"] as String +@Suppress("UNCHECKED_CAST") +val modDependencies = gradle.extra["modDependencies"] as List +@Suppress("UNCHECKED_CAST") +val packageIncludes = gradle.extra["packageIncludes"] as List +@Suppress("UNCHECKED_CAST") +val packageIncludeExtensions = gradle.extra["packageIncludeExtensions"] as List +@Suppress("UNCHECKED_CAST") +val otherDependencies = gradle.extra["otherDependencies"] as List +val libsFolder = gradle.extra["libsFolder"] as String +val devResolution = gradle.extra["devResolution"] as String +val javaVersion = gradle.extra["javaVersion"] as Int +val isLibrary = gradle.extra["isLibrary"] as Boolean + + + + + + + + +/// BUILD PIPELINE +/// In Most cases, you should not need to change anything below here. + + + + +// Workaround for a Gradle issue: the Kotlin compiler can try to write its session-alive flag file before build/.kotlin/sessions/ exists. +// This creates the folder preemptively before compilation to avoid that issue. +layout.buildDirectory.dir(".kotlin/sessions").get().asFile.mkdirs() + +//Local Maven repo where mod-dependency jars get staged, along with a matching "-sources.jar" +//(see stageModDependency / addModJars below). Declared up here (rather than next to docsRepoDir) +//because it needs to be initialized before the dependencies{} block runs, which happens earlier +//in this file. +val modDepsRepoDir = layout.buildDirectory.dir("modDepsRepo").get().asFile + +dependencies { + addModJars(modDependencies) + otherDependencies.forEach { addCompileOnlyJar(it) } + + //Loads basic starsector dependencies. + addStarsectorCoreDependencies() +} + +fun DependencyHandler.addStarsectorCoreDependencies() { + + //Starsectors core jars live in different folders per OS, so look them up through the layout. + val coreDir = starsectorLayout().gameWorkingDir + + //Starsector. The API jar comes through the local Maven repo (see repositories block) so IntelliJ can attach its source. + //starfarer_obf is obfuscated with no source available, so it stays a plain file dependency. + compileOnly("com.fs.starfarer:starfarer-api:local") + + //All other core jars in one files(...) call. + compileOnly(files( + File(coreDir, "starfarer_obf.jar"), + File(coreDir, "commons-compiler.jar"), + File(coreDir, "commons-compiler-jdk.jar"), + File(coreDir, "fs.common_obf.jar"), + File(coreDir, "fs.sound_obf.jar"), + File(coreDir, "janino.jar"), + File(coreDir, "jaxb-api-2.4.0-b180830.0359.jar"), + File(coreDir, "jaxb-api-2.4.0-b180830.0359-sources.jar"), + File(coreDir, "jinput.jar"), + File(coreDir, "jogg-0.0.7.jar"), + File(coreDir, "jorbis-0.0.15.jar"), + File(coreDir, "json.jar"), + File(coreDir, "log4j-1.2.9.jar"), + File(coreDir, "lwjgl.jar"), + File(coreDir, "lwjgl_util.jar"), + File(coreDir, "txw2-3.0.2.jar"), + File(coreDir, "webp-imageio-0.1.6.jar"), + File(coreDir, "xstream-1.4.10.jar"), + )) +} + + + +plugins { + // Apply the org.jetbrains.kotlin.jvm Plugin to add support for Kotlin. + alias(libs.plugins.kotlin.jvm) + + // Apply the java-library plugin for API and implementation separation. + `java-library` + + // The built-in `idea` plugin lets us steer IntelliJ's module config from this script, + // namely the compile-output dirs (see the `idea { ... }` block below). + idea +} + +// Move IntelliJ's compiled output from out/ to build/idea-out/ so we only have one top-level +// build folder. +idea { + module { + outputDir = file("build/idea-out/main") + testOutputDir = file("build/idea-out/test") + } +} + +repositories { + // Use Maven Central for resolving dependencies. + mavenCentral() + + //Local Maven repo of staged Starsector API artifacts. The maven layout (vs flatDir) is what + //actually lets IntelliJ pick up the "-sources.jar" sibling for autocomplete and navigation. + maven { url = uri(stageStarsectorApi()) } + + //Local Maven repo of staged mod-dependency jars (see addModJars/stageModDependency). Same trick as + //above: each mod jar is also staged under a matching "-sources.jar" name so IntelliJ attaches + //docs/navigation for it. Starsector mod jars already bundle their .java/.kt source files + //alongside the .class files, so the jar itself works fine as its own "sources" jar. + maven { url = uri(modDepsRepoDir) } +} + +// Apply a specific Java toolchain to ease working on different environments. +java { + toolchain { + languageVersion = JavaLanguageVersion.of(javaVersion) + } +} + +sourceSets { + main { + java { + setSrcDirs(listOf("src")) + } + kotlin { + setSrcDirs(listOf("src")) + } + } +} + +//Build in parameter names, in case another mod needs to check out the code without having source access. +tasks.withType().configureEach { + options.compilerArgs.add("-parameters") +} +kotlin { + compilerOptions { + javaParameters = true + } +} + +tasks.test { + enabled = false +} + +tasks.jar { + destinationDirectory.set(file("$rootDir/jars")) + archiveFileName.set(jarName) + + if (isLibrary) { + //Includes the .java and .kt sources for documentation detection + from(sourceSets.main.get().allSource) { + include("**/*.java", "**/*.kt") + } + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + } +} + +fun DependencyHandler.addModJars(jarNames: List) { + if (jarNames.isEmpty()) return + + val modsDir = file("$starsectorPath/mods/") + // Exclude this project's own folder. Otherwise, the configuration cache + // treats the mods own /jars/ directory listing as a config-time input, and + // every rebuild of the mod jar invalidates the cache. + val thisProjectFolder = projectDir.name + val modJarFiles = fileTree(modsDir) { + jarNames.forEach { include("*/jars/**/$it") } + exclude("$thisProjectFolder/**") + } + + // Also look inside the local libs folder, if present. Matched by filename, recursively. + val libsDir = file(libsFolder) + val libsJarFiles = if (libsDir.exists()) { + fileTree(libsDir) { + jarNames.forEach { include("**/$it") } + } + } else { + files() + } + + val allJarFiles = (modJarFiles + libsJarFiles).files + + // Realize the file tree once to detect missing entries. + val foundNames = allJarFiles.map { it.name }.toSet() + jarNames.filterNot { it in foundNames }.forEach { missing -> + logger.error( + "Mod dependency '$missing' was not found in any mod's " + + "/jars folder under ${modsDir.absolutePath} " + + "or in ${libsDir.absolutePath}." + ) + } + + // A jar name could theoretically be found more than once (e.g. present in both the mods + // folder and the libs folder) - keep only the first match per filename. + allJarFiles.distinctBy { it.name }.forEach { jarFile -> + val notation = stageModDependency(jarFile) + compileOnly(notation) + if (jarDeclaresAnnotationProcessor(jarFile)) { + annotationProcessor(notation) + } + } +} + +//Jars that ship an annotation processor declare it in META-INF/services. +//Such jars get registered on the annotation processor path too, so javac picks the processor +//up automatically. Kotlin sources would additionally need the kapt/ksp plugin, this only +//covers Java compilation. +fun jarDeclaresAnnotationProcessor(jarFile: File): Boolean { + //Track the jar's mtime as a configuration-cache input, so a swapped/updated jar re-runs this check. + providers.of(FileMtimeSource::class.java) { parameters.path.set(jarFile.absolutePath) }.get() + return runCatching { + ZipFile(jarFile).use { zip -> + zip.getEntry("META-INF/services/javax.annotation.processing.Processor") != null + } + }.getOrDefault(false) +} + +//Stages a mod-dependency jar as a local Maven artifact under modDepsRepoDir, so it can be added +//as "modjars::local". This mirrors stageStarsectorApi() below: the maven layout + +//"-sources.jar" naming convention is what lets IntelliJ automatically attach sources/docs for a +//compileOnly dependency. +//Starsector mod jars typically bundle their .java/.kt source files alongside the .class files +//in the same jar. +fun stageModDependency(jarFile: File): String { + val jarBaseName = jarFile.nameWithoutExtension + val artifactDir = File(modDepsRepoDir, "modjars/$jarBaseName/local") + val dstJar = File(artifactDir, "$jarBaseName-local.jar") + val dstSources = File(artifactDir, "$jarBaseName-local-sources.jar") + val pomFile = File(artifactDir, "$jarBaseName-local.pom") + + artifactDir.mkdirs() + + if (!dstJar.exists() || dstJar.lastModified() < jarFile.lastModified()) { + jarFile.copyTo(dstJar, overwrite = true) + } + + if (!dstSources.exists() || dstSources.lastModified() < jarFile.lastModified()) { + extractSourceEntriesOnly(jarFile, dstSources) + } + + if (!pomFile.exists()) { + pomFile.writeText( + """ + + + 4.0.0 + modjars + $jarBaseName + local + + """.trimIndent() + ) + } + + return "modjars:$jarBaseName:local" +} + +//Builds a "real" sources jar containing only .kt/.java/.kts entries copied out of the mod jar, +//discarding the .class entries. A straight copy of the whole jar technically also satisfies the +//"-sources.jar" naming convention and works fine for Java classes (IntelliJ's Java decompiler +//navigation matches by filename regardless of what else is in the jar), but the Kotlin plugin's +//library-source resolution appears to fall back to the compiled stub when it finds .class files +//sitting in what's supposed to be a pure source root. Filtering them out fixes that. +fun extractSourceEntriesOnly(srcJar: File, dstJar: File) { + val extractDir = File(dstJar.parentFile, "${dstJar.nameWithoutExtension}-tmp") + extractDir.deleteRecursively() + + project.copy { + from(zipTree(srcJar)) + into(extractDir) + include("**/*.kt", "**/*.java", "**/*.kts") + } + + dstJar.delete() + ant.withGroovyBuilder { + "zip"( + "destfile" to dstJar.absolutePath, + "basedir" to extractDir.absolutePath + ) + } + + extractDir.deleteRecursively() +} + +fun DependencyHandler.addCompileOnlyJar(path: String) { + val jarFile = file(path) + if (jarFile.exists()) { + compileOnly(files(jarFile)) + if (jarDeclaresAnnotationProcessor(jarFile)) annotationProcessor(files(jarFile)) + return + } + // Fallback: try resolving the same path relative to the libs folder. + val libsFile = file("$libsFolder/$path") + if (libsFile.exists()) { + compileOnly(files(libsFile)) + if (jarDeclaresAnnotationProcessor(libsFile)) annotationProcessor(files(libsFile)) + return + } + logger.error( + "Dependency '$path' was not found at ${jarFile.absolutePath} " + + "or at ${libsFile.absolutePath}." + ) +} + +enum class StarsectorPlatform { WINDOWS, LINUX, MAC } + +// Functions rather than vals so they can be called from the `dependencies {}` +// block at the top of the script, which runs before any val declared below it +// would be initialized. +fun currentPlatform(): StarsectorPlatform = System.getProperty("os.name").lowercase().let { os -> + when { + "win" in os -> StarsectorPlatform.WINDOWS + "mac" in os || "darwin" in os -> StarsectorPlatform.MAC + else -> StarsectorPlatform.LINUX + } +} + +//Holds the per-OS paths Starsector needs: the launcher file, the bundled java executable, and the games working dir. +data class StarsectorLayout( + val launcherFile: File, + val javaExecutable: File, + val gameWorkingDir: File, +) + +//Resolves all three paths for the current OS. Starsector ships a different folder structure on each platform. +fun starsectorLayout(): StarsectorLayout = file(starsectorPath).let { root -> + when (currentPlatform()) { + StarsectorPlatform.WINDOWS -> StarsectorLayout( + launcherFile = File(root, "vmparams"), + javaExecutable = File(root, "jre/bin/java.exe"), + gameWorkingDir = File(root, "starsector-core"), + ) + StarsectorPlatform.LINUX -> StarsectorLayout( + launcherFile = File(root, "starsector.sh"), + javaExecutable = File(root, "jre_linux/bin/java"), + gameWorkingDir = root, + ) + StarsectorPlatform.MAC -> StarsectorLayout( + launcherFile = File(root, "Contents/MacOS/starsector_mac.sh"), + javaExecutable = File(root, "Contents/Home/bin/java"), + gameWorkingDir = File(root, "Contents/Resources/Java"), + ) + } +} + +//Reads a file's modification time in a way the configuration cache will track as an input. +//Plain File.lastModified() calls at config time are NOT tracked by Gradle, so without this +//a Starsector update would not invalidate the cache and we'd keep serving the old staged +//API jar. Routing through a ValueSource is the documented escape hatch for "track external +//file state at configuration time". +abstract class FileMtimeSource : ValueSource { + interface Parameters : ValueSourceParameters { + val path: Property + } + override fun obtain(): Long = File(parameters.path.get()).let { + if (it.exists()) it.lastModified() else -1L + } +} + +//Stages the Starsector API as a local Maven repo under build/starsector-api/. +//Using a maven layout (not flatDir) because IntelliJ only reliably attaches sources when the +//artifact has a POM and follows the standard "--sources.jar" classifier convention. +//A jar IS a zip with optional manifest, so the source side is just a copy with the right filename. +//If you ever hit a zip layout IntelliJ does not like, swap the copy for a real extract + repack. +//Runs at configuration time so the files exist before Gradle resolves dependencies (including IDE sync). +fun stageStarsectorApi(): File { + val repoDir = layout.buildDirectory.dir("starsector-api").get().asFile + val artifactDir = File(repoDir, "com/fs/starfarer/starfarer-api/local") + val coreDir = starsectorLayout().gameWorkingDir + + val srcJar = File(coreDir, "starfarer.api.jar") + val srcZip = File(coreDir, "starfarer.api.zip") + val dstJar = File(artifactDir, "starfarer-api-local.jar") + val dstSources = File(artifactDir, "starfarer-api-local-sources.jar") + val pomFile = File(artifactDir, "starfarer-api-local.pom") + + //Force the configuration cache to depend on the source file mtimes. The `.get()` calls + //pull the values, which makes them part of the cache fingerprint. When Starsector is + //updated and these mtimes change, the cache invalidates and the staging logic re-runs. + providers.of(FileMtimeSource::class.java) { parameters.path.set(srcJar.absolutePath) }.get() + providers.of(FileMtimeSource::class.java) { parameters.path.set(srcZip.absolutePath) }.get() + + require(srcJar.exists()) { + "Starsector API jar not found at ${srcJar.absolutePath}. " + + "Check starsectorPath at the top of this build script." + } + + //Fast path: staged files match (or post-date) their sources, so we can return without doing anything. + val jarFresh = dstJar.exists() && dstJar.lastModified() >= srcJar.lastModified() + val sourcesFresh = !srcZip.exists() || (dstSources.exists() && dstSources.lastModified() >= srcZip.lastModified()) + if (jarFresh && sourcesFresh && pomFile.exists()) return repoDir + + artifactDir.mkdirs() + + //Only copy if the source is newer than the staged file, so repeat syncs are cheap. + fun stageIfStale(src: File, dst: File) { + if (!src.exists()) return + if (!dst.exists() || dst.lastModified() < src.lastModified()) { + src.copyTo(dst, overwrite = true) + } + } + + stageIfStale(srcJar, dstJar) + stageIfStale(srcZip, dstSources) + + //Minimal POM. Gradle's maven resolver needs one to recognise the artifact and to look up the -sources classifier. + if (!pomFile.exists()) { + pomFile.writeText( + """ + + + 4.0.0 + com.fs.starfarer + starfarer-api + local + + """.trimIndent() + ) + } + return repoDir +} + +data class StarsectorLaunchSpec( + val jvmArgs: List, + val classpath: List, + val mainClass: String, +) + +//Intermediate value returned by each per-platform parser. Classpath entries are still +//strings here. parseLauncher() resolves them to absolute Files against the working dir. +data class RawLaunchSpec( + val jvmArgs: List, + val classpath: List, + val mainClass: String, +) + +//Whitespace-aware tokenizer that keeps quoted content as a single token. Single or double +//quotes group their content; the quote chars themselves are consumed. Needed so launcher +//flags like -Dfoo="bar baz" survive instead of becoming two tokens. +fun shellTokenize(s: String): List { + val out = mutableListOf() + val cur = StringBuilder() + var quote: Char? = null + for (c in s) when { + quote != null -> if (c == quote) quote = null else cur.append(c) + c == '"' || c == '\'' -> quote = c + c.isWhitespace() -> if (cur.isNotEmpty()) { out += cur.toString(); cur.clear() } + else -> cur.append(c) + } + if (cur.isNotEmpty()) out += cur.toString() + return out +} + +//vmparams is a single line listing every flag, separated by whitespace. +fun parseWindowsLauncher(file: File): RawLaunchSpec { + val tokens = shellTokenize(file.readText().trim()) + return sliceJavaCommand(tokens, classpathSeparator = ';', sourceForError = file) +} + +//starsector.sh is a multi-line shell script with one flag per line, joined by `\`-continuations. +//Drop comment lines, then collapse each `\` into a space so the whole java invocation +//lands on one logical line before tokenizing. +fun parseLinuxLauncher(file: File): RawLaunchSpec { + val joined = file.readLines() + .filterNot { it.trim().startsWith("#") } + .joinToString("\n") + .replace(Regex("""\\\r?\n"""), " ") + val tokens = shellTokenize(joined) + return sliceJavaCommand(tokens, classpathSeparator = ':', sourceForError = file) +} + +//Same as Linux but additionally drops `${VAR}` placeholders. The mac script has `${EXTRAARGS}` +//as an injection point that would normally be expanded by the shell; we have nothing to expand +//it to, so we skip the token. +fun parseMacLauncher(file: File): RawLaunchSpec { + val joined = file.readLines() + .filterNot { it.trim().startsWith("#") } + .joinToString("\n") + .replace(Regex("""\\\r?\n"""), " ") + val tokens = shellTokenize(joined).filterNot { it.startsWith("\${") } + return sliceJavaCommand(tokens, classpathSeparator = ':', sourceForError = file) +} + +//Pulls jvmArgs, classpath entries, and main class out of the tokenized java invocation. +//Expected layout: [java] [jvmArgs...] [-classpath|-cp] [cp string] [mainClass] [args...] +fun sliceJavaCommand( + tokens: List, + classpathSeparator: Char, + sourceForError: File, +): RawLaunchSpec { + //Match the executable by basename. Case-sensitive on purpose: the Mac script does + //`cd ../Resources/Java`, and lowercase `java` must not match the uppercase `Java` dir. + val javaIdx = tokens.indexOfFirst { token -> + val basename = token.substringAfterLast('/').substringAfterLast('\\') + basename == "java" || basename == "java.exe" + } + require(javaIdx >= 0) { "Could not locate the java invocation in $sourceForError" } + + //First -classpath/-cp after the java token. If Starsector ever switches to --module-path, + //this is where it would break; extend the parser then. + val cpIdx = (javaIdx + 1 until tokens.size).firstOrNull { i -> + tokens[i] == "-classpath" || tokens[i] == "-cp" + } ?: error("Could not locate -classpath/-cp in $sourceForError") + require(cpIdx + 2 < tokens.size) { + "Missing classpath value or main class in $sourceForError" + } + + val classpath = tokens[cpIdx + 1].split(classpathSeparator) + .map { it.trim() } + .filter { it.isNotEmpty() } + + return RawLaunchSpec( + //Everything between `java` and `-classpath` is treated as a jvm arg. + jvmArgs = tokens.subList(javaIdx + 1, cpIdx), + classpath = classpath, + mainClass = tokens[cpIdx + 2], + ) +} + +//Reads the launcher for the current OS and returns a fully resolved launch spec. +//Relative classpath entries get resolved against the games working directory. +fun parseLauncher(): StarsectorLaunchSpec { + val layout = starsectorLayout() + val launcherFile = layout.launcherFile + require(launcherFile.exists()) { + "Starsector launcher file not found at ${launcherFile.absolutePath} " + + "(expected for platform=${currentPlatform()})" + } + + val raw = when (currentPlatform()) { + StarsectorPlatform.WINDOWS -> parseWindowsLauncher(launcherFile) + StarsectorPlatform.LINUX -> parseLinuxLauncher(launcherFile) + StarsectorPlatform.MAC -> parseMacLauncher(launcherFile) + } + + val workingDirPath = layout.gameWorkingDir.toPath() + val classpath = raw.classpath.map { workingDirPath.resolve(it).normalize().toFile() } + return StarsectorLaunchSpec(raw.jvmArgs, classpath, raw.mainClass) +} + +val launcherInfo by lazy { starsectorLayout() to parseLauncher() } +fun List.filteredArgs(): List = filterNot { it.contains("PrintCodeCache") } + +//Builds the mod jar, then runs Starsector using the same classpath/jvmArgs the launcher would use. +tasks.register("runStarsector") { + group = "starsector" + description = "Build the mod and launch Starsector (with launcher)." + dependsOn(tasks.jar) + + val (layout, parsed) = launcherInfo + setExecutable(layout.javaExecutable.absolutePath) + workingDir = layout.gameWorkingDir + mainClass.set(parsed.mainClass) + classpath = files(parsed.classpath) + //Stops treating game-crashes as build errors + isIgnoreExitValue = true + jvmArgs = parsed.jvmArgs.filteredArgs() +} + +//Same as above, but skips the launcher window and jumps straight in to the game. +//The extra -D flags are the same ones the launcher passes when you hit play, so the game gets the settings it expects. +tasks.register("runStarsectorNoLauncher") { + group = "starsector" + description = "Build the mod and launch Starsector, skipping the launcher." + dependsOn(tasks.jar) + + val (layout, parsed) = launcherInfo + setExecutable(layout.javaExecutable.absolutePath) + workingDir = layout.gameWorkingDir + mainClass.set(parsed.mainClass) + classpath = files(parsed.classpath) + isIgnoreExitValue = true + jvmArgs = listOf( + "-DstartRes=$devResolution", + "-DlaunchDirect=true", + "-DstartFS=false", + "-DstartSound=true", + ) + parsed.jvmArgs.filteredArgs() +} + +//Ensure IntelliJ's "Build and run using" stays on IDEA (not Gradle) so HotSwap can recompile +//changed classes in milliseconds via IntelliJ's incremental compiler instead of shelling out to +//Gradle on every reload. The .idea/ folder is gitignored (IDE config is user-specific), so we +//re-apply this on every Gradle sync. Never creates gradle.xml: if it's missing, IntelliJ is in +//the middle of a first-time import and writing the file ourselves can break its sync detection. +//Wrapped in runCatching: any failure here is non-fatal, sothe build/sync continues. +runCatching { + val gradleXml = file(".idea/gradle.xml") + if (gradleXml.exists()) { + val text = gradleXml.readText() + val canonical = """