Port Skylands terrain filter (#47)
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-10-04 15:54:54 -04:00
1 parent a9040e8712
commit 66b4c56eb6
12 files changed
+217 -10

No files matched your search

+19 -1
View File
@@ -56,7 +56,7 @@ An unpaired Block or Fluid page chooses a compatible feature from the Age seed:
full blocks can become Rocks or Floating Islands; source fluids can become one
of the lake features. Flat and Void terrain can only use Floating Islands from
this pool, while Cave terrain has no compatible material feature. A page-free
random Age rolls zero, one, or two distinct compatible features, choosing each
random Age rolls zero, one, or two distinct compatible material features, choosing each
material from the Block or Fluid Pages pool. The choices are saved on the Age
definition, so opening the Age again does not reroll them.
Repeated Block/Fluid and Feature pages remain separate entries in authored
@@ -73,6 +73,24 @@ common than in the first test build (one source chunk in 128 instead of 64).
Sky Islands should retain its terrain islands and gain occasional additional
feature islands. Try Cave terrain too; the Binder should reject that pairing.
**Skylands** is a separate subtractive feature, not the Sky Islands terrain
profile or the additive Floating Islands feature. Following Legacy's primer
filter, it removes base terrain below a smooth, seven-octave cutoff around
world Y=76 and removes liquid above the cutoff. The selected terrain still
generates first; Floating Islands and Tendrils are added afterward, so they
can coexist with Skylands. Skylands has no Block-page modifier. It can be
authored on every terrain, though Flat loses its low ground and Void has no
base terrain to change. Page-free random noise Ages have a seeded one-in-eight
chance to gain Skylands; random Flat and Void Ages do not roll this ineffective
filter. The cloud-height and horizon changes from Legacy are not included yet.
For an in-game check, compare Standard terrain with and without a Skylands
page. Skylands should leave upper hills suspended over open space, while a
Floating Islands page instead adds isolated high islands without removing the
ground below. Try Sky Islands terrain plus Skylands to confirm the page filters
the terrain rather than replacing its generator. A book with both Skylands and
Floating Islands should retain the added feature islands above the cutoff.
The **Tendrils** feature adapts Legacy's winding, branching cave-node geometry
into solid tubes. It is additive on all current terrain families, including
Flat, Void, and Cave; it does not change biome selection. A preceding full-
+1 -1
View File
@@ -18,7 +18,7 @@ Legacy does **not** reject a book just because it omits terrain, biome, weather,
| `WeatherNorm`, `Fast`, `Slow`, `Off`, `On`, `Cloudy`, `Rain`, `Snow`, `Storm` | `AgeTraits.Weather` has matching names except Legacy `On` maps to `ALWAYS` | Compile weather semantics separately from biome selection. `Snow` must remain deferred until optional season integration; do not silently substitute another weather mode. |
| Suns, moons, starfields; angle, phase, period, color/gradient/sunset modifiers | One `celestialPeriod`, `celestialAngle`, `celestialPhase` on `AgeTraits` | Compile only the representable single-cycle subset first. Multiple celestial bodies, independent periods, custom stars, gradients and sunsets need a richer definition and renderer, not flattening into one value. |
| Sky/fog/cloud/grass/foliage/water colors, natural variants, horizon, rainbow | Existing sky/dimension visuals and preview | Require an explicit modern client/biome-color design; metadata-only symbol pages must not pretend to change rendering. |
| Caves, ores, huge trees, lakes, floating islands, tendrils, skylands, spheres, spikes, crystals; vanilla villages, strongholds, mineshafts, dungeons, fortresses, obelisks, fissures | Current cave profile, vanilla data-pack generation, and authored Floating Islands/Tendrils | Compile supported vanilla placements and port bespoke shapes explicitly. Remaining bespoke structures and polish stay in later issues. |
| Caves, ores, huge trees, lakes, floating islands, tendrils, skylands, spheres, spikes, crystals; vanilla villages, strongholds, mineshafts, dungeons, fortresses, obelisks, fissures | Current cave profile, vanilla data-pack generation, and authored Floating Islands/Tendrils/Skylands | Compile supported vanilla placements and port bespoke shapes explicitly. Remaining bespoke structures and polish stay in later issues. |
| `EnvAccel`, explosions, lightning, meteors, scorching; PvP and dummy suppression pages | No equivalent Age-definition effect | Defer environmental hazards/instability effects to milestone 6, PvP rule to a separately scoped server policy; retain authored IDs without fake behavior. |
The Legacy symbol list and page names come from [ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L129-L240) and [SymbolRules](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/SymbolRules.java#L19-L137). Rivencraft's [catalog](../src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java) currently holds display metadata only; its built-in set is just standard terrain, native biomes, normal weather, normal lighting, normal time, and caves. [OrderedWriting](../src/main/java/net/dnorberg/rivencraft/writing/OrderedWriting.java) preserves page order, but no compiler yet connects it to [AgeTraits](../src/main/java/net/dnorberg/rivencraft/book/AgeTraits.java) or [Age profiles](AGE_PROFILES.md).
+1 -1
View File
@@ -36,7 +36,7 @@ mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.4.12.0
mod_version=0.4.13.0
immersive_portals_version=6.0.7
terrablender_version=4.1.0.8
serene_seasons_version_id=B6nTjh4b
@@ -28,6 +28,7 @@ public final class AgeFeatureSelection {
id("rocks"), id("floating_islands"), id("tendrils"));
private static final List<ResourceLocation> FLUID_FEATURES = List.of(
id("surface_lakes"), id("deep_lakes"), id("lava_lakes"));
private static final long RANDOM_SKYLANDS_SALT = 0x534B594C414E4453L;
private AgeFeatureSelection() {}
@@ -139,7 +140,7 @@ public final class AgeFeatureSelection {
return List.copyOf(resolved);
}
/** Page-free Ages roll up to two compatible material/feature pairs once at creation. */
/** Page-free Ages roll up to two material features, plus rare Skylands, once at creation. */
public static List<Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile, RegistryAccess registries) {
Random random = new Random(ageSeed ^ 0x4D4154455249414CL);
int count = random.nextInt(3);
@@ -157,6 +158,9 @@ public final class AgeFeatureSelection {
if (!materials.isEmpty()) choices.add(new Choice(feature,
Optional.of(materials.get(random.nextInt(materials.size())))));
}
if (profile.terrain() == AgeRuntimeProfile.Terrain.NOISE
&& Math.floorMod(mix(ageSeed ^ RANDOM_SKYLANDS_SALT), 8) == 0)
choices.add(new Choice(id("skylands"), Optional.empty()));
return List.copyOf(choices);
}
@@ -181,6 +185,7 @@ public final class AgeFeatureSelection {
}
public static String error(ResourceLocation choice, AgeRuntimeProfile profile, RegistryAccess registries) {
if (skylands(choice)) return null;
if (tendrils(choice)) return null;
if (floatingIslands(choice)) {
if (AgeBiomeSelection.isCave(profile))
@@ -264,6 +269,10 @@ public final class AgeFeatureSelection {
return choice.equals(id("tendrils"));
}
public static boolean skylands(ResourceLocation choice) {
return choice.equals(id("skylands"));
}
public static boolean supportsSurfaceVegetation(ResourceLocation id, RegistryAccess registries) {
var biome = registries.lookupOrThrow(Registries.BIOME)
.get(ResourceKey.create(Registries.BIOME, id));
@@ -68,12 +68,18 @@ public final class AgePersistenceGameTests {
helper.assertTrue(pageFree.plan() == null && panelOnly.plan() == null,
"Page-free and Link-Panel-only books must retain the random Age path");
for (AgeDefinition age : List.of(pageFree, panelOnly)) {
helper.assertTrue(age.randomFeatures().size() <= 2,
helper.assertTrue(age.randomFeatures().stream().filter(choice -> choice.material().isPresent())
.count() <= 2,
"A random Age may roll at most two material features");
helper.assertTrue(age.randomFeatures().stream().filter(choice ->
AgeFeatureSelection.skylands(choice.id())).count() <= 1,
"A random Age may roll at most one Skylands filter");
for (AgeFeatureSelection.Choice choice : age.randomFeatures()) {
helper.assertTrue(choice.material().isPresent()
helper.assertTrue(AgeFeatureSelection.skylands(choice.id())
? choice.material().isEmpty()
: choice.material().isPresent()
&& AgeFeatureSelection.materialError(choice) == null,
"Random features must each carry a valid block or fluid material");
"Random material features need valid blocks or fluids; Skylands needs none");
}
}
AgeRegistrySavedData registry = AgeRegistrySavedData.get(helper.getLevel());
@@ -32,7 +32,7 @@ public final class FeatureWritingGameTests {
Set<String> pages = Set.of("feature/caves", "feature/flowers", "feature/trees",
"feature/huge_trees", "feature/rocks", "feature/lava_lakes",
"feature/surface_lakes", "feature/deep_lakes", "feature/ravines",
"feature/floating_islands", "feature/tendrils");
"feature/floating_islands", "feature/tendrils", "feature/skylands");
helper.assertTrue(PageItem.bundledCreativeSymbols().containsAll(pages),
"Supported feature pages must appear in the creative and JEI symbol feed");
for (String path : pages) {
@@ -60,7 +60,8 @@ public final class FeatureWritingGameTests {
for (AgeFeatureSelection.Choice choice : AgeFeatureSelection.resolved(plan)) {
if (AgeFeatureSelection.caves(choice.id()) || AgeFeatureSelection.ravines(choice.id())
|| AgeFeatureSelection.floatingIslands(choice.id())
|| AgeFeatureSelection.tendrils(choice.id())) continue;
|| AgeFeatureSelection.tendrils(choice.id())
|| AgeFeatureSelection.skylands(choice.id())) continue;
var placement = AgeFeatureSelection.placedFeature(choice.id());
helper.assertTrue(registries.lookupOrThrow(Registries.PLACED_FEATURE)
.get(ResourceKey.create(Registries.PLACED_FEATURE, placement)).isPresent(),
@@ -178,6 +179,41 @@ public final class FeatureWritingGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void skylandsFiltersBaseTerrainIndependentlyOfIslandFeatures(GameTestHelper helper) {
var registries = helper.getLevel().registryAccess();
for (String terrain : List.of("standard", "amplified", "island", "flat", "void", "cave")) {
OrderedWriting writing = new OrderedWriting(List.of(panel(), page("terrain/" + terrain),
page("feature/skylands"), page("feature/floating_islands")));
if (terrain.equals("cave")) {
writing = new OrderedWriting(List.of(panel(), page("terrain/cave"),
page("feature/skylands")));
}
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 707L,
AgeGenerationProfiles.STANDARD, AgeTraits.select(707L), registries);
List<AgeFeatureSelection.Choice> resolved = AgeFeatureSelection.resolved(plan);
helper.assertValueEqual(resolved.getFirst(), new AgeFeatureSelection.Choice(
id("skylands"), Optional.empty()),
"Skylands must be a separate material-free feature on " + terrain);
helper.assertValueEqual(AgeFeatureSelection.resolved(AgeGenerationPlan.load(plan.save())), resolved,
"Skylands and any Floating Islands page must survive a plan reload");
}
rejects(helper, new OrderedWriting(List.of(panel(), page("terrain/standard"),
block("stone"), page("feature/skylands"))), "cannot consume a material page");
List<AgeFeatureSelection.Choice> randomNoise = AgeFeatureSelection.randomChoices(7L,
AgeRuntimeProfiles.resolve(id("standard")), registries);
helper.assertTrue(randomNoise.stream().anyMatch(choice -> AgeFeatureSelection.skylands(choice.id())
&& choice.material().isEmpty()),
"A page-free random noise Age must be able to roll material-free Skylands");
for (String terrain : List.of("flat", "void")) {
helper.assertTrue(AgeFeatureSelection.randomChoices(7L,
AgeRuntimeProfiles.resolve(id(terrain)), registries).stream()
.noneMatch(choice -> AgeFeatureSelection.skylands(choice.id())),
"Random " + terrain + " Ages must not roll a filter without useful upper terrain");
}
helper.succeed();
}
@GameTest(template = "empty")
public static void voidBiomesAreIndependentOfFloatingIslands(GameTestHelper helper) {
OrderedWriting voidDefault = new OrderedWriting(List.of(panel(), page("terrain/void")));
@@ -76,8 +76,13 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
StructureManager structures, ChunkAccess chunk) {
CompletableFuture<ChunkAccess> filled = super.fillFromNoise(blender, random, structures, chunk);
if (features.stream().noneMatch(choice -> AgeFeatureSelection.floatingIslands(choice.id())
|| AgeFeatureSelection.tendrils(choice.id()))) return filled;
|| AgeFeatureSelection.tendrils(choice.id())
|| AgeFeatureSelection.skylands(choice.id()))) return filled;
return filled.thenApply(result -> {
// Legacy's Skylands primer filter removes base terrain before large
// features add their own blocks to the chunk.
if (features.stream().anyMatch(choice -> AgeFeatureSelection.skylands(choice.id())))
SkylandsFeature.filter(result, ageSeed);
Map<ResourceLocation, Integer> occurrences = new HashMap<>();
for (AgeFeatureSelection.Choice choice : features) {
if (!AgeFeatureSelection.floatingIslands(choice.id())
@@ -100,6 +100,8 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator {
}
}
}
if (features.stream().anyMatch(choice -> AgeFeatureSelection.skylands(choice.id())))
SkylandsFeature.filter(chunk, ageSeed);
Map<ResourceLocation, Integer> occurrences = new HashMap<>();
for (AgeFeatureSelection.Choice choice : features) {
if (!AgeFeatureSelection.floatingIslands(choice.id())
@@ -0,0 +1,90 @@
package net.dnorberg.rivencraft.world;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
/**
* Subtractive Skylands terrain filter adapted from Mystcraft Legacy's
* SymbolSkylands (LGPL-3.0-or-later). Legacy keeps solid terrain above a
* seven-octave cutoff near Y=76 and discards liquid above that cutoff.
*/
final class SkylandsFeature {
private static final long SALT = 0x534B594C414E4453L;
private static final int BASE_CUTOFF = 76;
private static final BlockState AIR = Blocks.AIR.defaultBlockState();
private SkylandsFeature() {}
static void filter(ChunkAccess chunk, long ageSeed) {
int originX = chunk.getPos().getMinBlockX();
int originZ = chunk.getPos().getMinBlockZ();
int minY = chunk.getMinBuildHeight();
int maxY = chunk.getMaxBuildHeight();
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
for (int localX = 0; localX < 16; localX++) {
for (int localZ = 0; localZ < 16; localZ++) {
int cutoff = cutoff(ageSeed, originX + localX, originZ + localZ);
for (int y = minY; y < maxY; y++) {
pos.set(originX + localX, y, originZ + localZ);
BlockState state = chunk.getBlockState(pos);
if (!removes(state, y, cutoff)) continue;
chunk.setBlockState(pos, AIR, false);
surface.update(localX, y, localZ, AIR);
floor.update(localX, y, localZ, AIR);
}
}
}
}
static boolean removes(BlockState state, int y, int cutoff) {
return !state.isAir() && (y <= cutoff || state.liquid());
}
static int cutoff(long ageSeed, int x, int z) {
double variation = 0.0D;
int cellSize = 128;
double amplitude = 12.0D;
for (int octave = 0; octave < 7; octave++) {
variation += valueNoise(ageSeed ^ SALT ^ (long) octave * 0x9E3779B97F4A7C15L,
x, z, cellSize) * amplitude;
cellSize /= 2;
amplitude /= 2.0D;
}
return BASE_CUTOFF + (int) Math.round(variation);
}
private static double valueNoise(long seed, int x, int z, int cellSize) {
int cellX = Math.floorDiv(x, cellSize);
int cellZ = Math.floorDiv(z, cellSize);
double u = smooth((x - cellX * cellSize) / (double) cellSize);
double v = smooth((z - cellZ * cellSize) / (double) cellSize);
double north = lerp(value(seed, cellX, cellZ), value(seed, cellX + 1, cellZ), u);
double south = lerp(value(seed, cellX, cellZ + 1), value(seed, cellX + 1, cellZ + 1), u);
return lerp(north, south, v);
}
private static double value(long seed, int x, int z) {
long hash = mix(seed ^ (long) x * 0xD1B54A32D192ED03L
^ (long) z * 0xABC98388FB8FAC03L);
return (hash >>> 11) * 0x1.0p-52 - 1.0D;
}
private static double smooth(double value) {
return value * value * (3.0D - 2.0D * value);
}
private static double lerp(double first, double second, double amount) {
return first + (second - first) * amount;
}
private static long mix(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -0,0 +1,31 @@
package net.dnorberg.rivencraft.world;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.level.block.Blocks;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Checks the Legacy-style filter independently of a slow dimension-generation test. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class SkylandsFeatureGameTests {
private SkylandsFeatureGameTests() {}
@GameTest(template = "empty")
public static void skylandsOnlyKeepsUpperSolidTerrain(GameTestHelper helper) {
int cutoff = SkylandsFeature.cutoff(707L, 32, -48);
helper.assertValueEqual(SkylandsFeature.cutoff(707L, 32, -48), cutoff,
"Skylands cutoff must be deterministic for an Age seed and world column");
helper.assertTrue(SkylandsFeature.removes(Blocks.STONE.defaultBlockState(), cutoff, cutoff),
"Solid terrain at the cutoff must be removed");
helper.assertFalse(SkylandsFeature.removes(Blocks.STONE.defaultBlockState(), cutoff + 1, cutoff),
"Solid terrain above the cutoff must remain");
helper.assertTrue(SkylandsFeature.removes(Blocks.WATER.defaultBlockState(), cutoff + 1, cutoff),
"Liquids above the cutoff must be removed");
helper.assertFalse(SkylandsFeature.removes(Blocks.AIR.defaultBlockState(), cutoff - 1, cutoff),
"Air must remain air");
helper.succeed();
}
}
@@ -247,6 +247,8 @@
"symbol.rivencraft.feature.floating_islands.description": "Add high floating islands above the chosen surface terrain.",
"symbol.rivencraft.feature.tendrils": "Tendrils",
"symbol.rivencraft.feature.tendrils.description": "Add winding solid tendrils through the Age, using a random block or a preceding Block page.",
"symbol.rivencraft.feature.skylands": "Skylands",
"symbol.rivencraft.feature.skylands.description": "Remove terrain below a shifting sky cutoff; unlike Floating Islands, this does not add land.",
"symbol.rivencraft.feature.lava_lakes": "Small Lakes",
"symbol.rivencraft.feature.lava_lakes.description": "Add small surface lakes using a random fluid, or specify one with a fluid page.",
"generator.rivencraft.standard": "Standard",
@@ -0,0 +1,8 @@
{
"schema_version": 2,
"name": "symbol.rivencraft.feature.skylands",
"description": "symbol.rivencraft.feature.skylands.description",
"category": "rivencraft:feature",
"semantics": { "role": "feature", "value": "rivencraft:skylands" },
"poem": "Terrain Sky Void Suspension"
}