mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-12 03:24:14 +00:00
Compare commits
112
Commits
@@ -49,9 +49,11 @@ body:
|
||||
id: mcversion
|
||||
attributes:
|
||||
label: Minecraft Version
|
||||
description: What version of Minecraft is the server on? Iris 4.x targets 26.2 only; older versions are not supported.
|
||||
description: What version of Minecraft is the server on? Iris 4.x supports 26.1.2 to 26.3 on Bukkit-family servers and 26.3 on Fabric, Forge and NeoForge; older versions are not supported.
|
||||
options:
|
||||
- '26.3'
|
||||
- '26.2'
|
||||
- '26.1.2'
|
||||
- Other (unsupported)
|
||||
validations:
|
||||
required: true
|
||||
@@ -59,7 +61,7 @@ body:
|
||||
id: loaderversion
|
||||
attributes:
|
||||
label: Platform / Loader Version
|
||||
description: Exact server or loader build (see console). For example "Paper 26.2-60", "Fabric Loader 0.19.3", "NeoForge 26.2.0.12-beta".
|
||||
description: Exact server or loader build (see console). For example "Paper 26.3-133", "Fabric Loader 0.19.5", "NeoForge 26.3.0.33-beta".
|
||||
placeholder: DO NOT SAY "LATEST"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -4,7 +4,7 @@ Iris is a world generation engine for Minecraft servers and mod loaders. It gene
|
||||
biomes, caves, structures, objects, and entities from editable JSON packs, with a full in-game
|
||||
studio authoring workflow. The same engine runs as a Bukkit-family plugin and as a Fabric, Forge,
|
||||
or NeoForge server mod. Cross-platform generation targets deterministic parity for identical
|
||||
artifacts, pack bytes, seeds, and test areas. The master branch targets Minecraft 26.2.
|
||||
artifacts, pack bytes, seeds, and test areas. The master branch targets Minecraft 26.3.
|
||||
|
||||
# [Support](https://discord.gg/3xxPTpT) **|** [Documentation](https://github.com/VolmitSoftware/docs/blob/master/iris/00-overview.md) **|** [Git](https://github.com/IrisDimensions)
|
||||
|
||||
@@ -20,12 +20,12 @@ Canonical English is defined in the typed Java catalogs under `core/src/main/jav
|
||||
|
||||
| Platform | Artifact | Minecraft | Notes |
|
||||
|---|---|---|---|
|
||||
| Paper / Purpur / Leaf / Canvas | plugin jar | 26.1.2 - 26.2 | Full feature set |
|
||||
| Folia | plugin jar | 26.1.2 - 26.2 | Region-safe scheduling throughout |
|
||||
| Spigot / CraftBukkit | plugin jar | 26.1.2 - 26.2 | Managed `iris:*` creation and generation; exact vanilla-slot `/iris replace` is unavailable |
|
||||
| Fabric | mod jar | 26.2 | Server worldgen + client HUD; requires Fabric Loader 0.19.3+ |
|
||||
| Forge | mod jar | 26.2 | Server worldgen + client HUD; current target is Forge 26.2-65.1.1 |
|
||||
| NeoForge | mod jar | 26.2 | Server worldgen + client HUD; current target is NeoForge 26.2.0.59 |
|
||||
| Paper / Purpur / Leaf / Canvas | plugin jar | 26.1.2 - 26.3 | Full feature set |
|
||||
| Folia | plugin jar | 26.1.2 - 26.3 | Region-safe scheduling throughout |
|
||||
| Spigot / CraftBukkit | plugin jar | 26.1.2 - 26.3 | Managed `iris:*` creation and generation; exact vanilla-slot `/iris replace` is unavailable |
|
||||
| Fabric | mod jar | 26.3 | Server worldgen + client HUD; requires Fabric Loader 0.19.5+ |
|
||||
| Forge | mod jar | 26.3 | Server worldgen + client HUD; current target is Forge 26.3-66.0.8 |
|
||||
| NeoForge | mod jar | 26.3 | Server worldgen + client HUD; current target is NeoForge 26.3.0.33-beta |
|
||||
|
||||
Java 25 is required on every platform.
|
||||
|
||||
@@ -271,6 +271,12 @@ when it exists, otherwise `build/consumers/` inside the repo. `-Plocation=/path/
|
||||
changes only the consumer directory. Both tasks copy all four verified platform jars into
|
||||
`../PluginOuts/`.
|
||||
|
||||
VolmLib is built from source when a `VolmLib` checkout sits beside the repo or in a parent directory
|
||||
(`VOLMLIB_DIR` or `-PlocalVolmLibDirectory=<path>` pick another one). Without one, or with
|
||||
`-PuseLocalVolmLib=false`, the build uses VolmLib `master-SNAPSHOT` from JitPack. There is no VolmLib
|
||||
version to bump. The build logs which VolmLib it used, and every jar records it in
|
||||
`META-INF/volmit/volmlib.properties`.
|
||||
|
||||
If you need help compiling as a developer or contributor, ask in the Discord.
|
||||
|
||||
## Adapters / modded development
|
||||
@@ -298,9 +304,10 @@ properties:
|
||||
| `-PirisWorldCheck=<world>` | `iris.worldcheck` | Post-generation world integrity check |
|
||||
|
||||
Fabric additionally takes `-PirisClientRunDir=<dir>` to relocate the `runClient` working directory.
|
||||
Shared code lives in `adapters/minecraft-common` (all adapters), `adapters/modded-common`
|
||||
(loaders + the shared test suite), and `adapters/client-common` (client HUD and world-type
|
||||
screens); every adapter adds those source directories, so one edit reaches all three loaders.
|
||||
Shared code lives in `adapters/modded-common` (loaders + the shared test suite) and
|
||||
`adapters/client-common` (client HUD and world-type screens); every adapter adds those source
|
||||
directories, so one edit reaches all three loaders. Shared Minecraft-native code lives in VolmLib
|
||||
(`native/minecraft-common`).
|
||||
|
||||
For IDE import you can surface the three adapter builds in the root composite with
|
||||
`-PincludeModdedAdapters=true`. It is off by default: each adapter includes the root build back to
|
||||
|
||||
+36
-31
@@ -4,12 +4,12 @@ import art.arcane.volmlib.nativelib.v26_2_R1.terrain.NativeChunkGenerator;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeTerrainColumns;
|
||||
import art.arcane.iris.platform.bukkit.nms.BukkitTerrainColumnPolicy;
|
||||
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.GenerationSessionLease;
|
||||
import art.arcane.iris.world.history.BoundaryColumnGeometry;
|
||||
import art.arcane.iris.world.history.TerrainBoundarySignature;
|
||||
import art.arcane.iris.testsupport.PlatformLeakGuard;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeTerrainColumnPolicy;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
@@ -24,7 +24,7 @@ import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -32,6 +32,7 @@ import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.never;
|
||||
|
||||
public final class IrisChunkGeneratorHeightCacheTest {
|
||||
@Rule
|
||||
@@ -44,68 +45,72 @@ public final class IrisChunkGeneratorHeightCacheTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedNativeHeightSurvivesResolvedTerrainEvictionAndRuntimeChange() throws Exception {
|
||||
public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
when(fixture.complex().resolvedTerrainColumn(-1, -33))
|
||||
.thenThrow(new IllegalStateException("Full terrain was evicted"));
|
||||
|
||||
when(fixture.engine().getHeight(-1, -33, false)).thenReturn(4);
|
||||
for (int index = 0; index < 100; index++) {
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(
|
||||
-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
}
|
||||
verify(fixture.complex(), times(1)).resolvedTerrainColumn(-1, -33);
|
||||
verify(fixture.engine(), times(100)).getHeight(-1, -33, false);
|
||||
|
||||
when(fixture.engine().getCacheID()).thenReturn(2);
|
||||
doReturn(Optional.of(signature())).when(fixture.complex()).resolvedTerrainColumn(-1, -33);
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
verify(fixture.complex(), times(2)).resolvedTerrainColumn(-1, -33);
|
||||
when(fixture.engine().getHeight(-1, -33, false)).thenReturn(7);
|
||||
assertEquals(-8, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
verify(fixture.engine(), never()).getComplex();
|
||||
verify(fixture.engine(), never()).getMode();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ordinaryHeightRemainsLiveUntilResolvedTransitionTerrainExists() throws Exception {
|
||||
public void nativeHeightPredicatesSelectFluidOrSolidHeightWithoutGeneratingTerrain() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
||||
when(fixture.complex().resolvedTerrainColumn(0, 0))
|
||||
.thenReturn(Optional.empty(), Optional.empty(), Optional.of(signature()));
|
||||
when(fixture.engine().getHeight(0, 0, false)).thenReturn(20, 25);
|
||||
when(fixture.engine().getHeight(0, 0, true)).thenReturn(8);
|
||||
|
||||
assertEquals(5, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
assertEquals(10, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
assertEquals(-7, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
|
||||
verify(fixture.engine(), times(2)).getHeight(0, 0, false);
|
||||
verify(fixture.complex(), times(3)).resolvedTerrainColumn(0, 0);
|
||||
verify(fixture.engine(), times(1)).getHeight(0, 0, true);
|
||||
verify(fixture.engine(), never()).getComplex();
|
||||
verify(fixture.engine(), never()).getMode();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativePredicatesAndAccessorBoundsRemainExact() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
NativeTerrainColumnPolicy policy = mock(NativeTerrainColumnPolicy.class);
|
||||
NativeTerrainColumnPolicy.ColumnSession session = mock(NativeTerrainColumnPolicy.ColumnSession.class);
|
||||
when(policy.openQuery(any())).thenReturn(session);
|
||||
when(policy.placementKey(anyString())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
doReturn(Optional.of(signature().geometry())).when(session).resolvedColumn();
|
||||
NativeChunkGenerator<?, ?, ?, ?, ?> generator = generator(policy);
|
||||
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
||||
for (int repeat = 0; repeat < 2; repeat++) {
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
|
||||
assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null));
|
||||
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null));
|
||||
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE,
|
||||
assertEquals(-11, generator.getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
assertEquals(-13, generator.getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
|
||||
assertEquals(-13, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null));
|
||||
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null));
|
||||
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE,
|
||||
LevelHeightAccessor.create(-16, 2), null));
|
||||
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
|
||||
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
|
||||
LevelHeightAccessor.create(-14, 3), null));
|
||||
}
|
||||
verify(fixture.complex(), times(6)).resolvedTerrainColumn(0, 0);
|
||||
verify(session, times(6)).resolvedColumn();
|
||||
}
|
||||
|
||||
private static Fixture fixture() throws Exception {
|
||||
Engine engine = mock(Engine.class);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(engine.getCacheID()).thenReturn(1);
|
||||
when(engine.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop());
|
||||
when(complex.resolvedTerrainColumn(anyInt(), anyInt())).thenReturn(Optional.of(signature()));
|
||||
return new Fixture(generator(new BukkitTerrainColumnPolicy(engine, null)), engine);
|
||||
}
|
||||
|
||||
private static NativeChunkGenerator<?, ?, ?, ?, ?> generator(NativeTerrainColumnPolicy policy) throws Exception {
|
||||
NativeChunkGenerator<?, ?, ?, ?, ?> generator = mock(NativeChunkGenerator.class, CALLS_REAL_METHODS);
|
||||
set(generator, "terrainColumns", new NativeTerrainColumns(new BukkitTerrainColumnPolicy(engine, null)));
|
||||
return new Fixture(generator, engine, complex);
|
||||
set(generator, "terrainColumns", new NativeTerrainColumns(policy));
|
||||
return generator;
|
||||
}
|
||||
|
||||
private static void set(NativeChunkGenerator<?, ?, ?, ?, ?> generator, String name, Object value) throws Exception {
|
||||
@@ -127,6 +132,6 @@ public final class IrisChunkGeneratorHeightCacheTest {
|
||||
new TerrainBoundarySignature.BiomeEncoding(List.of("minecraft:plains"), new short[]{0})), geometry);
|
||||
}
|
||||
|
||||
private record Fixture(NativeChunkGenerator<?, ?, ?, ?, ?> generator, Engine engine, IrisComplex complex) {
|
||||
private record Fixture(NativeChunkGenerator<?, ?, ?, ?, ?> generator, Engine engine) {
|
||||
}
|
||||
}
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package art.arcane.iris.platform.bukkit.nms.v26_2_R1;
|
||||
|
||||
import art.arcane.volmlib.nativelib.v26_2_R1.terrain.NativeTerrainAccessImpl;
|
||||
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkHolderManager;
|
||||
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkTaskScheduler;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.level.ChunkLevel;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.TicketType;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import org.bukkit.craftbukkit.CraftWorld;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NmsChunkRetentionTest {
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retentionRequestsNothingBeyondTheOuterRingOfAFullRequest() {
|
||||
NativeTerrainAccessImpl binding = mock(NativeTerrainAccessImpl.class, CALLS_REAL_METHODS);
|
||||
assertEquals(ChunkLevel.RADIUS_AROUND_FULL_CHUNK, binding.fullChunkDependencyRadius());
|
||||
assertEquals(ChunkLevel.MAX_LEVEL, ChunkHolderManager.MAX_TICKET_LEVEL);
|
||||
assertEquals(ChunkLevel.generationStatus(ChunkLevel.FULL_CHUNK_LEVEL + binding.fullChunkDependencyRadius()),
|
||||
ChunkLevel.generationStatus(ChunkHolderManager.MAX_TICKET_LEVEL));
|
||||
assertEquals(ChunkStatus.STRUCTURE_STARTS, ChunkLevel.generationStatus(ChunkHolderManager.MAX_TICKET_LEVEL));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retainAndReleaseUseOnePermanentNonPersistentTicketAtTheMaximumLevel() throws Exception {
|
||||
NativeTerrainAccessImpl binding = mock(NativeTerrainAccessImpl.class, CALLS_REAL_METHODS);
|
||||
CraftWorld world = mock(CraftWorld.class);
|
||||
ServerLevel level = mock(ServerLevel.class);
|
||||
ChunkTaskScheduler scheduler = mock(ChunkTaskScheduler.class);
|
||||
ChunkHolderManager manager = mock(ChunkHolderManager.class);
|
||||
Field managerField = ChunkTaskScheduler.class.getField("chunkHolderManager");
|
||||
managerField.setAccessible(true);
|
||||
managerField.set(scheduler, manager);
|
||||
when(world.getHandle()).thenReturn(level);
|
||||
when(level.moonrise$getChunkTaskScheduler()).thenReturn(scheduler);
|
||||
when(manager.addTicketAtLevel(any(TicketType.class), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull()))
|
||||
.thenReturn(true);
|
||||
when(manager.removeTicketAtLevel(any(TicketType.class), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull()))
|
||||
.thenReturn(true);
|
||||
|
||||
assertTrue(binding.retainChunk(world, 3, -4));
|
||||
ArgumentCaptor<TicketType> type = ArgumentCaptor.forClass(TicketType.class);
|
||||
verify(manager).addTicketAtLevel(type.capture(), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
|
||||
assertEquals(TicketType.NO_TIMEOUT, type.getValue().timeout());
|
||||
assertTrue(type.getValue().doesLoad());
|
||||
assertFalse(type.getValue().doesSimulate());
|
||||
assertFalse(type.getValue().persist());
|
||||
|
||||
assertTrue(binding.releaseChunk(world, 3, -4));
|
||||
verify(manager).removeTicketAtLevel(same(type.getValue()), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
|
||||
|
||||
binding.retainChunk(world, 3, -4);
|
||||
ArgumentCaptor<TicketType> again = ArgumentCaptor.forClass(TicketType.class);
|
||||
verify(manager, times(2)).addTicketAtLevel(again.capture(), eq(3), eq(-4),
|
||||
eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
|
||||
assertSame(type.getValue(), again.getValue());
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -15,7 +15,6 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockResolver;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
|
||||
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeGenerationRegistryImpl;
|
||||
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeWorldGenerationImpl;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBiome;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockProperty;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
@@ -49,7 +48,7 @@ final class HeadlessNativePlatform implements IrisPlatform, PlatformRegistries,
|
||||
return access;
|
||||
}
|
||||
throw new IllegalStateException("Generation registry contract requires loaded native registries");
|
||||
}), "bukkit-generation-registry-v1", new NativeWorldGenerationImpl().generationRendererIdentity());
|
||||
}), "bukkit-generation-registry-v1");
|
||||
nativeAccess = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(registries,
|
||||
() -> { throw new UnsupportedOperationException("Headless reloadable registries are not loaded"); },
|
||||
message -> { throw new IllegalStateException("Unavailable native registry: " + message); }));
|
||||
|
||||
+36
-31
@@ -4,12 +4,12 @@ import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeChunkGenerator;
|
||||
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeTerrainColumns;
|
||||
import art.arcane.iris.platform.bukkit.nms.BukkitTerrainColumnPolicy;
|
||||
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.GenerationSessionLease;
|
||||
import art.arcane.iris.world.history.BoundaryColumnGeometry;
|
||||
import art.arcane.iris.world.history.TerrainBoundarySignature;
|
||||
import art.arcane.iris.testsupport.PlatformLeakGuard;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeTerrainColumnPolicy;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
@@ -24,7 +24,7 @@ import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -32,6 +32,7 @@ import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.never;
|
||||
|
||||
public final class IrisChunkGeneratorHeightCacheTest {
|
||||
@Rule
|
||||
@@ -45,68 +46,72 @@ public final class IrisChunkGeneratorHeightCacheTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedNativeHeightSurvivesResolvedTerrainEvictionAndRuntimeChange() throws Exception {
|
||||
public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
when(fixture.complex().resolvedTerrainColumn(-1, -33))
|
||||
.thenThrow(new IllegalStateException("Full terrain was evicted"));
|
||||
|
||||
when(fixture.engine().getHeight(-1, -33, false)).thenReturn(4);
|
||||
for (int index = 0; index < 100; index++) {
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(
|
||||
-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
}
|
||||
verify(fixture.complex(), times(1)).resolvedTerrainColumn(-1, -33);
|
||||
verify(fixture.engine(), times(100)).getHeight(-1, -33, false);
|
||||
|
||||
when(fixture.engine().getCacheID()).thenReturn(2);
|
||||
doReturn(Optional.of(signature())).when(fixture.complex()).resolvedTerrainColumn(-1, -33);
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
verify(fixture.complex(), times(2)).resolvedTerrainColumn(-1, -33);
|
||||
when(fixture.engine().getHeight(-1, -33, false)).thenReturn(7);
|
||||
assertEquals(-8, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
verify(fixture.engine(), never()).getComplex();
|
||||
verify(fixture.engine(), never()).getMode();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ordinaryHeightRemainsLiveUntilResolvedTransitionTerrainExists() throws Exception {
|
||||
public void nativeHeightPredicatesSelectFluidOrSolidHeightWithoutGeneratingTerrain() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
||||
when(fixture.complex().resolvedTerrainColumn(0, 0))
|
||||
.thenReturn(Optional.empty(), Optional.empty(), Optional.of(signature()));
|
||||
when(fixture.engine().getHeight(0, 0, false)).thenReturn(20, 25);
|
||||
when(fixture.engine().getHeight(0, 0, true)).thenReturn(8);
|
||||
|
||||
assertEquals(5, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
assertEquals(10, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
assertEquals(-7, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
|
||||
verify(fixture.engine(), times(2)).getHeight(0, 0, false);
|
||||
verify(fixture.complex(), times(3)).resolvedTerrainColumn(0, 0);
|
||||
verify(fixture.engine(), times(1)).getHeight(0, 0, true);
|
||||
verify(fixture.engine(), never()).getComplex();
|
||||
verify(fixture.engine(), never()).getMode();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativePredicatesAndAccessorBoundsRemainExact() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
NativeTerrainColumnPolicy policy = mock(NativeTerrainColumnPolicy.class);
|
||||
NativeTerrainColumnPolicy.ColumnSession session = mock(NativeTerrainColumnPolicy.ColumnSession.class);
|
||||
when(policy.openQuery(any())).thenReturn(session);
|
||||
when(policy.placementKey(anyString())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
doReturn(Optional.of(signature().geometry())).when(session).resolvedColumn();
|
||||
NativeChunkGenerator<?, ?, ?, ?, ?> generator = generator(policy);
|
||||
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
||||
for (int repeat = 0; repeat < 2; repeat++) {
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
|
||||
assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null));
|
||||
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null));
|
||||
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE,
|
||||
assertEquals(-11, generator.getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
assertEquals(-13, generator.getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
|
||||
assertEquals(-13, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null));
|
||||
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null));
|
||||
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE,
|
||||
LevelHeightAccessor.create(-16, 2), null));
|
||||
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
|
||||
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
|
||||
LevelHeightAccessor.create(-14, 3), null));
|
||||
}
|
||||
verify(fixture.complex(), times(6)).resolvedTerrainColumn(0, 0);
|
||||
verify(session, times(6)).resolvedColumn();
|
||||
}
|
||||
|
||||
private static Fixture fixture() throws Exception {
|
||||
Engine engine = mock(Engine.class);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(engine.getCacheID()).thenReturn(1);
|
||||
when(engine.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop());
|
||||
when(complex.resolvedTerrainColumn(anyInt(), anyInt())).thenReturn(Optional.of(signature()));
|
||||
return new Fixture(generator(new BukkitTerrainColumnPolicy(engine, null)), engine);
|
||||
}
|
||||
|
||||
private static NativeChunkGenerator<?, ?, ?, ?, ?> generator(NativeTerrainColumnPolicy policy) throws Exception {
|
||||
NativeChunkGenerator<?, ?, ?, ?, ?> generator = mock(NativeChunkGenerator.class, CALLS_REAL_METHODS);
|
||||
set(generator, "terrainColumns", new NativeTerrainColumns(new BukkitTerrainColumnPolicy(engine, null)));
|
||||
return new Fixture(generator, engine, complex);
|
||||
set(generator, "terrainColumns", new NativeTerrainColumns(policy));
|
||||
return generator;
|
||||
}
|
||||
|
||||
private static void set(NativeChunkGenerator<?, ?, ?, ?, ?> generator, String name, Object value) throws Exception {
|
||||
@@ -128,6 +133,6 @@ public final class IrisChunkGeneratorHeightCacheTest {
|
||||
new TerrainBoundarySignature.BiomeEncoding(List.of("minecraft:plains"), new short[]{0})), geometry);
|
||||
}
|
||||
|
||||
private record Fixture(NativeChunkGenerator<?, ?, ?, ?, ?> generator, Engine engine, IrisComplex complex) {
|
||||
private record Fixture(NativeChunkGenerator<?, ?, ?, ?, ?> generator, Engine engine) {
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -13,6 +13,7 @@ import net.minecraft.data.registries.VanillaRegistries;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.attribute.EnvironmentAttributes;
|
||||
import net.minecraft.world.attribute.modifier.MobSpawnSettingsModifier;
|
||||
import net.minecraft.world.entity.MobCategory;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.MobSpawnSettings;
|
||||
@@ -53,5 +54,7 @@ public class CustomBiomeCodecTest {
|
||||
assertEquals(2, decoded.count().minInclusive());
|
||||
assertEquals(4, decoded.count().maxInclusive());
|
||||
assertEquals(0x102030, biome.getWaterColor() & 0xFFFFFF);
|
||||
assertEquals(MobSpawnSettingsModifier.overlay(),
|
||||
biome.getAttributes().get(EnvironmentAttributes.NATURAL_MOB_SPAWNS).modifier());
|
||||
}
|
||||
}
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package art.arcane.iris.platform.bukkit.nms.v26_3_R1;
|
||||
|
||||
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeTerrainAccessImpl;
|
||||
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkHolderManager;
|
||||
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkTaskScheduler;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.level.ChunkLevel;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.TicketType;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import org.bukkit.craftbukkit.CraftWorld;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NmsChunkRetentionTest {
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retentionRequestsNothingBeyondTheOuterRingOfAFullRequest() {
|
||||
NativeTerrainAccessImpl binding = mock(NativeTerrainAccessImpl.class, CALLS_REAL_METHODS);
|
||||
assertEquals(ChunkLevel.RADIUS_AROUND_FULL_CHUNK, binding.fullChunkDependencyRadius());
|
||||
assertEquals(ChunkLevel.MAX_LEVEL, ChunkHolderManager.MAX_TICKET_LEVEL);
|
||||
assertEquals(ChunkLevel.generationStatus(ChunkLevel.FULL_CHUNK_LEVEL + binding.fullChunkDependencyRadius()),
|
||||
ChunkLevel.generationStatus(ChunkHolderManager.MAX_TICKET_LEVEL));
|
||||
assertEquals(ChunkStatus.STRUCTURE_STARTS, ChunkLevel.generationStatus(ChunkHolderManager.MAX_TICKET_LEVEL));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retainAndReleaseUseOnePermanentNonPersistentTicketAtTheMaximumLevel() throws Exception {
|
||||
NativeTerrainAccessImpl binding = mock(NativeTerrainAccessImpl.class, CALLS_REAL_METHODS);
|
||||
CraftWorld world = mock(CraftWorld.class);
|
||||
ServerLevel level = mock(ServerLevel.class);
|
||||
ChunkTaskScheduler scheduler = mock(ChunkTaskScheduler.class);
|
||||
ChunkHolderManager manager = mock(ChunkHolderManager.class);
|
||||
Field managerField = ChunkTaskScheduler.class.getField("chunkHolderManager");
|
||||
managerField.setAccessible(true);
|
||||
managerField.set(scheduler, manager);
|
||||
when(world.getHandle()).thenReturn(level);
|
||||
when(level.moonrise$getChunkTaskScheduler()).thenReturn(scheduler);
|
||||
when(manager.addTicketAtLevel(any(TicketType.class), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull()))
|
||||
.thenReturn(true);
|
||||
when(manager.removeTicketAtLevel(any(TicketType.class), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull()))
|
||||
.thenReturn(true);
|
||||
|
||||
assertTrue(binding.retainChunk(world, 3, -4));
|
||||
ArgumentCaptor<TicketType> type = ArgumentCaptor.forClass(TicketType.class);
|
||||
verify(manager).addTicketAtLevel(type.capture(), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
|
||||
assertEquals(TicketType.NO_TIMEOUT, type.getValue().timeout());
|
||||
assertTrue(type.getValue().doesLoad());
|
||||
assertFalse(type.getValue().doesSimulate());
|
||||
assertFalse(type.getValue().persist());
|
||||
|
||||
assertTrue(binding.releaseChunk(world, 3, -4));
|
||||
verify(manager).removeTicketAtLevel(same(type.getValue()), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
|
||||
|
||||
binding.retainChunk(world, 3, -4);
|
||||
ArgumentCaptor<TicketType> again = ArgumentCaptor.forClass(TicketType.class);
|
||||
verify(manager, times(2)).addTicketAtLevel(again.capture(), eq(3), eq(-4),
|
||||
eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
|
||||
assertSame(type.getValue(), again.getValue());
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.generation.biome.IrisBiomeCustom;
|
||||
import art.arcane.iris.generation.biome.IrisBiomeCustomSpawn;
|
||||
import art.arcane.iris.generation.biome.IrisBiomeCustomSpawnType;
|
||||
import art.arcane.iris.pack.datapack.v263.DataFixerV263;
|
||||
import art.arcane.iris.spi.PlatformGenerationRegistry;
|
||||
import art.arcane.iris.world.history.GenerationRegistryContract;
|
||||
import art.arcane.iris.world.history.GenerationRegistryContractFactory;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public final class BareMobSpawnHistoryTest {
|
||||
private static final String LEGACY_RENDERER_IDENTITY = "bukkit-v26_3_R1-generated-registry-json-v1";
|
||||
private static final String NATURAL_MOB_SPAWNS = "minecraft:gameplay/natural_mob_spawns";
|
||||
private static final GenerationRegistryContract.PhysicalResourceKey INSTALLED =
|
||||
new GenerationRegistryContract.PhysicalResourceKey(
|
||||
GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||
"iris:biomes/" + "c".repeat(64)
|
||||
);
|
||||
private static HeadlessNativeRegistries loaded;
|
||||
private static PlatformGenerationRegistry registry;
|
||||
|
||||
@BeforeClass
|
||||
public static void installOverlayRendering() throws IOException {
|
||||
assertNull(Bukkit.getServer());
|
||||
loaded = HeadlessNativeRegistries.load(Map.of(
|
||||
"data/iris/worldgen/biome/biomes/" + "c".repeat(64) + ".json",
|
||||
spawningBiome().generateJson(new DataFixerV263())
|
||||
));
|
||||
registry = new HeadlessNativePlatform(new File("unused-native-platform"), loaded::registries)
|
||||
.generationRegistry();
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void closeRegistries() throws IOException {
|
||||
if (loaded != null) {
|
||||
loaded.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bareMobSpawnHistoryValidatesAgainstTheInstalledOverlayRendering() throws IOException {
|
||||
GenerationRegistryContract legacy = legacyContract(INSTALLED, spawningBiome());
|
||||
GenerationRegistryContract.GeneratedSource source = legacy.generatedSources().get(INSTALLED);
|
||||
assertEquals(GenerationRegistryContract.FINGERPRINT_SCHEMA_VERSION_ONE, legacy.fingerprintSchema());
|
||||
assertEquals(LEGACY_RENDERER_IDENTITY, source.rendererIdentity());
|
||||
assertNotEquals(
|
||||
JsonParser.parseString(source.sourceJson()),
|
||||
JsonParser.parseString(registry.generatedDefinition(
|
||||
INSTALLED.registryKey(),
|
||||
INSTALLED.resourceKey()
|
||||
).value())
|
||||
);
|
||||
|
||||
GenerationRegistryContract available = GenerationRegistryContractFactory.captureRequiredDefinitions(
|
||||
Set.of(legacy),
|
||||
new DataFixerV263(),
|
||||
registry
|
||||
);
|
||||
|
||||
legacy.requireDefinitionsAvailableIn(available);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bareMobSpawnHistoryStillRejectsDifferentSemantics() throws IOException {
|
||||
GenerationRegistryContract changed = legacyContract(INSTALLED, spawningBiome().setTemperature(0.2D));
|
||||
|
||||
IOException failure = assertThrows(IOException.class,
|
||||
() -> GenerationRegistryContractFactory.captureRequiredDefinitions(
|
||||
Set.of(changed),
|
||||
new DataFixerV263(),
|
||||
registry
|
||||
));
|
||||
|
||||
assertEquals("Historical generated registry definition changed for minecraft:worldgen/biome / "
|
||||
+ INSTALLED.resourceKey() + ".", failure.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bareMobSpawnHistoryStillRejectsAMissingDefinition() throws IOException {
|
||||
GenerationRegistryContract missing = legacyContract(new GenerationRegistryContract.PhysicalResourceKey(
|
||||
GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||
"iris:biomes/" + "d".repeat(64)
|
||||
), spawningBiome());
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationRegistryContractFactory.captureRequiredDefinitions(
|
||||
Set.of(missing),
|
||||
new DataFixerV263(),
|
||||
registry
|
||||
));
|
||||
}
|
||||
|
||||
private static GenerationRegistryContract legacyContract(
|
||||
GenerationRegistryContract.PhysicalResourceKey key,
|
||||
IrisBiomeCustom biome
|
||||
) {
|
||||
JsonObject rendered = JsonParser.parseString(biome.generateJson(new DataFixerV263())).getAsJsonObject();
|
||||
JsonObject attributes = rendered.getAsJsonObject("attributes");
|
||||
attributes.add(NATURAL_MOB_SPAWNS, attributes.getAsJsonObject(NATURAL_MOB_SPAWNS).get("argument"));
|
||||
String captured = registry.canonicalDefinition(key.registryKey(), key.resourceKey(), rendered.toString())
|
||||
.value();
|
||||
String semantic = GenerationRegistryContractFactory.customBiomeEffectiveSemanticJson(biome, null);
|
||||
GenerationRegistryContract.GeneratedSource source = new GenerationRegistryContract.GeneratedSource(
|
||||
GenerationRegistryContractFactory.CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA,
|
||||
GenerationRegistryContractFactory.fingerprintCustomBiomeAuthoredDefinition(semantic),
|
||||
semantic,
|
||||
LEGACY_RENDERER_IDENTITY,
|
||||
GenerationRegistryContractFactory.fingerprintDefinition(
|
||||
key,
|
||||
PlatformGenerationRegistry.Definition.exactJson(captured),
|
||||
"generated"
|
||||
),
|
||||
captured
|
||||
);
|
||||
return GenerationRegistryContract.fromDefinitionsAndGeneratedSources(
|
||||
Map.of(key, GenerationRegistryContractFactory.fingerprintGeneratedSemantic(key, source)),
|
||||
Map.of(key, source)
|
||||
);
|
||||
}
|
||||
|
||||
private static IrisBiomeCustom spawningBiome() {
|
||||
return new IrisBiomeCustom()
|
||||
.setId("tester")
|
||||
.setSpawnRarity(2)
|
||||
.setSpawns(new KList<>(
|
||||
new IrisBiomeCustomSpawn()
|
||||
.setType("minecraft:zombie")
|
||||
.setGroup(IrisBiomeCustomSpawnType.MONSTER)
|
||||
.setMinCount(2)
|
||||
.setMaxCount(4)
|
||||
.setWeight(7),
|
||||
new IrisBiomeCustomSpawn()
|
||||
.setType("minecraft:cow")
|
||||
.setGroup(IrisBiomeCustomSpawnType.CREATURE)
|
||||
.setMinCount(3)
|
||||
.setMaxCount(3)
|
||||
.setWeight(5)
|
||||
));
|
||||
}
|
||||
}
|
||||
-3
@@ -1,7 +1,6 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.spi.PlatformGenerationRegistry;
|
||||
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeWorldGenerationImpl;
|
||||
import net.minecraft.data.registries.VanillaRegistries;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.junit.Test;
|
||||
@@ -22,8 +21,6 @@ public final class HeadlessGenerationRegistryTest {
|
||||
HeadlessNativeTestRegistries::get);
|
||||
PlatformGenerationRegistry registry = platform.generationRegistry();
|
||||
assertEquals("bukkit-generation-registry-v1", registry.runtimeIdentity());
|
||||
assertEquals(new NativeWorldGenerationImpl().generationRendererIdentity(),
|
||||
registry.generatedDefinitionRendererIdentity());
|
||||
assertEquals(registry.canonicalDefinition("minecraft:worldgen/biome", "iris:native_test", biome),
|
||||
registry.generatedDefinition("minecraft:worldgen/biome", "iris:native_test"));
|
||||
assertEquals(registry.canonicalDefinition("minecraft:dimension_type", "iris:native_test", dimension),
|
||||
|
||||
@@ -2,15 +2,11 @@ String apiVersion = providers.gradleProperty('apiVersion').get()
|
||||
def mainClass = 'art.arcane.iris.Iris'
|
||||
def bootstrapperClass = 'art.arcane.iris.IrisBootstrap'
|
||||
|
||||
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
|
||||
.orElse('com.github.VolmitSoftware:VolmLib:master-SNAPSHOT')
|
||||
.get()
|
||||
|
||||
dependencies {
|
||||
compileOnly(project(':core'))
|
||||
compileOnly(libs.lombok)
|
||||
annotationProcessor(libs.lombok)
|
||||
compileOnly(volmLibCoordinate) {
|
||||
compileOnly('com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
|
||||
transitive = false
|
||||
}
|
||||
compileOnly(libs.paper.api)
|
||||
@@ -23,7 +19,7 @@ dependencies {
|
||||
testImplementation(libs.bstats)
|
||||
testImplementation(project(':core'))
|
||||
testImplementation(testFixtures(project(':core')))
|
||||
testImplementation(volmLibCoordinate) {
|
||||
testImplementation('com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
|
||||
transitive = false
|
||||
}
|
||||
compileOnly(libs.placeholderApi)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import art.arcane.iris.platform.bootstrap.ServerProperties;
|
||||
import art.arcane.iris.world.IrisStartupValidation;
|
||||
import art.arcane.iris.world.IrisWorldStorage;
|
||||
import art.arcane.iris.world.lifecycle.BukkitWorldConfiguration;
|
||||
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Keeps a failed enable from handing Iris worlds to the vanilla generator.
|
||||
* <p>
|
||||
* Paper disables a plugin whose onEnable fails, and CraftServer builds every world bukkit.yml points at a disabled
|
||||
* generator plugin with the vanilla generator. On a server with Iris worlds, Iris therefore stays enabled with its
|
||||
* runtime locked: the generator resolver answers every Iris world with a fail-closed generator, which stops the
|
||||
* level before it exists, and the admission listener refuses logins. It only needs IrisStartupValidation and
|
||||
* logging, never a service the failed enable may not have created.
|
||||
*/
|
||||
final class FailedEnableLock {
|
||||
private FailedEnableLock() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true when Iris must stay enabled and locked, false when the failure may disable Iris as before
|
||||
*/
|
||||
static boolean engage(Throwable failure, boolean irisWorldsPresent, boolean startupWorldsCreated) {
|
||||
if (!irisWorldsPresent) {
|
||||
return false;
|
||||
}
|
||||
IrisStartupValidation.markRuntimeInvalid("Iris failed to enable: " + GenerationRefusalNotice.summary(failure));
|
||||
List<String> notice = notice(failure, startupWorldsCreated);
|
||||
try {
|
||||
for (String line : notice) {
|
||||
Iris.error(line);
|
||||
}
|
||||
Iris.reportError(failure);
|
||||
} catch (Throwable loggingFailure) {
|
||||
notice.forEach(System.err::println);
|
||||
failure.addSuppressed(loggingFailure);
|
||||
failure.printStackTrace(System.err);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deferring teardown to the server stop quiesces through the NMS binding and waits for generators Paper closes.
|
||||
* A failed enable may have neither, so it tears down at once even while the server stops.
|
||||
*/
|
||||
static boolean defersTeardownToServerStop(boolean enableFailed, boolean serverStopping) {
|
||||
return !enableFailed && serverStopping;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param startupWorldsCreated true when Iris enabled on a running server (a hotload), whose startup worlds
|
||||
* already exist, so nothing stops
|
||||
*/
|
||||
static List<String> notice(Throwable failure, boolean startupWorldsCreated) {
|
||||
return GenerationRefusalNotice.compose(
|
||||
"Iris failed to enable",
|
||||
GenerationRefusalNotice.causes(failure),
|
||||
List.of(
|
||||
"Iris stays enabled with a locked runtime so no Iris world falls back to vanilla generation.",
|
||||
startupWorldsCreated
|
||||
? "Every Iris world that is not loaded yet refuses to load; the server keeps running"
|
||||
+ " and no chunks are written for them."
|
||||
: "Every Iris world refuses to generate: server startup stops before the first one"
|
||||
+ " loads, and no chunks are written.",
|
||||
"Player logins are refused until this is fixed and the server restarts."
|
||||
));
|
||||
}
|
||||
|
||||
static boolean irisWorldsPresent() {
|
||||
try {
|
||||
return irisWorldsPresent(IrisWorldStorage.levelRoot(), ServerProperties.BUKKIT_YML);
|
||||
} catch (Throwable unreadable) {
|
||||
Iris.reportError("Could not inspect this server for Iris worlds; treating it as having them.", unreadable);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static boolean irisWorldsPresent(File levelRoot, File bukkitConfiguration) throws IOException {
|
||||
return IrisWorldStorage.hasManagedWorldStorage(levelRoot)
|
||||
|| BukkitWorldConfiguration.configuresLoadingIrisWorld(bukkitConfiguration, levelRoot.toPath());
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,9 @@ import art.arcane.iris.world.IrisStartupValidation;
|
||||
import art.arcane.iris.world.IrisStartupAdmissionListener;
|
||||
import art.arcane.iris.world.BukkitWorldReconciler;
|
||||
import art.arcane.iris.world.IrisWorldGeneratorResolver;
|
||||
import art.arcane.iris.world.IrisWorldStorage;
|
||||
import art.arcane.iris.world.PendingWorldDeleteQueue;
|
||||
import art.arcane.iris.world.PendingWorldReplacementManager;
|
||||
import art.arcane.iris.world.WorldRefusalReporter;
|
||||
import art.arcane.iris.configuration.SettingsHotloadWatch;
|
||||
import art.arcane.iris.pack.datapack.ServerConfigurator;
|
||||
import art.arcane.iris.pack.datapack.DatapackIngestService;
|
||||
@@ -200,6 +200,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false);
|
||||
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
|
||||
private volatile boolean generatorDrainCompleted;
|
||||
private volatile boolean enableFailed;
|
||||
private volatile PlaceholderRegistration papiRegistration;
|
||||
private volatile IrisPapiListener papiListener;
|
||||
private volatile IrisPapiState papiState;
|
||||
@@ -613,21 +614,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
return plugin == null ? ComponentLog.discriminator("Iris", "&a") : plugin.getTag();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false when the bootstrap was aborted (unsupported server version); the caller
|
||||
* must bail out of onEnable without touching any further setup.
|
||||
*/
|
||||
private boolean enable() {
|
||||
if (!INMS.isBound()) {
|
||||
Throwable bindFailure = INMS.bindFailure();
|
||||
Iris.error("Iris cannot start: " + (bindFailure == null
|
||||
? "no NMS binding is available for this server version."
|
||||
: bindFailure.getMessage()));
|
||||
// Deferred one tick: disablePlugin from inside onEnable re-enters onDisable
|
||||
// synchronously and the loader then continues registering the half-enabled plugin.
|
||||
J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1);
|
||||
return false;
|
||||
}
|
||||
private void enable() {
|
||||
EnableTimings timings = new EnableTimings();
|
||||
alreadyDrained.set(false);
|
||||
postStopFinisherStarted.set(false);
|
||||
@@ -788,7 +775,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
});
|
||||
timings.mark("startupTasks");
|
||||
timings.report();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static final class EnableTimings {
|
||||
@@ -910,20 +896,25 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
|
||||
public void onEnable() {
|
||||
IrisPlatforms.bind(new BukkitPlatform());
|
||||
enableFailed = false;
|
||||
IrisStartupValidation.begin();
|
||||
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
|
||||
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
|
||||
pendingWorldReplacements.registerPlatformEntryListener();
|
||||
boolean enabled;
|
||||
try {
|
||||
enabled = enable();
|
||||
generatorResolver.refusals().attach(this);
|
||||
IrisPlatforms.bind(new BukkitPlatform());
|
||||
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
|
||||
pendingWorldReplacements.registerPlatformEntryListener();
|
||||
if (!INMS.isBound()) {
|
||||
refuseUnsupportedServer();
|
||||
return;
|
||||
}
|
||||
enable();
|
||||
} catch (Throwable failure) {
|
||||
refuseVanillaFallback(failure);
|
||||
throw failure;
|
||||
}
|
||||
if (!enabled) {
|
||||
refuseVanillaFallback(null);
|
||||
if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
|
||||
generatorResolver.refusals().startupWorldsCreated())) {
|
||||
throw failure;
|
||||
}
|
||||
enableFailed = true;
|
||||
return;
|
||||
}
|
||||
BukkitGuiHost.install();
|
||||
@@ -939,54 +930,32 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
reportLockedRuntime();
|
||||
}
|
||||
|
||||
private void refuseUnsupportedServer() {
|
||||
Throwable bindFailure = INMS.bindFailure();
|
||||
IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null
|
||||
? "no NMS binding is available for this server version."
|
||||
: bindFailure.getMessage()), bindFailure);
|
||||
if (FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
|
||||
generatorResolver.refusals().startupWorldsCreated())) {
|
||||
enableFailed = true;
|
||||
return;
|
||||
}
|
||||
Iris.error(failure.getMessage());
|
||||
// Deferred one tick: disablePlugin from inside onEnable re-enters onDisable
|
||||
// synchronously and the loader then continues registering the half-enabled plugin.
|
||||
J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1);
|
||||
}
|
||||
|
||||
private static void reportLockedRuntime() {
|
||||
String denial = IrisStartupValidation.denialReason().orElse(null);
|
||||
if (denial == null) {
|
||||
return;
|
||||
}
|
||||
boolean managedStorage;
|
||||
try {
|
||||
managedStorage = IrisWorldStorage.hasManagedWorldStorage(IrisWorldStorage.levelRoot());
|
||||
} catch (Throwable unavailable) {
|
||||
Iris.reportError("Could not inspect Iris world storage while reporting the locked runtime.", unavailable);
|
||||
managedStorage = false;
|
||||
}
|
||||
for (String line : RuntimeLockNotice.compose(denial, managedStorage)) {
|
||||
for (String line : RuntimeLockNotice.compose(denial, FailedEnableLock.irisWorldsPresent())) {
|
||||
Iris.error(line);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops a server whose Iris worlds would otherwise be generated by the vanilla generator.
|
||||
* <p>
|
||||
* A disabled Iris gets no {@code getDefaultWorldGenerator} call at all, so the server falls back to
|
||||
* vanilla for every world bukkit.yml points at Iris and writes vanilla terrain into their region files.
|
||||
* There is no Bukkit API that refuses a world at that point, so the server is stopped instead. This is
|
||||
* damage control, not prevention: level creation runs in the same startup step that enables plugins, so
|
||||
* spawn chunks of the affected worlds can still be written before the stop takes effect. The prevention
|
||||
* lives in IrisBootstrap, which refuses startup before any level is created.
|
||||
*/
|
||||
private static void refuseVanillaFallback(Throwable failure) {
|
||||
File levelRoot;
|
||||
try {
|
||||
levelRoot = IrisWorldStorage.levelRoot();
|
||||
} catch (Throwable unavailable) {
|
||||
return;
|
||||
}
|
||||
if (!IrisWorldStorage.hasManagedWorldStorage(levelRoot)) {
|
||||
return;
|
||||
}
|
||||
Iris.error("Iris did not enable and this server has Iris worlds; stopping the server before they generate vanilla terrain.");
|
||||
if (failure != null) {
|
||||
Iris.reportError("Iris enable failed", failure);
|
||||
}
|
||||
try {
|
||||
Bukkit.shutdown();
|
||||
} catch (Throwable unavailable) {
|
||||
Iris.error("Could not stop the server: " + unavailable.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
public BukkitDebugDump debugDump() {
|
||||
return debugDump;
|
||||
}
|
||||
@@ -1014,11 +983,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
IrisLanguage.shutdown();
|
||||
teardownPapi();
|
||||
boolean serverStopping = IrisToolbelt.isServerStopping();
|
||||
boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping());
|
||||
boolean restartingAtStartupBoundary = startupBoundaryRestart.get();
|
||||
if (restartingAtStartupBoundary) {
|
||||
teardownRuntime("startup-boundary-restart", 30L);
|
||||
} else if (serverStopping) {
|
||||
} else if (deferToServerStop) {
|
||||
quiesceRuntimeForServerShutdown("onDisable");
|
||||
startPostStopFinisher();
|
||||
} else {
|
||||
@@ -1036,7 +1005,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
// super.onDisable() cancels plugin tasks and unregisters every listener.
|
||||
super.onDisable();
|
||||
if (!serverStopping || restartingAtStartupBoundary) {
|
||||
if (!deferToServerStop || restartingAtStartupBoundary) {
|
||||
finishTerminalCleanup();
|
||||
}
|
||||
}
|
||||
@@ -1457,6 +1426,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
return generatorResolver.resolveDefaultWorldGenerator(worldName, id);
|
||||
}
|
||||
|
||||
public ChunkGenerator requireWorldGenerator(String worldName, String id) {
|
||||
return generatorResolver.requireWorldGenerator(worldName, id);
|
||||
}
|
||||
|
||||
public WorldRefusalReporter worldRefusals() {
|
||||
return generatorResolver.refusals();
|
||||
}
|
||||
|
||||
public void splash() {
|
||||
Iris.info("Custom Biomes: " + INMS.get().countCustomBiomes());
|
||||
printPacks();
|
||||
|
||||
@@ -13,6 +13,7 @@ import art.arcane.iris.pack.datapack.DataVersion;
|
||||
import io.papermc.paper.ServerBuildInfo;
|
||||
import art.arcane.iris.pack.DefaultPackBootstrapProvisioner.ProvisionResult;
|
||||
import art.arcane.iris.platform.bootstrap.SlimJar;
|
||||
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||
import io.papermc.paper.plugin.bootstrap.BootstrapContext;
|
||||
import io.papermc.paper.plugin.bootstrap.PluginBootstrap;
|
||||
import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent;
|
||||
@@ -21,6 +22,7 @@ import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@@ -112,16 +114,18 @@ public final class IrisBootstrap implements PluginBootstrap {
|
||||
* Stops startup when a world the server is about to load carries Iris storage Iris cannot generate from.
|
||||
* <p>
|
||||
* The server enumerates levels from {@code <levelRoot>/dimensions/<namespace>/<key>} on disk, so a world
|
||||
* whose folder is present is going to be loaded whatever Iris does; and CraftBukkit swallows whatever
|
||||
* {@code getDefaultWorldGenerator} throws and falls back to the vanilla generator, which writes vanilla
|
||||
* terrain straight into that world's region files. A pack snapshot that has gone missing therefore has
|
||||
* to be found here, before any level is created, exactly as a corrupt {@code dimensions/<id>.json} is.
|
||||
* whose folder is present is going to be loaded whatever Iris does. The generator resolver answers a world
|
||||
* it cannot generate with a fail-closed generator, which stops startup at that world's own level creation,
|
||||
* after the levels before it have loaded. A pack snapshot that has gone missing is found here instead,
|
||||
* before any level is created, exactly as a corrupt {@code dimensions/<id>.json} is. An Iris world in a
|
||||
* vanilla slot whose bukkit.yml entry no longer names Iris stops startup too: CraftServer would build it
|
||||
* with the vanilla generator without ever asking Iris.
|
||||
* <p>
|
||||
* A world with no folder at all is not a startup failure: nothing enumerates it, nothing loads it, and
|
||||
* its bukkit.yml and Multiverse entries are what make restoring the folder a complete recovery. Neither
|
||||
* is a folder that holds no pack snapshot and no world data - {@link #quarantineWorthlessHusks} has
|
||||
* already moved those out of the dimensions tree, and one that survives it (a Spigot-layout world folder,
|
||||
* or a move that failed) owns nothing a vanilla generator could destroy. Both are only reported.
|
||||
* or a move that failed) owns nothing to lose. Both are only reported here.
|
||||
*/
|
||||
static void requireUsableWorldStorage(BukkitStartupPaths startupPaths) throws IOException {
|
||||
Path levelRoot = startupPaths.levelRoot();
|
||||
@@ -135,6 +139,7 @@ public final class IrisBootstrap implements PluginBootstrap {
|
||||
levelRoot
|
||||
);
|
||||
StringBuilder unusable = new StringBuilder();
|
||||
StringBuilder unbound = new StringBuilder();
|
||||
for (IrisWorldStorageEntry entry : entries) {
|
||||
switch (entry.state()) {
|
||||
case PRESENT -> {
|
||||
@@ -152,15 +157,29 @@ public final class IrisBootstrap implements PluginBootstrap {
|
||||
.append(": ")
|
||||
.append(entry.detail() == null ? "storage is unusable" : entry.detail())
|
||||
.append(')');
|
||||
case UNBOUND -> unbound.append(unbound.isEmpty() ? "" : "; ")
|
||||
.append(entry.configuredWorldName())
|
||||
.append(" (")
|
||||
.append(entry.storagePath())
|
||||
.append("): set worlds.")
|
||||
.append(entry.configuredWorldName())
|
||||
.append(".generator to Iris:<pack> in bukkit.yml, or move the folder aside to start a new"
|
||||
+ " vanilla world there");
|
||||
}
|
||||
}
|
||||
if (unusable.isEmpty()) {
|
||||
return;
|
||||
List<String> refusals = new ArrayList<>(2);
|
||||
if (!unusable.isEmpty()) {
|
||||
refusals.add("Iris world storage is unusable and the server would generate vanilla terrain over it: "
|
||||
+ unusable + ". Restore each world folder from a backup, or move it aside; a world with no"
|
||||
+ " folder is reported and skipped, and the server starts.");
|
||||
}
|
||||
if (!unbound.isEmpty()) {
|
||||
refusals.add("Iris worlds in vanilla slots are not bound to Iris, so the server would generate vanilla"
|
||||
+ " terrain into them: " + unbound + ".");
|
||||
}
|
||||
if (!refusals.isEmpty()) {
|
||||
throw new IllegalStateException(String.join(" ", refusals));
|
||||
}
|
||||
throw new IllegalStateException("Iris world storage is unusable and the server would generate vanilla"
|
||||
+ " terrain over it: " + unusable
|
||||
+ ". Restore each world folder from a backup, or move it aside; a world with no folder is"
|
||||
+ " reported and skipped, and the server starts.");
|
||||
}
|
||||
|
||||
static void armStartupFailure(BootstrapContext context, Throwable failure) {
|
||||
@@ -175,17 +194,30 @@ public final class IrisBootstrap implements PluginBootstrap {
|
||||
context.getLifecycleManager().registerEventHandler(eventType, event -> {
|
||||
throw new IllegalStateException("Iris bootstrap did not establish safe world-generation state.", failure);
|
||||
});
|
||||
List<String> notice = startupRefusalNotice(failure);
|
||||
try {
|
||||
context.getLogger().error(
|
||||
"Iris bootstrap failed; registry and world startup will be stopped at datapack discovery.",
|
||||
failure
|
||||
);
|
||||
for (String line : notice) {
|
||||
context.getLogger().error(line);
|
||||
}
|
||||
context.getLogger().warn("Iris bootstrap failure", failure);
|
||||
} catch (Throwable loggingFailure) {
|
||||
notice.forEach(System.err::println);
|
||||
failure.addSuppressed(loggingFailure);
|
||||
failure.printStackTrace(System.err);
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> startupRefusalNotice(Throwable failure) {
|
||||
return GenerationRefusalNotice.compose(
|
||||
"Iris stopped server startup before any world loads",
|
||||
GenerationRefusalNotice.causes(failure),
|
||||
List.of(
|
||||
"Startup stops at datapack discovery: no world loads and no chunks are written.",
|
||||
"Fix the cause, then start the server again. Paper's --safeMode hint does not apply:"
|
||||
+ " Iris worlds must not load without Iris."
|
||||
));
|
||||
}
|
||||
|
||||
private static ProvisionResult provision(BootstrapContext context, BukkitStartupPaths startupPaths) {
|
||||
try {
|
||||
return DefaultPackBootstrapProvisioner.provision(new BootstrapRequest(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package art.arcane.iris.api.tree;
|
||||
|
||||
public interface TreeFellerRunHooks {
|
||||
default boolean requiresSneaking() {
|
||||
return true;
|
||||
}
|
||||
|
||||
TreeFellerRunHooks NONE = new TreeFellerRunHooks() {
|
||||
@Override
|
||||
public void onActivationAccepted() {
|
||||
|
||||
@@ -511,7 +511,9 @@ public class CommandIris implements DirectorExecutor {
|
||||
return;
|
||||
}
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(world);
|
||||
IrisToolbelt.beginWorldMaintenance(world, "world-unload", true);
|
||||
// Maintenance that forbids mantle stages refuses every chunk, and Folia treats a refused chunk as a chunk
|
||||
// system failure, so it starts only once no player is left to request one.
|
||||
AtomicBoolean maintenanceEntered = new AtomicBoolean(false);
|
||||
try {
|
||||
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
|
||||
CompletableFuture<Boolean> sequence = IrisToolbelt.evacuateAsync(world)
|
||||
@@ -523,6 +525,8 @@ public class CommandIris implements DirectorExecutor {
|
||||
if (!Boolean.TRUE.equals(evacuated)) {
|
||||
return CompletableFuture.completedFuture(false);
|
||||
}
|
||||
IrisToolbelt.beginWorldMaintenance(world, "world-unload", true);
|
||||
maintenanceEntered.set(true);
|
||||
return WorldLifecycleService.get().unloadAsync(world, true);
|
||||
})
|
||||
.thenCompose(unloaded -> {
|
||||
@@ -537,7 +541,9 @@ public class CommandIris implements DirectorExecutor {
|
||||
});
|
||||
guardUnloadCompletion(sequence, terminalTimeout, world.getName())
|
||||
.whenComplete((unloaded, throwable) -> {
|
||||
IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
|
||||
if (maintenanceEntered.get()) {
|
||||
IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
|
||||
}
|
||||
lease.close();
|
||||
Runnable response = () -> reportUnloadResult(responseSender, world, unloaded, throwable);
|
||||
if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) {
|
||||
@@ -546,7 +552,9 @@ public class CommandIris implements DirectorExecutor {
|
||||
J.s(response);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
|
||||
if (maintenanceEntered.get()) {
|
||||
IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
|
||||
}
|
||||
lease.close();
|
||||
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
|
||||
Iris.reportError("Failed to unload world \"" + world.getName() + "\".", e);
|
||||
|
||||
+18
-7
@@ -722,19 +722,30 @@ public final class BukkitWorldReconciler {
|
||||
return WorldIdentity.resolve(worldKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* A world Iris cannot resolve is refused like one CraftServer asked for, with the banner and operator notice,
|
||||
* then fails this load only.
|
||||
*/
|
||||
@Override
|
||||
public CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed) {
|
||||
String configuredWorldName = worldKey.toString();
|
||||
ChunkGenerator generator;
|
||||
IrisDimension irisDimension;
|
||||
try {
|
||||
String logicalWorldName = IrisWorldStorage.logicalName(worldKey);
|
||||
String configuredWorldName = configuredWorldName(worldKey);
|
||||
Iris.info("Loading World: %s | Generator: %s", logicalWorldName, dimension);
|
||||
ChunkGenerator generator = plugin.getDefaultWorldGenerator(configuredWorldName, dimension);
|
||||
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
|
||||
configuredWorldName = configuredWorldName(worldKey);
|
||||
Iris.info("Loading World: %s | Generator: %s", IrisWorldStorage.logicalName(worldKey), dimension);
|
||||
generator = plugin.requireWorldGenerator(configuredWorldName, dimension);
|
||||
irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
|
||||
if (generator == null || irisDimension == null) {
|
||||
throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\".");
|
||||
}
|
||||
|
||||
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + logicalWorldName + " using Iris:" + dimension + "...");
|
||||
} catch (Throwable refusal) {
|
||||
plugin.worldRefusals().report(configuredWorldName, worldKey, refusal);
|
||||
return CompletableFuture.failedFuture(refusal);
|
||||
}
|
||||
try {
|
||||
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + IrisWorldStorage.logicalName(worldKey)
|
||||
+ " using Iris:" + dimension + "...");
|
||||
WorldCreator creator = WorldCreatorCompat.ofPersistentKey(worldKey)
|
||||
.generator(generator)
|
||||
.environment(BukkitEnvironment.from(irisDimension.getEnvironment()));
|
||||
|
||||
+15
@@ -18,6 +18,7 @@
|
||||
|
||||
package art.arcane.iris.world;
|
||||
|
||||
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||
import org.bukkit.HeightMap;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
@@ -48,6 +49,20 @@ final class IrisFailClosedChunkGenerator extends ChunkGenerator {
|
||||
+ "' remains locked: " + denialReason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Built on the way back to CraftServer, which turns any throw into the vanilla generator, so a cause that cannot
|
||||
* be summarized is named by its type instead.
|
||||
*/
|
||||
static IrisFailClosedChunkGenerator refused(String worldName, Throwable failure) {
|
||||
String reason;
|
||||
try {
|
||||
reason = GenerationRefusalNotice.summary(failure);
|
||||
} catch (Throwable unreadable) {
|
||||
reason = failure.getClass().getName();
|
||||
}
|
||||
return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': " + reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void generateNoise(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull ChunkData chunkData) {
|
||||
throw refusal();
|
||||
|
||||
+86
-8
@@ -73,6 +73,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Pack validation, dimension lookup, and the world generator / biome provider resolution that the
|
||||
@@ -90,6 +91,7 @@ public final class IrisWorldGeneratorResolver {
|
||||
private final AtomicBoolean externalContentRefreshQueued = new AtomicBoolean();
|
||||
private final AtomicBoolean externalContentRefreshRequested = new AtomicBoolean();
|
||||
private final Map<Path, PendingSnapshot> pendingSnapshots = new ConcurrentHashMap<>();
|
||||
private final WorldRefusalReporter refusals = new WorldRefusalReporter();
|
||||
|
||||
public IrisWorldGeneratorResolver(VolmitPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
@@ -480,12 +482,28 @@ public final class IrisWorldGeneratorResolver {
|
||||
return fallback.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* The generator CraftServer asks for. Paper turns a throw or a null here into the vanilla generator, so every
|
||||
* request aimed at an Iris world gets a generator back: the Iris one, or a fail-closed one that refuses to let
|
||||
* the level be created. Only the PlotSquared discovery sentinel, which is never created, gets null.
|
||||
*/
|
||||
@Nullable
|
||||
public ChunkGenerator resolveDefaultWorldGenerator(String worldName, @Nullable String id) {
|
||||
if (isPlotSquaredGeneratorDiscoveryProbe(worldName, id)) {
|
||||
Iris.debug("Ignoring PlotSquared generator discovery probe");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return requireWorldGenerator(worldName, id);
|
||||
} catch (Throwable failure) {
|
||||
return refuseWorld(worldName, failure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolution for Iris' own loads, which report a failure per world instead of handing it to CraftServer.
|
||||
*/
|
||||
public ChunkGenerator requireWorldGenerator(String worldName, @Nullable String id) {
|
||||
if (isGeneratorDiscoveryProbe(worldName, id)) {
|
||||
Iris.debug("Generator discovery probe for loaded world " + worldName);
|
||||
return IrisFailClosedChunkGenerator.discoveryProbe(worldName);
|
||||
@@ -505,21 +523,37 @@ public final class IrisWorldGeneratorResolver {
|
||||
return startupLock;
|
||||
}
|
||||
Iris.debug("Default World Generator Called for " + worldName + " using ID: " + id);
|
||||
if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType();
|
||||
Iris.debug("Generator ID: " + id + " requested by bukkit/plugin");
|
||||
String dimension = id == null || id.isEmpty() ? IrisSettings.get().getGenerator().getDefaultWorldType() : id;
|
||||
Iris.debug("Generator ID: " + dimension + " requested by bukkit/plugin");
|
||||
|
||||
File levelRoot = IrisWorldStorage.levelRoot();
|
||||
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
|
||||
requireWorldKeyAvailable(worldName, worldKey);
|
||||
requireOwnedWorld(worldName, levelRoot, worldKey);
|
||||
return resolveFrozenWorldGenerator(worldName, dimension);
|
||||
}
|
||||
|
||||
public WorldRefusalReporter refusals() {
|
||||
return refusals;
|
||||
}
|
||||
|
||||
private ChunkGenerator refuseWorld(String worldName, Throwable failure) {
|
||||
refusals.report(worldName, refusalKey(worldName), failure);
|
||||
try {
|
||||
return resolveFrozenWorldGenerator(worldName, id);
|
||||
} catch (RuntimeException failure) {
|
||||
Iris.reportError("Refusing to load configured Iris world '" + worldName
|
||||
+ "' because its frozen world-local pack snapshot could not be used.", failure);
|
||||
Bukkit.shutdown();
|
||||
throw failure;
|
||||
Iris.reportError(failure);
|
||||
} catch (Throwable loggingFailure) {
|
||||
System.err.println("[Iris] Could not log the refusal of '" + worldName + "': "
|
||||
+ loggingFailure.getClass().getName());
|
||||
}
|
||||
return IrisFailClosedChunkGenerator.refused(worldName, failure);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static NamespacedKey refusalKey(String worldName) {
|
||||
try {
|
||||
return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName());
|
||||
} catch (Throwable unresolved) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,12 +622,56 @@ public final class IrisWorldGeneratorResolver {
|
||||
return;
|
||||
}
|
||||
NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName());
|
||||
if (!IRIS_DIMENSION_NAMESPACE.equals(messageKey.getNamespace())) {
|
||||
if (dimensionRoot != null && IrisWorldStorage.holdsIrisContent(dimensionRoot.toPath())) {
|
||||
throw new IllegalStateException("'" + worldName + "' (" + messageKey + ") is an Iris world whose"
|
||||
+ " generation history is missing from "
|
||||
+ GenerationHistoryPaths.forDimension(dimensionRoot.toPath()).generationRoot()
|
||||
+ ", so Iris cannot generate it. Restore that folder from a backup. Keep worlds." + worldName
|
||||
+ ".generator in bukkit.yml: without it the server generates vanilla terrain into this world.");
|
||||
}
|
||||
// Only bukkit.yml binds a vanilla slot at startup, and the refusal stops startup, so no command can run
|
||||
// until that binding is gone.
|
||||
throw new IllegalStateException("'" + worldName + "' (" + messageKey
|
||||
+ ") is a vanilla world slot with no Iris world storage, so Iris cannot generate it."
|
||||
+ " Remove worlds." + worldName + ".generator from bukkit.yml"
|
||||
+ leftoverLevelDataRemoval(levelRoot)
|
||||
+ ", start the server, then run /iris replace " + messageKey
|
||||
+ " type=<pack> to replace it with Iris on the next restart.");
|
||||
}
|
||||
throw new IllegalStateException("'" + worldName + "' (" + messageKey
|
||||
+ ") has no Iris world storage, so Iris cannot generate it."
|
||||
+ " Create Iris worlds with /iris create " + IrisWorldStorage.logicalName(messageKey)
|
||||
+ " type=<pack>; Iris registers them with Multiverse itself.");
|
||||
}
|
||||
|
||||
/**
|
||||
* A start refused before the level's overworld existed still leaves level.dat behind, and vanilla cannot start
|
||||
* from a level.dat without world generation settings. A level that holds no chunks loses nothing to its removal.
|
||||
*/
|
||||
private static String leftoverLevelDataRemoval(File levelRoot) {
|
||||
Path level = levelRoot.toPath();
|
||||
Path overworld = level.resolve("dimensions").resolve("minecraft").resolve("overworld");
|
||||
if (Files.exists(overworld.resolve("data").resolve("minecraft").resolve("world_gen_settings.dat"),
|
||||
LinkOption.NOFOLLOW_LINKS)
|
||||
|| hasEntries(overworld.resolve("region"))
|
||||
|| hasEntries(level.resolve("region"))) {
|
||||
return "";
|
||||
}
|
||||
return " and delete " + level.resolve("level.dat") + " (this level has no chunks and was never created)";
|
||||
}
|
||||
|
||||
private static boolean hasEntries(Path directory) {
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
try (Stream<Path> entries = Files.list(directory)) {
|
||||
return entries.findAny().isPresent();
|
||||
} catch (IOException unreadable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasGenerationStorage(File dimensionRoot) {
|
||||
GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot.toPath());
|
||||
return Files.isDirectory(paths.generationRoot(), LinkOption.NOFOLLOW_LINKS)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.world;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.localization.C;
|
||||
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.world.WorldLoadEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentSkipListMap;
|
||||
|
||||
/**
|
||||
* Reports Iris worlds that refused to generate: a console banner that says what the refusal does to the server, and
|
||||
* an operator notice on join for as long as the world stays unloaded, since the banner scrolls away.
|
||||
*/
|
||||
public final class WorldRefusalReporter implements Listener {
|
||||
private static final String UNRESOLVED_KEY = "unresolved key";
|
||||
|
||||
private final Map<String, String> operatorNotices = new ConcurrentSkipListMap<>();
|
||||
private volatile boolean startupWorldsCreated;
|
||||
|
||||
/**
|
||||
* Iris enabled on a running server (a hotload) finds the startup worlds already loaded.
|
||||
*/
|
||||
public void attach(Plugin plugin) {
|
||||
if (!Bukkit.getWorlds().isEmpty()) {
|
||||
startupWorldsCreated = true;
|
||||
}
|
||||
Bukkit.getPluginManager().registerEvents(this, plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paper creates every startup level before it prepares any, and preparing one fires the first WorldLoadEvent.
|
||||
* A generator asked for after that belongs to a runtime create, which fails only its caller.
|
||||
*/
|
||||
public boolean startupWorldsCreated() {
|
||||
return startupWorldsCreated;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onWorldLoad(WorldLoadEvent event) {
|
||||
startupWorldsCreated = true;
|
||||
try {
|
||||
operatorNotices.remove(WorldIdentity.key(event.getWorld()).toString());
|
||||
} catch (RuntimeException unkeyed) {
|
||||
Iris.debug("Loaded world " + event.getWorld().getName() + " has no key to clear a refusal notice by");
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerJoin(PlayerJoinEvent event) {
|
||||
if (operatorNotices.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Player player = event.getPlayer();
|
||||
if (!player.isOp() && !player.hasPermission("iris.all")) {
|
||||
return;
|
||||
}
|
||||
for (String notice : operatorNotices.values()) {
|
||||
player.sendMessage(C.RED + notice);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Never throws: the caller is handing CraftServer a fail-closed generator or failing a load, and a reporting
|
||||
* failure must not turn either into something else.
|
||||
*/
|
||||
public void report(String worldName, @Nullable NamespacedKey worldKey, Throwable failure) {
|
||||
String key = worldKey == null ? UNRESOLVED_KEY : worldKey.toString();
|
||||
try {
|
||||
for (String line : notice(worldName, key, failure, startupWorldsCreated)) {
|
||||
Iris.error(line);
|
||||
}
|
||||
operatorNotices.put(worldKey == null ? worldName : key, "Iris world '" + worldName + "' (" + key
|
||||
+ ") did not load and refuses to generate: " + GenerationRefusalNotice.summary(failure));
|
||||
} catch (Throwable reportingFailure) {
|
||||
System.err.println("[Iris] Iris refused to generate world '" + worldName + "' (" + key
|
||||
+ "); the refusal could not be reported: " + reportingFailure.getClass().getName());
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> notice(String worldName, String worldKey, Throwable failure, boolean startupWorldsCreated) {
|
||||
return GenerationRefusalNotice.compose(
|
||||
"Iris refused to generate world '" + worldName + "' (" + worldKey + ")",
|
||||
GenerationRefusalNotice.causes(failure),
|
||||
List.of(
|
||||
"Iris does not generate this world and does not let vanilla or any other generator"
|
||||
+ " write it; no chunks are written.",
|
||||
startupWorldsCreated
|
||||
? "The world does not load; the server keeps running. Fix the cause, then load it again."
|
||||
: "Server startup stops before this world loads. Fix the cause, then start the server again."
|
||||
));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -189,7 +189,7 @@ public final class BukkitEnginePlatformHooks implements EnginePlatformHooks {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldBypassMantleStages(Engine engine) {
|
||||
public boolean shouldRefuseGeneration(Engine engine) {
|
||||
if (!J.isFolia() || !engine.getWorld().hasPlatformWorld()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ public class TreeFellerSVC implements IrisService, IrisTreeFellerService {
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void haltWhenSneakingStops(PlayerToggleSneakEvent event) {
|
||||
if (!event.isSneaking()) {
|
||||
runner.finishRuns(event.getPlayer().getUniqueId());
|
||||
runner.finishSneakRuns(event.getPlayer().getUniqueId());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-4
@@ -523,10 +523,15 @@ final class TreeFellingRunner {
|
||||
}
|
||||
|
||||
private boolean isRunControlActive(FellingRun run, Player player) {
|
||||
return player.isOnline()
|
||||
&& player.getGameMode() == GameMode.SURVIVAL
|
||||
&& player.isSneaking()
|
||||
&& player.getWorld().equals(run.candidate.world());
|
||||
try {
|
||||
return player.isOnline()
|
||||
&& player.getGameMode() == GameMode.SURVIVAL
|
||||
&& (!run.runHooks.requiresSneaking() || player.isSneaking())
|
||||
&& player.getWorld().equals(run.candidate.world());
|
||||
} catch (RuntimeException error) {
|
||||
IrisLogging.reportError("An Iris tree-feller integration control check failed.", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean reserveLogCost(FellingRun run) {
|
||||
@@ -622,6 +627,24 @@ final class TreeFellingRunner {
|
||||
}
|
||||
}
|
||||
|
||||
void finishSneakRuns(UUID playerId) {
|
||||
Set<FellingRun> runs = service.activeRuns.get(playerId);
|
||||
if (runs == null) {
|
||||
return;
|
||||
}
|
||||
for (FellingRun run : List.copyOf(runs)) {
|
||||
try {
|
||||
if (!run.runHooks.requiresSneaking()) {
|
||||
continue;
|
||||
}
|
||||
} catch (RuntimeException error) {
|
||||
IrisLogging.reportError("An Iris tree-feller integration control check failed.", error);
|
||||
}
|
||||
run.abortReason = "halted";
|
||||
finish(run);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<ChunkPosition, List<TreeMarkerTraversal.Position>> groupByChunk(
|
||||
List<TreeMarkerTraversal.Position> positions
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import art.arcane.iris.platform.bukkit.nms.INMS;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.world.IrisStartupAdmissionListener;
|
||||
import art.arcane.iris.world.IrisStartupValidation;
|
||||
import art.arcane.iris.world.IrisWorldGeneratorResolver;
|
||||
import art.arcane.iris.world.PendingWorldReplacementManager;
|
||||
import art.arcane.iris.world.task.J;
|
||||
import com.destroystokyo.paper.profile.PlayerProfile;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
import org.bukkit.generator.WorldInfo;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.After;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Field;
|
||||
import java.net.InetAddress;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
|
||||
/**
|
||||
* Paper disables a plugin whose onEnable throws, and CraftServer then builds every world bukkit.yml points at a
|
||||
* disabled generator plugin with the vanilla generator. A failed enable on a server with Iris worlds therefore has
|
||||
* to leave Iris enabled and locked instead.
|
||||
*/
|
||||
public class FailedEnableLockTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@After
|
||||
public void disableValidation() {
|
||||
IrisStartupValidation.disable();
|
||||
IrisPlatforms.unbind();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enableFailureWithIrisWorldsLocksTheRuntimeInsteadOfDisablingIris() throws Exception {
|
||||
IrisStartupValidation.begin();
|
||||
IllegalStateException failure = new IllegalStateException("service graph exploded");
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
assertTrue(FailedEnableLock.engage(failure, true, false));
|
||||
|
||||
String denial = IrisStartupValidation.denialReason().orElseThrow();
|
||||
assertTrue(denial, denial.contains("service graph exploded"));
|
||||
|
||||
ChunkGenerator generator = new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("world", "overworld");
|
||||
assertNotNull("a null generator is the vanilla generator", generator);
|
||||
IllegalStateException refusal = assertThrows(IllegalStateException.class,
|
||||
() -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
|
||||
assertTrue(refusal.getMessage(), refusal.getMessage().contains("service graph exploded"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
|
||||
AsyncPlayerPreLoginEvent login = new AsyncPlayerPreLoginEvent(
|
||||
"LockedTest", InetAddress.getLoopbackAddress(), UUID.randomUUID(), false, mock(PlayerProfile.class));
|
||||
new IrisStartupAdmissionListener().onAsyncPlayerPreLogin(login);
|
||||
assertEquals(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, login.getLoginResult());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enableFailureWithoutIrisWorldsLeavesIrisToBeDisabled() {
|
||||
IrisStartupValidation.begin();
|
||||
|
||||
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
assertFalse(FailedEnableLock.engage(new IllegalStateException("service graph exploded"), false, false));
|
||||
}
|
||||
|
||||
String denial = IrisStartupValidation.denialReason().orElseThrow();
|
||||
assertFalse(denial, denial.contains("service graph exploded"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void noticeSaysIrisStaysLockedAndWhatThatBlocks() {
|
||||
String notice = String.join("\n", FailedEnableLock.notice(
|
||||
new IllegalStateException("outer", new IllegalArgumentException("inner")), false));
|
||||
|
||||
assertTrue(notice, notice.contains("Iris failed to enable"));
|
||||
assertTrue(notice, notice.contains("outer"));
|
||||
assertTrue(notice, notice.contains("inner"));
|
||||
assertTrue(notice, notice.contains("vanilla"));
|
||||
assertTrue(notice, notice.contains("startup stops"));
|
||||
assertTrue(notice, notice.contains("logins"));
|
||||
}
|
||||
|
||||
/**
|
||||
* A hotload enables Iris on a running server whose startup worlds already exist, so nothing stops.
|
||||
*/
|
||||
@Test
|
||||
public void noticeAfterStartupWorldsExistSaysTheServerKeepsRunning() {
|
||||
String notice = String.join("\n", FailedEnableLock.notice(new IllegalStateException("outer"), true));
|
||||
|
||||
assertFalse(notice, notice.contains("startup stops"));
|
||||
assertTrue(notice, notice.contains("keeps running"));
|
||||
}
|
||||
|
||||
/**
|
||||
* IrisPlatforms.bind refuses a second platform, which is a real way for enable to throw. It has to throw inside
|
||||
* the guarded region, and the throw must not reach Paper.
|
||||
*/
|
||||
@Test
|
||||
public void onEnableKeepsIrisEnabledAndLockedWhenEnableThrowsOnAServerWithIrisWorlds() throws Exception {
|
||||
IrisPlatforms.bind(mock(IrisPlatform.class));
|
||||
Iris plugin = plugin();
|
||||
|
||||
try (EnableFixture fixture = new EnableFixture(true)) {
|
||||
plugin.onEnable();
|
||||
|
||||
String denial = IrisStartupValidation.denialReason().orElseThrow();
|
||||
assertTrue(denial, denial.contains("already bound"));
|
||||
ChunkGenerator generator = plugin.getDefaultWorldGenerator("world", "overworld");
|
||||
assertNotNull("a null generator is the vanilla generator", generator);
|
||||
IllegalStateException refusal = assertThrows(IllegalStateException.class,
|
||||
() -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
|
||||
assertTrue(refusal.getMessage(), refusal.getMessage().contains("already bound"));
|
||||
fixture.bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onEnableStillThrowsToPaperWhenTheServerHasNoIrisWorlds() throws Exception {
|
||||
IrisPlatforms.bind(mock(IrisPlatform.class));
|
||||
Iris plugin = plugin();
|
||||
|
||||
try (EnableFixture fixture = new EnableFixture(false)) {
|
||||
IllegalStateException failure = assertThrows(IllegalStateException.class, plugin::onEnable);
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("already bound"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anUnsupportedServerWithIrisWorldsLocksInsteadOfDisablingIris() throws Exception {
|
||||
Iris plugin = plugin();
|
||||
set(plugin, "pendingWorldReplacements", mock(PendingWorldReplacementManager.class));
|
||||
|
||||
try (EnableFixture fixture = new EnableFixture(true);
|
||||
MockedStatic<INMS> nms = mockStatic(INMS.class);
|
||||
MockedStatic<J> scheduler = mockStatic(J.class)) {
|
||||
nms.when(INMS::isBound).thenReturn(false);
|
||||
nms.when(INMS::bindFailure).thenReturn(new IllegalStateException("no NMS binding for 26.9"));
|
||||
|
||||
plugin.onEnable();
|
||||
|
||||
String denial = IrisStartupValidation.denialReason().orElseThrow();
|
||||
assertTrue(denial, denial.contains("no NMS binding for 26.9"));
|
||||
scheduler.verify(() -> J.s(any(Runnable.class), anyInt()), never());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deferring to the server stop quiesces through the NMS binding and waits for generators a failed enable never
|
||||
* made, so a failed enable tears down at once even while the server stops.
|
||||
*/
|
||||
@Test
|
||||
public void aFailedEnableNeverDefersItsTeardownToTheServerStop() {
|
||||
assertFalse(FailedEnableLock.defersTeardownToServerStop(true, true));
|
||||
assertFalse(FailedEnableLock.defersTeardownToServerStop(true, false));
|
||||
assertTrue(FailedEnableLock.defersTeardownToServerStop(false, true));
|
||||
assertFalse(FailedEnableLock.defersTeardownToServerStop(false, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void irisWorldsAreManagedStorageOrABukkitBindingForAWorldThatLoads() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("level", "world");
|
||||
File bukkit = new File(temporaryFolder.getRoot(), "bukkit.yml");
|
||||
|
||||
assertFalse(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
||||
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.set("worlds.removed_iris_world.generator", "Iris:overworld");
|
||||
yaml.save(bukkit);
|
||||
assertFalse("a leftover entry for a world that never loads", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
||||
|
||||
yaml.set("worlds.world.generator", "Iris:overworld");
|
||||
yaml.save(bukkit);
|
||||
assertTrue("an Iris overworld lives in a vanilla slot", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
||||
|
||||
assertTrue(bukkit.delete());
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/moon").mkdirs());
|
||||
assertTrue(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Paper builds plugins through its class loader, so the plugin under test is the real class with its fields
|
||||
* set by hand: the resolver getDefaultWorldGenerator delegates to, and whatever enable touches before failing.
|
||||
*/
|
||||
private static Iris plugin() throws Exception {
|
||||
Iris plugin = mock(Iris.class, CALLS_REAL_METHODS);
|
||||
set(plugin, "generatorResolver", new IrisWorldGeneratorResolver(plugin));
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private static void set(Iris plugin, String name, Object value) throws Exception {
|
||||
Field field = Iris.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(plugin, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* irisWorldsPresent reads the real server.properties, which a test has none of, so the answer is set here.
|
||||
*/
|
||||
private static final class EnableFixture implements AutoCloseable {
|
||||
private final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
private final MockedStatic<Iris> iris = mockStatic(Iris.class);
|
||||
private final MockedStatic<FailedEnableLock> lock = mockStatic(FailedEnableLock.class, CALLS_REAL_METHODS);
|
||||
|
||||
private EnableFixture(boolean irisWorlds) {
|
||||
PluginManager plugins = mock(PluginManager.class);
|
||||
bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
|
||||
lock.when(FailedEnableLock::irisWorldsPresent).thenReturn(irisWorlds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
lock.close();
|
||||
iris.close();
|
||||
bukkit.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -8,10 +8,16 @@ import io.papermc.paper.plugin.lifecycle.event.handler.configuration.LifecycleEv
|
||||
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEventType;
|
||||
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisBootstrapFailureContractTest {
|
||||
@@ -32,6 +38,27 @@ public class IrisBootstrapFailureContractTest {
|
||||
assertSame(cause, failure.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bootstrapFailureIsReportedWithTheRefusalBanner() {
|
||||
BootstrapContext context = mock(BootstrapContext.class);
|
||||
ComponentLogger logger = mock(ComponentLogger.class);
|
||||
when(context.getLogger()).thenReturn(logger);
|
||||
when(context.getLifecycleManager()).thenReturn(new CapturingLifecycleManager());
|
||||
IllegalStateException failure = new IllegalStateException("Iris world storage is unusable",
|
||||
new IOException("Invalid generation manifest: manifest.json"));
|
||||
|
||||
IrisBootstrap.armStartupFailure(context, failure, lifecycleEventType());
|
||||
|
||||
ArgumentCaptor<String> lines = ArgumentCaptor.forClass(String.class);
|
||||
verify(logger, atLeastOnce()).error(lines.capture());
|
||||
String banner = String.join("\n", lines.getAllValues());
|
||||
assertTrue(banner, banner.startsWith("=".repeat(78)));
|
||||
assertTrue(banner, banner.contains("Cause: Iris world storage is unusable"));
|
||||
assertTrue(banner, banner.contains("Caused by: Invalid generation manifest: manifest.json"));
|
||||
assertTrue(banner, banner.contains("no chunks are written"));
|
||||
assertTrue(banner, banner.contains("--safeMode"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static LifecycleEventType<BootstrapContext, LifecycleEvent, ?> lifecycleEventType() {
|
||||
return (LifecycleEventType<BootstrapContext, LifecycleEvent, ?>) mock(LifecycleEventType.class);
|
||||
|
||||
+75
@@ -175,6 +175,81 @@ public class IrisBootstrapWorldStorageContractTest {
|
||||
assertFalse(MissingWorldStorageLog.hasWarned("world_iris_moon"));
|
||||
}
|
||||
|
||||
/**
|
||||
* A vanilla slot that holds an Iris world is generated from its history like any Iris world, so a broken
|
||||
* history stops startup before the level exists instead of reaching the level-load refusal.
|
||||
*/
|
||||
@Test
|
||||
public void startupIsRefusedWhenABoundVanillaSlotLostItsGenerationHistory() throws Exception {
|
||||
Path serverRoot = temporaryFolder.newFolder("broken-overworld-server").toPath();
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/iris/engine-data"));
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/region"));
|
||||
Files.writeString(serverRoot.resolve("world/dimensions/minecraft/overworld/region/r.0.0.mca"), "terrain");
|
||||
writeServerProperties(serverRoot);
|
||||
writeBukkitWorlds(serverRoot, "world");
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
|
||||
|
||||
assertTrue(failure.getMessage(),
|
||||
failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/minecraft/overworld")
|
||||
.toString()));
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("generation history"));
|
||||
assertFalse(failure.getMessage(), failure.getMessage().contains("Remove worlds."));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unusableStorageNamesWhyTheHistoryCannotBeRead() throws Exception {
|
||||
Path serverRoot = temporaryFolder.newFolder("cut-history-server").toPath();
|
||||
Path generation = serverRoot.resolve("world/dimensions/minecraft/overworld/iris/generation");
|
||||
Files.createDirectories(generation);
|
||||
Files.writeString(generation.resolve("manifest.json"), "{\"schema\":1,\"epochs\":[{\"id", StandardCharsets.UTF_8);
|
||||
writeServerProperties(serverRoot);
|
||||
writeBukkitWorlds(serverRoot, "world");
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("Invalid generation manifest"));
|
||||
assertFalse(failure.getMessage(), failure.getMessage().contains("\n"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Without its bukkit.yml binding the server builds an Iris world in a vanilla slot with the vanilla generator,
|
||||
* which writes vanilla terrain into it.
|
||||
*/
|
||||
@Test
|
||||
public void startupIsRefusedWhenAnIrisWorldInAVanillaSlotLostItsBinding() throws Exception {
|
||||
Path serverRoot = temporaryFolder.newFolder("unbound-overworld-server").toPath();
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/iris/pack"));
|
||||
writeServerProperties(serverRoot);
|
||||
writeBukkitWorlds(serverRoot);
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("worlds.world.generator"));
|
||||
assertTrue(failure.getMessage(),
|
||||
failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/minecraft/overworld")
|
||||
.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startupContinuesForVanillaSlotsWithoutIrisContent() throws Exception {
|
||||
Path serverRoot = temporaryFolder.newFolder("vanilla-server").toPath();
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/region"));
|
||||
Files.writeString(serverRoot.resolve("world/dimensions/minecraft/overworld/region/r.0.0.mca"), "terrain");
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/the_nether/iris"));
|
||||
Files.writeString(serverRoot.resolve("world/dimensions/minecraft/the_nether/iris/.DS_Store"), "finder");
|
||||
writeServerProperties(serverRoot);
|
||||
writeBukkitWorlds(serverRoot);
|
||||
|
||||
IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot));
|
||||
}
|
||||
|
||||
private static BukkitStartupPaths startupPaths(Path serverRoot) throws Exception {
|
||||
return BukkitStartupPaths.resolve(serverRoot, new String[0]);
|
||||
}
|
||||
|
||||
+36
-2
@@ -11,12 +11,14 @@ import art.arcane.iris.world.history.GenerationHistory;
|
||||
import art.arcane.iris.world.history.GenerationHistoryPaths;
|
||||
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
|
||||
import art.arcane.iris.world.history.GenerationManifest;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
@@ -26,9 +28,11 @@ import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class BukkitWorldReconcilerRuntimeTest {
|
||||
@@ -95,6 +99,33 @@ public class BukkitWorldReconcilerRuntimeTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The startup reconcile and /iris load create worlds through the backend, so a world Iris refuses there needs
|
||||
* the same banner and operator notice as one CraftServer asked for.
|
||||
*/
|
||||
@Test
|
||||
public void aRefusedReconcilerCreateIsReportedAsARefusal() throws Exception {
|
||||
NamespacedKey key = new NamespacedKey("iris", "probe");
|
||||
IllegalStateException refusal = new IllegalStateException("Iris generation history is unusable at /srv/probe.");
|
||||
Iris plugin = mock(Iris.class);
|
||||
WorldRefusalReporter refusals = mock(WorldRefusalReporter.class);
|
||||
when(plugin.worldRefusals()).thenReturn(refusals);
|
||||
when(plugin.requireWorldGenerator("world_iris_probe", "overworld")).thenThrow(refusal);
|
||||
|
||||
try (MockedStatic<Iris> iris = mockStatic(Iris.class);
|
||||
MockedStatic<IrisWorldStorage> storage = mockStatic(IrisWorldStorage.class)) {
|
||||
storage.when(IrisWorldStorage::levelRoot).thenReturn(new File("world"));
|
||||
storage.when(() -> IrisWorldStorage.configuredWorldName(key, "world")).thenReturn("world_iris_probe");
|
||||
storage.when(() -> IrisWorldStorage.logicalName(key)).thenReturn("probe");
|
||||
|
||||
CompletableFuture<World> created = backend(plugin).createWorld(key, "overworld", 1337L);
|
||||
|
||||
ExecutionException failure = assertThrows(ExecutionException.class, created::get);
|
||||
assertSame(refusal, failure.getCause());
|
||||
verify(refusals).report("world_iris_probe", key, refusal);
|
||||
}
|
||||
}
|
||||
|
||||
private RuntimeFixture fixture() throws Exception {
|
||||
Path container = temporaryFolder.newFolder().toPath();
|
||||
Path root = container.resolve("world/dimensions/iris/probe");
|
||||
@@ -134,11 +165,14 @@ public class BukkitWorldReconcilerRuntimeTest {
|
||||
when(dimension.getLoadKey()).thenReturn("overworld");
|
||||
when(engine.getData()).thenReturn(data);
|
||||
when(data.getDataFolder()).thenReturn(pack.toFile());
|
||||
return new RuntimeFixture(backend(mock(Iris.class)), world, key, generator, engine, history, root, pack);
|
||||
}
|
||||
|
||||
private static BukkitWorldReconciler.Backend backend(Iris plugin) throws Exception {
|
||||
Class<?> backendClass = Class.forName(BukkitWorldReconciler.class.getName() + "$BukkitBackend");
|
||||
Constructor<?> constructor = backendClass.getDeclaredConstructor(Iris.class);
|
||||
constructor.setAccessible(true);
|
||||
BukkitWorldReconciler.Backend backend = (BukkitWorldReconciler.Backend) constructor.newInstance(mock(Iris.class));
|
||||
return new RuntimeFixture(backend, world, key, generator, engine, history, root, pack);
|
||||
return (BukkitWorldReconciler.Backend) constructor.newInstance(plugin);
|
||||
}
|
||||
|
||||
private record RuntimeFixture(BukkitWorldReconciler.Backend backend, World world, NamespacedKey key,
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package art.arcane.iris.world;
|
||||
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
import org.bukkit.generator.WorldInfo;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Paper 26.3 asks a plugin generator for its biome provider in MinecraftServer.createLevel and
|
||||
* CraftServer.createWorld before the level exists, outside any catch. A throw there is the only thing that stops a
|
||||
* broken Iris world from being created at all, so every fail-closed generator must keep throwing from it.
|
||||
*/
|
||||
public class IrisFailClosedChunkGeneratorTest {
|
||||
@Test
|
||||
public void everyFailClosedGeneratorRefusesTheBiomeProviderBeforeTheLevelExists() {
|
||||
List<ChunkGenerator> generators = List.of(
|
||||
IrisFailClosedChunkGenerator.discoveryProbe("world"),
|
||||
IrisFailClosedChunkGenerator.startupLock("world", "locked for a test"),
|
||||
IrisFailClosedChunkGenerator.refused("world", new IllegalStateException("history drifted")));
|
||||
|
||||
for (ChunkGenerator generator : generators) {
|
||||
IllegalStateException refusal = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
|
||||
assertTrue(refusal.getMessage(), refusal.getMessage().contains("'world'"));
|
||||
assertThrows(IllegalStateException.class, () -> generator.getDefaultPopulators(mock(World.class)));
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> generator.getFixedSpawnLocation(mock(World.class), new Random()));
|
||||
assertFalse(generator.shouldGenerateNoise());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refusalCarriesTheWholeCauseChain() {
|
||||
IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.",
|
||||
new IOException("Historical generated registry definition changed"));
|
||||
|
||||
IllegalStateException refusal = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisFailClosedChunkGenerator.refused("world_iris_moon", failure)
|
||||
.getDefaultBiomeProvider(mock(WorldInfo.class)));
|
||||
|
||||
assertTrue(refusal.getMessage(), refusal.getMessage().contains("world_iris_moon"));
|
||||
assertTrue(refusal.getMessage(), refusal.getMessage().contains("generation history is unusable"));
|
||||
assertTrue(refusal.getMessage(), refusal.getMessage().contains("registry definition changed"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal is built on the way back to CraftServer, which turns any throw into the vanilla generator, so an
|
||||
* unreadable cause still has to produce a generator.
|
||||
*/
|
||||
@Test
|
||||
public void aCauseWhoseMessageCannotBeReadStillRefuses() {
|
||||
IllegalStateException unreadable = new IllegalStateException() {
|
||||
@Override
|
||||
public String getMessage() {
|
||||
throw new UnsupportedOperationException("message unavailable");
|
||||
}
|
||||
};
|
||||
|
||||
ChunkGenerator generator = IrisFailClosedChunkGenerator.refused("world_iris_moon", unreadable);
|
||||
|
||||
IllegalStateException refusal = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
|
||||
assertTrue(refusal.getMessage(), refusal.getMessage().contains("world_iris_moon"));
|
||||
}
|
||||
}
|
||||
+177
-56
@@ -24,6 +24,7 @@ import org.bukkit.generator.WorldInfo;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -256,18 +257,12 @@ public class IrisWorldGeneratorResolverTest {
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
Server server = mock(Server.class);
|
||||
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
|
||||
bukkit.when(Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "overworld"));
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "overworld"));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("CheckingPlotSquaredGenerator"));
|
||||
assertTrue(refusal, refusal.contains("CheckingPlotSquaredGenerator"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
}
|
||||
@@ -382,32 +377,26 @@ public class IrisWorldGeneratorResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void externalCreateWithoutIrisStorageThrowsWithoutShutdown() throws Exception {
|
||||
public void externalCreateWithoutIrisStorageFailsClosedWithoutShutdown() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("external-create");
|
||||
File levelRoot = new File(worldContainer, "world");
|
||||
assertTrue(levelRoot.mkdirs());
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
Server server = mock(Server.class);
|
||||
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
|
||||
bukkit.when(Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("mvtest", "overworld"));
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("mvtest", "overworld"));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("mvtest"));
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("/iris create"));
|
||||
assertTrue(refusal, refusal.contains("mvtest"));
|
||||
assertTrue(refusal, refusal.contains("/iris create"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ownedIrisWorldWithUnusableSnapshotStillStopsTheServer() throws Exception {
|
||||
public void ownedIrisWorldWithUnusableSnapshotFailsClosedWithoutShutdown() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("owned-broken");
|
||||
File levelRoot = new File(worldContainer, "world");
|
||||
assertTrue(levelRoot.mkdirs());
|
||||
@@ -415,46 +404,162 @@ public class IrisWorldGeneratorResolverTest {
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
Server server = mock(Server.class);
|
||||
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
|
||||
bukkit.when(Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("world_iris_moon", "overworld"));
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("world_iris_moon", "overworld"));
|
||||
|
||||
bukkit.verify(Bukkit::shutdown);
|
||||
assertTrue(refusal, refusal.contains("world_iris_moon"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tester's failure: a world whose generation history no longer matches the live registries. CraftServer
|
||||
* turns a throw here into the vanilla generator, so the history failure has to come back as a generator that
|
||||
* refuses to create the level.
|
||||
*/
|
||||
@Test
|
||||
public void unusableGenerationHistoryFailsClosedWithoutShutdown() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("history-broken").getCanonicalFile();
|
||||
File levelRoot = new File(worldContainer, "world");
|
||||
File dimensionRoot = new File(levelRoot, "dimensions/iris/moon");
|
||||
assertTrue(new File(dimensionRoot, "iris/generation").mkdirs());
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class);
|
||||
MockedStatic<IrisWorlds> worlds = mockStatic(IrisWorlds.class)) {
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
worlds.when(() -> IrisWorlds.readBukkitWorldSeed("moon")).thenReturn(1337L);
|
||||
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("moon", "overworld"));
|
||||
|
||||
assertTrue(refusal, refusal.contains("'moon'"));
|
||||
assertTrue(refusal, refusal.contains("generation history is unusable"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void vanillaDimensionSlotWithoutFrozenPackIsNotOwned() throws Exception {
|
||||
public void internalLoadsGetTheResolutionFailureWithoutShutdown() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("internal-load").getCanonicalFile();
|
||||
File levelRoot = new File(worldContainer, "world");
|
||||
File dimensionRoot = new File(levelRoot, "dimensions/iris/moon");
|
||||
assertTrue(new File(dimensionRoot, "iris/generation").mkdirs());
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class);
|
||||
MockedStatic<IrisWorlds> worlds = mockStatic(IrisWorlds.class)) {
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
worlds.when(() -> IrisWorlds.readBukkitWorldSeed("moon")).thenReturn(1337L);
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> new IrisWorldGeneratorResolver(null).requireWorldGenerator("moon", "overworld"));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("generation history is unusable"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
iris.verify(() -> Iris.error(any(String.class), any(Object[].class)), never());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A vanilla slot bound to Iris in bukkit.yml with no Iris storage stops startup, so no command can run until the
|
||||
* binding is gone. /iris create is for new worlds anyway; a vanilla slot is taken over with /iris replace.
|
||||
*/
|
||||
@Test
|
||||
public void vanillaDimensionSlotWithoutFrozenPackFailsClosed() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("vanilla-slot");
|
||||
File levelRoot = new File(worldContainer, "world");
|
||||
assertTrue(new File(levelRoot, "dimensions/minecraft/the_nether").mkdirs());
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
Server server = mock(Server.class);
|
||||
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
|
||||
bukkit.when(Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("world_nether", "overworld"));
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("world_nether", "overworld"));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("minecraft:the_nether"));
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("/iris create"));
|
||||
assertTrue(refusal, refusal.contains("minecraft:the_nether"));
|
||||
assertTrue(refusal, refusal.contains("worlds.world_nether.generator"));
|
||||
assertTrue(refusal, refusal.contains("bukkit.yml"));
|
||||
assertTrue(refusal, refusal.contains("/iris replace minecraft:the_nether type=<pack>"));
|
||||
assertFalse(refusal, refusal.contains("/iris create"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The fix for a vanilla slot bound to Iris is removing the binding. Given for an Iris world whose history is
|
||||
* gone, that fix loads the world under the vanilla generator, which writes vanilla terrain into it.
|
||||
*/
|
||||
@Test
|
||||
public void vanillaSlotHoldingAnIrisWorldKeepsItsBindingAndPointsAtTheHistory() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("iris-in-vanilla-slot");
|
||||
File levelRoot = new File(worldContainer, "world");
|
||||
File dimensionRoot = new File(levelRoot, "dimensions/minecraft/overworld");
|
||||
assertTrue(new File(dimensionRoot, "iris/engine-data").mkdirs());
|
||||
assertTrue(new File(dimensionRoot, "data/minecraft").mkdirs());
|
||||
assertTrue(new File(dimensionRoot, "data/minecraft/world_gen_settings.dat").createNewFile());
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("world", "overworld"));
|
||||
|
||||
assertTrue(refusal, refusal.contains("minecraft:overworld"));
|
||||
assertTrue(refusal, refusal.contains("is an Iris world"));
|
||||
assertTrue(refusal, refusal.contains(new File(dimensionRoot, "iris/generation").getPath()));
|
||||
assertFalse(refusal, refusal.contains("Remove worlds.world.generator"));
|
||||
assertFalse(refusal, refusal.contains("/iris replace"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A start refused before the overworld existed still leaves level.dat behind, and vanilla cannot start from a
|
||||
* level.dat that has no world generation settings next to it.
|
||||
*/
|
||||
@Test
|
||||
public void vanillaSlotRefusalOnANeverCreatedLevelNamesTheLeftoverLevelDat() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("never-created");
|
||||
File levelRoot = new File(worldContainer, "world");
|
||||
assertTrue(levelRoot.mkdirs());
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("world", "overworld"));
|
||||
|
||||
assertTrue(refusal, refusal.contains("Remove worlds.world.generator"));
|
||||
assertTrue(refusal, refusal.contains(new File(levelRoot, "level.dat").getPath()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void vanillaSlotRefusalOnAnExistingLevelLeavesLevelDatAlone() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("existing-vanilla");
|
||||
File levelRoot = new File(worldContainer, "world");
|
||||
File settings = new File(levelRoot, "dimensions/minecraft/overworld/data/minecraft/world_gen_settings.dat");
|
||||
assertTrue(settings.getParentFile().mkdirs());
|
||||
assertTrue(settings.createNewFile());
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("world", "overworld"));
|
||||
|
||||
assertTrue(refusal, refusal.contains("Remove worlds.world.generator"));
|
||||
assertFalse(refusal, refusal.contains("level.dat"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void alreadyLoadedIrisKeyIsRefusedBeforeAnyEngineCanStart() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("duplicate-key");
|
||||
@@ -468,23 +573,39 @@ public class IrisWorldGeneratorResolverTest {
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
Server server = mock(Server.class);
|
||||
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
|
||||
bukkit.when(Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(List.of(loaded));
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of(loaded));
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("irisworld", "overworld"));
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("irisworld", "overworld"));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("iris:irisworld"));
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("world_iris_irisworld"));
|
||||
assertTrue(refusal, refusal.contains("iris:irisworld"));
|
||||
assertTrue(refusal, refusal.contains("world_iris_irisworld"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
}
|
||||
|
||||
private static void mockServer(MockedStatic<Bukkit> bukkit, File worldContainer, File levelRoot, List<World> worlds) {
|
||||
Server server = mock(Server.class);
|
||||
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
|
||||
bukkit.when(Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(worlds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves through CraftServer's fallback and proves the result is Iris' refusal rather than vanilla: Paper
|
||||
* calls getDefaultBiomeProvider before the level exists, and that throw is what stops the level being created.
|
||||
*/
|
||||
private static String assertFailsClosed(Supplier<ChunkGenerator> resolution) {
|
||||
ChunkGenerator vanillaFallback = mock(ChunkGenerator.class);
|
||||
ChunkGenerator selected = craftBukkitGeneratorOrFallback(resolution, vanillaFallback);
|
||||
assertNotSame(vanillaFallback, selected);
|
||||
IllegalStateException refusal = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> selected.getDefaultBiomeProvider(mock(WorldInfo.class)));
|
||||
return refusal.getMessage();
|
||||
}
|
||||
|
||||
private static void writeValidPack(Path packRoot) throws Exception {
|
||||
Files.createDirectories(packRoot.resolve("dimensions"));
|
||||
Files.createDirectories(packRoot.resolve("regions"));
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package art.arcane.iris.world;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.world.WorldLoadEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Whether a refused world stops startup depends on who asked for it: Paper's startup level creation aborts the
|
||||
* server, a runtime create only fails its caller. The banner must not claim the wrong one.
|
||||
*/
|
||||
public class WorldRefusalReporterTest {
|
||||
private static final NamespacedKey MOON = new NamespacedKey("iris", "moon");
|
||||
|
||||
@Test
|
||||
public void aRefusalBeforeAnyWorldLoadedSaysStartupStops() {
|
||||
String banner = reportAndCapture(new WorldRefusalReporter());
|
||||
|
||||
assertTrue(banner, banner.contains("'moon'"));
|
||||
assertTrue(banner, banner.contains("iris:moon"));
|
||||
assertTrue(banner, banner.contains("Iris generation history is unusable at /srv/moon."));
|
||||
assertTrue(banner, banner.contains("Historical generated registry definition changed for iris:biomes/abc"));
|
||||
assertTrue(banner, banner.contains("no chunks"));
|
||||
assertTrue(banner, banner.contains("startup stops"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Paper creates every startup level before it prepares any, and preparing one fires the first WorldLoadEvent.
|
||||
* A plugin enabling at POSTWORLD creates its worlds after that, before the first tick, and the server keeps
|
||||
* running when that create fails.
|
||||
*/
|
||||
@Test
|
||||
public void aRefusalAfterTheFirstWorldLoadSaysTheServerKeepsRunning() {
|
||||
WorldRefusalReporter reporter = new WorldRefusalReporter();
|
||||
reporter.onWorldLoad(new WorldLoadEvent(world(NamespacedKey.minecraft("overworld"))));
|
||||
|
||||
String banner = reportAndCapture(reporter);
|
||||
|
||||
assertTrue(banner, banner.contains("keeps running"));
|
||||
assertFalse(banner, banner.contains("startup stops"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void irisEnabledAfterWorldsExistTreatsEveryRefusalAsRuntime() {
|
||||
WorldRefusalReporter reporter = new WorldRefusalReporter();
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
PluginManager plugins = mock(PluginManager.class);
|
||||
World overworld = world(NamespacedKey.minecraft("overworld"));
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(List.of(overworld));
|
||||
bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
|
||||
|
||||
reporter.attach(plugin);
|
||||
}
|
||||
|
||||
assertTrue(reporter.startupWorldsCreated());
|
||||
verify(plugins).registerEvents(reporter, plugin);
|
||||
String banner = reportAndCapture(reporter);
|
||||
assertTrue(banner, banner.contains("keeps running"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void operatorsHearAboutARefusedWorldOnJoinUntilItLoads() {
|
||||
WorldRefusalReporter reporter = new WorldRefusalReporter();
|
||||
reportAndCapture(reporter);
|
||||
Player operator = player(true);
|
||||
Player member = player(false);
|
||||
|
||||
reporter.onPlayerJoin(new PlayerJoinEvent(operator, Component.empty()));
|
||||
reporter.onPlayerJoin(new PlayerJoinEvent(member, Component.empty()));
|
||||
|
||||
verify(operator).sendMessage(contains("iris:moon"));
|
||||
verify(member, never()).sendMessage(anyString());
|
||||
|
||||
reporter.onWorldLoad(new WorldLoadEvent(world(MOON)));
|
||||
Player later = player(true);
|
||||
reporter.onPlayerJoin(new PlayerJoinEvent(later, Component.empty()));
|
||||
verify(later, never()).sendMessage(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aFailureWhileReportingNeverEscapes() {
|
||||
Throwable unreadable = new IllegalStateException() {
|
||||
@Override
|
||||
public String getMessage() {
|
||||
throw new UnsupportedOperationException("message unavailable");
|
||||
}
|
||||
};
|
||||
|
||||
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
new WorldRefusalReporter().report("moon", MOON, unreadable);
|
||||
}
|
||||
}
|
||||
|
||||
private static String reportAndCapture(WorldRefusalReporter reporter) {
|
||||
IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.",
|
||||
new IOException("Historical generated registry definition changed for iris:biomes/abc"));
|
||||
List<String> lines = new ArrayList<>();
|
||||
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
iris.when(() -> Iris.error(anyString(), any(Object[].class))).thenAnswer(invocation -> {
|
||||
lines.add(invocation.getArgument(0, String.class));
|
||||
return null;
|
||||
});
|
||||
reporter.report("moon", MOON, failure);
|
||||
}
|
||||
return String.join("\n", lines);
|
||||
}
|
||||
|
||||
private static World world(NamespacedKey key) {
|
||||
World world = mock(World.class);
|
||||
when(world.getKey()).thenReturn(key);
|
||||
when(world.getName()).thenReturn(key.getKey());
|
||||
return world;
|
||||
}
|
||||
|
||||
private static Player player(boolean operator) {
|
||||
Player player = mock(Player.class);
|
||||
when(player.isOp()).thenReturn(operator);
|
||||
return player;
|
||||
}
|
||||
}
|
||||
@@ -32,11 +32,7 @@ buildscript {
|
||||
maven { url = uri('https://jitpack.io') }
|
||||
}
|
||||
dependencies {
|
||||
Properties libraryProperties = new Properties()
|
||||
file('../../gradle.properties').withInputStream { InputStream stream -> libraryProperties.load(stream) }
|
||||
String libraryCoordinate = providers.gradleProperty('volmLibCoordinate')
|
||||
.getOrElse(libraryProperties.getProperty('volmLibCoordinate'))
|
||||
classpath("com.github.VolmitSoftware.VolmLib:packaging:${libraryCoordinate.tokenize(':').last()}")
|
||||
classpath('com.github.VolmitSoftware.VolmLib:packaging:master-SNAPSHOT')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,13 +52,13 @@ interface IrisFileOperations {
|
||||
IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations)
|
||||
|
||||
apply from: file('../../gradle/modded-generation-build-revision.gradle')
|
||||
apply from: file('../../gradle/volmlib-stamp.gradle')
|
||||
|
||||
Properties rootProperties = new Properties()
|
||||
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
|
||||
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.2-26.2'))
|
||||
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2'))
|
||||
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.3'))
|
||||
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:master-SNAPSHOT'))
|
||||
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
|
||||
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.5'))
|
||||
Closure<String> irisArtifactName = { String platform, String targetVersion ->
|
||||
return "Iris v${project.version} [${platform}] ${targetVersion}.jar"
|
||||
}
|
||||
@@ -83,7 +79,6 @@ sourceSets {
|
||||
main {
|
||||
java {
|
||||
srcDir '../modded-common/src/main/java'
|
||||
srcDir '../minecraft-common/src/main/java'
|
||||
srcDir '../client-common/src/main/java'
|
||||
}
|
||||
resources {
|
||||
@@ -181,10 +176,10 @@ dependencies {
|
||||
compileOnly(libs.spigot) {
|
||||
transitive = false
|
||||
}
|
||||
add('bundle', volmLibCoordinate) {
|
||||
add('bundle', 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
|
||||
transitive = false
|
||||
}
|
||||
add('devBundle', volmLibCoordinate) {
|
||||
add('devBundle', 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
|
||||
transitive = false
|
||||
}
|
||||
add('bundle', libs.zip) {
|
||||
@@ -201,6 +196,7 @@ dependencies {
|
||||
libs.asm,
|
||||
libs.lru,
|
||||
libs.caffeine,
|
||||
'com.fulcrumgenomics:jlibdeflate:0.2.0',
|
||||
libs.dom4j,
|
||||
libs.jaxen
|
||||
]
|
||||
@@ -286,6 +282,9 @@ tasks.named('shadowJar', ShadowJar).configure {
|
||||
spec.delete(staleLoaderJars, legacyLoaderJar)
|
||||
}
|
||||
}
|
||||
from(tasks.named('generateVolmLibStamp')) {
|
||||
into('META-INF/volmit')
|
||||
}
|
||||
archiveFileName.set(irisArtifactName('Fabric', "${minecraftVersion}+${fabricLoaderVersion}"))
|
||||
configurations = [project.configurations.named('bundle').get()]
|
||||
mergeServiceFiles()
|
||||
|
||||
@@ -17,6 +17,7 @@ public final class IrisFabricBootstrap implements ModInitializer {
|
||||
public void onInitialize() {
|
||||
NativeFabricLoader loader = new NativeFabricLoader(new NativeLoaderOptions(
|
||||
"irisworldgen", "treefeller", ModdedTreeFellerService::runBreakProbe));
|
||||
loader.captureOriginalBiomes();
|
||||
NativeChunkGeneratorDefinition generator = IrisModdedChunkGenerator.DEFINITION;
|
||||
NativeFabricPackSources.bind(ModdedForcedDatapack::repositorySource);
|
||||
ModdedEngineBootstrap.bootCommon(loader, "Fabric", () -> NativeFabricBootstrap.registerGenerator(generator));
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
}
|
||||
],
|
||||
"depends": {
|
||||
"fabricloader": ">=0.19.3",
|
||||
"fabricloader": ">=0.19.5",
|
||||
"minecraft": "~${minecraftVersion}",
|
||||
"java": ">=25"
|
||||
}
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package art.arcane.volmlib.nativelib.minecraft26_2.fabric;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeRegistryDefinitions;
|
||||
import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import com.mojang.serialization.Lifecycle;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.MappedRegistry;
|
||||
import net.minecraft.core.RegistrationInfo;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeGenerationSettings;
|
||||
import net.minecraft.world.level.levelgen.GenerationStep;
|
||||
import net.minecraft.world.level.levelgen.carver.WorldCarver;
|
||||
import net.minecraft.world.level.levelgen.feature.Feature;
|
||||
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class NativeFabricBiomeDefinitionTest {
|
||||
private static final String BIOME_REGISTRY = "minecraft:worldgen/biome";
|
||||
private static final String BIOME_KEY = "iris:biomes/fabric_modified";
|
||||
private static final String SOURCE_JSON = "{\"has_precipitation\":true,\"temperature\":0.8,\"downfall\":0.4,"
|
||||
+ "\"effects\":{\"water_color\":\"#3f76e4\"},\"carvers\":[],\"features\":[]}";
|
||||
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generatedBiomeDefinitionExcludesFabricBiomeModifications() throws Exception {
|
||||
MappedRegistry<PlacedFeature> features = new MappedRegistry<>(Registries.PLACED_FEATURE, Lifecycle.stable());
|
||||
Holder.Reference<PlacedFeature> ore = features.register(
|
||||
ResourceKey.create(Registries.PLACED_FEATURE, Identifier.parse("iris:modded_ore")),
|
||||
new PlacedFeature(Holder.direct(mock(Feature.class)), List.of()),
|
||||
RegistrationInfo.BUILT_IN);
|
||||
features.freeze();
|
||||
MappedRegistry<WorldCarver> carvers = new MappedRegistry<>(Registries.CARVER, Lifecycle.stable());
|
||||
carvers.freeze();
|
||||
MappedRegistry<Biome> biomes = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable());
|
||||
RegistryAccess access = new RegistryAccess.ImmutableRegistryAccess(List.of(biomes, features, carvers));
|
||||
Biome source = Biome.DIRECT_CODEC.parse(RegistryOps.create(JsonOps.INSTANCE, access),
|
||||
JsonParser.parseString(SOURCE_JSON)).getOrThrow();
|
||||
NativeFabricLoader loader = new NativeFabricLoader(new NativeLoaderOptions("irisworldgen", "treefeller",
|
||||
BooleanSupplier::getAsBoolean));
|
||||
loader.recordOriginalBiome(source);
|
||||
Holder.Reference<Biome> biome = biomes.register(
|
||||
ResourceKey.create(Registries.BIOME, Identifier.parse(BIOME_KEY)), source, RegistrationInfo.BUILT_IN);
|
||||
biomes.freeze();
|
||||
addFeatureInPlace(biome.value().getGenerationSettings(), ore);
|
||||
warmInPlace(biome.value());
|
||||
NativeRegistryDefinitions definitions = new NativeRegistryDefinitions(() -> access, loader::unmodifiedBiome);
|
||||
|
||||
assertEquals(0.1F, biome.value().getBaseTemperature(), 0.0F);
|
||||
assertTrue(biome.value().getGenerationSettings().features()
|
||||
.get(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal()).contains(ore));
|
||||
assertEquals(JsonParser.parseString(definitions.canonicalDefinition(BIOME_REGISTRY, BIOME_KEY, SOURCE_JSON).value()),
|
||||
JsonParser.parseString(definitions.generatedDefinition(BIOME_REGISTRY, BIOME_KEY).value()));
|
||||
}
|
||||
|
||||
/**
|
||||
* What a Fabric biome modification that adds an ore does: it replaces the feature list of the registered biome's
|
||||
* own generation settings.
|
||||
*/
|
||||
private static void addFeatureInPlace(BiomeGenerationSettings settings, Holder<PlacedFeature> feature)
|
||||
throws ReflectiveOperationException {
|
||||
List<HolderSet<PlacedFeature>> steps = new ArrayList<>(settings.features());
|
||||
while (steps.size() <= GenerationStep.Decoration.UNDERGROUND_ORES.ordinal()) {
|
||||
steps.add(HolderSet.direct());
|
||||
}
|
||||
steps.set(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal(), HolderSet.direct(feature));
|
||||
Field features = BiomeGenerationSettings.class.getDeclaredField("features");
|
||||
features.setAccessible(true);
|
||||
features.set(settings, List.copyOf(steps));
|
||||
}
|
||||
|
||||
/**
|
||||
* What a Fabric weather modification does: it replaces the registered biome's climate settings.
|
||||
*/
|
||||
private static void warmInPlace(Biome biome) throws ReflectiveOperationException {
|
||||
Constructor<Biome.ClimateSettings> climate = Biome.ClimateSettings.class.getDeclaredConstructor(
|
||||
boolean.class, float.class, Biome.TemperatureModifier.class, float.class);
|
||||
climate.setAccessible(true);
|
||||
Field climateSettings = Biome.class.getDeclaredField("climateSettings");
|
||||
climateSettings.setAccessible(true);
|
||||
climateSettings.set(biome, climate.newInstance(true, 0.1F, Biome.TemperatureModifier.NONE, 0.4F));
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package art.arcane.volmlib.nativelib.minecraft26_2.fabric;
|
||||
|
||||
import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.entity.EntitySpawnReason;
|
||||
import net.minecraft.world.entity.Mob;
|
||||
import net.minecraft.world.level.ServerLevelAccessor;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NativeFabricSpawnPositionTest {
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chunkGenerationSpawnsUseTheVanillaRulesAndObstructionChecks() {
|
||||
NativeFabricLoader loader = new NativeFabricLoader(
|
||||
new NativeLoaderOptions("irisworldgen", "treefeller", BooleanSupplier::getAsBoolean));
|
||||
ServerLevelAccessor level = mock(ServerLevelAccessor.class);
|
||||
Mob mob = mock(Mob.class);
|
||||
when(mob.checkSpawnRules(level, EntitySpawnReason.CHUNK_GENERATION)).thenReturn(true);
|
||||
when(mob.checkSpawnObstruction(level)).thenReturn(true);
|
||||
assertTrue(loader.checkSpawnPosition(mob, level, EntitySpawnReason.CHUNK_GENERATION));
|
||||
|
||||
when(mob.checkSpawnObstruction(level)).thenReturn(false);
|
||||
assertFalse(loader.checkSpawnPosition(mob, level, EntitySpawnReason.CHUNK_GENERATION));
|
||||
|
||||
when(mob.checkSpawnObstruction(level)).thenReturn(true);
|
||||
when(mob.checkSpawnRules(level, EntitySpawnReason.CHUNK_GENERATION)).thenReturn(false);
|
||||
assertFalse(loader.checkSpawnPosition(mob, level, EntitySpawnReason.CHUNK_GENERATION));
|
||||
}
|
||||
}
|
||||
+13
-14
@@ -32,17 +32,13 @@ buildscript {
|
||||
maven { url = uri('https://jitpack.io') }
|
||||
}
|
||||
dependencies {
|
||||
Properties libraryProperties = new Properties()
|
||||
file('../../gradle.properties').withInputStream { InputStream stream -> libraryProperties.load(stream) }
|
||||
String libraryCoordinate = providers.gradleProperty('volmLibCoordinate')
|
||||
.getOrElse(libraryProperties.getProperty('volmLibCoordinate'))
|
||||
classpath("com.github.VolmitSoftware.VolmLib:packaging:${libraryCoordinate.tokenize(':').last()}")
|
||||
classpath('com.github.VolmitSoftware.VolmLib:packaging:master-SNAPSHOT')
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'net.minecraftforge.gradle' version '7.0.31'
|
||||
id 'net.minecraftforge.gradle' version '7.0.40'
|
||||
alias(libs.plugins.shadow)
|
||||
}
|
||||
|
||||
@@ -56,13 +52,13 @@ interface IrisFileOperations {
|
||||
IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations)
|
||||
|
||||
apply from: file('../../gradle/modded-generation-build-revision.gradle')
|
||||
apply from: file('../../gradle/volmlib-stamp.gradle')
|
||||
|
||||
Properties rootProperties = new Properties()
|
||||
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
|
||||
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.2-26.2'))
|
||||
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2'))
|
||||
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.2-65.1.1'))
|
||||
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:master-SNAPSHOT'))
|
||||
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
|
||||
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.3-66.0.8'))
|
||||
Closure<String> loaderDisplayVersion = { String loaderVersion ->
|
||||
String coordinatePrefix = "${minecraftVersion}-"
|
||||
if (loaderVersion.startsWith(coordinatePrefix)) {
|
||||
@@ -91,7 +87,6 @@ sourceSets {
|
||||
main {
|
||||
java {
|
||||
srcDir '../modded-common/src/main/java'
|
||||
srcDir '../minecraft-common/src/main/java'
|
||||
srcDir '../client-common/src/main/java'
|
||||
}
|
||||
resources {
|
||||
@@ -174,13 +169,13 @@ dependencies {
|
||||
compileOnly(libs.spigot) {
|
||||
transitive = false
|
||||
}
|
||||
add('bundle', volmLibCoordinate) {
|
||||
add('bundle', 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
|
||||
transitive = false
|
||||
}
|
||||
add('devBundle', volmLibCoordinate) {
|
||||
add('devBundle', 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
|
||||
transitive = false
|
||||
}
|
||||
add('runBundle', volmLibCoordinate) {
|
||||
add('runBundle', 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
|
||||
transitive = false
|
||||
}
|
||||
add('bundle', libs.zip) {
|
||||
@@ -200,6 +195,7 @@ dependencies {
|
||||
libs.asm,
|
||||
libs.lru,
|
||||
libs.caffeine,
|
||||
'com.fulcrumgenomics:jlibdeflate:0.2.0',
|
||||
libs.dom4j,
|
||||
libs.jaxen
|
||||
]
|
||||
@@ -271,8 +267,11 @@ tasks.named('shadowJar', ShadowJar).configure {
|
||||
spec.delete(staleLoaderJars, legacyLoaderJar)
|
||||
}
|
||||
}
|
||||
from(tasks.named('generateVolmLibStamp')) {
|
||||
into('META-INF/volmit')
|
||||
}
|
||||
manifest {
|
||||
// FML 26.2 has no TOML mixin handling at all (ModFileParser reads neither [[mixins]] nor
|
||||
// FML 26.3 has no TOML mixin handling at all (ModFileParser reads neither [[mixins]] nor
|
||||
// accessTransformers on Forge). This manifest attribute is the only registration mechanism;
|
||||
// do not move it into mods.toml.
|
||||
attributes('MixinConfigs': 'volmlib.entity.mixins.json,volmlib.client.mixins.json')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
modLoader = "javafml"
|
||||
loaderVersion = "[65,66)"
|
||||
loaderVersion = "[66,67)"
|
||||
license = "GPL-3.0"
|
||||
issueTrackerURL = "https://github.com/VolmitSoftware/Iris/issues"
|
||||
|
||||
@@ -16,7 +16,7 @@ logoFile = "assets/irisworldgen/icon.png"
|
||||
[[dependencies.irisworldgen]]
|
||||
modId = "forge"
|
||||
mandatory = true
|
||||
versionRange = "[65,66)"
|
||||
versionRange = "[66,67)"
|
||||
ordering = "NONE"
|
||||
side = "BOTH"
|
||||
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package art.arcane.volmlib.nativelib.minecraft26_2.forge;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeRegistryDefinitions;
|
||||
import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import com.mojang.serialization.Lifecycle;
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.MappedRegistry;
|
||||
import net.minecraft.core.RegistrationInfo;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.attribute.EnvironmentAttributes;
|
||||
import net.minecraft.world.attribute.modifier.MobSpawnSettingsModifier;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.MobSpawnSettings;
|
||||
import net.minecraft.world.level.levelgen.GenerationStep;
|
||||
import net.minecraft.world.level.levelgen.carver.WorldCarver;
|
||||
import net.minecraft.world.level.levelgen.feature.Feature;
|
||||
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
|
||||
import net.minecraftforge.common.world.BiomeModifier;
|
||||
import net.minecraftforge.common.world.ModifiableBiomeInfo;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class NativeForgeBiomeDefinitionTest {
|
||||
private static final String BIOME_REGISTRY = "minecraft:worldgen/biome";
|
||||
private static final String BIOME_KEY = "iris:biomes/forge_modified";
|
||||
private static final String SOURCE_JSON = "{\"has_precipitation\":true,\"temperature\":0.8,\"downfall\":0.4,"
|
||||
+ "\"effects\":{\"water_color\":\"#3f76e4\"},\"carvers\":[],\"features\":[]}";
|
||||
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generatedBiomeDefinitionExcludesForgeBiomeModifierOutput() {
|
||||
MappedRegistry<PlacedFeature> features = new MappedRegistry<>(Registries.PLACED_FEATURE, Lifecycle.stable());
|
||||
Holder.Reference<PlacedFeature> ore = features.register(
|
||||
ResourceKey.create(Registries.PLACED_FEATURE, Identifier.parse("iris:modded_ore")),
|
||||
new PlacedFeature(Holder.direct(mock(Feature.class)), List.of()),
|
||||
RegistrationInfo.BUILT_IN);
|
||||
features.freeze();
|
||||
MappedRegistry<WorldCarver> carvers = new MappedRegistry<>(Registries.CARVER, Lifecycle.stable());
|
||||
carvers.freeze();
|
||||
MappedRegistry<Biome> biomes = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable());
|
||||
RegistryAccess access = new RegistryAccess.ImmutableRegistryAccess(List.of(biomes, features, carvers));
|
||||
Biome source = Biome.DIRECT_CODEC.parse(RegistryOps.create(JsonOps.INSTANCE, access),
|
||||
JsonParser.parseString(SOURCE_JSON)).getOrThrow();
|
||||
Holder.Reference<Biome> biome = biomes.register(
|
||||
ResourceKey.create(Registries.BIOME, Identifier.parse(BIOME_KEY)), source, RegistrationInfo.BUILT_IN);
|
||||
biomes.freeze();
|
||||
biome.value().modifiableBiomeInfo().applyBiomeModifiers(biome, List.of(new ModdedContent(ore)));
|
||||
NativeRegistryDefinitions definitions = new NativeRegistryDefinitions(() -> access,
|
||||
new NativeForgeLoader(new NativeLoaderOptions("irisworldgen", "treefeller",
|
||||
BooleanSupplier::getAsBoolean))::unmodifiedBiome);
|
||||
|
||||
assertEquals(0.1F, biome.value().getBaseTemperature(), 0.0F);
|
||||
assertEquals(0x102030, biome.value().getWaterColor() & 0xFFFFFF);
|
||||
assertTrue(biome.value().getGenerationSettings().hasFeature(ore.value()));
|
||||
assertTrue(biome.value().getAttributes().contains(EnvironmentAttributes.NATURAL_MOB_SPAWNS));
|
||||
assertEquals(JsonParser.parseString(definitions.canonicalDefinition(BIOME_REGISTRY, BIOME_KEY, SOURCE_JSON).value()),
|
||||
JsonParser.parseString(definitions.generatedDefinition(BIOME_REGISTRY, BIOME_KEY).value()));
|
||||
}
|
||||
|
||||
private record ModdedContent(Holder<PlacedFeature> ore) implements BiomeModifier {
|
||||
@Override
|
||||
public void modify(Holder<Biome> biome, Phase phase, ModifiableBiomeInfo.BiomeInfo.Builder builder) {
|
||||
if (phase != Phase.ADD) {
|
||||
return;
|
||||
}
|
||||
builder.climateSettings().setTemperature(0.1F);
|
||||
builder.effects().waterColor(0x102030);
|
||||
builder.attributes().modify(EnvironmentAttributes.NATURAL_MOB_SPAWNS, MobSpawnSettingsModifier.overlay(),
|
||||
new MobSpawnSettings.Builder().addSpawn(EntityTypes.ZOMBIE, 5, 1, 2).build());
|
||||
builder.generationSettings().addFeature(GenerationStep.Decoration.UNDERGROUND_ORES, ore);
|
||||
}
|
||||
|
||||
@Override
|
||||
public MapCodec<? extends BiomeModifier> codec() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package art.arcane.volmlib.nativelib.minecraft26_2.forge;
|
||||
|
||||
import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.entity.EntitySpawnReason;
|
||||
import net.minecraft.world.entity.Mob;
|
||||
import net.minecraft.world.level.ServerLevelAccessor;
|
||||
import net.minecraftforge.common.util.Result;
|
||||
import net.minecraftforge.event.entity.living.MobSpawnEvent;
|
||||
import net.minecraftforge.eventbus.api.listener.EventListener;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NativeForgeSpawnPositionTest {
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chunkGenerationSpawnsFireThePositionCheckEventAndHonorItsDenial() {
|
||||
NativeForgeLoader loader = new NativeForgeLoader(
|
||||
new NativeLoaderOptions("irisworldgen", "treefeller", BooleanSupplier::getAsBoolean));
|
||||
ServerLevelAccessor level = mock(ServerLevelAccessor.class);
|
||||
Mob mob = mock(Mob.class);
|
||||
when(mob.checkSpawnRules(level, EntitySpawnReason.CHUNK_GENERATION)).thenReturn(true);
|
||||
when(mob.checkSpawnObstruction(level)).thenReturn(true);
|
||||
AtomicReference<EntitySpawnReason> observed = new AtomicReference<>();
|
||||
EventListener listener = MobSpawnEvent.PositionCheck.BUS.addListener((MobSpawnEvent.PositionCheck event) -> {
|
||||
observed.set(event.getSpawnReason());
|
||||
event.setResult(Result.DENY);
|
||||
});
|
||||
try {
|
||||
assertFalse(loader.checkSpawnPosition(mob, level, EntitySpawnReason.CHUNK_GENERATION));
|
||||
} finally {
|
||||
MobSpawnEvent.PositionCheck.BUS.removeListener(listener);
|
||||
}
|
||||
assertEquals(EntitySpawnReason.CHUNK_GENERATION, observed.get());
|
||||
assertTrue(loader.checkSpawnPosition(mob, level, EntitySpawnReason.CHUNK_GENERATION));
|
||||
}
|
||||
}
|
||||
+45
-20
@@ -44,6 +44,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeFeatureBiomeSourc
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeDimensionRuntime;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorOwner;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorHandle;
|
||||
@@ -58,6 +59,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorContext;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage;
|
||||
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||
import art.arcane.iris.structure.nativegen.NativeStructureOwnershipRecord;
|
||||
|
||||
import art.arcane.iris.structure.nativegen.IrisStructurePolicy;
|
||||
@@ -66,12 +68,12 @@ import art.arcane.iris.world.history.TerrainNativeBlockKeys;
|
||||
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeChunkWritePolicy;
|
||||
|
||||
import art.arcane.iris.world.history.TerrainBoundarySignature;
|
||||
import art.arcane.iris.world.history.NativeBiomeSpawnSelection;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.generation.runtime.IrisEngine;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.GenerationFailures;
|
||||
import art.arcane.iris.generation.runtime.GenerationSessionException;
|
||||
import art.arcane.iris.generation.runtime.GenerationSessionLease;
|
||||
import art.arcane.iris.structure.nativegen.NativeFeatureGenerationPolicy;
|
||||
@@ -454,11 +456,14 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
|
||||
|
||||
private Engine bindEngine(NativeWorld level) {
|
||||
requireBindingAllowed();
|
||||
// A recorded failure stands until repoint or unbind resets the binding; re-running the bind would repeat the
|
||||
// history verification on every chunk call and fail the same way.
|
||||
engineBinding.throwIfFailed(dimensionKey);
|
||||
try {
|
||||
requireGlobalStructureGeneration(
|
||||
NativeModdedServer.forWorld(level).generateStructures(), dimensionKey);
|
||||
} catch (RuntimeException error) {
|
||||
engineBinding.fail(error);
|
||||
recordBindFailure(level, error);
|
||||
throw error;
|
||||
}
|
||||
// Cache the owning level so hot paths never scan the level map to find themselves.
|
||||
@@ -500,7 +505,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
|
||||
engineBinding.complete(created);
|
||||
return created;
|
||||
} catch (Throwable error) {
|
||||
engineBinding.fail(error);
|
||||
recordBindFailure(level, error);
|
||||
if (error instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
@@ -512,6 +517,31 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
|
||||
}
|
||||
}
|
||||
|
||||
private void recordBindFailure(NativeWorld level, Throwable error) {
|
||||
// A runtime that is stopping says nothing about the world: fail this call, let the next bind try again.
|
||||
if (GenerationFailures.isShutdownFailure(error)) {
|
||||
ModdedIrisLog.warn("Iris bind of {} ({}) stopped with its runtime: {}", level.name(), dimensionKey,
|
||||
error.toString());
|
||||
return;
|
||||
}
|
||||
engineBinding.fail(error);
|
||||
for (String line : bindFailureNotice(level.name(), dimensionKey, error)) {
|
||||
ModdedIrisLog.error(line);
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> bindFailureNotice(String levelName, String dimensionKey, Throwable failure) {
|
||||
return GenerationRefusalNotice.compose(
|
||||
"Iris refused to generate " + levelName + " (generator " + dimensionKey + ")",
|
||||
GenerationRefusalNotice.causes(failure),
|
||||
List.of(
|
||||
"Iris does not generate this level and does not let vanilla or any other generator"
|
||||
+ " write it; no chunks are written.",
|
||||
"A startup level stops the server; a runtime dimension stays unloaded."
|
||||
+ " Fix the cause, then start the server again."
|
||||
));
|
||||
}
|
||||
|
||||
private void requireCompletedShutdown(Engine current) {
|
||||
if (current == null || !current.isClosing() || current.isClosed()) {
|
||||
return;
|
||||
@@ -565,21 +595,11 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
|
||||
}
|
||||
}
|
||||
|
||||
Engine engineOrNull() {
|
||||
requireBindingAllowed();
|
||||
Engine cached = engine;
|
||||
requireCompletedShutdown(cached);
|
||||
if (cached != null && !cached.isClosed()) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
return engine();
|
||||
} catch (Throwable ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Engine structureEngineOrNull() {
|
||||
/**
|
||||
* Null only while no level uses this generator (world creation screens, codec validation). A bound level whose
|
||||
* engine is refused, unloading or stopping throws, so no stage can mistake it for an unbound generator.
|
||||
*/
|
||||
Engine boundEngineOrNull() {
|
||||
requireBindingAllowed();
|
||||
engineBinding.throwIfFailed(dimensionKey);
|
||||
Engine cached = engine;
|
||||
@@ -984,7 +1004,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
|
||||
@Override
|
||||
public <H, S> NativeModdedBiomePolicy<H, S> biomePolicy(NativeBiomeSourceAccess<H, S> source) {
|
||||
ModdedBiomePolicy<H, S> policy = new ModdedBiomePolicy<>(source);
|
||||
policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(this::structureEngineOrNull, this::awaitStructureEngine,
|
||||
policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(this::boundEngineOrNull, this::awaitStructureEngine,
|
||||
this::allowsGenerationHistoryBypass, this::configuredStructureBiomeKeys,
|
||||
IrisModdedChunkGenerator::retainedBiomeKeys, this::visibleBiomeSeed));
|
||||
return policy;
|
||||
@@ -1012,6 +1032,11 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
|
||||
return ModdedEngineBootstrap.currentServer();
|
||||
}
|
||||
|
||||
@Override
|
||||
public NativeModdedLoader loader() {
|
||||
return ModdedEngineBootstrap.loader();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Engine current() {
|
||||
return engine();
|
||||
@@ -1086,7 +1111,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
|
||||
|
||||
@Override
|
||||
public NativeBlockColumn resolvedColumn(Engine current, int x, int z) {
|
||||
return current.getComplex().resolvedTerrainColumn(x, z).map(TerrainBoundarySignature::geometry).orElse(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+63
-15
@@ -29,6 +29,7 @@ import java.util.function.LongSupplier;
|
||||
|
||||
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.generation.stream.ProvisionalSampling;
|
||||
import art.arcane.iris.generation.runtime.DimensionStackContext;
|
||||
import art.arcane.iris.generation.runtime.DimensionStackLayout;
|
||||
import art.arcane.iris.generation.runtime.DimensionTerrainContext;
|
||||
@@ -71,6 +72,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
private volatile BiomeHolderTable<H> visibleBiomeCache = new BiomeHolderTable<>();
|
||||
private volatile BiomeHolderTable<H> structureBiomeCache = new BiomeHolderTable<>();
|
||||
private volatile BiomeHolderTable<H> surfaceStructureBiomeCache = new BiomeHolderTable<>();
|
||||
private volatile BiomeHolderTable<H> naturalStructureBiomeCache = new BiomeHolderTable<>();
|
||||
private volatile RuntimeCallbacks runtime;
|
||||
private volatile Set<String> possibleStructureBiomeKeys;
|
||||
private volatile BiomeKeySets biomeKeySets;
|
||||
@@ -93,6 +95,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
visibleBiomeCache = new BiomeHolderTable<>();
|
||||
structureBiomeCache = new BiomeHolderTable<>();
|
||||
surfaceStructureBiomeCache = new BiomeHolderTable<>();
|
||||
naturalStructureBiomeCache = new BiomeHolderTable<>();
|
||||
warnedUnresolvedBiomeKeys.clear();
|
||||
possibleStructureBiomeKeys = null;
|
||||
biomeKeySets = null;
|
||||
@@ -103,6 +106,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
visibleBiomeCache.evictRuntime(runtimeIdentity);
|
||||
structureBiomeCache.evictRuntime(runtimeIdentity);
|
||||
surfaceStructureBiomeCache.evictRuntime(runtimeIdentity);
|
||||
naturalStructureBiomeCache.evictRuntime(runtimeIdentity);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,7 +266,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
|
||||
@Override
|
||||
public H getNoiseBiome(int quartX, int quartY, int quartZ, S sampler) {
|
||||
Engine engine = engineOrNull();
|
||||
Engine engine = boundEngineOrNull();
|
||||
if (engine == null) {
|
||||
return access.serializedNoise(quartX, quartY, quartZ, sampler);
|
||||
}
|
||||
@@ -284,7 +288,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
|
||||
@Override
|
||||
public NativeBiomeSourcePolicy.BiomeLocation<H> findClosestBiome3d(ClosestQuery<H, S> query) {
|
||||
Engine engine = engineOrNull();
|
||||
Engine engine = boundEngineOrNull();
|
||||
if (engine == null) {
|
||||
return query.unboundFallback().get();
|
||||
}
|
||||
@@ -338,7 +342,12 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
H resolved = resolveStructureBiome(engine, quartX, quartY, quartZ, sampler);
|
||||
H resolved;
|
||||
try {
|
||||
resolved = ProvisionalSampling.memoizable(() -> resolveStructureBiome(engine, quartX, quartY, quartZ, sampler));
|
||||
} catch (ProvisionalSampling.Unmemoizable provisional) {
|
||||
return provisional.value();
|
||||
}
|
||||
if (cacheable) {
|
||||
cache.put(runtimeIdentity, key, resolved);
|
||||
}
|
||||
@@ -346,7 +355,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
}
|
||||
|
||||
public H getVisibleNoiseBiome(int quartX, int quartY, int quartZ, S sampler) {
|
||||
Engine engine = engineOrNull();
|
||||
Engine engine = boundEngineOrNull();
|
||||
if (engine == null) {
|
||||
return access.serializedNoise(quartX, quartY, quartZ, sampler);
|
||||
}
|
||||
@@ -388,8 +397,13 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
H resolved = resolveVisibleBiome(
|
||||
engine, quartX, quartY, quartZ, sampler, null);
|
||||
H resolved;
|
||||
try {
|
||||
resolved = ProvisionalSampling.memoizable(() -> resolveVisibleBiome(
|
||||
engine, quartX, quartY, quartZ, sampler, null));
|
||||
} catch (ProvisionalSampling.Unmemoizable provisional) {
|
||||
return provisional.value();
|
||||
}
|
||||
if (cacheable) {
|
||||
cache.put(runtimeIdentity, key, resolved);
|
||||
}
|
||||
@@ -397,7 +411,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
}
|
||||
|
||||
public H getVisibleSurfaceBiome(int blockX, int blockZ) {
|
||||
Engine engine = engineOrNull();
|
||||
Engine engine = boundEngineOrNull();
|
||||
if (engine == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -406,15 +420,15 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
try (historyScope) {
|
||||
GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_surface_spawn_biome");
|
||||
if (lease == null) {
|
||||
return null;
|
||||
throw new IllegalStateException("Iris surface spawn biome lookup was rejected during an engine transition");
|
||||
}
|
||||
try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
||||
if (!isReady(engine)) {
|
||||
return null;
|
||||
throw new IllegalStateException("Iris surface spawn biome lookup has no active engine runtime");
|
||||
}
|
||||
NativeBiomeSourceAccess.RegistryView<H> registry = biomeRegistry();
|
||||
if (registry == null) {
|
||||
return null;
|
||||
throw new IllegalStateException("Iris surface spawn biome lookup has no biome registry");
|
||||
}
|
||||
int quartX = ((blockX) >> 2);
|
||||
int quartZ = ((blockZ) >> 2);
|
||||
@@ -449,7 +463,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
}
|
||||
|
||||
public boolean isStructureReachable(Iterable<H> biomes) {
|
||||
Engine engine = engineOrNull();
|
||||
Engine engine = boundEngineOrNull();
|
||||
if (engine == null) {
|
||||
return isStructureReachable(biomes, possibleStructureBiomeKeys());
|
||||
}
|
||||
@@ -480,7 +494,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
int radius = query.radius();
|
||||
S sampler = query.sampler();
|
||||
int minQuartY = ((y - radius) >> 2);
|
||||
Engine engine = engineOrNull();
|
||||
Engine engine = boundEngineOrNull();
|
||||
if (engine == null) {
|
||||
return query.fallback().get();
|
||||
}
|
||||
@@ -531,7 +545,12 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
H resolved = resolveSurfaceStructureBiome(engine, quartX, quartZ, sampler);
|
||||
H resolved;
|
||||
try {
|
||||
resolved = ProvisionalSampling.memoizable(() -> resolveSurfaceStructureBiome(engine, quartX, quartZ, sampler));
|
||||
} catch (ProvisionalSampling.Unmemoizable provisional) {
|
||||
return provisional.value();
|
||||
}
|
||||
cache.put(runtimeIdentity, key, resolved);
|
||||
return resolved;
|
||||
}
|
||||
@@ -775,11 +794,40 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
if (!isReady(engine)) {
|
||||
throw new IllegalStateException("Iris structure biome lookup has no active engine runtime");
|
||||
}
|
||||
return getNoiseBiome(engine, quartX, quartY, quartZ, sampler);
|
||||
return getNaturalStructureBiome(engine, quartX, quartZ);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The structure state only samples biomes for the concentric-ring search, which reaches ~24k blocks out for
|
||||
// strongholds. The natural surface answers there without planning hydrology tiles, the same as the Bukkit ring search.
|
||||
private H getNaturalStructureBiome(Engine engine, int quartX, int quartZ) {
|
||||
long key = packColumnKey(quartX, quartZ);
|
||||
int runtimeIdentity = engine.getCacheID();
|
||||
BiomeHolderTable<H> cache = naturalStructureBiomeCache;
|
||||
H cached = cache.get(runtimeIdentity, key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
NativeBiomeSourceAccess.RegistryView<H> registry = biomeRegistry();
|
||||
if (registry == null) {
|
||||
throw new IllegalStateException("Iris structure biome lookup has no biome registry");
|
||||
}
|
||||
int blockX = quartX << 2;
|
||||
int blockZ = quartZ << 2;
|
||||
IrisBiome irisBiome = engine.getComplex().getNaturalTrueBiomeStream().get(blockX, blockZ);
|
||||
if (irisBiome == null) {
|
||||
throw new IllegalStateException("Iris returned no natural structure biome at block " + blockX + "," + blockZ);
|
||||
}
|
||||
H resolved = resolveHolder(registry, irisBiome.getStructureDerivativeKey());
|
||||
if (resolved == null) {
|
||||
throw new IllegalStateException("Iris natural structure biome derivative '"
|
||||
+ irisBiome.getStructureDerivativeKey() + "' is not registered at block " + blockX + "," + blockZ);
|
||||
}
|
||||
cache.put(runtimeIdentity, key, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
static long biomeResolutionSeed(long worldSeed, int blockX, int blockY, int blockZ) {
|
||||
return worldSeed
|
||||
^ ((long) blockX * 341873128712L)
|
||||
@@ -826,7 +874,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
return engine != null && !engine.isClosed() && engine.getComplex() != null;
|
||||
}
|
||||
|
||||
private Engine engineOrNull() {
|
||||
private Engine boundEngineOrNull() {
|
||||
RuntimeCallbacks current = runtime;
|
||||
return current == null ? null : current.currentEngine().get();
|
||||
}
|
||||
|
||||
+77
-14
@@ -79,6 +79,8 @@ import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.generation.concurrent.MultiBurst;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public final class ModdedEngineBootstrap {
|
||||
private static final String[] CORE_SELF_TEST_CLASSES = {
|
||||
@@ -87,12 +89,17 @@ public final class ModdedEngineBootstrap {
|
||||
"art.arcane.iris.pack.loading.IrisData"
|
||||
};
|
||||
private static final Object LOCK = new Object();
|
||||
private static final Object STOP_LOCK = new Object();
|
||||
private static final AtomicBoolean EXIT_FLUSH_REGISTERED = new AtomicBoolean();
|
||||
private static final long EXIT_FLUSH_SERVER_WAIT_SECONDS = 30L;
|
||||
private static final ModdedServiceManager UNBOUND_SERVICE_MANAGER = new ModdedServiceManager();
|
||||
private static volatile NativeModdedLoader loader;
|
||||
private static volatile BoundRuntime runtime;
|
||||
private static volatile NativeModdedServer currentServer;
|
||||
private static volatile NativeModdedServer spawnCaptureServer;
|
||||
private static volatile boolean initialSpawnWasDefault;
|
||||
private static volatile boolean stoppedPending;
|
||||
private static volatile Thread stoppingThread;
|
||||
|
||||
private ModdedEngineBootstrap() {
|
||||
}
|
||||
@@ -119,10 +126,12 @@ public final class ModdedEngineBootstrap {
|
||||
}
|
||||
|
||||
public static void start(NativeModdedServer server) {
|
||||
finishPreviousServer("server start");
|
||||
registerExitFlush();
|
||||
captureInitialSpawn(server);
|
||||
currentServer = server;
|
||||
bind();
|
||||
// Pair of the stop() burst-pools stage. Load-bearing on integrated servers: once
|
||||
// Pair of the stopped() burst-pools stage. Load-bearing on integrated servers: once
|
||||
// closed, MultiBurst falls back to a same-thread executor, so a second world load
|
||||
// without reopen() would silently run every burst inline.
|
||||
MultiBurst.burst.reopen();
|
||||
@@ -150,6 +159,7 @@ public final class ModdedEngineBootstrap {
|
||||
}
|
||||
|
||||
public static void serverAboutToStart(NativeModdedServer server) {
|
||||
finishPreviousServer("server start");
|
||||
captureInitialSpawn(server);
|
||||
ModdedStartup.prepareForStartup();
|
||||
}
|
||||
@@ -180,20 +190,16 @@ public final class ModdedEngineBootstrap {
|
||||
try {
|
||||
generator.unbindEngine(world);
|
||||
} catch (Throwable exception) {
|
||||
// Never propagate into the loader's unload loop: a throw there skips closing every later level. A
|
||||
// runtime dimension removal retries the eviction itself and fails with the retained mapping.
|
||||
ModdedIrisLog.error("Iris engine unload failed for {}", world.name(), exception);
|
||||
if (exception instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
if (exception instanceof Error fatalError) {
|
||||
throw fatalError;
|
||||
}
|
||||
throw new IllegalStateException("Iris engine unload failed for "
|
||||
+ world.name(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
public static void stop() {
|
||||
NativeModdedServer stoppingServer = currentServer;
|
||||
stoppingThread = Thread.currentThread();
|
||||
stoppedPending = true;
|
||||
Throwable failure = null;
|
||||
failure = runStopStage(failure, "pack downloads", IrisModdedCommands::shutdownDownloads);
|
||||
failure = runStopStage(failure, "world check", () -> ModdedWorldCheck.serverStopped(stoppingServer));
|
||||
@@ -205,6 +211,62 @@ public final class ModdedEngineBootstrap {
|
||||
failure = runStopStage(failure, "studio commands", ModdedStudioCommands::clear);
|
||||
failure = runStopStage(failure, "gui host", ModdedGuiHost::clear);
|
||||
failure = runStopStage(failure, "services", () -> services().disableAll());
|
||||
reportStopFailure("stopping", failure);
|
||||
}
|
||||
|
||||
/**
|
||||
* The loaders still drain queued chunk generation after server stopping and only close levels afterwards, so
|
||||
* engines, their mantle and generation history, and the generation pools stay open until the server stopped.
|
||||
* Engines of unloaded levels are already closed by {@link #levelUnloaded}; this closes whatever remains.
|
||||
* Fabric fires it at the tail of stopServer, so a throw there skips it: the next server start or the JVM
|
||||
* exit runs it instead.
|
||||
*/
|
||||
public static void stopped() {
|
||||
synchronized (STOP_LOCK) {
|
||||
stoppedPending = false;
|
||||
stoppingThread = null;
|
||||
closeStoppedServer();
|
||||
}
|
||||
}
|
||||
|
||||
private static void finishPreviousServer(String trigger) {
|
||||
synchronized (STOP_LOCK) {
|
||||
if (!stoppedPending) {
|
||||
return;
|
||||
}
|
||||
ModdedIrisLog.warn("Iris closing the engines the previous server left open ({})", trigger);
|
||||
stopped();
|
||||
}
|
||||
}
|
||||
|
||||
private static void registerExitFlush() {
|
||||
if (EXIT_FLUSH_REGISTERED.compareAndSet(false, true)) {
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(ModdedEngineBootstrap::flushAtExit, "Iris Exit Flush"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void flushAtExit() {
|
||||
if (!stoppedPending) {
|
||||
return;
|
||||
}
|
||||
Thread serverThread = stoppingThread;
|
||||
if (serverThread != null && serverThread != Thread.currentThread()) {
|
||||
try {
|
||||
serverThread.join(TimeUnit.SECONDS.toMillis(EXIT_FLUSH_SERVER_WAIT_SECONDS));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
if (serverThread.isAlive()) {
|
||||
ModdedIrisLog.warn("Iris skipped its exit flush: the server thread is still stopping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
finishPreviousServer("JVM exit");
|
||||
}
|
||||
|
||||
private static void closeStoppedServer() {
|
||||
Throwable failure = null;
|
||||
failure = runStopStage(failure, "world engines", ModdedWorldEngines::shutdown);
|
||||
failure = runStopStage(failure, "primary world router", ModdedPrimaryWorldRouter::clear);
|
||||
failure = runStopStage(failure, "dimension manager", ModdedDimensionManager::clear);
|
||||
@@ -226,10 +288,14 @@ public final class ModdedEngineBootstrap {
|
||||
spawnCaptureServer = null;
|
||||
initialSpawnWasDefault = false;
|
||||
});
|
||||
reportStopFailure("stopped", failure);
|
||||
}
|
||||
|
||||
private static void reportStopFailure(String phase, Throwable failure) {
|
||||
if (failure != null) {
|
||||
// The shutdown path must not propagate: propagating aborts the remaining loader stop handlers and
|
||||
// can leave the level unsaved. Every stage already logged its own failure.
|
||||
ModdedIrisLog.error("Iris modded shutdown completed with failures", failure);
|
||||
ModdedIrisLog.error("Iris modded shutdown ({}) completed with failures", phase, failure);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,10 +457,7 @@ public final class ModdedEngineBootstrap {
|
||||
BlockDataMergeSupport.StateMerger previousMerger = BlockDataMergeSupport.bindPlatformMerger(new NativeStateMerger(ModdedBlockResolution.BLOCKS)::merge);
|
||||
rollback.add(() -> BlockDataMergeSupport.restorePlatformMerger(previousMerger));
|
||||
|
||||
NativeTileReader nativeTileReader = new NativeTileReader(() -> {
|
||||
NativeModdedServer server = boundLoader.currentServer();
|
||||
return server == null ? null : server.registryAccess();
|
||||
});
|
||||
NativeTileReader nativeTileReader = NativeModdedServer.tileReader(boundLoader::currentServer);
|
||||
TileData.TileReader previousTileReader = TileData.bindPlatformReader(in -> ModdedTileData.wrap(nativeTileReader.read(in)));
|
||||
rollback.add(() -> TileData.restorePlatformReader(previousTileReader));
|
||||
|
||||
|
||||
+5
-4
@@ -314,12 +314,13 @@ public final class ModdedForcedDatapack {
|
||||
} catch (NoSuchAlgorithmException missing) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", missing);
|
||||
}
|
||||
DataVersion dataVersion = DataVersion.getRuntime();
|
||||
digest.update((HASH_SALT + '|' + ModdedEngineBootstrap.loader().platformName()
|
||||
+ '|' + DataVersion.V26_2.getPackFormat() + '\n').getBytes(StandardCharsets.UTF_8));
|
||||
+ '|' + dataVersion.getPackFormat() + '\n').getBytes(StandardCharsets.UTF_8));
|
||||
String inputFingerprint = IrisDatapackCompiler.computeInputFingerprint(
|
||||
compilationPackRoots(),
|
||||
List.of(),
|
||||
DataVersion.V26_2.get(),
|
||||
dataVersion.get(),
|
||||
false
|
||||
);
|
||||
digest.update(inputFingerprint.getBytes(StandardCharsets.UTF_8));
|
||||
@@ -328,7 +329,7 @@ public final class ModdedForcedDatapack {
|
||||
|
||||
private static void writeStagedPack(Path stagingDirectory) throws IOException {
|
||||
Map<String, KSet<String>> seenBiomes = new LinkedHashMap<>();
|
||||
IDataFixer fixer = DataVersion.V26_2.get();
|
||||
IDataFixer fixer = DataVersion.getRuntime().get();
|
||||
|
||||
int packCount = 0;
|
||||
KList<String> presetIds = new KList<>();
|
||||
@@ -679,7 +680,7 @@ public final class ModdedForcedDatapack {
|
||||
}
|
||||
|
||||
static void writePackMeta(Path packDirectory) throws IOException {
|
||||
int packFormat = DataVersion.V26_2.getPackFormat();
|
||||
int packFormat = DataVersion.getRuntime().getPackFormat();
|
||||
String json = "{\n"
|
||||
+ " \"pack\": {\n"
|
||||
+ " \"description\": \"Iris world generation biomes and dimension types for installed packs.\",\n"
|
||||
|
||||
+2
-12
@@ -9,12 +9,10 @@ import java.util.Objects;
|
||||
final class ModdedGenerationRegistry implements PlatformGenerationRegistry {
|
||||
private final NativeGenerationRegistry nativeRegistry;
|
||||
private final String runtimeIdentity;
|
||||
private final String rendererIdentity;
|
||||
|
||||
ModdedGenerationRegistry(NativeGenerationRegistry nativeRegistry, Identity identity) {
|
||||
ModdedGenerationRegistry(NativeGenerationRegistry nativeRegistry, String runtimeIdentity) {
|
||||
this.nativeRegistry = Objects.requireNonNull(nativeRegistry, "nativeRegistry");
|
||||
this.runtimeIdentity = requireText(identity.runtime(), "runtimeIdentity");
|
||||
this.rendererIdentity = requireText(identity.renderer(), "rendererIdentity");
|
||||
this.runtimeIdentity = requireText(runtimeIdentity, "runtimeIdentity");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -22,11 +20,6 @@ final class ModdedGenerationRegistry implements PlatformGenerationRegistry {
|
||||
return runtimeIdentity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generatedDefinitionRendererIdentity() {
|
||||
return rendererIdentity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String customBiomeResourceKey(String identitySha256) {
|
||||
return PlatformGenerationRegistry.contentAddressedCustomBiomeResourceKey(identitySha256);
|
||||
@@ -75,7 +68,4 @@ final class ModdedGenerationRegistry implements PlatformGenerationRegistry {
|
||||
}
|
||||
return required;
|
||||
}
|
||||
|
||||
record Identity(String runtime, String renderer) {
|
||||
}
|
||||
}
|
||||
|
||||
+28
-8
@@ -25,6 +25,8 @@ import art.arcane.volmlib.nativelib.terrain.feature.NativeFeatureTable;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedImportedFeatures;
|
||||
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.GenerationClosedException;
|
||||
import art.arcane.iris.generation.runtime.GenerationFailures;
|
||||
import art.arcane.iris.generation.runtime.GenerationSessionLease;
|
||||
import art.arcane.iris.structure.nativegen.NativeFeatureGenerationPolicy;
|
||||
import art.arcane.iris.structure.nativegen.IrisImportedFeatureControl;
|
||||
@@ -95,10 +97,12 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
|
||||
* the per-step feature lists and {@code BiomeFilter}'s hasFeature gate both see real features for a biome
|
||||
* whose datapack JSON declares none by design. Real registry biomes pass straight through.
|
||||
*
|
||||
* <p>With {@code importedFeatures} disabled there is no table and this is vanilla's default getter.
|
||||
* <p>With {@code importedFeatures} disabled there is no table and this is vanilla's default getter. So is an
|
||||
* unbound generator, which only world creation and codec validation ask. A bound generator whose engine is
|
||||
* refused or stopping throws: BiomeFilter would otherwise drop every imported feature of a custom biome.
|
||||
*/
|
||||
public NativeFeatureTable currentTable() {
|
||||
Engine engine = generator == null ? null : generator.engineOrNull();
|
||||
Engine engine = generator == null ? null : generator.boundEngineOrNull();
|
||||
return engine == null ? null : featureTables.get(engine.getCacheID());
|
||||
}
|
||||
|
||||
@@ -111,8 +115,15 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
|
||||
* the build itself is serialized because {@code applyBiomeDecoration} calls this from every worldgen
|
||||
* thread, and two threads that both found the stage unprepared would each run {@code FeatureSorter}, whose
|
||||
* cycle detection is the expensive part. Waiting here is safe: this caller holds no generator monitor.
|
||||
* A sealed runtime keeps the table it already settled but cannot build one, and decorating without it
|
||||
* would persist a chunk with no imported features, so it fails the chunk instead.
|
||||
*/
|
||||
public void prepare(Engine engine) {
|
||||
if ((engine.isClosed() || engine.isClosing())
|
||||
&& !settled(engine.getCacheID(), biomeSource.packGeneration())) {
|
||||
throw new GenerationClosedException("Iris imported features cannot prepare runtime "
|
||||
+ engine.getCacheID() + " while its engine is closing.");
|
||||
}
|
||||
prepare(engine, true);
|
||||
}
|
||||
|
||||
@@ -127,7 +138,7 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
|
||||
}
|
||||
|
||||
private void prepare(Engine engine, boolean waitForBuild) {
|
||||
if (engine == null || engine.isClosed() || engine.isClosing()) {
|
||||
if (engine.isClosed() || engine.isClosing()) {
|
||||
return;
|
||||
}
|
||||
int runtimeIdentity = engine.getCacheID();
|
||||
@@ -163,6 +174,7 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
|
||||
try {
|
||||
control = NativeFeatureGenerationPolicy.control(engine);
|
||||
} catch (RuntimeException error) {
|
||||
GenerationFailures.rethrowEngineFailure(error);
|
||||
ModdedIrisLog.error("Iris could not read importedFeatures for this dimension; features off: {}",
|
||||
error.toString(), error);
|
||||
markInert(runtimeIdentity, generation);
|
||||
@@ -177,6 +189,7 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
|
||||
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
||||
built = nativeFeatures.buildTable(new ModdedImportedFeaturePolicy(engine), control, generation);
|
||||
} catch (Throwable error) {
|
||||
GenerationFailures.rethrowEngineFailure(error);
|
||||
ModdedIrisLog.error("Iris importedFeatures is off for {}: feature table construction failed: {}",
|
||||
dimensionKey(engine), error.toString(), error);
|
||||
markInert(runtimeIdentity, generation);
|
||||
@@ -201,17 +214,24 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
|
||||
inertGenerations.put(runtimeIdentity, generation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Null only for a runtime whose features are settled off. A table retired or superseded since this chunk's
|
||||
* prepare fails the chunk: skipping the pass would persist it without imported features.
|
||||
*/
|
||||
public NativeFeatureTable placementTable(Engine engine) {
|
||||
int runtimeIdentity = engine.getCacheID();
|
||||
long generation = biomeSource.packGeneration();
|
||||
NativeFeatureTable table = featureTables.get(runtimeIdentity);
|
||||
if (table == null) {
|
||||
if (table == null && Long.valueOf(generation).equals(inertGenerations.get(runtimeIdentity))) {
|
||||
WorldCheckFeaturePlacement.recordFeaturesOff();
|
||||
return null;
|
||||
}
|
||||
if (table.generation() != biomeSource.packGeneration()) {
|
||||
featureTables.remove(runtimeIdentity, table);
|
||||
inertGenerations.remove(runtimeIdentity);
|
||||
return null;
|
||||
if (table == null || table.generation() != generation) {
|
||||
if (table != null) {
|
||||
featureTables.remove(runtimeIdentity, table);
|
||||
}
|
||||
throw new GenerationClosedException("Iris imported feature table for runtime " + runtimeIdentity
|
||||
+ " was retired before decoration.");
|
||||
}
|
||||
return generator == null ? null : table;
|
||||
}
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ public final class ModdedLifecycleCallbacks {
|
||||
ModdedEngineBootstrap::start,
|
||||
ModdedEngineBootstrap::serverStarted,
|
||||
ModdedEngineBootstrap::stop,
|
||||
ModdedEngineBootstrap::stopped,
|
||||
ModdedEngineBootstrap::levelLoaded,
|
||||
ModdedEngineBootstrap::levelUnloaded,
|
||||
ModdedProtocolHandler::onPlayerJoin,
|
||||
|
||||
@@ -40,29 +40,31 @@ import java.util.function.BooleanSupplier;
|
||||
* references and is safe on a dedicated server.
|
||||
*/
|
||||
public final class ModdedMixinAudit {
|
||||
private static final String ENTITY_CONFIG = "volmlib.entity.mixins.json";
|
||||
private static final String CLIENT_CONFIG = "volmlib.client.mixins.json";
|
||||
private static final AtomicBoolean AUDITED = new AtomicBoolean(false);
|
||||
|
||||
private static final List<ExpectedMixin> EXPECTED = List.of(
|
||||
new ExpectedMixin("EntityPersistenceMixin", "entity",
|
||||
static final List<ExpectedMixin> EXPECTED = List.of(
|
||||
new ExpectedMixin(ENTITY_CONFIG, "EntityPersistenceMixin",
|
||||
NativeMixinTarget.ENTITY, "iris$applyGeneratedPersistence",
|
||||
false, NativeMixinFlags::entityPersistenceRan),
|
||||
new ExpectedMixin("LivingEntityLootMixin", "entity",
|
||||
new ExpectedMixin(ENTITY_CONFIG, "LivingEntityLootMixin",
|
||||
NativeMixinTarget.LIVING_ENTITY, "iris$replaceBaseLoot",
|
||||
false, NativeMixinFlags::livingEntityLootRan),
|
||||
new ExpectedMixin("MobAwarenessMixin", "entity",
|
||||
new ExpectedMixin(ENTITY_CONFIG, "MobAwarenessMixin",
|
||||
NativeMixinTarget.MOB, "iris$tickUnawareMob",
|
||||
false, NativeMixinFlags::mobAwarenessRan),
|
||||
new ExpectedMixin("StructureTemplatePaletteConcurrencyMixin", "common",
|
||||
new ExpectedMixin(ENTITY_CONFIG, "StructureTemplatePaletteConcurrencyMixin",
|
||||
NativeMixinTarget.STRUCTURE_PALETTE,
|
||||
"iris$installConcurrentBlockCache",
|
||||
false, NativeMixinFlags::structureTemplatePaletteRan),
|
||||
new ExpectedMixin("NativeWorldOpenFlowsMixin", "client",
|
||||
new ExpectedMixin(CLIENT_CONFIG, "NativeWorldOpenFlowsMixin",
|
||||
NativeMixinTarget.WORLD_OPEN_FLOWS,
|
||||
"iris$openWorldCheckWorldStemCompatibility",
|
||||
"volmlib$openWorldCheckWorldStemCompatibility",
|
||||
true, NativeMixinFlags::worldOpenFlowsRan),
|
||||
new ExpectedMixin("NativeWorldTypeEntryMixin", "client",
|
||||
new ExpectedMixin(CLIENT_CONFIG, "NativeWorldTypeEntryMixin",
|
||||
NativeMixinTarget.WORLD_TYPE_ENTRY,
|
||||
"iris$describePreset",
|
||||
"volmlib$describePreset",
|
||||
true, NativeMixinFlags::worldTypeEntryRan));
|
||||
|
||||
private ModdedMixinAudit() {
|
||||
@@ -90,7 +92,7 @@ public final class ModdedMixinAudit {
|
||||
if (isApplied(expected)) {
|
||||
applied.add(expected.mixinName() + (expected.ran().getAsBoolean() ? "" : " (not yet exercised)"));
|
||||
} else {
|
||||
missing.add(expected.config() + '/' + expected.mixinName() + " -> " + expected.targetClass());
|
||||
missing.add(expected.config() + '/' + expected.mixinName() + " -> " + expected.targetClass().className());
|
||||
}
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
@@ -119,7 +121,7 @@ public final class ModdedMixinAudit {
|
||||
}
|
||||
}
|
||||
|
||||
private record ExpectedMixin(String mixinName, String config, NativeMixinTarget targetClass, String handlerMethod,
|
||||
boolean clientOnly, BooleanSupplier ran) {
|
||||
record ExpectedMixin(String config, String mixinName, NativeMixinTarget targetClass, String handlerMethod,
|
||||
boolean clientOnly, BooleanSupplier ran) {
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -102,7 +102,8 @@ final class ModdedNativeStructurePolicy implements NativeModdedStructureStage.Po
|
||||
|
||||
@Override
|
||||
public NativeBlockPositionPredicate protectedPositions(Engine context) {
|
||||
return GenerationWritePolicy.protectedPositions(context.getDimension().getStaticObjectLayer(context.getData()),
|
||||
return GenerationWritePolicy.protectedPositions(context.getDimension().getStaticObjectLayer(context.getData())
|
||||
.forTransition(context),
|
||||
context.getDimensionStackContext(), context.getMinHeight());
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeRegistryDefinitions;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntitySpawns;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
|
||||
@@ -38,8 +37,6 @@ import art.arcane.iris.spi.PlatformScheduler;
|
||||
import art.arcane.iris.spi.PlatformStructureHooks;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
|
||||
import net.minecraft.core.HolderLookup;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
@@ -63,25 +60,13 @@ public final class ModdedPlatform implements IrisPlatform {
|
||||
public ModdedPlatform(NativeModdedLoader loader) {
|
||||
this.loader = loader;
|
||||
this.registries = new ModdedRegistries(
|
||||
new NativeRegistryAccess(new NativeRegistryAccess.Configuration(
|
||||
ModdedPlatform::nativeRegistries, ModdedPlatform::reloadableRegistries,
|
||||
ModdedRegistries::warnNotReady)),
|
||||
ModdedPlatform::generationRegistry);
|
||||
NativeModdedServer.registryAccess(ModdedEngineBootstrap::currentServer, ModdedRegistries::warnNotReady),
|
||||
() -> generationRegistry(loader));
|
||||
this.scheduler = new ModdedScheduler();
|
||||
this.structureHooks = new ModdedStructureHooks(ModdedEngineBootstrap::currentServer);
|
||||
this.biomeWriter = new ModdedBiomeWriter(new NativeBiomeRegistry(ModdedEngineBootstrap::currentServer, "minecraft:plains"));
|
||||
}
|
||||
|
||||
private static HolderLookup.Provider nativeRegistries() {
|
||||
NativeModdedServer server = ModdedEngineBootstrap.currentServer();
|
||||
return server == null ? null : server.registryAccess();
|
||||
}
|
||||
|
||||
private static HolderLookup.Provider reloadableRegistries() {
|
||||
NativeModdedServer server = ModdedEngineBootstrap.currentServer();
|
||||
return server == null ? null : server.reloadableRegistries();
|
||||
}
|
||||
|
||||
public static void errorSink(Consumer<Throwable> sink) {
|
||||
ERROR_SINK = sink;
|
||||
}
|
||||
@@ -367,13 +352,12 @@ public final class ModdedPlatform implements IrisPlatform {
|
||||
}
|
||||
}
|
||||
|
||||
private static PlatformGenerationRegistry generationRegistry() {
|
||||
private static PlatformGenerationRegistry generationRegistry(NativeModdedLoader loader) {
|
||||
if (ModdedEngineBootstrap.currentServer() == null) {
|
||||
throw new IllegalStateException("Minecraft server is not ready for generation registry capture.");
|
||||
}
|
||||
return new ModdedGenerationRegistry(NativeModdedServer.registryDefinitions(ModdedEngineBootstrap::currentServer),
|
||||
new ModdedGenerationRegistry.Identity("modded-generation-registry-v1",
|
||||
"modded-generated-registry-json-v1|minecraft:" + BuildConstants.MINECRAFT_VERSION));
|
||||
return new ModdedGenerationRegistry(NativeModdedServer.registryDefinitions(ModdedEngineBootstrap::currentServer, loader),
|
||||
"modded-generation-registry-v1");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+86
-2
@@ -18,15 +18,17 @@
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeDimensionRuntime;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@@ -36,6 +38,8 @@ public final class ModdedPrimaryWorldRouter {
|
||||
private static final Set<UUID> routed = ConcurrentHashMap.newKeySet();
|
||||
private static final Set<UUID> inFlight = ConcurrentHashMap.newKeySet();
|
||||
private static int tickCounter = 0;
|
||||
private static volatile String loginRefusal;
|
||||
private static volatile String absentPrimary;
|
||||
|
||||
private ModdedPrimaryWorldRouter() {
|
||||
}
|
||||
@@ -43,6 +47,8 @@ public final class ModdedPrimaryWorldRouter {
|
||||
public static void clear() {
|
||||
routed.clear();
|
||||
inFlight.clear();
|
||||
loginRefusal = null;
|
||||
absentPrimary = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +62,20 @@ public final class ModdedPrimaryWorldRouter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects a joining player while the configured primary world is missing.
|
||||
*
|
||||
* @return true when the player was refused
|
||||
*/
|
||||
public static boolean refuseIfUnavailable(NativeProtocolPlayer player) {
|
||||
String refusal = loginRefusal;
|
||||
if (refusal == null || player == null) {
|
||||
return false;
|
||||
}
|
||||
player.disconnect(refusal);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void tick(NativeModdedServer server) {
|
||||
if (server == null) {
|
||||
return;
|
||||
@@ -68,17 +88,23 @@ public final class ModdedPrimaryWorldRouter {
|
||||
|
||||
ModdedModConfig config = ModdedModConfig.get();
|
||||
if (!config.routePlayersToPrimaryWorld()) {
|
||||
loginRefusal = null;
|
||||
return;
|
||||
}
|
||||
String primary = config.primaryWorld();
|
||||
if (primary.isBlank()) {
|
||||
loginRefusal = null;
|
||||
return;
|
||||
}
|
||||
|
||||
NativeWorld target = ModdedDimensionManager.level(server, primary);
|
||||
if (target == null) {
|
||||
if (ModdedStartup.dimensionsRestored()) {
|
||||
judgeMissingPrimary(server, primary);
|
||||
}
|
||||
return;
|
||||
}
|
||||
loginRefusal = null;
|
||||
NativeWorld overworld = server.overworld();
|
||||
if (NativeDimensionRuntime.sameWorld(target, overworld)) {
|
||||
return;
|
||||
@@ -97,7 +123,7 @@ public final class ModdedPrimaryWorldRouter {
|
||||
try {
|
||||
CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player,
|
||||
new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(),
|
||||
System.nanoTime() + TimeUnit.SECONDS.toNanos(10)));
|
||||
ModdedTeleportDeadline.fromNow()));
|
||||
teleport.whenComplete((success, failure) -> {
|
||||
inFlight.remove(id);
|
||||
if (Boolean.TRUE.equals(success) && failure == null) {
|
||||
@@ -115,4 +141,62 @@ public final class ModdedPrimaryWorldRouter {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* primaryWorld is instance-wide but the dimension it names belongs to one save. Only a save whose registry lists
|
||||
* it expected it here, so only that save has a primary world that failed to load.
|
||||
*/
|
||||
private static void judgeMissingPrimary(NativeModdedServer server, String primary) {
|
||||
if (loginRefusal == null && primary.equals(absentPrimary)) {
|
||||
return;
|
||||
}
|
||||
if (loginRefusal == null && !expectedInThisSave(server, primary)) {
|
||||
absentPrimary = primary;
|
||||
ModdedIrisLog.warn("Iris primary world '" + primary + "' from config/irisworldgen/modded.json does not"
|
||||
+ " exist in this save, so players are not routed. Create it here with /iris world replace-overworld"
|
||||
+ " <pack> or clear primaryWorld.");
|
||||
return;
|
||||
}
|
||||
refuseLogins(server, primary);
|
||||
}
|
||||
|
||||
private static boolean expectedInThisSave(NativeModdedServer server, String primary) {
|
||||
try {
|
||||
return ModdedDimensionRegistryStore.get(server, primary) != null;
|
||||
} catch (RuntimeException unreadable) {
|
||||
ModdedIrisLog.error("Iris could not read this save's dimension registry to judge primary world '"
|
||||
+ primary + "'", unreadable);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Players left in the overworld would generate the terrain the primary Iris world was configured to replace, so a
|
||||
* primary world this save expected and did not load refuses every player instead.
|
||||
*/
|
||||
private static void refuseLogins(NativeModdedServer server, String primary) {
|
||||
String refusal = "Iris primary world '" + primary + "' is not loaded, so this server refuses players."
|
||||
+ " An operator has to check the server console.";
|
||||
if (loginRefusal == null) {
|
||||
for (String line : GenerationRefusalNotice.compose(
|
||||
"Iris refused player logins: primary world '" + primary + "' is not loaded",
|
||||
List.of("routePlayersToPrimaryWorld sends players to '" + primary
|
||||
+ "', which this save's dimension registry expects but did not load at startup."),
|
||||
List.of(
|
||||
"Players are disconnected instead of playing in the overworld, so no chunks are"
|
||||
+ " written there.",
|
||||
"Fix the dimension (see the Iris errors above) or clear primaryWorld in"
|
||||
+ " config/irisworldgen/modded.json, then start the server again."
|
||||
))) {
|
||||
ModdedIrisLog.error(line);
|
||||
}
|
||||
}
|
||||
loginRefusal = refusal;
|
||||
List<NativeProtocolPlayer> online = new ArrayList<>();
|
||||
// Disconnecting removes the player from the list being walked, so collect first.
|
||||
server.forEachPlayer(online::add);
|
||||
for (NativeProtocolPlayer player : online) {
|
||||
player.disconnect(refusal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -123,11 +123,11 @@ public final class ModdedProtocolHandler {
|
||||
}
|
||||
|
||||
public static void onPlayerJoin(NativeProtocolPlayer player) {
|
||||
if (player == null) {
|
||||
if (player == null || ModdedPrimaryWorldRouter.refuseIfUnavailable(player)) {
|
||||
return;
|
||||
}
|
||||
String sessionId = sessionId(player);
|
||||
ModdedStartup.warnPackFailuresTo(player);
|
||||
ModdedStartup.warnStartupFailuresTo(player);
|
||||
IrisSessionRegistry current = registry;
|
||||
ModdedProtocolTransport currentTransport = transport;
|
||||
if (current == null || currentTransport == null) {
|
||||
|
||||
@@ -195,7 +195,8 @@ public final class ModdedScheduler implements PlatformScheduler {
|
||||
mainQueue.clear();
|
||||
delayedQueue.clear();
|
||||
mainThread = null;
|
||||
// Shutdown stage that always runs (ModdedEngineBootstrap.stop): release the level snapshot with it.
|
||||
// Runs from ModdedEngineBootstrap.stopped, or its fallback at the next server start or JVM exit when the
|
||||
// loader skipped it: release the level snapshot with it.
|
||||
ModdedServerLevels.forget();
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import art.arcane.iris.pack.PackValidationResult;
|
||||
import art.arcane.iris.pack.PackValidator;
|
||||
import art.arcane.iris.modded.command.ModdedPackCommands;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||
|
||||
@@ -36,6 +37,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -43,6 +45,7 @@ public final class ModdedStartup {
|
||||
private static final int COMPAT_BOOT_KEY_CAP = 3;
|
||||
private static final AtomicBoolean PREPARED = new AtomicBoolean(false);
|
||||
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||
private static final List<String> DIMENSION_FAILURES = new CopyOnWriteArrayList<>();
|
||||
|
||||
private ModdedStartup() {
|
||||
}
|
||||
@@ -50,6 +53,7 @@ public final class ModdedStartup {
|
||||
public static void reset() {
|
||||
PREPARED.set(false);
|
||||
STARTED.set(false);
|
||||
DIMENSION_FAILURES.clear();
|
||||
}
|
||||
|
||||
public static void prepareForStartup() {
|
||||
@@ -208,7 +212,7 @@ public final class ModdedStartup {
|
||||
}
|
||||
}
|
||||
|
||||
private static void reinjectPersistentDimensions(NativeModdedServer server) {
|
||||
static void reinjectPersistentDimensions(NativeModdedServer server) {
|
||||
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
|
||||
ModdedDimensionRegistryStore.loadForStartup(server);
|
||||
if (dimensions.isEmpty()) {
|
||||
@@ -233,7 +237,7 @@ public final class ModdedStartup {
|
||||
index, dimensions.size(), dimension.id(), dimension.pack(), dimension.dimension(),
|
||||
System.currentTimeMillis() - dimensionStartedAt);
|
||||
} catch (Throwable e) {
|
||||
ModdedIrisLog.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})", dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), e);
|
||||
reportDimensionFailure(dimension, e);
|
||||
if (e instanceof OutOfMemoryError outOfMemory) {
|
||||
throw outOfMemory;
|
||||
}
|
||||
@@ -243,6 +247,25 @@ public final class ModdedStartup {
|
||||
injected, dimensions.size(), System.currentTimeMillis() - startedAt);
|
||||
}
|
||||
|
||||
private static void reportDimensionFailure(ModdedDimensionRegistryStore.PersistentDimension dimension, Throwable failure) {
|
||||
List<String> notice = GenerationRefusalNotice.compose(
|
||||
"Iris refused to load dimension '" + dimension.id() + "' (pack=" + dimension.pack()
|
||||
+ " dim=" + dimension.dimension() + " seed=" + dimension.seed() + ")",
|
||||
GenerationRefusalNotice.causes(failure),
|
||||
List.of(
|
||||
"Iris does not generate this dimension and does not let vanilla or any other generator"
|
||||
+ " write it; no chunks are written.",
|
||||
"It stays unloaded. Fix the cause, then start the server again."
|
||||
));
|
||||
for (String line : notice) {
|
||||
ModdedIrisLog.error(line);
|
||||
}
|
||||
ModdedIrisLog.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})",
|
||||
dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), failure);
|
||||
DIMENSION_FAILURES.add("Iris dimension '" + dimension.id() + "' did not load and refuses to generate: "
|
||||
+ GenerationRefusalNotice.summary(failure));
|
||||
}
|
||||
|
||||
private static long newestModificationMillis(Path root) {
|
||||
long newest = 0L;
|
||||
try (Stream<Path> walk = Files.walk(root)) {
|
||||
@@ -261,13 +284,24 @@ public final class ModdedStartup {
|
||||
}
|
||||
|
||||
/**
|
||||
* SP-6: a pack excluded by validation is otherwise only visible in the console. Tell the operators who
|
||||
* can actually act on it when they join.
|
||||
* True once the first tick with a player list has re-injected the persistent dimensions. Before that a runtime
|
||||
* dimension that is not loaded yet is still on its way.
|
||||
*/
|
||||
public static void warnPackFailuresTo(NativeProtocolPlayer player) {
|
||||
public static boolean dimensionsRestored() {
|
||||
return STARTED.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* SP-6: a pack excluded by validation or a persistent dimension that did not load is otherwise only visible in
|
||||
* the console. Tell the operators who can actually act on it when they join.
|
||||
*/
|
||||
public static void warnStartupFailuresTo(NativeProtocolPlayer player) {
|
||||
if (player == null || !player.isGameMaster()) {
|
||||
return;
|
||||
}
|
||||
for (String failure : DIMENSION_FAILURES) {
|
||||
player.sendMessage(failure);
|
||||
}
|
||||
for (Map.Entry<String, PackValidationResult> entry : PackValidationRegistry.snapshot().entrySet()) {
|
||||
PackValidationResult result = entry.getValue();
|
||||
if (result == null || result.isLoadable()) {
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Deadline for a teleport that waits on its destination chunk. The first chunk of a cold dimension also loads the
|
||||
* pack and binds the engine; the deadline only bounds a destination whose chunk never completes (a refusing
|
||||
* generator or a removed dimension).
|
||||
*/
|
||||
public final class ModdedTeleportDeadline {
|
||||
private static final long DEADLINE_SECONDS = 120L;
|
||||
|
||||
private ModdedTeleportDeadline() {
|
||||
}
|
||||
|
||||
public static long fromNow() {
|
||||
return System.nanoTime() + TimeUnit.SECONDS.toNanos(DEADLINE_SECONDS);
|
||||
}
|
||||
}
|
||||
@@ -426,9 +426,9 @@ public final class ModdedWorldEngines {
|
||||
Engine engine = entry.getValue();
|
||||
try {
|
||||
// Latch the generator's unloading flag BEFORE closing (unbindEngine sets it,
|
||||
// then evicts): chunk-system drain work running after this stage would
|
||||
// otherwise see a closed engine and silently rebuild a fresh engine + Mantle
|
||||
// that no teardown stage ever closes, writing plates after the final save.
|
||||
// then evicts): late chunk work would otherwise see a closed engine and
|
||||
// silently rebuild a fresh engine + Mantle that no teardown stage ever
|
||||
// closes, writing plates after the final save.
|
||||
IrisModdedChunkGenerator generator = NativeWorldGenerators.find(level, IrisModdedChunkGenerator.class);
|
||||
if (generator != null) {
|
||||
generator.unbindEngine(level);
|
||||
|
||||
@@ -245,7 +245,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
MantleChunk<Matter> chunk = mantle.getChunk(chunkX, chunkZ).use();
|
||||
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
|
||||
try {
|
||||
List<PreparedMarkerSpawn> markers = markerSystemEnabled()
|
||||
? prepareMarkerSpawns(level, chunkX, chunkZ, chunk) : List.of();
|
||||
@@ -380,7 +380,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
return;
|
||||
}
|
||||
|
||||
MantleChunk<Matter> chunk = mantle.getChunk(chunkX, chunkZ).use();
|
||||
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
|
||||
try {
|
||||
if (markerSystemEnabled()) {
|
||||
spawnPreparedMarkers(level, prepareMarkerSpawns(level, chunkX, chunkZ, chunk), false);
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ final class WorldCheckDimensionContract {
|
||||
}
|
||||
|
||||
static Dimension expectedDimensionContract(IrisDimension dimension) {
|
||||
JSONObject json = new JSONObject(dimension.getDimensionType().toJson(DataVersion.V26_2.get()));
|
||||
JSONObject json = new JSONObject(dimension.getDimensionType().toJson(DataVersion.getRuntime().get()));
|
||||
return new Dimension(
|
||||
json.getInt("min_y"),
|
||||
json.getInt("height"),
|
||||
|
||||
+34
-4
@@ -36,6 +36,7 @@ import art.arcane.iris.modded.ModdedEngineBootstrap;
|
||||
import art.arcane.iris.modded.ModdedForcedDatapack;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
|
||||
import art.arcane.iris.modded.ModdedScheduler;
|
||||
import art.arcane.iris.modded.ModdedTeleportDeadline;
|
||||
import art.arcane.iris.modded.ModdedWorldgenIds;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
@@ -60,6 +61,7 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
@@ -133,7 +135,7 @@ public final class IrisModdedCommands {
|
||||
NativeModdedServer server = source.server();
|
||||
CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player, new NativeWorldTeleport.Destination(
|
||||
server, ModdedDimensionManager.level(server, dimensionId), 8.5D, Double.MIN_VALUE, 8.5D,
|
||||
System.nanoTime() + TimeUnit.SECONDS.toNanos(10L)));
|
||||
ModdedTeleportDeadline.fromNow()));
|
||||
teleport.whenComplete((success, failure) -> {
|
||||
if (Boolean.TRUE.equals(success) && failure == null) {
|
||||
return;
|
||||
@@ -142,14 +144,42 @@ public final class IrisModdedCommands {
|
||||
ModdedIrisLog.error("Iris teleport into '{}' failed for {}",
|
||||
dimensionId, player.id(), failure);
|
||||
}
|
||||
server.execute(() -> fail(source, IrisLanguage.plain(
|
||||
ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED,
|
||||
MessageArgument.untrusted("dimensionId", dimensionId))));
|
||||
server.execute(() -> fail(source, teleportFailure(dimensionId, player.name(), failure,
|
||||
ModdedDimensionManager.level(server, dimensionId) != null, server.player(player.id()) != null)));
|
||||
});
|
||||
ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORTING, MessageArgument.untrusted("value", player.name()), MessageArgument.untrusted("dimensionId", dimensionId)));
|
||||
return 1;
|
||||
}
|
||||
|
||||
static String teleportFailure(String dimensionId, String playerName, Throwable failure,
|
||||
boolean dimensionLoaded, boolean playerOnline) {
|
||||
if (failure != null) {
|
||||
return IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON,
|
||||
MessageArgument.untrusted("dimensionId", dimensionId),
|
||||
MessageArgument.untrusted("reason", failureReason(failure)));
|
||||
}
|
||||
if (!dimensionLoaded) {
|
||||
return IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED,
|
||||
MessageArgument.untrusted("dimensionId", dimensionId));
|
||||
}
|
||||
if (!playerOnline) {
|
||||
return IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_CANCELLED_PLAYER_OFFLINE,
|
||||
MessageArgument.untrusted("dimensionId", dimensionId),
|
||||
MessageArgument.untrusted("value", playerName));
|
||||
}
|
||||
return IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_REFUSED,
|
||||
MessageArgument.untrusted("dimensionId", dimensionId));
|
||||
}
|
||||
|
||||
private static String failureReason(Throwable failure) {
|
||||
Throwable cause = failure;
|
||||
while (cause instanceof CompletionException && cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
String message = cause.getMessage();
|
||||
return message == null || message.isBlank() ? cause.getClass().getSimpleName() : message;
|
||||
}
|
||||
|
||||
static int evacuate(NativeCommandSource source, NativeWorld target) {
|
||||
NativeModdedServer server = source.server();
|
||||
NativeWorld level = target != null ? target : source.world();
|
||||
|
||||
+2
-2
@@ -165,7 +165,7 @@ public final class ModdedDatapackCommands {
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
String json;
|
||||
try {
|
||||
json = dimension.getDimensionType().toJson(DataVersion.V26_2.get());
|
||||
json = dimension.getDimensionType().toJson(DataVersion.getRuntime().get());
|
||||
} catch (Throwable e) {
|
||||
ModdedIrisLog.error("Iris dimension type generation failed for {}", dimension.getLoadKey(), e);
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_DATAPACK_COMMANDS_DIMENSION_TYPE_GENERATION_FAILED, MessageArgument.untrusted("value", level.name()), MessageArgument.untrusted("value2", String.valueOf(e.getMessage()))));
|
||||
@@ -187,7 +187,7 @@ public final class ModdedDatapackCommands {
|
||||
}
|
||||
|
||||
File mcmeta = new File(worldDatapacksFolder(server), WORLD_PACK_NAME + "/pack.mcmeta");
|
||||
int packFormat = DataVersion.V26_2.getPackFormat();
|
||||
int packFormat = DataVersion.getRuntime().getPackFormat();
|
||||
String meta = "{\n"
|
||||
+ " \"pack\": {\n"
|
||||
+ " \"description\": \"Iris dimension types derived from the installed Iris packs.\",\n"
|
||||
|
||||
+6
-1
@@ -116,6 +116,11 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
}
|
||||
ModdedIrisLog.info("Iris modded pregen done: dim={} completed={} peakInFlight={} finalLimit={}",
|
||||
runtime.worldIdentity(), completed.get(), inFlightPeak.get(), adaptiveLimit.get());
|
||||
try {
|
||||
engine.syncGenerationHistory();
|
||||
} catch (RuntimeException e) {
|
||||
ModdedIrisLog.error("Iris modded pregen could not force generation history to stable storage", e);
|
||||
}
|
||||
if (deferFinalSaveIfRequested()) {
|
||||
return;
|
||||
}
|
||||
@@ -498,7 +503,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
/**
|
||||
* A dedicated server with {@code pause-when-empty-seconds > 0} returns from
|
||||
* {@code MinecraftServer#tickServer} before {@code tickChildren} once it has been empty for that
|
||||
* long (26.2 only keeps {@code tickConnection} plus the task/chunk-poll window alive). That
|
||||
* long (26.3 only keeps {@code tickConnection} plus the task/chunk-poll window alive). That
|
||||
* freezes every per-tick Iris service - world manager, scheduler, protocol sync, pregen HUD - and
|
||||
* the loader's own generation hooks for the whole job, and console pregen on a default
|
||||
* server.properties is always empty. The guard zeroes the setting for the duration of the job
|
||||
|
||||
+2
-3
@@ -25,6 +25,7 @@ import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.studio.render.RenderType;
|
||||
import art.arcane.iris.modded.ModdedIrisLog;
|
||||
import art.arcane.iris.modded.ModdedTeleportDeadline;
|
||||
|
||||
import java.awt.Desktop;
|
||||
import java.io.File;
|
||||
@@ -32,7 +33,6 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import art.arcane.volmlib.nativelib.view.WorldView;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class ModdedVisionOverlay implements GuiOverlay {
|
||||
@@ -63,8 +63,7 @@ public final class ModdedVisionOverlay implements GuiOverlay {
|
||||
world.execute(() -> {
|
||||
int surfaceY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2;
|
||||
Optional<WorldView.TeleportOperation> operation = world.teleport(opener,
|
||||
new WorldView.Destination(blockX + 0.5D, surfaceY, blockZ + 0.5D,
|
||||
System.nanoTime() + TimeUnit.SECONDS.toNanos(10L)));
|
||||
new WorldView.Destination(blockX + 0.5D, surfaceY, blockZ + 0.5D, ModdedTeleportDeadline.fromNow()));
|
||||
if (operation.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
+15
@@ -71,6 +71,18 @@ public final class ModdedCommandMessages {
|
||||
"iris.modded.irismoddedcommands.teleport_failed_dimension_is_not_loaded",
|
||||
"Teleport failed: dimension " + "{dimensionId}" + " is not loaded."
|
||||
);
|
||||
public static final TextKey IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON = TextKey.of(
|
||||
"iris.modded.irismoddedcommands.teleport_failed_reason",
|
||||
"Teleport into " + "{dimensionId}" + " failed: " + "{reason}"
|
||||
);
|
||||
public static final TextKey IRIS_MODDED_COMMANDS_TELEPORT_CANCELLED_PLAYER_OFFLINE = TextKey.of(
|
||||
"iris.modded.irismoddedcommands.teleport_cancelled_player_offline",
|
||||
"Teleport into " + "{dimensionId}" + " cancelled: " + "{value}" + " is no longer online."
|
||||
);
|
||||
public static final TextKey IRIS_MODDED_COMMANDS_TELEPORT_REFUSED = TextKey.of(
|
||||
"iris.modded.irismoddedcommands.teleport_refused",
|
||||
"Teleport into " + "{dimensionId}" + " was refused by the server."
|
||||
);
|
||||
public static final TextKey IRIS_MODDED_COMMANDS_TELEPORTING = TextKey.of(
|
||||
"iris.modded.irismoddedcommands.teleporting",
|
||||
"Teleporting " + "{value}" + " to " + "{dimensionId}" + "..."
|
||||
@@ -1266,6 +1278,9 @@ public final class ModdedCommandMessages {
|
||||
IRIS_MODDED_COMMANDS_CONSOLE_MUST_NAME_PLAYER_IRIS_TP_DIMENSION_PLAYER,
|
||||
IRIS_MODDED_COMMANDS_IS_NOT_GENERATED_BY_IRIS,
|
||||
IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED,
|
||||
IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON,
|
||||
IRIS_MODDED_COMMANDS_TELEPORT_CANCELLED_PLAYER_OFFLINE,
|
||||
IRIS_MODDED_COMMANDS_TELEPORT_REFUSED,
|
||||
IRIS_MODDED_COMMANDS_TELEPORTING,
|
||||
IRIS_MODDED_COMMANDS_IS_NOT_GENERATED_BY_IRIS_2,
|
||||
IRIS_MODDED_COMMANDS_CANNOT_EVACUATE_PRIMARY_WORLD_THERE_IS_NOWHERE_SEND_PLAYERS,
|
||||
|
||||
+62
-10
@@ -30,6 +30,7 @@ import art.arcane.iris.studio.view.PregeneratorJob;
|
||||
import art.arcane.iris.integration.Identifier;
|
||||
import art.arcane.iris.generation.cache.Cache;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.GenerationFailures;
|
||||
import art.arcane.iris.generation.block.TileData;
|
||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||
import art.arcane.iris.modded.ModdedBlockResolution;
|
||||
@@ -38,6 +39,7 @@ import art.arcane.iris.modded.ModdedTileData;
|
||||
import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockPlacement;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
|
||||
import art.arcane.iris.world.storage.matter.TileWrapper;
|
||||
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
@@ -47,6 +49,7 @@ import art.arcane.volmlib.util.matter.MatterCavern;
|
||||
import art.arcane.volmlib.util.matter.MatterUpdate;
|
||||
import art.arcane.volmlib.util.scheduling.PrecisionStopwatch;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import it.unimi.dsi.fastutil.ints.IntArrayList;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
@@ -149,7 +152,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
|
||||
int centerZ = player.z() >> 4;
|
||||
for (int dx = -PLAYER_CHUNK_RADIUS; dx <= PLAYER_CHUNK_RADIUS; dx++) {
|
||||
for (int dz = -PLAYER_CHUNK_RADIUS; dz <= PLAYER_CHUNK_RADIUS; dz++) {
|
||||
updateChunk(engine, level, centerX + dx, centerZ + dz);
|
||||
updatePassChunk(engine, level, centerX + dx, centerZ + dz);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -157,15 +160,29 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
|
||||
|
||||
private void updateForcedChunks(Engine engine, NativeWorld level) {
|
||||
new NativeWorldMaintenance(level).forEachForcedChunk(chunkKey ->
|
||||
updateChunk(engine, level, (int) chunkKey, (int) (chunkKey >> 32)));
|
||||
updatePassChunk(engine, level, (int) chunkKey, (int) (chunkKey >> 32)));
|
||||
}
|
||||
|
||||
public void updateRegeneratedChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) {
|
||||
updateChunk(engine, level, chunkX, chunkZ, false);
|
||||
}
|
||||
|
||||
private void updateChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) {
|
||||
updateChunk(engine, level, chunkX, chunkZ, true);
|
||||
/**
|
||||
* One chunk of a level pass. A failed chunk keeps its flags and retries on a later pass without stopping the
|
||||
* chunks after it; only a closed engine ends the level's pass.
|
||||
*/
|
||||
private void updatePassChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) {
|
||||
try {
|
||||
updateChunk(engine, level, chunkX, chunkZ, true);
|
||||
} catch (Throwable failure) {
|
||||
if (engine.isClosed() || engine.getMantle().getMantle().isClosed()) {
|
||||
throw failure;
|
||||
}
|
||||
if (IrisLogging.errorOnce("chunk-update:" + level.name() + ":" + chunkX + "," + chunkZ + ":" + failure,
|
||||
"Iris chunk update failed at " + chunkX + "," + chunkZ + " in " + level.name() + ", retrying each pass: " + failure)) {
|
||||
IrisLogging.reportError(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ, boolean requireNeighbors) {
|
||||
@@ -188,13 +205,18 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
|
||||
return;
|
||||
}
|
||||
|
||||
MantleChunk<Matter> chunk = mantle.getChunk(chunkX, chunkZ).use();
|
||||
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
|
||||
try {
|
||||
chunk.raiseFlagUnchecked(MantleFlag.ETCHED, () -> {
|
||||
chunk.raiseFlagUnchecked(MantleFlag.TILE, () -> runTilePass(engine, level, chunkX, chunkZ, chunk));
|
||||
chunk.raiseFlagUnchecked(MantleFlag.CUSTOM, () -> runCustomPass(engine, level, chunkX, chunkZ, chunk));
|
||||
chunk.raiseFlagUnchecked(MantleFlag.UPDATE, () -> runUpdatePass(engine, level, chunkX, chunkZ, chunk));
|
||||
});
|
||||
} catch (SavedBiomeUnavailableException unavailable) {
|
||||
if (!unavailable.isLoading() || unavailable.getSuppressed().length != 0) {
|
||||
throw unavailable;
|
||||
}
|
||||
IrisLogging.debug("Iris chunk update deferred at " + chunkX + "," + chunkZ + ": saved biomes loading");
|
||||
} finally {
|
||||
chunk.release();
|
||||
}
|
||||
@@ -315,15 +337,40 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
|
||||
}
|
||||
}
|
||||
|
||||
chunk.iterate(MatterUpdate.class, (Integer x, Integer yf, Integer z, MatterUpdate v) -> {
|
||||
if (v != null && v.isUpdate()) {
|
||||
update(engine, level, x, yf + minHeight, z, baseX, baseZ, chunk);
|
||||
}
|
||||
});
|
||||
IntArrayList completed = new IntArrayList();
|
||||
try {
|
||||
chunk.iterate(MatterUpdate.class, (Integer x, Integer yf, Integer z, MatterUpdate v) -> {
|
||||
if (v != null && v.isUpdate()) {
|
||||
update(engine, level, x, yf + minHeight, z, baseX, baseZ, chunk);
|
||||
completed.add((yf << 8) | ((x & 15) << 4) | (z & 15));
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException | Error failure) {
|
||||
forgetCompletedUpdates(chunk, completed, failure);
|
||||
throw failure;
|
||||
}
|
||||
chunk.deleteSlices(MatterUpdate.class);
|
||||
engine.getMetrics().getUpdates().put(stopwatch.getMilliseconds());
|
||||
}
|
||||
|
||||
private static void forgetCompletedUpdates(MantleChunk<Matter> chunk, IntArrayList completed, Throwable failure) {
|
||||
try {
|
||||
for (int index = 0; index < completed.size(); index++) {
|
||||
int position = completed.getInt(index);
|
||||
int y = position >>> 8;
|
||||
Matter section = chunk.get(y >> 4);
|
||||
if (section != null && section.hasSlice(MatterUpdate.class)) {
|
||||
section.<MatterUpdate>getSlice(MatterUpdate.class)
|
||||
.set((position >> 4) & 15, y & 15, position & 15, null);
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException | Error cleanupFailure) {
|
||||
if (cleanupFailure != failure) {
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void update(Engine engine, NativeWorld level, int x, int y, int z, int baseX, int baseZ, MantleChunk<Matter> chunk) {
|
||||
if (y < level.minHeight() || y >= level.maxHeight() - 1) {
|
||||
return;
|
||||
@@ -337,7 +384,12 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
|
||||
}
|
||||
try {
|
||||
ModdedLootApplier.apply(engine, level, pos, state, chunk);
|
||||
} catch (SavedBiomeUnavailableException unavailable) {
|
||||
GenerationFailures.rethrowEngineFailure(unavailable);
|
||||
IrisLogging.warnOnce("chunk-update-loot:" + level.name() + ":" + unavailable.getMessage(),
|
||||
"Iris left the container at " + pos + " in " + level.name() + " without loot: " + unavailable.getMessage());
|
||||
} catch (Throwable e) {
|
||||
GenerationFailures.rethrowEngineFailure(e);
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"pack": {
|
||||
"description": "Iris client resources and mod data",
|
||||
"min_format": [97, 1],
|
||||
"max_format": [121, 0]
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -72,10 +72,10 @@ public class IrisModLanguageAssetsTest {
|
||||
}
|
||||
|
||||
private Set<String> resourceFiles() throws Exception {
|
||||
URL resource = IrisModLanguageAssetsTest.class.getClassLoader().getResource(ROOT);
|
||||
assertNotNull("Missing mod language resource directory: " + ROOT, resource);
|
||||
URL resource = IrisModLanguageAssetsTest.class.getClassLoader().getResource(ROOT + "en_us.json");
|
||||
assertNotNull("Missing English mod language resource", resource);
|
||||
assertEquals("Mod language resources must resolve to a directory on disk", "file", resource.getProtocol());
|
||||
Path directory = Path.of(resource.toURI());
|
||||
Path directory = Path.of(resource.toURI()).getParent();
|
||||
try (Stream<Path> paths = Files.list(directory)) {
|
||||
return paths
|
||||
.filter(Files::isRegularFile)
|
||||
|
||||
+5
-4
@@ -6,6 +6,7 @@ import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.util.random.Weighted;
|
||||
import net.minecraft.util.random.WeightedList;
|
||||
import net.minecraft.util.valueproviders.UniformInt;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.level.biome.MobSpawnSettings;
|
||||
import org.junit.Test;
|
||||
@@ -24,10 +25,10 @@ public class IrisModdedChunkGeneratorSpawnTest {
|
||||
|
||||
@Test
|
||||
public void explicitSpawnsReplaceMatchingVanillaTypesAndPreserveOthers() {
|
||||
MobSpawnSettings.SpawnerData zombie = new MobSpawnSettings.SpawnerData(EntityTypes.ZOMBIE, 1, 4);
|
||||
MobSpawnSettings.SpawnerData vanillaSlime = new MobSpawnSettings.SpawnerData(EntityTypes.SLIME, 1, 1);
|
||||
MobSpawnSettings.SpawnerData explicitSlime = new MobSpawnSettings.SpawnerData(EntityTypes.SLIME, 2, 5);
|
||||
MobSpawnSettings.SpawnerData explicitCow = new MobSpawnSettings.SpawnerData(EntityTypes.COW, 2, 4);
|
||||
MobSpawnSettings.SpawnerData zombie = new MobSpawnSettings.SpawnerData(EntityTypes.ZOMBIE, UniformInt.of(1, 4));
|
||||
MobSpawnSettings.SpawnerData vanillaSlime = new MobSpawnSettings.SpawnerData(EntityTypes.SLIME, UniformInt.of(1, 1));
|
||||
MobSpawnSettings.SpawnerData explicitSlime = new MobSpawnSettings.SpawnerData(EntityTypes.SLIME, UniformInt.of(2, 5));
|
||||
MobSpawnSettings.SpawnerData explicitCow = new MobSpawnSettings.SpawnerData(EntityTypes.COW, UniformInt.of(2, 4));
|
||||
WeightedList<MobSpawnSettings.SpawnerData> vanilla = WeightedList.of(List.of(
|
||||
new Weighted<>(zombie, 100),
|
||||
new Weighted<>(vanillaSlime, 1)));
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedChunkGenerator;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
import art.arcane.volmlib.util.mantle.MantleClosedException;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.level.ServerChunkCache;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* A level whose Iris generator cannot bind must crash its own creation, never fall through to another generator,
|
||||
* and every later chunk call must fail fast on the recorded failure instead of re-running the whole bind
|
||||
* (history verification included) on every worker thread.
|
||||
*/
|
||||
public class ModdedBindFailureTest {
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aRecordedBindFailureIsReportedOnceAndRethrownWithoutBindingAgain() throws Exception {
|
||||
IrisModdedChunkGenerator generator = generator("overworld:overworld");
|
||||
NativeWorld level = level(generator, "minecraft:overworld");
|
||||
field("boundLevel").set(generator, level);
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
when(server.generateStructures()).thenReturn(false);
|
||||
|
||||
try (MockedStatic<NativeModdedServer> servers = mockStatic(NativeModdedServer.class);
|
||||
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||
servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
|
||||
|
||||
IllegalStateException recorded = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
|
||||
IllegalStateException second = assertThrows(IllegalStateException.class, generator::engine);
|
||||
IllegalStateException third = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
|
||||
|
||||
assertSame(recorded, second.getCause());
|
||||
assertSame(recorded, third.getCause());
|
||||
servers.verify(() -> NativeModdedServer.forWorld(level), times(1));
|
||||
log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ")
|
||||
&& line.contains("generate-structures=false"))), times(1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A bind that fails because the runtime is stopping (level unload, quit to title, server stop) says nothing about
|
||||
* the world: it fails the chunk that asked, but the next bind tries again and no refusal is announced.
|
||||
*/
|
||||
@Test
|
||||
public void aShutdownFailureDuringBindIsNeitherRememberedNorAnnouncedAsARefusal() throws Exception {
|
||||
IrisModdedChunkGenerator generator = generator("overworld:overworld");
|
||||
NativeWorld level = level(generator, "minecraft:overworld");
|
||||
field("boundLevel").set(generator, level);
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
when(server.generateStructures()).thenReturn(true);
|
||||
MantleClosedException closed = new MantleClosedException("Tectonic Plate is closed!");
|
||||
|
||||
try (MockedStatic<NativeModdedServer> servers = mockStatic(NativeModdedServer.class);
|
||||
MockedStatic<ModdedWorldEngines> engines = mockStatic(ModdedWorldEngines.class);
|
||||
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||
servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
|
||||
engines.when(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any())).thenThrow(closed);
|
||||
|
||||
assertSame(closed, assertThrows(MantleClosedException.class, generator::engine));
|
||||
assertSame(closed, assertThrows(MantleClosedException.class, generator::engine));
|
||||
|
||||
engines.verify(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any()), times(2));
|
||||
log.verify(() -> ModdedIrisLog.error(anyString()), never());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unloading a level resets its binding, so a refusal recorded in one load never carries into the next load of the
|
||||
* same generator (a runtime dimension removal that rolls back, or a level loaded again in the same JVM).
|
||||
*/
|
||||
@Test
|
||||
public void aRecordedRefusalDoesNotOutliveTheUnloadOfItsLevel() throws Exception {
|
||||
IrisModdedChunkGenerator generator = generator("overworld:overworld");
|
||||
NativeWorld level = level(generator, "minecraft:overworld");
|
||||
doNothing().when((NativeModdedChunkGenerator<?, ?, ?>) field("nativeGenerator").get(generator)).clearCaches();
|
||||
field("importedFeatures").set(generator, mock(ModdedImportedFeatureStage.class));
|
||||
field("announced").set(generator, new AtomicBoolean());
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
when(server.generateStructures()).thenReturn(false);
|
||||
|
||||
try (MockedStatic<NativeModdedServer> servers = mockStatic(NativeModdedServer.class);
|
||||
MockedStatic<ModdedWorldEngines> engines = mockStatic(ModdedWorldEngines.class);
|
||||
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||
servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
|
||||
|
||||
IllegalStateException first = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
|
||||
generator.unbindEngine(level);
|
||||
IllegalStateException second = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
|
||||
|
||||
assertNotSame(first, second.getCause());
|
||||
servers.verify(() -> NativeModdedServer.forWorld(level), times(2));
|
||||
engines.verify(() -> ModdedWorldEngines.evictOrThrow(level));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bindFailureNoticeNamesTheLevelTheCauseAndThatNothingGenerates() {
|
||||
String notice = String.join("\n", IrisModdedChunkGenerator.bindFailureNotice(
|
||||
"minecraft:overworld",
|
||||
"overworld:overworld",
|
||||
new IllegalStateException("Iris generation history is unusable for 'minecraft:overworld'",
|
||||
new IOException("Historical generated registry definition changed"))));
|
||||
|
||||
assertTrue(notice, notice.contains("minecraft:overworld"));
|
||||
assertTrue(notice, notice.contains("overworld:overworld"));
|
||||
assertTrue(notice, notice.contains("generation history is unusable"));
|
||||
assertTrue(notice, notice.contains("registry definition changed"));
|
||||
assertTrue(notice, notice.contains("no chunks"));
|
||||
}
|
||||
|
||||
private static IrisModdedChunkGenerator generator(String dimensionKey) throws ReflectiveOperationException {
|
||||
IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class, CALLS_REAL_METHODS);
|
||||
field("nativeGenerator").set(generator, mock(NativeModdedChunkGenerator.class, CALLS_REAL_METHODS));
|
||||
field("engineBinding").set(generator, new ModdedEngineBinding<Engine>(1L, TimeUnit.SECONDS));
|
||||
field("dimensionKey").set(generator, dimensionKey);
|
||||
return generator;
|
||||
}
|
||||
|
||||
private static NativeWorld level(IrisModdedChunkGenerator generator, String name) throws ReflectiveOperationException {
|
||||
NativeWorld level = mock(NativeWorld.class);
|
||||
ServerLevel handle = mock(ServerLevel.class);
|
||||
ServerChunkCache chunkSource = mock(ServerChunkCache.class);
|
||||
when(level.nativeHandle()).thenReturn(handle);
|
||||
when(level.name()).thenReturn(name);
|
||||
when(handle.getChunkSource()).thenReturn(chunkSource);
|
||||
when(chunkSource.getGenerator()).thenReturn((ChunkGenerator) field("nativeGenerator").get(generator));
|
||||
return level;
|
||||
}
|
||||
|
||||
private static Field field(String name) throws NoSuchFieldException {
|
||||
Field field = IrisModdedChunkGenerator.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return field;
|
||||
}
|
||||
}
|
||||
+56
@@ -1,6 +1,13 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.GenerationSessionException;
|
||||
import art.arcane.iris.generation.runtime.GenerationSessionLease;
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBiomeSourceAccess;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -13,6 +20,9 @@ import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ModdedBiomePolicyTest {
|
||||
@Test
|
||||
@@ -52,6 +62,52 @@ public class ModdedBiomePolicyTest {
|
||||
assertTrue(failure.getMessage().contains("example:missing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void structureRingBiomesResolveFromTheNaturalSurfaceWithoutPlanningHydrology() throws Exception {
|
||||
BiomeToken forest = new BiomeToken("minecraft:forest");
|
||||
IrisBiome natural = mock(IrisBiome.class);
|
||||
when(natural.getStructureDerivativeKey()).thenReturn(forest.key());
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(complex.getNaturalTrueBiomeStream()).thenReturn(
|
||||
ProceduralStream.of((x, z) -> natural, Interpolated.of(value -> 0D, value -> null)));
|
||||
when(complex.getTrueBiomeStream()).thenThrow(
|
||||
new AssertionError("Stronghold ring lookups must not plan hydrology"));
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(engine.getMinHeight()).thenReturn(-64);
|
||||
when(engine.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop());
|
||||
ModdedBiomePolicy<BiomeToken, Object> policy = new ModdedBiomePolicy<>(
|
||||
new Access(new ArrayList<>(List.of(forest))));
|
||||
policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(() -> engine, () -> engine, bound -> true,
|
||||
() -> Set.of(forest.key()), bound -> Set.of(), () -> 0L));
|
||||
|
||||
assertSame(forest, policy.requiredStructureBiome(6000, 0, -6000, null));
|
||||
assertSame(forest, policy.requiredStructureBiome(6000, -30, -6000, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacked initial mob spawning asks for the visible surface biome. A null answer made it spawn from the chunk's
|
||||
* top biome instead, so a sealed or unready engine has to fail the chunk.
|
||||
*/
|
||||
@Test
|
||||
public void surfaceSpawnBiomeRefusesAnEngineInTransitionInsteadOfAnsweringNothing() throws Exception {
|
||||
Engine sealed = mock(Engine.class);
|
||||
when(sealed.isClosing()).thenReturn(true);
|
||||
when(sealed.acquireGenerationLease(anyString())).thenThrow(new GenerationSessionException("sealed", true));
|
||||
Engine unready = mock(Engine.class);
|
||||
when(unready.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop());
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> boundTo(sealed).getVisibleSurfaceBiome(8, 8));
|
||||
assertThrows(IllegalStateException.class, () -> boundTo(unready).getVisibleSurfaceBiome(8, 8));
|
||||
}
|
||||
|
||||
private static ModdedBiomePolicy<BiomeToken, Object> boundTo(Engine engine) {
|
||||
ModdedBiomePolicy<BiomeToken, Object> policy = new ModdedBiomePolicy<>(new Access(new ArrayList<>()));
|
||||
policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(() -> engine, () -> engine, bound -> true,
|
||||
Set::of, bound -> Set.of(), () -> 0L));
|
||||
return policy;
|
||||
}
|
||||
|
||||
private static ModdedBiomePolicy<BiomeToken, Object> policy(List<BiomeToken> entries,
|
||||
AtomicReference<Set<String>> configured) {
|
||||
ModdedBiomePolicy<BiomeToken, Object> policy = new ModdedBiomePolicy<>(new Access(entries));
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* Dist gate. The loader source sets fold modded-common, minecraft-common and client-common into one output, so
|
||||
* Dist gate. The loader source sets fold modded-common and client-common into one output, so
|
||||
* nothing at compile time stops a server-side class from touching a client-only type. On a dedicated server
|
||||
* that is a NoClassDefFoundError at the first call, usually deep inside worldgen.
|
||||
*
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ public class ModdedDimensionTypeParityTest {
|
||||
IrisDimension end = dimension("the_end", IrisEnvironment.THE_END, 0, 256, 256, new IrisDimensionTypeOptions());
|
||||
IrisDimension custom = dimension("custom_contract", IrisEnvironment.CUSTOM, -128, 384, 384, customOptions);
|
||||
List<IrisDimension> dimensions = List.of(overworld, nether, end, custom);
|
||||
IDataFixer fixer = DataVersion.V26_2.get();
|
||||
IDataFixer fixer = DataVersion.V26_3.get();
|
||||
Path packDirectory = Files.createTempDirectory("iris-dimension-contracts");
|
||||
KList<File> roots = new KList<>();
|
||||
roots.add(packDirectory.toFile());
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.generation.concurrent.MultiBurst;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.modded.command.IrisModdedCommands;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeServerSpawn;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.level.ServerChunkCache;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedConstruction;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockConstruction;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ModdedEngineBootstrapShutdownTest {
|
||||
private IrisSettings previousSettings;
|
||||
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Before
|
||||
public void openRuntime() {
|
||||
previousSettings = IrisSettings.settings;
|
||||
IrisSettings.settings = new IrisSettings();
|
||||
reopenBurstPools();
|
||||
}
|
||||
|
||||
@After
|
||||
public void restoreRuntime() {
|
||||
reopenBurstPools();
|
||||
IrisModdedCommands.openDownloadAdmission();
|
||||
IrisSettings.settings = previousSettings;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void serverStoppingKeepsEnginesAndWorkersOpenUntilServerStopped() throws Exception {
|
||||
NativeWorld level = level();
|
||||
Engine engine = mock(Engine.class);
|
||||
ModdedWorldEngines.installReplacement(level, engine);
|
||||
Callable<Thread> worker = Thread::currentThread;
|
||||
|
||||
ModdedEngineBootstrap.stop();
|
||||
|
||||
verify(engine, never()).close();
|
||||
assertTrue(ModdedWorldEngines.activeEngines().contains(engine));
|
||||
assertNotSame(Thread.currentThread(), MultiBurst.burst.submit(worker).get(10L, TimeUnit.SECONDS));
|
||||
|
||||
ModdedEngineBootstrap.stopped();
|
||||
|
||||
verify(engine).close();
|
||||
assertFalse(ModdedWorldEngines.activeEngines().contains(engine));
|
||||
assertSame(Thread.currentThread(), MultiBurst.burst.submit(worker).get(10L, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nextServerStartClosesTheEnginesOfAServerThatNeverReachedStopped() {
|
||||
NativeWorld level = level();
|
||||
Engine engine = mock(Engine.class);
|
||||
ModdedWorldEngines.installReplacement(level, engine);
|
||||
ModdedEngineBootstrap.stop();
|
||||
|
||||
try (MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
|
||||
MockedConstruction<NativeServerSpawn> spawn = mockConstruction(NativeServerSpawn.class)) {
|
||||
ModdedEngineBootstrap.serverAboutToStart(mock(NativeModdedServer.class));
|
||||
}
|
||||
|
||||
verify(engine).close();
|
||||
assertFalse(ModdedWorldEngines.activeEngines().contains(engine));
|
||||
}
|
||||
|
||||
private static NativeWorld level() {
|
||||
ServerLevel serverLevel = mock(ServerLevel.class);
|
||||
ServerChunkCache chunks = mock(ServerChunkCache.class);
|
||||
when(serverLevel.getChunkSource()).thenReturn(chunks);
|
||||
when(chunks.getGenerator()).thenReturn(mock(ChunkGenerator.class));
|
||||
NativeWorld level = mock(NativeWorld.class);
|
||||
when(level.nativeHandle()).thenReturn(serverLevel);
|
||||
when(level.name()).thenReturn("example:shutdown");
|
||||
return level;
|
||||
}
|
||||
|
||||
private static void reopenBurstPools() {
|
||||
MultiBurst.burst.reopen();
|
||||
MultiBurst.hydrology.reopen();
|
||||
MultiBurst.ioBurst.reopen();
|
||||
}
|
||||
}
|
||||
+32
-4
@@ -20,6 +20,7 @@ package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.volmlib.util.collection.KSet;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import art.arcane.iris.pack.datapack.DataVersion;
|
||||
import art.arcane.iris.pack.datapack.v1217.DataFixerV1217;
|
||||
import art.arcane.iris.world.history.GenerationEpochContractFactory;
|
||||
import art.arcane.iris.world.history.GenerationRegistryContract;
|
||||
@@ -27,6 +28,8 @@ import art.arcane.iris.world.history.GenerationRegistryContractFactory;
|
||||
import art.arcane.iris.generation.biome.IrisCustomBiomeAliasResolver;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.generation.terrain.IrisDimensionType;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformGenerationRegistry;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.util.Set;
|
||||
@@ -48,23 +51,48 @@ import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ModdedForcedDatapackTest {
|
||||
@Test
|
||||
public void metadataRetains262FormatWhenBukkitAlsoSupports263() throws IOException {
|
||||
public void metadataUses263DataFormat() throws IOException {
|
||||
Path packDirectory = Files.createTempDirectory("iris-modded-pack-format");
|
||||
try {
|
||||
ModdedForcedDatapack.writePackMeta(packDirectory);
|
||||
JSONObject pack = new JSONObject(Files.readString(packDirectory.resolve("pack.mcmeta")))
|
||||
.getJSONObject("pack");
|
||||
assertEquals(107, pack.getInt("pack_format"));
|
||||
assertEquals(107, pack.getInt("min_format"));
|
||||
assertEquals(107, pack.getInt("max_format"));
|
||||
assertEquals(121, pack.getInt("pack_format"));
|
||||
assertEquals(121, pack.getInt("min_format"));
|
||||
assertEquals(121, pack.getInt("max_format"));
|
||||
} finally {
|
||||
deleteTree(packDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void metadataFollowsTheRuntimeDataVersionThatValidationUses() throws IOException {
|
||||
IrisPlatform previous = IrisPlatforms.getOrNull();
|
||||
IrisPlatform runtime = mock(IrisPlatform.class);
|
||||
when(runtime.minecraftVersion()).thenReturn("26.2");
|
||||
Path packDirectory = Files.createTempDirectory("iris-modded-runtime-pack-format");
|
||||
IrisPlatforms.unbind();
|
||||
IrisPlatforms.bind(runtime);
|
||||
try {
|
||||
ModdedForcedDatapack.writePackMeta(packDirectory);
|
||||
JSONObject pack = new JSONObject(Files.readString(packDirectory.resolve("pack.mcmeta")))
|
||||
.getJSONObject("pack");
|
||||
assertEquals(DataVersion.getRuntime().getPackFormat(), pack.getInt("pack_format"));
|
||||
assertEquals(DataVersion.V26_2.getPackFormat(), pack.getInt("pack_format"));
|
||||
} finally {
|
||||
IrisPlatforms.unbind();
|
||||
if (previous != null) {
|
||||
IrisPlatforms.bind(previous);
|
||||
}
|
||||
deleteTree(packDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retainedDimensionSourceRendersBeforeNativeRegistryExists() throws Exception {
|
||||
DataFixerV1217 fixer = new DataFixerV1217();
|
||||
|
||||
+68
-3
@@ -3,6 +3,7 @@ package art.arcane.iris.modded;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeFeatureBiomeSource;
|
||||
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.GenerationClosedException;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.structure.nativegen.IrisImportedFeatureControl;
|
||||
import org.junit.Test;
|
||||
@@ -11,6 +12,9 @@ import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
@@ -56,17 +60,78 @@ public class ModdedImportedFeatureStageTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closingRuntimeDoesNotBuildFeatureTables() {
|
||||
public void aSealedRuntimeWithoutASettledTableRefusesDecorationInsteadOfSkippingFeatures() {
|
||||
NativeFeatureBiomeSource source = mock(NativeFeatureBiomeSource.class);
|
||||
when(source.packGeneration()).thenReturn(1L);
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getCacheID()).thenReturn(7);
|
||||
when(engine.isClosing()).thenReturn(true);
|
||||
ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(source);
|
||||
|
||||
stage.prepare(engine);
|
||||
stage.prepareWithoutWaiting(engine);
|
||||
assertThrows(GenerationClosedException.class, () -> stage.prepare(engine));
|
||||
|
||||
verify(source, never()).packGeneration();
|
||||
verify(engine, never()).getDimension();
|
||||
assertFalse(stage.active());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aSealedRuntimeKeepsDecoratingWithTheTableItAlreadySettled() {
|
||||
NativeFeatureBiomeSource source = mock(NativeFeatureBiomeSource.class);
|
||||
when(source.packGeneration()).thenReturn(1L);
|
||||
Engine engine = disabledFeatures(7);
|
||||
ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(source);
|
||||
stage.bind(mock(IrisModdedChunkGenerator.class));
|
||||
stage.prepare(engine);
|
||||
|
||||
when(engine.isClosing()).thenReturn(true);
|
||||
stage.prepare(engine);
|
||||
|
||||
assertNull(stage.placementTable(engine));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aTableRetiredBetweenPrepareAndPlacementFailsTheChunk() {
|
||||
NativeFeatureBiomeSource source = mock(NativeFeatureBiomeSource.class);
|
||||
when(source.packGeneration()).thenReturn(1L);
|
||||
Engine engine = disabledFeatures(7);
|
||||
ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(source);
|
||||
stage.bind(mock(IrisModdedChunkGenerator.class));
|
||||
|
||||
stage.prepare(engine);
|
||||
stage.evictRuntime(7);
|
||||
assertThrows(GenerationClosedException.class, () -> stage.placementTable(engine));
|
||||
|
||||
stage.prepare(engine);
|
||||
when(source.packGeneration()).thenReturn(2L);
|
||||
assertThrows(GenerationClosedException.class, () -> stage.placementTable(engine));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anUnboundGeneratorAnswersVanillaGenerationSettings() {
|
||||
IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class);
|
||||
when(generator.boundEngineOrNull()).thenReturn(null);
|
||||
ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(mock(NativeFeatureBiomeSource.class));
|
||||
stage.bind(generator);
|
||||
|
||||
assertNull(stage.currentTable());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aFailedBindFailsTheGenerationSettingsInsteadOfDroppingImportedFeatures() {
|
||||
IllegalStateException refused = new IllegalStateException("Iris generator 'overworld:overworld' failed to bind");
|
||||
IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class);
|
||||
when(generator.boundEngineOrNull()).thenThrow(refused);
|
||||
ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(mock(NativeFeatureBiomeSource.class));
|
||||
stage.bind(generator);
|
||||
|
||||
assertSame(refused, assertThrows(IllegalStateException.class, stage::currentTable));
|
||||
}
|
||||
|
||||
private static Engine disabledFeatures(int runtimeIdentity) {
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getDimension()).thenReturn(new IrisDimension().setImportedFeatures(new IrisImportedFeatureControl()));
|
||||
when(engine.getCacheID()).thenReturn(runtimeIdentity);
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.Assume;
|
||||
import org.junit.Test;
|
||||
import org.objectweb.asm.AnnotationVisitor;
|
||||
import org.objectweb.asm.ClassReader;
|
||||
import org.objectweb.asm.ClassVisitor;
|
||||
import org.objectweb.asm.MethodVisitor;
|
||||
import org.objectweb.asm.Opcodes;
|
||||
import org.objectweb.asm.Type;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedMixinAuditTest {
|
||||
private static final String MIXIN_ANNOTATION = "Lorg/spongepowered/asm/mixin/Mixin;";
|
||||
private static final Set<String> INJECTOR_ANNOTATIONS = Set.of(
|
||||
"Lorg/spongepowered/asm/mixin/injection/Inject;",
|
||||
"Lorg/spongepowered/asm/mixin/injection/Redirect;",
|
||||
"Lorg/spongepowered/asm/mixin/injection/ModifyArg;",
|
||||
"Lorg/spongepowered/asm/mixin/injection/ModifyArgs;",
|
||||
"Lorg/spongepowered/asm/mixin/injection/ModifyVariable;",
|
||||
"Lorg/spongepowered/asm/mixin/injection/ModifyConstant;");
|
||||
private static final Pattern NEOFORGE_MIXIN_CONFIG = Pattern.compile(
|
||||
"\\[\\[mixins]]\\s*config\\s*=\\s*\"([^\"]+)\"");
|
||||
// One applied mixin per config proves the config was registered, which is all the audit detects.
|
||||
private static final Set<String> UNAUDITED = Set.of(
|
||||
"volmlib.entity.mixins.json/WorldGenerationWriteGuardMixin",
|
||||
"volmlib.entity.mixins.json/ChunkMapTerrainReceiptAccess",
|
||||
"volmlib.entity.mixins.json/ChunkTerrainReceiptMixin",
|
||||
"volmlib.entity.mixins.json/LevelChunkTerrainReceiptMixin",
|
||||
"volmlib.entity.mixins.json/SerializableTerrainReceiptMixin",
|
||||
"volmlib.fabric.mixins.json/BlockItemMixin",
|
||||
"volmlib.fabric.mixins.json/BlockMixin",
|
||||
"volmlib.fabric.mixins.json/PackRepositoryMixin");
|
||||
|
||||
@Test
|
||||
public void everyExpectedHandlerIsAnInjectorDeclaredByItsMixin() throws IOException {
|
||||
for (ModdedMixinAudit.ExpectedMixin expected : ModdedMixinAudit.EXPECTED) {
|
||||
MixinConfig config = MixinConfig.read(expected.config());
|
||||
String section = config.sections().get(expected.mixinName());
|
||||
assertNotNull(expected.config() + " does not list " + expected.mixinName(), section);
|
||||
assertEquals(expected.mixinName() + " client-only flag disagrees with its config section",
|
||||
"client".equals(section), expected.clientOnly());
|
||||
|
||||
MixinClass mixin = MixinClass.read(config.packageName() + '.' + expected.mixinName());
|
||||
assertTrue(expected.mixinName() + " does not target " + expected.targetClass().className()
|
||||
+ " (targets " + mixin.targets() + ")",
|
||||
mixin.targets().contains(expected.targetClass().className()));
|
||||
assertTrue(expected.mixinName() + " declares no injector handler named " + expected.handlerMethod()
|
||||
+ " (injectors " + mixin.injectors() + ")",
|
||||
mixin.injectors().contains(expected.handlerMethod()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyRegisteredMixinIsAuditedOrDeliberatelyUnaudited() throws IOException {
|
||||
Set<String> registered = registeredConfigs();
|
||||
Assume.assumeFalse("loader metadata on this classpath declares no mixin configs", registered.isEmpty());
|
||||
Set<String> audited = new LinkedHashSet<>();
|
||||
for (ModdedMixinAudit.ExpectedMixin expected : ModdedMixinAudit.EXPECTED) {
|
||||
audited.add(expected.config() + '/' + expected.mixinName());
|
||||
}
|
||||
|
||||
Set<String> listed = new TreeSet<>();
|
||||
for (String configName : registered) {
|
||||
for (String mixinName : MixinConfig.read(configName).sections().keySet()) {
|
||||
listed.add(configName + '/' + mixinName);
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> uncovered = new TreeSet<>(listed);
|
||||
uncovered.removeAll(audited);
|
||||
uncovered.removeAll(UNAUDITED);
|
||||
assertEquals("mixins neither audited nor deliberately unaudited", Set.of(), uncovered);
|
||||
|
||||
Set<String> unregisteredAudits = new TreeSet<>(audited);
|
||||
unregisteredAudits.removeAll(listed);
|
||||
assertEquals("audited mixins missing from the registered configs", Set.of(), unregisteredAudits);
|
||||
}
|
||||
|
||||
private static Set<String> registeredConfigs() throws IOException {
|
||||
Set<String> configs = new LinkedHashSet<>();
|
||||
String fabric = readResource("fabric.mod.json");
|
||||
if (fabric != null) {
|
||||
for (JsonElement entry : JsonParser.parseString(fabric).getAsJsonObject().getAsJsonArray("mixins")) {
|
||||
configs.add(entry.isJsonObject()
|
||||
? entry.getAsJsonObject().get("config").getAsString()
|
||||
: entry.getAsString());
|
||||
}
|
||||
}
|
||||
String neoforge = readResource("META-INF/neoforge.mods.toml");
|
||||
if (neoforge != null) {
|
||||
Matcher matcher = NEOFORGE_MIXIN_CONFIG.matcher(neoforge);
|
||||
while (matcher.find()) {
|
||||
configs.add(matcher.group(1));
|
||||
}
|
||||
}
|
||||
return configs;
|
||||
}
|
||||
|
||||
private static String readResource(String name) throws IOException {
|
||||
try (InputStream input = ModdedMixinAuditTest.class.getClassLoader().getResourceAsStream(name)) {
|
||||
return input == null ? null : new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private record MixinConfig(String packageName, Map<String, String> sections) {
|
||||
static MixinConfig read(String name) throws IOException {
|
||||
String json = readResource(name);
|
||||
assertNotNull("Missing mixin config resource " + name, json);
|
||||
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
|
||||
Map<String, String> sections = new LinkedHashMap<>();
|
||||
for (String section : new String[]{"mixins", "client", "server"}) {
|
||||
JsonArray entries = root.getAsJsonArray(section);
|
||||
if (entries == null) {
|
||||
continue;
|
||||
}
|
||||
for (JsonElement entry : entries) {
|
||||
sections.put(entry.getAsString(), section);
|
||||
}
|
||||
}
|
||||
return new MixinConfig(root.get("package").getAsString(), sections);
|
||||
}
|
||||
}
|
||||
|
||||
private record MixinClass(Set<String> targets, Set<String> injectors) {
|
||||
static MixinClass read(String className) throws IOException {
|
||||
String resource = className.replace('.', '/') + ".class";
|
||||
byte[] bytes;
|
||||
try (InputStream input = ModdedMixinAuditTest.class.getClassLoader().getResourceAsStream(resource)) {
|
||||
assertNotNull("Missing mixin class " + className, input);
|
||||
bytes = input.readAllBytes();
|
||||
}
|
||||
Set<String> targets = new TreeSet<>();
|
||||
Set<String> injectors = new TreeSet<>();
|
||||
new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) {
|
||||
@Override
|
||||
public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
|
||||
if (!MIXIN_ANNOTATION.equals(descriptor)) {
|
||||
return null;
|
||||
}
|
||||
return new AnnotationVisitor(Opcodes.ASM9) {
|
||||
@Override
|
||||
public AnnotationVisitor visitArray(String name) {
|
||||
return new AnnotationVisitor(Opcodes.ASM9) {
|
||||
@Override
|
||||
public void visit(String ignored, Object value) {
|
||||
if (value instanceof Type type) {
|
||||
targets.add(type.getClassName());
|
||||
} else if (value instanceof String target) {
|
||||
targets.add(target.replace('/', '.'));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
|
||||
String[] exceptions) {
|
||||
return new MethodVisitor(Opcodes.ASM9) {
|
||||
@Override
|
||||
public AnnotationVisitor visitAnnotation(String annotation, boolean visible) {
|
||||
if (INJECTOR_ANNOTATIONS.contains(annotation)) {
|
||||
injectors.add(name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
|
||||
return new MixinClass(targets, injectors);
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.packs.PackType;
|
||||
import net.minecraft.server.packs.metadata.pack.PackMetadataSection;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedPackMetadataTest {
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void metadataLoadsClientResourcesAndServerDataForCurrentMinecraft() throws IOException {
|
||||
try (InputStream resource = ModdedPackMetadataTest.class.getResourceAsStream("/pack.mcmeta")) {
|
||||
assertNotNull(resource);
|
||||
try (InputStreamReader reader = new InputStreamReader(resource, StandardCharsets.UTF_8)) {
|
||||
JsonObject metadata = JsonParser.parseReader(reader).getAsJsonObject();
|
||||
for (PackType type : PackType.values()) {
|
||||
PackMetadataSection section = PackMetadataSection.forPackType(type).codec()
|
||||
.parse(JsonOps.INSTANCE, metadata.get("pack")).getOrThrow();
|
||||
assertEquals("Iris client resources and mod data", section.description().getString());
|
||||
assertTrue(type.toString(), section.supportedFormats().isValueInRange(
|
||||
SharedConstants.getCurrentVersion().packVersion(type)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -9,6 +9,10 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedServerAccess;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedServerLevels;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.EntitySpawnReason;
|
||||
import net.minecraft.world.entity.Mob;
|
||||
import net.minecraft.world.level.ServerLevelAccessor;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
@@ -33,7 +37,7 @@ public class ModdedPlatformPathsTest {
|
||||
|
||||
@Override
|
||||
public String minecraftVersion() {
|
||||
return "26.2";
|
||||
return "26.3";
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -83,6 +87,16 @@ public class ModdedPlatformPathsTest {
|
||||
public boolean canBreakBlock(ServerLevel level, ServerPlayer player, BlockPos position, BlockState state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean checkSpawnPosition(Mob mob, ServerLevelAccessor level, EntitySpawnReason reason) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Biome unmodifiedBiome(Biome biome) {
|
||||
return biome;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
import org.junit.After;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.invocation.InvocationOnMock;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* A configured primary Iris world that did not load used to be skipped silently, leaving every player in the
|
||||
* overworld generating terrain Iris was configured to replace.
|
||||
*/
|
||||
public class ModdedPrimaryWorldRouterTest {
|
||||
private static final int TICK_INTERVAL = 20;
|
||||
private static final String PRIMARY = "iris:primary";
|
||||
|
||||
@After
|
||||
public void clearRouter() {
|
||||
ModdedPrimaryWorldRouter.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aPrimaryWorldThisSaveListsButDidNotRestoreRefusesPlayers() {
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
|
||||
onlinePlayers(server, online);
|
||||
ModdedModConfig routed = config(true, PRIMARY);
|
||||
|
||||
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
|
||||
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
|
||||
MockedStatic<ModdedDimensionRegistryStore> registry = mockStatic(ModdedDimensionRegistryStore.class);
|
||||
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
|
||||
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||
configs.when(ModdedModConfig::get).thenReturn(routed);
|
||||
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
|
||||
registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(persisted());
|
||||
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
|
||||
|
||||
evaluate(server);
|
||||
evaluate(server);
|
||||
|
||||
verify(online, times(2)).disconnect(contains(PRIMARY));
|
||||
log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ")
|
||||
&& line.contains(PRIMARY))), times(1));
|
||||
NativeProtocolPlayer joining = mock(NativeProtocolPlayer.class);
|
||||
assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(joining));
|
||||
verify(joining).disconnect(contains(PRIMARY));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* primaryWorld lives in the instance-wide modded.json, but the dimension it names lives in one save. Another
|
||||
* save, a new world or a reset world folder never had it, so nothing failed there and nothing is refused.
|
||||
*/
|
||||
@Test
|
||||
public void aPrimaryWorldThisSaveNeverHadLeavesRoutingIdleWithOneWarning() {
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
|
||||
onlinePlayers(server, online);
|
||||
ModdedModConfig routed = config(true, PRIMARY);
|
||||
|
||||
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
|
||||
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
|
||||
MockedStatic<ModdedDimensionRegistryStore> registry = mockStatic(ModdedDimensionRegistryStore.class);
|
||||
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
|
||||
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||
configs.when(ModdedModConfig::get).thenReturn(routed);
|
||||
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
|
||||
registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(null);
|
||||
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
|
||||
|
||||
evaluate(server);
|
||||
evaluate(server);
|
||||
|
||||
verify(online, never()).disconnect(anyString());
|
||||
assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
|
||||
log.verify(() -> ModdedIrisLog.warn(argThat((String line) -> line != null && line.contains(PRIMARY))), times(1));
|
||||
log.verify(() -> ModdedIrisLog.error(anyString()), never());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins are processed before the first Iris tick restores persistent dimensions, so a primary world that is
|
||||
* missing then is still loading, not broken.
|
||||
*/
|
||||
@Test
|
||||
public void aPrimaryWorldIsNotJudgedBeforeStartupRestoresDimensions() {
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
|
||||
onlinePlayers(server, online);
|
||||
ModdedModConfig routed = config(true, PRIMARY);
|
||||
|
||||
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
|
||||
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
|
||||
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
|
||||
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||
configs.when(ModdedModConfig::get).thenReturn(routed);
|
||||
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
|
||||
startup.when(ModdedStartup::dimensionsRestored).thenReturn(false);
|
||||
|
||||
evaluate(server);
|
||||
|
||||
verify(online, never()).disconnect(anyString());
|
||||
assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refusalLiftsOnceThePrimaryWorldIsLoadedOrUnconfigured() {
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
NativeWorld overworld = mock(NativeWorld.class);
|
||||
when(server.overworld()).thenReturn(overworld);
|
||||
onlinePlayers(server, mock(NativeProtocolPlayer.class));
|
||||
ModdedModConfig routed = config(true, PRIMARY);
|
||||
ModdedModConfig unconfigured = config(true, "");
|
||||
|
||||
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
|
||||
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
|
||||
MockedStatic<ModdedDimensionRegistryStore> registry = mockStatic(ModdedDimensionRegistryStore.class);
|
||||
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
|
||||
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||
registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(persisted());
|
||||
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
|
||||
configs.when(ModdedModConfig::get).thenReturn(routed);
|
||||
evaluate(server);
|
||||
assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
|
||||
|
||||
configs.when(ModdedModConfig::get).thenReturn(unconfigured);
|
||||
evaluate(server);
|
||||
assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
|
||||
|
||||
configs.when(ModdedModConfig::get).thenReturn(routed);
|
||||
evaluate(server);
|
||||
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(overworld);
|
||||
evaluate(server);
|
||||
assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A registry that cannot be read cannot prove the primary world was never here, so it counts as expected.
|
||||
*/
|
||||
@Test
|
||||
public void anUnreadableRegistryRefusesPlayersRatherThanGuessing() {
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
|
||||
onlinePlayers(server, online);
|
||||
ModdedModConfig routed = config(true, PRIMARY);
|
||||
|
||||
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
|
||||
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
|
||||
MockedStatic<ModdedDimensionRegistryStore> registry = mockStatic(ModdedDimensionRegistryStore.class);
|
||||
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
|
||||
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||
configs.when(ModdedModConfig::get).thenReturn(routed);
|
||||
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
|
||||
registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY))
|
||||
.thenThrow(new IllegalStateException("registry could not be read"));
|
||||
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
|
||||
|
||||
evaluate(server);
|
||||
|
||||
verify(online).disconnect(contains(PRIMARY));
|
||||
assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Routing waits on the primary world's first chunk, which on a cold world also loads the pack and binds the
|
||||
* engine, so it gets the same bounded deadline as /iris tp instead of timing out and retrying every second.
|
||||
*/
|
||||
@Test
|
||||
public void routingWaitsForAColdPrimaryWorldWithinTheTeleportDeadline() {
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
NativeWorld overworld = mock(NativeWorld.class);
|
||||
when(overworld.nativeHandle()).thenReturn(new Object());
|
||||
NativeWorld primary = mock(NativeWorld.class);
|
||||
when(primary.nativeHandle()).thenReturn(new Object());
|
||||
when(server.overworld()).thenReturn(overworld);
|
||||
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
|
||||
when(player.id()).thenReturn(UUID.randomUUID());
|
||||
when(player.isInWorld(overworld)).thenReturn(true);
|
||||
onlinePlayers(server, player);
|
||||
ModdedModConfig routed = config(true, PRIMARY);
|
||||
AtomicReference<NativeWorldTeleport.Destination> requested = new AtomicReference<>();
|
||||
|
||||
long before = System.nanoTime();
|
||||
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
|
||||
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
|
||||
MockedStatic<NativeWorldTeleport> teleport = mockStatic(NativeWorldTeleport.class)) {
|
||||
configs.when(ModdedModConfig::get).thenReturn(routed);
|
||||
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(primary);
|
||||
teleport.when(() -> NativeWorldTeleport.teleport(any(), any())).thenAnswer((InvocationOnMock invocation) -> {
|
||||
requested.set(invocation.getArgument(1));
|
||||
return new CompletableFuture<Boolean>();
|
||||
});
|
||||
|
||||
evaluate(server);
|
||||
}
|
||||
long after = System.nanoTime();
|
||||
|
||||
assertSame(primary, requested.get().world());
|
||||
long deadline = requested.get().deadlineNanos();
|
||||
assertTrue("deadline must outlast a cold first chunk", deadline - before >= TimeUnit.SECONDS.toNanos(60L));
|
||||
assertTrue("deadline must be bounded", deadline - after <= TimeUnit.SECONDS.toNanos(120L));
|
||||
}
|
||||
|
||||
private static ModdedDimensionRegistryStore.PersistentDimension persisted() {
|
||||
return new ModdedDimensionRegistryStore.PersistentDimension(PRIMARY, "overworld", "overworld", 1337L);
|
||||
}
|
||||
|
||||
private static void evaluate(NativeModdedServer server) {
|
||||
for (int tick = 0; tick < TICK_INTERVAL; tick++) {
|
||||
ModdedPrimaryWorldRouter.tick(server);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void onlinePlayers(NativeModdedServer server, NativeProtocolPlayer player) {
|
||||
doAnswer(invocation -> {
|
||||
invocation.getArgument(0, Consumer.class).accept(player);
|
||||
return null;
|
||||
}).when(server).forEachPlayer(any());
|
||||
}
|
||||
|
||||
private static ModdedModConfig config(boolean route, String primary) {
|
||||
ModdedModConfig config = mock(ModdedModConfig.class);
|
||||
when(config.routePlayersToPrimaryWorld()).thenReturn(route);
|
||||
when(config.primaryWorld()).thenReturn(primary);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -70,8 +70,7 @@ public class ModdedSavedBiomeConsumersTest {
|
||||
when(engine.getWorld()).thenReturn(mock(IrisWorld.class));
|
||||
when(engineMantle.getMantle()).thenReturn(mantle);
|
||||
when(mantle.isChunkLoaded(2, -1)).thenReturn(true);
|
||||
when(mantle.getChunk(2, -1)).thenReturn(chunk);
|
||||
when(chunk.use()).thenReturn(chunk);
|
||||
when(mantle.useChunk(2, -1)).thenReturn(chunk);
|
||||
BiomeEnvironment environment = environment();
|
||||
when(engine.getSurfaceBiomeEnvironment(40, -8))
|
||||
.thenThrow(new SavedBiomeUnavailableException("Loading", true))
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.pack.PackValidationRegistry;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||
import org.junit.After;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* A persistent dimension that cannot be re-injected is skipped, which already writes no chunks. The skip used to be
|
||||
* one console line; it has to be a banner and has to reach the operators who can fix it.
|
||||
*/
|
||||
public class ModdedStartupReinjectFailureTest {
|
||||
@After
|
||||
public void resetStartup() {
|
||||
ModdedStartup.reset();
|
||||
PackValidationRegistry.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aDimensionThatFailsToReinjectIsBanneredAndReportedToOperatorsOnJoin() {
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
ModdedDimensionRegistryStore.PersistentDimension moon =
|
||||
new ModdedDimensionRegistryStore.PersistentDimension("iris:moon", "overworld", "overworld", 1337L);
|
||||
IllegalStateException failure = new IllegalStateException("Iris generation history is unusable for 'iris:moon'");
|
||||
|
||||
try (MockedStatic<ModdedDimensionRegistryStore> store = mockStatic(ModdedDimensionRegistryStore.class);
|
||||
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
|
||||
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||
store.when(() -> ModdedDimensionRegistryStore.loadForStartup(server)).thenReturn(List.of(moon));
|
||||
manager.when(() -> ModdedDimensionManager.restorePersistent(server, "iris:moon", "overworld", "overworld", 1337L))
|
||||
.thenThrow(failure);
|
||||
|
||||
ModdedStartup.reinjectPersistentDimensions(server);
|
||||
|
||||
log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.contains("iris:moon")
|
||||
&& line.startsWith("Iris refused"))), times(1));
|
||||
log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ")
|
||||
&& line.contains("generation history is unusable"))), times(1));
|
||||
}
|
||||
|
||||
NativeProtocolPlayer operator = mock(NativeProtocolPlayer.class);
|
||||
when(operator.isGameMaster()).thenReturn(true);
|
||||
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
|
||||
ModdedStartup.warnStartupFailuresTo(operator);
|
||||
ModdedStartup.warnStartupFailuresTo(player);
|
||||
|
||||
verify(operator).sendMessage(argThat((String message) -> message.contains("iris:moon")
|
||||
&& message.contains("generation history is unusable")));
|
||||
verify(player, never()).sendMessage(anyString());
|
||||
|
||||
ModdedStartup.reset();
|
||||
NativeProtocolPlayer laterOperator = mock(NativeProtocolPlayer.class);
|
||||
when(laterOperator.isGameMaster()).thenReturn(true);
|
||||
ModdedStartup.warnStartupFailuresTo(laterOperator);
|
||||
verify(laterOperator, never()).sendMessage(any());
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -14,16 +14,18 @@ import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeResolver;
|
||||
import net.minecraft.world.level.biome.BiomeSource;
|
||||
import net.minecraft.world.level.biome.Climate;
|
||||
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureSet;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.AbstractSpreadingStructurePlacement;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadType;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacementType;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
@@ -166,10 +168,10 @@ public class ModdedStructureSetFrequencyOverridesTest {
|
||||
) {
|
||||
return new RandomSpreadStructurePlacement(
|
||||
Vec3i.ZERO,
|
||||
StructurePlacement.FrequencyReductionMethod.DEFAULT,
|
||||
AbstractSpreadingStructurePlacement.FrequencyReductionMethod.DEFAULT,
|
||||
1F,
|
||||
salt,
|
||||
Optional.of(new StructurePlacement.ExclusionZone(exclusionTarget, 1)),
|
||||
Optional.of(new AbstractSpreadingStructurePlacement.ExclusionZone(exclusionTarget, 1)),
|
||||
spacing,
|
||||
separation,
|
||||
RandomSpreadType.LINEAR);
|
||||
@@ -183,6 +185,7 @@ public class ModdedStructureSetFrequencyOverridesTest {
|
||||
RandomState.class,
|
||||
BiomeSource.class,
|
||||
long.class,
|
||||
ChunkPos.class,
|
||||
long.class,
|
||||
List.class);
|
||||
constructor.setAccessible(true);
|
||||
@@ -190,6 +193,7 @@ public class ModdedStructureSetFrequencyOverridesTest {
|
||||
null,
|
||||
new EmptyBiomeSource(),
|
||||
1L,
|
||||
ChunkPos.ZERO,
|
||||
1L,
|
||||
sets);
|
||||
}
|
||||
@@ -211,8 +215,8 @@ public class ModdedStructureSetFrequencyOverridesTest {
|
||||
|
||||
private static final class EmptyBiomeSource extends BiomeSource {
|
||||
@Override
|
||||
public Holder<Biome> getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) {
|
||||
return null;
|
||||
public BiomeResolver createResolver(Climate.Sampler sampler) {
|
||||
return (x, y, z) -> null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -226,18 +230,14 @@ public class ModdedStructureSetFrequencyOverridesTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class UnsupportedPlacement extends StructurePlacement {
|
||||
private UnsupportedPlacement() {
|
||||
super(Vec3i.ZERO, FrequencyReductionMethod.DEFAULT, 1F, 1, Optional.empty());
|
||||
}
|
||||
|
||||
private static final class UnsupportedPlacement implements StructurePlacement {
|
||||
@Override
|
||||
protected boolean isPlacementChunk(ChunkGeneratorStructureState state, int x, int z) {
|
||||
public boolean isStructureChunk(ChunkGeneratorStructureState state, int x, int z) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StructurePlacementType<?> type() {
|
||||
public com.mojang.serialization.MapCodec<? extends StructurePlacement> codec() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+7
-4
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.objectweb.asm.ClassReader;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -62,9 +63,11 @@ public class ModdedStructureTemplateCacheTest {
|
||||
config = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
assertTrue(config.contains("\"StructureTemplatePaletteConcurrencyMixin\""));
|
||||
assertNotNull(Class.forName(
|
||||
"art.arcane.volmlib.nativelib.minecraft26_2.modded.mixin.StructureTemplatePaletteConcurrencyMixin",
|
||||
false,
|
||||
ModdedStructureTemplateCacheTest.class.getClassLoader()));
|
||||
String mixin = "art/arcane/volmlib/nativelib/minecraft26_2/modded/mixin/StructureTemplatePaletteConcurrencyMixin";
|
||||
try (InputStream input = ModdedStructureTemplateCacheTest.class.getClassLoader()
|
||||
.getResourceAsStream(mixin + ".class")) {
|
||||
assertNotNull(input);
|
||||
assertEquals(mixin, new ClassReader(input).getClassName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public final class ModdedTerrainHeightQueryTest {
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativeQueriesUseLiveHeightsWithoutGeneratingTerrain() {
|
||||
IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class, CALLS_REAL_METHODS);
|
||||
Engine engine = mock(Engine.class);
|
||||
assertNull(generator.resolvedColumn(engine, -1, -33));
|
||||
verifyNoInteractions(engine);
|
||||
|
||||
when(engine.getHeight(-1, -33, true)).thenReturn(12, 18);
|
||||
when(engine.getHeight(-1, -33, false)).thenReturn(24);
|
||||
assertEquals(12, generator.terrainHeight(engine, -1, -33, true, true));
|
||||
assertEquals(18, generator.terrainHeight(engine, -1, -33, true, true));
|
||||
assertEquals(24, generator.terrainHeight(engine, -1, -33, false, true));
|
||||
verify(engine, never()).getComplex();
|
||||
verify(engine, never()).getMode();
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeTileReader;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
|
||||
import art.arcane.iris.generation.block.TileData;
|
||||
@@ -18,6 +19,7 @@ import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.BarrelBlockEntity;
|
||||
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||
import net.minecraft.world.level.block.entity.SignBlockEntity;
|
||||
import net.minecraft.world.level.block.entity.SignTextSlot;
|
||||
import net.minecraft.world.level.block.entity.SpawnerBlockEntity;
|
||||
import net.minecraft.world.level.storage.TagValueInput;
|
||||
import org.junit.BeforeClass;
|
||||
@@ -41,7 +43,7 @@ public class ModdedTileParityTest {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
registries = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY);
|
||||
NativeTileReader nativeTileReader = new NativeTileReader(() -> null);
|
||||
NativeTileReader nativeTileReader = NativeModdedServer.tileReader(() -> null);
|
||||
TileData.bindPlatformReader(in -> ModdedTileData.wrap(nativeTileReader.read(in)));
|
||||
TileData.bindPlatformFactory(ModdedTileData::fromProperties);
|
||||
}
|
||||
@@ -60,9 +62,9 @@ public class ModdedTileParityTest {
|
||||
|
||||
sign.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING, registries, tile.nativeData().payload()));
|
||||
|
||||
assertEquals("Iris", sign.getFrontText().getMessage(0, false).getString());
|
||||
assertEquals("parity", sign.getBackText().getMessage(3, false).getString());
|
||||
assertEquals(DyeColor.BLUE, sign.getFrontText().getColor());
|
||||
assertEquals("Iris", sign.getText(SignTextSlot.FRONT).getMessages(false).get(0).getString());
|
||||
assertEquals("parity", sign.getText(SignTextSlot.BACK).getMessages(false).get(3).getString());
|
||||
assertEquals(DyeColor.BLUE, sign.getText(SignTextSlot.FRONT).getColor());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+15
@@ -11,14 +11,22 @@ import art.arcane.iris.generation.decoration.IrisSurface;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.tags.TagLoader;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
@@ -34,6 +42,13 @@ public class ModdedWaterEntitySpawnTest {
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
for (Block block : BuiltInRegistries.BLOCK) {
|
||||
for (BlockState state : block.getStateDefinition().getPossibleStates()) {
|
||||
state.initCache();
|
||||
}
|
||||
}
|
||||
BuiltInRegistries.BLOCK.prepareTagReload(new TagLoader.LoadResult<>(Registries.BLOCK, Map.of(
|
||||
BlockTags.BLOCKS_MOTION, List.of(Blocks.OAK_FENCE.builtInRegistryHolder())))).apply();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+42
-3
@@ -1,20 +1,31 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockProperty;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NativeRegistryAccessTest {
|
||||
@BeforeClass
|
||||
@@ -25,7 +36,7 @@ public class NativeRegistryAccessTest {
|
||||
|
||||
@Test
|
||||
public void statePropertiesPreserveTypesDefaultsAndSharedGroups() {
|
||||
NativeRegistryAccess access = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(() -> null, () -> null, message -> {}));
|
||||
NativeRegistryAccess access = NativeModdedServer.registryAccess(() -> null, message -> {});
|
||||
Map<String, List<NativeBlockProperty>> properties = access.blockStateProperties();
|
||||
|
||||
assertSame(properties.get("minecraft:stone"), properties.get("minecraft:dirt"));
|
||||
@@ -41,7 +52,7 @@ public class NativeRegistryAccessTest {
|
||||
@Test
|
||||
public void staticRegistriesResolveWithoutAnActiveServerAndDynamicReadsStayEmpty() {
|
||||
List<String> warnings = new ArrayList<>();
|
||||
NativeRegistryAccess access = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(() -> null, () -> null, warnings::add));
|
||||
NativeRegistryAccess access = NativeModdedServer.registryAccess(() -> null, warnings::add);
|
||||
|
||||
assertEquals("minecraft:diamond_sword", access.item("DIAMOND SWORD").key());
|
||||
assertEquals("creature", access.entity("minecraft:cow").spawnCategory());
|
||||
@@ -49,6 +60,34 @@ public class NativeRegistryAccessTest {
|
||||
assertTrue(access.biomeKeys().isEmpty());
|
||||
assertTrue(access.structureKeys().isEmpty());
|
||||
assertTrue(access.enchantmentKeys().isEmpty());
|
||||
assertEquals(List.of("biome", "structure", "enchantment"), warnings);
|
||||
assertTrue(access.lootTableKeys().isEmpty());
|
||||
assertEquals(List.of("biome", "structure", "enchantment", "loot table"), warnings);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dynamicRegistriesFollowServerStartupReloadAndShutdown() {
|
||||
AtomicReference<NativeModdedServer> activeServer = new AtomicReference<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
NativeRegistryAccess access = NativeModdedServer.registryAccess(activeServer::get, warnings::add);
|
||||
assertTrue(access.lootTableKeys().isEmpty());
|
||||
|
||||
MinecraftServer server = mock(MinecraftServer.class, RETURNS_DEEP_STUBS);
|
||||
when(server.registryAccess().lookupOrThrow(Registries.BIOME).listElementIds())
|
||||
.thenAnswer(invocation -> Stream.of(ResourceKey.create(Registries.BIOME, Identifier.parse("example:biome"))));
|
||||
when(server.reloadableRegistries().lookup().lookupOrThrow(Registries.LOOT_TABLE).listElementIds())
|
||||
.thenAnswer(invocation -> Stream.of(ResourceKey.create(Registries.LOOT_TABLE, Identifier.parse("example:initial"))));
|
||||
activeServer.set(NativeModdedServer.fromHandle(server));
|
||||
|
||||
assertEquals(List.of("example:biome"), access.biomeKeys());
|
||||
assertEquals(List.of("example:initial"), access.lootTableKeys());
|
||||
HolderLookup.Provider reloadedRegistries = mock(HolderLookup.Provider.class, RETURNS_DEEP_STUBS);
|
||||
when(reloadedRegistries.lookupOrThrow(Registries.LOOT_TABLE).listElementIds())
|
||||
.thenAnswer(invocation -> Stream.of(ResourceKey.create(Registries.LOOT_TABLE, Identifier.parse("example:reloaded"))));
|
||||
when(server.reloadableRegistries().lookup()).thenReturn(reloadedRegistries);
|
||||
assertEquals(List.of("example:reloaded"), access.lootTableKeys());
|
||||
activeServer.set(null);
|
||||
assertTrue(access.biomeKeys().isEmpty());
|
||||
assertTrue(access.lootTableKeys().isEmpty());
|
||||
assertEquals(List.of("loot table", "biome", "loot table"), warnings);
|
||||
}
|
||||
}
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import art.arcane.iris.localization.IrisLanguage;
|
||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||
import art.arcane.iris.modded.ModdedDimensionManager;
|
||||
import art.arcane.iris.modded.localization.ModdedCommandMessages;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerChunkCache;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.level.Level;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.invocation.InvocationOnMock;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisModdedCommandsTeleportTest {
|
||||
private static final String DIMENSION = "irisworldgen:1";
|
||||
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void teleportWaitsForAColdDestinationWithinABoundedDeadline() {
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
NativeCommandSource source = mock(NativeCommandSource.class);
|
||||
when(source.server()).thenReturn(server);
|
||||
NativeWorld level = mock(NativeWorld.class);
|
||||
when(level.name()).thenReturn(DIMENSION);
|
||||
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
|
||||
when(player.name()).thenReturn("Tester");
|
||||
AtomicReference<NativeWorldTeleport.Destination> requested = new AtomicReference<>();
|
||||
|
||||
long before = System.nanoTime();
|
||||
try (MockedStatic<NativeWorldGenerators> generators = mockStatic(NativeWorldGenerators.class);
|
||||
MockedStatic<ModdedDimensionManager> dimensions = mockStatic(ModdedDimensionManager.class);
|
||||
MockedStatic<NativeWorldTeleport> teleport = mockStatic(NativeWorldTeleport.class)) {
|
||||
generators.when(() -> NativeWorldGenerators.find(level, IrisModdedChunkGenerator.class))
|
||||
.thenReturn(mock(IrisModdedChunkGenerator.class));
|
||||
dimensions.when(() -> ModdedDimensionManager.level(server, DIMENSION)).thenReturn(level);
|
||||
teleport.when(() -> NativeWorldTeleport.teleport(any(), any())).thenAnswer((InvocationOnMock invocation) -> {
|
||||
requested.set(invocation.getArgument(1));
|
||||
return new CompletableFuture<Boolean>();
|
||||
});
|
||||
|
||||
assertEquals(1, IrisModdedCommands.tp(source, level, player));
|
||||
}
|
||||
long after = System.nanoTime();
|
||||
|
||||
long deadline = requested.get().deadlineNanos();
|
||||
assertTrue("deadline must outlast a cold first chunk", deadline - before >= TimeUnit.SECONDS.toNanos(60L));
|
||||
assertTrue("deadline must be bounded", deadline - after <= TimeUnit.SECONDS.toNanos(120L));
|
||||
}
|
||||
|
||||
@Test(timeout = 15_000L)
|
||||
public void expiredTeleportReportsTheCallersDeadline() throws Exception {
|
||||
MinecraftServer server = mock(MinecraftServer.class);
|
||||
when(server.isSameThread()).thenReturn(true);
|
||||
ResourceKey<Level> dimension = ResourceKey.create(Registries.DIMENSION, Identifier.parse(DIMENSION));
|
||||
ServerChunkCache chunks = mock(ServerChunkCache.class);
|
||||
when(chunks.addTicketAndLoadWithRadius(any(), any(), anyInt())).thenReturn(new CompletableFuture<>());
|
||||
ServerLevel serverLevel = mock(ServerLevel.class);
|
||||
when(serverLevel.getServer()).thenReturn(server);
|
||||
when(serverLevel.dimension()).thenReturn(dimension);
|
||||
when(serverLevel.getChunkSource()).thenReturn(chunks);
|
||||
when(server.getLevel(dimension)).thenReturn(serverLevel);
|
||||
ServerPlayer serverPlayer = mock(ServerPlayer.class);
|
||||
when(serverPlayer.getUUID()).thenReturn(UUID.randomUUID());
|
||||
NativeWorld level = mock(NativeWorld.class);
|
||||
when(level.nativeHandle()).thenReturn(serverLevel);
|
||||
|
||||
CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(NativeProtocolPlayer.fromHandle(serverPlayer),
|
||||
new NativeWorldTeleport.Destination(NativeModdedServer.fromHandle(server), level, 8.5D,
|
||||
Double.MIN_VALUE, 8.5D, System.nanoTime() + TimeUnit.SECONDS.toNanos(2L)));
|
||||
|
||||
ExecutionException failure = assertThrows(ExecutionException.class, () -> teleport.get(10L, TimeUnit.SECONDS));
|
||||
assertTrue(failure.getCause() instanceof TimeoutException);
|
||||
assertTrue(failure.getCause().getMessage(), failure.getCause().getMessage().contains("within 2 seconds"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failureReportsItsRootCauseInsteadOfAnUnloadedDimension() {
|
||||
String message = IrisModdedCommands.teleportFailure(DIMENSION, "Tester",
|
||||
new CompletionException(new IllegalStateException("Exception generating new chunk")), true, true);
|
||||
|
||||
assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON,
|
||||
MessageArgument.untrusted("dimensionId", DIMENSION),
|
||||
MessageArgument.untrusted("reason", "Exception generating new chunk")), message);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failureWithoutMessageNamesTheFailureType() {
|
||||
String message = IrisModdedCommands.teleportFailure(DIMENSION, "Tester", new TimeoutException(), true, true);
|
||||
|
||||
assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON,
|
||||
MessageArgument.untrusted("dimensionId", DIMENSION),
|
||||
MessageArgument.untrusted("reason", "TimeoutException")), message);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unloadedDimensionIsReportedOnlyWhenItIsGone() {
|
||||
assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED,
|
||||
MessageArgument.untrusted("dimensionId", DIMENSION)),
|
||||
IrisModdedCommands.teleportFailure(DIMENSION, "Tester", null, false, true));
|
||||
assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_CANCELLED_PLAYER_OFFLINE,
|
||||
MessageArgument.untrusted("dimensionId", DIMENSION),
|
||||
MessageArgument.untrusted("value", "Tester")),
|
||||
IrisModdedCommands.teleportFailure(DIMENSION, "Tester", null, true, false));
|
||||
assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_REFUSED,
|
||||
MessageArgument.untrusted("dimensionId", DIMENSION)),
|
||||
IrisModdedCommands.teleportFailure(DIMENSION, "Tester", null, true, true));
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.volmlib.nativelib.view.WorldView;
|
||||
import org.junit.Test;
|
||||
import org.mockito.invocation.InvocationOnMock;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ModdedVisionOverlayTest {
|
||||
/**
|
||||
* A Vision click can land anywhere in the world, so the destination chunk is usually cold and gets the same
|
||||
* bounded deadline as /iris tp.
|
||||
*/
|
||||
@Test
|
||||
public void teleportWaitsForAColdDestinationWithinTheTeleportDeadline() {
|
||||
WorldView world = mock(WorldView.class);
|
||||
doAnswer((InvocationOnMock invocation) -> {
|
||||
invocation.getArgument(0, Runnable.class).run();
|
||||
return null;
|
||||
}).when(world).execute(any());
|
||||
UUID opener = UUID.randomUUID();
|
||||
AtomicReference<WorldView.Destination> requested = new AtomicReference<>();
|
||||
when(world.teleport(eq(opener), any())).thenAnswer((InvocationOnMock invocation) -> {
|
||||
requested.set(invocation.getArgument(1));
|
||||
return Optional.empty();
|
||||
});
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getMinHeight()).thenReturn(-64);
|
||||
when(engine.getHeight(anyInt(), anyInt(), eq(false))).thenReturn(130);
|
||||
|
||||
long before = System.nanoTime();
|
||||
new ModdedVisionOverlay(new ModdedVisionOverlay.Context(world, engine, opener)).teleport(-3.5D, 17.25D);
|
||||
long after = System.nanoTime();
|
||||
|
||||
WorldView.Destination destination = requested.get();
|
||||
assertEquals(-3.5D, destination.x(), 0D);
|
||||
assertEquals(68D, destination.y(), 0D);
|
||||
assertEquals(17.5D, destination.z(), 0D);
|
||||
assertTrue("deadline must outlast a cold first chunk",
|
||||
destination.deadlineNanos() - before >= TimeUnit.SECONDS.toNanos(60L));
|
||||
assertTrue("deadline must be bounded", destination.deadlineNanos() - after <= TimeUnit.SECONDS.toNanos(120L));
|
||||
}
|
||||
}
|
||||
+259
@@ -2,15 +2,30 @@ package art.arcane.iris.modded.service;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedPlatformWorld;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.generation.mantle.EngineMantle;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.EngineMetrics;
|
||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||
import art.arcane.iris.modded.ModdedLootApplier;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.world.IrisWorld;
|
||||
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
|
||||
import art.arcane.iris.world.storage.matter.TileWrapper;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldMaintenance;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
import art.arcane.volmlib.util.function.Consumer4;
|
||||
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleHooks;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterSlice;
|
||||
import art.arcane.volmlib.util.matter.MatterUpdate;
|
||||
import art.arcane.volmlib.util.matter.slices.UpdateMatter;
|
||||
import net.minecraft.SharedConstants;
|
||||
@@ -24,19 +39,33 @@ import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.material.FlowingFluid;
|
||||
import org.junit.Test;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.MockedConstruction;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.invocation.InvocationOnMock;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.LongConsumer;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockConstruction;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ModdedChunkUpdateServiceTest {
|
||||
@@ -70,6 +99,148 @@ public class ModdedChunkUpdateServiceTest {
|
||||
assertTrue(chunk.deleted());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loadingSavedBiomeDefersThePassAndForgetsOnlyCompletedLoot() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
IrisWorld world = mock(IrisWorld.class);
|
||||
when(world.minHeight()).thenReturn(-64);
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getWorld()).thenReturn(world);
|
||||
when(engine.getMetrics()).thenReturn(new EngineMetrics(8));
|
||||
ServerLevel level = mock(ServerLevel.class);
|
||||
when(level.getMinY()).thenReturn(-64);
|
||||
when(level.getMaxY()).thenReturn(319);
|
||||
when(level.getHeight()).thenReturn(384);
|
||||
when(level.getBlockState(any(BlockPos.class))).thenReturn(Blocks.CHEST.defaultBlockState());
|
||||
ChestUpdateMantleChunk chunk = new ChestUpdateMantleChunk();
|
||||
SavedBiomeUnavailableException loading = new SavedBiomeUnavailableException(
|
||||
"Saved biome information is loading at chunk 0,0. Try again shortly.", true);
|
||||
|
||||
try (MockedStatic<ModdedLootApplier> loot = mockStatic(ModdedLootApplier.class)) {
|
||||
loot.when(() -> ModdedLootApplier.apply(any(), any(), any(), any(), any())).thenAnswer((InvocationOnMock invocation) -> {
|
||||
NativeBlockPoint position = invocation.getArgument(2);
|
||||
if (position.x() == 2) {
|
||||
throw loading;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
SavedBiomeUnavailableException thrown = assertThrows(SavedBiomeUnavailableException.class,
|
||||
() -> new ModdedChunkUpdateService().runUpdatePass(
|
||||
engine, new ModdedPlatformWorld(level), 0, 0, chunk));
|
||||
|
||||
assertSame(loading, thrown);
|
||||
}
|
||||
assertFalse(chunk.deleted());
|
||||
verify(chunk.slice).set(1, 5, 1, null);
|
||||
verify(chunk.slice, never()).set(2, 5, 2, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loadingSavedBiomeLeavesTheChunkUnetchedWithoutAnErrorAndRetriesOnTheNextPass() {
|
||||
ChestUpdateMantleChunk chunk = new ChestUpdateMantleChunk();
|
||||
Engine engine = updateEngine(chunk);
|
||||
NativeWorld level = chestLevel();
|
||||
AtomicInteger loadingAttempts = new AtomicInteger();
|
||||
ModdedChunkUpdateService service = new ModdedChunkUpdateService();
|
||||
|
||||
try (MockedStatic<ModdedLootApplier> loot = mockStatic(ModdedLootApplier.class);
|
||||
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
|
||||
loot.when(() -> ModdedLootApplier.apply(any(), any(), any(), any(), any())).thenAnswer((InvocationOnMock invocation) -> {
|
||||
NativeBlockPoint position = invocation.getArgument(2);
|
||||
if (position.x() == 2 && loadingAttempts.getAndIncrement() == 0) {
|
||||
throw new SavedBiomeUnavailableException("Saved biome information is loading at chunk 0,0.", true);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
service.updateRegeneratedChunk(engine, level, 0, 0);
|
||||
|
||||
assertFalse(chunk.isFlagged(MantleFlag.ETCHED));
|
||||
assertFalse(chunk.isFlagged(MantleFlag.UPDATE));
|
||||
assertFalse(chunk.deleted());
|
||||
logging.verify(() -> IrisLogging.reportError(any(Throwable.class)), never());
|
||||
logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class)), never());
|
||||
|
||||
service.updateRegeneratedChunk(engine, level, 0, 0);
|
||||
|
||||
assertTrue(chunk.isFlagged(MantleFlag.ETCHED));
|
||||
assertTrue(chunk.deleted());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void permanentSavedBiomeUnavailabilityCompletesThePassWithAWarning() {
|
||||
ChestUpdateMantleChunk chunk = new ChestUpdateMantleChunk();
|
||||
Engine engine = updateEngine(chunk);
|
||||
NativeWorld level = chestLevel();
|
||||
String reason = "This chunk has no exact saved Iris biome assignment for generation 3.";
|
||||
|
||||
try (MockedStatic<ModdedLootApplier> loot = mockStatic(ModdedLootApplier.class);
|
||||
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
|
||||
loot.when(() -> ModdedLootApplier.apply(any(), any(), any(), any(), any())).thenAnswer((InvocationOnMock invocation) -> {
|
||||
NativeBlockPoint position = invocation.getArgument(2);
|
||||
if (position.x() == 2) {
|
||||
throw new SavedBiomeUnavailableException(reason, false);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, 0, 0);
|
||||
|
||||
assertTrue(chunk.isFlagged(MantleFlag.ETCHED));
|
||||
assertTrue(chunk.deleted());
|
||||
logging.verify(() -> IrisLogging.warnOnce(anyString(), contains(reason)));
|
||||
logging.verify(() -> IrisLogging.reportError(any(Throwable.class)), never());
|
||||
logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class)), never());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedChunkDoesNotStopTheLevelPassForItsOtherChunks() {
|
||||
IrisSettings previousSettings = IrisSettings.settings;
|
||||
IrisSettings.settings = new IrisSettings();
|
||||
FailingMantleChunk failing = new FailingMantleChunk();
|
||||
ChestUpdateMantleChunk healthy = new ChestUpdateMantleChunk();
|
||||
@SuppressWarnings("unchecked")
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
when(mantle.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
when(mantle.useChunk(0, 0)).thenAnswer((InvocationOnMock invocation) -> failing.use());
|
||||
when(mantle.useChunk(4, 0)).thenAnswer((InvocationOnMock invocation) -> healthy.use());
|
||||
Engine engine = engineWith(mantle);
|
||||
NativeWorld level = chestLevel();
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
when(server.worlds()).thenReturn(List.of(level));
|
||||
IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class);
|
||||
when(generator.engineIfBound()).thenReturn(engine);
|
||||
|
||||
try (MockedStatic<NativeWorldGenerators> generators = mockStatic(NativeWorldGenerators.class);
|
||||
MockedStatic<ModdedLootApplier> loot = mockStatic(ModdedLootApplier.class);
|
||||
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class);
|
||||
MockedConstruction<NativeWorldMaintenance> maintenance = mockConstruction(NativeWorldMaintenance.class,
|
||||
(NativeWorldMaintenance mocked, MockedConstruction.Context context) -> {
|
||||
when(mocked.hasForcedChunks()).thenReturn(true);
|
||||
doAnswer((InvocationOnMock invocation) -> {
|
||||
LongConsumer action = invocation.getArgument(0);
|
||||
action.accept(0L);
|
||||
action.accept(4L);
|
||||
return null;
|
||||
}).when(mocked).forEachForcedChunk(any(LongConsumer.class));
|
||||
})) {
|
||||
generators.when(() -> NativeWorldGenerators.find(level, IrisModdedChunkGenerator.class)).thenReturn(generator);
|
||||
|
||||
new ModdedChunkUpdateService().onServerTick(server);
|
||||
|
||||
assertFalse(failing.isFlagged(MantleFlag.ETCHED));
|
||||
assertTrue(healthy.isFlagged(MantleFlag.ETCHED));
|
||||
assertTrue(healthy.deleted());
|
||||
logging.verify(() -> IrisLogging.errorOnce(anyString(), anyString()));
|
||||
} finally {
|
||||
IrisSettings.settings = previousSettings;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scansWhenPlayersArePresent() {
|
||||
assertTrue(ModdedChunkUpdateService.hasUpdateTargets(true, false));
|
||||
@@ -166,6 +337,94 @@ public class ModdedChunkUpdateServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ChestUpdateMantleChunk extends MantleChunk<Matter> {
|
||||
@SuppressWarnings("unchecked")
|
||||
private final MatterSlice<MatterUpdate> slice = mock(MatterSlice.class);
|
||||
private final Matter section = mock(Matter.class);
|
||||
private boolean deleted;
|
||||
|
||||
private ChestUpdateMantleChunk() {
|
||||
super(1, 0, 0, emptyAdapter(), MantleHooks.NONE);
|
||||
when(section.hasSlice(MatterUpdate.class)).thenReturn(true);
|
||||
when(section.getSlice(MatterUpdate.class)).thenReturn(slice);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Matter get(int index) {
|
||||
return section;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> void iterate(
|
||||
Class<T> type,
|
||||
Consumer4<Integer, Integer, Integer, T> iterator
|
||||
) {
|
||||
if (type == MatterUpdate.class) {
|
||||
iterator.accept(1, 5, 1, type.cast(UpdateMatter.ON));
|
||||
iterator.accept(2, 5, 2, type.cast(UpdateMatter.ON));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteSlices(Class<?> type) {
|
||||
if (type == MatterUpdate.class) {
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean deleted() {
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
private static Engine updateEngine(ChestUpdateMantleChunk chunk) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
when(mantle.isChunkLoaded(0, 0)).thenReturn(true);
|
||||
when(mantle.hasFlag(0, 0, MantleFlag.ETCHED)).thenAnswer((InvocationOnMock invocation) -> chunk.isFlagged(MantleFlag.ETCHED));
|
||||
when(mantle.useChunk(0, 0)).thenAnswer((InvocationOnMock invocation) -> chunk.use());
|
||||
return engineWith(mantle);
|
||||
}
|
||||
|
||||
private static Engine engineWith(Mantle<Matter> mantle) {
|
||||
IrisWorld world = mock(IrisWorld.class);
|
||||
when(world.minHeight()).thenReturn(-64);
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
when(engineMantle.getMantle()).thenReturn(mantle);
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getWorld()).thenReturn(world);
|
||||
when(engine.getMetrics()).thenReturn(new EngineMetrics(8));
|
||||
when(engine.getMantle()).thenReturn(engineMantle);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static NativeWorld chestLevel() {
|
||||
NativeBlockState chest = mock(NativeBlockState.class);
|
||||
when(chest.isStorage()).thenReturn(true);
|
||||
when(chest.isStorageChest()).thenReturn(true);
|
||||
NativeWorld level = mock(NativeWorld.class);
|
||||
when(level.name()).thenReturn("example:updates");
|
||||
when(level.minHeight()).thenReturn(-64);
|
||||
when(level.maxHeight()).thenReturn(320);
|
||||
when(level.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
when(level.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(chest);
|
||||
return level;
|
||||
}
|
||||
|
||||
private static final class FailingMantleChunk extends MantleChunk<Matter> {
|
||||
private FailingMantleChunk() {
|
||||
super(1, 0, 0, emptyAdapter(), MantleHooks.NONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> void iterate(
|
||||
Class<T> type,
|
||||
Consumer4<Integer, Integer, Integer, T> iterator
|
||||
) {
|
||||
throw new IllegalStateException("corrupt " + type.getSimpleName() + " slice");
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static MantleDataAdapter<Matter> emptyAdapter() {
|
||||
return (MantleDataAdapter<Matter>) Proxy.newProxyInstance(
|
||||
|
||||
+10
-6
@@ -6,7 +6,11 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedPlatformWorld;
|
||||
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.levelgen.feature.Feature;
|
||||
import net.minecraft.world.level.levelgen.feature.FallenTreeFeature;
|
||||
import net.minecraft.world.level.levelgen.feature.HugeBrownMushroomFeature;
|
||||
import net.minecraft.world.level.levelgen.feature.HugeRedMushroomFeature;
|
||||
import net.minecraft.world.level.levelgen.feature.OreFeature;
|
||||
import net.minecraft.world.level.levelgen.feature.TreeFeature;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
@@ -32,11 +36,11 @@ public class NativeStructureOperationsTest {
|
||||
|
||||
@Test
|
||||
public void objectFeaturesMatchPaperGroups() {
|
||||
assertEquals("trees", NativeStructureOperations.classifyFeature(Feature.TREE));
|
||||
assertEquals("fallen_trees", NativeStructureOperations.classifyFeature(Feature.FALLEN_TREE));
|
||||
assertEquals("mushrooms", NativeStructureOperations.classifyFeature(Feature.HUGE_BROWN_MUSHROOM));
|
||||
assertEquals("mushrooms", NativeStructureOperations.classifyFeature(Feature.HUGE_RED_MUSHROOM));
|
||||
assertNull(NativeStructureOperations.classifyFeature(Feature.ORE));
|
||||
assertEquals("trees", NativeStructureOperations.classifyFeature(mock(TreeFeature.class)));
|
||||
assertEquals("fallen_trees", NativeStructureOperations.classifyFeature(mock(FallenTreeFeature.class)));
|
||||
assertEquals("mushrooms", NativeStructureOperations.classifyFeature(mock(HugeBrownMushroomFeature.class)));
|
||||
assertEquals("mushrooms", NativeStructureOperations.classifyFeature(mock(HugeRedMushroomFeature.class)));
|
||||
assertNull(NativeStructureOperations.classifyFeature(mock(OreFeature.class)));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -32,17 +32,13 @@ buildscript {
|
||||
maven { url = uri('https://jitpack.io') }
|
||||
}
|
||||
dependencies {
|
||||
Properties libraryProperties = new Properties()
|
||||
file('../../gradle.properties').withInputStream { InputStream stream -> libraryProperties.load(stream) }
|
||||
String libraryCoordinate = providers.gradleProperty('volmLibCoordinate')
|
||||
.getOrElse(libraryProperties.getProperty('volmLibCoordinate'))
|
||||
classpath("com.github.VolmitSoftware.VolmLib:packaging:${libraryCoordinate.tokenize(':').last()}")
|
||||
classpath('com.github.VolmitSoftware.VolmLib:packaging:master-SNAPSHOT')
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'net.neoforged.moddev' version '2.0.141'
|
||||
id 'net.neoforged.moddev' version '2.0.147'
|
||||
alias(libs.plugins.shadow)
|
||||
}
|
||||
|
||||
@@ -56,13 +52,13 @@ interface IrisFileOperations {
|
||||
IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations)
|
||||
|
||||
apply from: file('../../gradle/modded-generation-build-revision.gradle')
|
||||
apply from: file('../../gradle/volmlib-stamp.gradle')
|
||||
|
||||
Properties rootProperties = new Properties()
|
||||
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
|
||||
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.2-26.2'))
|
||||
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2'))
|
||||
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(rootProperties.getProperty('neoForgeVersion', '26.2.0.59'))
|
||||
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:master-SNAPSHOT'))
|
||||
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
|
||||
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(rootProperties.getProperty('neoForgeVersion', '26.3.0.33-beta'))
|
||||
Closure<String> irisArtifactName = { String platform, String targetVersion ->
|
||||
return "Iris v${project.version} [${platform}] ${targetVersion}.jar"
|
||||
}
|
||||
@@ -84,7 +80,6 @@ sourceSets {
|
||||
main {
|
||||
java {
|
||||
srcDir '../modded-common/src/main/java'
|
||||
srcDir '../minecraft-common/src/main/java'
|
||||
srcDir '../client-common/src/main/java'
|
||||
}
|
||||
resources {
|
||||
@@ -153,10 +148,10 @@ dependencies {
|
||||
compileOnly(libs.spigot) {
|
||||
transitive = false
|
||||
}
|
||||
add('bundle', volmLibCoordinate) {
|
||||
add('bundle', 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
|
||||
transitive = false
|
||||
}
|
||||
add('devBundle', volmLibCoordinate) {
|
||||
add('devBundle', 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
|
||||
transitive = false
|
||||
}
|
||||
add('bundle', libs.zip) {
|
||||
@@ -173,6 +168,7 @@ dependencies {
|
||||
libs.asm,
|
||||
libs.lru,
|
||||
libs.caffeine,
|
||||
'com.fulcrumgenomics:jlibdeflate:0.2.0',
|
||||
libs.dom4j,
|
||||
libs.jaxen
|
||||
]
|
||||
@@ -274,6 +270,9 @@ tasks.named('shadowJar', ShadowJar).configure {
|
||||
spec.delete(staleLoaderJars, legacyLoaderJar)
|
||||
}
|
||||
}
|
||||
from(tasks.named('generateVolmLibStamp')) {
|
||||
into('META-INF/volmit')
|
||||
}
|
||||
archiveFileName.set(irisArtifactName('NeoForge', "${minecraftVersion}+${neoForgeVersion}"))
|
||||
configurations = [project.configurations.named('bundle').get()]
|
||||
from(sourceSets.main.output)
|
||||
|
||||
@@ -25,7 +25,7 @@ logoFile = "assets/irisworldgen/icon.png"
|
||||
[[dependencies.irisworldgen]]
|
||||
modId = "neoforge"
|
||||
type = "required"
|
||||
versionRange = "[26.2,26.3)"
|
||||
versionRange = "[26.3,26.4)"
|
||||
ordering = "NONE"
|
||||
side = "BOTH"
|
||||
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package art.arcane.volmlib.nativelib.minecraft26_2.neoforge;
|
||||
|
||||
import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.entity.EntitySpawnReason;
|
||||
import net.minecraft.world.entity.Mob;
|
||||
import net.minecraft.world.level.ServerLevelAccessor;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.event.entity.living.MobSpawnEvent;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NativeNeoForgeSpawnPositionTest {
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chunkGenerationSpawnsFireThePositionCheckEventAndHonorItsDenial() {
|
||||
NativeNeoForgeLoader loader = new NativeNeoForgeLoader(
|
||||
new NativeLoaderOptions("irisworldgen", "treefeller", BooleanSupplier::getAsBoolean));
|
||||
ServerLevelAccessor level = mock(ServerLevelAccessor.class);
|
||||
Mob mob = mock(Mob.class);
|
||||
when(mob.checkSpawnRules(level, EntitySpawnReason.CHUNK_GENERATION)).thenReturn(true);
|
||||
when(mob.checkSpawnObstruction(level)).thenReturn(true);
|
||||
AtomicBoolean deny = new AtomicBoolean(true);
|
||||
AtomicReference<EntitySpawnReason> observed = new AtomicReference<>();
|
||||
Consumer<MobSpawnEvent.PositionCheck> listener = (MobSpawnEvent.PositionCheck event) -> {
|
||||
if (event.getEntity() == mob && deny.get()) {
|
||||
observed.set(event.getSpawnType());
|
||||
event.setResult(MobSpawnEvent.PositionCheck.Result.FAIL);
|
||||
}
|
||||
};
|
||||
NeoForge.EVENT_BUS.addListener(MobSpawnEvent.PositionCheck.class, listener);
|
||||
try {
|
||||
assertFalse(loader.checkSpawnPosition(mob, level, EntitySpawnReason.CHUNK_GENERATION));
|
||||
assertEquals(EntitySpawnReason.CHUNK_GENERATION, observed.get());
|
||||
deny.set(false);
|
||||
assertTrue(loader.checkSpawnPosition(mob, level, EntitySpawnReason.CHUNK_GENERATION));
|
||||
} finally {
|
||||
NeoForge.EVENT_BUS.unregister(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
-118
@@ -43,8 +43,7 @@ buildscript {
|
||||
maven { url = uri('https://jitpack.io') }
|
||||
}
|
||||
dependencies {
|
||||
String volmLibRevision = providers.gradleProperty('volmLibCoordinate').get().tokenize(':').last()
|
||||
classpath("com.github.VolmitSoftware.VolmLib:packaging:${volmLibRevision}")
|
||||
classpath('com.github.VolmitSoftware.VolmLib:packaging:master-SNAPSHOT')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +54,7 @@ plugins {
|
||||
|
||||
apply plugin: 'art.arcane.volmit-packaging'
|
||||
apply from: file('gradle/runtime-relocations.gradle')
|
||||
apply from: file('gradle/volmlib-stamp.gradle')
|
||||
|
||||
interface IrisFileOperations {
|
||||
@Inject
|
||||
@@ -70,21 +70,15 @@ project(':core') {
|
||||
group = 'art.arcane'
|
||||
version = rootProject.version
|
||||
}
|
||||
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse('26.2')
|
||||
// The Bukkit jar retains 26.1.2 and 26.2 alongside 26.3; loader jars stay 26.2-only.
|
||||
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse('26.3')
|
||||
// The Bukkit jar retains 26.1.2 and 26.2 alongside 26.3; loader jars are 26.3-only.
|
||||
String bukkitMinecraftRange = providers.gradleProperty('bukkitMinecraftRange').getOrElse('26.1.2-26.3')
|
||||
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse('0.19.3')
|
||||
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse('26.2-65.1.1')
|
||||
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.2.0.59')
|
||||
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
|
||||
.orElse('com.github.VolmitSoftware:VolmLib:master-SNAPSHOT')
|
||||
.get()
|
||||
String useLocalVolmLib = providers.gradleProperty('useLocalVolmLib').getOrElse('true')
|
||||
String localVolmLibDirectory = providers.gradleProperty('localVolmLibDirectory').getOrNull()
|
||||
boolean useMavenLocal = providers.gradleProperty('useMavenLocal')
|
||||
.orElse(useLocalVolmLib.equalsIgnoreCase('true') ? 'false' : 'true')
|
||||
.map { String value -> value.equalsIgnoreCase('true') }
|
||||
.get()
|
||||
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse('0.19.5')
|
||||
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse('26.3-66.0.8')
|
||||
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.3.0.33-beta')
|
||||
String volmLibShared = 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT'
|
||||
File localVolmLibCheckout = (File) gradle.extensions.extraProperties.get('volmLibDirectory')
|
||||
Map<String, String> volmLibStamp = (Map<String, String>) gradle.extensions.extraProperties.get('volmLibStamp')
|
||||
Closure<String> loaderDisplayVersion = { String loaderVersion ->
|
||||
String coordinatePrefix = "${minecraftVersion}-"
|
||||
if (loaderVersion.startsWith(coordinatePrefix)) {
|
||||
@@ -153,11 +147,11 @@ nmsBindings.each { String key ->
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.github.VolmitSoftware.VolmLib:native-${key}:${volmLibCoordinate.tokenize(':').last()}") {
|
||||
implementation("com.github.VolmitSoftware.VolmLib:native-${key}:master-SNAPSHOT") {
|
||||
transitive = false
|
||||
}
|
||||
compileOnly(project(':core'))
|
||||
compileOnly(volmLibCoordinate) {
|
||||
compileOnly(volmLibShared) {
|
||||
transitive = false
|
||||
}
|
||||
compileOnly(rootProject.libs.annotations)
|
||||
@@ -169,10 +163,10 @@ nmsBindings.each { String key ->
|
||||
}
|
||||
testImplementation(project(':core'))
|
||||
testImplementation(testFixtures(project(':core')))
|
||||
testImplementation("com.github.VolmitSoftware.VolmLib:native-common:${volmLibCoordinate.tokenize(':').last()}") {
|
||||
testImplementation('com.github.VolmitSoftware.VolmLib:native-common:master-SNAPSHOT') {
|
||||
transitive = false
|
||||
}
|
||||
testCompileOnly(volmLibCoordinate) {
|
||||
testCompileOnly(volmLibShared) {
|
||||
transitive = false
|
||||
}
|
||||
testRuntimeOnly(project(':core'))
|
||||
@@ -183,49 +177,32 @@ nmsBindings.each { String key ->
|
||||
|
||||
def included = configurations.create('included')
|
||||
def nativeRuntime = configurations.create('nativeRuntime')
|
||||
def publishedNativeRuntime = configurations.create('publishedNativeRuntime') {
|
||||
canBeConsumed = false
|
||||
transitive = false
|
||||
resolutionStrategy.useGlobalDependencySubstitutionRules = false
|
||||
}
|
||||
repositories.configureEach { repository ->
|
||||
if (repository.name != 'publishedNativeProviders') {
|
||||
repository.content { notForConfigurations('publishedNativeRuntime') }
|
||||
}
|
||||
}
|
||||
repositories.maven {
|
||||
name = 'publishedNativeProviders'
|
||||
url = uri('https://jitpack.io/')
|
||||
content {
|
||||
onlyForConfigurations('publishedNativeRuntime')
|
||||
includeGroup('com.github.VolmitSoftware.VolmLib')
|
||||
}
|
||||
}
|
||||
def nativeRuntimeOutput = layout.buildDirectory.dir('generated/native-runtime')
|
||||
def nativeRuntimeRules = layout.buildDirectory.file('generated/native-runtime-keep.pro')
|
||||
List<String> nativeRuntimeModules = ['native-common'] + nmsBindings.collect { String adapter -> 'native-' + adapter }
|
||||
dependencies {
|
||||
nativeRuntimeModules.each { String module ->
|
||||
add('nativeRuntime', "com.github.VolmitSoftware.VolmLib:${module}:${volmLibCoordinate.tokenize(':').last()}") {
|
||||
add('nativeRuntime', "com.github.VolmitSoftware.VolmLib:${module}:master-SNAPSHOT") {
|
||||
transitive = false
|
||||
}
|
||||
add('publishedNativeRuntime', "com.github.VolmitSoftware.VolmLib:${module}:${volmLibCoordinate.tokenize(':').last()}")
|
||||
}
|
||||
}
|
||||
// The natives PackedJar embeds are the single source for the runtime manifest, the reachability roots and
|
||||
// the ProGuard keep rules, so the shrinker can never prune against a different provider build.
|
||||
Closure<Map<String, File>> embeddedNatives = { ->
|
||||
Map<ComponentIdentifier, File> resolved = new LinkedHashMap<>()
|
||||
nativeRuntime.incoming.artifacts.artifacts.each { artifact ->
|
||||
resolved.put(artifact.id.componentIdentifier, artifact.file)
|
||||
}
|
||||
return PackedJar.resolveNativeArtifacts(resolved)
|
||||
}
|
||||
def generateNativeRuntime = tasks.register('generateNativeRuntime') {
|
||||
inputs.files(publishedNativeRuntime)
|
||||
inputs.property('version', volmLibCoordinate.tokenize(':').last())
|
||||
inputs.property('adapters', nmsBindings)
|
||||
inputs.files(nativeRuntime)
|
||||
inputs.property('modules', nativeRuntimeModules)
|
||||
outputs.dir(nativeRuntimeOutput)
|
||||
outputs.file(nativeRuntimeRules)
|
||||
doLast {
|
||||
Map<ComponentIdentifier, File> resolved = new LinkedHashMap<>()
|
||||
publishedNativeRuntime.incoming.artifacts.artifacts.each { artifact ->
|
||||
resolved.put(artifact.id.componentIdentifier, artifact.file)
|
||||
}
|
||||
Map<String, File> artifacts = NativeRuntimeArtifacts.publishedArtifacts(resolved,
|
||||
nativeRuntimeModules, volmLibCoordinate.tokenize(':').last())
|
||||
NativeRuntimeArtifacts.generate(artifacts, volmLibCoordinate.tokenize(':').last(),
|
||||
NativeRuntimeArtifacts.generate(embeddedNatives(), nativeRuntimeModules,
|
||||
nativeRuntimeOutput.get().asFile, nativeRuntimeRules.get().asFile)
|
||||
}
|
||||
}
|
||||
@@ -240,6 +217,9 @@ tasks.named('jar', Jar).configure {
|
||||
inputs.files(included)
|
||||
dependsOn(generateNativeRuntime)
|
||||
from(nativeRuntimeOutput)
|
||||
from(tasks.named('generateVolmLibStamp')) {
|
||||
into('META-INF/volmit')
|
||||
}
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
includeEmptyDirs = false
|
||||
from(jarJar)
|
||||
@@ -312,35 +292,9 @@ tasks.register('buildBukkit', Copy) {
|
||||
// task therefore has to carry the full inventory of what those builds read: every shared source tree,
|
||||
// every build script and version file they load, and the local VolmLib checkout when the composite
|
||||
// resolves one. Anything missing from that list publishes a stale jar.
|
||||
Closure<File> resolveLocalVolmLibDirectory = { ->
|
||||
String configured = localVolmLibDirectory != null && !localVolmLibDirectory.isBlank()
|
||||
? localVolmLibDirectory
|
||||
: System.getenv('VOLMLIB_DIR')
|
||||
if (configured != null && !configured.isBlank()) {
|
||||
File configuredDirectory = new File(configured)
|
||||
if (!configuredDirectory.isAbsolute()) {
|
||||
configuredDirectory = new File(rootDir, configured)
|
||||
}
|
||||
return configuredDirectory.isDirectory() ? configuredDirectory : null
|
||||
}
|
||||
|
||||
File current = rootDir
|
||||
while (current != null) {
|
||||
File candidate = new File(current, 'VolmLib')
|
||||
if (new File(candidate, 'settings.gradle').isFile() || new File(candidate, 'settings.gradle.kts').isFile()) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
current = current.parentFile
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
List<Object> moddedSharedInputs = []
|
||||
[
|
||||
'adapters/modded-common/src',
|
||||
'adapters/minecraft-common/src',
|
||||
'adapters/client-common/src',
|
||||
'core/src/main',
|
||||
'core/build.gradle',
|
||||
@@ -354,11 +308,11 @@ List<Object> moddedSharedInputs = []
|
||||
'lombok.config',
|
||||
'gradle/libs.versions.toml',
|
||||
'gradle/volmlib-resolution.settings.gradle',
|
||||
'gradle/volmlib-stamp.gradle',
|
||||
'gradle/generation-build-revision.gradle',
|
||||
'gradle/modded-generation-build-revision.gradle',
|
||||
'gradle/native-minecraft-sources.gradle'
|
||||
].each { String path -> moddedSharedInputs.add(layout.projectDirectory.file(path).asFile) }
|
||||
File localVolmLibCheckout = useLocalVolmLib.equalsIgnoreCase('true') ? resolveLocalVolmLibDirectory() : null
|
||||
if (localVolmLibCheckout != null) {
|
||||
moddedSharedInputs.add(fileTree(localVolmLibCheckout) {
|
||||
include('shared/src/main/**', 'native-api/src/main/**', 'native-terrain-api/src/main/**',
|
||||
@@ -369,6 +323,16 @@ if (localVolmLibCheckout != null) {
|
||||
})
|
||||
}
|
||||
|
||||
// The published master-SNAPSHOT is resolved inside the nested build, so no outer input changes when it
|
||||
// moves. Without a local checkout the Exec tasks always run and the nested build decides what is stale.
|
||||
Closure<Void> declareVolmLibInputs = { Exec task ->
|
||||
task.inputs.property('volmLibStamp', volmLibStamp)
|
||||
if (localVolmLibCheckout == null) {
|
||||
task.outputs.upToDateWhen { false }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
Closure<Void> declareModdedJarIo = { Exec task, String adapter, String artifactName, Map<String, String> versions ->
|
||||
task.inputs.files(moddedSharedInputs).withPropertyName('moddedSharedSources')
|
||||
.withPathSensitivity(PathSensitivity.RELATIVE)
|
||||
@@ -378,8 +342,7 @@ Closure<Void> declareModdedJarIo = { Exec task, String adapter, String artifactN
|
||||
layout.projectDirectory.file("adapters/${adapter}/settings.gradle").asFile
|
||||
).withPropertyName('adapterSources')
|
||||
versions.each { String key, String value -> task.inputs.property(key, value) }
|
||||
task.inputs.property('useLocalVolmLib', useLocalVolmLib)
|
||||
task.inputs.property('volmLibCoordinate', volmLibCoordinate)
|
||||
declareVolmLibInputs(task)
|
||||
task.outputs.file(layout.projectDirectory.file("adapters/${adapter}/build/libs/${artifactName}"))
|
||||
return null
|
||||
}
|
||||
@@ -395,8 +358,7 @@ tasks.register('fabricJar', Exec) {
|
||||
"-PirisVersion=${project.version}",
|
||||
"-PminecraftVersion=${minecraftVersion}",
|
||||
"-PfabricLoaderVersion=${fabricLoaderVersion}",
|
||||
"-PuseLocalVolmLib=${useLocalVolmLib}",
|
||||
"-PvolmLibCoordinate=${volmLibCoordinate}"
|
||||
"-PuseLocalVolmLib=${localVolmLibCheckout != null}"
|
||||
]
|
||||
if (localVolmLibCheckout != null) {
|
||||
environment('ORG_GRADLE_PROJECT_localVolmLibDirectory', localVolmLibCheckout.absolutePath)
|
||||
@@ -433,8 +395,7 @@ tasks.register('forgeJar', Exec) {
|
||||
"-PirisVersion=${project.version}",
|
||||
"-PminecraftVersion=${minecraftVersion}",
|
||||
"-PforgeVersion=${forgeVersion}",
|
||||
"-PuseLocalVolmLib=${useLocalVolmLib}",
|
||||
"-PvolmLibCoordinate=${volmLibCoordinate}"
|
||||
"-PuseLocalVolmLib=${localVolmLibCheckout != null}"
|
||||
]
|
||||
if (localVolmLibCheckout != null) {
|
||||
environment('ORG_GRADLE_PROJECT_localVolmLibDirectory', localVolmLibCheckout.absolutePath)
|
||||
@@ -471,8 +432,7 @@ tasks.register('neoforgeJar', Exec) {
|
||||
"-PirisVersion=${project.version}",
|
||||
"-PminecraftVersion=${minecraftVersion}",
|
||||
"-PneoForgeVersion=${neoForgeVersion}",
|
||||
"-PuseLocalVolmLib=${useLocalVolmLib}",
|
||||
"-PvolmLibCoordinate=${volmLibCoordinate}"
|
||||
"-PuseLocalVolmLib=${localVolmLibCheckout != null}"
|
||||
]
|
||||
if (localVolmLibCheckout != null) {
|
||||
environment('ORG_GRADLE_PROJECT_localVolmLibDirectory', localVolmLibCheckout.absolutePath)
|
||||
@@ -529,8 +489,7 @@ tasks.register('moddedTest', Exec) {
|
||||
"-PirisVersion=${project.version}",
|
||||
"-PminecraftVersion=${minecraftVersion}",
|
||||
"-PfabricLoaderVersion=${fabricLoaderVersion}",
|
||||
"-PuseLocalVolmLib=${useLocalVolmLib}",
|
||||
"-PvolmLibCoordinate=${volmLibCoordinate}"
|
||||
"-PuseLocalVolmLib=${localVolmLibCheckout != null}"
|
||||
]
|
||||
if (localVolmLibCheckout != null) {
|
||||
environment('ORG_GRADLE_PROJECT_localVolmLibDirectory', localVolmLibCheckout.absolutePath)
|
||||
@@ -546,8 +505,7 @@ tasks.register('moddedTest', Exec) {
|
||||
inputs.property('irisVersion', project.version.toString())
|
||||
inputs.property('minecraftVersion', minecraftVersion)
|
||||
inputs.property('fabricLoaderVersion', fabricLoaderVersion)
|
||||
inputs.property('useLocalVolmLib', useLocalVolmLib)
|
||||
inputs.property('volmLibCoordinate', volmLibCoordinate)
|
||||
declareVolmLibInputs(it)
|
||||
outputs.dir(layout.projectDirectory.dir('adapters/fabric/build/test-results/test'))
|
||||
mustRunAfter(tasks.named('jar'))
|
||||
mustRunAfter(project(':core').tasks.named('check'))
|
||||
@@ -576,8 +534,17 @@ tasks.named('check').configure {
|
||||
}
|
||||
|
||||
List<String> commonModdedArtifactEntries = [
|
||||
'pack.mcmeta',
|
||||
'com/fulcrumgenomics/jlibdeflate/LibdeflateDecompressor.class',
|
||||
'com/fulcrumgenomics/jlibdeflate/LibdeflateException.class',
|
||||
'native/linux-aarch64/libjlibdeflate.so',
|
||||
'native/linux-x86_64/libjlibdeflate.so',
|
||||
'native/osx-aarch64/libjlibdeflate.dylib',
|
||||
'native/osx-x86_64/libjlibdeflate.dylib',
|
||||
'native/windows-x86_64/jlibdeflate.dll',
|
||||
'META-INF/iris/generation-kernels/catalog.tsv',
|
||||
'META-INF/iris/generation-kernels/abi-1.revision',
|
||||
'META-INF/volmit/volmlib.properties',
|
||||
'META-INF/services/art.arcane.iris.localization.IrisMessageContributor',
|
||||
'art/arcane/iris/generation/runtime/IrisEngine.class',
|
||||
'art/arcane/iris/pack/datapack/DataVersion.class',
|
||||
@@ -693,6 +660,7 @@ List<String> requiredBukkitArtifactEntries = [
|
||||
'slimjar.dat',
|
||||
'slimjar-resolutions.dat',
|
||||
'META-INF/iris/native-runtime.properties',
|
||||
'META-INF/volmit/volmlib.properties',
|
||||
'iris-language-source.properties',
|
||||
'agent.jar',
|
||||
'art/arcane/iris/Iris.class',
|
||||
@@ -718,6 +686,7 @@ tasks.register('verifyBukkitArtifact') {
|
||||
dependsOn('jar')
|
||||
File artifact = bukkitArtifact
|
||||
inputs.file(artifact)
|
||||
inputs.files(nativeRuntime)
|
||||
inputs.files(commandHandlerSources)
|
||||
inputs.files(mantleComponentSources)
|
||||
doLast {
|
||||
@@ -726,7 +695,7 @@ tasks.register('verifyBukkitArtifact') {
|
||||
requiredBukkitArtifactEntries,
|
||||
16,
|
||||
runtimeDownloadedPrefixes)
|
||||
NativeRuntimeArtifacts.verify(artifact, nmsBindings, volmLibCoordinate.tokenize(':').last())
|
||||
NativeRuntimeArtifacts.verify(artifact, nativeRuntimeModules, embeddedNatives())
|
||||
logger.lifecycle("Verified ${artifact.name} packaging and class reference graph")
|
||||
}
|
||||
}
|
||||
@@ -812,11 +781,6 @@ tasks.register('irisDev', Copy) {
|
||||
dependsOn(':core:javadocJar')
|
||||
}
|
||||
|
||||
configurations.configureEach {
|
||||
resolutionStrategy.cacheChangingModulesFor(0, 'seconds')
|
||||
resolutionStrategy.cacheDynamicVersionsFor(0, 'seconds')
|
||||
}
|
||||
|
||||
allprojects {
|
||||
apply plugin: 'java'
|
||||
|
||||
@@ -827,9 +791,6 @@ allprojects {
|
||||
}
|
||||
|
||||
repositories {
|
||||
if (useMavenLocal) {
|
||||
mavenLocal()
|
||||
}
|
||||
mavenCentral()
|
||||
maven { url = uri('https://repo.papermc.io/repository/maven-public/') }
|
||||
maven { url = uri('https://repo.codemc.org/repository/maven-public/') }
|
||||
@@ -983,22 +944,15 @@ project(':adapters:bukkit:nms:v26_3_R1') {
|
||||
canBeResolved = true
|
||||
}
|
||||
dependencies.add(headlessNativeSources.name,
|
||||
"com.github.VolmitSoftware.VolmLib:native-minecraft26_2-sources:${volmLibCoordinate.tokenize(':').last()}")
|
||||
'com.github.VolmitSoftware.VolmLib:native-minecraft26_2-sources:master-SNAPSHOT')
|
||||
List<String> headlessNativeTypes = ['ModdedBlockState', 'ModdedBiome', 'NativeBlockProperties', 'NativeBlockResolver',
|
||||
'NativeBlockFaces', 'NativeStateRotator', 'NativeStateMerger',
|
||||
'NativeRegistryAccess', 'ModdedItem', 'ModdedEntityType', 'NativeTileReader', 'NativeTileData']
|
||||
TaskProvider<Sync> unpackHeadlessNativeSources = tasks.register('unpackHeadlessNativeSources', Sync) {
|
||||
dependsOn(headlessNativeSources)
|
||||
inputs.property('nativeTypes', headlessNativeTypes)
|
||||
inputs.property('nativeVersion', '26.3')
|
||||
from(provider { headlessNativeSources.collect { File artifact -> zipTree(artifact) } }) {
|
||||
headlessNativeTypes.each { String type -> include("art/arcane/volmlib/nativelib/minecraft26_2/modded/${type}.java") }
|
||||
include('versions/26.3/**')
|
||||
eachFile { FileCopyDetails source ->
|
||||
if (source.path.startsWith('versions/26.3/')) {
|
||||
source.path = source.path.substring('versions/26.3/'.length())
|
||||
}
|
||||
}
|
||||
}
|
||||
from(rootProject.file('adapters/modded-common/src/main/java')) {
|
||||
include('art/arcane/iris/modded/ModdedDecoratorHooks.java',
|
||||
@@ -1016,10 +970,10 @@ project(':adapters:bukkit:nms:v26_3_R1') {
|
||||
dependencies {
|
||||
offlineImplementation(project(':probe'))
|
||||
offlineImplementation(project(':core'))
|
||||
offlineImplementation("com.github.VolmitSoftware.VolmLib:native-common:${volmLibCoordinate.tokenize(':').last()}") {
|
||||
offlineImplementation('com.github.VolmitSoftware.VolmLib:native-common:master-SNAPSHOT') {
|
||||
transitive = false
|
||||
}
|
||||
offlineImplementation(volmLibCoordinate) {
|
||||
offlineImplementation(volmLibShared) {
|
||||
transitive = false
|
||||
}
|
||||
}
|
||||
@@ -1132,14 +1086,11 @@ commands:
|
||||
}
|
||||
|
||||
tasks.withType(PackedJar).configureEach {
|
||||
nativeManifestPath.set('META-INF/iris/native-runtime.properties')
|
||||
nativeResourceDirectory.set('META-INF/iris/native/')
|
||||
dependsOn(tasks.named('verifyBukkitArtifact'), configurations.nativeRuntime)
|
||||
nativeArtifacts.set(providers.provider {
|
||||
Map<ComponentIdentifier, File> resolved = new LinkedHashMap<>()
|
||||
configurations.nativeRuntime.incoming.artifacts.artifacts.each { artifact ->
|
||||
resolved.put(artifact.id.componentIdentifier, artifact.file)
|
||||
}
|
||||
PackedJar.resolveNativeArtifacts(resolved)
|
||||
})
|
||||
nativeManifestPath.set(NativeRuntimeArtifacts.MANIFEST)
|
||||
nativeResourceDirectory.set(NativeRuntimeArtifacts.RESOURCES)
|
||||
dependsOn(tasks.named('verifyBukkitArtifact'), nativeRuntime)
|
||||
nativeArtifacts.set(providers.provider { embeddedNatives() })
|
||||
doLast { PackedJar task ->
|
||||
NativeRuntimeArtifacts.verifyPacked(task.outputJar.get().asFile, nativeRuntimeModules, embeddedNatives())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ dependencies {
|
||||
implementation(gradleApi())
|
||||
implementation('org.ow2.asm:asm:9.10.1')
|
||||
implementation('org.apache.commons:commons-compress:1.28.0')
|
||||
implementation('org.tukaani:xz:1.10')
|
||||
implementation('io.papermc.paperweight:paperweight-userdev:2.0.0-beta.21')
|
||||
testImplementation('junit:junit:4.13.2')
|
||||
}
|
||||
|
||||
@@ -85,6 +85,11 @@ public final class ModdedArtifactVerifier {
|
||||
throw new GradleException(artifact.getName() + " is missing " + requiredEntry);
|
||||
}
|
||||
}
|
||||
if (jar.getJarEntry("assets/irisworldgen/lang/en_us.json") != null
|
||||
&& jar.getJarEntry("pack.mcmeta") == null) {
|
||||
throw new GradleException(artifact.getName()
|
||||
+ " ships Iris client language resources without pack.mcmeta");
|
||||
}
|
||||
|
||||
Set<String> bundledRuntimeEntries = new LinkedHashSet<>();
|
||||
Set<String> privateReferenceClasses = new LinkedHashSet<>();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import org.gradle.api.GradleException;
|
||||
import org.gradle.api.artifacts.component.ComponentIdentifier;
|
||||
import org.gradle.api.artifacts.component.ModuleComponentIdentifier;
|
||||
import org.tukaani.xz.XZInputStream;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -11,6 +11,7 @@ import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
@@ -21,46 +22,32 @@ import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
public final class NativeRuntimeArtifacts {
|
||||
public static final String MANIFEST = "META-INF/iris/native-runtime.properties";
|
||||
public static final String RESOURCES = "META-INF/iris/native/";
|
||||
private static final String PAYLOAD = "META-INF/volmit/runtime.jar.xz";
|
||||
private static final String GROUP = "com.github.VolmitSoftware.VolmLib";
|
||||
private static final String NATIVE = "art/arcane/volmlib/nativelib/";
|
||||
|
||||
private NativeRuntimeArtifacts() {
|
||||
}
|
||||
|
||||
public static Map<String, File> publishedArtifacts(Map<ComponentIdentifier, File> resolved,
|
||||
List<String> modules, String version) {
|
||||
Map<String, File> artifacts = new TreeMap<>();
|
||||
for (Map.Entry<ComponentIdentifier, File> entry : resolved.entrySet()) {
|
||||
if (!(entry.getKey() instanceof ModuleComponentIdentifier component)
|
||||
|| !GROUP.equals(component.getGroup()) || !version.equals(component.getVersion())
|
||||
|| !modules.contains(component.getModule())) {
|
||||
throw new GradleException("Native runtime providers must resolve to published coordinates at "
|
||||
+ version + ": " + entry.getKey().getDisplayName());
|
||||
}
|
||||
if (artifacts.put(component.getModule(), entry.getValue()) != null) {
|
||||
throw new GradleException("Duplicate native runtime provider: " + component.getModule());
|
||||
}
|
||||
}
|
||||
if (!artifacts.keySet().equals(new TreeSet<>(modules))) {
|
||||
throw new GradleException("Published native runtime providers differ: " + artifacts.keySet()
|
||||
+ "; expected " + modules);
|
||||
}
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
public static void generate(Map<String, File> artifacts, String version, File output, File rules) throws IOException {
|
||||
public static void generate(Map<String, File> natives, List<String> modules, File output, File rules) throws IOException {
|
||||
requireModules(natives, modules);
|
||||
Map<String, File> providers = new TreeMap<>(natives);
|
||||
List<String> manifest = new ArrayList<>();
|
||||
manifest.add("modules=" + String.join(",", artifacts.keySet()));
|
||||
manifest.add("repository=https://jitpack.io/");
|
||||
manifest.add("modules=" + String.join(",", providers.keySet()));
|
||||
manifest.add("storage=embedded");
|
||||
Set<String> retained = new TreeSet<>();
|
||||
for (Map.Entry<String, File> artifact : artifacts.entrySet()) {
|
||||
String module = artifact.getKey();
|
||||
manifest.add(module + ".coordinate=" + GROUP + ":" + module + ":" + version);
|
||||
manifest.add(module + ".sha256=" + digest(artifact.getValue()));
|
||||
try (JarFile jar = new JarFile(artifact.getValue())) {
|
||||
for (Map.Entry<String, File> provider : providers.entrySet()) {
|
||||
String module = provider.getKey();
|
||||
String digest = digest(Files.readAllBytes(provider.getValue().toPath()));
|
||||
manifest.add(module + ".coordinate=" + GROUP + ":" + module + ":embedded-" + digest);
|
||||
manifest.add(module + ".sha256=" + digest);
|
||||
try (JarFile jar = new JarFile(provider.getValue())) {
|
||||
Enumeration<JarEntry> entries = jar.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
JarEntry entry = entries.nextElement();
|
||||
@@ -92,31 +79,15 @@ public final class NativeRuntimeArtifacts {
|
||||
Files.write(rules.toPath(), keep, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public static void verify(File artifact, List<String> adapters, String version) throws IOException {
|
||||
public static void verify(File artifact, List<String> modules, Map<String, File> natives) throws IOException {
|
||||
requireModules(natives, modules);
|
||||
try (JarFile jar = new JarFile(artifact)) {
|
||||
JarEntry entry = jar.getJarEntry(MANIFEST);
|
||||
if (entry == null) {
|
||||
throw new GradleException("Missing native runtime dependency manifest");
|
||||
}
|
||||
Properties manifest = new Properties();
|
||||
try (InputStream input = jar.getInputStream(entry)) {
|
||||
manifest.load(input);
|
||||
}
|
||||
Set<String> expected = new TreeSet<>();
|
||||
expected.add("native-common");
|
||||
for (String adapter : adapters) {
|
||||
expected.add("native-" + adapter);
|
||||
}
|
||||
Set<String> actual = new TreeSet<>(List.of(manifest.getProperty("modules", "").split(",")));
|
||||
if (!expected.equals(actual)) {
|
||||
throw new GradleException("Native dependency modules differ: " + actual + "; expected " + expected);
|
||||
}
|
||||
for (String module : expected) {
|
||||
String coordinate = GROUP + ":" + module + ":" + version;
|
||||
if (!coordinate.equals(manifest.getProperty(module + ".coordinate"))
|
||||
|| !manifest.getProperty(module + ".sha256", "").matches("[0-9a-f]{64}")) {
|
||||
throw new GradleException("Invalid native runtime dependency: " + module);
|
||||
}
|
||||
verifyManifest(input.readAllBytes(), natives);
|
||||
}
|
||||
Enumeration<JarEntry> entries = jar.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
@@ -128,6 +99,82 @@ public final class NativeRuntimeArtifacts {
|
||||
}
|
||||
}
|
||||
|
||||
public static void verifyPacked(File artifact, List<String> modules, Map<String, File> natives) throws IOException {
|
||||
requireModules(natives, modules);
|
||||
Map<String, byte[]> contents;
|
||||
try (JarFile jar = new JarFile(artifact)) {
|
||||
JarEntry payload = jar.getJarEntry(PAYLOAD);
|
||||
if (payload == null) {
|
||||
contents = read(Files.newInputStream(artifact.toPath()));
|
||||
} else {
|
||||
try (InputStream input = new XZInputStream(jar.getInputStream(payload))) {
|
||||
contents = read(new ByteArrayInputStream(input.readAllBytes()));
|
||||
}
|
||||
JarEntry outer = jar.getJarEntry(MANIFEST);
|
||||
byte[] embedded = contents.get(MANIFEST);
|
||||
if (outer != null && embedded != null) {
|
||||
try (InputStream input = jar.getInputStream(outer)) {
|
||||
if (!Arrays.equals(input.readAllBytes(), embedded)) {
|
||||
throw new GradleException("Packed native runtime manifests differ");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
byte[] manifest = contents.get(MANIFEST);
|
||||
if (manifest == null) {
|
||||
throw new GradleException("Missing native runtime dependency manifest");
|
||||
}
|
||||
verifyManifest(manifest, natives);
|
||||
for (String module : natives.keySet()) {
|
||||
byte[] provider = contents.get(RESOURCES + module + ".jar");
|
||||
if (provider == null) {
|
||||
throw new GradleException("Missing embedded native provider: " + module);
|
||||
}
|
||||
if (!digest(provider).equals(digest(Files.readAllBytes(natives.get(module).toPath())))) {
|
||||
throw new GradleException("Embedded native provider differs from the resolved build: " + module);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void verifyManifest(byte[] bytes, Map<String, File> natives) throws IOException {
|
||||
Properties manifest = new Properties();
|
||||
manifest.load(new ByteArrayInputStream(bytes));
|
||||
Set<String> actual = new TreeSet<>(List.of(manifest.getProperty("modules", "").split(",")));
|
||||
if (!natives.keySet().equals(actual)) {
|
||||
throw new GradleException("Native dependency modules differ: " + actual + "; expected " + natives.keySet());
|
||||
}
|
||||
if (!"embedded".equals(manifest.getProperty("storage"))) {
|
||||
throw new GradleException("Native runtime providers must be embedded: " + manifest.getProperty("storage"));
|
||||
}
|
||||
for (Map.Entry<String, File> provider : natives.entrySet()) {
|
||||
String module = provider.getKey();
|
||||
String digest = digest(Files.readAllBytes(provider.getValue().toPath()));
|
||||
if (!digest.equals(manifest.getProperty(module + ".sha256"))
|
||||
|| !(GROUP + ":" + module + ":embedded-" + digest).equals(manifest.getProperty(module + ".coordinate"))) {
|
||||
throw new GradleException("Native runtime manifest does not describe the embedded provider: " + module);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireModules(Map<String, File> natives, List<String> modules) {
|
||||
if (!natives.keySet().equals(new TreeSet<>(modules))) {
|
||||
throw new GradleException("Embedded native providers differ: " + natives.keySet() + "; expected " + modules);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, byte[]> read(InputStream source) throws IOException {
|
||||
Map<String, byte[]> contents = new TreeMap<>();
|
||||
try (ZipInputStream archive = new ZipInputStream(source)) {
|
||||
for (ZipEntry entry = archive.getNextEntry(); entry != null; entry = archive.getNextEntry()) {
|
||||
if (!entry.isDirectory()) {
|
||||
contents.put(entry.getName(), archive.readAllBytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
private static boolean implementation(String name) {
|
||||
if (!name.startsWith(NATIVE)) {
|
||||
return false;
|
||||
@@ -137,9 +184,9 @@ public final class NativeRuntimeArtifacts {
|
||||
|| relative.matches("v[0-9_]+R[0-9]+/.*");
|
||||
}
|
||||
|
||||
private static String digest(File file) throws IOException {
|
||||
private static String digest(byte[] bytes) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file.toPath())));
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ public final class GenerationRevisionScope {
|
||||
"core/src/main/java",
|
||||
"spi/src/main/java",
|
||||
"adapters/bukkit/plugin/src/main/java",
|
||||
"adapters/minecraft-common/src/main/java",
|
||||
"adapters/modded-common/src/main/java",
|
||||
"adapters/client-common/src/main/java",
|
||||
"adapters/fabric/src/main/java",
|
||||
@@ -29,6 +28,10 @@ public final class GenerationRevisionScope {
|
||||
"core/src/main/java/art/arcane/iris/world/safeguard/Diagnostic.java",
|
||||
"core/src/main/java/art/arcane/iris/world/safeguard/ValueWithDiagnostics.java",
|
||||
"core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashRenderer.java",
|
||||
"core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashComposer.java",
|
||||
"core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashPackScanner.java",
|
||||
"adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandDebug.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandDeveloper.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandPregen.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandIris.java",
|
||||
|
||||
@@ -33,6 +33,31 @@ public class GenerationBuildRevisionTest {
|
||||
GenerationBuildRevision.fingerprint(GenerationBuildRevision.capture(fixture.options(), fixture.dependencies())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dependencyReleaseNamesAndMavenMetadataDoNotChangeGenerationIdentity() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
String original = GenerationBuildRevision.fingerprint(GenerationBuildRevision.read(fixture.revision()));
|
||||
Path released = fixture.root().resolve("math-2.0.jar");
|
||||
try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(released))) {
|
||||
for (Map.Entry<String, String> entry : Map.of(
|
||||
"META-INF/MANIFEST.MF", "Manifest-Version: 1.0\r\nImplementation-Version: 2.0\r\nBuild-Time: 12345\r\n\r\n",
|
||||
"META-INF/maven/example/math/pom.properties", "groupId=example\nartifactId=math\nversion=2.0\n",
|
||||
"META-INF/maven/example/math/pom.xml", "<project><version>2.0</version></project>",
|
||||
"example/Math.class", "same code").entrySet()) {
|
||||
ZipEntry archiveEntry = new ZipEntry(entry.getKey());
|
||||
archiveEntry.setTime(1_700_000_000_000L);
|
||||
output.putNextEntry(archiveEntry);
|
||||
output.write(entry.getValue().getBytes(StandardCharsets.UTF_8));
|
||||
output.closeEntry();
|
||||
}
|
||||
}
|
||||
Map<String, Path> dependencies = Map.of("math", released);
|
||||
|
||||
GenerationBuildRevision.verifySnapshot(fixture.root(), fixture.revision(), dependencies);
|
||||
assertEquals(original, GenerationBuildRevision.fingerprint(
|
||||
GenerationBuildRevision.capture(fixture.options(), dependencies)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsChangedAddedAndDeletedGenerationSources() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
|
||||
@@ -44,6 +44,25 @@ public class GenerationRevisionScopeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void releaseVersionAndExpandedPluginMetadataPreserveTheGenerationFingerprint() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
String original = GenerationBuildRevision.fingerprint(GenerationBuildRevision.read(fixture.revision()));
|
||||
for (String version : List.of("4.1.2-26.2", "4.1.3-26.2", "4.1.3-26.2-build-123")) {
|
||||
write(fixture.root().resolve("gradle.properties"), "irisVersion=" + version + "\n");
|
||||
write(fixture.root().resolve("adapters/bukkit/plugin/src/main/resources/paper-plugin.yml"),
|
||||
"name: Iris\nversion: " + version + "\n");
|
||||
write(fixture.root().resolve("adapters/bukkit/plugin/build/resources/main/paper-plugin.yml"),
|
||||
"name: Iris\nversion: " + version + "\n");
|
||||
write(fixture.root().resolve("adapters/fabric/build/resources/main/fabric.mod.json"),
|
||||
"{\"id\":\"iris\",\"version\":\"" + version + "\"}\n");
|
||||
|
||||
GenerationBuildRevision.verifySnapshot(fixture.root(), fixture.revision(), fixture.dependencies());
|
||||
assertEquals(original, GenerationBuildRevision.fingerprint(
|
||||
GenerationBuildRevision.capture(fixture.options(), fixture.dependencies())));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generationChangesProduceANewBuildRevision() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
@@ -88,6 +107,10 @@ public class GenerationRevisionScopeTest {
|
||||
}
|
||||
assertFalse(manifest.sources().containsKey(ENGINE_ROOT + "EngineDiagnostics.java"));
|
||||
assertFalse(manifest.sources().containsKey(COMMAND_ROOT + "CommandIris.java"));
|
||||
assertFalse(manifest.sources().containsKey(COMMAND_ROOT + "CommandDebug.java"));
|
||||
assertFalse(manifest.sources().containsKey("core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashComposer.java"));
|
||||
assertFalse(manifest.sources().containsKey("core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashPackScanner.java"));
|
||||
assertFalse(manifest.sources().containsKey("adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -44,6 +44,28 @@ public class ModdedArtifactVerifierTest {
|
||||
ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsClientLanguagesWithoutPackMetadata() throws Exception {
|
||||
Map<String, byte[]> entries = validEntries();
|
||||
entries.put("assets/irisworldgen/lang/en_us.json", "{}".getBytes(StandardCharsets.UTF_8));
|
||||
File artifact = createArtifact(entries);
|
||||
|
||||
GradleException failure = assertThrows(GradleException.class,
|
||||
() -> ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES));
|
||||
assertTrue(failure.getMessage().contains("client language resources without pack.mcmeta"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsClientLanguagesWithPackMetadata() throws Exception {
|
||||
Map<String, byte[]> entries = validEntries();
|
||||
entries.put("assets/irisworldgen/lang/en_us.json", "{}".getBytes(StandardCharsets.UTF_8));
|
||||
entries.put("pack.mcmeta", ("{\"pack\":{\"description\":\"Iris\","
|
||||
+ "\"min_format\":[88,0],\"max_format\":[107,1]}}")
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
ModdedArtifactVerifier.verify(createArtifact(entries), REQUIRED_ENTRIES);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsRelocatedRuntimeReference() throws Exception {
|
||||
Map<String, byte[]> entries = validEntries();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user