diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs
index a56809f..2ae8816 100644
--- a/Core/Scripts/ConfigManager.cs
+++ b/Core/Scripts/ConfigManager.cs
@@ -105,6 +105,24 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
public const float CRATER_EROSION_CORE_DEFAULT = 0.80f;
public const float CRATER_EROSION_FEATHER_DEFAULT = 1.05f;
+ // Rivers (task 22, C0b part 2a): carve the frozen task-21b river plan's
+ // beds into the RENDER map — ocean trunks, routed giants (lowland reach to
+ // the sea), lake-enders. NO WATER yet (part 2b). "off" until the routing-
+ // style gate; the A/B batch turns it on explicitly. RiverRoutingStyle picks
+ // the lowland routing for routed giants: "short" heads direct (lightly
+ // terrain-aware), "lowground" follows the lowest ground and wanders like a
+ // real river — the task-22 gate decides which ships; "lowground" is the
+ // provisional default pending that verdict. Width/depth scales are taste
+ // dials on the flow-proportional bed profile. RiverSeaMargin is the bed's
+ // absolute floor above sea — the erosion flood-guard discipline: no river
+ // bed may create inland below-sea cells, so the rendered coastline cannot
+ // move even with rivers carved.
+ public static string Rivers = "off";
+ public static string RiverRoutingStyle = "lowground";
+ public static float RiverWidthScale = 1.0f;
+ public static float RiverDepthScale = 1.0f;
+ public static float RiverSeaMargin = 0.2f; // m above sea, bed floor
+
// Island falloff shaping (task 11).
//
// CoastProfile: "wide" adds the submarine shelf — the height curve is identity
@@ -140,6 +158,24 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
{
string path = "res://ServerConfig.json";
+ // Iteration override (task 22): ISLA_SERVER_CONFIG names an alternate
+ // config FILE to load instead of the repo's ServerConfig.json. Batch and
+ // A/B runs point this at a scratch config, so the developer's live
+ // ServerConfig.json is never written by tooling again — the whole
+ // backup/restore dance (and its task-19 near-miss) goes away. Loud, so
+ // a forgotten env var cannot silently masquerade as the repo config.
+ string envPath = OS.GetEnvironment("ISLA_SERVER_CONFIG");
+ if (!string.IsNullOrEmpty(envPath))
+ {
+ if (FileAccess.FileExists(envPath))
+ {
+ path = envPath;
+ GD.Print($"[ConfigManager] ⚠ ISLA_SERVER_CONFIG override: loading '{envPath}' (NOT the repo ServerConfig.json).");
+ }
+ else
+ GD.PrintErr($"[ConfigManager] ISLA_SERVER_CONFIG set but '{envPath}' does not exist — falling back to the repo config.");
+ }
+
if (!FileAccess.FileExists(path))
{
GD.PrintErr("[ConfigManager] ServerConfig.json not found! Defaulting to 8K.");
@@ -272,6 +308,30 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
ErosionCarveCap = Mathf.Clamp(ErosionCarveCap, 0f, 60f);
if (rawCount != ErosionDropletCount || rawLife != ErosionDropletLifetime || rawCap != ErosionCarveCap)
GD.PrintErr($"[ConfigManager] Erosion governor out of bounds — clamped: count {rawCount}->{ErosionDropletCount}, lifetime {rawLife}->{ErosionDropletLifetime}, cap {rawCap}->{ErosionCarveCap} m.");
+ // Rivers gate + dials (task 22)
+ if (data.ContainsKey("Rivers"))
+ {
+ string rv = (string)data["Rivers"];
+ if (rv == "off" || rv == "v1")
+ Rivers = rv;
+ else
+ GD.PrintErr($"[ConfigManager] Unknown Rivers '{rv}'. Keeping '{Rivers}'.");
+ }
+ if (data.ContainsKey("RiverRoutingStyle"))
+ {
+ string st = (string)data["RiverRoutingStyle"];
+ if (st == "short" || st == "lowground")
+ RiverRoutingStyle = st;
+ else
+ GD.PrintErr($"[ConfigManager] Unknown RiverRoutingStyle '{st}'. Keeping '{RiverRoutingStyle}'.");
+ }
+ if (data.ContainsKey("RiverWidthScale")) RiverWidthScale = (float)data["RiverWidthScale"];
+ if (data.ContainsKey("RiverDepthScale")) RiverDepthScale = (float)data["RiverDepthScale"];
+ if (data.ContainsKey("RiverSeaMargin")) RiverSeaMargin = (float)data["RiverSeaMargin"];
+ RiverWidthScale = Mathf.Clamp(RiverWidthScale, 0.1f, 5f);
+ RiverDepthScale = Mathf.Clamp(RiverDepthScale, 0.1f, 5f);
+ RiverSeaMargin = Mathf.Clamp(RiverSeaMargin, 0f, 5f);
+
// Crater erosion treatment (task 19)
if (data.ContainsKey("CraterErosionMode"))
{
diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs
index 7daf51c..e8ff480 100644
--- a/Tools/Scripts/MapGenerator.cs
+++ b/Tools/Scripts/MapGenerator.cs
@@ -53,6 +53,7 @@ public partial class MapGenerator : TextureRect
// Hydraulic erosion (task 17): output-only droplet pass on the RENDER map,
// after detail, before the crater carve. The classify map never sees it.
private bool _erosionOn;
+ private bool _riversOn;
private byte _craterErosionMode;
// The carve's radius as a fraction of CraterRadius — the ONLY place this number
@@ -120,6 +121,7 @@ public partial class MapGenerator : TextureRect
_curveKnots = ConfigManager.TerrainCurve == "v5" ? HeightCurve.V5 : null;
_curveOn = _curveKnots != null;
_erosionOn = ConfigManager.Erosion == "v1";
+ _riversOn = ConfigManager.Rivers == "v1";
_craterErosionMode = ConfigManager.CraterErosionMode == "feather"
? HydraulicErosion.CRATER_MODE_FEATHER : HydraulicErosion.CRATER_MODE_FULL;
// (The monotonicity assertion now runs inside GenerateTopography, against the
@@ -228,6 +230,15 @@ public partial class MapGenerator : TextureRect
GD.Print($"{T()} Towns placed: {_towns.Count}.");
await CaptureStage("2_towns");
+ // --- RIVERS (task 22, C0b part 2a): carve the frozen plan's beds. ---
+ // AFTER towns (GenerateTowns reads the render map for land/slope/water
+ // checks, so carving first would move towns and destabilise every A/B) and
+ // BEFORE roads (a full run's A* should see the carved beds). RENDER map
+ // only: biomes and WBID are already computed from classify — the oracle is
+ // untouched by construction. NO WATER — part 2b.
+ if (_riversOn)
+ CarveRivers();
+
if (ConfigManager.SkipRoads)
{
// Iteration toggle (terrain-water task 03): the road pass is ~25 min of a
@@ -790,6 +801,81 @@ public partial class MapGenerator : TextureRect
}
}
+ ///
+ /// The river-carving pass (task 22): recomputes the frozen task-21b plan on the
+ /// eroded surface, routes the routed giants' lowland reaches (SHORT/LOWGROUND),
+ /// and carves every promoted bed. Render map only; flood-guarded exactly like
+ /// erosion (water-pixel count A/B around the pass, throw on any change).
+ ///
+ private void CarveRivers()
+ {
+ ulong tRiv0 = Time.GetTicksMsec();
+ float[,] seaMap = null;
+ float seaFlat = ConfigManager.SeaLevelValue;
+ if (ConfigManager.SeaLevelModel != "flat")
+ {
+ seaMap = new float[MapSize, MapSize];
+ for (int x = 0; x < MapSize; x++)
+ for (int y = 0; y < MapSize; y++)
+ seaMap[x, y] = GetSeaLevel(_tempMap[x, y]);
+ }
+ long wetBefore = CountRenderWaterPixels(seaMap, seaFlat);
+ float topBefore = 0f;
+ for (int x = 0; x < MapSize; x++)
+ for (int y = 0; y < MapSize; y++)
+ if (_heightMap[x, y] > topBefore) topBefore = _heightMap[x, y];
+
+ // Masks from the SHARED water predicates — identical by construction to the
+ // WBID-derived masks the task-21b tool used.
+ bool[] isOcean = new bool[MapSize * MapSize];
+ bool[] isClassifyWater = new bool[MapSize * MapSize];
+ for (int x = 0; x < MapSize; x++)
+ for (int y = 0; y < MapSize; y++)
+ {
+ isOcean[x * MapSize + y] = IsOceanPixel(x, y);
+ isClassifyWater[x * MapSize + y] = IsWaterPixel(x, y);
+ }
+
+ float southX = -1f, southY = -1f;
+ foreach (var t in _towns)
+ if (t.Position.Y > southY) { southX = t.Position.X; southY = t.Position.Y; }
+
+ var p = new RiverCarvePass.Params
+ {
+ RoutingStyle = ConfigManager.RiverRoutingStyle == "short"
+ ? RiverCarvePass.STYLE_SHORT : RiverCarvePass.STYLE_LOWGROUND,
+ WidthScale = ConfigManager.RiverWidthScale,
+ DepthScale = ConfigManager.RiverDepthScale,
+ SeaMarginM = ConfigManager.RiverSeaMargin
+ };
+ var st = RiverCarvePass.Apply(_heightMap, MapSize, isOcean, isClassifyWater,
+ southX, southY, seaMap, seaFlat,
+ _impactCenter.X, _impactCenter.Y,
+ _impactRadius * ConfigManager.CraterErosionCore,
+ () => (Time.GetTicksMsec()) / 1000.0, p);
+
+ long wetAfter = CountRenderWaterPixels(seaMap, seaFlat);
+ if (wetAfter != wetBefore)
+ throw new System.InvalidOperationException(
+ $"[MapGenerator] RIVER FLOOD-GUARD VIOLATION: render-map water pixels {wetBefore} -> {wetAfter}. Refusing to generate.");
+ float topAfter = 0f;
+ for (int x = 0; x < MapSize; x++)
+ for (int y = 0; y < MapSize; y++)
+ if (_heightMap[x, y] > topAfter) topAfter = _heightMap[x, y];
+
+ GD.Print($"{T()} [Rivers] v1 '{ConfigManager.RiverRoutingStyle}': plan {st.AnalysisSeconds:F1}s, " +
+ $"routing {st.RoutingSeconds:F1}s, carve {st.CarveSeconds:F1}s " +
+ $"({(Time.GetTicksMsec() - tRiv0) / 1000.0:F1}s total). " +
+ $"{st.CarvedCells} cell-writes, {st.CarvedVolumeM3:F0} m³, max cut {st.MaxCutM:F1} m. " +
+ $"Water pixels {wetBefore} -> {wetAfter} (flood guard holds); island top {topBefore * 251f:F2} -> {topAfter * 251f:F2} m.");
+ foreach (var r in st.Rivers)
+ GD.Print($"{T()} [Rivers] {r.Name} [{r.Kind}{(r.SouthernCandidate ? " SOUTHERN" : "")}]: " +
+ $"drainage {r.DrainagePx}, course {r.CourseLenPx} px" +
+ (r.RouteLenPx > 0 ? $", lowland route {r.RouteLenPx} px (straight {r.RouteStraightPx:F0}, wander {r.WanderRatio:F2})" : "") +
+ $", {(r.ReachedOcean ? "reaches ocean/terminal" : "*** ROUTE INCOMPLETE ***")}, " +
+ $"max cut {r.MaxCutM:F1} m, {r.VolumeM3:F0} m³.");
+ }
+
// Render-map water pixel count — the erosion flood-guard's external check.
private long CountRenderWaterPixels(float[,] seaMap, float seaFlat)
{
diff --git a/Tools/Scripts/RiverCarvePass.cs b/Tools/Scripts/RiverCarvePass.cs
new file mode 100644
index 0000000..a5d803b
--- /dev/null
+++ b/Tools/Scripts/RiverCarvePass.cs
@@ -0,0 +1,361 @@
+using System;
+using System.Collections.Generic;
+
+///
+/// River bed carving — C0b part 2a (terrain-water task 22). The first river pass
+/// that MODIFIES terrain: executes the frozen task-21b plan by carving channel
+/// beds for the promoted rivers. NO WATER — part 2b puts water into these beds
+/// once the routing-style gate picks SHORT or LOWGROUND.
+///
+/// Standalone numeric (D-035 family). Runs the task-21 DrainageAnalysis in-
+/// pipeline (deterministic: same seed → same eroded surface → same plan), then:
+///
+/// 1. LOWLAND ROUTING (the A/B): each routed giant gets a route from its
+/// pooling terminal to the nearest OCEAN cell by deterministic Dijkstra.
+/// SHORT — cost ≈ distance, uphill penalised: heads direct, avoids walls.
+/// LOWGROUND — cost ≈ elevation above sea: follows the lowest available
+/// ground and wanders like a real lowland river.
+/// 2. BED CARVING: every promoted course (trunks, routed giants + their lowland
+/// reaches, lake-enders, tributaries) is stamped as a parabolic channel with
+/// a smoothstep shoulder — a bed for water to sit in, not a canyon. Width and
+/// depth grow downstream with drainage. The bed elevation is made MONOTONE
+/// NON-INCREASING toward the outlet (water must flow), and is clamped to
+/// sea + margin everywhere — the flood-guard discipline erosion established:
+/// below-sea cells are read-only, no carve may create inland below-sea cells,
+/// so the rendered coastline cannot move. The bed meets the ocean AT the
+/// coast, where the terrain itself descends through sea level.
+/// 3. Crater: nothing inside the protected core is modified (erosion's rule).
+///
+/// Output-only: the caller applies this to the RENDER map after classify-side
+/// data (biomes, WBID) is already computed — the oracle stays byte-identical.
+///
+public static class RiverCarvePass
+{
+ public const float M_PER_UNIT = 251f;
+ public const byte STYLE_SHORT = 0;
+ public const byte STYLE_LOWGROUND = 1;
+
+ private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
+ private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
+ private static readonly float[] DIST = {
+ 1.41421356f, 1f, 1.41421356f, 1f, 1f, 1.41421356f, 1f, 1.41421356f };
+
+ // Routing cost constants. SHORT pays lightly for climbing (8 per metre of rise,
+ // so a 10 m wall costs like an 80 px detour — walls are avoided, direction is
+ // kept). LOWGROUND pays for BEING high (1 per metre of elevation per px) plus
+ // heavily for climbing, so the cheapest corridor is the lowest ground even when
+ // that wanders.
+ private const float SHORT_UPHILL_PER_M = 8f;
+ private const float LOWGROUND_ELEV_PER_M = 1f;
+ private const float LOWGROUND_BASE = 0.05f;
+ private const float LOWGROUND_UPHILL_PER_M = 50f;
+
+ // Bed geometry: sizes grow downstream from head to mouth, scaled by
+ // sqrt(drainage / 1e6) so a 2.3M px giant carves roughly 2.3× deeper/wider at
+ // the mouth than a 0.4M px trunk. Kept channel-scale, per the erosion
+ // detailing philosophy.
+ private const float DEPTH_HEAD_M = 1.0f;
+ private const float DEPTH_MOUTH_M = 6.0f; // × sizeFactor × DepthScale
+ private const float HALFWIDTH_HEAD_PX = 2.0f;
+ private const float HALFWIDTH_MOUTH_PX = 12.0f; // × sizeFactor × WidthScale
+ private const float MIN_BED_SLOPE = 0.002f; // m per px of enforced descent
+
+ public class Params
+ {
+ public byte RoutingStyle = STYLE_LOWGROUND;
+ public float WidthScale = 1.0f;
+ public float DepthScale = 1.0f;
+ public float SeaMarginM = 0.2f; // bed floor above sea, everywhere
+ public DrainageAnalysis.Params PlanParams = new();
+ }
+
+ public class RiverStat
+ {
+ public string Name;
+ public string Kind;
+ public bool SouthernCandidate;
+ public long DrainagePx;
+ public int CourseLenPx;
+ public int RouteLenPx; // lowland reach only (routed giants)
+ public float RouteStraightPx;
+ public float WanderRatio; // routeLen / straight-line
+ public bool ReachedOcean;
+ public float MaxCutM;
+ public double VolumeM3;
+ }
+
+ public class Stats
+ {
+ public List Rivers = new();
+ public long CarvedCells;
+ public double CarvedVolumeM3;
+ public float MaxCutM;
+ public double AnalysisSeconds, RoutingSeconds, CarveSeconds;
+ }
+
+ public static Stats Apply(float[,] height, int mapSize, bool[] isOcean,
+ bool[] isClassifyWater, float southX, float southY,
+ float[,] seaMap, float seaFlat,
+ float craterCx, float craterCy, float craterCoreRadius,
+ Func secondsNow, Params p)
+ {
+ int n = mapSize;
+ var stats = new Stats();
+ float SeaAt(int x, int y) => seaMap != null ? seaMap[x, y] : seaFlat;
+ float coreSq = craterCoreRadius * craterCoreRadius;
+
+ // --- the frozen plan, recomputed deterministically in-pipeline ---
+ double t0 = secondsNow();
+ var plan = DrainageAnalysis.Run(height, mapSize, isOcean, isClassifyWater, southX, southY, p.PlanParams);
+ stats.AnalysisSeconds = secondsNow() - t0;
+
+ // --- lowland routing for the routed giants (the A/B) ---
+ t0 = secondsNow();
+ var giantRoutes = new List>();
+ foreach (var g in plan.Giants)
+ {
+ if (g.Kind != "routed") { giantRoutes.Add(null); continue; }
+ giantRoutes.Add(RouteToOcean(height, n, isOcean,
+ (int)g.Terminal.x, (int)g.Terminal.y, p.RoutingStyle, SeaAt));
+ }
+ stats.RoutingSeconds = secondsNow() - t0;
+
+ // --- carve ---
+ t0 = secondsNow();
+ int riverIdx = 0;
+ foreach (var t in plan.Trunks)
+ {
+ riverIdx++;
+ var course = new List<(float x, float y)>(t.Course);
+ course.Reverse(); // head → mouth
+ var rs = CarveRiver($"trunk{riverIdx}", "ocean-trunk", t.DrainageAreaPx,
+ course, null, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
+ rs.ReachedOcean = true; // outlet is on the coast by construction
+ foreach (var trib in t.Tributaries)
+ CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
+ }
+ int gi = 0;
+ foreach (var g in plan.Giants)
+ {
+ var route = giantRoutes[gi]; gi++;
+ var course = new List<(float x, float y)>(g.Course);
+ course.Reverse(); // head → terminal
+ var rs = CarveRiver($"giant{gi}", g.Kind, g.DrainageAreaPx,
+ course, route, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
+ rs.SouthernCandidate = g.SouthernCandidate;
+ rs.ReachedOcean = g.Kind != "routed" || (route != null && route.Count > 0);
+ foreach (var trib in g.Tributaries)
+ CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
+ }
+ stats.CarveSeconds = secondsNow() - t0;
+ return stats;
+ }
+
+ ///
+ /// Deterministic Dijkstra from the start cell to the nearest ocean cell under
+ /// the selected style's cost model. Returns the path start → ocean (1-px steps),
+ /// or an empty list if no path exists (reported upstream, never asserted away).
+ ///
+ private static List<(float x, float y)> RouteToOcean(float[,] height, int n,
+ bool[] isOcean, int sx, int sy, byte style, Func seaAt)
+ {
+ int total = n * n;
+ var gcost = new float[total];
+ var parent = new int[total];
+ var closed = new bool[total];
+ Array.Fill(gcost, float.MaxValue);
+ Array.Fill(parent, -1);
+
+ float ElevM(int x, int y) => MathF.Max(0f, (height[x, y] - seaAt(x, y)) * M_PER_UNIT);
+
+ var pq = new PriorityQueue();
+ int start = sx * n + sy;
+ gcost[start] = 0f;
+ pq.Enqueue(start, (0f, start));
+ int goal = -1;
+
+ while (pq.Count > 0)
+ {
+ int c = pq.Dequeue();
+ if (closed[c]) continue;
+ closed[c] = true;
+ if (isOcean[c]) { goal = c; break; }
+ int cx = c / n, cy = c % n;
+ float hc = height[cx, cy];
+ for (int k = 0; k < 8; k++)
+ {
+ int nx = cx + DX[k], ny = cy + DY[k];
+ if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
+ int ni = nx * n + ny;
+ if (closed[ni]) continue;
+ float dhM = MathF.Max(0f, (height[nx, ny] - hc) * M_PER_UNIT);
+ float step = style == STYLE_SHORT
+ ? DIST[k] + dhM * SHORT_UPHILL_PER_M
+ : DIST[k] * (LOWGROUND_BASE + ElevM(nx, ny) * LOWGROUND_ELEV_PER_M)
+ + dhM * LOWGROUND_UPHILL_PER_M;
+ float nc = gcost[c] + step;
+ if (nc < gcost[ni])
+ {
+ gcost[ni] = nc;
+ parent[ni] = c;
+ pq.Enqueue(ni, (nc, ni));
+ }
+ }
+ }
+
+ var path = new List<(float x, float y)>();
+ if (goal >= 0)
+ {
+ for (int c = goal; c >= 0; c = parent[c])
+ path.Add((c / n, c % n));
+ path.Reverse();
+ }
+ return path;
+ }
+
+ private static void CarveTributary(DrainageAnalysis.Stream trib, float[,] height, int n,
+ Func seaAt, float coreSq, float craterCx, float craterCy,
+ Params p, Stats stats)
+ {
+ var course = new List<(float x, float y)>(trib.Course);
+ course.Reverse(); // head → confluence
+ CarveRiver(null, "tributary", trib.DrainageAreaPx, course, null,
+ height, n, seaAt, coreSq, craterCx, craterCy, p, stats);
+ }
+
+ ///
+ /// Carves one river: densify the course, build a monotone-descending clamped
+ /// bed profile, stamp the channel. Returns the per-river stat (also appended
+ /// to stats.Rivers unless name is null — tributaries fold into the totals).
+ ///
+ private static RiverStat CarveRiver(string name, string kind, long drainagePx,
+ List<(float x, float y)> upland, List<(float x, float y)> lowlandRoute,
+ float[,] height, int n, Func seaAt,
+ float coreSq, float craterCx, float craterCy, Params p, Stats stats)
+ {
+ // Full head→mouth polyline: upland stem, then the lowland reach if any.
+ var pts = new List<(float x, float y)>(upland);
+ if (lowlandRoute != null && lowlandRoute.Count > 1)
+ pts.AddRange(lowlandRoute.GetRange(1, lowlandRoute.Count - 1));
+
+ // Densify to ~1-px samples (plan courses are decimated ×4).
+ var dense = new List<(float x, float y)>();
+ for (int i = 0; i + 1 < pts.Count; i++)
+ {
+ var a = pts[i]; var b = pts[i + 1];
+ float segLen = MathF.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
+ int steps = Math.Max(1, (int)MathF.Ceiling(segLen));
+ for (int s2 = 0; s2 < steps; s2++)
+ dense.Add((a.x + (b.x - a.x) * s2 / steps, a.y + (b.y - a.y) * s2 / steps));
+ }
+ if (pts.Count > 0) dense.Add(pts[^1]);
+ if (dense.Count < 2) return new RiverStat();
+
+ float sizeFactor = MathF.Sqrt(drainagePx / 1_000_000f);
+ var rs = new RiverStat
+ {
+ Name = name, Kind = kind, DrainagePx = drainagePx,
+ CourseLenPx = dense.Count,
+ RouteLenPx = lowlandRoute?.Count ?? 0
+ };
+ if (lowlandRoute != null && lowlandRoute.Count > 1)
+ {
+ var a = lowlandRoute[0]; var b = lowlandRoute[^1];
+ rs.RouteStraightPx = MathF.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
+ // Wander = POLYLINE length over straight-line — cell count undercounts
+ // diagonal steps and can read below 1, which is geometrically impossible.
+ float polyLen = 0f;
+ for (int i = 1; i < lowlandRoute.Count; i++)
+ {
+ float sdx = lowlandRoute[i].x - lowlandRoute[i - 1].x;
+ float sdy = lowlandRoute[i].y - lowlandRoute[i - 1].y;
+ polyLen += MathF.Sqrt(sdx * sdx + sdy * sdy);
+ }
+ rs.RouteLenPx = (int)polyLen;
+ rs.WanderRatio = rs.RouteStraightPx > 1f ? polyLen / rs.RouteStraightPx : 1f;
+ }
+
+ // Bed profile: raw = terrain − depth(t); then monotone non-increasing
+ // downstream; then clamped to sea + margin. The clamp can flatten the tail
+ // near the mouth — allowed: non-increasing is what water needs, and the
+ // flood guard is absolute.
+ int m = dense.Count;
+ var bed = new float[m];
+ var depth = new float[m];
+ var halfW = new float[m];
+ for (int i = 0; i < m; i++)
+ {
+ float t = m > 1 ? (float)i / (m - 1) : 1f;
+ depth[i] = (DEPTH_HEAD_M + (DEPTH_MOUTH_M * sizeFactor - DEPTH_HEAD_M) * t) * p.DepthScale;
+ if (depth[i] < 0.5f) depth[i] = 0.5f;
+ halfW[i] = (HALFWIDTH_HEAD_PX + (HALFWIDTH_MOUTH_PX * sizeFactor - HALFWIDTH_HEAD_PX) * t) * p.WidthScale;
+ if (halfW[i] < 1.5f) halfW[i] = 1.5f;
+ int cx = (int)dense[i].x, cy = (int)dense[i].y;
+ bed[i] = height[cx, cy] - depth[i] / M_PER_UNIT;
+ }
+ for (int i = 1; i < m; i++)
+ {
+ float maxAllowed = bed[i - 1] - MIN_BED_SLOPE / M_PER_UNIT;
+ if (bed[i] > maxAllowed) bed[i] = maxAllowed;
+ }
+ for (int i = 0; i < m; i++)
+ {
+ int cx = (int)dense[i].x, cy = (int)dense[i].y;
+ float floor = seaAt(cx, cy) + p.SeaMarginM / M_PER_UNIT;
+ if (bed[i] < floor) bed[i] = floor;
+ }
+
+ // Stamp: parabolic channel to the rim, smoothstep shoulder back to terrain.
+ for (int i = 0; i < m; i++)
+ {
+ float hw = halfW[i];
+ float outer = hw * 2f;
+ int cx0 = (int)MathF.Floor(dense[i].x - outer), cx1 = (int)MathF.Ceiling(dense[i].x + outer);
+ int cy0 = (int)MathF.Floor(dense[i].y - outer), cy1 = (int)MathF.Ceiling(dense[i].y + outer);
+ float rimH = bed[i] + depth[i] / M_PER_UNIT;
+ for (int x = cx0; x <= cx1; x++)
+ {
+ if (x < 0 || x >= n) continue;
+ for (int y = cy0; y <= cy1; y++)
+ {
+ if (y < 0 || y >= n) continue;
+ float rx = x - dense[i].x, ry = y - dense[i].y;
+ float r = MathF.Sqrt(rx * rx + ry * ry);
+ if (r > outer) continue;
+ float ddx = x - craterCx, ddy = y - craterCy;
+ if (ddx * ddx + ddy * ddy < coreSq) continue; // crater core protected
+ float sea = seaAt(x, y);
+ float old = height[x, y];
+ if (old < sea) continue; // below-sea cells read-only
+ float target;
+ if (r <= hw)
+ {
+ float f = r / hw;
+ target = bed[i] + (depth[i] / M_PER_UNIT) * f * f;
+ }
+ else
+ {
+ float f = (r - hw) / hw; // 0..1 across the shoulder
+ f = f * f * (3f - 2f * f); // smoothstep
+ target = rimH + (old - rimH) * f;
+ }
+ float floor = sea + p.SeaMarginM / M_PER_UNIT;
+ if (target < floor) target = floor;
+ if (target < old)
+ {
+ float cutM = (old - target) * M_PER_UNIT;
+ height[x, y] = target;
+ stats.CarvedCells++;
+ stats.CarvedVolumeM3 += cutM;
+ if (cutM > stats.MaxCutM) stats.MaxCutM = cutM;
+ if (cutM > rs.MaxCutM) rs.MaxCutM = cutM;
+ rs.VolumeM3 += cutM;
+ }
+ }
+ }
+ }
+
+ if (name != null) stats.Rivers.Add(rs);
+ return rs;
+ }
+}