diff --git a/TODO.md b/TODO.md index d070fd8f..961993aa 100644 --- a/TODO.md +++ b/TODO.md @@ -7085,6 +7085,51 @@ is **pre-approved** (keys in `tools/ai-assets/.env`, run via `uv`). --- +## ✅ Done (2026-08-03): terrain wonders — signature scars, continents, overhangs & the tunnel carver (#698–#709) + +Three worldgen waves in one branch (⚠️ Waves 1+3 are a one-time reshape of existing worlds, accepted +like #576–#580; **continents are NEW WORLDS ONLY** via `WorldDescription.TerrainContinents`): + +- **#698 mega-rift** — ~3 % of solid-air worlds carry ONE canyon girdling the whole planet (meandering + great-circle path, 100–200 deep, fjord-flooded floors, rim waterfalls for free). +- **#699 complex craters** — per-body rolls add central peaks (~45 %) + terraced walls (~40 %) to big + basins; hotspot **crater chains** (3–6 aligned bowls) + **ejecta rays** (deep-rock repaint, trig-free + dot-product cones). +- **#700 terrain grain** — per-world direction for dune seas + mountain chains via integer stretch + + period-normalised shear (⚠️ LatPeriod ≠ C/2 exactly — naive shear tears the seam; coefficients must + couple in units of the target axis's period). +- **#701 exotic accents** — hex basalt column fields (lava/ashen/basalt worlds), travertine spring + terraces with 1-deep deck pools (salt-white repaint), penitente blade-ice fields (cold worlds), salt + polygons (Voronoi ridge network on salt pans). +- **#702 ring calderas** (200–400 radius rim wall + sunken floor, self-flooding) + **whole-planet + escarpments** (~4 %: two storeys split by a meandering 60–100 cliff, applied pre-style). +- **#703 style×archetype hybrid** — a rolled 20–40 % of most styled worlds fades into the archetype + blend (deserts get gravel plains between dune seas); identity styles (flats/spires) stay pure. +- **#704 continents & real oceans** — NEW WORLDS ONLY: large planets (circ ≥ 8000, ~50 % roll, ocean + type excluded, lava/ashen included → basalt continents in lava oceans) get a domain-warped bimodal + platform/basin offset UNDER everything; the sea percentile targets the basin share so oceans settle + at the shelf. Plumbed: WorldDescription flag (default-true for new configs) → GameServer start → + `JoinAccepted.TerrainContinents` → client preview bakes (`WorldMinimap.Bake`). CLI escape: + `continents=off`. +- **#705 multi-band columns** — `GetExtraBands` generalises the floating-island second band: up to 6 + bands/column (island tiers, ponds, caps, waterfalls); classic sky-island fill is bit-preserved. +- **#706 overhangs** — natural arches (abutment pillars + sagging rock bar), sea stacks (low-coast + pillars, ~60 % mushroom-capped), hoodoo fields (thin stems + wider caprocks) on badlands/tablelands/ + mesa/canyons. +- **#707 verticality** — multi-tier skylands (1–3 layers, stalactite tapers, meadow ponds spilling + ENDLESS WATERFALLS over the rim), cenotes (30–80-deep sheer shafts with overhanging lips + turquoise + pools, caves open into the walls for free), underground mega-caverns (ellipsoid voids with + water/lava lakes + crystal-studded floors). +- **#708 tunnel carver** — deterministic hotspot worms (xorshift polylines, capsule y-spans per + column); **#709 consumers** — lava tubes (wider/smoother on volcano worlds), skylight shafts + + cave MOUTHS (tunnels may break the surface), waterfall plunge-pool incision (slot under falls), + crevasse fields on ≤ −8 °C worlds. +- **Tests** — `TerrainWondersTests` (21): seam wrap on wonder worlds, mega-rift wraps + depth, + escarpment storeys, caldera rim/floor, crater-trait distribution, chains, salt-ridge fraction, + continents gating (flag/size/ocean-identity) + bimodality + basin sea, band compat (tier-0 == + classic query), cenote sheerness, tunnel determinism + surface mouths, chunk smoke on all wonder + worlds. Status: **local branch `terrain-wonders` only — NO PR yet** (user decision). + ## ✅ Done (2026-08-03): beaches along seas and larger lakes (#679) Coastlines ended abruptly — the biome surface (grass/mud/snow) ran straight into the water; sandy diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs b/client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs index a8b045dd..3ae27bc0 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs @@ -141,6 +141,10 @@ public void SetLanguage(GameLocale locale) /// The world seed (from JoinAccepted) — drives the per-world flora colours, also for /// OTHER bodies seen from orbit (FloraTints is a pure function of seed + location + species). public long WorldSeed => _worldSeed; + + /// Whether this save was created with continents (#704, from JoinAccepted) — the local + /// preview generators (minimap/orbit bakes) must apply the same gate as the server. + public bool TerrainContinents { get; private set; } private System.Collections.Generic.Dictionary _floraTintByBlock; /// Total seconds this world has been played (from JoinAccepted, server-accumulated). The live @@ -1214,6 +1218,7 @@ private void Start() Debug.Log($"Joined as {m.PlayerId} at {LocationName} (seed {m.WorldSeed})."); LoadingPlanetType = m.PlanetType; _worldSeed = m.WorldSeed; + TerrainContinents = m.TerrainContinents; // #704: previews must match the server's gate CumulativePlaytimeSeconds = m.CumulativePlaytimeSeconds; // saved world total; session ticks on top if (_sessionStartRealtime < 0f) { diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/SkyBodiesView.cs b/client/Assets/BlocksBeyondTheStars/Scripts/SkyBodiesView.cs index 0693baae..741f8eb9 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/SkyBodiesView.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/SkyBodiesView.cs @@ -340,7 +340,7 @@ float Prominence(NetBody b) if (planet != null) { baked = WorldMinimap.Bake(Game.Content, Game.Atlas, Game.WorldSeed, locationKey, body.PlanetType, bodyCirc, 96, 48, - bodyId: body.Id); + bodyId: body.Id, continents: Game.TerrainContinents); tint = Color.Lerp(Color.white, sunHue, 0.35f); // light star-hue wash over the real map } else diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs b/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs index 0781181f..9323a248 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs @@ -1357,7 +1357,8 @@ private void ShowLandMap(BlocksBeyondTheStars.Networking.Messages.NetLandingPad[ UiKit.Place(mapGo, pad, mapTop, mapW, mapH); var raw = mapGo.AddComponent(); raw.texture = WorldMinimap.Bake(Game.Content, Game.Atlas, Game.WorldSeed, locName, typeKey, circ, 256, 128, - bodyId: !string.IsNullOrEmpty(_choosePadBody) ? _choosePadBody : Game?.LocationName ?? "home"); + bodyId: !string.IsNullOrEmpty(_choosePadBody) ? _choosePadBody : Game?.LocationName ?? "home", + continents: Game?.TerrainContinents ?? false); raw.raycastTarget = true; // Day/night terminator: shade the night half of the strip + mark dawn/dusk, so you can see which pads @@ -2834,7 +2835,8 @@ private void SpawnBody(string name, string bodyId, string kind, string locationN int circ = WorldConstants.CircumferenceFor( string.IsNullOrEmpty(bodyId) ? locationName ?? "home" : bodyId, ClassOf(kind, planetType), sizeBias); var baked = WorldMinimap.Bake(Game.Content, Game.Atlas, Game.WorldSeed, locationName, planetType, circ, 96, 48, - bodyId: string.IsNullOrEmpty(bodyId) ? locationName ?? "home" : bodyId); + bodyId: string.IsNullOrEmpty(bodyId) ? locationName ?? "home" : bodyId, + continents: Game.TerrainContinents); Color washTint = Color.Lerp(Color.white, sunHue, 0.35f); sphere.GetComponent().sharedMaterial = LitPhase(washTint, sunDir, baked, new Vector2(1f, 1f)); } diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/WorldMinimap.cs b/client/Assets/BlocksBeyondTheStars/Scripts/WorldMinimap.cs index f3d254a3..37fe0bae 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/WorldMinimap.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/WorldMinimap.cs @@ -28,9 +28,9 @@ public static class WorldMinimap /// world. stays the name-derived key the flora-tint rolls use. public static Texture2D Bake(GameContent content, BlockTextureAtlas atlas, long worldSeed, string locationName, string planetTypeKey, int circumference, int texW, int texH, - string bodyId = null) + string bodyId = null, bool continents = false) { - string key = $"{worldSeed}|{locationName}|{bodyId}|{planetTypeKey}|{circumference}|{texW}x{texH}"; + string key = $"{worldSeed}|{locationName}|{bodyId}|{planetTypeKey}|{circumference}|{texW}x{texH}|{continents}"; if (_cache.TryGetValue(key, out var cached) && cached != null) { return cached; @@ -56,6 +56,7 @@ public static Texture2D Bake(GameContent content, BlockTextureAtlas atlas, long // Local preview generator: wrap size + the BODY identity (#478 — the body id salts the // terrain seed, so the preview must carry it or it would show a different world's terrain). gen.SetWorldMode(circumference, cratered: false, landingPads: null, bodyId); + gen.SetContinentsEnabled(continents); // #704: same creation-time gate as the server int latPeriod = WorldConstants.LatitudePeriodFor(circumference); int sea = gen.SeaLevel(planet); diff --git a/docs/developer/WORLD_GENERATION.md b/docs/developer/WORLD_GENERATION.md index f25c75de..4cce3bcf 100644 --- a/docs/developer/WORLD_GENERATION.md +++ b/docs/developer/WORLD_GENERATION.md @@ -406,3 +406,38 @@ When a chunk is generated (`WorldGenerator.Generate(planet, coord)`): Every step is a pure function of `(seed, planetKey, coordinates)` — no persistent world storage, so server and clients build the same chunk independently. + +## 11. Terrain wonders (#698–#709, 2026-08-03) + +Three stacked waves on top of §4's mechanisms — same determinism rules (pure functions of the seed, +hotspot-cell landmarks, quantile-calibrated consumers): + +**Wave 1 — signature scars (class-B reshape).** +- *Mega-rift* (#698): ~3 % of solid-air worlds carry one meandering canyon girdling the entire planet + (`MegaRiftOffset` — a great-circle path periodic in X, not a hotspot cell). +- *Complex craters* (#699): per-body `CraterProfile` rolls central peaks + terraced walls; hotspot + crater chains + trig-free ejecta-ray repaint (`CraterChainCarve`, `CraterRayAt`). +- *Terrain grain* (#700): per-world direction for dunes/mountain chains via `GrainFbm` — integer + stretch + period-normalised shear. ⚠ The latitude period is NOT exactly circumference/2 (chunk + rounding), so shear must couple in units of the target axis's period or the seam tears. +- *Exotic accents* (#701): hex basalt column fields, travertine terraces (salt repaint + 1-deep deck + pools), penitente blade-ice fields, Voronoi salt polygons. +- *Ring calderas + whole-planet escarpments* (#702); *style×archetype hybrid* (#703): a rolled + 20–40 % of most styled worlds fades into the archetype blend (identity styles flats/spires exempt). + +**Continents (#704, NEW WORLDS ONLY).** `WorldDescription.TerrainContinents` (default true for new +configs, false on load) gates a bimodal platform/basin offset under everything on large planets +(circ ≥ 8000, ~50 % per-body roll, ocean type excluded, lava/ashen → basalt continents in lava +oceans). The sea percentile targets the basin share, so oceans settle at the shelf. The flag reaches +client preview bakes via `JoinAccepted.TerrainContinents`. + +**Wave 2 — overhangs (#705–#707).** `GetExtraBands` generalises the floating-island band: up to 6 +extra bands per column (island tiers/ponds/waterfalls, arch bars, sea-stack + hoodoo caps, cenote +lips), filled by `Generate`'s band switch. Multi-tier skylands (1–3 layers + stalactites, endless +rim waterfalls), cenotes (sheer shafts; caves open into the walls for free), underground +mega-caverns (`TryGetCavernSpan`, water/lava lakes, crystal-studded floors). + +**Wave 3 — the tunnel carver (#708/#709).** `TunnelSpans` rebuilds a seeded worm polyline per +hotspot cell (xorshift, capsule y-spans per column) — noodle tunnels, wider lava tubes on volcano +worlds, skylight shafts, and real cave MOUTHS (tunnels may break the surface). River waterfall +columns incise a plunge-pool slot; ≤ −8 °C worlds grow crevasse fields. diff --git a/src/BlocksBeyondTheStars.GameServer/GameServer.cs b/src/BlocksBeyondTheStars.GameServer/GameServer.cs index 5da342d4..3b3c14a1 100644 --- a/src/BlocksBeyondTheStars.GameServer/GameServer.cs +++ b/src/BlocksBeyondTheStars.GameServer/GameServer.cs @@ -266,10 +266,12 @@ public void Start() _generator = new WorldGenerator(_meta.Seed, _content); // World options: flora/ore factors are part of the save's description — set BEFORE any chunk - // generates so worldgen stays deterministic across reloads. + // generates so worldgen stays deterministic across reloads. Continents (#704) ride the same + // path: baked at creation, re-applied on every load, never flipped on an existing save. _generator.SetWorldOptionFactors( _meta.Description.FloraDensity.FloraFactor(), _meta.Description.RareResources.OreFactor()); + _generator.SetContinentsEnabled(_meta.Description.TerrainContinents); _worlds = new WorldManager(_content, _generator, _repo); BuildGalaxy(); // resolves _meta.ActiveLocationId to a concrete celestial body id LoadPlayerStations(); // item 20 S4: restore persisted player stations onto the star map + registry @@ -2422,6 +2424,7 @@ private void HandleJoin(int connectionId, JoinRequest join) PlanetName = planetName, SystemName = systemName, CumulativePlaytimeSeconds = _meta.CumulativePlaytimeSeconds, + TerrainContinents = _meta.Description.TerrainContinents, }); SendInventory(session); SendPlayerState(session); diff --git a/src/BlocksBeyondTheStars.Networking/Messages.cs b/src/BlocksBeyondTheStars.Networking/Messages.cs index 14e1970f..8b118f33 100644 --- a/src/BlocksBeyondTheStars.Networking/Messages.cs +++ b/src/BlocksBeyondTheStars.Networking/Messages.cs @@ -648,6 +648,10 @@ public sealed class JoinAccepted /// Total seconds this world has been played (server-accumulated, see /// WorldMetadata.CumulativePlaytimeSeconds); shown alongside the per-session timer in the HUD. public long CumulativePlaytimeSeconds { get; set; } + + /// Whether this save was created with continents (#704) — the client's preview generators + /// must apply the same gate or orbit/minimap textures would show the wrong coastlines. + public bool TerrainContinents { get; set; } } public sealed class JoinRejected diff --git a/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs b/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs index d97a505c..29d65a03 100644 --- a/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs +++ b/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs @@ -92,7 +92,7 @@ public sealed class ServerConfig /// and /// properties /// default to false, so a loaded save whose metadata predates the features stays byte-identical. - public BlocksBeyondTheStars.Shared.World.WorldDescription World { get; set; } = new() { SystemVariance = true, AsteroidBelts = true }; + public BlocksBeyondTheStars.Shared.World.WorldDescription World { get; set; } = new() { SystemVariance = true, AsteroidBelts = true, TerrainContinents = true }; /// Optional AI mission backend level (Off keeps the game fully AI-free). public AiLevel AiLevel { get; set; } = AiLevel.Off; @@ -595,6 +595,13 @@ public IReadOnlyList ApplyCommandLine(string[]? args) else if (string.Equals(value, "on", StringComparison.OrdinalIgnoreCase)) { World.AsteroidBelts = true; applied.Add("belts"); } else if (string.Equals(value, "off", StringComparison.OrdinalIgnoreCase)) { World.AsteroidBelts = false; applied.Add("belts"); } break; + case "continents": + // Continents & real oceans (#704). On for every new world by default; "off" restores + // the classic noise-coast terrain (same escape-hatch role as "variance"/"belts"). + if (bool.TryParse(value, out var tc)) { World.TerrainContinents = tc; applied.Add("continents"); } + else if (string.Equals(value, "on", StringComparison.OrdinalIgnoreCase)) { World.TerrainContinents = true; applied.Add("continents"); } + else if (string.Equals(value, "off", StringComparison.OrdinalIgnoreCase)) { World.TerrainContinents = false; applied.Add("continents"); } + break; case "danger": // Global hostility multiplier (#547) — scales space-ambush odds + bandit-camp presence. if (Enum.TryParse(value, ignoreCase: true, out var dg)) { World.Danger = dg; applied.Add("danger"); } diff --git a/src/BlocksBeyondTheStars.Shared/World/WorldDescription.cs b/src/BlocksBeyondTheStars.Shared/World/WorldDescription.cs index a7d5ce00..04b5c563 100644 --- a/src/BlocksBeyondTheStars.Shared/World/WorldDescription.cs +++ b/src/BlocksBeyondTheStars.Shared/World/WorldDescription.cs @@ -128,6 +128,15 @@ public sealed class WorldDescription /// (ServerConfig's default description carries true; a loaded save keeps whatever it stored). public bool AsteroidBelts { get; set; } + /// Continents & real oceans (#704): when true, large planets (circumference ≥ 8000) may + /// roll a bimodal continental-platform/ocean-basin terrain regime (~50 % of eligible bodies) — real + /// continents separated by real oceans, basalt continents in lava oceans on the volcanic types. MUST + /// default to false for the same reason as : terrain is re-derived from + /// the seed, and flipping this on an existing save would relocate the oceans wholesale — a player + /// base could wake up on the seabed. New worlds switch it on at creation (ServerConfig's default + /// description carries true; a loaded save keeps whatever it stored). + public bool TerrainContinents { get; set; } + // --- World options (creation-time; baked into the save's metadata — they shape worldgen) --- /// Flora/tree density factor applied on top of each world's seeded variation. diff --git a/src/BlocksBeyondTheStars.WorldGeneration/WorldGenerator.cs b/src/BlocksBeyondTheStars.WorldGeneration/WorldGenerator.cs index 0b2b1424..844a24bc 100644 --- a/src/BlocksBeyondTheStars.WorldGeneration/WorldGenerator.cs +++ b/src/BlocksBeyondTheStars.WorldGeneration/WorldGenerator.cs @@ -82,6 +82,21 @@ public void SetWorldMode(int circumference, bool cratered, IReadOnlyListWhether this generator currently applies the continents feature gate (#704). + public bool ContinentsEnabled => _continentsEnabled; + + /// Enables the continents feature for this generator (#704) — from the save's + /// WorldDescription.TerrainContinents on the server, from the join handshake on the client. + /// Call BEFORE any chunk or height query, like . + public void SetContinentsEnabled(bool enabled) => _continentsEnabled = enabled; + /// The flattened pad surface height for a column, or null when it is not on a pad. private int? PadSurfaceAt(int worldX, int worldZ) { @@ -226,8 +241,10 @@ private double ArchetypeOffset(int archetype, PlanetType planet, long seed, doub case 0: return h * amp * 0.18; // flats case 1: return h * amp * 0.55; // rolling plains case 2: return h * amp * 1.00; // hills - case 3: // mountains (lightly ridged) - return (h * 0.88 + Ridge(h) * 0.12) * amp * 1.9; + case 3: // mountains (lightly ridged; #700 — a directional share so ranges chain along the grain) + return (h * 0.58 + Ridge(h) * 0.12 + + OrientedRidge(seed, GrainFor(seed), worldX, worldZ, planet.TerrainScale * 0.9) * 0.30) + * amp * 1.9; case 4: // canyons (strongly ridged) return (h * 0.35 + Ridge(h) * 0.65) * amp * 1.3; case 5: // plateau decks (#576): terraced mesa country as a REGION, not a whole-world style @@ -241,7 +258,9 @@ private double ArchetypeOffset(int archetype, PlanetType planet, long seed, doub case 6: // extreme peaks (#576): the far tail of relief, well above the mountains archetype { - double r = h * 0.25 + Ridge(h) * 0.75; + // #700: the extreme crests follow the world's grain too — the tallest ranges chain. + double or6 = OrientedRidge(seed, GrainFor(seed), worldX, worldZ, planet.TerrainScale * 0.9); + double r = h * 0.25 + Ridge(h) * 0.45 + or6 * 0.30; if (r > 0) { r = System.Math.Pow(r, 1.6); // flatter mid-slopes, prouder crests @@ -290,6 +309,11 @@ public int SurfaceHeight(PlanetType planet, int worldX, int worldZ) int h = RawSurfaceHeight(planet, worldX, worldZ); long seed = PlanetSeed(planet); double overlay = HasVolcanoes(planet) ? VolcanoOffset(planet, seed, worldX, worldZ) : 0.0; + if (overlay == 0.0 && HasCalderas(planet)) + { + overlay = CalderaOffset(seed, worldX, worldZ); + } + if (overlay == 0.0 && HasMassifs(planet)) { overlay = MassifOffset(planet, seed, worldX, worldZ); @@ -300,11 +324,42 @@ public int SurfaceHeight(PlanetType planet, int worldX, int worldZ) overlay = TableMountainOffset(seed, worldX, worldZ); } + if (overlay == 0.0 && HasOverhangLandmarks(planet)) + { + overlay = OverhangGroundOffset(planet, seed, worldX, worldZ); + } + + if (overlay == 0.0 && HasTravertine(planet) + && TryGetTravertine(seed, worldX, worldZ, out double deckRise, out _)) + { + overlay = deckRise; + } + + if (overlay == 0.0 && HasPenitentes(planet)) + { + overlay = PenitenteRise(planet, seed, worldX, worldZ); + } + + if (overlay == 0.0 && HasCenotes(planet)) + { + overlay = CenoteOffset(planet, seed, worldX, worldZ); + } + + if (overlay == 0.0 && HasCrevasses(planet)) + { + overlay = CrevasseOffset(seed, worldX, worldZ); + } + if (overlay == 0.0 && HasRifts(planet)) { overlay = RiftOffset(seed, worldX, worldZ); } + if (overlay == 0.0 && HasMegaRift(planet, seed)) + { + overlay = MegaRiftOffset(seed, worldX, worldZ); + } + if (overlay != 0.0) { h += (int)System.Math.Round(overlay); @@ -323,28 +378,85 @@ private int RawSurfaceHeight(PlanetType planet, int worldX, int worldZ) // Airless moons + landable asteroids (item 33): mostly flat regolith (a gentle undulation only — no // hills/mountains/canyons) pocked with round impact craters carved on top. How rolling that regolith - // is, and how dense/deep/sharp the craters are, is this BODY's own character (#518). + // is, and how dense/deep/sharp the craters are, is this BODY's own character (#518). Crater CHAINS + // (#699) — aligned secondary-impact strings — carve on top of the primary field. if (planet.Cratered || _crateredWorld) { double flat = h * CraterProfileFor(seed).Flatness * planet.Amplitude; - return planet.BaseHeight + (int)System.Math.Round(flat + CraterCarve(seed, worldX, worldZ, planet)); + return planet.BaseHeight + (int)System.Math.Round( + flat + CraterCarve(seed, worldX, worldZ, planet) + CraterChainCarve(seed, worldX, worldZ)); } double drama = DramaFor(seed); // W-R1: per-world relief multiplier (gentle ↔ dramatic) + // Continents (#704, new worlds only): a bimodal platform/basin offset UNDER everything else, so + // styles, archetypes and landmarks simply ride on the continent or drown in the ocean basin. + double baseline = ContinentOffset(planet, seed, worldX, worldZ); + + // Whole-planet escarpment (#702): a rare two-storey world — the step is part of the baseline too. + if (HasEscarpment(planet, seed)) + { + baseline += EscarpmentOffset(seed, worldX, worldZ); + } + + // Salt polygons (#701): the cracked-plate ridge network of salt pans. + if (HasSaltPolygons(planet)) + { + baseline += SaltPolygonRidge(seed, worldX, worldZ); + } + + // Basalt column fields (#701): stepped hex prisms on volcanic-reading worlds. + if (HasBasaltFields(planet) && TryGetBasaltColumns(seed, worldX, worldZ, out double hexRise)) + { + baseline += hexRise; + } + // A planet may dictate an overall terrain SHAPE (item 21 V2) so worlds read structurally different — - // mesas, dunes, spires, etc. — instead of every world using the same mixed blend. + // mesas, dunes, spires, etc. — instead of every world using the same mixed blend. Since #703 a broad + // fade field hands 20–40 % of most styled worlds to the archetype blend, so a dunes world has gravel + // plains between its dune seas instead of being dunes from pole to pole. if (!string.IsNullOrEmpty(planet.TerrainStyle)) { - return planet.BaseHeight + (int)System.Math.Round(StyledHeightOffset(planet, planet.TerrainStyle, seed, h, worldX, worldZ) * drama); + double styled = StyledHeightOffset(planet, planet.TerrainStyle, seed, h, worldX, worldZ); + if (StyleHybridEligible(planet.TerrainStyle)) + { + ulong uh = Noise.Hash(seed ^ 0x57FADE, 2, 4, 8); + double a = 0.34 + (uh & 0xFF) / 255.0 * 0.08; // archetype share threshold, rolled per world + double b = a + 0.08; + double fade = FbmT(seed + 0x57FAD1, worldX, worldZ, planet.TerrainScale * 6.0, octaves: 2); + if (fade < b) + { + double arch = BlendedArchetypeOffset(planet, seed, h, worldX, worldZ); + if (fade <= a) + { + styled = arch; + } + else + { + double f = (fade - a) / (b - a); + styled = arch + (styled - arch) * (f * f * (3.0 - 2.0 * f)); + } + } + } + + return planet.BaseHeight + (int)System.Math.Round(baseline + styled * drama); } // Regional terrain character: a large-scale field selects how rugged this area is (a blend across // the world's archetype subset), so the surface varies between flat plains, hills, mountains — and, // where the subset drew the #576 archetypes, terraced decks, extreme crests or rift gorges. - return planet.BaseHeight + (int)System.Math.Round(BlendedArchetypeOffset(planet, seed, h, worldX, worldZ) * drama); + return planet.BaseHeight + (int)System.Math.Round(baseline + BlendedArchetypeOffset(planet, seed, h, worldX, worldZ) * drama); } + /// Styles that hand a rolled 20–40 % of their surface to the archetype blend (#703). The + /// identity styles stay pure: flats IS the world (ocean floor, salt pan, sky-world ground) and spires + /// is the crystal identity. + private static bool StyleHybridEligible(string style) => style.ToLowerInvariant() switch + { + "mountains" or "canyons" or "mesa" or "dunes" or "hills" or "tablelands" or "badlands" or "karst" => true, + _ => false, + }; + /// The vertical band [..] of a floating sky island at /// this column (item 21 V5), or false if no island covers it. The single source of truth for the /// island mask — used by chunk generation AND by settlement placement, so both agree on island heights. @@ -357,20 +469,9 @@ public bool FloatingIslandBand(PlanetType planet, int worldX, int worldZ, out in return false; } - long seed = PlanetSeed(planet); - double im = FbmT(seed + 0x15A4D, worldX, worldZ, planet.TerrainScale * 1.4, octaves: 3); - if (im <= 0.60) // matches the chunk-gen coverage threshold - { - return false; - } - - double t = (im - 0.60) / 0.40; // 0..1 toward an island's centre - double alt = FbmT(seed + 0x15A4E, worldX, worldZ, planet.TerrainScale * 3.0, octaves: 2); - int center = planet.BaseHeight + 28 + (int)((alt - 0.5) * 24.0); - int half = 2 + (int)(t * 8.0); // 2..10 thick - top = center + half; - bottom = center - half - (int)(t * 6.0); // tapered rocky underside - return true; + // Tier 0 of the (#707) multi-tier sky: same seeds and shaping as the classic single band, so + // existing sky worlds keep their islands; upper tiers are GetExtraBands' business. + return FloatingIslandTier(planet, PlanetSeed(planet), 0, worldX, worldZ, out top, out bottom, out _); } /// The TOP world-Y of a floating sky island at this column, or if none. @@ -551,152 +652,1284 @@ private bool TryGetHotspot(long salt, double cellSize, double chance, double mar dz -= period; } - if (dz < -period / 2.0) + if (dz < -period / 2.0) + { + dz += period; + } + + return true; + } + + private const double ButteCellSize = 1600.0; // hotspot pitch (≈8 candidate cells on a default world) + private const double ButteChance = 0.40; // fraction of cells that grow a table mountain + private const double ButteMaxRadius = 120.0; + + /// Table mountains grow on dry, rocky-reading worlds (#577) — dune/mesa/canyon-style terrain + /// plus the savanna — never on airless bodies, sky worlds or void interiors. + private bool HasTableMountains(PlanetType planet) + { + if (planet.Void || planet.Cratered || _crateredWorld || planet.FloatingIslands) + { + return false; + } + + return (planet.TerrainStyle?.ToLowerInvariant()) switch + { + "dunes" or "mesa" or "canyons" or "tablelands" or "badlands" => true, + // savanna since #577; varied added 2026-08-03 (visibility tuning): the START world is a + // varied world, and without a key exception it carried no big landmark family at all. + _ => string.Equals(planet.Key, "savanna", System.StringComparison.OrdinalIgnoreCase) + || string.Equals(planet.Key, "varied", System.StringComparison.OrdinalIgnoreCase), + }; + } + + /// The table mountain's height contribution at a column, or 0 if none covers it (#577): a + /// talus foot steepening into a near-vertical upper wall (outer 30 % of the radius), then a dead-flat + /// cap with a light rock roll so the top reads as stone, not glass. + private double TableMountainOffset(long seed, int worldX, int worldZ) + { + if (!TryGetHotspot(seed ^ 0x7AB1E0, ButteCellSize, ButteChance, ButteMaxRadius + 20.0, + worldX, worldZ, out ulong h, out double dx, out double dz)) + { + return 0.0; + } + + double radius = 40.0 + ((h >> 16) & 0x3FF) / 1023.0 * (ButteMaxRadius - 40.0); // 40..120 + double dist = System.Math.Sqrt(dx * dx + dz * dz); + if (dist > radius) + { + return 0.0; + } + + double height = 30.0 + ((h >> 26) & 0x3FF) / 1023.0 * 40.0; // 30..70 + double t = 1.0 - dist / radius; + if (t >= 0.30) + { + double roll = FbmT(seed + 0x7AB2E, worldX, worldZ, 24.0, octaves: 2); + return height + (roll - 0.5) * 2.0; // the table top + } + + return height * System.Math.Pow(t / 0.30, 1.8); // talus foot → near-vertical upper wall + } + + private const double MassifCellSize = 3200.0; // very sparse: ~1 in 5 default worlds carries a massif + private const double MassifChance = 0.10; // decision "bold but varied": a massif is a FIND, not the norm + private const double MassifMaxRadius = 300.0; + + /// Massifs — rare single giant mountains, visible from very far — grow on any solid-ground + /// world with an atmosphere (#578); airless/cratered bodies are geologically dead, sky worlds stay + /// floaty, void interiors have no terrain. + private bool HasMassifs(PlanetType planet) + { + if (planet.Void || planet.Cratered || _crateredWorld || planet.FloatingIslands) + { + return false; + } + + return !string.Equals(planet.Atmosphere, "none", System.StringComparison.OrdinalIgnoreCase); + } + + /// The massif's height contribution at a column, or 0 if none covers it (#578): a broad cone + /// with ridged flanks (spurs + gullies from a mid-frequency field); the summit is capped at the rolled + /// height so flank noise sculpts the sides, never the peak — and the roll itself is clamped so the + /// summit stays under with margin for the underlying swell. + private double MassifOffset(PlanetType planet, long seed, int worldX, int worldZ) + { + if (!TryGetHotspot(seed ^ 0x3A551F, MassifCellSize, MassifChance, MassifMaxRadius + 20.0, + worldX, worldZ, out ulong h, out double dx, out double dz)) + { + return 0.0; + } + + double radius = 150.0 + ((h >> 16) & 0x3FF) / 1023.0 * (MassifMaxRadius - 150.0); // 150..300 + double dist = System.Math.Sqrt(dx * dx + dz * dz); + if (dist > radius) + { + return 0.0; + } + + double height = 120.0 + ((h >> 26) & 0x3FF) / 1023.0 * 100.0; // 120..220 + height = System.Math.Min(height, MaxNaturalSurfaceY - 16.0 - planet.BaseHeight); + + double t = 1.0 - dist / radius; + double flank = FbmT(seed + 0x3A552F, worldX, worldZ, 90.0, octaves: 2); + return height * System.Math.Min(1.0, System.Math.Pow(t, 1.5) * (0.75 + 0.5 * flank)); + } + + private const double RiftCellSize = 2400.0; + private const double RiftChance = 0.15; // ~1 in 4 worlds — a gorge is a discovery, not scenery + private const double RiftMaxHalfLen = 500.0; + private const double RiftMaxHalfWidth = 28.0; + + /// Rift chasms cut the same worlds massifs grow on (#578) — solid ground plus an atmosphere. + /// Where the floor dips under the sea level the rift floods into a fjord lake for free (the sea fill + /// is by level), and rivers crossing the rim drop in as waterfalls. + private bool HasRifts(PlanetType planet) => HasMassifs(planet); + + /// The rift's (negative) height contribution at a column, or 0 if none covers it (#578): a + /// straight gorge segment with steep walls dropping to a broad floor, tapered toward both ends so the + /// chasm closes naturally instead of ending in a cliff face. + private double RiftOffset(long seed, int worldX, int worldZ) + { + if (!TryGetHotspot(seed ^ 0x21F7A9, RiftCellSize, RiftChance, + RiftMaxHalfLen + RiftMaxHalfWidth + 16.0, worldX, worldZ, + out ulong h, out double dx, out double dz)) + { + return 0.0; + } + + double angle = ((h >> 16) & 0x3FF) / 1023.0 * System.Math.PI; + double halfLen = 260.0 + ((h >> 26) & 0x3FF) / 1023.0 * (RiftMaxHalfLen - 260.0); // 260..500 + double halfWidth = 14.0 + ((h >> 56) & 0xFF) / 255.0 * (RiftMaxHalfWidth - 14.0); // 14..28 + + double cos = System.Math.Cos(angle); + double sin = System.Math.Sin(angle); + double along = dx * cos + dz * sin; + double across = -dx * sin + dz * cos; + if (System.Math.Abs(along) > halfLen || System.Math.Abs(across) > halfWidth) + { + return 0.0; + } + + // Depth comes from a re-hash — the primary hash's roll bits are spent on placement + shape. + ulong h2 = h * 0x9E3779B97F4A7C15UL; + double depth = 50.0 + ((h2 >> 20) & 0x3FF) / 1023.0 * 80.0; // 50..130 + + double Smooth(double v) => v * v * (3.0 - 2.0 * v); + double w = 1.0 - System.Math.Abs(across) / halfWidth; // 0 rim .. 1 axis + double wall = w >= 0.45 ? 1.0 : Smooth(w / 0.45); // walls in the outer 45 %, flat floor within + double endT = 1.0 - System.Math.Abs(along) / halfLen; + double taper = endT >= 0.15 ? 1.0 : Smooth(endT / 0.15); + return -depth * wall * taper; + } + + // --- Mega-rift (#698): ONE colossal canyon girdling the entire planet — the Valles-Marineris + // signature scar. Not a hotspot cell: a meandering great-circle path at a rolled latitude. The meander + // is a torus FBM of X alone, so the canyon closes seamlessly on itself after a full circumnavigation. + // Very rare — a world-defining find, never the norm. --- + private const double MegaRiftChance = 16 / 256.0; // ~6 % of eligible worlds (visibility tuning 2026-08-03) + private const double MegaRiftMeanderFrac = 0.10; // meander amplitude as a fraction of the latitude period + private const double MegaRiftMaxHalfWidth = 40.0; // canyon up to ~80 wide + + /// True when this world carries the world-girdling canyon (#698) — same solid-ground + + /// atmosphere gate as massifs, then a rare per-body roll. + private bool HasMegaRift(PlanetType planet, long seed) + => HasMassifs(planet) && (Noise.Hash(seed ^ 0x6E64A11F, 3, 1, 7) & 0xFF) < 256 * MegaRiftChance; + + /// The mega-rift's (negative) height contribution at a column (#698): steep smoothstep walls + /// dropping 100–200 blocks to a broad flat floor. Wherever the floor dips under the sea percentile it + /// floods into a chain of fjord lakes, and rivers crossing the rim fall in as waterfalls — both for + /// free (the calibration samples the full ). + private double MegaRiftOffset(long seed, int worldX, int worldZ) + { + ulong h = Noise.Hash(seed ^ 0x6E64A120, 1, 2, 3); + int period = LatPeriod; + double z0 = ((((h >> 8) & 0x3FF) / 1023.0) - 0.5) * period; // rolled central latitude + + // Cheap band reject BEFORE the meander FBMs: columns far from the canyon's latitude band pay ~nothing. + double band = period * MegaRiftMeanderFrac + MegaRiftMaxHalfWidth * 1.5; + double dz0 = WorldConstants.WrapDeltaZ(worldZ - z0, _circumference); + if (System.Math.Abs(dz0) > band) + { + return 0.0; + } + + double meander = (FbmT(seed + 0x6E64A121, worldX, 0.0, _circumference / 6.0, octaves: 2) - 0.5) + * 2.0 * (period * MegaRiftMeanderFrac); + double halfWidth = 15.0 + ((h >> 18) & 0x3FF) / 1023.0 * (MegaRiftMaxHalfWidth - 15.0); // 15..40 + halfWidth *= 0.75 + FbmT(seed + 0x6E64A122, worldX, 64.0, _circumference / 12.0, octaves: 2) * 0.5; + double depth = 100.0 + ((h >> 28) & 0x3FF) / 1023.0 * 100.0; // 100..200 + + double dz = WorldConstants.WrapDeltaZ(worldZ - z0 - meander, _circumference); + if (System.Math.Abs(dz) > halfWidth) + { + return 0.0; + } + + double w = 1.0 - System.Math.Abs(dz) / halfWidth; // 0 rim .. 1 axis + double wall = w >= 0.45 ? 1.0 : w / 0.45 * (w / 0.45) * (3.0 - 2.0 * (w / 0.45)); + return -depth * wall; + } + + // --- Ring caldera (#702): a colossal circular mountain wall around a sunken interior basin. Where the + // basin dips under the sea percentile it floods into a ring lake (lava on dry volcanic worlds). --- + private const double CalderaCellSize = 2400.0; + private const double CalderaChance = 0.20; // visibility tuning 2026-08-03: ~0.6 per start-size world + private const double CalderaMaxRadius = 400.0; + + private bool HasCalderas(PlanetType planet) => HasMassifs(planet); // same solid-ground + air gate + + /// The ring caldera's height contribution at a column (#702): a smooth rim wall peaking at + /// ~86 % of the radius, enclosing a gently dished sunken floor. + private double CalderaOffset(long seed, int worldX, int worldZ) + { + if (!TryGetHotspot(seed ^ 0x0CA1DE7A, CalderaCellSize, CalderaChance, CalderaMaxRadius + 30.0, + worldX, worldZ, out ulong h, out double dx, out double dz)) + { + return 0.0; + } + + double radius = 200.0 + ((h >> 16) & 0x3FF) / 1023.0 * (CalderaMaxRadius - 200.0); // 200..400 + double dist = System.Math.Sqrt(dx * dx + dz * dz); + if (dist > radius) + { + return 0.0; + } + + double rimH = 40.0 + ((h >> 26) & 0x3FF) / 1023.0 * 30.0; // 40..70 wall + double basin = 20.0 + ((h >> 56) & 0xFF) / 255.0 * 20.0; // 20..40 sunken interior + double u = dist / radius; + if (u >= 0.72) + { + double v = (u - 0.72) / 0.28; // 0 basin edge .. 1 outside foot + double s = 1.0 - System.Math.Abs(2.0 * v - 1.0); // peak mid-band + return rimH * (s * s * (3.0 - 2.0 * s)); + } + + return -basin * (1.0 - 0.25 * (u / 0.72)); // near-flat sunken floor, slightly dished toward the wall + } + + // --- Whole-planet escarpment (#702): a rare great-circle step that splits the world into an upper and + // a lower storey, separated by a continuous meandering cliff. Applied to the RAW height (before styles + // and landmarks) so every landform simply rides on its storey. --- + private const double EscarpmentChance = 14 / 256.0; // ~5.5 % of eligible worlds + + private bool HasEscarpment(PlanetType planet, long seed) + => !planet.FloatingIslands && !planet.Void + && (Noise.Hash(seed ^ 0x0E5CA29F, 9, 2, 5) & 0xFF) < 256 * EscarpmentChance; + + /// The escarpment's height contribution (#702): +step/2 on the upper storey, −step/2 on the + /// lower, blended across a cliff band whose path meanders like the mega-rift's. + private double EscarpmentOffset(long seed, int worldX, int worldZ) + { + ulong h = Noise.Hash(seed ^ 0x0E5CA2A0, 4, 4, 4); + int period = LatPeriod; + double z0 = ((((h >> 8) & 0x3FF) / 1023.0) - 0.5) * period; + double step = 60.0 + ((h >> 18) & 0x3FF) / 1023.0 * 40.0; // 60..100 storey separation + double blend = 24.0 + ((h >> 28) & 0x3FF) / 1023.0 * 24.0; // 24..48 cliff band width + + double dz0 = WorldConstants.WrapDeltaZ(worldZ - z0, _circumference); + double reach = period * MegaRiftMeanderFrac + blend; + if (System.Math.Abs(dz0) > reach) + { + return dz0 > 0 ? step * 0.5 : -step * 0.5; // far from the cliff: plain storey offset + } + + double meander = (FbmT(seed + 0x0E5CA2A1, worldX, 0.0, _circumference / 6.0, octaves: 2) - 0.5) + * 2.0 * (period * MegaRiftMeanderFrac); + double dz = WorldConstants.WrapDeltaZ(worldZ - z0 - meander, _circumference); + double t = System.Math.Clamp(dz / blend * 0.5 + 0.5, 0.0, 1.0); + return (t * t * (3.0 - 2.0 * t) - 0.5) * step; + } + + // --- Travertine spring terraces (#701): Pamukkale — blinding-white stepped decks cascading down a + // gentle mound, each deck holding a shallow pool. Warm wet worlds only. --- + private const double TravertineCellSize = 1000.0; + private const double TravertineChance = 0.45; // visibility tuning 2026-08-03 + private const double TravertineMaxRadius = 60.0; + + private bool HasTravertine(PlanetType planet) + { + if (planet.Void || planet.Cratered || _crateredWorld || planet.FloatingIslands) + { + return false; + } + + bool hasAir = !string.Equals(planet.Atmosphere, "none", System.StringComparison.OrdinalIgnoreCase); + double waterAb = planet.WaterAbundance ?? (hasAir ? 0.55 : 0.0); + return hasAir && waterAb >= 0.4 && planet.BaseTemperature >= 5.0; + } + + /// The travertine mound's terraced height contribution (#701) plus whether this column is a + /// deck POOL cell (a 1-deep water pool sitting flush on the deck). White repaint is Generate's job. + private bool TryGetTravertine(long seed, int worldX, int worldZ, out double deckRise, out bool pool) + { + deckRise = 0.0; + pool = false; + if (!TryGetHotspot(seed ^ 0x7AA7E271, TravertineCellSize, TravertineChance, TravertineMaxRadius + 16.0, + worldX, worldZ, out ulong h, out double dx, out double dz)) + { + return false; + } + + double radius = 30.0 + ((h >> 16) & 0x3FF) / 1023.0 * (TravertineMaxRadius - 30.0); // 30..60 + double dist = System.Math.Sqrt(dx * dx + dz * dz); + if (dist > radius) + { + return false; + } + + double height = 10.0 + ((h >> 26) & 0x3FF) / 1023.0 * 8.0; // 10..18 mound + double u = 1.0 - dist / radius; + double rise = height * System.Math.Pow(u, 1.35); + const double deckStep = 2.0; + double deck = System.Math.Floor(rise / deckStep) * deckStep; + deckRise = deck; + pool = rise - deck > 1.2 && dist < radius * 0.9; // deck interiors pond up; the outer fringe stays dry + return true; + } + + // --- Penitente fields (#701): dense blade-like ice spikes in patches on cold worlds. --- + private const double PenitenteCellSize = 900.0; + private const double PenitenteChance = 0.50; // visibility tuning 2026-08-03 + private const double PenitenteMaxRadius = 120.0; + + private bool HasPenitentes(PlanetType planet) + { + if (planet.Void || planet.Cratered || _crateredWorld || planet.FloatingIslands) + { + return false; + } + + return !string.Equals(planet.Atmosphere, "none", System.StringComparison.OrdinalIgnoreCase) + && planet.BaseTemperature <= -5.0; + } + + /// The penitente field's spike rise at a column (#701), 0 outside any field. Spikes are a + /// high-frequency masked field inside sparse patch cells; Generate repaints tall spikes as ice. + private double PenitenteRise(PlanetType planet, long seed, int worldX, int worldZ) + { + if (!TryGetHotspot(seed ^ 0x9E217E27, PenitenteCellSize, PenitenteChance, PenitenteMaxRadius + 16.0, + worldX, worldZ, out ulong h, out double dx, out double dz)) + { + return 0.0; + } + + double radius = 50.0 + ((h >> 16) & 0x3FF) / 1023.0 * (PenitenteMaxRadius - 50.0); // 50..120 + double dist = System.Math.Sqrt(dx * dx + dz * dz); + if (dist > radius) + { + return 0.0; + } + + double sp = FbmT(seed + 0x9E217E28, worldX, worldZ, 7.0, octaves: 2); + if (sp <= 0.60) + { + return 0.0; + } + + double t = (sp - 0.60) / 0.40; + double edge = System.Math.Min(1.0, (1.0 - dist / radius) / 0.2); + return t * t * 5.0 * (edge * edge * (3.0 - 2.0 * edge)); // blades up to ~5 tall, fading at the field edge + } + + // --- Salt polygons (#701): giant cracked plates on salt pans — a Voronoi edge network raised one + // block, so the flats read as tessellated crust instead of a billiard table. Trig-free arithmetic. --- + private const double SaltPolyCell = 26.0; // plate pitch (blocks) + private const double SaltPolyEdge = 1.7; // |d2−d1| below this ⇒ on a plate boundary ridge + + private static bool HasSaltPolygons(PlanetType planet) + => string.Equals(planet.BeachBlock, "salt", System.StringComparison.OrdinalIgnoreCase) + || string.Equals(planet.Key, "salt_flats", System.StringComparison.OrdinalIgnoreCase); + + /// +1 on the polygon boundary ridges of a salt pan, 0 on the plate interiors (#701). Seam-safe: + /// the Voronoi cell grid is modular over the torus in both axes. + private double SaltPolygonRidge(long seed, int worldX, int worldZ) + { + int period = LatPeriod; + int nx = System.Math.Max(1, (int)System.Math.Round(_circumference / SaltPolyCell)); + int nz = System.Math.Max(1, (int)System.Math.Round(period / SaltPolyCell)); + double cw = _circumference / (double)nx; + double ch = period / (double)nz; + int wx = WorldConstants.WrapX(worldX, _circumference); + int zc = ((worldZ + period / 2) % period + period) % period; + int cxI = System.Math.Min(nx - 1, (int)(wx / cw)); + int czI = System.Math.Min(nz - 1, (int)(zc / ch)); + + double d1 = double.MaxValue, d2 = double.MaxValue; + for (int ix = -1; ix <= 1; ix++) + for (int iz = -1; iz <= 1; iz++) + { + int gx = ((cxI + ix) % nx + nx) % nx; + int gz = ((czI + iz) % nz + nz) % nz; + ulong ph = Noise.Hash(seed ^ 0x5A17F1A7, gx, 0, gz); + double px = (cxI + ix + 0.15 + ((ph >> 8) & 0x3FF) / 1023.0 * 0.7) * cw; + double pz = (czI + iz + 0.15 + ((ph >> 20) & 0x3FF) / 1023.0 * 0.7) * ch; + double ddx = WorldConstants.WrapDeltaX(wx - px, _circumference); + double ddz = zc - pz; + if (ddz > period / 2.0) { ddz -= period; } + if (ddz < -period / 2.0) { ddz += period; } + double d = System.Math.Sqrt(ddx * ddx + ddz * ddz); + if (d < d1) { d2 = d1; d1 = d; } + else if (d < d2) { d2 = d; } + } + + return d2 - d1 < SaltPolyEdge ? 1.0 : 0.0; + } + + // --- Hexagonal basalt column fields (#701): Giant's-Causeway patches on volcanic-reading worlds — + // stepped hex prisms quantised over a local axial hex grid, repainted basalt by Generate. --- + private const double BasaltFieldCellSize = 1000.0; + private const double BasaltFieldChance = 0.40; // visibility tuning 2026-08-03 + private const double BasaltFieldMaxRadius = 140.0; + private const double Sqrt3Over3 = 0.5773502691896258; // compile-time constant — no libm at runtime + + private bool HasBasaltFields(PlanetType planet) + { + if (planet.Void || planet.Cratered || _crateredWorld || planet.FloatingIslands) + { + return false; + } + + return string.Equals(planet.Key, "lava", System.StringComparison.OrdinalIgnoreCase) + || string.Equals(planet.Key, "ashen", System.StringComparison.OrdinalIgnoreCase) + || string.Equals(planet.DeepBlock, "basalt", System.StringComparison.OrdinalIgnoreCase); + } + + /// The basalt column field's stepped height contribution at a column (#701), and whether the + /// column lies inside a field at all (Generate repaints those basalt). Hex axial rounding on the + /// feature-LOCAL offset (dx,dz), so the grid needs no torus treatment of its own. + private bool TryGetBasaltColumns(long seed, int worldX, int worldZ, out double stepRise) + { + stepRise = 0.0; + if (!TryGetHotspot(seed ^ 0xBA5A17C0, BasaltFieldCellSize, BasaltFieldChance, BasaltFieldMaxRadius + 16.0, + worldX, worldZ, out ulong h, out double dx, out double dz)) + { + return false; + } + + double radius = 60.0 + ((h >> 16) & 0x3FF) / 1023.0 * (BasaltFieldMaxRadius - 60.0); // 60..140 + double dist = System.Math.Sqrt(dx * dx + dz * dz); + if (dist > radius) + { + return false; + } + + double s = 4.0 + ((h >> 26) & 0x3) * 1.0; // hex pitch 4..7 + // Pointy-top axial coordinates of the LOCAL offset, cube-rounded to the containing hex. + double qf = (Sqrt3Over3 * dx - dz / 3.0) / s; + double rf = (2.0 / 3.0 * dz) / s; + double xf = qf, zf = rf, yf = -xf - zf; + double rx = System.Math.Round(xf), ry = System.Math.Round(yf), rz = System.Math.Round(zf); + double ddx = System.Math.Abs(rx - xf), ddy = System.Math.Abs(ry - yf), ddz = System.Math.Abs(rz - zf); + if (ddx > ddy && ddx > ddz) { rx = -ry - rz; } + else if (ddy <= ddz) { rz = -rx - ry; } + + ulong hexHash = Noise.Hash(seed ^ 0xBA5A17C1, (int)rx, 0, (int)rz); + double level = ((int)(hexHash & 0x7) - 3) * 1.2; // −3.6..+4.8 in ~1.2 steps + double edge = System.Math.Min(1.0, (1.0 - dist / radius) / 0.15); + stepRise = level * (edge * edge * (3.0 - 2.0 * edge)); + return true; + } + + // --- Terrain grain (#700): a per-world direction for dunes and mountain chains, expressed torus-safely + // as integer stretch + period-normalised shear of the noise input. Rotation would break the wrap + // periods; these two provably preserve them: X' = x·m advances the X circle a whole m laps per wrap, + // and the shear couples the axes in UNITS OF THE TARGET AXIS'S PERIOD — Z' = z + k·(P/C)·x shifts by + // exactly k·P under an X wrap (k whole Z periods) for ANY integers k, m, and any C/P ratio (the + // latitude period is rounded to 32-block chunks, so it is NOT exactly C/2 — a naive x-coefficient + // shear would tear the seam). --- + private readonly struct TerrainGrain + { + public readonly bool Swap; // false: compress X, shear Z; true: compress Z, shear X + public readonly int Stretch; // integer compression of the primary axis + public readonly int Shear; // integer shear coefficient (any int is wrap-safe in both families) + + public TerrainGrain(bool swap, int stretch, int shear) + { + Swap = swap; + Stretch = stretch; + Shear = shear; + } + } + + private static TerrainGrain GrainFor(long seed) + { + ulong u = Noise.Hash(seed ^ 0x06172A17, 5, 9, 2); + bool swap = (u & 1UL) != 0; + int stretch = 3 + (int)((u >> 1) & 0x3) % 3; // 3..5 + int shear = (int)((u >> 3) & 0x3) - 1; // −1..2 + if (shear == 0 && ((u >> 5) & 1UL) != 0) + { + shear = 2; // keep a healthy share of visibly diagonal worlds + } + + return new TerrainGrain(swap, stretch, shear); + } + + /// Samples an FBM field in this world's grain direction (#700): the primary axis is integer- + /// stretched (whole extra wrap laps → seam-exact) and the cross axis sheared in units of its own + /// period, so the torus wrap stays seamless on both axes for any world size. + private double GrainFbm(long seed, in TerrainGrain g, int worldX, int worldZ, double scale, int octaves) + { + double period = LatPeriod; + return g.Swap + ? FbmT(seed, worldX + g.Shear * (_circumference / period) * worldZ, (double)worldZ * g.Stretch, scale, octaves) + : FbmT(seed, (double)worldX * g.Stretch, worldZ + g.Shear * (period / _circumference) * worldX, scale, octaves); + } + + /// An oriented ridge value in [−1,1] for mountain-chain shaping (#700): crests elongate along + /// the world's grain instead of blobbing isotropically. + private double OrientedRidge(long seed, in TerrainGrain g, int worldX, int worldZ, double scale) + { + double o = GrainFbm(seed + 0x06172A18, g, worldX, worldZ, scale, octaves: 2); + return (1.0 - System.Math.Abs(2.0 * o - 1.0)) * 2.0 - 1.0; + } + + // --- Continents & real oceans (#704, new worlds only): a very-low-frequency continentalness field + // pushes eligible large planets into a BIMODAL height regime — continental platform vs ocean basin, + // joined by a smoothstep shelf. Everything else (styles, archetypes, landmarks, sea percentile, + // rivers, beaches) simply rides on top. Domain-warped so coastlines are ragged, not blobby. --- + // The size gate: planets only (moons are 2500–4000). Lowered 8000 → 6000 (user decision 2026-08-03) + // so the START world (body key "varied" hashes to circumference 6064 in every save) is eligible — + // at 8000 no player ever saw a continent without flying to a big planet first. + public const int ContinentMinCircumference = 6000; + + private readonly struct ContinentProfile + { + public readonly bool Active; + public readonly double Wavelength; // continent pitch (blocks): circumference / 4..7 + public readonly double Threshold; // continentalness above this is land + public readonly double Shelf; // half-width of the shelf smoothstep, in field units + public readonly double BasinDepth; // ocean basin floor below base (35..60) + public readonly double Lift; // platform lift above base (6..14) + public readonly double LandFrac; // rolled target land fraction (0.25..0.55) + + public ContinentProfile(bool active, double wavelength, double threshold, double shelf, + double basinDepth, double lift, double landFrac) + { + Active = active; + Wavelength = wavelength; + Threshold = threshold; + Shelf = shelf; + BasinDepth = basinDepth; + Lift = lift; + LandFrac = landFrac; + } + } + + /// This body's continent roll (#704). Inactive unless the world was created with continents, + /// the body is a large planet (≥ ), carries a sea to relocate + /// (water — or lava on the lava/ashen worlds: basalt continents in a lava ocean), and wins the ~50 % + /// per-body roll. The ocean type keeps its 78–97 %-flooded identity and never rolls continents. + private ContinentProfile ContinentProfileFor(PlanetType planet, long seed) + { + if (!_continentsEnabled || _circumference < ContinentMinCircumference + || planet.Void || planet.Cratered || _crateredWorld || planet.FloatingIslands) + { + return default; + } + + bool hasAir = !string.Equals(planet.Atmosphere, "none", System.StringComparison.OrdinalIgnoreCase); + if (!hasAir) + { + return default; + } + + double waterAb = planet.WaterAbundance ?? 0.55; + bool volcanic = planet.SurfaceBlock == "basalt" || planet.DeepBlock == "basalt"; + bool lavaOcean = (planet.LavaAbundance ?? (volcanic ? 0.7 : 0.0)) > 0.0 + && (string.Equals(planet.Key, "lava", System.StringComparison.OrdinalIgnoreCase) + || string.Equals(planet.Key, "ashen", System.StringComparison.OrdinalIgnoreCase)); + if (waterAb >= 1.0 || (waterAb < 0.3 && !lavaOcean)) + { + return default; + } + + ulong u = Noise.Hash(seed ^ 0x0C047A9E, 7, 7, 7); + if ((u & 0xFF) >= 128) + { + return default; // ~50 % of eligible bodies stay classic noise-coast worlds + } + + double k = 4.0 + ((u >> 8) & 0x3); // 4..7 → 1–2 supercontinents .. many landmasses + double landFrac = 0.25 + ((u >> 12) & 0x3FF) / 1023.0 * 0.30; // 25..55 % land + double threshold = 0.62 - landFrac * 0.35; // rough map; the sea percentile makes it exact + double shelf = 0.02 + ((u >> 22) & 0xFF) / 255.0 * 0.025; + double basin = 35.0 + ((u >> 30) & 0x3FF) / 1023.0 * 25.0; // 35..60 + double lift = 6.0 + ((u >> 40) & 0xFF) / 255.0 * 8.0; // 6..14 + return new ContinentProfile(true, _circumference / k, threshold, shelf, basin, lift, landFrac); + } + + /// The continental platform/basin offset at a column (#704), 0 on non-continental worlds. + /// Domain-warped continentalness → smoothstep between −BasinDepth and +Lift across the shelf band. + /// Where the field hovers near the threshold the shelf yields shallow seas and island arcs. + private double ContinentOffset(PlanetType planet, long seed, int worldX, int worldZ) + { + var p = ContinentProfileFor(planet, seed); + if (!p.Active) + { + return 0.0; + } + + double warpAmp = p.Wavelength * 0.1; + double wx = worldX + (FbmT(seed + 0x0C047AA0, worldX, worldZ, p.Wavelength / 3.0, octaves: 2) - 0.5) * 2.0 * warpAmp; + double wz = worldZ + (FbmT(seed + 0x0C047AA1, worldX, worldZ, p.Wavelength / 3.0, octaves: 2) - 0.5) * 2.0 * warpAmp; + double c = FbmT(seed + 0x0C047AA2, wx, wz, p.Wavelength, octaves: 3); + double m = System.Math.Clamp((c - (p.Threshold - p.Shelf)) / (2.0 * p.Shelf), 0.0, 1.0); + m = m * m * (3.0 - 2.0 * m); + return -p.BasinDepth + m * (p.BasinDepth + p.Lift); + } + + // ================= Overhang landforms (#705/#706/#707) ================= + // The engine's terrain is a strict heightfield; the floating-island slab proved a SECOND solid band + // per column works end to end. #705 generalises that: a column may carry up to a few extra bands + // (island tiers, arch bars, pillar caps, cenote lips, island waterfalls), each filled by Generate. + + /// What an extra column band is made of (#705). Island reproduces the classic sky-island + /// fill bit-for-bit; IslandPond is an island whose top cell is water; Cap is bare rock (arch bars, + /// hoodoo/sea-stack caps, cenote lips); Waterfall is a standing column of water. + public enum BandKind : byte + { + Island = 0, + IslandPond = 1, + Cap = 2, + Waterfall = 3, + } + + /// One extra solid/fluid band of a column (#705), in inclusive world-Y coordinates. + public struct ColumnBand + { + public int Bottom; + public int Top; + public BandKind Kind; + } + + /// Max extra bands a column can carry (#705): 3 island tiers + a cap + a waterfall. + public const int MaxColumnBands = 6; + + /// Collects every extra band covering this column (#705): sky-island tiers (with ponds, + /// stalactites and edge waterfalls, #707), arch bars, sea-stack and hoodoo caps and cenote lips + /// (#706/#707). Returns the band count written into . The single source of + /// truth — Generate, placement queries and tests all agree on what hangs where. + public int GetExtraBands(PlanetType planet, int worldX, int worldZ, System.Span bands) + { + int n = 0; + long seed = PlanetSeed(planet); + if (planet.FloatingIslands) + { + int tiers = SkyTiersFor(seed); + for (int t = 0; t < tiers && n < bands.Length; t++) + { + if (FloatingIslandTier(planet, seed, t, worldX, worldZ, out int top, out int bottom, out double it)) + { + var kind = BandKind.Island; + double pond = FbmT(seed + 0x5C1A7F + t * 0x1010, worldX, worldZ, planet.TerrainScale * 0.8, octaves: 2); + if (pond > 0.62 && it > 0.55) + { + kind = BandKind.IslandPond; // a meadow pool sunk into the island top (#707) + } + + bands[n++] = new ColumnBand { Bottom = bottom, Top = top, Kind = kind }; + + // Endless waterfall (#707): island ponds spill over the rim — a standing water column + // from just under the island down to the ground (or the sea it plunges into). + if (pond > 0.62 && it > 0.02 && it <= 0.10 && n < bands.Length) + { + int ground = SurfaceHeight(planet, worldX, worldZ); + if (ground + 1 < bottom - 1) + { + bands[n++] = new ColumnBand { Bottom = ground + 1, Top = bottom - 1, Kind = BandKind.Waterfall }; + } + } + } + } + } + + if (n < bands.Length && HasArches(planet) && TryGetArchBar(planet, seed, worldX, worldZ, out int abLo, out int abHi)) + { + bands[n++] = new ColumnBand { Bottom = abLo, Top = abHi, Kind = BandKind.Cap }; + } + + if (n < bands.Length && HasSeaStacks(planet) && TryGetSeaStackCap(planet, seed, worldX, worldZ, out int scLo, out int scHi)) + { + bands[n++] = new ColumnBand { Bottom = scLo, Top = scHi, Kind = BandKind.Cap }; + } + + if (n < bands.Length && HasHoodoos(planet) && TryGetHoodooCap(planet, seed, worldX, worldZ, out int hcLo, out int hcHi)) + { + bands[n++] = new ColumnBand { Bottom = hcLo, Top = hcHi, Kind = BandKind.Cap }; + } + + if (n < bands.Length && HasCenotes(planet) && TryGetCenoteLip(planet, seed, worldX, worldZ, out int clLo, out int clHi)) + { + bands[n++] = new ColumnBand { Bottom = clLo, Top = clHi, Kind = BandKind.Cap }; + } + + return n; + } + + /// True when any band-producing feature can exist on this world at all — Generate's cheap + /// whole-chunk gate so classic worlds pay nothing for #705. + public bool HasExtraBands(PlanetType planet) + => planet.FloatingIslands || HasArches(planet) || HasSeaStacks(planet) || HasHoodoos(planet) || HasCenotes(planet); + + // --- Multi-tier skylands (#707): floating worlds roll 1–3 island tiers; tier 0 is the classic band + // (same seeds and shaping, so existing sky worlds keep their islands), upper tiers stack ~36 blocks + // apart. All tiers grow rocky stalactite tapers where a high-frequency mask spikes. --- + private static int SkyTiersFor(long seed) => 1 + (int)(Noise.Hash(seed ^ 0x5C1A7E, 3, 3, 3) % 3UL); + + private bool FloatingIslandTier(PlanetType planet, long seed, int tier, int worldX, int worldZ, + out int top, out int bottom, out double t) + { + top = int.MinValue; + bottom = int.MaxValue; + t = 0.0; + double im = FbmT(seed + 0x15A4D + tier * 0x1010, worldX, worldZ, planet.TerrainScale * 1.4, octaves: 3); + if (im <= 0.60) + { + return false; + } + + t = (im - 0.60) / 0.40; + double alt = FbmT(seed + 0x15A4E + tier * 0x1010, worldX, worldZ, planet.TerrainScale * 3.0, octaves: 2); + int center = planet.BaseHeight + 28 + tier * 36 + (int)((alt - 0.5) * 24.0); + int half = 2 + (int)(t * 8.0); + top = center + half; + bottom = center - half - (int)(t * 6.0); + + // Stalactites (#707): rocky icicles hanging beneath the islands. + double sp = FbmT(seed + 0x57A1AC + tier, worldX, worldZ, 9.0, octaves: 2); + if (sp > 0.70) + { + bottom -= (int)((sp - 0.70) / 0.30 * 8.0); + } + + return true; + } + + // --- Natural arches & rock bridges (#706): two steep abutment pillars joined by a solid bar band — + // the first freestanding overhang landform. Dry rocky worlds (the table-mountain gate). --- + private const double ArchCellSize = 1900.0; + private const double ArchChance = 0.45; // visibility tuning 2026-08-03 + private const double ArchMargin = 60.0; + + private bool HasArches(PlanetType planet) => HasTableMountains(planet); + + private bool TryGetArchGeometry(long seed, int worldX, int worldZ, + out double along, out double across, out double halfSpan, out double abutR, out double height, + out double barHalfW, out double barThick, out ulong hash, out double dx, out double dz) + { + along = across = halfSpan = abutR = height = barHalfW = barThick = 0.0; + if (!TryGetHotspot(seed ^ 0xA2C4B0, ArchCellSize, ArchChance, ArchMargin, + worldX, worldZ, out hash, out dx, out dz)) + { + return false; + } + + int di = (int)((hash >> 20) & 0x7); + double dirLen = System.Math.Sqrt((double)(ChainDirX[di] * ChainDirX[di] + ChainDirZ[di] * ChainDirZ[di])); + double ux = ChainDirX[di] / dirLen, uz = ChainDirZ[di] / dirLen; + along = dx * ux + dz * uz; + across = -dx * uz + dz * ux; + halfSpan = 8.0 + ((hash >> 24) & 0x7); // 8..15 + abutR = 4.0 + ((hash >> 28) & 0x3); // 4..7 + height = 14.0 + ((hash >> 32) & 0xF) * 0.8; // 14..26 + barHalfW = 2.5 + ((hash >> 36) & 0x3) * 0.7; // 2.5..4.6 + barThick = 2.0 + ((hash >> 38) & 0x1); // 2..3 + return true; + } + + /// The arch abutments' steep ground rise at a column (#706), 0 elsewhere. + private double ArchGroundOffset(long seed, int worldX, int worldZ) + { + if (!TryGetArchGeometry(seed, worldX, worldZ, out double along, out double across, + out double halfSpan, out double abutR, out double height, out _, out _, out _, out _, out _)) + { + return 0.0; + } + + double da = System.Math.Min( + System.Math.Sqrt((along + halfSpan) * (along + halfSpan) + across * across), + System.Math.Sqrt((along - halfSpan) * (along - halfSpan) + across * across)); + if (da > abutR) + { + return 0.0; + } + + return height * System.Math.Pow(1.0 - da / abutR, 0.25); // near-vertical pillar, flat-ish top + } + + /// The arch's bridging bar band at a column (#706): a solid rock beam spanning the two + /// abutments at their top height, anchored to the pre-landmark ground under the feature centre. + private bool TryGetArchBar(PlanetType planet, long seed, int worldX, int worldZ, out int bottom, out int top) + { + bottom = 0; + top = -1; + if (!TryGetArchGeometry(seed, worldX, worldZ, out double along, out double across, + out double halfSpan, out _, out double height, out double barHalfW, out double barThick, out _, + out double dx, out double dz)) + { + return false; + } + + if (System.Math.Abs(along) > halfSpan || System.Math.Abs(across) > barHalfW) + { + return false; + } + + // The bar sags slightly toward mid-span, like a real rock bridge; it anchors to the pre-landmark + // ground under the feature CENTRE so the beam is level regardless of the terrain under each end. + double sag = 1.5 * (1.0 - System.Math.Abs(along) / halfSpan); + int anchor = RawSurfaceHeight(planet, worldX - (int)System.Math.Round(dx), worldZ - (int)System.Math.Round(dz)); + top = anchor + (int)System.Math.Round(height - sag); + bottom = top - (int)barThick + 1; + return true; + } + + // --- Sea stacks (#706): pillars standing in the surf just off low coasts, some mushroom-capped. --- + private const double SeaStackCellSize = 260.0; + private const double SeaStackChance = 0.25; // visibility tuning 2026-08-03 + private const double SeaStackMargin = 16.0; + + private bool HasSeaStacks(PlanetType planet) + { + if (planet.Void || planet.Cratered || _crateredWorld || planet.FloatingIslands) + { + return false; + } + + bool hasAir = !string.Equals(planet.Atmosphere, "none", System.StringComparison.OrdinalIgnoreCase); + double waterAb = planet.WaterAbundance ?? (hasAir ? 0.55 : 0.0); + return hasAir && waterAb >= 0.5; + } + + private bool TryGetSeaStack(PlanetType planet, long seed, int worldX, int worldZ, + out double dist, out double stemR, out double rise, out int anchor, out ulong hash) + { + dist = stemR = rise = 0.0; + anchor = 0; + if (!TryGetHotspot(seed ^ 0x5EA57ACC, SeaStackCellSize, SeaStackChance, SeaStackMargin, + worldX, worldZ, out hash, out double dx, out double dz)) + { + return false; + } + + // Stacks only rise from LOW ground (coasts + shallows): probe the base swell at the stack centre. + int cx = worldX - (int)System.Math.Round(dx); + int cz = worldZ - (int)System.Math.Round(dz); + double hswell = (FbmT(seed, cx, cz, planet.TerrainScale, octaves: 4) - 0.5) * 2.0; + if (hswell > -0.05) + { + return false; + } + + dist = System.Math.Sqrt(dx * dx + dz * dz); + stemR = 2.0 + ((hash >> 16) & 0x3); // 2..5 + rise = 14.0 + ((hash >> 20) & 0x7); // 14..21 + anchor = RawSurfaceHeight(planet, cx, cz); + return true; + } + + /// The sea stack's stem rise at a column (#706), 0 elsewhere. + private double SeaStackGroundOffset(PlanetType planet, long seed, int worldX, int worldZ) + { + if (!TryGetSeaStack(planet, seed, worldX, worldZ, out double dist, out double stemR, out double rise, out _, out _) + || dist > stemR) + { + return 0.0; + } + + return rise * System.Math.Pow(1.0 - dist / stemR, 0.2); // a sheer pillar + } + + /// The sea stack's overhanging mushroom cap (#706): a rock disc two blocks wider than the + /// stem, sitting on the pillar top. Only ~60 % of stacks carry one — the rest stay sheer. + private bool TryGetSeaStackCap(PlanetType planet, long seed, int worldX, int worldZ, out int bottom, out int top) + { + bottom = 0; + top = -1; + if (!TryGetSeaStack(planet, seed, worldX, worldZ, out double dist, out double stemR, out double rise, + out int anchor, out ulong h) + || ((h >> 28) & 0xFF) >= 154 // ~60 % capped + || dist > stemR + 2.0) + { + return false; + } + + top = anchor + (int)System.Math.Round(rise) + 1; + bottom = top - 1 - (int)((h >> 36) & 0x1); + return true; + } + + // --- Hoodoos / fairy chimneys (#706): dense fields of thin spires balancing wider caprocks. --- + private const double HoodooCellSize = 24.0; + private const double HoodooChance = 0.5; + private const double HoodooRegionThreshold = 0.55; // visibility tuning 2026-08-03: broader fields + + private bool HasHoodoos(PlanetType planet) => (planet.TerrainStyle?.ToLowerInvariant()) switch + { + "badlands" or "tablelands" or "mesa" or "canyons" => !planet.Void && !planet.Cratered && !_crateredWorld, + _ => false, + }; + + private bool HoodooRegionAt(PlanetType planet, long seed, int worldX, int worldZ) + => FbmT(seed + 0x400D00, worldX, worldZ, planet.TerrainScale * 1.6, octaves: 2) > HoodooRegionThreshold; + + private bool TryGetHoodoo(PlanetType planet, long seed, int worldX, int worldZ, + out double dist, out double rise, out int anchor, out ulong hash) + { + dist = rise = 0.0; + anchor = 0; + hash = 0; + if (!HoodooRegionAt(planet, seed, worldX, worldZ) + || !TryGetHotspot(seed ^ 0x400D01, HoodooCellSize, HoodooChance, 4.0, + worldX, worldZ, out hash, out double dx, out double dz)) + { + return false; + } + + dist = System.Math.Sqrt(dx * dx + dz * dz); + rise = 6.0 + ((hash >> 16) & 0x7); // 6..13 + anchor = RawSurfaceHeight(planet, worldX - (int)System.Math.Round(dx), worldZ - (int)System.Math.Round(dz)); + return true; + } + + /// The hoodoo's thin stem rise at a column (#706), 0 elsewhere. + private double HoodooGroundOffset(PlanetType planet, long seed, int worldX, int worldZ) + { + if (!TryGetHoodoo(planet, seed, worldX, worldZ, out double dist, out double rise, out _, out _) + || dist > 1.6) + { + return 0.0; + } + + return rise * System.Math.Pow(1.0 - dist / 1.6, 0.2); + } + + /// The hoodoo's caprock band (#706): a wider dark disc balanced on the stem. + private bool TryGetHoodooCap(PlanetType planet, long seed, int worldX, int worldZ, out int bottom, out int top) + { + bottom = 0; + top = -1; + if (!TryGetHoodoo(planet, seed, worldX, worldZ, out double dist, out double rise, out int anchor, out ulong h) + || dist > 2.6) + { + return false; + } + + top = anchor + (int)System.Math.Round(rise) + 1; + bottom = top - (int)((h >> 20) & 0x1); // 1–2 thick + return true; + } + + /// Whether any overhang landmark's GROUND geometry (abutments, stems) can exist here (#706). + private bool HasOverhangLandmarks(PlanetType planet) + => HasArches(planet) || HasSeaStacks(planet) || HasHoodoos(planet); + + /// The overhang landmarks' ground rise at a column (#706): arch abutments, then sea-stack + /// stems, then hoodoo stems — first hit wins (they share the one-landmark-per-column rule). + private double OverhangGroundOffset(PlanetType planet, long seed, int worldX, int worldZ) + { + double o = HasArches(planet) ? ArchGroundOffset(seed, worldX, worldZ) : 0.0; + if (o == 0.0 && HasSeaStacks(planet)) + { + o = SeaStackGroundOffset(planet, seed, worldX, worldZ); + } + + if (o == 0.0 && HasHoodoos(planet)) + { + o = HoodooGroundOffset(planet, seed, worldX, worldZ); + } + + return o; + } + + // --- Cenotes (#707): sudden circular shafts dropping 30–80 blocks from green ground straight into + // the cave layer — with an overhanging ring lip, and a turquoise pool where the world is wet. --- + private const double CenoteCellSize = 800.0; + private const double CenoteChance = 0.45; // visibility tuning 2026-08-03 + private const double CenoteMaxRadius = 20.0; // …and bigger, so a shaft reads as a place, not a pothole + + private bool HasCenotes(PlanetType planet) + { + if (planet.Void || planet.Cratered || _crateredWorld || planet.FloatingIslands + || planet.CaveThreshold <= 0.0) + { + return false; + } + + bool hasAir = !string.Equals(planet.Atmosphere, "none", System.StringComparison.OrdinalIgnoreCase); + double waterAb = planet.WaterAbundance ?? (hasAir ? 0.55 : 0.0); + return hasAir && waterAb > 0.3; + } + + private bool TryGetCenote(PlanetType planet, long seed, int worldX, int worldZ, + out double dist, out double radius, out double depth, out int anchor, out ulong hash) + { + dist = radius = depth = 0.0; + anchor = 0; + if (!TryGetHotspot(seed ^ 0x0CE07E, CenoteCellSize, CenoteChance, CenoteMaxRadius + 6.0, + worldX, worldZ, out hash, out double dx, out double dz)) + { + return false; + } + + dist = System.Math.Sqrt(dx * dx + dz * dz); + radius = 9.0 + ((hash >> 16) & 0x7) * 1.5; // 9..19.5 + depth = 30.0 + ((hash >> 20) & 0x3F) * 0.8; // 30..80 + anchor = RawSurfaceHeight(planet, worldX - (int)System.Math.Round(dx), worldZ - (int)System.Math.Round(dz)); + return true; + } + + /// The cenote shaft's (negative) offset at a column (#707): near-vertical walls to a flat + /// floor. Caves open into the shaft walls for free — the mesher exposes every cave cell the shaft + /// face touches. + private double CenoteOffset(PlanetType planet, long seed, int worldX, int worldZ) + { + if (!TryGetCenote(planet, seed, worldX, worldZ, out double dist, out double radius, out double depth, out _, out _) + || dist > radius) + { + return 0.0; + } + + double t = System.Math.Min(1.0, (1.0 - dist / radius) / 0.15); + return -depth * (t * t * (3.0 - 2.0 * t)); + } + + /// The cenote's overhanging ring lip (#707): a rock band at the original ground level + /// reaching over the shaft's outer edge. + private bool TryGetCenoteLip(PlanetType planet, long seed, int worldX, int worldZ, out int bottom, out int top) + { + bottom = 0; + top = -1; + if (!TryGetCenote(planet, seed, worldX, worldZ, out double dist, out double radius, out _, out int anchor, out _) + || dist < radius * 0.82 || dist > radius * 0.98) + { + return false; + } + + top = anchor; + bottom = anchor - 1; + return true; + } + + /// The cenote pool (#707): on wet worlds ~60 % of cenotes hold water over their floor. + /// Outputs the pool's flat top Y (anchored to the feature centre), for Generate's fluid override. + public bool TryGetCenotePool(PlanetType planet, int worldX, int worldZ, out int poolTopY) + { + poolTopY = 0; + if (!HasCenotes(planet)) + { + return false; + } + + long seed = PlanetSeed(planet); + if (!TryGetCenote(planet, seed, worldX, worldZ, out double dist, out double radius, out double depth, + out int anchor, out ulong h) + || dist >= radius || ((h >> 40) & 0xFF) >= 154) + { + return false; + } + + double waterAb = planet.WaterAbundance ?? 0.55; + if (waterAb < 0.45) { - dz += period; + return false; } + poolTopY = anchor - (int)depth + 4 + (int)((h >> 48) & 0x3); // 4..7 deep pool over the floor return true; } - private const double ButteCellSize = 1600.0; // hotspot pitch (≈8 candidate cells on a default world) - private const double ButteChance = 0.40; // fraction of cells that grow a table mountain - private const double ButteMaxRadius = 120.0; + // --- Crevasse fields (#709): narrow deep slits across the ice of the coldest worlds. --- + private const double CrevasseCellSize = 500.0; + private const double CrevasseChance = 0.30; + private const double CrevasseMaxHalfLen = 80.0; - /// Table mountains grow on dry, rocky-reading worlds (#577) — dune/mesa/canyon-style terrain - /// plus the savanna — never on airless bodies, sky worlds or void interiors. - private bool HasTableMountains(PlanetType planet) + private bool HasCrevasses(PlanetType planet) { if (planet.Void || planet.Cratered || _crateredWorld || planet.FloatingIslands) { return false; } - return (planet.TerrainStyle?.ToLowerInvariant()) switch - { - "dunes" or "mesa" or "canyons" or "tablelands" or "badlands" => true, - _ => string.Equals(planet.Key, "savanna", System.StringComparison.OrdinalIgnoreCase), - }; + return !string.Equals(planet.Atmosphere, "none", System.StringComparison.OrdinalIgnoreCase) + && planet.BaseTemperature <= -8.0; } - /// The table mountain's height contribution at a column, or 0 if none covers it (#577): a - /// talus foot steepening into a near-vertical upper wall (outer 30 % of the radius), then a dead-flat - /// cap with a light rock roll so the top reads as stone, not glass. - private double TableMountainOffset(long seed, int worldX, int worldZ) + /// The crevasse's (negative) offset at a column (#709): a thin, steep slit 8–18 deep. + private double CrevasseOffset(long seed, int worldX, int worldZ) { - if (!TryGetHotspot(seed ^ 0x7AB1E0, ButteCellSize, ButteChance, ButteMaxRadius + 20.0, - worldX, worldZ, out ulong h, out double dx, out double dz)) + if (!TryGetHotspot(seed ^ 0x0C2EFA55, CrevasseCellSize, CrevasseChance, + CrevasseMaxHalfLen + 8.0, worldX, worldZ, out ulong h, out double dx, out double dz)) { return 0.0; } - double radius = 40.0 + ((h >> 16) & 0x3FF) / 1023.0 * (ButteMaxRadius - 40.0); // 40..120 - double dist = System.Math.Sqrt(dx * dx + dz * dz); - if (dist > radius) + double angle = ((h >> 16) & 0x3FF) / 1023.0 * System.Math.PI; + double halfLen = 30.0 + ((h >> 26) & 0x3FF) / 1023.0 * (CrevasseMaxHalfLen - 30.0); + double halfWidth = 1.5 + ((h >> 56) & 0xFF) / 255.0 * 2.0; // 1.5..3.5 + double cos = System.Math.Cos(angle); + double sin = System.Math.Sin(angle); + double along = dx * cos + dz * sin; + double across = -dx * sin + dz * cos; + if (System.Math.Abs(along) > halfLen || System.Math.Abs(across) > halfWidth) { return 0.0; } - double height = 30.0 + ((h >> 26) & 0x3FF) / 1023.0 * 40.0; // 30..70 - double t = 1.0 - dist / radius; - if (t >= 0.30) - { - double roll = FbmT(seed + 0x7AB2E, worldX, worldZ, 24.0, octaves: 2); - return height + (roll - 0.5) * 2.0; // the table top - } - - return height * System.Math.Pow(t / 0.30, 1.8); // talus foot → near-vertical upper wall + ulong h2 = h * 0x9E3779B97F4A7C15UL; + double depth = 8.0 + ((h2 >> 20) & 0x3FF) / 1023.0 * 10.0; // 8..18 + double w = 1.0 - System.Math.Abs(across) / halfWidth; + double wall = w >= 0.35 ? 1.0 : (w / 0.35) * (w / 0.35) * (3.0 - 2.0 * (w / 0.35)); + double endT = 1.0 - System.Math.Abs(along) / halfLen; + double taper = endT >= 0.2 ? 1.0 : (endT / 0.2) * (endT / 0.2) * (3.0 - 2.0 * (endT / 0.2)); + return -depth * wall * taper; } - private const double MassifCellSize = 3200.0; // very sparse: ~1 in 5 default worlds carries a massif - private const double MassifChance = 0.10; // decision "bold but varied": a massif is a FIND, not the norm - private const double MassifMaxRadius = 300.0; + // --- Underground mega-caverns (#707): rare ellipsoid voids 60–140 wide deep below the surface, + // some holding a still lake (water above the lava table, molten below). Crystal glints stud the + // shell so the find reads as a wonder, not a glitch. --- + private const double CavernCellSize = 1700.0; + private const double CavernChance = 0.22; + private const double CavernMaxRx = 70.0; - /// Massifs — rare single giant mountains, visible from very far — grow on any solid-ground - /// world with an atmosphere (#578); airless/cratered bodies are geologically dead, sky worlds stay - /// floaty, void interiors have no terrain. - private bool HasMassifs(PlanetType planet) + private bool HasCaverns(PlanetType planet) + => !planet.Void && planet.CaveThreshold > 0.0; + + /// The mega-cavern's vertical air span at this column (#707), with the lake surface (or + /// int.MinValue when this cavern rolled dry). False when no cavern covers the column. + public bool TryGetCavernSpan(PlanetType planet, int worldX, int worldZ, + out int yLo, out int yHi, out int lakeY) { - if (planet.Void || planet.Cratered || _crateredWorld || planet.FloatingIslands) + yLo = yHi = 0; + lakeY = int.MinValue; + if (!HasCaverns(planet)) { return false; } - return !string.Equals(planet.Atmosphere, "none", System.StringComparison.OrdinalIgnoreCase); + long seed = PlanetSeed(planet); + if (!TryGetHotspot(seed ^ 0x0CAFE27A, CavernCellSize, CavernChance, CavernMaxRx + 10.0, + worldX, worldZ, out ulong h, out double dx, out double dz)) + { + return false; + } + + double rx = 30.0 + ((h >> 16) & 0x3FF) / 1023.0 * (CavernMaxRx - 30.0); // 30..70 + double rz = rx * (0.75 + ((h >> 26) & 0xFF) / 255.0 * 0.5); // slightly oval + double ry = 14.0 + ((h >> 34) & 0x3FF) / 1023.0 * 14.0; // 14..28 tall + double q = 1.0 - (dx / rx) * (dx / rx) - (dz / rz) * (dz / rz); + if (q <= 0.0) + { + return false; + } + + int cy = planet.BaseHeight - 90 - (int)((h >> 44) & 0x3F); // 90..153 below base + double half = ry * System.Math.Sqrt(q); + yLo = cy - (int)half; + yHi = cy + (int)half; + if (((h >> 50) & 0x1) != 0) + { + lakeY = cy - (int)(ry * (0.35 + ((h >> 52) & 0x3) * 0.05)); // lake fills the lower bowl + } + + return true; } - /// The massif's height contribution at a column, or 0 if none covers it (#578): a broad cone - /// with ridged flanks (spurs + gullies from a mid-frequency field); the summit is capped at the rolled - /// height so flank noise sculpts the sides, never the peak — and the roll itself is clamped so the - /// summit stays under with margin for the underlying swell. - private double MassifOffset(PlanetType planet, long seed, int worldX, int worldZ) + // ================= Tunnel carver (#708) ================= + // One deterministic worm carver: each hotspot cell may host a seeded polyline whose capsule radius + // varies along its length. Carved during the vertical fill like the blob caves — but tunnels may + // reach the surface, so caves finally have MOUTHS (#709). On volcano worlds a share of the worms + // roll wide and smooth: lava tubes, with occasional skylight shafts where the roof thins. + private const double TunnelCellSize = 700.0; + private const double TunnelChance = 0.5; + private const double TunnelMargin = 320.0; + private const int TunnelMaxSpans = 6; + + private bool HasTunnels(PlanetType planet) + => !planet.Void && planet.CaveThreshold > 0.0; + + /// Computes this column's tunnel-carve y-spans (#708) into and + /// returns the count. Deterministic per (seed, column); the polyline is rebuilt from the cell hash + /// (cheap arithmetic), so no state is shared between server, client and tests. + public int TunnelSpans(PlanetType planet, int worldX, int worldZ, System.Span<(int Lo, int Hi)> spans) { - if (!TryGetHotspot(seed ^ 0x3A551F, MassifCellSize, MassifChance, MassifMaxRadius + 20.0, + if (!HasTunnels(planet)) + { + return 0; + } + + long seed = PlanetSeed(planet); + if (!TryGetHotspot(seed ^ 0x7A22E1, TunnelCellSize, TunnelChance, TunnelMargin, worldX, worldZ, out ulong h, out double dx, out double dz)) { - return 0.0; + return 0; } - double radius = 150.0 + ((h >> 16) & 0x3FF) / 1023.0 * (MassifMaxRadius - 150.0); // 150..300 - double dist = System.Math.Sqrt(dx * dx + dz * dz); - if (dist > radius) + // Rebuild the worm from its cell hash — an xorshift stream keeps the rolls cheap + portable. + ulong s = h | 1UL; + double Next() { - return 0.0; + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + return (s & 0xFFFFF) / 1048576.0; } - double height = 120.0 + ((h >> 26) & 0x3FF) / 1023.0 * 100.0; // 120..220 - height = System.Math.Min(height, MaxNaturalSurfaceY - 16.0 - planet.BaseHeight); + bool tube = HasVolcanoes(planet) && Next() < 0.3; // lava tubes: wider, smoother, shallower (#709) + int segs = tube ? 5 + (int)(Next() * 4) : 6 + (int)(Next() * 7); + double px = 0.0, pz = 0.0; + double py = planet.BaseHeight - (tube ? 4.0 + Next() * 10.0 : 8.0 + Next() * 26.0); + double vx = Next() * 2.0 - 1.0, vz = Next() * 2.0 - 1.0; + double vlen = System.Math.Sqrt(vx * vx + vz * vz); + if (vlen < 0.2) { vx = 1.0; vz = 0.0; vlen = 1.0; } + vx /= vlen; + vz /= vlen; - double t = 1.0 - dist / radius; - double flank = FbmT(seed + 0x3A552F, worldX, worldZ, 90.0, octaves: 2); - return height * System.Math.Min(1.0, System.Math.Pow(t, 1.5) * (0.75 + 0.5 * flank)); - } + int n = 0; + for (int i = 0; i < segs && n < spans.Length; i++) + { + double len = 24.0 + Next() * 20.0; + double drift = tube ? 0.15 : 0.35; + double vy = (Next() - 0.55) * drift; // worms trend gently downward + double qx = px + vx * len, qz = pz + vz * len, qy = py + vy * len; + double lim = TunnelMargin - 24.0; + qx = System.Math.Clamp(qx, -lim, lim); + qz = System.Math.Clamp(qz, -lim, lim); + double radius = (tube ? 4.5 + Next() * 2.0 : 2.5 + Next() * 2.0); + + AddSegmentSpan(px, py, pz, qx, qy, qz, radius, dx, dz, spans, ref n); + + // Skylight shaft (#709): sometimes the roof opens to the sky — a thin vertical worm from the + // segment end up past the surface, so tunnels get real MOUTHS you can climb into. + // (0.12 → 0.22, visibility tuning 2026-08-03: mouths are how players FIND the tunnels.) + if (Next() < 0.22 && n < spans.Length) + { + AddSegmentSpan(qx, qy, qz, qx, planet.BaseHeight + 60.0, qz, 1.7, dx, dz, spans, ref n); + } - private const double RiftCellSize = 2400.0; - private const double RiftChance = 0.15; // ~1 in 4 worlds — a gorge is a discovery, not scenery - private const double RiftMaxHalfLen = 500.0; - private const double RiftMaxHalfWidth = 28.0; + px = qx; py = qy; pz = qz; + double turn = (Next() - 0.5) * 0.9; + double nvx = vx + turn * -vz, nvz = vz + turn * vx; // cheap heading drift, no trig + double nl = System.Math.Sqrt(nvx * nvx + nvz * nvz); + vx = nvx / nl; + vz = nvz / nl; + } - /// Rift chasms cut the same worlds massifs grow on (#578) — solid ground plus an atmosphere. - /// Where the floor dips under the sea level the rift floods into a fjord lake for free (the sea fill - /// is by level), and rivers crossing the rim drop in as waterfalls. - private bool HasRifts(PlanetType planet) => HasMassifs(planet); + return n; + } - /// The rift's (negative) height contribution at a column, or 0 if none covers it (#578): a - /// straight gorge segment with steep walls dropping to a broad floor, tapered toward both ends so the - /// chasm closes naturally instead of ending in a cliff face. - private double RiftOffset(long seed, int worldX, int worldZ) + /// Adds the y-span where the capsule (p→q, radius r) covers the column at local offset + /// (dx, dz) — sampled at 8 points along the segment (the worm is organic; exactness buys nothing). + private static void AddSegmentSpan(double pxx, double pyy, double pzz, double qx, double qy, double qz, + double r, double dx, double dz, System.Span<(int Lo, int Hi)> spans, ref int n) { - if (!TryGetHotspot(seed ^ 0x21F7A9, RiftCellSize, RiftChance, - RiftMaxHalfLen + RiftMaxHalfWidth + 16.0, worldX, worldZ, - out ulong h, out double dx, out double dz)) + double lo = double.MaxValue, hi = double.MinValue; + for (int k = 0; k <= 8; k++) { - return 0.0; - } + double t = k / 8.0; + double sx = pxx + (qx - pxx) * t; + double sz = pzz + (qz - pzz) * t; + double d2 = (dx - sx) * (dx - sx) + (dz - sz) * (dz - sz); + if (d2 > r * r) + { + continue; + } - double angle = ((h >> 16) & 0x3FF) / 1023.0 * System.Math.PI; - double halfLen = 260.0 + ((h >> 26) & 0x3FF) / 1023.0 * (RiftMaxHalfLen - 260.0); // 260..500 - double halfWidth = 14.0 + ((h >> 56) & 0xFF) / 255.0 * (RiftMaxHalfWidth - 14.0); // 14..28 + double sy = pyy + (qy - pyy) * t; + double dy = System.Math.Sqrt(r * r - d2); + if (sy - dy < lo) { lo = sy - dy; } + if (sy + dy > hi) { hi = sy + dy; } + } - double cos = System.Math.Cos(angle); - double sin = System.Math.Sin(angle); - double along = dx * cos + dz * sin; - double across = -dx * sin + dz * cos; - if (System.Math.Abs(along) > halfLen || System.Math.Abs(across) > halfWidth) + if (hi < lo || n >= spans.Length) { - return 0.0; + return; } - // Depth comes from a re-hash — the primary hash's roll bits are spent on placement + shape. - ulong h2 = h * 0x9E3779B97F4A7C15UL; - double depth = 50.0 + ((h2 >> 20) & 0x3FF) / 1023.0 * 80.0; // 50..130 - - double Smooth(double v) => v * v * (3.0 - 2.0 * v); - double w = 1.0 - System.Math.Abs(across) / halfWidth; // 0 rim .. 1 axis - double wall = w >= 0.45 ? 1.0 : Smooth(w / 0.45); // walls in the outer 45 %, flat floor within - double endT = 1.0 - System.Math.Abs(along) / halfLen; - double taper = endT >= 0.15 ? 1.0 : Smooth(endT / 0.15); - return -depth * wall * taper; + spans[n++] = ((int)System.Math.Floor(lo), (int)System.Math.Ceiling(hi)); } /// Height offset (blocks, added to BaseHeight) for a planet with an explicit @@ -717,7 +1950,10 @@ private double StyledHeightOffset(PlanetType planet, string style, long seed, do case "mountains": { - double r = h * 0.25 + Ridge(h) * 0.75; // sharp, rugged + // #700: part of the ridging comes from an oriented field, so ranges form CHAINS along + // the world's grain instead of isotropic knots. + double or = OrientedRidge(seed, GrainFor(seed), worldX, worldZ, planet.TerrainScale * 0.9); + double r = h * 0.25 + Ridge(h) * 0.30 + or * 0.45; // sharp, rugged, directional if (r > 0) { r = System.Math.Pow(r, 1.35); // W-R1 crest sharpening: flatter mid-slopes, prouder peaks @@ -751,7 +1987,10 @@ private double StyledHeightOffset(PlanetType planet, string style, long seed, do case "dunes": { // Parallel wind-blown ridges: a ridged mid-frequency field laid over a gentle base. - double d = FbmT(seed + 0x0D0E, worldX, worldZ, planet.TerrainScale * 0.45, octaves: 2); + // Since #700 the field is sampled in the world's GRAIN — every desert rolls a wind + // direction and its dune crests march that way instead of blobbing isotropically. + var g = GrainFor(seed); + double d = GrainFbm(seed + 0x0D0E, g, worldX, worldZ, planet.TerrainScale * 0.45, octaves: 2); double ridged = 1.0 - System.Math.Abs(d * 2.0 - 1.0); // 0..1 dune crests return h * amp * 0.25 + ridged * amp * 0.85; } @@ -832,9 +2071,11 @@ private readonly struct CraterProfile public readonly double RimHeight; // raised ejecta lip at the crater edge (blocks) public readonly double RimBand; // mask range outside the rim where the lip fades back to flat public readonly double Flatness; // how much of the base swell survives between craters (× amplitude) + public readonly bool ComplexPeaks; // #699: big basins rebound a central peak (rock's own character) + public readonly bool Terraced; // #699: bowl walls step down in ring terraces instead of one slope public CraterProfile(double threshold, double band, double maxDepth, double rimHeight, double rimBand, - double flatness) + double flatness, bool complexPeaks, bool terraced) { Threshold = threshold; Band = band; @@ -842,6 +2083,8 @@ public CraterProfile(double threshold, double band, double maxDepth, double rimH RimHeight = rimHeight; RimBand = rimBand; Flatness = flatness; + ComplexPeaks = complexPeaks; + Terraced = terraced; } } @@ -868,7 +2111,9 @@ private static CraterProfile CraterProfileFor(long seed) maxDepth: 5.0 + 7.0 * R(0x0C1C), // 5 .. 12 blocks at the centre rimHeight: 0.8 + 2.4 * R(0x0C1D), // barely-there lip .. a sharp ejecta wall rimBand: 0.05 + 0.05 * R(0x0C1E), - flatness: 0.18 + 0.27 * R(0x0C1F)); // billiard-table regolith .. noticeably rolling ground + flatness: 0.18 + 0.27 * R(0x0C1F), // billiard-table regolith .. noticeably rolling ground + complexPeaks: R(0x0C20) < 0.45, // #699: ~45 % of bodies rebound central peaks + terraced: R(0x0C21) < 0.40); // #699: ~40 % step their bowl walls if (_craterProfiles.Count >= 512) { @@ -891,7 +2136,27 @@ private double CraterCarve(long seed, int worldX, int worldZ, PlanetType planet) { // Inside the basin: a smooth bowl down to -MaxDepth, with a rim lip right at the edge. double t = System.Math.Min(1.0, d / p.Band); - double bowl = -p.MaxDepth * (t * t * (3.0 - 2.0 * t)); // smoothstep deepening + double s = t * t * (3.0 - 2.0 * t); // smoothstep deepening + if (p.Terraced && p.MaxDepth >= 7.0) + { + // #699: quantise part of the slope into ring terraces (real complex-crater wall slumping). + double q = System.Math.Round(s * 3.0) / 3.0; + s = s * 0.45 + q * 0.55; + } + + double bowl = -p.MaxDepth * s; + if (p.ComplexPeaks && p.MaxDepth >= 7.0) + { + // #699: the far interior of the BIG basins (mask well past the rim band) rebounds a central + // peak — small dishes never reach this zone, so simple craters stay simple. + double big = (d - p.Band) / p.Band; + if (big > 0.0) + { + double pk = System.Math.Min(1.0, big); + bowl += p.MaxDepth * 0.55 * (pk * pk * (3.0 - 2.0 * pk)); + } + } + double lip = p.RimHeight * System.Math.Max(0.0, 1.0 - t * 4.0); // a lip at the edge, gone a quarter in return bowl + lip; } @@ -901,6 +2166,113 @@ private double CraterCarve(long seed, int worldX, int worldZ, PlanetType planet) return p.RimHeight * (1.0 - o); } + // --- Crater chains + ejecta rays (#699): a string of aligned secondary bowls marching away from a + // primary impact, and bright radial rays repainted around the primary. Hotspot-cell features on + // cratered bodies, layered ON TOP of the primary FBM crater field. Trig-free (integer direction + // vectors, dot-product ray tests), so golden-hash tests stay portable. --- + private const double ChainCellSize = 420.0; + private const double ChainChance = 0.30; + private const double ChainMargin = 200.0; + private static readonly int[] ChainDirX = { 1, 0, 1, 1, 2, 1, 2, 1 }; + private static readonly int[] ChainDirZ = { 0, 1, 1, -1, 1, 2, -1, -2 }; + + /// The crater chain's height contribution at a column (#699): 3–6 bowls of shrinking radius + /// spaced along a rolled direction, each a smoothstep dish with a small rim lip. + private double CraterChainCarve(long seed, int worldX, int worldZ) + { + if (!TryGetHotspot(seed ^ 0x0C4A17, ChainCellSize, ChainChance, ChainMargin, + worldX, worldZ, out ulong h, out double dx, out double dz)) + { + return 0.0; + } + + int count = 3 + (int)((h >> 20) & 0x3); // 3..6 bowls + double spacing = 18.0 + ((h >> 24) & 0x7); // 18..25 apart + int di = (int)((h >> 28) & 0x7); + double dirLen = System.Math.Sqrt((double)(ChainDirX[di] * ChainDirX[di] + ChainDirZ[di] * ChainDirZ[di])); + double ux = ChainDirX[di] / dirLen, uz = ChainDirZ[di] / dirLen; + double r0 = 9.0 + ((h >> 32) & 0x7); // primary radius 9..16 + double depth0 = 5.0 + ((h >> 36) & 0x3); // primary depth 5..8 + + double carve = 0.0; + double radius = r0, depth = depth0; + for (int i = 0; i < count; i++) + { + double cx = ux * spacing * i, cz = uz * spacing * i; + double bx = dx - cx, bz = dz - cz; + double dist = System.Math.Sqrt(bx * bx + bz * bz); + if (dist <= radius) + { + double t = 1.0 - dist / radius; + double s = t * t * (3.0 - 2.0 * t); + double bowl = -depth * s + 1.0 * System.Math.Max(0.0, 1.0 - t * 4.0); // dish + rim lip + if (bowl < carve) + { + carve = bowl; // overlapping bowls take the deepest, not the sum + } + } + + radius *= 0.82; + depth *= 0.85; + } + + return carve; + } + + /// True when this column lies on one of the primary crater's bright ejecta rays (#699) — + /// Generate repaints those cells with the body's deep rock for contrast. Rays are dot-product cones + /// around 5–8 rolled integer directions, reaching 1.3–4.5 primary radii out. + public bool CraterRayAt(PlanetType planet, int worldX, int worldZ) + { + if (!(planet.Cratered || _crateredWorld)) + { + return false; + } + + long seed = PlanetSeed(planet); + if (!TryGetHotspot(seed ^ 0x0C4A17, ChainCellSize, ChainChance, ChainMargin, + worldX, worldZ, out ulong h, out double dx, out double dz)) + { + return false; + } + + double r0 = 9.0 + ((h >> 32) & 0x7); + double dist = System.Math.Sqrt(dx * dx + dz * dz); + if (dist < r0 * 1.3 || dist > r0 * 4.5) + { + return false; + } + + ulong h2 = h * 0x9E3779B97F4A7C15UL; + int rays = 5 + (int)(h2 & 0x3); // 5..8 rays + double nx = dx / dist, nz = dz / dist; + for (int i = 0; i < rays; i++) + { + // Each ray's direction comes from its own hash bits — normalized integer-ish vectors, no trig. + ulong rh = h2 >> (4 + i * 7); + double rx = ((rh & 0xF) / 15.0) * 2.0 - 1.0; + double rz = (((rh >> 4) & 0x7) / 7.0) * 2.0 - 1.0; + double rl = System.Math.Sqrt(rx * rx + rz * rz); + if (rl < 0.2) + { + continue; + } + + double len = 0.55 + ((rh >> 7) & 0x3) / 3.0 * 0.45; // per-ray reach 55–100 % of the max + if (dist > r0 * 4.5 * len) + { + continue; + } + + if (nx * (rx / rl) + nz * (rz / rl) > 0.965) + { + return true; + } + } + + return false; + } + // Rare metals exposed as small clumps on deep crater floors — the reward for exploring craters (item 33). private const double CraterFloorMinDepth = 4.0; // only craters at least this deep host metal private const double CraterMetalRegion = 0.55; // per-crater gate: only SOME craters are metal-bearing @@ -1012,12 +2384,12 @@ private sealed class WorldCalibration // body salt), so it is safe — and important — to share across generator instances: the client bakes a // fresh WorldGenerator per minimap/orbit texture and the tests spin up hundreds, each of which would // otherwise re-sample ~17k heights + 2×4096 field points. - private static readonly System.Collections.Generic.Dictionary<(long, string, int, bool, long), WorldCalibration> _calibs = new(); + private static readonly System.Collections.Generic.Dictionary<(long, string, int, bool, long, bool), WorldCalibration> _calibs = new(); private static readonly object _calibLock = new object(); private WorldCalibration CalibFor(PlanetType planet) { - var key = (_worldSeed, planet.Key, _circumference, _crateredWorld, _locationSalt); + var key = (_worldSeed, planet.Key, _circumference, _crateredWorld, _locationSalt, _continentsEnabled); lock (_calibLock) { if (_calibs.TryGetValue(key, out var cached)) @@ -1069,18 +2441,28 @@ private WorldCalibration BuildCalibration(PlanetType planet) bool volcanic = planet.SurfaceBlock == "basalt" || planet.DeepBlock == "basalt"; double waterAb = planet.WaterAbundance ?? (hasAir ? 0.55 : 0.0); double lavaAb = planet.LavaAbundance ?? (volcanic ? 0.7 : 0.0); + + // Continents (#704): on a continental world the sea's job is to FILL THE BASINS — the flood target + // becomes the basin share (1 − rolled land fraction), so the waterline settles at/near the shelf + // edge regardless of how the threshold→area mapping behaves. The percentile machinery makes it exact. + var continent = ContinentProfileFor(planet, seed); if (waterAb > 0.0 && _content.GetBlock("water") is { } water) { - double frac = waterAb >= 1.0 - ? 0.78 + 0.19 * R01(0x5EA01) // ocean-class band: 78–97 % water (islands guaranteed) - : System.Math.Clamp(0.06 + 0.40 * waterAb + (R01(0x5EA02) - 0.5) * 0.08, 0.02, 0.60); + double frac = continent.Active + ? System.Math.Clamp((1.0 - continent.LandFrac) * 0.97, 0.35, 0.75) + : waterAb >= 1.0 + ? 0.78 + 0.19 * R01(0x5EA01) // ocean-class band: 78–97 % water (islands guaranteed) + : System.Math.Clamp(0.06 + 0.40 * waterAb + (R01(0x5EA02) - 0.5) * 0.08, 0.02, 0.60); c.SeaLevel = QuantileLevel(c.SortedHeights, frac); c.SeaFluid = water.NumericId; } else if (lavaAb > 0.0 && _content.GetBlock("lava") is { } lava) { // Only dry volcanic/airless worlds pool a lava sea (B54: visible across low + mid terrain). - double frac = System.Math.Clamp(0.30 * lavaAb + (R01(0x5EA03) - 0.5) * 0.06, 0.05, 0.55); + // A continental lava world (#704) floods its basins the same way — basalt continents. + double frac = continent.Active + ? System.Math.Clamp((1.0 - continent.LandFrac) * 0.97, 0.35, 0.75) + : System.Math.Clamp(0.30 * lavaAb + (R01(0x5EA03) - 0.5) * 0.06, 0.05, 0.55); c.SeaLevel = QuantileLevel(c.SortedHeights, frac); c.SeaFluid = lava.NumericId; } @@ -1499,14 +2881,14 @@ private BlockId BeachBlockFor(PlanetType planet) // STATIC like the calibration cache: the field is a pure function of (world seed, planet, size, // cratered, body salt), and fresh generator instances (tests, client preview bakes) would otherwise // re-run the ~300 ms network build per instance. - private static readonly System.Collections.Generic.Dictionary<(long, string, int, bool, long), RiverField> _riverFields = new(); + private static readonly System.Collections.Generic.Dictionary<(long, string, int, bool, long, bool), RiverField> _riverFields = new(); private static readonly object _riverLock = new object(); /// This world's routed river placement (built once per world, then cached). Empty on worlds that /// get no rivers (no water sea, or WaterAbundance below the river threshold). public RiverField RiverFieldFor(PlanetType planet) { - var key = (_worldSeed, planet.Key, _circumference, _crateredWorld, _locationSalt); + var key = (_worldSeed, planet.Key, _circumference, _crateredWorld, _locationSalt, _continentsEnabled); lock (_riverLock) { if (_riverFields.TryGetValue(key, out var cached)) @@ -1861,6 +3243,18 @@ public ChunkData Generate(PlanetType planet, ChunkCoord coord) bool floatingIslands = planet.FloatingIslands; // item 21 V5: drifting sky-island slabs above the surface + // Terrain wonders (#698–#709): per-world feature gates, resolved once per chunk so classic worlds + // pay a handful of boolean checks and nothing else. + bool anyBands = HasExtraBands(planet); // #705: sky tiers, arch bars, caps, cenote lips, falls + bool travertineWorld = HasTravertine(planet); // #701: white spring terraces with deck pools + bool penitenteWorld = HasPenitentes(planet); // #701: blade-ice fields (repainted ice) + bool basaltFieldWorld = HasBasaltFields(planet); // #701: hex column fields (repainted basalt) + bool cenoteWorld = HasCenotes(planet); // #707: shaft pools + bool cavernWorld = HasCaverns(planet); // #707: underground mega-caverns + bool tunnelWorld = HasTunnels(planet); // #708: worm tunnels + cave mouths + var saltBlockId = _content.GetBlock("salt")?.NumericId ?? BlockId.Air; + var cavernCrystalId = _content.GetBlock("crystal")?.NumericId ?? BlockId.Air; + // Geysers / vents (item 21 follow-up): sparse erupting spouts — water geysers on reasonably wet worlds, // steam/lava vents on volcanic/ashen worlds. A marker block at the surface; the client attaches the // eruption VFX + hiss when the player is near. Deterministic, very sparse (landmark-rare). @@ -1909,6 +3303,10 @@ public ChunkData Generate(PlanetType planet, ChunkCoord coord) var origin = WorldConstants.ChunkOrigin(coord); + // Scratch spans reused by every column (#705/#708) — allocated once per chunk (CA2014). + System.Span bands = stackalloc ColumnBand[MaxColumnBands]; + System.Span<(int Lo, int Hi)> tunnelSpans = stackalloc (int Lo, int Hi)[TunnelMaxSpans]; + for (int lx = 0; lx < WorldConstants.ChunkSize; lx++) for (int lz = 0; lz < WorldConstants.ChunkSize; lz++) { @@ -1962,10 +3360,41 @@ public ChunkData Generate(PlanetType planet, ChunkCoord coord) { riverHere = true; seabedY = river.BedY; + if (river.WaterfallDrop > 0) + { + // River incision (#709): the plunge pool under a waterfall cuts a slot into the bed — + // the erosion look without simulating erosion. + seabedY -= System.Math.Min(6, river.WaterfallDrop); + } + waterTop = river.WaterfallDrop > 0 ? river.WaterSurfaceY + river.WaterfallDrop : river.WaterSurfaceY; columnFluid = riverField.FillFluid; // water on watery worlds, lava on lava/ashen worlds (L2) } + // Travertine deck pools (#701): shallow 1-deep water flush on the white terrace decks. + bool travertineHere = false; + if (travertineWorld && !pondHere && !craterHere && !riverHere && surfaceY > fluidLevel + && TryGetTravertine(seed, worldX, worldZ, out double travDeck, out bool travPool)) + { + travertineHere = travDeck > 0.0 || travPool; + if (travPool && !seaWaterId.IsAir) + { + seabedY = surfaceY - 1; + waterTop = surfaceY; + columnFluid = seaWaterId; + } + } + + // Cenote pools (#707): a turquoise pool standing over the shaft floor. + if (cenoteWorld && !pondHere && !craterHere && !riverHere && !seaWaterId.IsAir + && TryGetCenotePool(planet, worldX, worldZ, out int cenotePoolTop) + && cenotePoolTop > surfaceY) + { + seabedY = surfaceY; + waterTop = cenotePoolTop; + columnFluid = seaWaterId; + } + // Frozen water (#494): a cold column's water freezes from the waterline down — a walkable // ice sheet with liquid below on merely-cold bodies, frozen through to the seabed in the // deep cold or where the sheet reaches the bed anyway. Lava columns never freeze. @@ -2031,15 +3460,56 @@ public ChunkData Generate(PlanetType planet, ChunkCoord coord) subSurfaceId = basaltId; } - // Floating islands (item 21 V5): a per-column sky-island slab high above the surface — a grass-topped - // deck on a tapered rocky underbelly, scattered by a region mask, drifting in the air. The band is - // resolved by the shared helper so settlement placement can query the same island tops. - int islandTop = int.MinValue, islandBottom = int.MaxValue; - if (floatingIslands) + // Travertine terraces read blinding white (#701) — salt is the closest shipped block. + if (travertineHere && !saltBlockId.IsAir) + { + surfaceId = saltBlockId; + subSurfaceId = saltBlockId; + } + + // Penitente blades freeze to solid ice (#701) where they rise meaningfully. + if (penitenteWorld && !iceId.IsAir && PenitenteRise(planet, seed, worldX, worldZ) > 1.5) + { + surfaceId = iceId; + subSurfaceId = iceId; + } + + // Basalt column fields read as dark columnar rock (#701). + if (basaltFieldWorld && !basaltId.IsAir && TryGetBasaltColumns(seed, worldX, worldZ, out _)) + { + surfaceId = basaltId; + subSurfaceId = basaltId; + } + + // Ejecta rays (#699): bright/contrast streaks radiating from the primary chain crater — + // repainted with the body's deep rock so the rays pop against the regolith. + if ((planet.Cratered || _crateredWorld) && CraterRayAt(planet, worldX, worldZ)) + { + surfaceId = deepId; + subSurfaceId = deepId; + } + + // Extra column bands (#705): sky-island tiers (with ponds + stalactites), arch bars, + // sea-stack/hoodoo caps, cenote lips and island waterfalls. Tier 0 keeps the classic + // sky-island fill; islandTop below feeds the island flora pass like before. + int bandCount = anyBands ? GetExtraBands(planet, worldX, worldZ, bands) : 0; + int islandTop = int.MinValue; + for (int b = 0; b < bandCount; b++) { - FloatingIslandBand(planet, worldX, worldZ, out islandTop, out islandBottom); + if (bands[b].Kind == BandKind.Island && bands[b].Top > islandTop) + { + islandTop = bands[b].Top; + } } + // Underground mega-cavern (#707): this column's void span, if a cavern covers it. + int cavLo = 0, cavHi = -1, cavLakeY = int.MinValue; + bool cavernHere = cavernWorld + && TryGetCavernSpan(planet, worldX, worldZ, out cavLo, out cavHi, out cavLakeY); + + // Tunnel carver (#708): this column's worm-carve y-spans (empty on most columns). + int tunnelCount = tunnelWorld ? TunnelSpans(planet, worldX, worldZ, tunnelSpans) : 0; + // Crater-floor metal clumps (item 33): on a cratered world, the top cells of a metal-bearing deep // crater floor are exposed rare ore instead of regolith (only some craters, a few clumps each). BlockId? craterMetal = (planet.Cratered || _crateredWorld) @@ -2060,11 +3530,43 @@ public ChunkData Generate(PlanetType planet, ChunkCoord coord) // reads as solid ice instead of water (#494). chunk.Set(lx, ly, lz, worldY > waterTop - iceTop ? iceId : columnFluid); } - else if (floatingIslands && worldY >= islandBottom && worldY <= islandTop) + else { - // A sky island: grass-topped deck, sub-surface just under it, stone underbelly below. - var ib = worldY == islandTop ? surfaceId : (worldY >= islandTop - 2 ? subSurfaceId : deepId); - chunk.Set(lx, ly, lz, ib); + // Extra bands (#705): sky-island tiers, arch bars, caps, lips, waterfalls. + for (int b = 0; b < bandCount; b++) + { + if (worldY < bands[b].Bottom || worldY > bands[b].Top) + { + continue; + } + + switch (bands[b].Kind) + { + case BandKind.Island: + // Classic sky island: grass top, sub-surface under it, stone below. + chunk.Set(lx, ly, lz, worldY == bands[b].Top ? surfaceId + : worldY >= bands[b].Top - 2 ? subSurfaceId : deepId); + break; + case BandKind.IslandPond: + // An island whose meadow top is a sunk pool (#707). + chunk.Set(lx, ly, lz, worldY == bands[b].Top && !seaWaterId.IsAir + ? seaWaterId + : worldY >= bands[b].Top - 2 ? subSurfaceId : deepId); + break; + case BandKind.Cap: + chunk.Set(lx, ly, lz, deepId); // bare rock: arch bars, caps, lips + break; + default: // BandKind.Waterfall (#707): a standing column of falling water + if (!seaWaterId.IsAir) + { + chunk.Set(lx, ly, lz, seaWaterId); + } + + break; + } + + break; // first covering band wins + } } continue; // else air above the surface @@ -2088,6 +3590,51 @@ public ChunkData Generate(PlanetType planet, ChunkCoord coord) continue; } + // Underground mega-cavern (#707): a vast void with an optional still lake in its bowl. + if (cavernHere && worldY >= cavLo && worldY <= cavHi) + { + if (worldY <= cavLakeY && !airlessBody) + { + chunk.Set(lx, ly, lz, depth > lavaTableDepth ? lavaFloorId : seaWaterId); + } + + continue; // void (or the lake fill above) + } + + // Cavern shell glints (#707): sparse crystal studs the cell just under the cavern floor. + if (cavernHere && worldY == cavLo - 1 && !cavernCrystalId.IsAir + && Noise.Value01(seed + 0x0CAFE1, WorldConstants.WrapX(worldX, _circumference), worldY, Wz(worldZ)) < 0.10) + { + chunk.Set(lx, ly, lz, cavernCrystalId); + continue; + } + + // Tunnel carver (#708): worm tunnels may reach all the way to the surface — cave MOUTHS. + if (tunnelCount > 0) + { + bool inTunnel = false; + for (int t = 0; t < tunnelCount; t++) + { + if (worldY >= tunnelSpans[t].Lo && worldY <= tunnelSpans[t].Hi) + { + inTunnel = true; + break; + } + } + + if (inTunnel) + { + // Below the lava table the same molten-pocket rule as blob caves applies (#580). + if (!airlessBody && depth > lavaTableDepth + && ValueT(seed + 0xDEE9, worldX, worldY, worldZ, 56.0, 40.0, 56.0) > 0.47) + { + chunk.Set(lx, ly, lz, lavaFloorId); + } + + continue; + } + } + // Carve caves below the surface layer (quantile-calibrated per world, #472). if (caveThreshold > 0.0 && depth > 1) { diff --git a/tests/BlocksBeyondTheStars.Tests/CreatureTests.cs b/tests/BlocksBeyondTheStars.Tests/CreatureTests.cs index 95a27f13..8f61af50 100644 --- a/tests/BlocksBeyondTheStars.Tests/CreatureTests.cs +++ b/tests/BlocksBeyondTheStars.Tests/CreatureTests.cs @@ -1085,14 +1085,19 @@ public void Aggressor_GivesUpAfterChasingTooLong_ThenItsCooldownDecays() hostile.Temperament = CreatureTemperament.Aggressive; var creature = server.Creatures.First(); creature.SpeciesId = hostile.Id; - creature.Position = new Vector3f(2, 64, 0); // right next to the player (well within aggro) + // Anchor the duel at the creature's own surface-snapped spot: since the terrain-wonders wave + // the ground at the origin is no longer a flat Y 64, and a hardcoded column can bury the prey + // in rock (no line of aggro → the chase never starts). + var duelPos = creature.Position; + p.State.Position = duelPos; + creature.Position = new Vector3f(duelPos.X + 2, duelPos.Y, duelPos.Z); // right next to the player creature.ChaseTimer = 0; creature.GiveUpTimer = 0; // Hold the prey still and let the aggressor chase past the give-up cap (~7s). for (int i = 0; i < 100 && creature.GiveUpTimer <= 0; i++) { - p.State.Position = new Vector3f(0, 64, 0); + p.State.Position = duelPos; server.Tick(0.2); } diff --git a/tests/BlocksBeyondTheStars.Tests/TerrainWondersTests.cs b/tests/BlocksBeyondTheStars.Tests/TerrainWondersTests.cs new file mode 100644 index 00000000..92c8f391 --- /dev/null +++ b/tests/BlocksBeyondTheStars.Tests/TerrainWondersTests.cs @@ -0,0 +1,491 @@ +// Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games) +// SPDX-License-Identifier: AGPL-3.0-or-later +// This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text. +using System.Reflection; +using BlocksBeyondTheStars.Shared.Content; +using BlocksBeyondTheStars.Shared.Definitions; +using BlocksBeyondTheStars.Shared.World; +using BlocksBeyondTheStars.WorldGeneration; +using Xunit; + +namespace BlocksBeyondTheStars.Tests; + +/// Terrain wonders (#698–#709): mega-rift, complex craters + chains, oriented terrain, exotic +/// accents, calderas/escarpments, style hybrid, continents, overhang bands, cenotes, caverns, tunnels. +public class TerrainWondersTests +{ + private static GameContent Content() => ContentLoader.LoadFromDirectory(TestPaths.DataDir()); + + private static object Invoke(WorldGenerator gen, string method, params object[] args) + => typeof(WorldGenerator).GetMethod(method, BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(gen, args)!; + + private static long SeedOf(WorldGenerator gen, PlanetType planet) + => (long)Invoke(gen, "PlanetSeed", planet); + + // ---------- Wave 1 ---------- + + [Theory] + [InlineData("desert")] // #700 oriented dunes + #706 arches + [InlineData("highland")] // #700 oriented mountain chains + [InlineData("ice")] // #701 penitentes + #709 crevasses (blend world) + [InlineData("salt_flats")] // #701 salt polygons + [InlineData("lava")] // #701 basalt column fields, lava sea + [InlineData("skylands")] // #707 multi-tier islands + public void SurfaceHeight_WrapsSeamFree_OnWonderWorlds(string key) + { + var content = Content(); + var planet = content.GetPlanet(key)!; + var gen = new WorldGenerator(1234, content); + int circ = WorldConstants.Circumference; + int period = WorldConstants.LatitudePeriodFor(circ); + + for (int z = -period / 2; z < period / 2; z += 97) + for (int x = 0; x < circ; x += 191) + { + Assert.Equal(gen.SurfaceHeight(planet, x, z), gen.SurfaceHeight(planet, x + circ, z)); + Assert.Equal(gen.SurfaceHeight(planet, x, z), gen.SurfaceHeight(planet, x, z + period)); + } + } + + [Fact] + public void MegaRift_GirdlesTheWholePlanet() + { + // #698: find a seed whose savanna world carries the mega-rift, then verify the canyon exists at + // EVERY longitude (it wraps) and reaches serious depth. + var content = Content(); + var planet = content.GetPlanet("savanna")!; + WorldGenerator? gen = null; + long seed = 0; + for (long s = 1; s < 400 && gen is null; s++) + { + var g = new WorldGenerator(s * 7919, content); + long ps = SeedOf(g, planet); + if ((bool)Invoke(g, "HasMegaRift", planet, ps)) + { + gen = g; + seed = ps; + } + } + + Assert.NotNull(gen); + int period = WorldConstants.LatitudePeriodFor(WorldConstants.Circumference); + for (int x = 0; x < WorldConstants.Circumference; x += 500) + { + double deepest = 0; + for (int z = -period / 2; z < period / 2; z += 4) + { + double o = (double)Invoke(gen!, "MegaRiftOffset", seed, x, z); + if (o < deepest) + { + deepest = o; + } + } + + Assert.True(deepest < -50.0, $"mega-rift missing/shallow at x={x} (deepest {deepest})"); + } + } + + [Fact] + public void Escarpment_SplitsTheWorldIntoTwoStoreys() + { + // #702: on an escarpment world the far-north and far-south storeys differ by the rolled step. + var content = Content(); + var planet = content.GetPlanet("savanna")!; + WorldGenerator? gen = null; + long seed = 0; + for (long s = 1; s < 400 && gen is null; s++) + { + var g = new WorldGenerator(s * 6151 + 1, content); + long ps = SeedOf(g, planet); + if ((bool)Invoke(g, "HasEscarpment", planet, ps)) + { + gen = g; + seed = ps; + } + } + + Assert.NotNull(gen); + int period = WorldConstants.LatitudePeriodFor(WorldConstants.Circumference); + // Somewhere the offset is clearly positive AND somewhere clearly negative (two storeys exist). + double lo = double.MaxValue, hi = double.MinValue; + for (int z = -period / 2; z < period / 2; z += 64) + { + double o = (double)Invoke(gen!, "EscarpmentOffset", seed, 1000, z); + lo = System.Math.Min(lo, o); + hi = System.Math.Max(hi, o); + } + + Assert.True(hi - lo >= 50.0, $"escarpment step too small: {hi - lo}"); + Assert.True(hi > 20.0 && lo < -20.0, $"storeys not centred: hi {hi}, lo {lo}"); + } + + [Fact] + public void RingCaldera_HasARimWallAroundASunkenFloor() + { + // #702: find a caldera via its offset field, then check ring-positive / interior-negative. + var content = Content(); + var planet = content.GetPlanet("varied")!; + int period = WorldConstants.LatitudePeriodFor(WorldConstants.Circumference); + for (long s = 1; s < 200; s++) + { + var gen = new WorldGenerator(s * 104729, content); + long seed = SeedOf(gen, planet); + for (int z = -period / 2; z < period / 2; z += 64) + for (int x = 0; x < WorldConstants.Circumference; x += 64) + { + double o = (double)Invoke(gen, "CalderaOffset", seed, x, z); + if (o < -15.0) + { + // Inside a sunken floor: a rim (positive offset) must exist within the max radius. + bool rimFound = false; + for (int d = 8; d <= 420 && !rimFound; d += 8) + { + rimFound = (double)Invoke(gen, "CalderaOffset", seed, x + d, z) > 15.0; + } + + Assert.True(rimFound, "caldera floor without a rim wall"); + return; + } + } + } + + Assert.Fail("no ring caldera found in the scanned seeds"); + } + + [Fact] + public void CraterProfiles_RollComplexPeaksAndTerraces_PerBody() + { + // #699: both complex-crater traits appear on some bodies and stay absent on others. + var t = typeof(WorldGenerator).GetMethod("CraterProfileFor", BindingFlags.NonPublic | BindingFlags.Static)!; + int peaks = 0, terraced = 0, plain = 0; + for (long s = 1; s <= 400; s++) + { + object p = t.Invoke(null, new object[] { s * 2654435761L })!; + bool cp = (bool)p.GetType().GetField("ComplexPeaks")!.GetValue(p)!; + bool tr = (bool)p.GetType().GetField("Terraced")!.GetValue(p)!; + if (cp) peaks++; + if (tr) terraced++; + if (!cp && !tr) plain++; + } + + Assert.InRange(peaks, 100, 300); + Assert.InRange(terraced, 80, 280); + Assert.True(plain > 40, "every body rolled complex traits — simple craters must survive"); + } + + [Fact] + public void CraterChains_CarveAlignedBowlStrings() + { + // #699: on a cratered body, the chain carve is nonzero somewhere and never positive beyond its lip. + var content = Content(); + var planet = content.GetPlanet("asteroid")!; + int found = 0; + for (long s = 1; s <= 40 && found == 0; s++) + { + var gen = new WorldGenerator(s * 15485863, content); + gen.SetWorldMode(1600, cratered: true, landingPads: null, "belt0-a1"); + long seed = SeedOf(gen, planet); + int period = WorldConstants.LatitudePeriodFor(1600); + for (int z = -period / 2; z < period / 2; z += 8) + for (int x = 0; x < 1600; x += 8) + { + double o = (double)Invoke(gen, "CraterChainCarve", seed, x, z); + Assert.True(o <= 1.01, $"chain carve rose above its rim lip: {o}"); + if (o < -3.0) + { + found++; + } + } + } + + Assert.True(found > 0, "no crater chain found on any scanned asteroid"); + } + + [Fact] + public void SaltPolygons_RidgeTheSaltPan() + { + // #701: salt flats carry a sparse +1 ridge network — present, but far from covering the pan. + var content = Content(); + var planet = content.GetPlanet("salt_flats")!; + var gen = new WorldGenerator(4242, content); + long seed = SeedOf(gen, planet); + int ridged = 0, total = 0; + for (int z = -400; z < 400; z += 3) + for (int x = 0; x < 800; x += 3) + { + double r = (double)Invoke(gen, "SaltPolygonRidge", seed, x, z); + Assert.True(r is 0.0 or 1.0); + if (r > 0) + { + ridged++; + } + + total++; + } + + double frac = ridged / (double)total; + Assert.InRange(frac, 0.02, 0.35); + } + + // ---------- Continents (#704) ---------- + + [Fact] + public void Continents_NeverRoll_WithoutTheFlag_OrOnSmallWorlds() + { + var content = Content(); + var planet = content.GetPlanet("varied")!; + var m = typeof(WorldGenerator).GetMethod("ContinentProfileFor", BindingFlags.NonPublic | BindingFlags.Instance)!; + bool Active(WorldGenerator g) + { + object prof = m.Invoke(g, new object[] { planet, SeedOf(g, planet) })!; + return (bool)prof.GetType().GetField("Active")!.GetValue(prof)!; + } + + for (long s = 1; s <= 40; s++) + { + // Flag off → never, regardless of size. + var off = new WorldGenerator(s * 31, content); + off.SetWorldMode(9600, cratered: false, landingPads: null, $"sys{s}-p1"); + Assert.False(Active(off), "continents rolled with the flag off"); + + // Flag on but a moon-sized world → never (the gate now sits at 6000, so the start world + // qualifies while moons at 2500–4000 stay out). + var small = new WorldGenerator(s * 31, content); + small.SetContinentsEnabled(true); + small.SetWorldMode(4000, cratered: false, landingPads: null, $"sys{s}-p1"); + Assert.False(Active(small), "continents rolled below the size gate"); + } + } + + [Fact] + public void Continents_MakeLargeWorldsBimodal_AndOceanKeepsItsIdentity() + { + var content = Content(); + var varied = content.GetPlanet("varied")!; + var ocean = content.GetPlanet("ocean")!; + var m = typeof(WorldGenerator).GetMethod("ContinentProfileFor", BindingFlags.NonPublic | BindingFlags.Instance)!; + + WorldGenerator? contGen = null; + for (long s = 1; s <= 80 && contGen is null; s++) + { + var g = new WorldGenerator(s * 6700417, content); + g.SetContinentsEnabled(true); + g.SetWorldMode(9600, cratered: false, landingPads: null, $"sys{s}-p2"); + object prof = m.Invoke(g, new object[] { varied, SeedOf(g, varied) })!; + if ((bool)prof.GetType().GetField("Active")!.GetValue(prof)!) + { + contGen = g; + } + + // The ocean type must NEVER roll continents (its identity is the 78–97 % flood). + object oceanProf = m.Invoke(g, new object[] { ocean, SeedOf(g, ocean) })!; + Assert.False((bool)oceanProf.GetType().GetField("Active")!.GetValue(oceanProf)!, + "ocean type rolled continents"); + } + + Assert.NotNull(contGen); + + // Bimodal: platform columns sit clearly above basin columns — the spread far exceeds a classic + // varied world's, and both regimes are well represented. + int period = WorldConstants.LatitudePeriodFor(9600); + int lows = 0, highs = 0; + for (int z = -period / 2; z < period / 2; z += 160) + for (int x = 0; x < 9600; x += 160) + { + int h = contGen!.SurfaceHeight(varied, x, z); + if (h < varied.BaseHeight - 22) + { + lows++; + } + else if (h >= varied.BaseHeight) + { + highs++; + } + } + + Assert.True(lows > 100, $"no ocean basins found ({lows} low columns)"); + Assert.True(highs > 100, $"no continental platforms found ({highs} high columns)"); + } + + [Fact] + public void Continents_FloodBasins_WithARealSea() + { + // The sea percentile targets the basin share, so the waterline must sit far above the basin + // floors and below the platforms. + var content = Content(); + var varied = content.GetPlanet("varied")!; + var m = typeof(WorldGenerator).GetMethod("ContinentProfileFor", BindingFlags.NonPublic | BindingFlags.Instance)!; + for (long s = 1; s <= 80; s++) + { + var g = new WorldGenerator(s * 6700417, content); + g.SetContinentsEnabled(true); + g.SetWorldMode(9600, cratered: false, landingPads: null, $"sys{s}-p2"); + object prof = m.Invoke(g, new object[] { varied, SeedOf(g, varied) })!; + if (!(bool)prof.GetType().GetField("Active")!.GetValue(prof)!) + { + continue; + } + + int sea = g.SeaLevel(varied); + Assert.True(sea != int.MinValue, "continental world has no sea"); + Assert.InRange(sea, varied.BaseHeight - 40, varied.BaseHeight + 12); + return; + } + + Assert.Fail("no continental varied world found in the scanned seeds"); + } + + // ---------- Overhangs (#705/#706/#707) ---------- + + [Fact] + public void ExtraBands_AreEmpty_OnAirlessSpireWorlds() + { + // crystal: airless (no stacks/cenotes), spires style (no arches/hoodoos), no floating islands — + // the band machinery must report nothing and cost nothing. + var content = Content(); + var planet = content.GetPlanet("crystal")!; + var gen = new WorldGenerator(99, content); + Assert.False(gen.HasExtraBands(planet)); + System.Span bands = stackalloc WorldGenerator.ColumnBand[WorldGenerator.MaxColumnBands]; + for (int x = 0; x < 2000; x += 61) + { + Assert.Equal(0, gen.GetExtraBands(planet, x, 17, bands)); + } + } + + [Fact] + public void Skylands_KeepTierZero_IdenticalToTheClassicBandQuery() + { + // #707 compat: FloatingIslandBand (the settlement-placement contract) must agree with tier 0 of + // GetExtraBands wherever an island exists. + var content = Content(); + var planet = content.GetPlanet("skylands")!; + var gen = new WorldGenerator(7, content); + System.Span bands = stackalloc WorldGenerator.ColumnBand[WorldGenerator.MaxColumnBands]; + int checkedCols = 0; + for (int z = -600; z < 600 && checkedCols < 200; z += 13) + for (int x = 0; x < 3000 && checkedCols < 200; x += 29) + { + if (!gen.FloatingIslandBand(planet, x, z, out int top, out int bottom)) + { + continue; + } + + int n = gen.GetExtraBands(planet, x, z, bands); + bool found = false; + for (int i = 0; i < n; i++) + { + if (bands[i].Top == top && bands[i].Bottom == bottom + && bands[i].Kind is WorldGenerator.BandKind.Island or WorldGenerator.BandKind.IslandPond) + { + found = true; + } + } + + Assert.True(found, $"tier-0 island at ({x},{z}) missing from GetExtraBands"); + checkedCols++; + } + + Assert.True(checkedCols > 50, "scan found too few island columns to be meaningful"); + } + + [Fact] + public void Cenotes_DropSheerShafts_IntoTheGround() + { + // #707: find a cenote and verify the shaft is deep and its wall near-vertical. + var content = Content(); + var planet = content.GetPlanet("jungle")!; + int period = WorldConstants.LatitudePeriodFor(WorldConstants.Circumference); + for (long s = 1; s <= 30; s++) + { + var gen = new WorldGenerator(s * 32452843, content); + long seed = SeedOf(gen, planet); + for (int z = -period / 2; z < period / 2; z += 12) + for (int x = 0; x < WorldConstants.Circumference; x += 12) + { + double o = (double)Invoke(gen, "CenoteOffset", planet, seed, x, z); + if (o < -28.0) + { + // Sheer: walking +x from ANY floor column must cross the rim within one full + // diameter (radius reaches ~20 since the visibility tuning → scan 44). + bool rim = false; + for (int d = 2; d <= 44 && !rim; d += 2) + { + rim = (double)Invoke(gen, "CenoteOffset", planet, seed, x + d, z) > -2.0; + } + + Assert.True(rim, "cenote wall is not sheer"); + return; + } + } + } + + Assert.Fail("no cenote found in the scanned seeds"); + } + + // ---------- Tunnels (#708/#709) ---------- + + [Fact] + public void Tunnels_AreDeterministic_AndReachTheSurfaceSomewhere() + { + var content = Content(); + var planet = content.GetPlanet("rocky")!; + var a = new WorldGenerator(7, content); + var b = new WorldGenerator(7, content); + System.Span<(int, int)> sa = stackalloc (int, int)[8]; + System.Span<(int, int)> sb = stackalloc (int, int)[8]; + + bool anySpan = false, mouth = false; + int period = WorldConstants.LatitudePeriodFor(WorldConstants.Circumference); + for (int z = -period / 2; z < period / 2 && !(anySpan && mouth); z += 10) + for (int x = 0; x < WorldConstants.Circumference && !(anySpan && mouth); x += 10) + { + int na = a.TunnelSpans(planet, x, z, sa); + int nb = b.TunnelSpans(planet, x, z, sb); + Assert.Equal(na, nb); + for (int i = 0; i < na; i++) + { + Assert.Equal(sa[i], sb[i]); + anySpan = true; + if (sa[i].Item2 >= a.SurfaceHeight(planet, x, z)) + { + mouth = true; // a skylight/mouth breaks the surface (#709) + } + } + } + + Assert.True(anySpan, "no tunnel found anywhere on the world"); + Assert.True(mouth, "no tunnel mouth/skylight reaches the surface"); + } + + [Fact] + public void WonderWorlds_GenerateChunks_WithoutErrors() + { + // Smoke: the full Generate path (bands, pools, repaints, caverns, tunnels) holds together. + var content = Content(); + foreach (var key in new[] { "desert", "ice", "salt_flats", "skylands", "lava", "jungle", "badlands" }) + { + var planet = content.GetPlanet(key)!; + var gen = new WorldGenerator(7, content); + for (int cy = -2; cy <= 6; cy += 2) + { + Assert.NotNull(gen.Generate(planet, new ChunkCoord(3, cy, 1))); + } + } + } + + [Fact] + public void ContinentalChunks_Generate_OnLargeWorlds() + { + var content = Content(); + var planet = content.GetPlanet("varied")!; + var gen = new WorldGenerator(6700417, content); + gen.SetContinentsEnabled(true); + gen.SetWorldMode(9600, cratered: false, landingPads: null, "sys1-p2"); + for (int cx = 0; cx < 8; cx += 2) + { + Assert.NotNull(gen.Generate(planet, new ChunkCoord(cx, 3, 0))); + } + } +} diff --git a/tests/BlocksBeyondTheStars.Tests/WorldGenerationTests.cs b/tests/BlocksBeyondTheStars.Tests/WorldGenerationTests.cs index 4390ee9a..90da5365 100644 --- a/tests/BlocksBeyondTheStars.Tests/WorldGenerationTests.cs +++ b/tests/BlocksBeyondTheStars.Tests/WorldGenerationTests.cs @@ -646,16 +646,19 @@ public void LavaWorld_CarvesLavaRivers() { if (gen.SurfaceRiverDepth(planet, wx, wz) <= 0) continue; - int surfaceY = gen.SurfaceHeight(planet, wx, wz); + // Scan around the CHANNEL's own water surface: since the terrain-wonders wave a channel + // may run entrenched a few blocks below its neighbouring columns, so the column surface + // is not a reliable anchor for where the routed fill sits. + Assert.True(field.TryGet(wx, wz, out var col), "SurfaceRiverDepth and the field disagree"); bool molten = false; - for (int y = surfaceY - 3; y <= surfaceY + 6 && !molten; y++) + for (int y = col.BedY; y <= col.WaterSurfaceY + col.WaterfallDrop + 1 && !molten; y++) { if (y < 0) continue; var chunk = gen.Generate(planet, new ChunkCoord(wx / cs, y / cs, wz / cs)); if (chunk.Get(wx % cs, ((y % cs) + cs) % cs, wz % cs).Value == lavaId.Value) molten = true; } - Assert.True(molten, $"routed lava column ({wx},{wz}) holds no lava near surface {surfaceY}"); + Assert.True(molten, $"routed lava column ({wx},{wz}) holds no lava in its channel band"); rivers++; }