feat: stepped river water + widen + lake-ender join + the task-22 nits (terrain-water task 23, C0b finale)

THE PAYOFF: rivers now carry water. Each carved main river becomes a chain of
stepped flat water-body reaches — a new reach every RiverStepDropM (2 m) of bed
descent, sitting RiverWaterDepthM (1.2 m) above its bed, strictly descending to
the outlet. Reaches are ordinary water bodies (WBID cells + WBTB type 2 'river',
fresh; WSRF derives from body levels as ever), so river water renders through
the C1 path with the task-15 presence rule at its banks, untouched. Existing
bodies are never overwritten — a river MEETS its lake or the sea. Measured on
1280587109: 298 reaches across 6 rivers, 184,620 wet px, levels stepping 278 m
down to 39.5 m; in-engine, one valley frame renders surfaces 46.2-114.5 m.

Beds widened (RiverWidthScale default 1.75). Lake-enders: the stem's pooling
terminal IS a local minimum, so the first extension attempt (blind steepest
descent) dead-ended in 0 steps — replaced with the lowground Dijkstra to the
nearest classify-water cell; the E-lagoon river now joins its lake (87 px).
Smoothing is applied ONLY to lowland routes: smoothing upland stems moved them
off their carved valley floors into the walls (max cut 14.6 -> 27.3 m measured;
split restores valley-floor fidelity). TYPE_RIVER added to the WBTB registry
(parser validation extended; doc updated in the docs commit).

Task-22 nits fixed: max-cut is now CUMULATIVE vs the pre-pass surface (the
honest number: p95 carve 7.0 m; 339 cells island-wide exceed 20 m — localized
outlet-gorge notches where stems cross deposit ridges, deepest 26 m); and
0_height/0_water are re-drawn and re-captured AFTER the river pass so the
exported snapshots show beds and water.

Guards: flood guard holds (water pixels unchanged around the carve; the water
stage touches no heights), BIOME oracle md5-identical to the task-22 baseline,
0_water changed (that IS the river water), island top 457.65 m exact, crater
core excluded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-11 19:55:04 -04:00
parent 148602b4c5
commit 8f5767308a
4 changed files with 210 additions and 13 deletions

View file

@ -119,9 +119,16 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
// absolute floor above sea — the erosion flood-guard discipline: no river // absolute floor above sea — the erosion flood-guard discipline: no river
// bed may create inland below-sea cells, so the rendered coastline cannot // bed may create inland below-sea cells, so the rendered coastline cannot
// move even with rivers carved. // move even with rivers carved.
// RiverStepDropM/RiverWaterDepthM (task 23): the stepped-water dials — each
// river is a chain of flat water-body reaches; a new reach starts every
// StepDrop metres of bed descent and sits WaterDepth metres above its bed.
// Smaller drop = more, finer steps = smoother water (the smoothing dial;
// tilted continuous-slope water is the deferred model B).
public static string Rivers = "off"; public static string Rivers = "off";
public static string RiverRoutingStyle = "lowground"; public static string RiverRoutingStyle = "lowground";
public static float RiverWidthScale = 1.0f; public static float RiverWidthScale = 1.75f; // widened at the task-23 gate's ask
public static float RiverStepDropM = 2.0f;
public static float RiverWaterDepthM = 1.2f;
public static float RiverDepthScale = 1.0f; public static float RiverDepthScale = 1.0f;
public static float RiverSeaMargin = 0.2f; // m above sea, bed floor public static float RiverSeaMargin = 0.2f; // m above sea, bed floor
@ -330,6 +337,10 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
if (data.ContainsKey("RiverWidthScale")) RiverWidthScale = (float)data["RiverWidthScale"]; if (data.ContainsKey("RiverWidthScale")) RiverWidthScale = (float)data["RiverWidthScale"];
if (data.ContainsKey("RiverDepthScale")) RiverDepthScale = (float)data["RiverDepthScale"]; if (data.ContainsKey("RiverDepthScale")) RiverDepthScale = (float)data["RiverDepthScale"];
if (data.ContainsKey("RiverSeaMargin")) RiverSeaMargin = (float)data["RiverSeaMargin"]; if (data.ContainsKey("RiverSeaMargin")) RiverSeaMargin = (float)data["RiverSeaMargin"];
if (data.ContainsKey("RiverStepDropM")) RiverStepDropM = (float)data["RiverStepDropM"];
if (data.ContainsKey("RiverWaterDepthM")) RiverWaterDepthM = (float)data["RiverWaterDepthM"];
RiverStepDropM = Mathf.Clamp(RiverStepDropM, 0.25f, 10f);
RiverWaterDepthM = Mathf.Clamp(RiverWaterDepthM, 0.2f, 5f);
RiverWidthScale = Mathf.Clamp(RiverWidthScale, 0.1f, 5f); RiverWidthScale = Mathf.Clamp(RiverWidthScale, 0.1f, 5f);
RiverDepthScale = Mathf.Clamp(RiverDepthScale, 0.1f, 5f); RiverDepthScale = Mathf.Clamp(RiverDepthScale, 0.1f, 5f);
RiverSeaMargin = Mathf.Clamp(RiverSeaMargin, 0f, 5f); RiverSeaMargin = Mathf.Clamp(RiverSeaMargin, 0f, 5f);

