From 34092da6bebbbd832a69c2edf61b133a805fe318 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Fri, 21 Aug 2026 02:03:24 -0400 Subject: [PATCH] d --- ...ucturePostProcessorSurfaceTerrainTest.java | 219 +++++++++++------- adapters/bukkit/plugin/build.gradle | 2 +- .../src/main/java/art/arcane/iris/Iris.java | 4 +- .../iris/core/SettingsHotloadWatch.java | 48 +++- .../iris/core/commands/CommandJigsaw.java | 10 + .../art/arcane/iris/core/service/WandSVC.java | 2 +- .../commands/CommandJigsawContractTest.java | 20 +- adapters/fabric/build.gradle | 2 +- adapters/forge/build.gradle | 2 +- .../NativeStructureSurfaceFitter.java | 140 +++++------ .../service/ModdedSettingsHotloadService.java | 104 +++++++-- .../service/ModdedStudioHotloadService.java | 23 +- adapters/neoforge/build.gradle | 2 +- build.gradle | 2 +- core/build.gradle | 2 +- core/purity-allowlist.txt | 1 - .../art/arcane/iris/core/IrisSettings.java | 61 +++++ .../iris/core/localization/IrisLanguage.java | 211 ++++++++++++++--- .../core/localization/LocaleHotloadGate.java | 100 ++++++++ .../localization/LocaleHotloadSnapshot.java | 29 +++ .../project/StudioOpenProgressReporter.java | 4 +- .../iris/core/runtime/ChunkJobReporter.java | 6 +- .../service/PackDownloadProgressReporter.java | 41 ++-- .../arcane/iris/core/service/StudioSVC.java | 49 ++-- .../iris/core/structure/VillageImporter.java | 2 +- .../arcane/iris/core/tools/IrisCreator.java | 12 +- .../tools/WorldCreationProgressReporter.java | 4 +- .../iris/engine/object/IrisObjectIO.java | 4 +- .../engine/platform/BukkitChunkGenerator.java | 9 +- .../iris/platform/bukkit/BukkitPlatform.java | 4 + .../common/scheduling/jobs/DownloadJob.java | 4 +- .../common/scheduling/jobs/JobCollection.java | 2 +- .../util/common/scheduling/jobs/QueueJob.java | 2 +- .../common/scheduling/jobs/SingleJob.java | 2 +- .../iris/core/IrisSettingsDefaultsTest.java | 63 +++++ .../iris/core/ProgressBossBarToggleTest.java | 89 +++++++ .../IrisLanguageHotloadSnapshotTest.java | 55 +++++ .../core/localization/IrisLanguageTest.java | 22 ++ .../localization/LocaleHotloadGateTest.java | 159 +++++++++++++ .../service/StudioSVCPackAdmissionTest.java | 60 +++++ .../structure/VillageImporterBundleTest.java | 6 +- gradle.properties | 2 +- probe/build.gradle | 2 +- 43 files changed, 1308 insertions(+), 279 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/core/localization/LocaleHotloadGate.java create mode 100644 core/src/main/java/art/arcane/iris/core/localization/LocaleHotloadSnapshot.java create mode 100644 core/src/test/java/art/arcane/iris/core/ProgressBossBarToggleTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/localization/IrisLanguageHotloadSnapshotTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/localization/LocaleHotloadGateTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/StudioSVCPackAdmissionTest.java diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java index be7a7f319..cd8704091 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java @@ -100,13 +100,17 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { } @Test - public void sourceBeardAdjustmentsPrepareTerrainAcrossDeclaredSteps() { + public void sourceBeardAdjustmentsPrepareOnlyAtTheSurfaceStructuresStep() { assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( TerrainAdjustment.BEARD_THIN, GenerationStep.Decoration.SURFACE_STRUCTURES)); - assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( + assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( TerrainAdjustment.BEARD_BOX, GenerationStep.Decoration.FLUID_SPRINGS)); - assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( + assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( TerrainAdjustment.BEARD_THIN, GenerationStep.Decoration.UNDERGROUND_STRUCTURES)); + assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( + TerrainAdjustment.BEARD_BOX, GenerationStep.Decoration.UNDERGROUND_DECORATION)); + assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( + TerrainAdjustment.BEARD_THIN, GenerationStep.Decoration.STRONGHOLDS)); } @Test @@ -119,6 +123,31 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { TerrainAdjustment.NONE, GenerationStep.Decoration.FLUID_SPRINGS)); } + @Test + public void shallowUndergroundBeardBoxDoesNotMutateTheTopSurface() throws Exception { + PoolElementStructurePiece piece = rigidTemplatePiece( + new InlineSinglePoolElement(template(List.of( + block(0, 1, 0, Blocks.DEEPSLATE_BRICKS.defaultBlockState())))), + new BoundingBox(0, 67, 0, 0, 74, 0), 1, Rotation.NONE); + Structure structure = new DesertPyramidStructure( + new Structure.StructureSettings( + HolderSet.empty(), Map.of(), + GenerationStep.Decoration.UNDERGROUND_DECORATION, + TerrainAdjustment.BEARD_BOX)); + StructureStart start = new StructureStart( + structure, new ChunkPos(0, 0), 0, + new PiecesContainer(List.of(piece))); + BoundingBox area = new BoundingBox(0, 48, 0, 0, 80, 0); + Map blocks = flatTerrain(area, 64); + Map before = new HashMap<>(blocks); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, List.of(surfaceTarget(start)), + (x, z) -> 64); + + assertEquals(before, blocks); + } + @Test public void explicitTerrainOverrideDisablesSourceBeardFitting() { StructureStart start = desertStart(TerrainAdjustment.BEARD_BOX); @@ -158,85 +187,70 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { } @Test - public void beardBoxUsesTheFullRigidHeightWhileBeardThinUsesTheGroundPlane() { - NativeStructureSurfaceFitter.SurfaceAnchor thin = - new NativeStructureSurfaceFitter.SurfaceAnchor(0, 4, 0, 4, 64, 2); - NativeStructureSurfaceFitter.SurfaceAnchor box = - new NativeStructureSurfaceFitter.SurfaceAnchor( - 0, 4, 0, 4, 64, 2, 65, 90); - - assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget( - List.of(thin), 2, 2, 80)); - assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget( - List.of(box), 2, 2, 80)); - } - - @Test - public void beardInfluenceBeginsAtGroundYRatherThanTheSurfaceBlock() { + public void sourceSurfaceAnchorMeetsOneBlockBelowTheAuthoredGroundPlane() { BoundingBox bounds = new BoundingBox(0, 64, 0, 4, 90, 4); - NativeStructureSurfaceFitter.SurfaceAnchor thin = - NativeStructureSurfaceFitter.surfaceAnchor( - bounds, 68, 2, TerrainAdjustment.BEARD_THIN); - NativeStructureSurfaceFitter.SurfaceAnchor box = - NativeStructureSurfaceFitter.surfaceAnchor( - bounds, 68, 2, TerrainAdjustment.BEARD_BOX); + NativeStructureSurfaceFitter.SurfaceAnchor anchor = + NativeStructureSurfaceFitter.surfaceAnchor(bounds, 68, 2); - assertEquals(67, thin.meetY()); - assertEquals(0, thin.verticalDistance(68)); - assertEquals(1, thin.verticalDistance(67)); - assertEquals(0, box.verticalDistance(68)); - assertEquals(1, box.verticalDistance(67)); - assertEquals(0, box.verticalDistance(90)); - assertEquals(1, box.verticalDistance(91)); + assertEquals(67, anchor.meetY()); + assertEquals(0, anchor.minX()); + assertEquals(4, anchor.maxX()); + assertEquals(0, anchor.minZ()); + assertEquals(4, anchor.maxZ()); } @Test - public void rigidFootprintsRaiseTerrainAcrossGapsLargerThanTheBeardKernel() { + public void rigidFootprintsBoundTerrainAcrossGapsLargerThanTheBeardKernel() { NativeStructureSurfaceFitter.SurfaceAnchor rigid = anchor(72, 2); NativeStructureSurfaceFitter.SurfaceAnchor junction = anchor(72, 1); - assertEquals(72, NativeStructureSurfaceFitter.resolveSurfaceTarget( + assertEquals(46, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(rigid), 2, 2, 40)); - assertEquals(40, NativeStructureSurfaceFitter.resolveSurfaceTarget( + assertEquals(46, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(junction), 2, 2, 40)); } @Test - public void elevatedRigidFootprintsBlendIntoAHorizontalRescueTaper() { - int originalY = 40; - int kernelEdge = NativeStructureSurfaceFitter.resolveSurfaceTarget( - List.of(anchor(51, 2)), 5, 2, originalY); - int gapThirteen = NativeStructureSurfaceFitter.resolveSurfaceTarget( - List.of(anchor(52, 2)), 5, 2, originalY); - int gapEighteen = NativeStructureSurfaceFitter.resolveSurfaceTarget( - List.of(anchor(57, 2)), 5, 2, originalY); - int gapTwentyFour = NativeStructureSurfaceFitter.resolveSurfaceTarget( - List.of(anchor(63, 2)), 5, 2, originalY); + public void beardSkirtBoundsAndGradesUpwardAndDownwardSymmetrically() { + int originalY = 64; + NativeStructureSurfaceFitter.SurfaceAnchor high = anchor(96, 2); + NativeStructureSurfaceFitter.SurfaceAnchor low = anchor(32, 2); + int[] raised = new int[13]; + int[] lowered = new int[13]; + for (int outset = 0; outset <= 12; outset++) { + int x = 4 + outset; + raised[outset] = NativeStructureSurfaceFitter.resolveSurfaceTarget( + List.of(high), x, 2, originalY); + lowered[outset] = NativeStructureSurfaceFitter.resolveSurfaceTarget( + List.of(low), x, 2, originalY); + } - assertEquals(originalY, kernelEdge); - assertTrue(gapThirteen >= kernelEdge); - assertTrue(gapEighteen > gapThirteen); - assertTrue(gapTwentyFour > gapEighteen); - assertTrue(gapTwentyFour < 63); - assertEquals(originalY, NativeStructureSurfaceFitter.resolveSurfaceTarget( - List.of(anchor(63, 2)), 16, 2, originalY)); - assertEquals(originalY, NativeStructureSurfaceFitter.resolveSurfaceTarget( - List.of(anchor(63, 1)), 5, 2, originalY)); - assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget( - List.of(anchor(64, 2)), 5, 2, 80)); + assertArrayEquals( + new int[]{70, 69, 68, 67, 67, 66, 66, 65, 65, 64, 64, 64, 64}, + raised); + assertArrayEquals( + new int[]{58, 59, 60, 61, 61, 62, 62, 63, 63, 64, 64, 64, 64}, + lowered); + for (int outset = 0; outset <= 12; outset++) { + assertEquals(raised[outset] - originalY, originalY - lowered[outset]); + } } @Test - public void projectedCenterAndRigidChildrenShareTerrainSupportAcrossTheAssembly() { + public void projectedCenterAndRigidChildrenUseProcessedFoundationCells() throws Exception { Structure structure = new DesertPyramidStructure( new Structure.StructureSettings( HolderSet.empty(), Map.of(), GenerationStep.Decoration.SURFACE_STRUCTURES, TerrainAdjustment.BEARD_BOX)); - PoolElementStructurePiece center = rigidPiece( - new BoundingBox(0, 71, 0, 3, 80, 3), 1); - PoolElementStructurePiece child = rigidPiece( - new BoundingBox(10, 65, 0, 13, 76, 3), 7); + PoolElementStructurePiece center = rigidTemplatePiece( + new InlineSinglePoolElement(template(List.of( + block(1, 1, 1, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(0, 71, 0, 3, 80, 3), 1, Rotation.NONE); + PoolElementStructurePiece child = rigidTemplatePiece( + new InlineSinglePoolElement(template(List.of( + block(1, 7, 1, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(10, 65, 0, 13, 76, 3), 7, Rotation.NONE); StructureStart start = new StructureStart( structure, new ChunkPos(0, 0), 0, new PiecesContainer(List.of(center, child))); @@ -244,8 +258,8 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { Map blocks = new HashMap<>(); for (int x = area.minX(); x <= area.maxX(); x++) { for (int z = area.minZ(); z <= area.maxZ(); z++) { - put(blocks, x, 63, z, Blocks.DIRT.defaultBlockState()); - put(blocks, x, 64, z, Blocks.GRASS_BLOCK.defaultBlockState()); + put(blocks, x, 64, z, Blocks.DIRT.defaultBlockState()); + put(blocks, x, 65, z, Blocks.GRASS_BLOCK.defaultBlockState()); } } @@ -255,21 +269,66 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE))); NativeStructureSurfaceFitter.prepareSurfaceStructures( world(blocks), area, targets, - (x, z) -> 64); + (x, z) -> 65); assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 1, 71, 1)); assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 11, 71, 1)); assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 11, 70, 1)); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 2, 65, 2)); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 12, 65, 2)); } @Test - public void terrainMatchingFootprintsRaiseAndLowerOnlyLowestSolidColumns() throws Exception { + public void sparseRigidFootprintsIgnoreAirAndSolidsAboveTheGroundPlane() throws Exception { + StructureTemplate sparseTemplate = template(List.of( + block(0, 1, 0, Blocks.COBBLESTONE.defaultBlockState()), + block(15, 1, 0, Blocks.AIR.defaultBlockState()), + block(30, 5, 0, Blocks.STONE.defaultBlockState()))); + PoolElementStructurePiece piece = rigidTemplatePiece( + new InlineSinglePoolElement(sparseTemplate), + new BoundingBox(0, 68, 0, 30, 76, 0), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart( + List.of(piece), TerrainAdjustment.BEARD_THIN); + BoundingBox area = new BoundingBox(0, 52, 0, 30, 80, 0); + Map blocks = flatTerrain(area, 64); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, List.of(surfaceTarget(start)), + (x, z) -> 64); + + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 0, 68, 0)); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 15, 64, 0)); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 30, 64, 0)); + } + + @Test + public void extremeRigidFoundationGapDoesNotCreateATerrainPillar() throws Exception { + StructureTemplate template = template(List.of( + block(0, 1, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece piece = rigidTemplatePiece( + new InlineSinglePoolElement(template), + new BoundingBox(0, 90, 0, 0, 96, 0), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart( + List.of(piece), TerrainAdjustment.BEARD_THIN); + BoundingBox area = new BoundingBox(0, 30, 0, 0, 100, 0); + Map blocks = flatTerrain(area, 40); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, List.of(surfaceTarget(start)), + (x, z) -> 40); + + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 0, 46, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 0, 47, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 0, 89, 0)); + } + + @Test + public void terrainMatchingFootprintsFollowProcessedLowestSolidColumns() throws Exception { StructureTemplate pathTemplate = template(List.of( block(0, 0, 0, Blocks.AIR.defaultBlockState()), - block(0, 1, 0, Blocks.DIRT_PATH.defaultBlockState()), + block(0, 2, 0, Blocks.DIRT.defaultBlockState()), block(15, 0, 0, Blocks.AIR.defaultBlockState()), - block(30, 0, 0, Blocks.AIR.defaultBlockState()), - block(30, 1, 0, Blocks.DIRT_PATH.defaultBlockState()), + block(30, 0, 0, Blocks.DIRT.defaultBlockState()), block(45, 0, 0, Blocks.DANDELION.defaultBlockState()))); InlineSinglePoolElement element = new InlineSinglePoolElement( pathTemplate, List.of(), StructureTemplatePool.Projection.TERRAIN_MATCHING); @@ -290,8 +349,8 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { world(blocks), area, List.of(surfaceTarget(start)), (x, z) -> x == 0 ? 60 : x == 30 ? 72 : 64); - assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 0, 68, 0)); - assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 30, 68, 0)); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 0, 61, 0)); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 30, 71, 0)); assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 30, 72, 0)); assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 15, 64, 0)); assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 15, 65, 0)); @@ -770,10 +829,10 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { } @Test - public void surfaceAnchorIsFlushInsideAndUnchangedAtRadius() { + public void surfaceAnchorCapsInsideAndIsUnchangedAtRadius() { NativeStructureSurfaceFitter.SurfaceAnchor anchor = anchor(72, 2); - assertEquals(72, NativeStructureSurfaceFitter.resolveSurfaceTarget( + assertEquals(70, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(anchor), 2, 2, 64)); assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(anchor), 16, 2, 64)); @@ -786,7 +845,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { NativeStructureSurfaceFitter.SurfaceAnchor raised = anchor(68, 2); NativeStructureSurfaceFitter.SurfaceAnchor lowered = anchor(64, 2); - assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget( + assertEquals(65, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(raised), 10, 2, 64)); assertEquals(67, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(lowered), 10, 2, 68)); @@ -1197,16 +1256,20 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { } @Test - public void stackedRigidPiecesPreserveTheLowerAuthoredSurface() { + public void stackedRigidPiecesPreserveTheLowerProcessedFoundation() throws Exception { Structure structure = new DesertPyramidStructure( new Structure.StructureSettings( HolderSet.empty(), Map.of(), GenerationStep.Decoration.SURFACE_STRUCTURES, TerrainAdjustment.BEARD_BOX)); - PoolElementStructurePiece lower = rigidPiece( - new BoundingBox(0, 62, 0, 4, 65, 4), 1); - PoolElementStructurePiece upper = rigidPiece( - new BoundingBox(0, 66, 0, 4, 78, 4), 1); + PoolElementStructurePiece lower = rigidTemplatePiece( + new InlineSinglePoolElement(template(List.of( + block(2, 1, 2, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(0, 62, 0, 4, 65, 4), 1, Rotation.NONE); + PoolElementStructurePiece upper = rigidTemplatePiece( + new InlineSinglePoolElement(template(List.of( + block(2, 1, 2, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(0, 66, 0, 4, 78, 4), 1, Rotation.NONE); StructureStart start = new StructureStart( structure, new ChunkPos(0, 0), 0, new PiecesContainer(List.of(upper, lower))); @@ -1236,7 +1299,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { NativeStructureSurfaceFitter.SurfaceAnchor adjacent = new NativeStructureSurfaceFitter.SurfaceAnchor(5, 9, 0, 4, 70, 2); - assertEquals(65, NativeStructureSurfaceFitter.resolveSurfaceTarget( + assertEquals(69, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(adjacent), 4, 2, 64)); assertEquals(66, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(local, adjacent), 4, 2, 64)); diff --git a/adapters/bukkit/plugin/build.gradle b/adapters/bukkit/plugin/build.gradle index 75104bc8c..88404947e 100644 --- a/adapters/bukkit/plugin/build.gradle +++ b/adapters/bukkit/plugin/build.gradle @@ -3,7 +3,7 @@ def mainClass = 'art.arcane.iris.Iris' def bootstrapperClass = 'art.arcane.iris.IrisBootstrap' String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') - .orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522') + .orElse('com.github.VolmitSoftware:VolmLib:e6574cf814b3dd670385a4d632a63fde2038e186') .get() dependencies { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index e0f417f2d..40e5da5cc 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -669,7 +669,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { watch::readSettingsContent, watch::normalizeSettingsContent ); - configHotloadEngine.configure(3_000L, List.of(watch.settingsFile()), List.of()); + configHotloadEngine.configure(500L, 3_000L, List.of(watch.settingsFile()), List.of()); // Stale-temp cleanup must complete before services enable: StudioSVC.onEnable downloads // packs through cache/temp on an async thread, and a concurrent delete of that folder // truncated pack imports mid-copy (partial packs/ without dimensions/). @@ -714,7 +714,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { pendingWorldReplacements.captureVanillaLevelContext(); pendingWorldReplacements.verifyLoadedPublishedWorlds(); J.a(this::bstats); - J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 60); + J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 10); J.sr(this::tickQueue, 0); J.s(this::setupPapi); if (IrisStartupValidation.isReady()) { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/SettingsHotloadWatch.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/SettingsHotloadWatch.java index d3f264ceb..9ccd9c57a 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/SettingsHotloadWatch.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/SettingsHotloadWatch.java @@ -21,9 +21,12 @@ package art.arcane.iris.core; import art.arcane.iris.Iris; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.volmlib.util.hotload.ConfigHotloadEngine; -import art.arcane.volmlib.util.io.IO; import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.List; /** @@ -31,6 +34,8 @@ import java.util.List; * {@link ConfigHotloadEngine} is built from and drains the touched-file queue. */ public final class SettingsHotloadWatch { + private static final int MAX_SETTINGS_BYTES = 2 * 1024 * 1024; + private final File settingsFile; public SettingsHotloadWatch(File settingsFile) { @@ -46,13 +51,17 @@ public final class SettingsHotloadWatch { return; } - for (File file : engine.pollTouchedFiles()) { - engine.processFileChange(file, ignored -> { - IrisSettings.invalidate(); - IrisSettings.get(); - IrisLanguage.reload(); - return true; - }, ignored -> Iris.info("Hotloaded settings.json ")); + for (ConfigHotloadEngine.StableContentSnapshot snapshot : engine.pollTouchedSnapshots()) { + if ("missing".equals(snapshot.signature())) { + engine.processSnapshotChange(snapshot, ignored -> true, null); + Iris.warn("settings.json was removed; retaining the last valid runtime settings."); + continue; + } + engine.processSnapshotChange( + snapshot, + stable -> applySettingsSnapshot(stable.file(), stable.normalizedContent()), + ignored -> Iris.info("Hotloaded settings.json ") + ); } IrisLanguage.update(); } @@ -76,8 +85,12 @@ public final class SettingsHotloadWatch { return null; } - try { - return IO.readAll(file); + try (InputStream input = Files.newInputStream(file.toPath())) { + byte[] content = input.readNBytes(MAX_SETTINGS_BYTES + 1); + if (content.length > MAX_SETTINGS_BYTES) { + throw new IOException("Settings exceed " + MAX_SETTINGS_BYTES + " bytes: " + file); + } + return new String(content, StandardCharsets.UTF_8); } catch (Throwable ex) { Iris.warn("Failed to read settings file %s: %s%s", file.getAbsolutePath(), @@ -95,4 +108,19 @@ public final class SettingsHotloadWatch { return text.replace("\r\n", "\n").trim(); } + + private boolean applySettingsSnapshot(File file, String content) { + if (content == null) { + return false; + } + try { + return IrisSettings.applyHotloadSnapshot(content, IrisLanguage::reload); + } catch (RuntimeException failure) { + Iris.warn("Rejected invalid settings hotload from %s: %s", + file.getAbsolutePath(), + failure.getMessage() == null ? failure.getClass().getSimpleName() : failure.getMessage()); + Iris.reportError(failure); + return false; + } + } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java index a9c52a36f..6c0d302c6 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java @@ -2,6 +2,7 @@ package art.arcane.iris.core.commands; import art.arcane.iris.Iris; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.BrokenPackException; import art.arcane.iris.core.runtime.WorldRuntimeControlService; import art.arcane.iris.core.runtime.StudioOpenCoordinator; import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype; @@ -960,6 +961,11 @@ public class CommandJigsaw implements DirectorExecutor { Throwable failure = unwrapCompletionFailure(throwable == null ? new IllegalStateException("Studio open completed without a result") : throwable); + if (isExpectedOpenDenial(failure)) { + Iris.warn("Jigsaw Studio open for '%s' was denied: %s", + structureKey, failure.getMessage()); + return; + } Iris.reportError("Failed to open Jigsaw Studio for '" + structureKey + "'.", failure); sendError(commandSender, "Jigsaw Studio open failed: " + failure.getMessage()); } @@ -1022,6 +1028,10 @@ public class CommandJigsaw implements DirectorExecutor { return current; } + static boolean isExpectedOpenDenial(Throwable failure) { + return unwrapCompletionFailure(failure) instanceof BrokenPackException; + } + private void sendError(String message) { sender().sendMessage(C.RED + message); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/WandSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/WandSVC.java index 9e6b57eaf..5afa2c8f6 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/WandSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/WandSVC.java @@ -101,7 +101,7 @@ public class WandSVC implements IrisService { var latch = new CountDownLatch(1); var holder = Iris.tickets.getHolder(p.getWorld()); new Job() { - private int i; + private volatile int i; private Chunk chunk; @Override diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandJigsawContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandJigsawContractTest.java index 393d4b552..d464d170a 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandJigsawContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandJigsawContractTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.core.commands; +import art.arcane.iris.core.pack.BrokenPackException; import art.arcane.iris.core.runtime.StudioOpenCoordinator; import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; @@ -7,6 +8,7 @@ import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariantCatalog; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioWorkcellSpec; +import art.arcane.iris.core.service.JigsawStudioService; import art.arcane.iris.core.structure.authoring.StructureBackend; import art.arcane.iris.core.structure.authoring.StructureCapability; import art.arcane.iris.core.structure.authoring.StructureHash; @@ -17,7 +19,6 @@ import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionInputKind; import art.arcane.iris.core.structure.export.VanillaJigsawExportFormat; import art.arcane.iris.core.tools.IrisCreator; -import art.arcane.iris.core.service.JigsawStudioService; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDirection; import art.arcane.iris.engine.object.IrisJigsawConnector; @@ -44,6 +45,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CompletionException; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; @@ -101,6 +103,18 @@ public class CommandJigsawContractTest { CommandJigsaw.STUDIO_OPEN_KIND.datapackPreparation()); } + @Test + public void packValidationDenialsAreExpectedOpenFailures() { + BrokenPackException failure = new BrokenPackException( + "overworld", List.of("Biome 'broken' has no resolvable regions.")); + + assertTrue(CommandJigsaw.isExpectedOpenDenial(failure)); + assertTrue(CommandJigsaw.isExpectedOpenDenial( + new CompletionException(failure))); + assertFalse(CommandJigsaw.isExpectedOpenDenial( + new IllegalStateException("Unexpected Studio failure"))); + } + @Test public void committedActivationStartsInitialEvaluationBeforePlayerBinding() throws Exception { String source = Files.readString(Path.of( @@ -129,6 +143,10 @@ public class CommandJigsawContractTest { NamespacedKey source = CommandJigsaw.parseRegisteredStructureKey("minecraft:village_plains"); assertEquals("minecraft_village_plains", CommandJigsaw.resolveConversionTarget(source, "auto")); assertEquals("villages/plains", CommandJigsaw.resolveConversionTarget(source, "iris:villages/plains")); + NamespacedKey ancientCity = CommandJigsaw.parseRegisteredStructureKey( + "minecraft:ancient_city"); + assertEquals("minecraft_ancient_city", + CommandJigsaw.resolveConversionTarget(ancientCity, "auto")); assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveConversionTarget(source, "custom:village")); } diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index 70bcdcc74..de22ce325 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -31,7 +31,7 @@ file('../../gradle.properties').withInputStream { InputStream stream -> rootProp String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.0.0-26.2')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2')) String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.3')) -String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522')) +String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:e6574cf814b3dd670385a4d632a63fde2038e186')) Closure irisArtifactName = { String platform, String targetVersion -> return "Iris v${project.version} [${platform}] ${targetVersion}.jar" } diff --git a/adapters/forge/build.gradle b/adapters/forge/build.gradle index 5a4dad964..832c1c97e 100644 --- a/adapters/forge/build.gradle +++ b/adapters/forge/build.gradle @@ -31,7 +31,7 @@ file('../../gradle.properties').withInputStream { InputStream stream -> rootProp String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.0.0-26.2')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2')) String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.2-65.1.1')) -String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522')) +String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:e6574cf814b3dd670385a4d632a63fde2038e186')) Closure loaderDisplayVersion = { String loaderVersion -> String coordinatePrefix = "${minecraftVersion}-" if (loaderVersion.startsWith(coordinatePrefix)) { diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java index a71134827..8e54eea10 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java @@ -33,6 +33,7 @@ public final class NativeStructureSurfaceFitter { private static final double SURFACE_TERRAIN_FALLOFF = 2.0; private static final long SURFACE_TERRAIN_INFLUENCE_SCALE = 1_000_000L; private static final int SURFACE_TERRAIN_RADIUS = 12; + private static final int MAX_SOURCE_SURFACE_DELTA = 6; private static final int MAX_SURFACE_TEMPLATE_CELLS = 4_194_304; private static final Set WARNED_SOURCE_BUDGET = ConcurrentHashMap.newKeySet(); @@ -113,8 +114,9 @@ public final class NativeStructureSurfaceFitter { static boolean shouldPrepareSurfaceTerrain(TerrainAdjustment adjustment, GenerationStep.Decoration step) { - return adjustment == TerrainAdjustment.BEARD_THIN - || adjustment == TerrainAdjustment.BEARD_BOX; + return step == GenerationStep.Decoration.SURFACE_STRUCTURES + && (adjustment == TerrainAdjustment.BEARD_THIN + || adjustment == TerrainAdjustment.BEARD_BOX); } static int surfaceTerrainRadius() { @@ -131,50 +133,27 @@ public final class NativeStructureSurfaceFitter { int localTargetY = originalY; SurfaceAnchor selectedLocal = null; long totalInfluence = 0L; - long weightedMeetY = 0L; + long weightedTargetY = 0L; long maximumInfluence = 0L; for (SurfaceAnchor anchor : anchors) { int outX = IrisObjectVacuum.outset(worldX, anchor.minX(), anchor.maxX()); int outZ = IrisObjectVacuum.outset(worldZ, anchor.minZ(), anchor.maxZ()); boolean containsColumn = outX == 0 && outZ == 0; - if (containsColumn && anchor.strength() > 1 && originalY < anchor.meetY()) { - if (precedes(anchor, selectedLocal)) { - localTargetY = anchor.meetY(); - selectedLocal = anchor; - } + long distanceSquared = (long) outX * outX + (long) outZ * outZ; + long radiusSquared = (long) SURFACE_TERRAIN_RADIUS * SURFACE_TERRAIN_RADIUS; + if (distanceSquared > radiusSquared) { continue; } - int verticalDistance = anchor.verticalDistance(originalY); - long horizontalDistanceSquared = (long) outX * outX + (long) outZ * outZ; - long distanceSquared = (long) outX * outX + (long) outZ * outZ - + (long) verticalDistance * verticalDistance; - double factor = 0D; - long radiusSquared = (long) SURFACE_TERRAIN_RADIUS * SURFACE_TERRAIN_RADIUS; - if (distanceSquared <= radiusSquared) { - double distance = Math.sqrt(distanceSquared); - factor = Math.pow( - 1D - distance / SURFACE_TERRAIN_RADIUS, SURFACE_TERRAIN_FALLOFF); - } - if (anchor.strength() > 1 && originalY < anchor.meetY() - && verticalDistance > SURFACE_TERRAIN_RADIUS - && horizontalDistanceSquared <= radiusSquared) { - double horizontalDistance = Math.sqrt(horizontalDistanceSquared); - double horizontalFactor = Math.pow( - 1D - horizontalDistance / SURFACE_TERRAIN_RADIUS, - SURFACE_TERRAIN_FALLOFF); - double rescueProgress = Math.min(1D, - (verticalDistance - SURFACE_TERRAIN_RADIUS) - / (double) SURFACE_TERRAIN_RADIUS); - double rescueWeight = rescueProgress * rescueProgress - * (3D - 2D * rescueProgress); - factor = Math.max(factor, horizontalFactor * rescueWeight); - } + double distance = Math.sqrt(distanceSquared); + double factor = Math.pow( + 1D - distance / SURFACE_TERRAIN_RADIUS, SURFACE_TERRAIN_FALLOFF); if (factor <= 0D) { continue; } + int boundedTargetY = boundedSurfaceTarget(originalY, anchor.meetY()); if (containsColumn) { if (precedes(anchor, selectedLocal)) { - localTargetY = anchor.meetY(); + localTargetY = boundedTargetY; selectedLocal = anchor; } continue; @@ -185,19 +164,35 @@ public final class NativeStructureSurfaceFitter { } long weightedInfluence = influence * Math.max(1, anchor.strength()); totalInfluence += weightedInfluence; - weightedMeetY += weightedInfluence * anchor.meetY(); + weightedTargetY += weightedInfluence * boundedTargetY; maximumInfluence = Math.max(maximumInfluence, influence); } if (selectedLocal != null) { - return new SurfaceResolution(localTargetY, selectedLocal.strength() > 1); + return new SurfaceResolution( + localTargetY, + selectedLocal.strength() > 1 + && localTargetY == selectedLocal.meetY()); } if (totalInfluence == 0L) { return new SurfaceResolution(originalY, false); } - double blendedMeetY = weightedMeetY / (double) totalInfluence; + double blendedTargetY = weightedTargetY / (double) totalInfluence; double factor = maximumInfluence / (double) SURFACE_TERRAIN_INFLUENCE_SCALE; return new SurfaceResolution( - (int) Math.round(originalY + ((blendedMeetY - originalY) * factor)), false); + blendSurfaceTarget(originalY, blendedTargetY, factor), false); + } + + private static int boundedSurfaceTarget(int originalY, int meetY) { + int delta = Math.max( + -MAX_SOURCE_SURFACE_DELTA, + Math.min(MAX_SOURCE_SURFACE_DELTA, meetY - originalY)); + return originalY + delta; + } + + private static int blendSurfaceTarget(int originalY, double targetY, double factor) { + double delta = (targetY - originalY) * factor; + int magnitude = (int) Math.round(Math.abs(delta)); + return originalY + (delta < 0D ? -magnitude : magnitude); } private static boolean precedes(SurfaceAnchor candidate, SurfaceAnchor selected) { @@ -235,12 +230,16 @@ public final class NativeStructureSurfaceFitter { area.maxX() + SURFACE_TERRAIN_RADIUS, area.maxY(), area.maxZ() + SURFACE_TERRAIN_RADIUS); List anchors = new ArrayList<>(); + Set seenStarts = Collections.newSetFromMap( + new IdentityHashMap<>()); for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) { if (!requiresSourceSurfaceTerrain(target)) { continue; } StructureStart start = target.start(); - TerrainAdjustment adjustment = start.getStructure().terrainAdaptation(); + if (!seenStarts.add(start)) { + continue; + } BoundingBox firstBounds = start.getPieces().getFirst().getBoundingBox(); BlockPos firstCenter = firstBounds.getCenter(); BlockPos referencePosition = new BlockPos( @@ -251,19 +250,15 @@ public final class NativeStructureSurfaceFitter { if (piece instanceof PoolElementStructurePiece poolPiece) { StructureTemplatePool.Projection projection = poolPiece.getElement().getProjection(); - if (projection == StructureTemplatePool.Projection.RIGID) { - BoundingBox bounds = poolPiece.getBoundingBox(); - anchors.add(surfaceAnchor( - bounds, bounds.minY() + poolPiece.getGroundLevelDelta(), - 2, adjustment)); - } else if (!budgetExceeded - && projection == StructureTemplatePool.Projection.TERRAIN_MATCHING + if (!budgetExceeded + && (projection == StructureTemplatePool.Projection.RIGID + || projection == StructureTemplatePool.Projection.TERRAIN_MATCHING) && intersectsHorizontally(poolPiece.getBoundingBox(), influenceArea)) { try { List pieceAnchors = new ArrayList<>(); - addTerrainMatchingSurfaceAnchors( + addProcessedSurfaceAnchors( world, influenceArea, poolPiece, referencePosition, - adjustment, budget, pieceAnchors); + projection, budget, pieceAnchors); anchors.addAll(pieceAnchors); } catch (TemplateBudgetExceeded ignored) { budgetExceeded = true; @@ -274,22 +269,22 @@ public final class NativeStructureSurfaceFitter { anchors.add(new SurfaceAnchor( junction.getSourceX(), junction.getSourceX(), junction.getSourceZ(), junction.getSourceZ(), - junction.getSourceGroundY() - 1, 1, - junction.getSourceGroundY(), junction.getSourceGroundY())); + junction.getSourceGroundY() - 1, 1)); } continue; } BoundingBox bounds = piece.getBoundingBox(); - anchors.add(surfaceAnchor(bounds, bounds.minY(), 2, adjustment)); + anchors.add(surfaceAnchor(bounds, bounds.minY(), 2)); } } return List.copyOf(anchors); } - private static void addTerrainMatchingSurfaceAnchors( + private static void addProcessedSurfaceAnchors( WorldGenLevel world, BoundingBox influenceArea, PoolElementStructurePiece piece, BlockPos referencePosition, - TerrainAdjustment adjustment, TemplateCellBudget budget, + StructureTemplatePool.Projection projection, + TemplateCellBudget budget, List anchors) { NativeStructureTemplateOccupancy.OccupancyResult occupancy = NativeStructureTemplateOccupancy.resolve( @@ -299,14 +294,21 @@ public final class NativeStructureSurfaceFitter { if (!occupancy.resolved()) { return; } - int groundY = piece.getBoundingBox().minY() + piece.getGroundLevelDelta(); + int maximumFoundationY = piece.getBoundingBox().minY() + + piece.getGroundLevelDelta(); Map lowest = NativeStructureTemplateOccupancy.lowestProcessedSolidCells(occupancy.cells()); for (NativeStructureTemplateOccupancy.LowestCell cell : lowest.values()) { budget.consume(1); + if (projection == StructureTemplatePool.Projection.RIGID + && cell.y() > maximumFoundationY) { + continue; + } + int groundY = projection == StructureTemplatePool.Projection.RIGID + ? maximumFoundationY : cell.y(); BoundingBox column = new BoundingBox( cell.x(), groundY, cell.z(), cell.x(), groundY, cell.z()); - anchors.add(surfaceAnchor(column, groundY, 2, adjustment)); + anchors.add(surfaceAnchor(column, groundY, 2)); } } @@ -318,7 +320,7 @@ public final class NativeStructureSurfaceFitter { if (WARNED_SOURCE_BUDGET.add(structureId)) { IrisLogging.warn("Native structure SOURCE fitting for '" + structureId + "' exceeded its bounded template budget; " - + "skipping remaining terrain-matching footprints for this start"); + + "skipping remaining processed footprints for this start"); } } @@ -462,16 +464,9 @@ public final class NativeStructureSurfaceFitter { return first.surfaceY() <= second.surfaceY() ? first : second; } - static SurfaceAnchor surfaceAnchor(BoundingBox bounds, int groundY, int strength, - TerrainAdjustment adjustment) { - int meetY = groundY - 1; - if (adjustment == TerrainAdjustment.BEARD_BOX) { - return new SurfaceAnchor( - bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(), - meetY, strength, groundY, bounds.maxY()); - } + static SurfaceAnchor surfaceAnchor(BoundingBox bounds, int groundY, int strength) { return new SurfaceAnchor(bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(), - meetY, strength, groundY, groundY); + groundY - 1, strength); } static boolean requiresSurfaceTerrain(StructureStart start) { @@ -498,8 +493,8 @@ public final class NativeStructureSurfaceFitter { && target.start() != null && target.start().isValid() && target.terrain().resolvedMode() == IrisStructureTerrainMode.SOURCE && shouldPrepareSurfaceTerrain( - target.start().getStructure().terrainAdaptation(), - target.start().getStructure().step()); + target.start().getStructure().terrainAdaptation(), + target.start().getStructure().step()); } private static void fitSurfaceTerrain(WorldGenLevel world, BoundingBox area, @@ -823,16 +818,7 @@ public final class NativeStructureSurfaceFitter { return state.isSolid() && !NativeStructureVegetationClearer.isTreeBlock(state); } - record SurfaceAnchor(int minX, int maxX, int minZ, int maxZ, int meetY, int strength, - int minInfluenceY, int maxInfluenceY) { - SurfaceAnchor(int minX, int maxX, int minZ, int maxZ, int meetY, int strength) { - this(minX, maxX, minZ, maxZ, meetY, strength, meetY + 1, meetY + 1); - } - - int verticalDistance(int y) { - return IrisObjectVacuum.outset(y, minInfluenceY, maxInfluenceY); - } - + record SurfaceAnchor(int minX, int maxX, int minZ, int maxZ, int meetY, int strength) { } private record SurfaceMaterials(BlockState surface, BlockState subsurface) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedSettingsHotloadService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedSettingsHotloadService.java index 937a080e0..87a5747a8 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedSettingsHotloadService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedSettingsHotloadService.java @@ -21,48 +21,118 @@ package art.arcane.iris.modded.service; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.volmlib.util.hotload.ConfigHotloadEngine; import net.minecraft.server.MinecraftServer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.concurrent.TimeUnit; public final class ModdedSettingsHotloadService implements ModdedTickableService { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); - private static final long POLL_PERIOD_MILLIS = 3_000L; + private static final long POLL_PERIOD_MILLIS = 500L; + private static final long HOTLOAD_COOLDOWN_MILLIS = 3_000L; + private static final int MAX_SETTINGS_BYTES = 2 * 1024 * 1024; - private long lastPollAt; - private long lastModified; + private ConfigHotloadEngine hotloadEngine; + private long lastPollAtNanos; @Override public void onEnable() { - lastPollAt = 0L; - lastModified = settingsFile().lastModified(); + File settingsFile = settingsFile(); + hotloadEngine = new ConfigHotloadEngine( + this::isSettingsFile, + () -> List.of(settingsFile), + this::readSettings, + this::normalizeSettings + ); + hotloadEngine.configure( + POLL_PERIOD_MILLIS, + HOTLOAD_COOLDOWN_MILLIS, + List.of(settingsFile), + List.of() + ); + lastPollAtNanos = 0L; } @Override public void onDisable() { + ConfigHotloadEngine active = hotloadEngine; + hotloadEngine = null; + if (active != null) { + active.clear(); + } } @Override public void onServerTick(MinecraftServer server) { - long now = System.currentTimeMillis(); - if (now - lastPollAt < POLL_PERIOD_MILLIS) { + ConfigHotloadEngine active = hotloadEngine; + if (active == null) { return; } - lastPollAt = now; - long modified = settingsFile().lastModified(); - if (modified == lastModified) { + + long now = System.nanoTime(); + if (now - lastPollAtNanos < TimeUnit.MILLISECONDS.toNanos(POLL_PERIOD_MILLIS)) { + return; + } + lastPollAtNanos = now; + + try { + for (ConfigHotloadEngine.StableContentSnapshot snapshot : active.pollTouchedSnapshots()) { + if ("missing".equals(snapshot.signature())) { + active.processSnapshotChange(snapshot, ignored -> true, null); + LOGGER.warn("settings.json was removed; retaining the last valid runtime settings"); + continue; + } + active.processSnapshotChange(snapshot, this::reloadSettings, ignored -> LOGGER.info("Hotloaded settings.json")); + } IrisLanguage.update(); - return; + } catch (RuntimeException failure) { + LOGGER.error("Iris settings hotload watcher failed", failure); } - if (IrisSettings.settings != null) { - IrisSettings.invalidate(); + } + + private boolean reloadSettings(ConfigHotloadEngine.StableContentSnapshot snapshot) { + try { + String content = snapshot.normalizedContent(); + if (content == null) { + return false; + } + return IrisSettings.applyHotloadSnapshot(content, IrisLanguage::reload); + } catch (RuntimeException failure) { + LOGGER.error("Iris settings hotload failed; keeping the previous runtime settings", failure); + return false; } - IrisSettings.get(); - IrisLanguage.reload(); - lastModified = settingsFile().lastModified(); - LOGGER.info("Hotloaded settings.json"); + } + + private boolean isSettingsFile(File file) { + return file != null && settingsFile().getAbsoluteFile().equals(file.getAbsoluteFile()); + } + + private String readSettings(File file) { + if (file == null || !file.isFile()) { + return null; + } + try (InputStream input = Files.newInputStream(file.toPath())) { + byte[] content = input.readNBytes(MAX_SETTINGS_BYTES + 1); + if (content.length > MAX_SETTINGS_BYTES) { + throw new IOException("Settings exceed " + MAX_SETTINGS_BYTES + " bytes: " + file); + } + return new String(content, StandardCharsets.UTF_8); + } catch (IOException failure) { + throw new UncheckedIOException("Failed to read Iris settings from " + file, failure); + } + } + + private String normalizeSettings(String content) { + return content == null ? null : content.replace("\r\n", "\n").trim(); } private static File settingsFile() { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java index 91737b66b..3d0676d4c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java @@ -43,6 +43,7 @@ import org.slf4j.LoggerFactory; import java.io.File; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; @@ -82,6 +83,9 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, if (active != null) { active.shutdownNow(); } + for (Watch watch : watches.values()) { + watch.close(); + } watches.clear(); } @@ -172,7 +176,13 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, } if (!watches.isEmpty()) { Set keep = seen == null ? Set.of() : seen; - watches.keySet().removeIf((String key) -> !keep.contains(key)); + watches.entrySet().removeIf((Map.Entry entry) -> { + if (keep.contains(entry.getKey())) { + return false; + } + entry.getValue().close(); + return true; + }); } } @@ -212,6 +222,7 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, private void poll(String dimensionId, Watch watch, IrisModdedChunkGenerator generator, Engine engine) { try { if (watch.engine != engine) { + watch.close(); watch.engine = engine; watch.folder = new ReactiveFolder( engine.getData().getDataFolder(), @@ -241,6 +252,7 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, LOGGER.info("Iris studio hotload {} pack={} {}ms", dimensionId, engine.getDimension().getLoadKey(), System.currentTimeMillis() - start); } catch (Throwable e) { LOGGER.error("Iris studio hotload failed for {}", dimensionId, e); + throw new IllegalStateException("Iris studio hotload failed for " + dimensionId, e); } } @@ -272,5 +284,14 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, private final AtomicBoolean busy = new AtomicBoolean(false); private volatile Engine engine; private volatile ReactiveFolder folder; + + private void close() { + ReactiveFolder active = folder; + folder = null; + if (active != null) { + active.clear(); + } + engine = null; + } } } diff --git a/adapters/neoforge/build.gradle b/adapters/neoforge/build.gradle index a10978606..3177a6e89 100644 --- a/adapters/neoforge/build.gradle +++ b/adapters/neoforge/build.gradle @@ -31,7 +31,7 @@ file('../../gradle.properties').withInputStream { InputStream stream -> rootProp String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.0.0-26.2')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2')) String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(rootProperties.getProperty('neoForgeVersion', '26.2.0.59')) -String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522')) +String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:e6574cf814b3dd670385a4d632a63fde2038e186')) Closure irisArtifactName = { String platform, String targetVersion -> return "Iris v${project.version} [${platform}] ${targetVersion}.jar" } diff --git a/build.gradle b/build.gradle index b4153d0e9..53960fe4a 100644 --- a/build.gradle +++ b/build.gradle @@ -54,7 +54,7 @@ String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').get String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse('26.2-65.1.1') String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.2.0.59') String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') - .orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522') + .orElse('com.github.VolmitSoftware:VolmLib:e6574cf814b3dd670385a4d632a63fde2038e186') .get() String useLocalVolmLib = providers.gradleProperty('useLocalVolmLib').getOrElse('true') String localVolmLibDirectory = providers.gradleProperty('localVolmLibDirectory').getOrNull() diff --git a/core/build.gradle b/core/build.gradle index afc991214..855a3b7d9 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -37,7 +37,7 @@ plugins { def lib = 'art.arcane.iris.util' String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') - .orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522') + .orElse('com.github.VolmitSoftware:VolmLib:e6574cf814b3dd670385a4d632a63fde2038e186') .get() String sentryAuthToken = findProperty('sentry.auth.token') as String ?: System.getenv('SENTRY_AUTH_TOKEN') boolean hasSentryAuthToken = sentryAuthToken != null && !sentryAuthToken.isBlank() diff --git a/core/purity-allowlist.txt b/core/purity-allowlist.txt index 73b462fc2..31a622766 100644 --- a/core/purity-allowlist.txt +++ b/core/purity-allowlist.txt @@ -63,7 +63,6 @@ art/arcane/iris/core/service/ExternalDataSVC.java art/arcane/iris/core/service/GlobalCacheSVC.java art/arcane/iris/core/service/JigsawStudioMarkerParser.java art/arcane/iris/core/service/JigsawStudioMenuController.java -art/arcane/iris/core/service/JigsawStudioBoundsRenderer.java art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java art/arcane/iris/core/service/JigsawStudioService.java art/arcane/iris/core/service/JigsawStudioToolCodec.java diff --git a/core/src/main/java/art/arcane/iris/core/IrisSettings.java b/core/src/main/java/art/arcane/iris/core/IrisSettings.java index 216da6f49..721bd7f09 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisSettings.java +++ b/core/src/main/java/art/arcane/iris/core/IrisSettings.java @@ -30,6 +30,8 @@ import lombok.Data; import java.io.File; import java.io.IOException; +import java.util.Objects; +import java.util.function.Predicate; @Data public class IrisSettings { @@ -136,6 +138,46 @@ public class IrisSettings { } } + public static IrisSettings installHotloadSnapshot(String rawJson) { + IrisSettings parsed = parseHotloadSnapshot(rawJson); + synchronized (SETTINGS_LOCK) { + settings = parsed; + } + return parsed; + } + + public static boolean applyHotloadSnapshot(String rawJson, Predicate candidateActivation) { + Objects.requireNonNull(candidateActivation, "candidateActivation"); + IrisSettings parsed = parseHotloadSnapshot(rawJson); + if (!candidateActivation.test(parsed)) { + return false; + } + synchronized (SETTINGS_LOCK) { + settings = parsed; + } + return true; + } + + public static IrisSettings parseHotloadSnapshot(String rawJson) { + if (rawJson == null || rawJson.isBlank()) { + throw new IllegalArgumentException("Iris settings snapshot is empty"); + } + + IrisSettings parsed; + try { + parsed = new Gson().fromJson(rawJson, IrisSettings.class); + if (parsed == null) { + throw new IllegalArgumentException("Iris settings snapshot did not contain an object"); + } + migrateLegacyKeys(parsed, rawJson); + } catch (RuntimeException failure) { + throw new IllegalArgumentException("Iris settings snapshot is invalid", failure); + } + + parsed.fillMissingSections(); + return parsed; + } + public void forceSave() { File s = IrisPlatforms.get().dataFile("settings.json"); @@ -147,6 +189,20 @@ public class IrisSettings { } } + private void fillMissingSections() { + general = general == null ? new IrisSettingsGeneral() : general; + world = world == null ? new IrisSettingsWorld() : world; + gui = gui == null ? new IrisSettingsGUI() : gui; + autoConfiguration = autoConfiguration == null ? new IrisSettingsAutoconfiguration() : autoConfiguration; + generator = generator == null ? new IrisSettingsGenerator() : generator; + concurrency = concurrency == null ? new IrisSettingsConcurrency() : concurrency; + studio = studio == null ? new IrisSettingsStudio() : studio; + performance = performance == null ? new IrisSettingsPerformance() : performance; + pregen = pregen == null ? new IrisSettingsPregen() : pregen; + sentry = sentry == null ? new IrisSettingsSentry() : sentry; + treeFeller = treeFeller == null ? new IrisSettingsTreeFeller() : treeFeller; + } + @Data public static class IrisSettingsAutoconfiguration { public boolean configureSpigotTimeoutTime = true; @@ -273,6 +329,11 @@ public class IrisSettings { public boolean splashLogoStartup = true; public boolean useConsoleCustomColors = true; public boolean useCustomColorsIngame = true; + /** + * Boss bar progress loaders for jobs, studio opens, world creation, chunk jobs and pack + * downloads. Turning this off keeps the action bar progress line; only the bar goes away. + */ + public boolean progressBossBar = true; public boolean adjustVanillaHeight = false; public boolean autoIngestDatapacks = true; /** diff --git a/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java b/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java index 3ac28c418..a06c0ed81 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java +++ b/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java @@ -28,19 +28,32 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import java.io.File; +import java.io.IOException; import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.attribute.BasicFileAttributes; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; public final class IrisLanguage { private static final long MAX_LOCALE_BYTES = 2L * 1024L * 1024L; + private static final long HOTLOAD_CONTENT_STABILITY_NANOS = TimeUnit.MILLISECONDS.toNanos(250L); + private static final long HOTLOAD_DELETION_GRACE_NANOS = TimeUnit.SECONDS.toNanos(3L); + private static final long HOTLOAD_COOLDOWN_NANOS = TimeUnit.SECONDS.toNanos(3L); private static final int MAX_REPORTED_ISSUES = 12; private static final Pattern LOCALE_NAME = Pattern.compile("[A-Za-z0-9_-]+"); private static final Pattern LEGACY_COLOR = Pattern.compile("(?i)\\u00a7[0-9A-FK-ORX]"); @@ -55,10 +68,17 @@ public final class IrisLanguage { * new snapshot and the whole memo is discarded. Bounded by the catalog size. */ private static final AtomicReference PLAIN_MEMO = new AtomicReference<>(null); + private static final LocaleHotloadGate HOTLOAD_GATE = new LocaleHotloadGate( + new LocaleHotloadGate.Timing( + HOTLOAD_CONTENT_STABILITY_NANOS, + HOTLOAD_DELETION_GRACE_NANOS, + HOTLOAD_COOLDOWN_NANOS + ) + ); private static volatile File dataFolder; - private static volatile File watchedFile; - private static volatile long watchedSignature = Long.MIN_VALUE; + private static volatile String lastCaptureFailureKey; + private static volatile String lastInvalidHotloadLocale; private static volatile String activeLocale = CATALOG.englishLocale(); private IrisLanguage() { @@ -82,6 +102,20 @@ public final class IrisLanguage { return reload(root, configuredLocale()); } + public static synchronized boolean reload(IrisSettings candidate) { + IrisSettings resolvedCandidate = Objects.requireNonNull(candidate, "Candidate settings cannot be null"); + File root = dataFolder; + if (root == null && IrisPlatforms.isBound()) { + root = IrisPlatforms.get().dataFolder(); + } + if (root == null) { + return false; + } + IrisSettings.IrisSettingsGeneral general = resolvedCandidate.getGeneral(); + String locale = general == null ? CATALOG.englishLocale() : general.getLanguage(); + return reload(root, locale); + } + public static synchronized boolean reload(File root, String locale) { File resolvedRoot = root == null ? null : root.getAbsoluteFile(); if (resolvedRoot == null) { @@ -92,16 +126,26 @@ public final class IrisLanguage { requestedLocale = normalizeLocale(locale); } catch (RuntimeException exception) { dataFolder = resolvedRoot; + HOTLOAD_GATE.reset(null); IrisLogging.error("Rejected locale setting '" + locale + "'; continuing with " + activeLocale + "."); IrisLogging.reportError(exception); exception.printStackTrace(); return false; } - LocalizationReloadResult result = MANAGER.reload(() -> loadCandidate(resolvedRoot, requestedLocale)); - dataFolder = resolvedRoot; + File override = overrideFile(resolvedRoot, requestedLocale); - watchedFile = override; - watchedSignature = signature(override); + SnapshotCapture capture = captureForReload(override, requestedLocale); + LocalizationReloadResult result; + if (capture.failure() == null) { + result = MANAGER.reload(() -> loadCandidate(resolvedRoot, requestedLocale, capture.snapshot())); + } else { + result = MANAGER.reload(() -> { + throw capture.failure(); + }); + } + dataFolder = resolvedRoot; + HOTLOAD_GATE.reset(capture.snapshot()); + lastCaptureFailureKey = null; if (!result.applied()) { reportRejectedReload(requestedLocale, result); return false; @@ -123,14 +167,47 @@ public final class IrisLanguage { try { locale = normalizeLocale(configuredLocale()); } catch (RuntimeException exception) { - return reload(root, configuredLocale()); + String invalidLocale = configuredLocale(); + if (Objects.equals(lastInvalidHotloadLocale, invalidLocale)) { + return false; + } + lastInvalidHotloadLocale = invalidLocale; + return reload(root, invalidLocale); } + lastInvalidHotloadLocale = null; File expected = overrideFile(root, locale); - long signature = signature(expected); - if (expected.equals(watchedFile) && signature == watchedSignature) { + LocaleHotloadSnapshot snapshot; + try { + snapshot = captureHotloadSnapshot(expected, locale); + } catch (IOException | RuntimeException failure) { + HOTLOAD_GATE.unavailable(); + reportCaptureFailure(expected, failure); + return false; + } + if (snapshot == null) { + HOTLOAD_GATE.unavailable(); return true; } - return reload(root, locale); + lastCaptureFailureKey = null; + + LocaleHotloadGate.Attempt attempt = HOTLOAD_GATE.observe(snapshot, System.nanoTime()); + if (attempt == null) { + return true; + } + + LocalizationReloadResult result = MANAGER.reload(() -> loadCandidate(root, locale, attempt.snapshot())); + boolean applied = result.applied(); + HOTLOAD_GATE.complete(attempt, System.nanoTime(), applied); + if (!applied) { + reportRejectedReload(locale, result); + return false; + } + + activeLocale = locale; + int warnings = result.validation().warnings().size(); + IrisLogging.info("Loaded locale " + locale + " with " + warnings + " fallback " + + (warnings == 1 ? "entry" : "entries") + "."); + return true; } public static String activeLocale() { @@ -229,13 +306,16 @@ public final class IrisLanguage { throw new IllegalArgumentException("Unsupported Iris message key: " + key.id()); } - private static LocalizationCandidate loadCandidate(File root, String locale) throws Exception { + private static LocalizationCandidate loadCandidate( + File root, + String locale, + LocaleHotloadSnapshot snapshot + ) throws Exception { File folder = new File(root, "languages/overrides"); Files.createDirectories(folder.toPath()); List overlays = new ArrayList<>(2); - File override = overrideFile(root, locale); - if (override.exists()) { - overlays.add(loadFileOverlay(override, locale)); + if (!snapshot.missing()) { + overlays.add(parseOverlay(snapshot.file().getPath(), locale, snapshot.content())); } if (!CATALOG.englishLocale().equals(locale)) { @@ -269,17 +349,6 @@ public final class IrisLanguage { } } - private static LocaleOverlay loadFileOverlay(File override, String locale) throws Exception { - if (!override.isFile()) { - throw new IllegalArgumentException("Locale override is not a regular file: " + override.getPath()); - } - if (override.length() > MAX_LOCALE_BYTES) { - throw new IllegalArgumentException("Locale override is too large: " + override.getPath()); - } - String raw = Files.readString(override.toPath(), StandardCharsets.UTF_8); - return parseOverlay(override.getPath(), locale, raw); - } - private static LocaleOverlay parseOverlay(String source, String locale, String raw) { JsonElement parsed = JsonParser.parseString(raw == null || raw.isBlank() ? "{}" : raw); if (!parsed.isJsonObject()) { @@ -461,11 +530,92 @@ public final class IrisLanguage { return new File(new File(root, "languages/overrides"), normalizeLocale(locale) + ".json").getAbsoluteFile(); } - private static long signature(File file) { - if (file == null || !file.exists()) { - return 0L; + static LocaleHotloadSnapshot captureHotloadSnapshot(File file, String locale) throws IOException { + File resolvedFile = Objects.requireNonNull(file, "Locale override file cannot be null").getAbsoluteFile(); + String resolvedLocale = normalizeLocale(locale); + BasicFileAttributes before; + try { + before = Files.readAttributes(resolvedFile.toPath(), BasicFileAttributes.class); + } catch (NoSuchFileException failure) { + return LocaleHotloadSnapshot.missing(resolvedFile, resolvedLocale); } - return file.lastModified() * 31L + file.length(); + if (!before.isRegularFile()) { + throw new IllegalArgumentException("Locale override is not a regular file: " + resolvedFile.getPath()); + } + if (before.size() > MAX_LOCALE_BYTES) { + throw new IllegalArgumentException("Locale override is too large: " + resolvedFile.getPath()); + } + + byte[] bytes; + try (InputStream input = Files.newInputStream(resolvedFile.toPath())) { + bytes = input.readNBytes((int) MAX_LOCALE_BYTES + 1); + } catch (NoSuchFileException failure) { + return null; + } + if (bytes.length > MAX_LOCALE_BYTES) { + throw new IllegalArgumentException("Locale override is too large: " + resolvedFile.getPath()); + } + + BasicFileAttributes after; + try { + after = Files.readAttributes(resolvedFile.toPath(), BasicFileAttributes.class); + } catch (NoSuchFileException failure) { + return null; + } + if (!sameIdentity(before, after) || bytes.length != after.size()) { + return null; + } + + String content; + try { + content = StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString(); + } catch (CharacterCodingException failure) { + throw new IOException("Locale override is not valid UTF-8: " + resolvedFile.getPath(), failure); + } + return LocaleHotloadSnapshot.present(resolvedFile, resolvedLocale, content, sha256(bytes)); + } + + private static SnapshotCapture captureForReload(File file, String locale) { + try { + LocaleHotloadSnapshot snapshot = captureHotloadSnapshot(file, locale); + if (snapshot == null) { + return new SnapshotCapture( + null, + new IOException("Locale override changed while being read: " + file.getPath()) + ); + } + return new SnapshotCapture(snapshot, null); + } catch (Exception failure) { + return new SnapshotCapture(null, failure); + } + } + + private static boolean sameIdentity(BasicFileAttributes before, BasicFileAttributes after) { + return before.isRegularFile() == after.isRegularFile() + && before.size() == after.size() + && before.lastModifiedTime().equals(after.lastModifiedTime()) + && Objects.equals(before.fileKey(), after.fileKey()); + } + + private static String sha256(byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(content)); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable", failure); + } + } + + private static void reportCaptureFailure(File file, Exception failure) { + String detail = failure.getMessage() == null ? failure.getClass().getSimpleName() : failure.getMessage(); + String failureKey = file.getAbsolutePath() + ":" + failure.getClass().getName() + ":" + detail; + if (Objects.equals(failureKey, lastCaptureFailureKey)) { + return; + } + lastCaptureFailureKey = failureKey; + IrisLogging.error("Failed to capture stable locale override " + file.getPath() + ": " + detail); + IrisLogging.reportError(failure); + failure.printStackTrace(); } private static void reportRejectedReload(String locale, LocalizationReloadResult result) { @@ -489,4 +639,7 @@ public final class IrisLanguage { private record PlainMemo(LocalizationSnapshot snapshot, Map values) { } + + private record SnapshotCapture(LocaleHotloadSnapshot snapshot, Exception failure) { + } } diff --git a/core/src/main/java/art/arcane/iris/core/localization/LocaleHotloadGate.java b/core/src/main/java/art/arcane/iris/core/localization/LocaleHotloadGate.java new file mode 100644 index 000000000..08d47d675 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/localization/LocaleHotloadGate.java @@ -0,0 +1,100 @@ +package art.arcane.iris.core.localization; + +import java.util.Objects; + +final class LocaleHotloadGate { + private final Timing timing; + + private long generation; + private LocaleHotloadSnapshot baseline; + private LocaleHotloadSnapshot observed; + private long observedSinceNanos; + private LocaleHotloadSnapshot pending; + private Attempt inFlight; + private long lastCompletedAtNanos; + private boolean hasCompleted; + + LocaleHotloadGate(Timing timing) { + this.timing = Objects.requireNonNull(timing, "Locale hotload timing cannot be null"); + } + + synchronized Attempt observe(LocaleHotloadSnapshot snapshot, long nowNanos) { + LocaleHotloadSnapshot current = Objects.requireNonNull(snapshot, "Locale hotload snapshot cannot be null"); + if (!current.equals(observed)) { + observed = current; + observedSinceNanos = nowNanos; + return null; + } + + long stabilityNanos = current.missing() + ? timing.deletionGraceNanos() + : timing.contentStabilityNanos(); + if (!elapsed(nowNanos, observedSinceNanos, stabilityNanos)) { + return null; + } + + pending = current.equals(baseline) ? null : current; + if (pending == null || inFlight != null || !pending.equals(observed)) { + return null; + } + if (hasCompleted && !elapsed(nowNanos, lastCompletedAtNanos, timing.cooldownNanos())) { + return null; + } + + Attempt attempt = new Attempt(generation, pending); + inFlight = attempt; + return attempt; + } + + synchronized void unavailable() { + observed = null; + observedSinceNanos = 0L; + } + + synchronized void complete(Attempt attempt, long nowNanos, boolean applied) { + if (attempt == null || inFlight == null || !inFlight.equals(attempt) || attempt.generation() != generation) { + return; + } + + inFlight = null; + lastCompletedAtNanos = nowNanos; + hasCompleted = true; + if (!applied) { + return; + } + + baseline = attempt.snapshot(); + if (attempt.snapshot().equals(pending)) { + pending = null; + } + } + + synchronized void reset(LocaleHotloadSnapshot snapshot) { + generation++; + baseline = snapshot; + observed = snapshot; + observedSinceNanos = 0L; + pending = null; + inFlight = null; + lastCompletedAtNanos = 0L; + hasCompleted = false; + } + + private boolean elapsed(long nowNanos, long startNanos, long durationNanos) { + return nowNanos - startNanos >= durationNanos; + } + + record Timing(long contentStabilityNanos, long deletionGraceNanos, long cooldownNanos) { + Timing { + if (contentStabilityNanos < 0L || deletionGraceNanos < 0L || cooldownNanos < 0L) { + throw new IllegalArgumentException("Locale hotload timing cannot be negative"); + } + } + } + + record Attempt(long generation, LocaleHotloadSnapshot snapshot) { + Attempt { + snapshot = Objects.requireNonNull(snapshot, "Locale hotload attempt snapshot cannot be null"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/localization/LocaleHotloadSnapshot.java b/core/src/main/java/art/arcane/iris/core/localization/LocaleHotloadSnapshot.java new file mode 100644 index 000000000..39b424b81 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/localization/LocaleHotloadSnapshot.java @@ -0,0 +1,29 @@ +package art.arcane.iris.core.localization; + +import java.io.File; +import java.util.Objects; + +record LocaleHotloadSnapshot(File file, String locale, String content, String sha256) { + LocaleHotloadSnapshot { + file = Objects.requireNonNull(file, "Locale override file cannot be null").getAbsoluteFile(); + locale = Objects.requireNonNull(locale, "Locale cannot be null"); + sha256 = Objects.requireNonNull(sha256, "Locale content hash cannot be null"); + } + + static LocaleHotloadSnapshot missing(File file, String locale) { + return new LocaleHotloadSnapshot(file, locale, null, "missing"); + } + + static LocaleHotloadSnapshot present(File file, String locale, String content, String sha256) { + return new LocaleHotloadSnapshot( + file, + locale, + Objects.requireNonNull(content, "Locale content cannot be null"), + sha256 + ); + } + + boolean missing() { + return content == null; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/project/StudioOpenProgressReporter.java b/core/src/main/java/art/arcane/iris/core/project/StudioOpenProgressReporter.java index 0c872bb96..0e8f13e75 100644 --- a/core/src/main/java/art/arcane/iris/core/project/StudioOpenProgressReporter.java +++ b/core/src/main/java/art/arcane/iris/core/project/StudioOpenProgressReporter.java @@ -19,6 +19,7 @@ package art.arcane.iris.core.project; import art.arcane.iris.core.localization.BukkitRuntimeMessages; +import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.RuntimeProgressMessages; import art.arcane.iris.util.common.format.C; @@ -46,7 +47,8 @@ final class StudioOpenProgressReporter { AtomicInteger taskId = new AtomicInteger(-1); org.bukkit.boss.BossBar bossBar; - if (sender.isPlayer() && sender.player() != null) { + if (sender.isPlayer() && sender.player() != null + && IrisSettings.get().getGeneral().isProgressBossBar()) { bossBar = Bukkit.createBossBar( IrisLanguage.text(RuntimeProgressMessages.STUDIO_OPENING), org.bukkit.boss.BarColor.BLUE, diff --git a/core/src/main/java/art/arcane/iris/core/runtime/ChunkJobReporter.java b/core/src/main/java/art/arcane/iris/core/runtime/ChunkJobReporter.java index f98eecd2f..49884c194 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/ChunkJobReporter.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/ChunkJobReporter.java @@ -18,6 +18,7 @@ package art.arcane.iris.core.runtime; +import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.RuntimeProgressMessages; import art.arcane.iris.spi.IrisLogging; @@ -109,8 +110,9 @@ public final class ChunkJobReporter { } private void startReporter() { - boolean player = sender.isPlayer() && sender.player() != null; - BossBar bossBar = player + boolean showBossBar = sender.isPlayer() && sender.player() != null + && IrisSettings.get().getGeneral().isProgressBossBar(); + BossBar bossBar = showBossBar ? Bukkit.createBossBar(IrisLanguage.text( RuntimeProgressMessages.CHUNK_BOSSBAR_WORKING, MessageArgument.trusted("title", title) diff --git a/core/src/main/java/art/arcane/iris/core/service/PackDownloadProgressReporter.java b/core/src/main/java/art/arcane/iris/core/service/PackDownloadProgressReporter.java index ba9165ce7..5963f08f4 100644 --- a/core/src/main/java/art/arcane/iris/core/service/PackDownloadProgressReporter.java +++ b/core/src/main/java/art/arcane/iris/core/service/PackDownloadProgressReporter.java @@ -1,5 +1,6 @@ package art.arcane.iris.core.service; +import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.PackDownloadMessages; import art.arcane.iris.core.pack.PackDownloader; @@ -246,15 +247,17 @@ final class PackDownloadProgressReporter implements PackDownloader.DownloadProgr private void renderHudPulse(HudSnapshot snapshot) { try { - BukkitPlatform.hudLanes().show( - player, - hudLaneId, - snapshot.line(), - snapshot.progress(), - BarColor.BLUE, - BarStyle.SEGMENTED_20, - 1_500L - ); + if (IrisSettings.get().getGeneral().isProgressBossBar()) { + BukkitPlatform.hudLanes().show( + player, + hudLaneId, + snapshot.line(), + snapshot.progress(), + BarColor.BLUE, + BarStyle.SEGMENTED_20, + 1_500L + ); + } sender.sendAction(snapshot.line()); } catch (RuntimeException failure) { disableHud(failure); @@ -310,15 +313,17 @@ final class PackDownloadProgressReporter implements PackDownloader.DownloadProgr Runnable retiredCleanup = () -> retireHudLane(cleaned); Runnable display = () -> { try { - BukkitPlatform.hudLanes().show( - player, - hudLaneId, - message, - progress, - color, - BarStyle.SOLID, - 4_000L - ); + if (IrisSettings.get().getGeneral().isProgressBossBar()) { + BukkitPlatform.hudLanes().show( + player, + hudLaneId, + message, + progress, + color, + BarStyle.SOLID, + 4_000L + ); + } sender.sendAction(message); } finally { if (!J.runEntity(player, cleanup, HUD_TERMINAL_TICKS, retiredCleanup)) { diff --git a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java index 8df72be85..90c42b1e7 100644 --- a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java @@ -488,33 +488,50 @@ public class StudioSVC implements IrisService { } } - private static boolean blockIfPackBroken(VolmitSender sender, String dimm) { + private static BrokenPackException reportPackAdmissionFailure( + VolmitSender sender, String dimm) { Optional startupDenial = IrisStartupValidation.denialReason(); - if (startupDenial.isPresent()) { - sender.sendMessage(startupDenial.get()); - return true; - } IrisDimension dimension = IrisToolbelt.getDimension(dimm); String packName = dimension == null || dimension.getLoader() == null ? dimm : dimension.getLoader().getDataFolder().getName(); PackValidationResult validation = PackValidationRegistry.get(packName); - if (validation != null && validation.isLoadable()) { - return false; + BrokenPackException failure = resolvePackAdmissionFailure( + packName, startupDenial, validation); + if (failure == null) { + return null; + } + if (startupDenial.isPresent()) { + sender.sendMessage(startupDenial.get()); + return failure; } sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_CANNOT_OPEN_STUDIO_PACK_HAS_BLOCKING_ERRORS, MessageArgument.untrusted("dimm", String.valueOf(dimm)))); + for (String reason : failure.getReasons()) { + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_MESSAGE, MessageArgument.untrusted("reason", String.valueOf(reason)))); + } + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FIX_PACK_RUN_IRIS_PACK_VALIDATE_REVALIDATE, MessageArgument.untrusted("dimm", String.valueOf(packName)))); + return failure; + } + + static BrokenPackException resolvePackAdmissionFailure( + String packName, + Optional startupDenial, + PackValidationResult validation + ) { + if (startupDenial.isPresent()) { + return new BrokenPackException(packName, List.of(startupDenial.get())); + } + if (validation != null && validation.isLoadable()) { + return null; + } List failures = validation == null ? List.of("Required pack validation has not completed. Studio creation fails closed until validation succeeds.") : validation.getBlockingErrors(); - for (String reason : failures) { - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_MESSAGE, MessageArgument.untrusted("reason", String.valueOf(reason)))); - } - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FIX_PACK_RUN_IRIS_PACK_VALIDATE_REVALIDATE, MessageArgument.untrusted("dimm", String.valueOf(dimm)))); - return true; + return new BrokenPackException(packName, failures); } public void open(VolmitSender sender, long seed, String dimm, Consumer onDone) throws IrisException { - if (blockIfPackBroken(sender, dimm)) { + if (reportPackAdmissionFailure(sender, dimm) != null) { return; } studioTransitions.submit(() -> replaceActiveProject(sender, seed, dimm, onDone)) @@ -537,9 +554,9 @@ public class StudioSVC implements IrisService { Runnable beforeOpen, Consumer onDone ) { - if (blockIfPackBroken(sender, dimension)) { - return CompletableFuture.failedFuture( - new IllegalStateException("Studio pack '" + dimension + "' has blocking validation errors.")); + BrokenPackException failure = reportPackAdmissionFailure(sender, dimension); + if (failure != null) { + return CompletableFuture.failedFuture(failure); } return studioTransitions.submit(() -> replaceActiveProjectTracked( sender, diff --git a/core/src/main/java/art/arcane/iris/core/structure/VillageImporter.java b/core/src/main/java/art/arcane/iris/core/structure/VillageImporter.java index e16149625..928fe60d1 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/VillageImporter.java +++ b/core/src/main/java/art/arcane/iris/core/structure/VillageImporter.java @@ -426,7 +426,7 @@ public final class VillageImporter { emittedPools.size(), emittedPieces.size(), losses, true); } - String msg = "Imported village " + structureKey + " as '" + name + "': " + emittedPieces.size() + " pieces, " + emittedPools.size() + " pools, " + pieceBlocks + " blocks"; + String msg = "Imported jigsaw structure " + structureKey + " as '" + name + "': " + emittedPieces.size() + " pieces, " + emittedPools.size() + " pools, " + pieceBlocks + " blocks"; if (!losses.isEmpty()) { msg += " (" + losses.size() + " fidelity warning(s) recorded)"; } diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java index d20757568..17a7cfc5f 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java @@ -624,11 +624,13 @@ public class IrisCreator { return; } if (showLoaderHud) { - BukkitPlatform.hudLanes().show(sender.player(), "iris:pregen", IrisLanguage.text( - RuntimeProgressMessages.WORLD_PREGEN_ACTION, - MessageArgument.trusted("bar", ""), - MessageArgument.trusted("percent", percent) - ), p, BarColor.GREEN, BarStyle.SOLID, 4000L); + if (IrisSettings.get().getGeneral().isProgressBossBar()) { + BukkitPlatform.hudLanes().show(sender.player(), "iris:pregen", IrisLanguage.text( + RuntimeProgressMessages.WORLD_PREGEN_ACTION, + MessageArgument.trusted("bar", ""), + MessageArgument.trusted("percent", percent) + ), p, BarColor.GREEN, BarStyle.SOLID, 4000L); + } int barWidth = 44; int filled = (int) Math.round(Math.max(0.0D, Math.min(1.0D, p)) * barWidth); StringBuilder bar = new StringBuilder(barWidth * 3 + 4); diff --git a/core/src/main/java/art/arcane/iris/core/tools/WorldCreationProgressReporter.java b/core/src/main/java/art/arcane/iris/core/tools/WorldCreationProgressReporter.java index 6a54b8a1a..47e9f2966 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/WorldCreationProgressReporter.java +++ b/core/src/main/java/art/arcane/iris/core/tools/WorldCreationProgressReporter.java @@ -18,6 +18,7 @@ package art.arcane.iris.core.tools; +import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.RuntimeProgressMessages; import art.arcane.iris.spi.IrisLogging; @@ -78,7 +79,8 @@ final class WorldCreationProgressReporter { static WorldCreationProgressReporter start(VolmitSender sender, String worldName) { WorldCreationProgressReporter reporter = new WorldCreationProgressReporter(sender, worldName); - if (sender.isPlayer() && sender.player() != null) { + if (sender.isPlayer() && sender.player() != null + && IrisSettings.get().getGeneral().isProgressBossBar()) { try { J.sfut(reporter::initializePlayerHud).get(5L, TimeUnit.SECONDS); } catch (Throwable failure) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java index 87482d6cc..f6d311241 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java @@ -281,8 +281,8 @@ public final class IrisObjectIO { AtomicReference ref = new AtomicReference<>(); CountDownLatch latch = new CountDownLatch(1); new Job() { - private int total = self.blocks.size() * 3 + self.states.size(); - private int c = 0; + private volatile int total = self.blocks.size() * 3 + self.states.size(); + private volatile int c = 0; @Override public String getName() { diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index f32ef4335..fa151945e 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -147,7 +147,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun this.dimensionKey = dimensionKey; this.folder = new ReactiveFolder( dataLocation, - (_a, _b, _c) -> hotload(), + (_a, _b, _c) -> hotloadFromWatcher(), new KList<>(".iob", ".json"), new KList<>(".iris"), new KList<>() @@ -542,6 +542,13 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun withExclusiveControl(() -> getEngine().hotload()); } + private void hotloadFromWatcher() { + if (!shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { + return; + } + withExclusiveControlFuture(() -> getEngine().hotload(), 30L, TimeUnit.SECONDS).join(); + } + @Override public CompletableFuture hotloadComplexAsync(long acquisitionTimeout, TimeUnit unit) { Engine activeEngine = getEngine(); diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java index c48304286..ca9587a30 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java @@ -18,6 +18,7 @@ package art.arcane.iris.platform.bukkit; +import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.MinecraftVersion; import art.arcane.iris.engine.object.IrisPosition; @@ -161,6 +162,9 @@ public final class BukkitPlatform implements IrisPlatform { } public static void showProgressLane(Player player, String laneId, String title, double progress, long staleMillis) { + if (!IrisSettings.get().getGeneral().isProgressBossBar()) { + return; + } hudLanes().show(player, laneId, title, progress, BarColor.BLUE, BarStyle.SOLID, staleMillis); } diff --git a/core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/DownloadJob.java b/core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/DownloadJob.java index ae7cae035..3fe90c64b 100644 --- a/core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/DownloadJob.java +++ b/core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/DownloadJob.java @@ -30,8 +30,8 @@ import java.net.URI; public class DownloadJob implements Job { private final DL.Download download; - private int tw; - private int cw; + private volatile int tw; + private volatile int cw; public DownloadJob(String url, File destination) throws MalformedURLException { tw = 1; diff --git a/core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/JobCollection.java b/core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/JobCollection.java index fdff8f96b..b24358b37 100644 --- a/core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/JobCollection.java +++ b/core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/JobCollection.java @@ -23,7 +23,7 @@ import art.arcane.volmlib.util.collection.KList; public class JobCollection implements Job { private final String name; private final KList jobs; - private String status; + private volatile String status; public JobCollection(String name, Job... jobs) { this(name, new KList<>(jobs)); diff --git a/core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/QueueJob.java b/core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/QueueJob.java index 967c7e72d..80db9c682 100644 --- a/core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/QueueJob.java +++ b/core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/QueueJob.java @@ -25,7 +25,7 @@ import java.util.concurrent.atomic.AtomicInteger; public abstract class QueueJob implements Job { final KList queue; private final AtomicInteger completed; - protected int totalWork; + protected volatile int totalWork; public QueueJob() { totalWork = 0; diff --git a/core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/SingleJob.java b/core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/SingleJob.java index 57dcbe465..13180feea 100644 --- a/core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/SingleJob.java +++ b/core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/SingleJob.java @@ -21,7 +21,7 @@ package art.arcane.iris.util.common.scheduling.jobs; public class SingleJob implements Job { private final String name; private final Runnable runnable; - private boolean done; + private volatile boolean done; public SingleJob(String name, Runnable runnable) { this.name = name; diff --git a/core/src/test/java/art/arcane/iris/core/IrisSettingsDefaultsTest.java b/core/src/test/java/art/arcane/iris/core/IrisSettingsDefaultsTest.java index fcd792d6d..0bbe35536 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisSettingsDefaultsTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisSettingsDefaultsTest.java @@ -6,7 +6,10 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertThrows; public class IrisSettingsDefaultsTest { @Test @@ -34,4 +37,64 @@ public class IrisSettingsDefaultsTest { assertTrue(settings.isAutoIngestDatapacks()); assertFalse(settings.isAutoImportDatapackStructures()); } + + @Test + public void hotloadSnapshotIsValidatedBeforeReplacingLiveSettings() { + IrisSettings previous = IrisSettings.settings; + IrisSettings live = new IrisSettings(); + IrisSettings.settings = live; + try { + assertThrows(IllegalArgumentException.class, + () -> IrisSettings.installHotloadSnapshot("{\"general\":")); + assertSame(live, IrisSettings.settings); + + IrisSettings installed = IrisSettings.installHotloadSnapshot( + "{\"general\":{\"language\":\"fr_FR\"},\"world\":null}"); + assertSame(installed, IrisSettings.settings); + assertEquals("fr_FR", installed.getGeneral().getLanguage()); + assertNotNull(installed.getWorld()); + } finally { + IrisSettings.settings = previous; + } + } + + @Test + public void rejectedHotloadActivationKeepsPreviousLiveSettings() { + IrisSettings previous = IrisSettings.settings; + IrisSettings live = new IrisSettings(); + live.getGeneral().setLanguage("en_US"); + IrisSettings.settings = live; + try { + boolean applied = IrisSettings.applyHotloadSnapshot( + "{\"general\":{\"language\":\"de_DE\"}}", + candidate -> false + ); + + assertFalse(applied); + assertSame(live, IrisSettings.settings); + assertEquals("en_US", IrisSettings.settings.getGeneral().getLanguage()); + } finally { + IrisSettings.settings = previous; + } + } + + @Test + public void successfulHotloadActivationPublishesCandidateSettings() { + IrisSettings previous = IrisSettings.settings; + IrisSettings live = new IrisSettings(); + IrisSettings.settings = live; + try { + boolean applied = IrisSettings.applyHotloadSnapshot( + "{\"general\":{\"language\":\"de_DE\"},\"world\":null}", + candidate -> "de_DE".equals(candidate.getGeneral().getLanguage()) + ); + + assertTrue(applied); + assertNotSame(live, IrisSettings.settings); + assertEquals("de_DE", IrisSettings.settings.getGeneral().getLanguage()); + assertNotNull(IrisSettings.settings.getWorld()); + } finally { + IrisSettings.settings = previous; + } + } } diff --git a/core/src/test/java/art/arcane/iris/core/ProgressBossBarToggleTest.java b/core/src/test/java/art/arcane/iris/core/ProgressBossBarToggleTest.java new file mode 100644 index 000000000..795b11a6d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/ProgressBossBarToggleTest.java @@ -0,0 +1,89 @@ +package art.arcane.iris.core; + +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class ProgressBossBarToggleTest { + @Test + public void progressBossBarsAreEnabledByDefault() { + assertTrue(new IrisSettings.IrisSettingsGeneral().isProgressBossBar()); + } + + @Test + public void showProgressLaneNeverTouchesTheLaneServiceWhenDisabled() { + withProgressBossBar(false, () -> + BukkitPlatform.showProgressLane(null, "iris:job", "Working", 0.5D, 4000L)); + } + + @Test + public void showProgressLaneStillReachesTheLaneServiceWhenEnabled() { + withProgressBossBar(true, () -> assertThrows(IllegalStateException.class, + () -> BukkitPlatform.showProgressLane(null, "iris:job", "Working", 0.5D, 4000L))); + } + + @Test + public void studioOpenBossBarIsGatedBySettings() throws Exception { + assertGated("core/project/StudioOpenProgressReporter.java"); + } + + @Test + public void worldCreationBossBarIsGatedBySettings() throws Exception { + assertGated("core/tools/WorldCreationProgressReporter.java"); + } + + @Test + public void chunkJobBossBarIsGatedBySettings() throws Exception { + assertGated("core/runtime/ChunkJobReporter.java"); + } + + @Test + public void packDownloadLaneIsGatedWhileTheActionBarSurvives() throws Exception { + String source = source("core/service/PackDownloadProgressReporter.java"); + + assertTrue(source.contains("isProgressBossBar()")); + assertTrue("the action bar must keep reporting when boss bars are off", + source.contains("sender.sendAction(snapshot.line())")); + } + + @Test + public void pregenLaneIsGatedWhileTheActionBarSurvives() throws Exception { + String source = source("core/tools/IrisCreator.java"); + int lane = source.indexOf("\"iris:pregen\""); + int gate = source.lastIndexOf("isProgressBossBar()", lane); + + assertTrue("the pregen boss bar lane must sit behind general.progressBossBar", + gate > 0 && lane - gate < 200); + assertTrue("the action bar must keep reporting when boss bars are off", + source.contains("RuntimeProgressMessages.WORLD_PREGEN_ACTION")); + } + + private void assertGated(String relativePath) throws Exception { + assertTrue(relativePath + " must consult general.progressBossBar before creating a boss bar", + source(relativePath).contains("isProgressBossBar()")); + } + + private String source(String relativePath) throws Exception { + return Files.readString(Path.of("src/main/java/art/arcane/iris").resolve(relativePath)); + } + + private void withProgressBossBar(boolean enabled, Runnable body) { + IrisSettings previous = IrisSettings.settings; + try { + IrisSettings live = new IrisSettings(); + live.getGeneral().setProgressBossBar(enabled); + IrisSettings.settings = live; + assertFalse("another test hosted a HUD; this test needs an unhosted platform", + BukkitPlatform.hasHud()); + body.run(); + } finally { + IrisSettings.settings = previous; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageHotloadSnapshotTest.java b/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageHotloadSnapshotTest.java new file mode 100644 index 000000000..0b3d5284c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageHotloadSnapshotTest.java @@ -0,0 +1,55 @@ +package art.arcane.iris.core.localization; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.attribute.FileTime; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class IrisLanguageHotloadSnapshotTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void capturesMissingOverrideAsStableTombstone() throws Exception { + File override = new File(temporaryFolder.getRoot(), "languages/overrides/en_US.json"); + + LocaleHotloadSnapshot snapshot = IrisLanguage.captureHotloadSnapshot(override, "en_US"); + + assertTrue(snapshot.missing()); + assertEquals("missing", snapshot.sha256()); + } + + @Test + public void detectsSameMetadataContentReplacementBySha256() throws Exception { + File override = new File(temporaryFolder.getRoot(), "languages/overrides/en_US.json"); + Files.createDirectories(override.toPath().getParent()); + String firstContent = "{\"messages\":{\"a\":\"1\"}}"; + String secondContent = "{\"messages\":{\"a\":\"2\"}}"; + Files.writeString(override.toPath(), firstContent, StandardCharsets.UTF_8); + FileTime fixedTime = FileTime.fromMillis(10_000L); + Files.setLastModifiedTime(override.toPath(), fixedTime); + LocaleHotloadSnapshot first = IrisLanguage.captureHotloadSnapshot(override, "en_US"); + + Files.writeString(override.toPath(), secondContent, StandardCharsets.UTF_8); + Files.setLastModifiedTime(override.toPath(), fixedTime); + LocaleHotloadSnapshot second = IrisLanguage.captureHotloadSnapshot(override, "en_US"); + + assertFalse(first.missing()); + assertFalse(second.missing()); + assertEquals(firstContent.length(), secondContent.length()); + assertEquals(first.file(), second.file()); + assertEquals(firstContent, first.content()); + assertEquals(secondContent, second.content()); + assertNotEquals(first.sha256(), second.sha256()); + assertNotEquals(first, second); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageTest.java b/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageTest.java index 3f2da90ab..090c70182 100644 --- a/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageTest.java +++ b/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.core.localization; +import art.arcane.iris.core.IrisSettings; import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.localization.LocaleOverlay; import art.arcane.volmlib.util.localization.LocalizationValidationResult; @@ -35,6 +36,7 @@ import java.util.stream.Stream; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; public class IrisLanguageTest { @@ -237,6 +239,26 @@ public class IrisLanguageTest { ); } + @Test + public void rejectedCandidateLocaleLeavesPreviousSettingsAndLanguageActive() { + IrisSettings previous = IrisSettings.settings; + IrisSettings live = new IrisSettings(); + live.getGeneral().setLanguage("en_US"); + IrisSettings.settings = live; + try { + boolean applied = IrisSettings.applyHotloadSnapshot( + "{\"general\":{\"language\":\"../invalid\"}}", + IrisLanguage::reload + ); + + assertFalse(applied); + assertSame(live, IrisSettings.settings); + assertEquals("en_US", IrisLanguage.activeLocale()); + } finally { + IrisSettings.settings = previous; + } + } + @Test public void untrustedArgumentsCannotInjectLegacyOrMiniMessageFormatting() throws Exception { writeOverride("de_DE", """ diff --git a/core/src/test/java/art/arcane/iris/core/localization/LocaleHotloadGateTest.java b/core/src/test/java/art/arcane/iris/core/localization/LocaleHotloadGateTest.java new file mode 100644 index 000000000..7155ceb7e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/localization/LocaleHotloadGateTest.java @@ -0,0 +1,159 @@ +package art.arcane.iris.core.localization; + +import org.junit.Test; + +import java.io.File; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +public class LocaleHotloadGateTest { + @Test + public void anchorsCooldownAtCompletedApplication() { + LocaleHotloadGate gate = gate(); + LocaleHotloadSnapshot initial = snapshot("initial"); + LocaleHotloadSnapshot first = snapshot("first"); + LocaleHotloadSnapshot second = snapshot("second"); + gate.reset(initial); + + assertNull(gate.observe(first, 0L)); + LocaleHotloadGate.Attempt firstAttempt = gate.observe(first, 100L); + assertNotNull(firstAttempt); + gate.complete(firstAttempt, 500L, true); + + assertNull(gate.observe(second, 600L)); + assertNull(gate.observe(second, 700L)); + assertNull(gate.observe(second, 3_499L)); + LocaleHotloadGate.Attempt secondAttempt = gate.observe(second, 3_500L); + + assertNotNull(secondAttempt); + assertEquals(second, secondAttempt.snapshot()); + } + + @Test + public void coalescesCooldownBurstToLatestStableSnapshot() { + LocaleHotloadGate gate = gate(); + LocaleHotloadSnapshot first = snapshot("first"); + LocaleHotloadSnapshot intermediate = snapshot("intermediate"); + LocaleHotloadSnapshot latest = snapshot("latest"); + gate.reset(snapshot("initial")); + + assertNull(gate.observe(first, 0L)); + LocaleHotloadGate.Attempt firstAttempt = gate.observe(first, 100L); + assertNotNull(firstAttempt); + gate.complete(firstAttempt, 200L, true); + + assertNull(gate.observe(intermediate, 250L)); + assertNull(gate.observe(intermediate, 350L)); + assertNull(gate.observe(latest, 400L)); + assertNull(gate.observe(latest, 500L)); + assertNull(gate.observe(latest, 3_199L)); + LocaleHotloadGate.Attempt latestAttempt = gate.observe(latest, 3_200L); + + assertNotNull(latestAttempt); + assertEquals(latest, latestAttempt.snapshot()); + } + + @Test + public void requiresDeletionGraceAndCancelsTransientMissingSnapshot() { + LocaleHotloadGate gate = gate(); + LocaleHotloadSnapshot initial = snapshot("initial"); + LocaleHotloadSnapshot replacement = snapshot("replacement"); + LocaleHotloadSnapshot missing = LocaleHotloadSnapshot.missing(initial.file(), "en_US"); + gate.reset(initial); + + assertNull(gate.observe(missing, 0L)); + assertNull(gate.observe(missing, 499L)); + assertNull(gate.observe(replacement, 500L)); + LocaleHotloadGate.Attempt replacementAttempt = gate.observe(replacement, 600L); + assertNotNull(replacementAttempt); + assertEquals(replacement, replacementAttempt.snapshot()); + gate.complete(replacementAttempt, 600L, true); + + assertNull(gate.observe(missing, 700L)); + assertNull(gate.observe(missing, 1_199L)); + assertNull(gate.observe(missing, 1_200L)); + LocaleHotloadGate.Attempt deletionAttempt = gate.observe(missing, 3_600L); + + assertNotNull(deletionAttempt); + assertEquals(missing, deletionAttempt.snapshot()); + } + + @Test + public void unavailableReadBlocksStalePendingSnapshot() { + LocaleHotloadGate gate = gate(); + LocaleHotloadSnapshot first = snapshot("first"); + LocaleHotloadSnapshot pending = snapshot("pending"); + gate.reset(snapshot("initial")); + + assertNull(gate.observe(first, 0L)); + LocaleHotloadGate.Attempt firstAttempt = gate.observe(first, 100L); + assertNotNull(firstAttempt); + gate.complete(firstAttempt, 200L, true); + assertNull(gate.observe(pending, 300L)); + assertNull(gate.observe(pending, 400L)); + + gate.unavailable(); + assertNull(gate.observe(pending, 3_200L)); + LocaleHotloadGate.Attempt recoveredAttempt = gate.observe(pending, 3_300L); + + assertNotNull(recoveredAttempt); + assertEquals(pending, recoveredAttempt.snapshot()); + } + + @Test + public void failedApplicationRetriesOnlyAfterCooldown() { + LocaleHotloadGate gate = gate(); + LocaleHotloadSnapshot changed = snapshot("changed"); + gate.reset(snapshot("initial")); + + assertNull(gate.observe(changed, 0L)); + LocaleHotloadGate.Attempt failedAttempt = gate.observe(changed, 100L); + assertNotNull(failedAttempt); + gate.complete(failedAttempt, 500L, false); + + assertNull(gate.observe(changed, 3_499L)); + LocaleHotloadGate.Attempt retryAttempt = gate.observe(changed, 3_500L); + + assertNotNull(retryAttempt); + assertEquals(changed, retryAttempt.snapshot()); + } + + @Test + public void manualResetInvalidatesInFlightAutomaticAttempt() { + LocaleHotloadGate gate = gate(); + LocaleHotloadSnapshot automatic = snapshot("automatic"); + LocaleHotloadSnapshot manual = snapshot("manual"); + LocaleHotloadSnapshot changed = snapshot("changed"); + gate.reset(snapshot("initial")); + + assertNull(gate.observe(automatic, 0L)); + LocaleHotloadGate.Attempt staleAttempt = gate.observe(automatic, 100L); + assertNotNull(staleAttempt); + gate.reset(manual); + gate.complete(staleAttempt, 200L, true); + + assertNull(gate.observe(manual, 200L)); + assertNull(gate.observe(changed, 300L)); + LocaleHotloadGate.Attempt currentAttempt = gate.observe(changed, 400L); + + assertNotNull(currentAttempt); + assertNotEquals(staleAttempt.generation(), currentAttempt.generation()); + assertEquals(changed, currentAttempt.snapshot()); + } + + private LocaleHotloadGate gate() { + return new LocaleHotloadGate(new LocaleHotloadGate.Timing(100L, 500L, 3_000L)); + } + + private LocaleHotloadSnapshot snapshot(String content) { + return LocaleHotloadSnapshot.present( + new File("locale.json"), + "en_US", + content, + content + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/StudioSVCPackAdmissionTest.java b/core/src/test/java/art/arcane/iris/core/service/StudioSVCPackAdmissionTest.java new file mode 100644 index 000000000..395bc948b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/StudioSVCPackAdmissionTest.java @@ -0,0 +1,60 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.pack.BrokenPackException; +import art.arcane.iris.core.pack.PackValidationResult; +import org.junit.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class StudioSVCPackAdmissionTest { + @Test + public void loadablePackIsAdmitted() { + PackValidationResult validation = new PackValidationResult( + "overworld", List.of(), List.of(), 1L); + + assertNull(StudioSVC.resolvePackAdmissionFailure( + "overworld", Optional.empty(), validation)); + } + + @Test + public void missingValidationFailsClosedWithTheResolvedPackName() { + BrokenPackException failure = StudioSVC.resolvePackAdmissionFailure( + "overworld-pack", Optional.empty(), null); + + assertEquals("overworld-pack", failure.getPackName()); + assertEquals(List.of( + "Required pack validation has not completed. Studio creation fails closed until validation succeeds."), + failure.getReasons()); + } + + @Test + public void blockingValidationPreservesEveryReasonInOrder() { + List reasons = List.of( + "Biome 'broken' has no resolvable regions.", + "Structure 'castle' references missing pool 'castle/start'."); + PackValidationResult validation = new PackValidationResult( + "overworld", reasons, List.of(), 1L); + + BrokenPackException failure = StudioSVC.resolvePackAdmissionFailure( + "overworld", Optional.empty(), validation); + + assertEquals("overworld", failure.getPackName()); + assertEquals(reasons, failure.getReasons()); + } + + @Test + public void startupDenialTakesPriorityOverCachedPackValidation() { + String denial = "Restart the server after changing external datapacks."; + PackValidationResult validation = new PackValidationResult( + "overworld", List.of(), List.of(), 1L); + + BrokenPackException failure = StudioSVC.resolvePackAdmissionFailure( + "overworld", Optional.of(denial), validation); + + assertEquals(List.of(denial), failure.getReasons()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/VillageImporterBundleTest.java b/core/src/test/java/art/arcane/iris/core/structure/VillageImporterBundleTest.java index 22f7ab477..d8a3a7ae5 100644 --- a/core/src/test/java/art/arcane/iris/core/structure/VillageImporterBundleTest.java +++ b/core/src/test/java/art/arcane/iris/core/structure/VillageImporterBundleTest.java @@ -112,9 +112,9 @@ public class VillageImporterBundleTest { } @Test - public void nativeMaximumDistanceControlsIrisAssemblyRadius() { + public void registeredJigsawMetadataIncludesAncientCitySourceAndAssemblyContract() { Map structure = VillageImporter.structureJson( - "minecraft:village_plains", + "minecraft:ancient_city", "village/pool/minecraft/start", 6, 81 @@ -122,8 +122,10 @@ public class VillageImporterBundleTest { assertEquals(6, structure.get("maxDepth")); assertEquals(6, structure.get("maxSizeChunks")); + assertEquals("STRUCTURE_PIECE", structure.get("placeMode")); assertEquals(IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH.name(), structure.get("branchFailurePolicy")); + assertEquals("minecraft:ancient_city", structure.get("vanillaSource")); } private StructureResourceBundle bundle(String objectContent) { diff --git a/gradle.properties b/gradle.properties index 68c75295d..a6f725308 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,4 +30,4 @@ apiVersion=26.1 fabricLoaderVersion=0.19.3 forgeVersion=26.2-65.1.1 neoForgeVersion=26.2.0.59 -volmLibCoordinate=com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522 +volmLibCoordinate=com.github.VolmitSoftware:VolmLib:e6574cf814b3dd670385a4d632a63fde2038e186 diff --git a/probe/build.gradle b/probe/build.gradle index e0d68893e..665118904 100644 --- a/probe/build.gradle +++ b/probe/build.gradle @@ -10,7 +10,7 @@ application { } String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') - .orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522') + .orElse('com.github.VolmitSoftware:VolmLib:e6574cf814b3dd670385a4d632a63fde2038e186') .get() dependencies {