-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuild.gradle
More file actions
1163 lines (1072 loc) · 54.5 KB
/
Copy pathbuild.gradle
File metadata and controls
1163 lines (1072 loc) · 54.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
plugins {
id 'java-library'
id 'idea'
id 'org.jetbrains.kotlin.jvm' version '2.2.0'
id 'net.neoforged.moddev' version '2.0.78'
}
version = mod_version
group = mod_group_id
repositories {
mavenLocal()
mavenCentral()
// Modrinth's maven hosts Jade. Needed for the compile-only Jade API we
// link against in lekkit.scev.compat.jade.*; absence of Jade at runtime
// is handled by @WailaPlugin-gated classloading.
maven {
name = "Modrinth"
url = "https://api.modrinth.com/maven"
content { includeGroup "maven.modrinth" }
}
// CC: Tweaked — for the IPeripheral / IComputerAccess surface used
// by lekkit.scev.compat.cc.*. Soft-dep: CC need not be installed at
// runtime; the compat layer self-isolates behind a ModList check.
maven {
name = "squiddev"
url = "https://maven.squiddev.cc"
content { includeGroup "cc.tweaked" }
}
// JEI (Just Enough Items) — dev-time only. Modrinth's maven collides
// all three loader variants (fabric/forge/neoforge) on a single version
// number and resolves to fabric first, so pull from BlameJared's maven
// where the loader is baked into the artifactId.
maven {
name = "BlameJared"
url = "https://maven.blamejared.com"
content { includeGroup "mezz.jei" }
}
// JitPack hosts on-demand builds of GitHub projects. Used for Concentus,
// a pure-Java Opus codec that has no Maven Central release. Scoped to
// the concentus group so an unrelated mod shipping its own JitPack dep
// can't accidentally resolve through our declaration.
maven {
name = "JitPack"
url = "https://jitpack.io"
content { includeGroup "com.github.lostromb.concentus" }
}
// Wisp Forest — owo-lib (declarative GUI framework) and its endec
// transitives (io.wispforest.endec:netty/gson/jankson). The
// 1.21-Neo branch publishes a Mojmap-mapped neoforge artifact for
// direct ModDevGradle consumption.
maven {
name = "WispForest"
url = "https://maven.wispforest.io/releases"
content {
includeGroup "io.wispforest"
includeGroup "io.wispforest.endec"
}
}
// Sinytra — owo-lib pulls forgified-fabric-api:fabric-api-base for
// its packet/event abstraction layer on NeoForge. Scoped so we don't
// accidentally pull a full Forgified Fabric API tree.
maven {
name = "Sinytra"
url = "https://maven.su5ed.dev/releases"
content { includeGroup "org.sinytra.forgified-fabric-api" }
}
// CreateMod maven — Create itself + its mandatory runtime stack
// (Ponder, Flywheel, Registrate). Unlike the Modrinth maven (which
// serves only the binary), this one publishes real POM + Gradle
// module metadata, so transitive resolution works. Scoped to the
// groups Create's POM actually pulls so unrelated artifacts can't
// accidentally resolve through here.
maven {
name = "CreateMod"
url = "https://maven.createmod.net"
content {
includeGroup "com.simibubi.create"
includeGroup "com.tterrag.registrate"
includeGroup "net.createmod.ponder"
includeGroup "dev.engine-room.flywheel"
includeGroup "dev.engine-room.vanillin"
}
}
// IThundxr's snapshots — Registrate publishes here (the ${registrate_version}
// string in Create's POM matches a snapshot artifact). Pulled in as
// a transitive of Create only.
maven {
name = "IThundxr"
url = "https://maven.ithundxr.dev/snapshots"
content { includeGroup "com.tterrag.registrate" }
}
// Architectury — cross-loader API required by Create: Power Grid (and
// any other Architectury-built addon we layer on later). Scoped to
// the dev.architectury group so unrelated artifacts don't route here.
maven {
name = "Architectury"
url = "https://maven.architectury.dev"
content { includeGroup "dev.architectury" }
}
}
base {
archivesName = "${mod_id}-neoforge-${minecraft_version}"
}
java.toolchain.languageVersion = JavaLanguageVersion.of(java_version as Integer)
// Kotlin — align the JVM target with Java's toolchain so stdlib / coroutines /
// whatever else we jarJar stays loadable by the Minecraft process (Java 21).
// The Kotlin plugin otherwise defaults to 1.8 which would emit bytecode
// compatible with a runtime we never ship to.
kotlin {
jvmToolchain(java_version as Integer)
// Context parameters (kotlin 2.2+) — kowo-lib's DSL uses them on the
// `root { … }` and `+component` extension surface. Without the flag
// every screen built with the DSL would fail to compile with a
// "context parameter requires opt-in" warning that promotes to error.
compilerOptions {
freeCompilerArgs.add('-Xcontext-parameters')
}
}
neoForge {
version = project.neo_version
// owo-lib applies NeoForge interface injection on a handful of
// vanilla classes (AbstractWidget, EditBox, AbstractContainerMenu,
// GuiGraphics, …) so they implement owo's Component /
// OwoScreenHandler / etc. supertypes. Compile-time visibility of
// those injected supertypes (e.g. so `ButtonComponent` is
// assignable to owo's `Component`) requires the data to be on
// moddev's `interfaceInjectionData` config — auto-discovery from
// dependencies happens at runtime only.
//
// We do NOT feed owo's access transformer the same way. Owo ships
// META-INF/owo.accesstransformer.cfg in its jar; ModDevGradle
// applies it at runtime through dependency discovery, but
// double-applying it via `accessTransformers.from(...)` here
// corrupted the dev MC jar enough that `Minecraft.<init>` died
// with NoClassDefFoundError on
// net/minecraft/network/chat/FormattedText. The interface
// injection didn't cause that crash; the AT did.
//
// The committed `gradle/owo-accesstransformer.cfg` stays for
// drift detection — diff against the dep's META-INF when bumping
// the owo version to see what additional methods/classes get
// unlocked.
interfaceInjectionData {
from(file('gradle/owo-interfaces.json'))
}
runs {
client {
client()
systemProperty 'neoforge.enabledGameTestNamespaces', mod_id
}
server {
server()
programArgument '--nogui'
systemProperty 'neoforge.enabledGameTestNamespaces', mod_id
}
gameTestServer {
type = 'gameTestServer'
systemProperty 'neoforge.enabledGameTestNamespaces', mod_id
}
data {
data()
programArguments.addAll '--mod', mod_id,
'--all',
'--output', file('src/generated/resources/').getAbsolutePath(),
'--existing', file('src/main/resources/').getAbsolutePath()
}
configureEach {
systemProperty 'forge.logging.markers', 'REGISTRIES'
logLevel = org.slf4j.event.Level.DEBUG
}
}
mods {
"${mod_id}" {
sourceSet sourceSets.main
}
}
unitTest {
enable()
testedMod = mods."${mod_id}"
}
}
sourceSets.main.resources { srcDir 'src/generated/resources' }
dependencies {
// Kotlin stdlib — jarJar'd into the shipped mod jar so we don't require
// Kotlin for Forge (KFF) as a separate runtime mod. The Kotlin plugin
// already puts stdlib on the compile+dev-runtime classpath, so this line
// is purely about making the production jar self-contained. Version
// range [2.0, 3.0) lets NeoForge's nested-jar resolver coexist with
// other Kotlin-using mods by picking the newest compatible stdlib.
//
// Unlike the opus naive natives further down (which hand-roll their own
// META-INF/jarjar entries to dodge module-name collisions), stdlib is a
// single jar with a unique module name, so the standard plugin DSL
// works fine here.
jarJar(implementation("org.jetbrains.kotlin:kotlin-stdlib")) {
version {
strictly "[2.0, 3.0)"
prefer "2.2.0"
}
}
// NeoForge's ModuleClassLoader sees only `additionalRuntimeClasspath`
// during dev runs (runClient / runData / runGameTestServer), not
// Gradle's regular `implementation` classpath. Without this entry,
// dev-mode datagen constructs every mod — including any Kotlin code
// transitively reachable from mod init (e.g. ScevCCBootstrap) — and
// NoClassDefFoundErrors on kotlin.jvm.functions.Function1 as soon as
// a `.kt` file touches an init path. `implementation` above still
// makes the production jar self-contained via jarJar.
additionalRuntimeClasspath 'org.jetbrains.kotlin:kotlin-stdlib:2.2.0'
// Coroutines — jarJar'd ahead of the first concurrency port (likely
// SoundStreamManager's ring-buffer → channel/actor refactor) so that
// PR doesn't have to rewire the build. Range floor is 1.8 because
// structured-concurrency / Flow APIs pre-1.8 have diverged enough that
// dedup-to-older by NeoForge's resolver would surprise-break us.
//
// Targeting `-jvm` directly (not the bare `kotlinx-coroutines-core`):
// coroutines ships as a Kotlin Multiplatform module where the base
// artifact is a capability-only redirect. Moddev's jarJar resolver
// reads Gradle Module Metadata first and ends up with the KMP
// capability coordinates — which look like `kotlinx.coroutines.core`
// (dotted) plus a hash `artifactVersion` in the emitted metadata.json.
// Pointing at `-jvm` skips the redirect and yields sane Maven
// coordinates (`kotlinx-coroutines-core-jvm` / `1.10.2`).
//
// `transitive = false` keeps the resolver from dragging in another
// copy of kotlin-stdlib through coroutines' transitive graph — we
// already ship one above, and two jarJar entries targeting the same
// {group, artifact} would trigger moddev's dedupe.
jarJar(implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm")) {
version {
strictly "[1.8, 2.0)"
prefer "1.10.2"
}
transitive = false
}
// Same reason as the kotlin-stdlib line above: dev runs need
// coroutines-core on the ModuleClassLoader-visible classpath.
additionalRuntimeClasspath 'org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.11.3'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.11.3'
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.11.3'
// runTest + StandardTestDispatcher for the suspend-based RPC /
// peripheral dispatch tests. Version range matches the jarJar'd
// kotlinx-coroutines-core-jvm above.
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2'
// Jade HUD integration — compile against the full jar (API-only artifact
// is not published to Modrinth). No hard dep: our compat classes live
// under lekkit.scev.compat.jade and are only loaded when Jade scans them.
compileOnly "maven.modrinth:jade:${jade_version}+neoforge"
runtimeOnly "maven.modrinth:jade:${jade_version}+neoforge"
// CC: Tweaked — IPeripheral / IComputerAccess API for the lekkit.scev.
// compat.cc package. compileOnly so shipping jars stay slim; the full
// mod is pulled as a runtime dep so dev runs exercise real CC.
// ScevCCPeripheral is behind a ModList.isLoaded check so servers
// without CC installed load scev fine.
compileOnly "cc.tweaked:cc-tweaked-${minecraft_version}-core-api:${cc_tweaked_version}"
compileOnly "cc.tweaked:cc-tweaked-${minecraft_version}-forge-api:${cc_tweaked_version}"
runtimeOnly "cc.tweaked:cc-tweaked-${minecraft_version}-forge:${cc_tweaked_version}"
// testCompileOnly mirrors the compileOnly above — the
// lekkit.scev.test.cc tests import IPeripheral / IArguments / etc
// to dispatch against hand-rolled fake peripherals.
testCompileOnly "cc.tweaked:cc-tweaked-${minecraft_version}-core-api:${cc_tweaked_version}"
testCompileOnly "cc.tweaked:cc-tweaked-${minecraft_version}-forge-api:${cc_tweaked_version}"
// testImplementation on the full forge mod so integration tests
// (e.g. lekkit.scev.test.cc.CcPrinterIntegrationTest) can reference
// real first-party peripherals like PrinterPeripheral /
// PrinterBlockEntity / NetworkedTerminal directly from CC's JARs
// at both compile and run time. Paired with neoForge.unitTest
// providing Minecraft, this is enough to construct a real
// PrinterPeripheral(mockedBE) and exercise the full scev RPC →
// CC dispatch → peripheral chain. The -forge-api testCompileOnly
// above stays because it scopes the narrower API surface our
// production compat layer uses.
testImplementation "cc.tweaked:cc-tweaked-${minecraft_version}-forge:${cc_tweaked_version}"
// Mockito 5.x defaults to the inline mockmaker — can mock final
// classes (PrinterBlockEntity is `final`) without extra agent
// wiring. Used only in the CC integration test; pure JVM state
// machine for the printer's paper/ink/page fields.
testImplementation 'org.mockito:mockito-core:5.15.2'
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.4.0'
// Advanced Peripherals — extra CC peripherals (player detector,
// chat box, ME bridge, energy detector, ...). No scev code touches
// it; runtimeOnly so runClient gets a richer CC surface for manual
// testing of the scev RPC <-> peripheral bridge against a real
// third-party peripheral mod. No published API jar, so we pull the
// full mod via Modrinth's maven. Botania has no 1.21.1 NeoForge
// release on Modrinth (Vazkii hasn't ported past 1.20.1), so it's
// intentionally omitted from this stack.
runtimeOnly "maven.modrinth:advancedperipherals:${advanced_peripherals_version}"
// AE2 + Mekanism — pulled in so AP's ME bridge / chemical tank /
// energy detector integrations actually have something to bind to
// at runtime. Both publish proper NeoForge artifacts to Modrinth's
// maven for 1.21.1.
runtimeOnly "maven.modrinth:ae2:${ae2_version}"
// GuideME — AE2's in-game guide, extracted to its own mod in 19.x
// and declared as a HARD dep in AE2's mods.toml. Without it, FML's
// mod sorter rejects ae2 with "Missing or unsupported mandatory
// dependencies: guideme [MISSING]" and aborts mod loading.
runtimeOnly "maven.modrinth:guideme:${guideme_version}"
// All four Mekanism subprojects share a single version_number per
// release — pulling the full set so AP's gas/chemical/energy
// peripherals have the broadest possible runtime surface.
runtimeOnly "maven.modrinth:mekanism:${mekanism_version}"
runtimeOnly "maven.modrinth:mekanism-generators:${mekanism_version}"
runtimeOnly "maven.modrinth:mekanism-tools:${mekanism_version}"
runtimeOnly "maven.modrinth:mekanism-additions:${mekanism_version}"
// Create — soft-dep target for cross-mod integration under
// lekkit.scev.compat.create. Resolved from CreateMod's maven (see
// the repository{} block above), which ships proper POM + Gradle
// module metadata — the runtimeOnly here pulls Flywheel, Ponder,
// Registrate transitively so dev runs load Create out of the box.
// Same compile/runtime split as CC: Tweaked above (compileOnly
// keeps the production jar slim; ScevCreate* compat classes are
// ModList-gated so absence at runtime is safe).
// Excludes: Create's POM declares its own cross-mod integration
// targets (FTB Chunks/Library/Teams, Architectury, JourneyMap API,
// Curios) as runtime-scope transitives. None are needed for scev's
// use of Create — Create itself runs fine without them, those
// modules are just dormant. Pulling them would require wiring four
// more mavens (ftb.dev, architectury.dev, jm.gserv.me,
// theillusivec4.top); excluding here keeps our repo list focused on
// what scev actually links against.
def createIntegrationExclusions = {
exclude group: "dev.ftb.mods"
exclude group: "dev.architectury"
exclude group: "info.journeymap"
exclude group: "top.theillusivec4.curios"
}
compileOnly("com.simibubi.create:create-${minecraft_version}:${create_version}", createIntegrationExclusions)
runtimeOnly("com.simibubi.create:create-${minecraft_version}:${create_version}", createIntegrationExclusions)
// Create: Power Grid — patryk3211's electricity-physics layer for
// Create. Hits a pin on Modrinth's maven: PowerGrid publishes BOTH
// a Forge 1.20.1 jar and a NeoForge 1.21.1 jar under version slug
// `0.5.5.1` (no loader discriminator), and Modrinth's maven serves
// the Forge one — which throws "for Forge or older NeoForge" at
// mod load. Sidestep by fetching the correct file from the
// Modrinth CDN by direct URL via fetchPowerGridJar (further down)
// and depending on the local copy as a file. Architectury is a
// hard runtime dep declared in PowerGrid's mods.toml, pulled
// independently from maven.architectury.dev.
compileOnly files("${buildDir}/external/powergrid-mc${minecraft_version}-${power_grid_version}.jar")
runtimeOnly files("${buildDir}/external/powergrid-mc${minecraft_version}-${power_grid_version}.jar")
runtimeOnly "dev.architectury:architectury-neoforge:${architectury_version}"
// JEI — dev-time recipe browser. The compileOnly api line is for the
// single JEI plugin we ship: ScevJeiPlugin returns the MachineScreen's
// framebuffer panel as an exclusion area so JEI's right-side overlay
// hides while the player is interacting with a running guest.
compileOnly "mezz.jei:jei-${minecraft_version}-common-api:${jei_version}"
compileOnly "mezz.jei:jei-${minecraft_version}-neoforge-api:${jei_version}"
runtimeOnly "mezz.jei:jei-${minecraft_version}-neoforge:${jei_version}"
// Opus codec (Concentus, pure-Java). Compresses the HDA audio stream:
// 48 kHz mono 16-bit PCM (768 kbps) -> Opus @ 128 kbps VBR music-tuned.
//
// Pinned to the master HEAD at 2025-09-27 via JitPack's commit-hash
// addressing — Concentus has no Maven Central release, but its
// bit-exact Java port of libopus 1.x is stable enough that a
// pinned commit is effectively a version. Direct version string
// (no strictly/prefer range) because JitPack commit hashes aren't
// semver-ordered and NeoForge's version-range resolver rejects
// them if declared as a range preference.
//
// Perf delta vs libopus native: ~2-3× slower encode, identical
// output. At 50 frames/sec × 20 ms complexity-10 that's fractions
// of a core per active stream — negligible compared to the
// emulated VMs sharing the same thread.
//
// This replaces a ~250-line build.gradle dance (six LWJGL-opus
// native jars that all declared the same Automatic-Module-Name,
// requiring a custom repack task + hand-rolled META-INF/jarjar
// metadata merge to coexist with moddev's jarJar plugin).
jarJar(implementation('com.github.lostromb.concentus:Concentus:3885c4e465'))
additionalRuntimeClasspath 'com.github.lostromb.concentus:Concentus:3885c4e465'
// owo-lib — declarative GUI framework. Mojmap NeoForge artifact
// published from the 1.21-Neo branch's `owo-neo-publish` subproject;
// it ships with access transformers + interface-injection metadata
// already configured for ModDevGradle, so no AT plumbing on our side.
//
// The exclude is load-bearing. owo's neo build pulls
// `org.sinytra.forgified-fabric-api:fabric-api-base` for its event/
// packet abstractions, and any other mod the user has installed that
// also provides fabric-api-base (Sodium, Sable, simulated, …) declares
// the same JPMS-exported package — `net.fabricmc.fabric.api.event`.
// NeoForge's module resolver fails-fast on duplicate exports, so we
// exclude our own copy and trust whatever fabric-api-base the user
// already has in run/mods (or a sibling mod's jarJar) to satisfy
// owo's runtime requirement.
// owo-lib ships a META-INF/neoforge.mods.toml — must reach NeoForge as a
// discovered mod, not via additionalRuntimeClasspath (which adds it as
// a plain JPMS library with no readability into the `minecraft` module,
// so BaseOwoHandledScreen → AbstractContainerScreen NoClassDefFoundErrors
// at MenuScreens.register time). `implementation` lands on the runtime
// classpath where moddev's mod discovery picks up the mods.toml.
implementation("io.wispforest:owo-lib-neoforge:${owo_version}") {
exclude group: 'org.sinytra.forgified-fabric-api', module: 'fabric-api-base'
}
// kowo-lib (the upstream Kotlin DSL on top of owo) is published as a
// Yarn-mapped jar — every method reference inside is `class_2561`,
// `class_5250`, etc., which the Mojmap classpath we live on simply
// doesn't have. Rather than wire yarn-mappings-patch through our build
// we vendored the (~80-line) DSL into src/main/kotlin/.../client/screen/owo/Dsl.kt
// as MIT-license-attributed code; it gives us the same `+button(...)`,
// `100.fill`, `verticalFlow { … }` ergonomics with zero external dep.
}
// jar (and run* tasks below) depend on syncGeneratedResources, not runData
// directly: the Copy task overlays src/generated/resources/ onto
// build/resources/main/ AFTER both processResources and runData have run,
// which is the only way the data-pack resources reach the runtime
// classpath jar bundles / dev runs read.
tasks.named('jar').configure {
dependsOn 'syncGeneratedResources'
}
// processResources runs early, before runData has populated
// src/generated/resources/, so build/resources/main/ ends up missing
// the data-pack resources runData emits (e.g. the empty structure
// template @GameTest needs). Adding processResources.dependsOn runData
// — or even mustRunAfter — creates the cycle
// classes → processResources → runData → classes
// (runData is a JavaExec needing classes). Sidestep with a Copy task
// that overlays the generated dir on top of build/resources/main/
// after both processResources and runData have run.
tasks.register('syncGeneratedResources', Copy) {
dependsOn 'runData'
mustRunAfter 'processResources'
from 'src/generated/resources'
into "${buildDir}/resources/main"
}
tasks.matching { it.name in ['runGameTestServer', 'runClient', 'runServer'] }
.configureEach { dependsOn 'syncGeneratedResources' }
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
processResources {
def replaceProperties = [
minecraft_version : minecraft_version,
minecraft_version_range: minecraft_version_range,
neo_version : neo_version,
neo_version_range : neo_version_range,
loader_version_range : loader_version_range,
mod_id : mod_id,
mod_name : mod_name,
mod_authors : mod_authors,
mod_description : mod_description,
mod_version : mod_version
]
inputs.properties replaceProperties
filesMatching(['META-INF/neoforge.mods.toml']) {
expand replaceProperties
}
}
test {
useJUnitPlatform()
// Asset / construction tests read paths relative to the project root.
workingDir = project.projectDir
systemProperty 'scev.projectDir', project.projectDir.absolutePath
// Several tests assert against files runData emits into
// src/generated/resources/.
dependsOn 'runData'
}
// Native libraries live under src/main/resources/natives/<classifier>/ so they're on
// the classpath in dev AND bundled into the shipped jar automatically (no extra
// `jar.from` stanza needed).
//
// CI builds one native per target platform in parallel matrix jobs and downloads
// them into src/main/resources/natives/ before running `./gradlew build`. Local
// dev builds use `buildRvvmNative` below, which compiles librvvm for the host
// platform only — same invocation as CI (`make lib USE_JNI=1`). Cross-platform
// natives are a CI-only story; a local `./gradlew runClient` only needs the
// host OS's library.
// Pointed at our fork's staging branch. pufit/RVVM#staging = LekKit/RVVM#staging
// plus the pluggable HDA backend + JNI ring buffer API the mod relies on
// for sound. When that work merges upstream into LekKit/RVVM staging, we
// can flip the URL back to LekKit/RVVM and the branch stays the same.
ext.rvvmRef = 'staging'
ext.rvvmSrcDir = file("${buildDir}/rvvm-src")
ext.rvvmUpstream = 'https://github.com/pufit/RVVM.git'
/** Host OS / arch detection mirroring RVVM's output directory naming. */
ext.rvvmHostTriple = {
String osn = System.getProperty('os.name').toLowerCase()
String arch = System.getProperty('os.arch').toLowerCase()
String norm = arch == 'amd64' ? 'x86_64' : arch == 'aarch64' ? 'arm64' : arch
if (osn.contains('linux')) return "linux.${norm}"
if (osn.contains('mac')) return "darwin.${norm}"
if (osn.contains('windows')) return "windows.${norm}"
throw new GradleException("Unsupported host OS: ${osn}")
}
/** Corresponding subdir under src/main/resources/natives/ — matches what NativeLoader expects. */
ext.rvvmClassifier = {
String osn = System.getProperty('os.name').toLowerCase()
String arch = System.getProperty('os.arch').toLowerCase()
String norm = arch == 'amd64' ? 'x86_64' : arch == 'aarch64' ? 'aarch64' : arch
if (osn.contains('linux')) return "linux-${norm}"
if (osn.contains('mac')) return "macos-${norm}"
if (osn.contains('windows')) return "windows-${norm}"
throw new GradleException("Unsupported host OS: ${osn}")
}
ext.rvvmLibName = {
String osn = System.getProperty('os.name').toLowerCase()
if (osn.contains('mac')) return 'librvvm.dylib'
if (osn.contains('windows')) return 'rvvm.dll'
return 'librvvm.so'
}
/**
* Output file name for the OpenH264 JNI wrapper on the host platform.
* Matches what `System.mapLibraryName("scev_h264")` returns, so
* `NativeLoader.loadLibrary("scev_h264")` finds the file at runtime.
*/
ext.scevH264LibName = {
String osn = System.getProperty('os.name').toLowerCase()
if (osn.contains('mac')) return 'libscev_h264.dylib'
if (osn.contains('windows')) return 'scev_h264.dll'
return 'libscev_h264.so'
}
/**
* Clone or update the upstream RVVM source tree to {@code build/rvvm-src} at
* the configured ref. Idempotent; does a fresh fetch + hard-reset each run to
* keep the local checkout aligned with the remote ref (which moves).
*/
tasks.register('fetchRvvmSrc') {
group = 'native'
description = "Clone or update ${rvvmUpstream}@${rvvmRef} under build/rvvm-src"
// Intentionally NOT declaring outputs.dir: Gradle pre-creates declared
// output directories, which would make the fresh-vs-update check below
// always take the "update" branch on the first run — `git fetch` into a
// non-repo dir fails. Keep the task input-tracked on ref + URL so a
// change to either invalidates downstream tasks.
inputs.property('rvvmRef', rvvmRef)
inputs.property('rvvmUpstream', rvvmUpstream)
doLast {
def gitDir = new File(rvvmSrcDir, '.git')
if (!gitDir.exists()) {
rvvmSrcDir.parentFile.mkdirs()
if (rvvmSrcDir.exists()) {
// Directory present but not a git checkout (e.g. Gradle created
// it from a previous outputs.dir declaration). Blow it away so
// `git clone` doesn't error with "destination already exists".
delete rvvmSrcDir
}
exec {
commandLine 'git', 'clone', '--depth', '1', '-b', rvvmRef,
rvvmUpstream, rvvmSrcDir.absolutePath
}
} else {
// Reconcile origin URL with rvvmUpstream. Without this, switching
// rvvmUpstream (e.g. to a fork carrying extra patches) has no
// effect on an existing checkout: `git fetch origin` keeps pulling
// from the old URL and the shipped .so silently lags behind the
// Java side that expects the fork's new symbols.
//
// We compare by owner/repo identity, not raw URL, so that
// contributors can `git remote set-url origin git@github.com:...`
// to push via SSH without this task clobbering the change back
// to HTTPS every build. Both
// https://github.com/pufit/RVVM.git
// git@github.com:pufit/RVVM.git
// point at the same repo; either is fine for fetch + push.
def currentUrl = new ByteArrayOutputStream().withStream { os ->
exec {
workingDir rvvmSrcDir
commandLine 'git', 'remote', 'get-url', 'origin'
standardOutput = os
}
os.toString().trim()
}
def repoKey = { String url ->
// Strip scheme, user, host, trailing .git — leaves "owner/repo".
String u = url ?: ''
u = u.replaceFirst(/^(?:https?|ssh):\/\/[^\/@]+(?::[^@]*)?@?[^\/]+\//, '')
.replaceFirst(/^git@[^:]+:/, '')
.replaceFirst(/\.git$/, '')
.toLowerCase()
return u
}
if (repoKey(currentUrl) != repoKey(rvvmUpstream)) {
logger.lifecycle("rvvm-src origin is ${currentUrl}, expected ${rvvmUpstream} — updating remote")
exec { workingDir rvvmSrcDir; commandLine 'git', 'remote', 'set-url', 'origin', rvvmUpstream }
}
exec { workingDir rvvmSrcDir; commandLine 'git', 'fetch', '--depth', '1', 'origin', rvvmRef }
exec { workingDir rvvmSrcDir; commandLine 'git', 'reset', '--hard', "origin/${rvvmRef}" }
}
}
}
/**
* Build librvvm.{so,dylib,dll} for the host platform by running RVVM's
* Makefile target {@code lib USE_JNI=1}, then copy the artifact into
* src/main/resources/natives/<classifier>/. No-op if the library already
* exists at the destination (so `./gradlew build` is incremental).
*
* <p>Windows: requires a POSIX-ish shell (MSYS2/MinGW) on PATH. The CI
* Windows job uses msys2/setup-msys2@v2 — local Windows devs will need a
* similar setup or should prefer running dev builds inside WSL.
*/
tasks.register('buildRvvmNative') {
group = 'native'
description = 'Compile librvvm for the host platform and drop into src/main/resources/natives/'
dependsOn 'fetchRvvmSrc'
def destDir = file("src/main/resources/natives/${rvvmClassifier()}")
def destFile = new File(destDir, rvvmLibName())
outputs.file destFile
// Re-run whenever upstream source identity changes, even if destFile is
// already present from a previous build against a different ref/fork.
inputs.property('rvvmRef', rvvmRef)
inputs.property('rvvmUpstream', rvvmUpstream)
// And track the actual checked-out commit so a moving branch (e.g.
// staging) still invalidates us when the tip advances.
inputs.property('rvvmSrcHead', { ->
def head = new File(rvvmSrcDir, '.git/HEAD')
if (!head.exists()) return ''
def ref = head.text.trim()
if (ref.startsWith('ref: ')) {
def refFile = new File(rvvmSrcDir, ".git/${ref.substring(5)}")
return refFile.exists() ? refFile.text.trim() : ref
}
return ref
})
doLast {
def built = new File(rvvmSrcDir, "release.${rvvmHostTriple()}/${rvvmLibName()}")
// Always invoke make — it's internally incremental, so the no-op cost
// is trivial and it guarantees we pick up source updates. The previous
// `if (!built.exists())` guard was a correctness bug: once an initial
// build had happened, switching the upstream fork/ref would leave the
// stale .so in place because make was never re-run.
logger.lifecycle("Building librvvm via RVVM's Makefile (target: lib USE_JNI=1)...")
exec {
workingDir rvvmSrcDir
commandLine 'make', 'lib', 'USE_JNI=1', "-j${Runtime.runtime.availableProcessors()}"
}
if (!built.exists()) {
throw new GradleException("RVVM build did not produce ${built} — check `make lib USE_JNI=1` output above")
}
destDir.mkdirs()
destFile.bytes = built.bytes
logger.lifecycle("Installed ${destFile.name} -> ${destDir}")
}
}
// Tie the native build into the normal resource-processing pipeline so
// `./gradlew build` (or `./gradlew runClient`) implicitly pulls in the
// host's librvvm. Skippable via `-x buildRvvmNative` when iterating on
// Java-only changes.
processResources.dependsOn 'buildRvvmNative'
// Pull the lekkit.rvvm.* JNI wrapper sources straight from the upstream
// RVVM checkout in build/rvvm-src instead of vendoring a duplicate copy
// under src/main/java/lekkit/rvvm/. The clone is already a build input
// (fetchRvvmSrc above), pinned to ${rvvmRef}, so the wrappers stay in
// lockstep with the librvvm.so they bind against — no risk of the Java
// side declaring a native that the .so doesn't export, or vice versa.
//
// compileJava AND compileKotlin both need the sources present — Kotlin
// runs first in this project's task graph and resolves Java symbols
// from the same srcDir to populate its classpath, so wiring only Java
// would leave Kotlin to fail with "Unresolved reference RVVMNative" on
// a fresh checkout.
sourceSets.main.java.srcDirs += file("${rvvmSrcDir}/src/bindings/jni")
tasks.named('compileJava').configure { dependsOn 'fetchRvvmSrc' }
tasks.named('compileKotlin').configure { dependsOn 'fetchRvvmSrc' }
// --------------------------------------------------------------------
// OpenH264 JNI wrapper native — vendored + built via zig
// --------------------------------------------------------------------
// The H.264 codec lives in a self-contained JNI library alongside
// librvvm:
//
// vendor/openh264/ vendored Cisco OpenH264 source (v2.6.0)
// native/openh264-jni/ our thin JNI wrapper + Makefile
// src/main/resources/natives/<classifier>/libscev_h264.*
// final product, statically linked
//
// Build pipeline (`buildH264Native`):
// 1. Mirror vendor/openh264/ to build/openh264-src/ — OpenH264's own
// Makefile is an in-tree build (writes .o/.a next to sources); we
// keep the committed vendor tree clean by doing the actual build
// in build/.
// 2. Invoke native/openh264-jni/Makefile with OPENH264_SRC pointed at
// build/openh264-src/. That Makefile first builds libopenh264.a
// (zig c++, USE_ASM=No), then compiles scev_h264.c (zig cc) and
// links both into a single libscev_h264.{so,dylib,dll}.
// 3. Copy the output into src/main/resources/natives/<classifier>/.
//
// Same CI artifact story as librvvm: per-platform matrix jobs build
// their own libscev_h264, the main build job downloads all three into
// the classifier dirs. See .github/workflows/ci.yml.
ext.openh264VendorDir = file('vendor/openh264')
ext.openh264BuildSrc = file("${buildDir}/openh264-src")
/**
* Mirror the vendored OpenH264 source into build/openh264-src/ so the
* in-tree build (.o, .a) doesn't pollute the committed vendor copy.
* Gradle's Copy-task up-to-date check makes this effectively a no-op
* on repeat runs unless the vendor tree actually changes.
*/
tasks.register('prepareOpenH264Src', Copy) {
group = 'native'
description = 'Mirror vendor/openh264/ to build/openh264-src/ for an out-of-tree build.'
from openh264VendorDir
into openh264BuildSrc
}
/**
* Build libscev_h264.{so,dylib,dll} for the host platform. Requires
* zig + make on PATH. On NixOS the dev shell (see flake.nix) brings
* both in; CI installs zig via mlugg/setup-zig and uses the runner's
* default make (MSYS2's on Windows).
*/
tasks.register('buildH264Native') {
group = 'native'
description = 'Build libscev_h264 for the host platform and drop into src/main/resources/natives/'
dependsOn 'prepareOpenH264Src'
def jniSrcDir = file('native/openh264-jni')
def destDir = file("src/main/resources/natives/${rvvmClassifier()}")
def destFile = new File(destDir, scevH264LibName())
outputs.file destFile
// Input tracking is against the committed vendor tree, not the
// copied build/openh264-src/: CI's main-build job downloads a
// pre-built libscev_h264 artifact, and we want gradle's up-to-date
// check to skip `make` in that case without needing zig on the
// main runner. Vendor tree content is what actually determines
// whether rebuilding is necessary.
inputs.dir(openh264VendorDir)
inputs.file(new File(jniSrcDir, 'scev_h264.c'))
inputs.file(new File(jniSrcDir, 'Makefile'))
doLast {
destDir.mkdirs()
def jdkPath = System.getProperty('java.home')
logger.lifecycle("Building ${destFile.name} via native/openh264-jni/Makefile (zig cc)...")
exec {
workingDir jniSrcDir
commandLine 'make', "-j${Runtime.runtime.availableProcessors()}"
environment 'JAVA_HOME', jdkPath
environment 'OPENH264_SRC', openh264BuildSrc.absolutePath
environment 'OUTPUT', destFile.absolutePath
}
if (!destFile.exists()) {
throw new GradleException("H.264 native build did not produce ${destFile} — check `make` output above")
}
logger.lifecycle("Installed ${destFile.name} -> ${destDir}")
}
}
processResources.dependsOn 'buildH264Native'
// --------------------------------------------------------------------
// mlterm-fb-embed — host-side terminal renderer (xterm-class)
// --------------------------------------------------------------------
//
// Builds libscev_term.{so,dylib,dll} by linking SolAstrius/mlterm-fb-embed
// (branch fb-embed) statically into native/mlterm-jni/scev_term.c +
// scev_term_jni.c. Same vendor/JNI shape as openh264 except the source
// tree lives in a separate fork (out of this repo) — MLTERM_SRC must
// point at a checkout. CI clones into build/mlterm-src/; dev shells
// can point at a local working copy directly.
//
// Pinned at: SolAstrius/mlterm-fb-embed @ fb-embed (no SHA pin yet —
// the branch is still moving; pin to a specific SHA once the surface
// stabilises).
//
// Outputs:
// src/main/resources/natives/<classifier>/libscev_term.{so,dylib,dll}
// src/main/resources/natives/<classifier>/mlterm-libexec/{mlimgloader,registobmp}
//
// The libexec helpers ride along beside the .so because mlterm execs
// them at runtime for image loading + ReGIS rendering. NativeLoader
// reads MLTERM_LIBEXECDIR from the env (set in the same code path
// that does System.load) so the .so finds them next to itself.
ext.mltermSrcDefault = file("${buildDir}/mlterm-src")
ext.mltermFork = 'https://github.com/SolAstrius/mlterm-fb-embed.git'
ext.mltermRef = 'fb-embed'
ext.scevTermLibName = {
String osn = System.getProperty('os.name').toLowerCase()
if (osn.contains('mac')) return 'libscev_term.dylib'
if (osn.contains('windows')) return 'scev_term.dll'
return 'libscev_term.so'
}
/**
* Resolve where the mlterm source lives. Honors -PMLTERM_SRC=path
* (Gradle property) and MLTERM_SRC (env var) for dev iteration on a
* local fork checkout; falls back to build/mlterm-src/ which the
* fetchMltermSrc task populates.
*/
ext.resolveMltermSrc = {
if (project.hasProperty('MLTERM_SRC')) return file(project.MLTERM_SRC)
String env = System.getenv('MLTERM_SRC')
if (env != null && !env.isEmpty()) return file(env)
return mltermSrcDefault
}
ext.mltermSrcShaFile = file("${buildDir}/mlterm-src.sha")
tasks.register('fetchMltermSrc') {
group = 'native'
description = "Clone or update ${mltermFork}@${mltermRef} under build/mlterm-src; record HEAD SHA"
inputs.property('ref', mltermRef)
inputs.property('url', mltermFork)
inputs.property('override', project.hasProperty('MLTERM_SRC') ? project.MLTERM_SRC.toString()
: (System.getenv('MLTERM_SRC') ?: ''))
outputs.file mltermSrcShaFile
outputs.upToDateWhen { false } // always re-check upstream HEAD; stamp file dedupes downstream work
doLast {
def overridden = project.hasProperty('MLTERM_SRC') || System.getenv('MLTERM_SRC') != null
def dir = resolveMltermSrc()
if (!overridden) {
if (!dir.exists()) {
dir.parentFile.mkdirs()
exec { commandLine 'git', 'clone', '--branch', mltermRef, '--depth', '1', mltermFork, dir.absolutePath }
} else {
exec { workingDir dir; commandLine 'git', 'fetch', 'origin', mltermRef }
exec { workingDir dir; commandLine 'git', 'reset', '--hard', "origin/${mltermRef}" }
}
}
def sha
if (dir.exists() && new File(dir, '.git').exists()) {
def out = new ByteArrayOutputStream()
exec {
workingDir dir
commandLine 'git', 'rev-parse', 'HEAD'
standardOutput = out
}
sha = out.toString().trim()
} else {
// Non-git override (e.g. extracted tarball): hash the tree mtime as a fallback signal.
sha = "notgit:${dir.absolutePath}:${System.currentTimeMillis()}"
}
mltermSrcShaFile.parentFile.mkdirs()
mltermSrcShaFile.text = sha + '\n'
}
}
tasks.register('buildMltermNative') {
group = 'native'
description = 'Build libscev_term + mlterm libexec helpers, drop into src/main/resources/natives/'
dependsOn 'fetchMltermSrc'
def jniSrcDir = file('native/mlterm-jni')
def destDir = file("src/main/resources/natives/${rvvmClassifier()}")
def destFile = new File(destDir, scevTermLibName())
def libexecDir = new File(destDir, 'mlterm-libexec')
inputs.file(new File(jniSrcDir, 'scev_term.c'))
inputs.file(new File(jniSrcDir, 'scev_term_jni.c'))
inputs.file(new File(jniSrcDir, 'scev_term.h'))
inputs.file(new File(jniSrcDir, 'Makefile'))
inputs.file(new File(jniSrcDir, 'build-freetype.sh'))
inputs.dir(new File(jniSrcDir, 'jni_headers'))
// The vendored FreeType tarball is the sole external C dep; a version
// bump must rebuild the native. (Cross-target builds are CI's job, same
// as librvvm/libscev_h264 — this task only builds for the host.)
inputs.files(fileTree('vendor/freetype'))
inputs.property('mltermRef', mltermRef)
inputs.file(mltermSrcShaFile).withPropertyName('mltermSrcSha')
outputs.file destFile
outputs.dir libexecDir
doLast {
destDir.mkdirs()
def mltermSrc = resolveMltermSrc()
if (!mltermSrc.exists()) {
throw new GradleException("MLTERM_SRC ${mltermSrc} doesn't exist (fetchMltermSrc didn't populate it?)")
}
def jdkPath = System.getProperty('java.home')
logger.lifecycle("Building ${destFile.name} via native/mlterm-jni/Makefile (zig cc, MLTERM_SRC=${mltermSrc})...")
exec {
workingDir jniSrcDir
commandLine 'make', "-j${Runtime.runtime.availableProcessors()}"
environment 'JAVA_HOME', jdkPath
environment 'MLTERM_SRC', mltermSrc.absolutePath
environment 'OUTPUT', destFile.absolutePath
}
if (!destFile.exists()) {
throw new GradleException("mlterm native build did not produce ${destFile} — check `make` output above")
}
logger.lifecycle("Installed ${destFile.name} -> ${destDir}")
}
}
processResources.dependsOn 'buildMltermNative'
// --------------------------------------------------------------------
// Alpine preloaded-NVMe image — fetched from scev-alpine's CI release
// --------------------------------------------------------------------
// scev-alpine's GitHub Actions workflow rebuilds the sys-install image
// on every push to its main branch and on a weekly cron. Each run
// overwrites the rolling `latest` release tag with fresh artefacts —
// see https://github.com/SolAstrius/scev-alpine/releases/tag/latest.
// We just download the .img.zst from there, decompress it, and drop
// it in resources. No Docker, no kernel cross-build on the mod side.
//
// Why we don't build it here: the kernel cross-compile is a 30-minute
// job even with ccache hot. scev-alpine pays that cost once per push
// in CI and ships the result; mod consumers should pay zero seconds.
// Older revisions of this task did Docker + sibling clone; cut for
// the obvious reason (45 min cold first build is hostile).
//
// Pinning: alpineRel tracks scev-alpine's `ALPINE_REL` (the exact
// Alpine point release the upstream's `latest-releases.yaml` resolves
// to at build time). Bump when scev-alpine moves to a new point
// release — the asset filename in the release embeds the version, so
// a stale pin would 404. The `latest` tag itself stays stable.
//
// Offline / air-gapped builds: drop the .img.zst into
// ${buildDir}/alpine/ manually before invoking Gradle. The task
// short-circuits the curl when the archive is already present.
ext.alpineVersion = '3.23'
ext.alpineRel = '3.23.4'
ext.scevAlpineRepo = 'SolAstrius/scev-alpine'
ext.scevAlpineReleaseTag = 'latest'
tasks.register('fetchAlpineImage') {
group = 'native'
description = "Download scev-alpine's sys-install NVMe image from the rolling 'latest' release"
def assetName = "alpine-scev-sysinstall-${alpineRel}-riscv64.img.zst"
def downloadUrl = "https://github.com/${scevAlpineRepo}/releases/download/${scevAlpineReleaseTag}/${assetName}"
def cacheDir = file("${buildDir}/alpine")
def archive = file("${cacheDir}/${assetName}")
def destFile = file('src/main/resources/assets/scev/firmware/alpine_rootfs.img')
// URL is an input so a pin bump (alpineRel) invalidates the task and
// forces a re-download even if the cached archive name happens to
// collide. The 'latest' release tag is rolling — CI rebuilds the
// sys-install image under the same tag whenever scev-alpine's main
// moves — so we also probe the asset's HEAD and feed Last-Modified +
// Content-Length in as inputs. That way a CI re-roll on an unchanged
// alpineRel still invalidates the task without us having to manually
// wipe the cache. Probe is wrapped in a Provider so it runs at
// up-to-date check time, not configuration time, and only when this
// task is actually in the graph. Probe failures (offline, GitHub
// down) collapse to a stable sentinel so air-gapped builds with a
// pre-populated cache still succeed.
inputs.property('downloadUrl', downloadUrl)
inputs.property('remoteAssetFingerprint', providers.provider {
try {
def out = new ByteArrayOutputStream()
exec {
commandLine 'curl', '-fsSLI', downloadUrl
standardOutput = out
ignoreExitValue = true
}
def headers = out.toString('UTF-8').toLowerCase().readLines()
def lastMod = headers.findAll { it.startsWith('last-modified:') }.last()
def length = headers.findAll { it.startsWith('content-length:') }.last()
"${lastMod.trim()}|${length.trim()}".toString()
} catch (Throwable ignored) {
'offline'
}
})
outputs.file destFile