plugins { id 'application' id 'com.diffplug.spotless' version '7.0.2' } group = 'com.eliasmueller.llamamod' // Release builds pass -PreleaseVersion= (see .gitea/workflows/release.yml). version = findProperty('releaseVersion') ?: '0.2.3' repositories { mavenCentral() } def lwjglVersion = '3.4.2' def lwjglNatives = ['natives-linux', 'natives-windows'] dependencies { implementation 'com.formdev:flatlaf:3.5.4' implementation 'com.formdev:flatlaf-fonts-inter:4.1' // level editor viewport: LWJGL (OpenGL) inside Swing via lwjgl3-awt; natives for both packages implementation platform("org.lwjgl:lwjgl-bom:${lwjglVersion}") implementation('org.lwjglx:lwjgl3-awt:0.2.5') { transitive = false } implementation 'org.joml:joml:1.10.8' ['lwjgl', 'lwjgl-opengl', 'lwjgl-jawt', 'lwjgl-egl'].each { m -> implementation "org.lwjgl:${m}" if (m in ['lwjgl', 'lwjgl-opengl']) { lwjglNatives.each { n -> runtimeOnly "org.lwjgl:${m}::${n}" } } } testImplementation platform('org.junit:junit-bom:5.11.4') testImplementation 'org.junit.jupiter:junit-jupiter' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } def targetJavaVersion = 25 java { toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion) } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' options.release.set(targetJavaVersion) } application { mainClass = 'com.eliasmueller.llamamod.Main' } // build-info.properties: version and a build id (hash of the compiled classes and resources, without // the edition switch, so an unchanged rebuild keeps the id and the mod cache stays valid) def buildInfoDir = layout.buildDirectory.dir('generated/buildinfo') tasks.register('buildInfo') { def classes = sourceSets.main.output.classesDirs def resources = fileTree('src/main/resources') { exclude 'com/eliasmueller/llamamod/edition.properties' } def ver = project.version.toString() def outDir = buildInfoDir inputs.files(classes).withPathSensitivity(PathSensitivity.RELATIVE) inputs.files(resources).withPathSensitivity(PathSensitivity.RELATIVE) inputs.property('version', ver) outputs.dir(outDir) doLast { def md = java.security.MessageDigest.getInstance('SHA-256') def add = { File root -> if (!root.exists()) { return } def files = [] root.eachFileRecurse(groovy.io.FileType.FILES) { files << it } files.sort { root.toPath().relativize(it.toPath()).toString().replace('\\', '/') }.each { f -> if (f.name == 'edition.properties') { return } md.update(root.toPath().relativize(f.toPath()).toString().replace('\\', '/').getBytes('UTF-8')) md.update(f.bytes) } } classes.files.each(add) add(file('src/main/resources')) def id = md.digest().encodeHex().toString().substring(0, 10) def f = outDir.get().file('com/eliasmueller/llamamod/build-info.properties').asFile f.parentFile.mkdirs() f.text = "version=${ver}\nbuild=${id}\n" } } // user manual (docs/manual, Markdown + images) bundled for the editor's Help → Manual window processResources { from('docs/manual') { into 'com/eliasmueller/llamamod/editor/tools/help/manual' } from(tasks.named('buildInfo')) } test { useJUnitPlatform() // golden tests: -Dllamamod.cd= (or env LLAMAMOD_CD) -Dllamamod.ref=; // tests that need the CD are skipped when the folder does not exist systemProperty 'llamamod.cd', System.getProperty('llamamod.cd', System.getenv('LLAMAMOD_CD') ?: "${System.getProperty('user.home')}/.wine-lama/iso-dk/Setup/Eng/Cd") systemProperty 'llamamod.ref', System.getProperty('llamamod.ref', '') // level builder references: python3 tools/levelbuild.py levels/testwelt.py /testwelt.wad etc. systemProperty 'llamamod.lbref', System.getProperty('llamamod.lbref', '') // English groove.exe (2000-10-17) for the version tests; never committed, skipped when absent systemProperty 'llamamod.exeEn', System.getProperty('llamamod.exeEn', "${projectDir}/re/groove_en.exe") maxHeapSize = '2g' } // fat jars with all dependencies: full edition (fatJar) and launcher edition without the editor UI // (fatJarLauncher, edition.properties editor=false; the editor classes stay, mods need them) def editions = ['full', 'launcher'] def launcherEditionDir = layout.buildDirectory.dir('generated/edition-launcher') tasks.register('launcherEdition') { def outDir = launcherEditionDir outputs.dir(outDir) doLast { def f = outDir.get().file('com/eliasmueller/llamamod/edition.properties').asFile f.parentFile.mkdirs() f.text = 'editor=false\n' } } editions.each { ed -> tasks.register(ed == 'full' ? 'fatJar' : 'fatJarLauncher', Jar) { archiveClassifier = ed == 'full' ? 'all' : 'launcher' duplicatesStrategy = DuplicatesStrategy.EXCLUDE manifest { attributes 'Main-Class': application.mainClass, 'Implementation-Version': project.version, 'Multi-Release': 'true' } if (ed == 'full') { from sourceSets.main.output } else { from(tasks.named('launcherEdition')) from(sourceSets.main.output) { exclude 'com/eliasmueller/llamamod/edition.properties' } } dependsOn configurations.runtimeClasspath from { configurations.runtimeClasspath.findAll { it.name.endsWith('jar') }.collect { zipTree(it) } } exclude 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA', 'module-info.class', 'META-INF/versions/*/module-info.class' } } spotless { java { target 'src/**/*.java' palantirJavaFormat() removeUnusedImports() } } // ---------------------------------------------------------------- packaging // Self-contained Linux folder (+ .tar.gz) and Windows zip (LlamaMod.exe + runtime), both built on // Linux. jlink cross-links using the Temurin jmods of the target platform; the jlink binary must // be the exact same JDK version (JDK 25 ships no jmods, so they are downloaded separately). // Downloads are cached in ~/opt (override: -Pllamamod.opt=). def runtimeVersion = '25.0.5+8' def optDir = file(findProperty('llamamod.opt') ?: "${System.getProperty('user.home')}/opt") def distDir = layout.buildDirectory.dir('dist').get().asFile // jdk.unsupported: sun.misc.Unsafe for LWJGL def runtimeModules = 'java.base,java.desktop,java.prefs,java.logging,jdk.charsets,jdk.zipfs,jdk.unsupported' def extraModules = [linux: '', windows: ',jdk.crypto.mscapi'] def adoptium = { String os, String imageType -> "https://api.adoptium.net/v3/binary/version/jdk-${runtimeVersion.replace('+', '%2B')}/${os}/x64/${imageType}/hotspot/normal/eclipse" } def sh = { List cmd -> def p = new ProcessBuilder(cmd.collect { it.toString() }).inheritIO().start() if (p.waitFor() != 0) { throw new GradleException("Command failed: ${cmd.join(' ')}") } } def download = { String url, File target -> if (!target.exists()) { target.parentFile.mkdirs() logger.lifecycle("Downloading ${url}") def tmp = new File(target.path + '.part') new URI(url).toURL().withInputStream { i -> tmp.withOutputStream { it << i } } tmp.renameTo(target) } target } tasks.register('fetchJdk') { description = 'Downloads the Linux JDK (for jlink) and the jmods of both target platforms.' doLast { def jdk = new File(optDir, "jdk-${runtimeVersion}") if (!new File(jdk, 'bin/jlink').exists()) { def tgz = download(adoptium('linux', 'jdk'), new File(optDir, "dl/jdk-${runtimeVersion}-linux.tar.gz")) sh(['tar', 'xzf', tgz, '-C', optDir]) } ['linux': 'tar.gz', 'windows': 'zip'].each { os, ext -> def dir = new File(optDir, "jmods-${runtimeVersion}-${os}") if (!dir.exists()) { def arc = download(adoptium(os, 'jmods'), new File(optDir, "dl/jmods-${runtimeVersion}-${os}.${ext}")) def tmp = new File(optDir, "jmods-${runtimeVersion}-${os}.tmp") project.delete(tmp) tmp.mkdirs() if (ext == 'zip') { project.copy { from project.zipTree(arc); into tmp } } else { sh(['tar', 'xzf', arc, '-C', tmp]) } // archives contain one top-level folder holding the *.jmod files def jmodDir = fileTree(tmp).matching { include '**/java.base.jmod' }.singleFile.parentFile jmodDir.renameTo(dir) project.delete(tmp) } } } } def jlinkTask = { String name, String os -> tasks.register(name) { dependsOn 'fetchJdk' def out = new File(layout.buildDirectory.get().asFile, "runtime-${os}") outputs.dir(out) doLast { project.delete(out) sh([new File(optDir, "jdk-${runtimeVersion}/bin/jlink").path, '--module-path', new File(optDir, "jmods-${runtimeVersion}-${os}").path, '--add-modules', runtimeModules + extraModules[os], '--strip-debug', '--no-header-files', '--no-man-pages', '--compress', 'zip-9', '--output', out.path]) } } } jlinkTask('jlinkLinux', 'linux') jlinkTask('jlinkWindows', 'windows') def jarTask = { String ed -> ed == 'full' ? 'fatJar' : 'fatJarLauncher' } def suffix = { String ed -> ed == 'full' ? '' : 'Launcher' } def packageName = { String ed, String os -> "LlamaMod-${project.version}-${ed}-${os}-x64" } editions.each { ed -> def stage = new File(distDir, "${ed}/LlamaMod-linux-x64") tasks.register("linuxImage${suffix(ed)}", Sync) { dependsOn jarTask(ed), 'jlinkLinux' from(layout.buildDirectory.dir('runtime-linux')) { into 'runtime' } from(tasks.named(jarTask(ed))) { into 'lib'; rename { 'llamamod.jar' } } from('packaging/linux') { filePermissions { unix('rwxr-xr-x') }; include 'LlamaMod', '*.sh' } from('packaging/linux') { exclude 'LlamaMod', '*.sh' } from('src/main/resources/com/eliasmueller/llamamod/ui/icon-256.png') { rename { 'llamamod.png' } } into stage // jlink marks legal/ files read-only, so Sync cannot overwrite them doFirst { project.delete(stage) } } tasks.register("packageLinux${suffix(ed)}") { description = "Self-contained Linux build (${ed} edition): build/dist/${packageName(ed, 'linux')}.tar.gz." dependsOn "linuxImage${suffix(ed)}" doLast { // system tar keeps the executable bits of the runtime (Gradle's Tar normalizes them) sh(['tar', 'czf', new File(distDir, "${packageName(ed, 'linux')}.tar.gz"), '-C', stage.parentFile, stage.name]) } } def exe = new File(layout.buildDirectory.get().asFile, "exe/${ed}/LlamaMod.exe") tasks.register("exeWindows${suffix(ed)}") { description = 'Wraps the fat jar into LlamaMod.exe with launch4j (Linux binary, downloaded to ~/opt).' dependsOn jarTask(ed) inputs.files(tasks.named(jarTask(ed))) inputs.dir('packaging/windows') outputs.file(exe) doLast { def l4j = new File(optDir, 'launch4j') if (!new File(l4j, 'launch4j.jar').exists()) { def tgz = download('https://sourceforge.net/projects/launch4j/files/launch4j-3/3.50/launch4j-3.50-linux-x64.tgz/download', new File(optDir, 'dl/launch4j-3.50-linux-x64.tgz')) sh(['tar', 'xzf', tgz, '-C', optDir]) } exe.parentFile.mkdirs() def cfg = new File(exe.parentFile, 'launch4j.xml') cfg.text = file('packaging/windows/launch4j.xml.in').text .replace('@JAR@', tasks.named(jarTask(ed)).get().archiveFile.get().asFile.path) .replace('@OUT@', exe.path) .replace('@ICON@', file('packaging/icon.ico').path) .replace('@VERSION@', project.version.toString()) sh([new File(optDir, "jdk-${runtimeVersion}/bin/java").path, '-jar', new File(l4j, 'launch4j.jar').path, cfg.path]) } } tasks.register("packageWindows${suffix(ed)}", Zip) { description = "Windows build (made on Linux, ${ed} edition): build/dist/${packageName(ed, 'windows')}.zip." dependsOn "exeWindows${suffix(ed)}", 'jlinkWindows' from(layout.buildDirectory.dir('runtime-windows')) { into 'LlamaMod/runtime' } from(exe) { into 'LlamaMod' } from('packaging/windows') { exclude '*.in'; into 'LlamaMod' } destinationDirectory = distDir archiveFileName = "${packageName(ed, 'windows')}.zip" } } tasks.register('packageAll') { description = 'Linux and Windows packages of both editions (full, launcher) in build/dist.' dependsOn editions.collectMany { ["packageLinux${suffix(it)}", "packageWindows${suffix(it)}"] } }