View file

@ -47,6 +47,7 @@ namespace IslaApocalypse.Core
{ {
public const byte TYPE_OCEAN = 0; public const byte TYPE_OCEAN = 0;
public const byte TYPE_LAKE = 1; public const byte TYPE_LAKE = 1;
public const byte TYPE_RIVER = 2; // task 23: a stepped river REACH (one flat level)
public const byte SALINITY_FRESH = 0; public const byte SALINITY_FRESH = 0;
public const byte SALINITY_SALT = 1; public const byte SALINITY_SALT = 1;
@ -432,7 +433,7 @@ namespace IslaApocalypse.Core
body.SurfaceLevel = reader.ReadSingle(); body.SurfaceLevel = reader.ReadSingle();
body.PixelCount = reader.ReadInt32(); body.PixelCount = reader.ReadInt32();
body.Centroid = new Vector2(reader.ReadSingle(), reader.ReadSingle()); body.Centroid = new Vector2(reader.ReadSingle(), reader.ReadSingle());
if (body.Type > WaterBodyInfo.TYPE_LAKE) if (body.Type > WaterBodyInfo.TYPE_RIVER)
{ {
GD.PrintErr($"[MapDataParser] ERROR: water body {i} has unknown type {body.Type}."); GD.PrintErr($"[MapDataParser] ERROR: water body {i} has unknown type {body.Type}.");
return false; return false;

View file

@ -237,7 +237,16 @@ public partial class MapGenerator : TextureRect
// only: biomes and WBID are already computed from classify — the oracle is // only: biomes and WBID are already computed from classify — the oracle is
// untouched by construction. NO WATER — part 2b. // untouched by construction. NO WATER — part 2b.
if (_riversOn) if (_riversOn)
{
CarveRivers(); CarveRivers();
// The 0_height/0_water snapshots were captured before rivers existed —
// re-draw and re-capture so the exported PNGs show the carved beds and
// the new river water (task-22 nit 2).
DrawHeightStageTexture();
await CaptureStage("0_height");
DrawWaterStageTexture();
await CaptureStage("0_water");
}
if (ConfigManager.SkipRoads) if (ConfigManager.SkipRoads)
{ {
@ -863,6 +872,36 @@ public partial class MapGenerator : TextureRect
for (int y = 0; y < MapSize; y++) for (int y = 0; y < MapSize; y++)
if (_heightMap[x, y] > topAfter) topAfter = _heightMap[x, y]; if (_heightMap[x, y] > topAfter) topAfter = _heightMap[x, y];
// --- Stepped river water (task 23, part 2b): reaches as flat water bodies.
// Levels-not-cells, the existing model: WBID cells + WBTB entries per reach;
// the writer derives WSRF from body levels. Touches no heights, so the
// flood-guard count above stays valid; touches no classify data, so the
// BIOME oracle holds (0_water changes — that IS the river water).
ushort nextBodyId = 1;
foreach (var b in _waterBodies)
if (b.Id >= nextBodyId) nextBodyId = (ushort)(b.Id + 1);
var reaches = RiverCarvePass.AddSteppedWater(_heightMap, MapSize, _waterBodyIds,
nextBodyId, st.Carved, seaMap, seaFlat,
_impactCenter.X, _impactCenter.Y,
_impactRadius * ConfigManager.CraterErosionCore,
ConfigManager.RiverStepDropM, ConfigManager.RiverWaterDepthM);
long riverWetPx = 0;
foreach (var reach in reaches)
{
_waterBodies.Add(new WaterBodyInfo
{
Id = reach.Id,
Type = WaterBodyInfo.TYPE_RIVER,
Salinity = WaterBodyInfo.SALINITY_FRESH,
SurfaceLevel = reach.Level,
PixelCount = reach.PixelCount,
Centroid = new Vector2((float)reach.Cx, (float)reach.Cy)
});
riverWetPx += reach.PixelCount;
}
GD.Print($"{T()} [Rivers] water: {reaches.Count} stepped reaches across {st.Carved.Count} rivers, " +
$"{riverWetPx} wet px, step drop {ConfigManager.RiverStepDropM:F1} m, depth {ConfigManager.RiverWaterDepthM:F1} m.");
GD.Print($"{T()} [Rivers] v1 '{ConfigManager.RiverRoutingStyle}': plan {st.AnalysisSeconds:F1}s, " + GD.Print($"{T()} [Rivers] v1 '{ConfigManager.RiverRoutingStyle}': plan {st.AnalysisSeconds:F1}s, " +
$"routing {st.RoutingSeconds:F1}s, carve {st.CarveSeconds:F1}s " + $"routing {st.RoutingSeconds:F1}s, carve {st.CarveSeconds:F1}s " +
$"({(Time.GetTicksMsec() - tRiv0) / 1000.0:F1}s total). " + $"({(Time.GetTicksMsec() - tRiv0) / 1000.0:F1}s total). " +

View file

@ -84,12 +84,26 @@ public static class RiverCarvePass
public double VolumeM3; public double VolumeM3;
} }
/// <summary>The carved geometry the water stage consumes (main rivers only).</summary>
public class CarvedRiver
{
public string Name, Kind;
public bool Southern;
public long DrainagePx;
public List<(float x, float y)> Dense; // head → mouth, ~1-px samples
public float[] Bed; // raw units, monotone non-increasing
public float[] HalfW; // px
public bool ReachedWaterTerminal; // lake-enders: extension reached classify water
}
public class Stats public class Stats
{ {
public List<RiverStat> Rivers = new(); public List<RiverStat> Rivers = new();
public List<CarvedRiver> Carved = new(); // for the stepped-water stage (task 23)
internal float[] PrePass; // cumulative-cut baseline
public long CarvedCells; public long CarvedCells;
public double CarvedVolumeM3; public double CarvedVolumeM3;
public float MaxCutM; public float MaxCutM; // CUMULATIVE vs pre-pass heights (task-22 nit 1 fixed)
public double AnalysisSeconds, RoutingSeconds, CarveSeconds; public double AnalysisSeconds, RoutingSeconds, CarveSeconds;
} }
@ -122,6 +136,14 @@ public static class RiverCarvePass
// --- carve --- // --- carve ---
t0 = secondsNow(); t0 = secondsNow();
// Pre-pass snapshot: max-cut is measured CUMULATIVELY against the heights
// this pass found, not per-write — overlapping stamps re-cut a cell and the
// per-write number understated the true deepest cut ~4× (task-22 nit 1).
float[] pre = new float[n * n];
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
pre[x * n + y] = height[x, y];
stats.PrePass = pre;
int riverIdx = 0; int riverIdx = 0;
foreach (var t in plan.Trunks) foreach (var t in plan.Trunks)
{ {
@ -140,10 +162,25 @@ public static class RiverCarvePass
var route = giantRoutes[gi]; gi++; var route = giantRoutes[gi]; gi++;
var course = new List<(float x, float y)>(g.Course); var course = new List<(float x, float y)>(g.Course);
course.Reverse(); // head → terminal course.Reverse(); // head → terminal
// Lake-enders (task 23): the stem pools on dry ground short of its lake
// BECAUSE its pooling point is a local minimum — a blind descent walk
// dead-ends there immediately (measured: 0 steps). Route to the nearest
// classify-water cell with the same lowground Dijkstra the routed giants
// use, so the bed (and then the water) actually joins the lake.
bool reachedLake = false;
if (g.Kind == "lake-ender")
{
var ext = RouteToOcean(height, n, isClassifyWater,
(int)g.Terminal.x, (int)g.Terminal.y, STYLE_LOWGROUND, SeaAt);
if (ext.Count > 0) { route = ext; reachedLake = true; }
}
var rs = CarveRiver($"giant{gi}", g.Kind, g.DrainageAreaPx, var rs = CarveRiver($"giant{gi}", g.Kind, g.DrainageAreaPx,
course, route, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats); course, route, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
rs.SouthernCandidate = g.SouthernCandidate; rs.SouthernCandidate = g.SouthernCandidate;
rs.ReachedOcean = g.Kind != "routed" || (route != null && route.Count > 0); rs.ReachedOcean = g.Kind == "routed" ? (route != null && route.Count > 0) : reachedLake;
if (stats.Carved.Count > 0) stats.Carved[^1].ReachedWaterTerminal = reachedLake;
foreach (var trib in g.Tributaries) foreach (var trib in g.Tributaries)
CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats); CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
} }
@ -205,7 +242,7 @@ public static class RiverCarvePass
/// or an empty list if no path exists (reported upstream, never asserted away). /// or an empty list if no path exists (reported upstream, never asserted away).
/// </summary> /// </summary>
private static List<(float x, float y)> RouteToOcean(float[,] height, int n, private static List<(float x, float y)> RouteToOcean(float[,] height, int n,
bool[] isOcean, int sx, int sy, byte style, Func<int, int, float> seaAt) bool[] targets, int sx, int sy, byte style, Func<int, int, float> seaAt)
{ {
int total = n * n; int total = n * n;
var gcost = new float[total]; var gcost = new float[total];
@ -227,7 +264,7 @@ public static class RiverCarvePass
int c = pq.Dequeue(); int c = pq.Dequeue();
if (closed[c]) continue; if (closed[c]) continue;
closed[c] = true; closed[c] = true;
if (isOcean[c]) { goal = c; break; } if (targets[c]) { goal = c; break; }
int cx = c / n, cy = c % n; int cx = c / n, cy = c % n;
float hc = height[cx, cy]; float hc = height[cx, cy];
for (int k = 0; k < 8; k++) for (int k = 0; k < 8; k++)
@ -281,12 +318,17 @@ public static class RiverCarvePass
float[,] height, int n, Func<int, int, float> seaAt, float[,] height, int n, Func<int, int, float> seaAt,
float coreSq, float craterCx, float craterCy, Params p, Stats stats) float coreSq, float craterCx, float craterCy, Params p, Stats stats)
{ {
// Full head→mouth polyline: upland stem, then the lowland reach if any — // Full head→mouth polyline: upland stem, then the lowland reach if any.
// then SMOOTHED (task 23) so the carved centreline carries no routing kinks. // ONLY the lowland reach is smoothed: the Dijkstra 45° kinks live there, on
// near-flat ground where a rounded corner costs nothing. The upland stems
// already thread the carved valley FLOORS — smoothing them off-line cut
// valley walls (measured: max cut 14.6 → 27.3 m before this was split).
var pts = new List<(float x, float y)>(upland); var pts = new List<(float x, float y)>(upland);
if (lowlandRoute != null && lowlandRoute.Count > 1) if (lowlandRoute != null && lowlandRoute.Count > 1)
pts.AddRange(lowlandRoute.GetRange(1, lowlandRoute.Count - 1)); {
pts = SmoothCourse(pts); var smoothedRoute = SmoothCourse(lowlandRoute);
pts.AddRange(smoothedRoute.GetRange(1, smoothedRoute.Count - 1));
}
// Densify to ~1-px samples (plan courses are decimated ×4). // Densify to ~1-px samples (plan courses are decimated ×4).
var dense = new List<(float x, float y)>(); var dense = new List<(float x, float y)>();
@ -394,18 +436,122 @@ public static class RiverCarvePass
if (target < old) if (target < old)
{ {
float cutM = (old - target) * M_PER_UNIT; float cutM = (old - target) * M_PER_UNIT;
// Cumulative depth vs the PRE-PASS surface (nit 1): the
// honest "how deep did we cut here in total" number.
float cumM = (stats.PrePass[x * n + y] - target) * M_PER_UNIT;
height[x, y] = target; height[x, y] = target;
stats.CarvedCells++; stats.CarvedCells++;
stats.CarvedVolumeM3 += cutM; stats.CarvedVolumeM3 += cutM;
if (cutM > stats.MaxCutM) stats.MaxCutM = cutM; if (cumM > stats.MaxCutM) stats.MaxCutM = cumM;
if (cutM > rs.MaxCutM) rs.MaxCutM = cutM; if (cumM > rs.MaxCutM) rs.MaxCutM = cumM;
rs.VolumeM3 += cutM; rs.VolumeM3 += cutM;
} }
} }
} }
} }
if (name != null) stats.Rivers.Add(rs); if (name != null)
{
stats.Rivers.Add(rs);
stats.Carved.Add(new CarvedRiver
{
Name = name, Kind = kind, DrainagePx = drainagePx,
Dense = dense, Bed = bed, HalfW = halfW
});
}
return rs; return rs;
} }
/// <summary>
/// The stepped-water builder (task 23, part 2b): segments each carved main
/// river into REACHES — flat water bodies stepping down the bed toward the
/// outlet — and stamps their ids into the WBID grid. Reuses the existing
/// levels-not-cells water model exactly: one body per reach, one flat level
/// each; the writer derives WSRF from body levels as it always has. The step
/// drops are the smoothing dial (smaller drop = more, finer steps); tilted
/// water is the deferred model B and is NOT built here.
///
/// Emission is plain data (no engine types): the caller turns reaches into
/// WBTB entries. Wet cells: inside the channel half-width, currently dry in
/// WBID, at/above sea (below-sea cells belong to the ocean/crater-seam rule),
/// bed below the reach level. Existing water bodies are never overwritten —
/// a river MEETS a lake or the sea, it does not repaint them.
/// </summary>
public class Reach
{
public ushort Id;
public string River;
public float Level; // raw units
public int PixelCount;
public double Cx, Cy; // centroid accumulators → mean
}
public static List<Reach> AddSteppedWater(float[,] height, int mapSize,
ushort[,] wbid, ushort firstId, List<CarvedRiver> rivers,
float[,] seaMap, float seaFlat, float craterCx, float craterCy,
float craterCoreRadius, float stepDropM, float waterDepthM)
{
int n = mapSize;
float SeaAt(int x, int y) => seaMap != null ? seaMap[x, y] : seaFlat;
float coreSq = craterCoreRadius * craterCoreRadius;
var reaches = new List<Reach>();
ushort nextId = firstId;
foreach (var r in rivers)
{
int m = r.Dense.Count;
if (m < 2) continue;
int i = 0;
float lastLevel = float.MaxValue;
while (i < m)
{
// Reach spans from i while the bed stays within stepDropM of the
// reach's starting bed; its flat level sits waterDepthM above that
// start (deepening toward the next step — the pool behind a riffle).
float startBed = r.Bed[i];
float level = startBed + waterDepthM / M_PER_UNIT;
if (level >= lastLevel) // enforce strict descent
level = lastLevel - 0.01f / M_PER_UNIT;
int j = i;
while (j < m && r.Bed[j] > startBed - stepDropM / M_PER_UNIT) j++;
var reach = new Reach { Id = nextId, River = r.Name, Level = level };
for (int k2 = i; k2 < j; k2++)
{
float hw = r.HalfW[k2];
int x0 = (int)MathF.Floor(r.Dense[k2].x - hw), x1 = (int)MathF.Ceiling(r.Dense[k2].x + hw);
int y0 = (int)MathF.Floor(r.Dense[k2].y - hw), y1 = (int)MathF.Ceiling(r.Dense[k2].y + hw);
for (int x = x0; x <= x1; x++)
{
if (x < 0 || x >= n) continue;
for (int y = y0; y <= y1; y++)
{
if (y < 0 || y >= n) continue;
if (wbid[x, y] != 0) continue; // never repaint existing water
float rx = x - r.Dense[k2].x, ry = y - r.Dense[k2].y;
if (rx * rx + ry * ry > hw * hw) continue;
float ddx = x - craterCx, ddy = y - craterCy;
if (ddx * ddx + ddy * ddy < coreSq) continue;
float h = height[x, y];
float sea = SeaAt(x, y);
if (h < sea) continue; // ocean/seam territory
if (h >= level) continue; // bank above the water line
wbid[x, y] = nextId;
reach.PixelCount++;
reach.Cx += x; reach.Cy += y;
}
}
}
if (reach.PixelCount > 0)
{
reach.Cx /= reach.PixelCount; reach.Cy /= reach.PixelCount;
reaches.Add(reach);
nextId++;
lastLevel = level;
}
i = j;
}
}
return reaches;
}
} }