This commit is contained in:
Brian Neumann-Fopiano
2026-08-21 02:03:24 -04:00
parent f43df6c232
commit 34092da6be
43 changed files with 1308 additions and 279 deletions
@@ -100,13 +100,17 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
} }
@Test @Test
public void sourceBeardAdjustmentsPrepareTerrainAcrossDeclaredSteps() { public void sourceBeardAdjustmentsPrepareOnlyAtTheSurfaceStructuresStep() {
assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
TerrainAdjustment.BEARD_THIN, GenerationStep.Decoration.SURFACE_STRUCTURES)); TerrainAdjustment.BEARD_THIN, GenerationStep.Decoration.SURFACE_STRUCTURES));
assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
TerrainAdjustment.BEARD_BOX, GenerationStep.Decoration.FLUID_SPRINGS)); TerrainAdjustment.BEARD_BOX, GenerationStep.Decoration.FLUID_SPRINGS));
assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
TerrainAdjustment.BEARD_THIN, GenerationStep.Decoration.UNDERGROUND_STRUCTURES)); 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 @Test
@@ -119,6 +123,31 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
TerrainAdjustment.NONE, GenerationStep.Decoration.FLUID_SPRINGS)); 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<BlockPos, BlockState> blocks = flatTerrain(area, 64);
Map<BlockPos, BlockState> before = new HashMap<>(blocks);
NativeStructureSurfaceFitter.prepareSurfaceStructures(
world(blocks), area, List.of(surfaceTarget(start)),
(x, z) -> 64);
assertEquals(before, blocks);
}
@Test @Test
public void explicitTerrainOverrideDisablesSourceBeardFitting() { public void explicitTerrainOverrideDisablesSourceBeardFitting() {
StructureStart start = desertStart(TerrainAdjustment.BEARD_BOX); StructureStart start = desertStart(TerrainAdjustment.BEARD_BOX);
@@ -158,85 +187,70 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
} }
@Test @Test
public void beardBoxUsesTheFullRigidHeightWhileBeardThinUsesTheGroundPlane() { public void sourceSurfaceAnchorMeetsOneBlockBelowTheAuthoredGroundPlane() {
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() {
BoundingBox bounds = new BoundingBox(0, 64, 0, 4, 90, 4); BoundingBox bounds = new BoundingBox(0, 64, 0, 4, 90, 4);
NativeStructureSurfaceFitter.SurfaceAnchor thin = NativeStructureSurfaceFitter.SurfaceAnchor anchor =
NativeStructureSurfaceFitter.surfaceAnchor( NativeStructureSurfaceFitter.surfaceAnchor(bounds, 68, 2);
bounds, 68, 2, TerrainAdjustment.BEARD_THIN);
NativeStructureSurfaceFitter.SurfaceAnchor box =
NativeStructureSurfaceFitter.surfaceAnchor(
bounds, 68, 2, TerrainAdjustment.BEARD_BOX);
assertEquals(67, thin.meetY()); assertEquals(67, anchor.meetY());
assertEquals(0, thin.verticalDistance(68)); assertEquals(0, anchor.minX());
assertEquals(1, thin.verticalDistance(67)); assertEquals(4, anchor.maxX());
assertEquals(0, box.verticalDistance(68)); assertEquals(0, anchor.minZ());
assertEquals(1, box.verticalDistance(67)); assertEquals(4, anchor.maxZ());
assertEquals(0, box.verticalDistance(90));
assertEquals(1, box.verticalDistance(91));
} }
@Test @Test
public void rigidFootprintsRaiseTerrainAcrossGapsLargerThanTheBeardKernel() { public void rigidFootprintsBoundTerrainAcrossGapsLargerThanTheBeardKernel() {
NativeStructureSurfaceFitter.SurfaceAnchor rigid = anchor(72, 2); NativeStructureSurfaceFitter.SurfaceAnchor rigid = anchor(72, 2);
NativeStructureSurfaceFitter.SurfaceAnchor junction = anchor(72, 1); NativeStructureSurfaceFitter.SurfaceAnchor junction = anchor(72, 1);
assertEquals(72, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(46, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(rigid), 2, 2, 40)); List.of(rigid), 2, 2, 40));
assertEquals(40, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(46, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(junction), 2, 2, 40)); List.of(junction), 2, 2, 40));
} }
@Test @Test
public void elevatedRigidFootprintsBlendIntoAHorizontalRescueTaper() { public void beardSkirtBoundsAndGradesUpwardAndDownwardSymmetrically() {
int originalY = 40; int originalY = 64;
int kernelEdge = NativeStructureSurfaceFitter.resolveSurfaceTarget( NativeStructureSurfaceFitter.SurfaceAnchor high = anchor(96, 2);
List.of(anchor(51, 2)), 5, 2, originalY); NativeStructureSurfaceFitter.SurfaceAnchor low = anchor(32, 2);
int gapThirteen = NativeStructureSurfaceFitter.resolveSurfaceTarget( int[] raised = new int[13];
List.of(anchor(52, 2)), 5, 2, originalY); int[] lowered = new int[13];
int gapEighteen = NativeStructureSurfaceFitter.resolveSurfaceTarget( for (int outset = 0; outset <= 12; outset++) {
List.of(anchor(57, 2)), 5, 2, originalY); int x = 4 + outset;
int gapTwentyFour = NativeStructureSurfaceFitter.resolveSurfaceTarget( raised[outset] = NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(anchor(63, 2)), 5, 2, originalY); List.of(high), x, 2, originalY);
lowered[outset] = NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(low), x, 2, originalY);
}
assertEquals(originalY, kernelEdge); assertArrayEquals(
assertTrue(gapThirteen >= kernelEdge); new int[]{70, 69, 68, 67, 67, 66, 66, 65, 65, 64, 64, 64, 64},
assertTrue(gapEighteen > gapThirteen); raised);
assertTrue(gapTwentyFour > gapEighteen); assertArrayEquals(
assertTrue(gapTwentyFour < 63); new int[]{58, 59, 60, 61, 61, 62, 62, 63, 63, 64, 64, 64, 64},
assertEquals(originalY, NativeStructureSurfaceFitter.resolveSurfaceTarget( lowered);
List.of(anchor(63, 2)), 16, 2, originalY)); for (int outset = 0; outset <= 12; outset++) {
assertEquals(originalY, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(raised[outset] - originalY, originalY - lowered[outset]);
List.of(anchor(63, 1)), 5, 2, originalY)); }
assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(anchor(64, 2)), 5, 2, 80));
} }
@Test @Test
public void projectedCenterAndRigidChildrenShareTerrainSupportAcrossTheAssembly() { public void projectedCenterAndRigidChildrenUseProcessedFoundationCells() throws Exception {
Structure structure = new DesertPyramidStructure( Structure structure = new DesertPyramidStructure(
new Structure.StructureSettings( new Structure.StructureSettings(
HolderSet.empty(), Map.of(), HolderSet.empty(), Map.of(),
GenerationStep.Decoration.SURFACE_STRUCTURES, GenerationStep.Decoration.SURFACE_STRUCTURES,
TerrainAdjustment.BEARD_BOX)); TerrainAdjustment.BEARD_BOX));
PoolElementStructurePiece center = rigidPiece( PoolElementStructurePiece center = rigidTemplatePiece(
new BoundingBox(0, 71, 0, 3, 80, 3), 1); new InlineSinglePoolElement(template(List.of(
PoolElementStructurePiece child = rigidPiece( block(1, 1, 1, Blocks.COBBLESTONE.defaultBlockState())))),
new BoundingBox(10, 65, 0, 13, 76, 3), 7); 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( StructureStart start = new StructureStart(
structure, new ChunkPos(0, 0), 0, structure, new ChunkPos(0, 0), 0,
new PiecesContainer(List.of(center, child))); new PiecesContainer(List.of(center, child)));
@@ -244,8 +258,8 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
Map<BlockPos, BlockState> blocks = new HashMap<>(); Map<BlockPos, BlockState> blocks = new HashMap<>();
for (int x = area.minX(); x <= area.maxX(); x++) { for (int x = area.minX(); x <= area.maxX(); x++) {
for (int z = area.minZ(); z <= area.maxZ(); z++) { for (int z = area.minZ(); z <= area.maxZ(); z++) {
put(blocks, x, 63, z, Blocks.DIRT.defaultBlockState()); put(blocks, x, 64, z, Blocks.DIRT.defaultBlockState());
put(blocks, x, 64, z, Blocks.GRASS_BLOCK.defaultBlockState()); put(blocks, x, 65, z, Blocks.GRASS_BLOCK.defaultBlockState());
} }
} }
@@ -255,21 +269,66 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE))); new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE)));
NativeStructureSurfaceFitter.prepareSurfaceStructures( NativeStructureSurfaceFitter.prepareSurfaceStructures(
world(blocks), area, targets, 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, 1, 71, 1));
assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 11, 71, 1)); assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 11, 71, 1));
assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 11, 70, 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 @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<BlockPos, BlockState> 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<BlockPos, BlockState> 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( StructureTemplate pathTemplate = template(List.of(
block(0, 0, 0, Blocks.AIR.defaultBlockState()), 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(15, 0, 0, Blocks.AIR.defaultBlockState()),
block(30, 0, 0, Blocks.AIR.defaultBlockState()), block(30, 0, 0, Blocks.DIRT.defaultBlockState()),
block(30, 1, 0, Blocks.DIRT_PATH.defaultBlockState()),
block(45, 0, 0, Blocks.DANDELION.defaultBlockState()))); block(45, 0, 0, Blocks.DANDELION.defaultBlockState())));
InlineSinglePoolElement element = new InlineSinglePoolElement( InlineSinglePoolElement element = new InlineSinglePoolElement(
pathTemplate, List.of(), StructureTemplatePool.Projection.TERRAIN_MATCHING); pathTemplate, List.of(), StructureTemplatePool.Projection.TERRAIN_MATCHING);
@@ -290,8 +349,8 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
world(blocks), area, List.of(surfaceTarget(start)), world(blocks), area, List.of(surfaceTarget(start)),
(x, z) -> x == 0 ? 60 : x == 30 ? 72 : 64); (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, 0, 61, 0));
assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 30, 68, 0)); assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 30, 71, 0));
assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 30, 72, 0)); assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 30, 72, 0));
assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 15, 64, 0)); assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 15, 64, 0));
assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 15, 65, 0)); assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 15, 65, 0));
@@ -770,10 +829,10 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
} }
@Test @Test
public void surfaceAnchorIsFlushInsideAndUnchangedAtRadius() { public void surfaceAnchorCapsInsideAndIsUnchangedAtRadius() {
NativeStructureSurfaceFitter.SurfaceAnchor anchor = anchor(72, 2); NativeStructureSurfaceFitter.SurfaceAnchor anchor = anchor(72, 2);
assertEquals(72, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(70, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(anchor), 2, 2, 64)); List.of(anchor), 2, 2, 64));
assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(anchor), 16, 2, 64)); List.of(anchor), 16, 2, 64));
@@ -786,7 +845,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
NativeStructureSurfaceFitter.SurfaceAnchor raised = anchor(68, 2); NativeStructureSurfaceFitter.SurfaceAnchor raised = anchor(68, 2);
NativeStructureSurfaceFitter.SurfaceAnchor lowered = anchor(64, 2); NativeStructureSurfaceFitter.SurfaceAnchor lowered = anchor(64, 2);
assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(65, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(raised), 10, 2, 64)); List.of(raised), 10, 2, 64));
assertEquals(67, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(67, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(lowered), 10, 2, 68)); List.of(lowered), 10, 2, 68));
@@ -1197,16 +1256,20 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
} }
@Test @Test
public void stackedRigidPiecesPreserveTheLowerAuthoredSurface() { public void stackedRigidPiecesPreserveTheLowerProcessedFoundation() throws Exception {
Structure structure = new DesertPyramidStructure( Structure structure = new DesertPyramidStructure(
new Structure.StructureSettings( new Structure.StructureSettings(
HolderSet.empty(), Map.of(), HolderSet.empty(), Map.of(),
GenerationStep.Decoration.SURFACE_STRUCTURES, GenerationStep.Decoration.SURFACE_STRUCTURES,
TerrainAdjustment.BEARD_BOX)); TerrainAdjustment.BEARD_BOX));
PoolElementStructurePiece lower = rigidPiece( PoolElementStructurePiece lower = rigidTemplatePiece(
new BoundingBox(0, 62, 0, 4, 65, 4), 1); new InlineSinglePoolElement(template(List.of(
PoolElementStructurePiece upper = rigidPiece( block(2, 1, 2, Blocks.COBBLESTONE.defaultBlockState())))),
new BoundingBox(0, 66, 0, 4, 78, 4), 1); 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( StructureStart start = new StructureStart(
structure, new ChunkPos(0, 0), 0, structure, new ChunkPos(0, 0), 0,
new PiecesContainer(List.of(upper, lower))); new PiecesContainer(List.of(upper, lower)));
@@ -1236,7 +1299,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
NativeStructureSurfaceFitter.SurfaceAnchor adjacent = NativeStructureSurfaceFitter.SurfaceAnchor adjacent =
new NativeStructureSurfaceFitter.SurfaceAnchor(5, 9, 0, 4, 70, 2); new NativeStructureSurfaceFitter.SurfaceAnchor(5, 9, 0, 4, 70, 2);
assertEquals(65, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(69, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(adjacent), 4, 2, 64)); List.of(adjacent), 4, 2, 64));
assertEquals(66, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(66, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(local, adjacent), 4, 2, 64)); List.of(local, adjacent), 4, 2, 64));
+1 -1
View File
@@ -3,7 +3,7 @@ def mainClass = 'art.arcane.iris.Iris'
def bootstrapperClass = 'art.arcane.iris.IrisBootstrap' def bootstrapperClass = 'art.arcane.iris.IrisBootstrap'
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
.orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522') .orElse('com.github.VolmitSoftware:VolmLib:e6574cf814b3dd670385a4d632a63fde2038e186')
.get() .get()
dependencies { dependencies {
@@ -669,7 +669,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
watch::readSettingsContent, watch::readSettingsContent,
watch::normalizeSettingsContent 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 // 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 // packs through cache/temp on an async thread, and a concurrent delete of that folder
// truncated pack imports mid-copy (partial packs/<key> without dimensions/). // truncated pack imports mid-copy (partial packs/<key> without dimensions/).
@@ -714,7 +714,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
pendingWorldReplacements.captureVanillaLevelContext(); pendingWorldReplacements.captureVanillaLevelContext();
pendingWorldReplacements.verifyLoadedPublishedWorlds(); pendingWorldReplacements.verifyLoadedPublishedWorlds();
J.a(this::bstats); J.a(this::bstats);
J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 60); J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 10);
J.sr(this::tickQueue, 0); J.sr(this::tickQueue, 0);
J.s(this::setupPapi); J.s(this::setupPapi);
if (IrisStartupValidation.isReady()) { if (IrisStartupValidation.isReady()) {
@@ -21,9 +21,12 @@ package art.arcane.iris.core;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.volmlib.util.hotload.ConfigHotloadEngine; import art.arcane.volmlib.util.hotload.ConfigHotloadEngine;
import art.arcane.volmlib.util.io.IO;
import java.io.File; 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; import java.util.List;
/** /**
@@ -31,6 +34,8 @@ import java.util.List;
* {@link ConfigHotloadEngine} is built from and drains the touched-file queue. * {@link ConfigHotloadEngine} is built from and drains the touched-file queue.
*/ */
public final class SettingsHotloadWatch { public final class SettingsHotloadWatch {
private static final int MAX_SETTINGS_BYTES = 2 * 1024 * 1024;
private final File settingsFile; private final File settingsFile;
public SettingsHotloadWatch(File settingsFile) { public SettingsHotloadWatch(File settingsFile) {
@@ -46,13 +51,17 @@ public final class SettingsHotloadWatch {
return; return;
} }
for (File file : engine.pollTouchedFiles()) { for (ConfigHotloadEngine.StableContentSnapshot snapshot : engine.pollTouchedSnapshots()) {
engine.processFileChange(file, ignored -> { if ("missing".equals(snapshot.signature())) {
IrisSettings.invalidate(); engine.processSnapshotChange(snapshot, ignored -> true, null);
IrisSettings.get(); Iris.warn("settings.json was removed; retaining the last valid runtime settings.");
IrisLanguage.reload(); continue;
return true; }
}, ignored -> Iris.info("Hotloaded settings.json ")); engine.processSnapshotChange(
snapshot,
stable -> applySettingsSnapshot(stable.file(), stable.normalizedContent()),
ignored -> Iris.info("Hotloaded settings.json ")
);
} }
IrisLanguage.update(); IrisLanguage.update();
} }
@@ -76,8 +85,12 @@ public final class SettingsHotloadWatch {
return null; return null;
} }
try { try (InputStream input = Files.newInputStream(file.toPath())) {
return IO.readAll(file); 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) { } catch (Throwable ex) {
Iris.warn("Failed to read settings file %s: %s%s", Iris.warn("Failed to read settings file %s: %s%s",
file.getAbsolutePath(), file.getAbsolutePath(),
@@ -95,4 +108,19 @@ public final class SettingsHotloadWatch {
return text.replace("\r\n", "\n").trim(); 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;
}
}
} }
@@ -2,6 +2,7 @@ package art.arcane.iris.core.commands;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.core.loader.IrisData; 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.WorldRuntimeControlService;
import art.arcane.iris.core.runtime.StudioOpenCoordinator; import art.arcane.iris.core.runtime.StudioOpenCoordinator;
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype; import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype;
@@ -960,6 +961,11 @@ public class CommandJigsaw implements DirectorExecutor {
Throwable failure = unwrapCompletionFailure(throwable == null Throwable failure = unwrapCompletionFailure(throwable == null
? new IllegalStateException("Studio open completed without a result") ? new IllegalStateException("Studio open completed without a result")
: throwable); : 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); Iris.reportError("Failed to open Jigsaw Studio for '" + structureKey + "'.", failure);
sendError(commandSender, "Jigsaw Studio open failed: " + failure.getMessage()); sendError(commandSender, "Jigsaw Studio open failed: " + failure.getMessage());
} }
@@ -1022,6 +1028,10 @@ public class CommandJigsaw implements DirectorExecutor {
return current; return current;
} }
static boolean isExpectedOpenDenial(Throwable failure) {
return unwrapCompletionFailure(failure) instanceof BrokenPackException;
}
private void sendError(String message) { private void sendError(String message) {
sender().sendMessage(C.RED + message); sender().sendMessage(C.RED + message);
} }
@@ -101,7 +101,7 @@ public class WandSVC implements IrisService {
var latch = new CountDownLatch(1); var latch = new CountDownLatch(1);
var holder = Iris.tickets.getHolder(p.getWorld()); var holder = Iris.tickets.getHolder(p.getWorld());
new Job() { new Job() {
private int i; private volatile int i;
private Chunk chunk; private Chunk chunk;
@Override @Override
@@ -1,5 +1,6 @@
package art.arcane.iris.core.commands; 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.StudioOpenCoordinator;
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype; import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; 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.JigsawStudioMode;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariantCatalog; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariantCatalog;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioWorkcellSpec; 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.StructureBackend;
import art.arcane.iris.core.structure.authoring.StructureCapability; import art.arcane.iris.core.structure.authoring.StructureCapability;
import art.arcane.iris.core.structure.authoring.StructureHash; 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.conversion.IrisStructureAdoptionInputKind;
import art.arcane.iris.core.structure.export.VanillaJigsawExportFormat; import art.arcane.iris.core.structure.export.VanillaJigsawExportFormat;
import art.arcane.iris.core.tools.IrisCreator; 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.IrisDimension;
import art.arcane.iris.engine.object.IrisDirection; import art.arcane.iris.engine.object.IrisDirection;
import art.arcane.iris.engine.object.IrisJigsawConnector; import art.arcane.iris.engine.object.IrisJigsawConnector;
@@ -44,6 +45,7 @@ import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@@ -101,6 +103,18 @@ public class CommandJigsawContractTest {
CommandJigsaw.STUDIO_OPEN_KIND.datapackPreparation()); 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 @Test
public void committedActivationStartsInitialEvaluationBeforePlayerBinding() throws Exception { public void committedActivationStartsInitialEvaluationBeforePlayerBinding() throws Exception {
String source = Files.readString(Path.of( String source = Files.readString(Path.of(
@@ -129,6 +143,10 @@ public class CommandJigsawContractTest {
NamespacedKey source = CommandJigsaw.parseRegisteredStructureKey("minecraft:village_plains"); NamespacedKey source = CommandJigsaw.parseRegisteredStructureKey("minecraft:village_plains");
assertEquals("minecraft_village_plains", CommandJigsaw.resolveConversionTarget(source, "auto")); assertEquals("minecraft_village_plains", CommandJigsaw.resolveConversionTarget(source, "auto"));
assertEquals("villages/plains", CommandJigsaw.resolveConversionTarget(source, "iris:villages/plains")); 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, assertThrows(IllegalArgumentException.class,
() -> CommandJigsaw.resolveConversionTarget(source, "custom:village")); () -> CommandJigsaw.resolveConversionTarget(source, "custom:village"));
} }
+1 -1
View File
@@ -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 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 minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2'))
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.3')) 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<String> irisArtifactName = { String platform, String targetVersion -> Closure<String> irisArtifactName = { String platform, String targetVersion ->
return "Iris v${project.version} [${platform}] ${targetVersion}.jar" return "Iris v${project.version} [${platform}] ${targetVersion}.jar"
} }
+1 -1
View File
@@ -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 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 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 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<String> loaderDisplayVersion = { String loaderVersion -> Closure<String> loaderDisplayVersion = { String loaderVersion ->
String coordinatePrefix = "${minecraftVersion}-" String coordinatePrefix = "${minecraftVersion}-"
if (loaderVersion.startsWith(coordinatePrefix)) { if (loaderVersion.startsWith(coordinatePrefix)) {
@@ -33,6 +33,7 @@ public final class NativeStructureSurfaceFitter {
private static final double SURFACE_TERRAIN_FALLOFF = 2.0; private static final double SURFACE_TERRAIN_FALLOFF = 2.0;
private static final long SURFACE_TERRAIN_INFLUENCE_SCALE = 1_000_000L; private static final long SURFACE_TERRAIN_INFLUENCE_SCALE = 1_000_000L;
private static final int SURFACE_TERRAIN_RADIUS = 12; 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 int MAX_SURFACE_TEMPLATE_CELLS = 4_194_304;
private static final Set<String> WARNED_SOURCE_BUDGET = private static final Set<String> WARNED_SOURCE_BUDGET =
ConcurrentHashMap.newKeySet(); ConcurrentHashMap.newKeySet();
@@ -113,8 +114,9 @@ public final class NativeStructureSurfaceFitter {
static boolean shouldPrepareSurfaceTerrain(TerrainAdjustment adjustment, static boolean shouldPrepareSurfaceTerrain(TerrainAdjustment adjustment,
GenerationStep.Decoration step) { GenerationStep.Decoration step) {
return adjustment == TerrainAdjustment.BEARD_THIN return step == GenerationStep.Decoration.SURFACE_STRUCTURES
|| adjustment == TerrainAdjustment.BEARD_BOX; && (adjustment == TerrainAdjustment.BEARD_THIN
|| adjustment == TerrainAdjustment.BEARD_BOX);
} }
static int surfaceTerrainRadius() { static int surfaceTerrainRadius() {
@@ -131,50 +133,27 @@ public final class NativeStructureSurfaceFitter {
int localTargetY = originalY; int localTargetY = originalY;
SurfaceAnchor selectedLocal = null; SurfaceAnchor selectedLocal = null;
long totalInfluence = 0L; long totalInfluence = 0L;
long weightedMeetY = 0L; long weightedTargetY = 0L;
long maximumInfluence = 0L; long maximumInfluence = 0L;
for (SurfaceAnchor anchor : anchors) { for (SurfaceAnchor anchor : anchors) {
int outX = IrisObjectVacuum.outset(worldX, anchor.minX(), anchor.maxX()); int outX = IrisObjectVacuum.outset(worldX, anchor.minX(), anchor.maxX());
int outZ = IrisObjectVacuum.outset(worldZ, anchor.minZ(), anchor.maxZ()); int outZ = IrisObjectVacuum.outset(worldZ, anchor.minZ(), anchor.maxZ());
boolean containsColumn = outX == 0 && outZ == 0; boolean containsColumn = outX == 0 && outZ == 0;
if (containsColumn && anchor.strength() > 1 && originalY < anchor.meetY()) { long distanceSquared = (long) outX * outX + (long) outZ * outZ;
if (precedes(anchor, selectedLocal)) { long radiusSquared = (long) SURFACE_TERRAIN_RADIUS * SURFACE_TERRAIN_RADIUS;
localTargetY = anchor.meetY(); if (distanceSquared > radiusSquared) {
selectedLocal = anchor;
}
continue; 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); double distance = Math.sqrt(distanceSquared);
factor = Math.pow( double factor = Math.pow(
1D - distance / SURFACE_TERRAIN_RADIUS, SURFACE_TERRAIN_FALLOFF); 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);
}
if (factor <= 0D) { if (factor <= 0D) {
continue; continue;
} }
int boundedTargetY = boundedSurfaceTarget(originalY, anchor.meetY());
if (containsColumn) { if (containsColumn) {
if (precedes(anchor, selectedLocal)) { if (precedes(anchor, selectedLocal)) {
localTargetY = anchor.meetY(); localTargetY = boundedTargetY;
selectedLocal = anchor; selectedLocal = anchor;
} }
continue; continue;
@@ -185,19 +164,35 @@ public final class NativeStructureSurfaceFitter {
} }
long weightedInfluence = influence * Math.max(1, anchor.strength()); long weightedInfluence = influence * Math.max(1, anchor.strength());
totalInfluence += weightedInfluence; totalInfluence += weightedInfluence;
weightedMeetY += weightedInfluence * anchor.meetY(); weightedTargetY += weightedInfluence * boundedTargetY;
maximumInfluence = Math.max(maximumInfluence, influence); maximumInfluence = Math.max(maximumInfluence, influence);
} }
if (selectedLocal != null) { if (selectedLocal != null) {
return new SurfaceResolution(localTargetY, selectedLocal.strength() > 1); return new SurfaceResolution(
localTargetY,
selectedLocal.strength() > 1
&& localTargetY == selectedLocal.meetY());
} }
if (totalInfluence == 0L) { if (totalInfluence == 0L) {
return new SurfaceResolution(originalY, false); return new SurfaceResolution(originalY, false);
} }
double blendedMeetY = weightedMeetY / (double) totalInfluence; double blendedTargetY = weightedTargetY / (double) totalInfluence;
double factor = maximumInfluence / (double) SURFACE_TERRAIN_INFLUENCE_SCALE; double factor = maximumInfluence / (double) SURFACE_TERRAIN_INFLUENCE_SCALE;
return new SurfaceResolution( 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) { private static boolean precedes(SurfaceAnchor candidate, SurfaceAnchor selected) {
@@ -235,12 +230,16 @@ public final class NativeStructureSurfaceFitter {
area.maxX() + SURFACE_TERRAIN_RADIUS, area.maxY(), area.maxX() + SURFACE_TERRAIN_RADIUS, area.maxY(),
area.maxZ() + SURFACE_TERRAIN_RADIUS); area.maxZ() + SURFACE_TERRAIN_RADIUS);
List<SurfaceAnchor> anchors = new ArrayList<>(); List<SurfaceAnchor> anchors = new ArrayList<>();
Set<StructureStart> seenStarts = Collections.newSetFromMap(
new IdentityHashMap<>());
for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) { for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) {
if (!requiresSourceSurfaceTerrain(target)) { if (!requiresSourceSurfaceTerrain(target)) {
continue; continue;
} }
StructureStart start = target.start(); StructureStart start = target.start();
TerrainAdjustment adjustment = start.getStructure().terrainAdaptation(); if (!seenStarts.add(start)) {
continue;
}
BoundingBox firstBounds = start.getPieces().getFirst().getBoundingBox(); BoundingBox firstBounds = start.getPieces().getFirst().getBoundingBox();
BlockPos firstCenter = firstBounds.getCenter(); BlockPos firstCenter = firstBounds.getCenter();
BlockPos referencePosition = new BlockPos( BlockPos referencePosition = new BlockPos(
@@ -251,19 +250,15 @@ public final class NativeStructureSurfaceFitter {
if (piece instanceof PoolElementStructurePiece poolPiece) { if (piece instanceof PoolElementStructurePiece poolPiece) {
StructureTemplatePool.Projection projection = StructureTemplatePool.Projection projection =
poolPiece.getElement().getProjection(); poolPiece.getElement().getProjection();
if (projection == StructureTemplatePool.Projection.RIGID) { if (!budgetExceeded
BoundingBox bounds = poolPiece.getBoundingBox(); && (projection == StructureTemplatePool.Projection.RIGID
anchors.add(surfaceAnchor( || projection == StructureTemplatePool.Projection.TERRAIN_MATCHING)
bounds, bounds.minY() + poolPiece.getGroundLevelDelta(),
2, adjustment));
} else if (!budgetExceeded
&& projection == StructureTemplatePool.Projection.TERRAIN_MATCHING
&& intersectsHorizontally(poolPiece.getBoundingBox(), influenceArea)) { && intersectsHorizontally(poolPiece.getBoundingBox(), influenceArea)) {
try { try {
List<SurfaceAnchor> pieceAnchors = new ArrayList<>(); List<SurfaceAnchor> pieceAnchors = new ArrayList<>();
addTerrainMatchingSurfaceAnchors( addProcessedSurfaceAnchors(
world, influenceArea, poolPiece, referencePosition, world, influenceArea, poolPiece, referencePosition,
adjustment, budget, pieceAnchors); projection, budget, pieceAnchors);
anchors.addAll(pieceAnchors); anchors.addAll(pieceAnchors);
} catch (TemplateBudgetExceeded ignored) { } catch (TemplateBudgetExceeded ignored) {
budgetExceeded = true; budgetExceeded = true;
@@ -274,22 +269,22 @@ public final class NativeStructureSurfaceFitter {
anchors.add(new SurfaceAnchor( anchors.add(new SurfaceAnchor(
junction.getSourceX(), junction.getSourceX(), junction.getSourceX(), junction.getSourceX(),
junction.getSourceZ(), junction.getSourceZ(), junction.getSourceZ(), junction.getSourceZ(),
junction.getSourceGroundY() - 1, 1, junction.getSourceGroundY() - 1, 1));
junction.getSourceGroundY(), junction.getSourceGroundY()));
} }
continue; continue;
} }
BoundingBox bounds = piece.getBoundingBox(); BoundingBox bounds = piece.getBoundingBox();
anchors.add(surfaceAnchor(bounds, bounds.minY(), 2, adjustment)); anchors.add(surfaceAnchor(bounds, bounds.minY(), 2));
} }
} }
return List.copyOf(anchors); return List.copyOf(anchors);
} }
private static void addTerrainMatchingSurfaceAnchors( private static void addProcessedSurfaceAnchors(
WorldGenLevel world, BoundingBox influenceArea, WorldGenLevel world, BoundingBox influenceArea,
PoolElementStructurePiece piece, BlockPos referencePosition, PoolElementStructurePiece piece, BlockPos referencePosition,
TerrainAdjustment adjustment, TemplateCellBudget budget, StructureTemplatePool.Projection projection,
TemplateCellBudget budget,
List<SurfaceAnchor> anchors) { List<SurfaceAnchor> anchors) {
NativeStructureTemplateOccupancy.OccupancyResult occupancy = NativeStructureTemplateOccupancy.OccupancyResult occupancy =
NativeStructureTemplateOccupancy.resolve( NativeStructureTemplateOccupancy.resolve(
@@ -299,14 +294,21 @@ public final class NativeStructureSurfaceFitter {
if (!occupancy.resolved()) { if (!occupancy.resolved()) {
return; return;
} }
int groundY = piece.getBoundingBox().minY() + piece.getGroundLevelDelta(); int maximumFoundationY = piece.getBoundingBox().minY()
+ piece.getGroundLevelDelta();
Map<Long, NativeStructureTemplateOccupancy.LowestCell> lowest = Map<Long, NativeStructureTemplateOccupancy.LowestCell> lowest =
NativeStructureTemplateOccupancy.lowestProcessedSolidCells(occupancy.cells()); NativeStructureTemplateOccupancy.lowestProcessedSolidCells(occupancy.cells());
for (NativeStructureTemplateOccupancy.LowestCell cell : lowest.values()) { for (NativeStructureTemplateOccupancy.LowestCell cell : lowest.values()) {
budget.consume(1); 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( BoundingBox column = new BoundingBox(
cell.x(), groundY, cell.z(), cell.x(), groundY, cell.z()); 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)) { if (WARNED_SOURCE_BUDGET.add(structureId)) {
IrisLogging.warn("Native structure SOURCE fitting for '" IrisLogging.warn("Native structure SOURCE fitting for '"
+ structureId + "' exceeded its bounded template budget; " + 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; return first.surfaceY() <= second.surfaceY() ? first : second;
} }
static SurfaceAnchor surfaceAnchor(BoundingBox bounds, int groundY, int strength, 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());
}
return new SurfaceAnchor(bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(), return new SurfaceAnchor(bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(),
meetY, strength, groundY, groundY); groundY - 1, strength);
} }
static boolean requiresSurfaceTerrain(StructureStart start) { static boolean requiresSurfaceTerrain(StructureStart start) {
@@ -823,16 +818,7 @@ public final class NativeStructureSurfaceFitter {
return state.isSolid() && !NativeStructureVegetationClearer.isTreeBlock(state); return state.isSolid() && !NativeStructureVegetationClearer.isTreeBlock(state);
} }
record SurfaceAnchor(int minX, int maxX, int minZ, int maxZ, int meetY, int strength, 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);
}
} }
private record SurfaceMaterials(BlockState surface, BlockState subsurface) { private record SurfaceMaterials(BlockState surface, BlockState subsurface) {
@@ -21,48 +21,118 @@ package art.arcane.iris.modded.service;
import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.util.hotload.ConfigHotloadEngine;
import net.minecraft.server.MinecraftServer; import net.minecraft.server.MinecraftServer;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.File; 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 { public final class ModdedSettingsHotloadService implements ModdedTickableService {
private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); 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 ConfigHotloadEngine hotloadEngine;
private long lastModified; private long lastPollAtNanos;
@Override @Override
public void onEnable() { public void onEnable() {
lastPollAt = 0L; File settingsFile = settingsFile();
lastModified = settingsFile().lastModified(); 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 @Override
public void onDisable() { public void onDisable() {
ConfigHotloadEngine active = hotloadEngine;
hotloadEngine = null;
if (active != null) {
active.clear();
}
} }
@Override @Override
public void onServerTick(MinecraftServer server) { public void onServerTick(MinecraftServer server) {
long now = System.currentTimeMillis(); ConfigHotloadEngine active = hotloadEngine;
if (now - lastPollAt < POLL_PERIOD_MILLIS) { if (active == null) {
return; return;
} }
lastPollAt = now;
long modified = settingsFile().lastModified(); long now = System.nanoTime();
if (modified == lastModified) { 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(); IrisLanguage.update();
return; } catch (RuntimeException failure) {
LOGGER.error("Iris settings hotload watcher failed", failure);
} }
if (IrisSettings.settings != null) {
IrisSettings.invalidate();
} }
IrisSettings.get();
IrisLanguage.reload(); private boolean reloadSettings(ConfigHotloadEngine.StableContentSnapshot snapshot) {
lastModified = settingsFile().lastModified(); try {
LOGGER.info("Hotloaded settings.json"); 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;
}
}
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() { private static File settingsFile() {
@@ -43,6 +43,7 @@ import org.slf4j.LoggerFactory;
import java.io.File; import java.io.File;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
@@ -82,6 +83,9 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
if (active != null) { if (active != null) {
active.shutdownNow(); active.shutdownNow();
} }
for (Watch watch : watches.values()) {
watch.close();
}
watches.clear(); watches.clear();
} }
@@ -172,7 +176,13 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
} }
if (!watches.isEmpty()) { if (!watches.isEmpty()) {
Set<String> keep = seen == null ? Set.of() : seen; Set<String> keep = seen == null ? Set.of() : seen;
watches.keySet().removeIf((String key) -> !keep.contains(key)); watches.entrySet().removeIf((Map.Entry<String, Watch> 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) { private void poll(String dimensionId, Watch watch, IrisModdedChunkGenerator generator, Engine engine) {
try { try {
if (watch.engine != engine) { if (watch.engine != engine) {
watch.close();
watch.engine = engine; watch.engine = engine;
watch.folder = new ReactiveFolder( watch.folder = new ReactiveFolder(
engine.getData().getDataFolder(), 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); LOGGER.info("Iris studio hotload {} pack={} {}ms", dimensionId, engine.getDimension().getLoadKey(), System.currentTimeMillis() - start);
} catch (Throwable e) { } catch (Throwable e) {
LOGGER.error("Iris studio hotload failed for {}", dimensionId, 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 final AtomicBoolean busy = new AtomicBoolean(false);
private volatile Engine engine; private volatile Engine engine;
private volatile ReactiveFolder folder; private volatile ReactiveFolder folder;
private void close() {
ReactiveFolder active = folder;
folder = null;
if (active != null) {
active.clear();
}
engine = null;
}
} }
} }
+1 -1
View File
@@ -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 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 minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2'))
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(rootProperties.getProperty('neoForgeVersion', '26.2.0.59')) 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<String> irisArtifactName = { String platform, String targetVersion -> Closure<String> irisArtifactName = { String platform, String targetVersion ->
return "Iris v${project.version} [${platform}] ${targetVersion}.jar" return "Iris v${project.version} [${platform}] ${targetVersion}.jar"
} }
+1 -1
View File
@@ -54,7 +54,7 @@ String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').get
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse('26.2-65.1.1') String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse('26.2-65.1.1')
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.2.0.59') String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.2.0.59')
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
.orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522') .orElse('com.github.VolmitSoftware:VolmLib:e6574cf814b3dd670385a4d632a63fde2038e186')
.get() .get()
String useLocalVolmLib = providers.gradleProperty('useLocalVolmLib').getOrElse('true') String useLocalVolmLib = providers.gradleProperty('useLocalVolmLib').getOrElse('true')
String localVolmLibDirectory = providers.gradleProperty('localVolmLibDirectory').getOrNull() String localVolmLibDirectory = providers.gradleProperty('localVolmLibDirectory').getOrNull()
+1 -1
View File
@@ -37,7 +37,7 @@ plugins {
def lib = 'art.arcane.iris.util' def lib = 'art.arcane.iris.util'
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
.orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522') .orElse('com.github.VolmitSoftware:VolmLib:e6574cf814b3dd670385a4d632a63fde2038e186')
.get() .get()
String sentryAuthToken = findProperty('sentry.auth.token') as String ?: System.getenv('SENTRY_AUTH_TOKEN') String sentryAuthToken = findProperty('sentry.auth.token') as String ?: System.getenv('SENTRY_AUTH_TOKEN')
boolean hasSentryAuthToken = sentryAuthToken != null && !sentryAuthToken.isBlank() boolean hasSentryAuthToken = sentryAuthToken != null && !sentryAuthToken.isBlank()
-1
View File
@@ -63,7 +63,6 @@ art/arcane/iris/core/service/ExternalDataSVC.java
art/arcane/iris/core/service/GlobalCacheSVC.java art/arcane/iris/core/service/GlobalCacheSVC.java
art/arcane/iris/core/service/JigsawStudioMarkerParser.java art/arcane/iris/core/service/JigsawStudioMarkerParser.java
art/arcane/iris/core/service/JigsawStudioMenuController.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/JigsawStudioPreviewRenderer.java
art/arcane/iris/core/service/JigsawStudioService.java art/arcane/iris/core/service/JigsawStudioService.java
art/arcane/iris/core/service/JigsawStudioToolCodec.java art/arcane/iris/core/service/JigsawStudioToolCodec.java
@@ -30,6 +30,8 @@ import lombok.Data;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.Objects;
import java.util.function.Predicate;
@Data @Data
public class IrisSettings { 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<IrisSettings> 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() { public void forceSave() {
File s = IrisPlatforms.get().dataFile("settings.json"); 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 @Data
public static class IrisSettingsAutoconfiguration { public static class IrisSettingsAutoconfiguration {
public boolean configureSpigotTimeoutTime = true; public boolean configureSpigotTimeoutTime = true;
@@ -273,6 +329,11 @@ public class IrisSettings {
public boolean splashLogoStartup = true; public boolean splashLogoStartup = true;
public boolean useConsoleCustomColors = true; public boolean useConsoleCustomColors = true;
public boolean useCustomColorsIngame = 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 adjustVanillaHeight = false;
public boolean autoIngestDatapacks = true; public boolean autoIngestDatapacks = true;
/** /**
@@ -28,19 +28,32 @@ import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import java.io.File; import java.io.File;
import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; 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.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern; import java.util.regex.Pattern;
public final class IrisLanguage { public final class IrisLanguage {
private static final long MAX_LOCALE_BYTES = 2L * 1024L * 1024L; 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 int MAX_REPORTED_ISSUES = 12;
private static final Pattern LOCALE_NAME = Pattern.compile("[A-Za-z0-9_-]+"); 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]"); 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. * new snapshot and the whole memo is discarded. Bounded by the catalog size.
*/ */
private static final AtomicReference<PlainMemo> PLAIN_MEMO = new AtomicReference<>(null); private static final AtomicReference<PlainMemo> 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 dataFolder;
private static volatile File watchedFile; private static volatile String lastCaptureFailureKey;
private static volatile long watchedSignature = Long.MIN_VALUE; private static volatile String lastInvalidHotloadLocale;
private static volatile String activeLocale = CATALOG.englishLocale(); private static volatile String activeLocale = CATALOG.englishLocale();
private IrisLanguage() { private IrisLanguage() {
@@ -82,6 +102,20 @@ public final class IrisLanguage {
return reload(root, configuredLocale()); 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) { public static synchronized boolean reload(File root, String locale) {
File resolvedRoot = root == null ? null : root.getAbsoluteFile(); File resolvedRoot = root == null ? null : root.getAbsoluteFile();
if (resolvedRoot == null) { if (resolvedRoot == null) {
@@ -92,16 +126,26 @@ public final class IrisLanguage {
requestedLocale = normalizeLocale(locale); requestedLocale = normalizeLocale(locale);
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
dataFolder = resolvedRoot; dataFolder = resolvedRoot;
HOTLOAD_GATE.reset(null);
IrisLogging.error("Rejected locale setting '" + locale + "'; continuing with " + activeLocale + "."); IrisLogging.error("Rejected locale setting '" + locale + "'; continuing with " + activeLocale + ".");
IrisLogging.reportError(exception); IrisLogging.reportError(exception);
exception.printStackTrace(); exception.printStackTrace();
return false; return false;
} }
LocalizationReloadResult result = MANAGER.reload(() -> loadCandidate(resolvedRoot, requestedLocale));
dataFolder = resolvedRoot;
File override = overrideFile(resolvedRoot, requestedLocale); File override = overrideFile(resolvedRoot, requestedLocale);
watchedFile = override; SnapshotCapture capture = captureForReload(override, requestedLocale);
watchedSignature = signature(override); 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()) { if (!result.applied()) {
reportRejectedReload(requestedLocale, result); reportRejectedReload(requestedLocale, result);
return false; return false;
@@ -123,14 +167,47 @@ public final class IrisLanguage {
try { try {
locale = normalizeLocale(configuredLocale()); locale = normalizeLocale(configuredLocale());
} catch (RuntimeException exception) { } 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); File expected = overrideFile(root, locale);
long signature = signature(expected); LocaleHotloadSnapshot snapshot;
if (expected.equals(watchedFile) && signature == watchedSignature) { 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 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() { public static String activeLocale() {
@@ -229,13 +306,16 @@ public final class IrisLanguage {
throw new IllegalArgumentException("Unsupported Iris message key: " + key.id()); 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"); File folder = new File(root, "languages/overrides");
Files.createDirectories(folder.toPath()); Files.createDirectories(folder.toPath());
List<LocaleOverlay> overlays = new ArrayList<>(2); List<LocaleOverlay> overlays = new ArrayList<>(2);
File override = overrideFile(root, locale); if (!snapshot.missing()) {
if (override.exists()) { overlays.add(parseOverlay(snapshot.file().getPath(), locale, snapshot.content()));
overlays.add(loadFileOverlay(override, locale));
} }
if (!CATALOG.englishLocale().equals(locale)) { 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) { private static LocaleOverlay parseOverlay(String source, String locale, String raw) {
JsonElement parsed = JsonParser.parseString(raw == null || raw.isBlank() ? "{}" : raw); JsonElement parsed = JsonParser.parseString(raw == null || raw.isBlank() ? "{}" : raw);
if (!parsed.isJsonObject()) { if (!parsed.isJsonObject()) {
@@ -461,11 +530,92 @@ public final class IrisLanguage {
return new File(new File(root, "languages/overrides"), normalizeLocale(locale) + ".json").getAbsoluteFile(); return new File(new File(root, "languages/overrides"), normalizeLocale(locale) + ".json").getAbsoluteFile();
} }
private static long signature(File file) { static LocaleHotloadSnapshot captureHotloadSnapshot(File file, String locale) throws IOException {
if (file == null || !file.exists()) { File resolvedFile = Objects.requireNonNull(file, "Locale override file cannot be null").getAbsoluteFile();
return 0L; 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) { private static void reportRejectedReload(String locale, LocalizationReloadResult result) {
@@ -489,4 +639,7 @@ public final class IrisLanguage {
private record PlainMemo(LocalizationSnapshot snapshot, Map<String, String> values) { private record PlainMemo(LocalizationSnapshot snapshot, Map<String, String> values) {
} }
private record SnapshotCapture(LocaleHotloadSnapshot snapshot, Exception failure) {
}
} }
@@ -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");
}
}
}
@@ -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;
}
}
@@ -19,6 +19,7 @@
package art.arcane.iris.core.project; package art.arcane.iris.core.project;
import art.arcane.iris.core.localization.BukkitRuntimeMessages; 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.IrisLanguage;
import art.arcane.iris.core.localization.RuntimeProgressMessages; import art.arcane.iris.core.localization.RuntimeProgressMessages;
import art.arcane.iris.util.common.format.C; import art.arcane.iris.util.common.format.C;
@@ -46,7 +47,8 @@ final class StudioOpenProgressReporter {
AtomicInteger taskId = new AtomicInteger(-1); AtomicInteger taskId = new AtomicInteger(-1);
org.bukkit.boss.BossBar bossBar; org.bukkit.boss.BossBar bossBar;
if (sender.isPlayer() && sender.player() != null) { if (sender.isPlayer() && sender.player() != null
&& IrisSettings.get().getGeneral().isProgressBossBar()) {
bossBar = Bukkit.createBossBar( bossBar = Bukkit.createBossBar(
IrisLanguage.text(RuntimeProgressMessages.STUDIO_OPENING), IrisLanguage.text(RuntimeProgressMessages.STUDIO_OPENING),
org.bukkit.boss.BarColor.BLUE, org.bukkit.boss.BarColor.BLUE,
@@ -18,6 +18,7 @@
package art.arcane.iris.core.runtime; 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.IrisLanguage;
import art.arcane.iris.core.localization.RuntimeProgressMessages; import art.arcane.iris.core.localization.RuntimeProgressMessages;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
@@ -109,8 +110,9 @@ public final class ChunkJobReporter {
} }
private void startReporter() { private void startReporter() {
boolean player = sender.isPlayer() && sender.player() != null; boolean showBossBar = sender.isPlayer() && sender.player() != null
BossBar bossBar = player && IrisSettings.get().getGeneral().isProgressBossBar();
BossBar bossBar = showBossBar
? Bukkit.createBossBar(IrisLanguage.text( ? Bukkit.createBossBar(IrisLanguage.text(
RuntimeProgressMessages.CHUNK_BOSSBAR_WORKING, RuntimeProgressMessages.CHUNK_BOSSBAR_WORKING,
MessageArgument.trusted("title", title) MessageArgument.trusted("title", title)
@@ -1,5 +1,6 @@
package art.arcane.iris.core.service; 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.IrisLanguage;
import art.arcane.iris.core.localization.PackDownloadMessages; import art.arcane.iris.core.localization.PackDownloadMessages;
import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.core.pack.PackDownloader;
@@ -246,6 +247,7 @@ final class PackDownloadProgressReporter implements PackDownloader.DownloadProgr
private void renderHudPulse(HudSnapshot snapshot) { private void renderHudPulse(HudSnapshot snapshot) {
try { try {
if (IrisSettings.get().getGeneral().isProgressBossBar()) {
BukkitPlatform.hudLanes().show( BukkitPlatform.hudLanes().show(
player, player,
hudLaneId, hudLaneId,
@@ -255,6 +257,7 @@ final class PackDownloadProgressReporter implements PackDownloader.DownloadProgr
BarStyle.SEGMENTED_20, BarStyle.SEGMENTED_20,
1_500L 1_500L
); );
}
sender.sendAction(snapshot.line()); sender.sendAction(snapshot.line());
} catch (RuntimeException failure) { } catch (RuntimeException failure) {
disableHud(failure); disableHud(failure);
@@ -310,6 +313,7 @@ final class PackDownloadProgressReporter implements PackDownloader.DownloadProgr
Runnable retiredCleanup = () -> retireHudLane(cleaned); Runnable retiredCleanup = () -> retireHudLane(cleaned);
Runnable display = () -> { Runnable display = () -> {
try { try {
if (IrisSettings.get().getGeneral().isProgressBossBar()) {
BukkitPlatform.hudLanes().show( BukkitPlatform.hudLanes().show(
player, player,
hudLaneId, hudLaneId,
@@ -319,6 +323,7 @@ final class PackDownloadProgressReporter implements PackDownloader.DownloadProgr
BarStyle.SOLID, BarStyle.SOLID,
4_000L 4_000L
); );
}
sender.sendAction(message); sender.sendAction(message);
} finally { } finally {
if (!J.runEntity(player, cleanup, HUD_TERMINAL_TICKS, retiredCleanup)) { if (!J.runEntity(player, cleanup, HUD_TERMINAL_TICKS, retiredCleanup)) {
@@ -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<String> startupDenial = IrisStartupValidation.denialReason(); Optional<String> startupDenial = IrisStartupValidation.denialReason();
if (startupDenial.isPresent()) {
sender.sendMessage(startupDenial.get());
return true;
}
IrisDimension dimension = IrisToolbelt.getDimension(dimm); IrisDimension dimension = IrisToolbelt.getDimension(dimm);
String packName = dimension == null || dimension.getLoader() == null String packName = dimension == null || dimension.getLoader() == null
? dimm ? dimm
: dimension.getLoader().getDataFolder().getName(); : dimension.getLoader().getDataFolder().getName();
PackValidationResult validation = PackValidationRegistry.get(packName); PackValidationResult validation = PackValidationRegistry.get(packName);
if (validation != null && validation.isLoadable()) { BrokenPackException failure = resolvePackAdmissionFailure(
return false; 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)))); 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<String> startupDenial,
PackValidationResult validation
) {
if (startupDenial.isPresent()) {
return new BrokenPackException(packName, List.of(startupDenial.get()));
}
if (validation != null && validation.isLoadable()) {
return null;
}
List<String> failures = validation == null List<String> failures = validation == null
? List.of("Required pack validation has not completed. Studio creation fails closed until validation succeeds.") ? List.of("Required pack validation has not completed. Studio creation fails closed until validation succeeds.")
: validation.getBlockingErrors(); : validation.getBlockingErrors();
for (String reason : failures) { return new BrokenPackException(packName, 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;
} }
public void open(VolmitSender sender, long seed, String dimm, Consumer<World> onDone) throws IrisException { public void open(VolmitSender sender, long seed, String dimm, Consumer<World> onDone) throws IrisException {
if (blockIfPackBroken(sender, dimm)) { if (reportPackAdmissionFailure(sender, dimm) != null) {
return; return;
} }
studioTransitions.submit(() -> replaceActiveProject(sender, seed, dimm, onDone)) studioTransitions.submit(() -> replaceActiveProject(sender, seed, dimm, onDone))
@@ -537,9 +554,9 @@ public class StudioSVC implements IrisService {
Runnable beforeOpen, Runnable beforeOpen,
Consumer<World> onDone Consumer<World> onDone
) { ) {
if (blockIfPackBroken(sender, dimension)) { BrokenPackException failure = reportPackAdmissionFailure(sender, dimension);
return CompletableFuture.failedFuture( if (failure != null) {
new IllegalStateException("Studio pack '" + dimension + "' has blocking validation errors.")); return CompletableFuture.failedFuture(failure);
} }
return studioTransitions.submit(() -> replaceActiveProjectTracked( return studioTransitions.submit(() -> replaceActiveProjectTracked(
sender, sender,
@@ -426,7 +426,7 @@ public final class VillageImporter {
emittedPools.size(), emittedPieces.size(), losses, true); 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()) { if (!losses.isEmpty()) {
msg += " (" + losses.size() + " fidelity warning(s) recorded)"; msg += " (" + losses.size() + " fidelity warning(s) recorded)";
} }
@@ -624,11 +624,13 @@ public class IrisCreator {
return; return;
} }
if (showLoaderHud) { if (showLoaderHud) {
if (IrisSettings.get().getGeneral().isProgressBossBar()) {
BukkitPlatform.hudLanes().show(sender.player(), "iris:pregen", IrisLanguage.text( BukkitPlatform.hudLanes().show(sender.player(), "iris:pregen", IrisLanguage.text(
RuntimeProgressMessages.WORLD_PREGEN_ACTION, RuntimeProgressMessages.WORLD_PREGEN_ACTION,
MessageArgument.trusted("bar", ""), MessageArgument.trusted("bar", ""),
MessageArgument.trusted("percent", percent) MessageArgument.trusted("percent", percent)
), p, BarColor.GREEN, BarStyle.SOLID, 4000L); ), p, BarColor.GREEN, BarStyle.SOLID, 4000L);
}
int barWidth = 44; int barWidth = 44;
int filled = (int) Math.round(Math.max(0.0D, Math.min(1.0D, p)) * barWidth); int filled = (int) Math.round(Math.max(0.0D, Math.min(1.0D, p)) * barWidth);
StringBuilder bar = new StringBuilder(barWidth * 3 + 4); StringBuilder bar = new StringBuilder(barWidth * 3 + 4);
@@ -18,6 +18,7 @@
package art.arcane.iris.core.tools; 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.IrisLanguage;
import art.arcane.iris.core.localization.RuntimeProgressMessages; import art.arcane.iris.core.localization.RuntimeProgressMessages;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
@@ -78,7 +79,8 @@ final class WorldCreationProgressReporter {
static WorldCreationProgressReporter start(VolmitSender sender, String worldName) { static WorldCreationProgressReporter start(VolmitSender sender, String worldName) {
WorldCreationProgressReporter reporter = new WorldCreationProgressReporter(sender, worldName); WorldCreationProgressReporter reporter = new WorldCreationProgressReporter(sender, worldName);
if (sender.isPlayer() && sender.player() != null) { if (sender.isPlayer() && sender.player() != null
&& IrisSettings.get().getGeneral().isProgressBossBar()) {
try { try {
J.sfut(reporter::initializePlayerHud).get(5L, TimeUnit.SECONDS); J.sfut(reporter::initializePlayerHud).get(5L, TimeUnit.SECONDS);
} catch (Throwable failure) { } catch (Throwable failure) {
@@ -281,8 +281,8 @@ public final class IrisObjectIO {
AtomicReference<IOException> ref = new AtomicReference<>(); AtomicReference<IOException> ref = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1); CountDownLatch latch = new CountDownLatch(1);
new Job() { new Job() {
private int total = self.blocks.size() * 3 + self.states.size(); private volatile int total = self.blocks.size() * 3 + self.states.size();
private int c = 0; private volatile int c = 0;
@Override @Override
public String getName() { public String getName() {
@@ -147,7 +147,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
this.dimensionKey = dimensionKey; this.dimensionKey = dimensionKey;
this.folder = new ReactiveFolder( this.folder = new ReactiveFolder(
dataLocation, dataLocation,
(_a, _b, _c) -> hotload(), (_a, _b, _c) -> hotloadFromWatcher(),
new KList<>(".iob", ".json"), new KList<>(".iob", ".json"),
new KList<>(".iris"), new KList<>(".iris"),
new KList<>() new KList<>()
@@ -542,6 +542,13 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
withExclusiveControl(() -> getEngine().hotload()); withExclusiveControl(() -> getEngine().hotload());
} }
private void hotloadFromWatcher() {
if (!shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) {
return;
}
withExclusiveControlFuture(() -> getEngine().hotload(), 30L, TimeUnit.SECONDS).join();
}
@Override @Override
public CompletableFuture<Void> hotloadComplexAsync(long acquisitionTimeout, TimeUnit unit) { public CompletableFuture<Void> hotloadComplexAsync(long acquisitionTimeout, TimeUnit unit) {
Engine activeEngine = getEngine(); Engine activeEngine = getEngine();
@@ -18,6 +18,7 @@
package art.arcane.iris.platform.bukkit; 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.INMS;
import art.arcane.iris.core.nms.MinecraftVersion; import art.arcane.iris.core.nms.MinecraftVersion;
import art.arcane.iris.engine.object.IrisPosition; 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) { 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); hudLanes().show(player, laneId, title, progress, BarColor.BLUE, BarStyle.SOLID, staleMillis);
} }
@@ -30,8 +30,8 @@ import java.net.URI;
public class DownloadJob implements Job { public class DownloadJob implements Job {
private final DL.Download download; private final DL.Download download;
private int tw; private volatile int tw;
private int cw; private volatile int cw;
public DownloadJob(String url, File destination) throws MalformedURLException { public DownloadJob(String url, File destination) throws MalformedURLException {
tw = 1; tw = 1;
@@ -23,7 +23,7 @@ import art.arcane.volmlib.util.collection.KList;
public class JobCollection implements Job { public class JobCollection implements Job {
private final String name; private final String name;
private final KList<Job> jobs; private final KList<Job> jobs;
private String status; private volatile String status;
public JobCollection(String name, Job... jobs) { public JobCollection(String name, Job... jobs) {
this(name, new KList<>(jobs)); this(name, new KList<>(jobs));
@@ -25,7 +25,7 @@ import java.util.concurrent.atomic.AtomicInteger;
public abstract class QueueJob<T> implements Job { public abstract class QueueJob<T> implements Job {
final KList<T> queue; final KList<T> queue;
private final AtomicInteger completed; private final AtomicInteger completed;
protected int totalWork; protected volatile int totalWork;
public QueueJob() { public QueueJob() {
totalWork = 0; totalWork = 0;
@@ -21,7 +21,7 @@ package art.arcane.iris.util.common.scheduling.jobs;
public class SingleJob implements Job { public class SingleJob implements Job {
private final String name; private final String name;
private final Runnable runnable; private final Runnable runnable;
private boolean done; private volatile boolean done;
public SingleJob(String name, Runnable runnable) { public SingleJob(String name, Runnable runnable) {
this.name = name; this.name = name;
@@ -6,7 +6,10 @@ import org.junit.Test;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; 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.assertTrue;
import static org.junit.Assert.assertThrows;
public class IrisSettingsDefaultsTest { public class IrisSettingsDefaultsTest {
@Test @Test
@@ -34,4 +37,64 @@ public class IrisSettingsDefaultsTest {
assertTrue(settings.isAutoIngestDatapacks()); assertTrue(settings.isAutoIngestDatapacks());
assertFalse(settings.isAutoImportDatapackStructures()); 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;
}
}
} }
@@ -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;
}
}
}
@@ -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);
}
}
@@ -1,5 +1,6 @@
package art.arcane.iris.core.localization; 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.MessageArgument;
import art.arcane.volmlib.util.localization.LocaleOverlay; import art.arcane.volmlib.util.localization.LocaleOverlay;
import art.arcane.volmlib.util.localization.LocalizationValidationResult; 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.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class IrisLanguageTest { 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 @Test
public void untrustedArgumentsCannotInjectLegacyOrMiniMessageFormatting() throws Exception { public void untrustedArgumentsCannotInjectLegacyOrMiniMessageFormatting() throws Exception {
writeOverride("de_DE", """ writeOverride("de_DE", """
@@ -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
);
}
}
@@ -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<String> 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());
}
}
@@ -112,9 +112,9 @@ public class VillageImporterBundleTest {
} }
@Test @Test
public void nativeMaximumDistanceControlsIrisAssemblyRadius() { public void registeredJigsawMetadataIncludesAncientCitySourceAndAssemblyContract() {
Map<String, Object> structure = VillageImporter.structureJson( Map<String, Object> structure = VillageImporter.structureJson(
"minecraft:village_plains", "minecraft:ancient_city",
"village/pool/minecraft/start", "village/pool/minecraft/start",
6, 6,
81 81
@@ -122,8 +122,10 @@ public class VillageImporterBundleTest {
assertEquals(6, structure.get("maxDepth")); assertEquals(6, structure.get("maxDepth"));
assertEquals(6, structure.get("maxSizeChunks")); assertEquals(6, structure.get("maxSizeChunks"));
assertEquals("STRUCTURE_PIECE", structure.get("placeMode"));
assertEquals(IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH.name(), assertEquals(IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH.name(),
structure.get("branchFailurePolicy")); structure.get("branchFailurePolicy"));
assertEquals("minecraft:ancient_city", structure.get("vanillaSource"));
} }
private StructureResourceBundle bundle(String objectContent) { private StructureResourceBundle bundle(String objectContent) {
+1 -1
View File
@@ -30,4 +30,4 @@ apiVersion=26.1
fabricLoaderVersion=0.19.3 fabricLoaderVersion=0.19.3
forgeVersion=26.2-65.1.1 forgeVersion=26.2-65.1.1
neoForgeVersion=26.2.0.59 neoForgeVersion=26.2.0.59
volmLibCoordinate=com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522 volmLibCoordinate=com.github.VolmitSoftware:VolmLib:e6574cf814b3dd670385a4d632a63fde2038e186
+1 -1
View File
@@ -10,7 +10,7 @@ application {
} }
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
.orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522') .orElse('com.github.VolmitSoftware:VolmLib:e6574cf814b3dd670385a4d632a63fde2038e186')
.get() .get()
dependencies { dependencies {