From 148602b4c52ef266cc59dbb99a2d02bc42a6be30 Mon Sep 17 00:00:00 2001 From: beezm Date: Tue, 11 Aug 2026 19:37:29 -0400 Subject: [PATCH 1/8] feat: lock lowground routing; smooth river courses with the road pass's RDP+Chaikin (terrain-water task 23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LOWGROUND is the gate verdict and the locked default ('short' stays available behind the dial for the record). Every river polyline (upland stem + lowland reach) is now decimated with RDP(4.0) and rounded with 4 Chaikin passes — numerically mirroring MapGenerator.SmoothPath, since this pass is Godot-free — before the bed profile is built, so the carved centreline carries none of the 8-connected Dijkstra 45° kinks (task-22 §4 caveat a). The monotone-descent and sea-clamp constraints are applied to the smoothed course, unchanged. Co-Authored-By: Claude Fable 5 --- Core/Scripts/ConfigManager.cs | 4 ++- Tools/Scripts/RiverCarvePass.cs | 52 ++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index 2ae8816..f7443db 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -113,7 +113,9 @@ namespace IslaApocalypse.Core // Change this if your namespace is different // 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 + // dials on the flow-proportional bed profile. LOWGROUND is the LOCKED + // default — the task-22/23 gate verdict ("short" stays available for the + // record). 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. diff --git a/Tools/Scripts/RiverCarvePass.cs b/Tools/Scripts/RiverCarvePass.cs index a5d803b..26f5530 100644 --- a/Tools/Scripts/RiverCarvePass.cs +++ b/Tools/Scripts/RiverCarvePass.cs @@ -151,6 +151,54 @@ public static class RiverCarvePass return stats; } + // ---- Route smoothing (task 23): the road pass's AAA pipeline, numerically ---- + // RDP(4.0) decimation + 4 Chaikin corner-cutting passes, endpoints pinned — + // the same constants and structure as MapGenerator.SmoothPath, mirrored here + // because this pass is Godot-free. Kills the 8-connected Dijkstra 45° kinks; + // the bed then carves along the smoothed centreline. + private static List<(float x, float y)> SmoothCourse(List<(float x, float y)> raw) + { + if (raw.Count < 3) return raw; + var dec = Rdp(raw, 0, raw.Count - 1, 4.0f); + if (dec.Count < 3) return raw; + var sm = dec; + for (int pass = 0; pass < 4; pass++) + { + var nxt = new List<(float x, float y)>(sm.Count * 2) { sm[0] }; + for (int i = 0; i + 1 < sm.Count; i++) + { + var a = sm[i]; var b = sm[i + 1]; + nxt.Add((a.x * 0.75f + b.x * 0.25f, a.y * 0.75f + b.y * 0.25f)); + nxt.Add((a.x * 0.25f + b.x * 0.75f, a.y * 0.25f + b.y * 0.75f)); + } + nxt.Add(sm[^1]); + sm = nxt; + } + return sm; + } + + private static List<(float x, float y)> Rdp(List<(float x, float y)> pts, int i0, int i1, float tol) + { + if (i1 - i0 <= 1) return new List<(float x, float y)> { pts[i0], pts[i1] }; + var a = pts[i0]; var b = pts[i1]; + float abx = b.x - a.x, aby = b.y - a.y; + float abLen = MathF.Sqrt(abx * abx + aby * aby); + float maxD = 0f; int maxI = i0; + for (int i = i0 + 1; i < i1; i++) + { + float d = abLen < 1e-6f + ? MathF.Sqrt((pts[i].x - a.x) * (pts[i].x - a.x) + (pts[i].y - a.y) * (pts[i].y - a.y)) + : MathF.Abs(abx * (a.y - pts[i].y) - (a.x - pts[i].x) * aby) / abLen; + if (d > maxD) { maxD = d; maxI = i; } + } + if (maxD <= tol) return new List<(float x, float y)> { pts[i0], pts[i1] }; + var left = Rdp(pts, i0, maxI, tol); + var right = Rdp(pts, maxI, i1, tol); + left.RemoveAt(left.Count - 1); + left.AddRange(right); + return left; + } + /// /// 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), @@ -233,10 +281,12 @@ public static class RiverCarvePass 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. + // Full head→mouth polyline: upland stem, then the lowland reach if any — + // then SMOOTHED (task 23) so the carved centreline carries no routing kinks. var pts = new List<(float x, float y)>(upland); if (lowlandRoute != null && lowlandRoute.Count > 1) pts.AddRange(lowlandRoute.GetRange(1, lowlandRoute.Count - 1)); + pts = SmoothCourse(pts); // Densify to ~1-px samples (plan courses are decimated ×4). var dense = new List<(float x, float y)>(); From 8f5767308a6afff4cec83d9e4112a12c4305a0c7 Mon Sep 17 00:00:00 2001 From: beezm Date: Tue, 11 Aug 2026 19:55:04 -0400 Subject: [PATCH 2/8] feat: stepped river water + widen + lake-ender join + the task-22 nits (terrain-water task 23, C0b finale) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Core/Scripts/ConfigManager.cs | 13 ++- Core/Scripts/MapDataParser.cs | 3 +- Tools/Scripts/MapGenerator.cs | 39 ++++++++ Tools/Scripts/RiverCarvePass.cs | 168 +++++++++++++++++++++++++++++--- 4 files changed, 210 insertions(+), 13 deletions(-) diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index f7443db..cb1a953 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -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 // bed may create inland below-sea cells, so the rendered coastline cannot // 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 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 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("RiverDepthScale")) RiverDepthScale = (float)data["RiverDepthScale"]; 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); RiverDepthScale = Mathf.Clamp(RiverDepthScale, 0.1f, 5f); RiverSeaMargin = Mathf.Clamp(RiverSeaMargin, 0f, 5f); diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index d86ed9b..b91f73a 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -47,6 +47,7 @@ namespace IslaApocalypse.Core { public const byte TYPE_OCEAN = 0; 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_SALT = 1; @@ -432,7 +433,7 @@ namespace IslaApocalypse.Core body.SurfaceLevel = reader.ReadSingle(); body.PixelCount = reader.ReadInt32(); 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}."); return false; diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index e8ff480..4390107 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -237,7 +237,16 @@ public partial class MapGenerator : TextureRect // only: biomes and WBID are already computed from classify — the oracle is // untouched by construction. NO WATER — part 2b. if (_riversOn) + { 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) { @@ -863,6 +872,36 @@ public partial class MapGenerator : TextureRect for (int y = 0; y < MapSize; 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, " + $"routing {st.RoutingSeconds:F1}s, carve {st.CarveSeconds:F1}s " + $"({(Time.GetTicksMsec() - tRiv0) / 1000.0:F1}s total). " + diff --git a/Tools/Scripts/RiverCarvePass.cs b/Tools/Scripts/RiverCarvePass.cs index 26f5530..cf4dd35 100644 --- a/Tools/Scripts/RiverCarvePass.cs +++ b/Tools/Scripts/RiverCarvePass.cs @@ -84,12 +84,26 @@ public static class RiverCarvePass public double VolumeM3; } + /// The carved geometry the water stage consumes (main rivers only). + 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 List Rivers = new(); + public List Carved = new(); // for the stepped-water stage (task 23) + internal float[] PrePass; // cumulative-cut baseline public long CarvedCells; public double CarvedVolumeM3; - public float MaxCutM; + public float MaxCutM; // CUMULATIVE vs pre-pass heights (task-22 nit 1 fixed) public double AnalysisSeconds, RoutingSeconds, CarveSeconds; } @@ -122,6 +136,14 @@ public static class RiverCarvePass // --- carve --- 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; foreach (var t in plan.Trunks) { @@ -140,10 +162,25 @@ public static class RiverCarvePass var route = giantRoutes[gi]; gi++; var course = new List<(float x, float y)>(g.Course); 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, course, route, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats); 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) 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). /// private static List<(float x, float y)> RouteToOcean(float[,] height, int n, - bool[] isOcean, int sx, int sy, byte style, Func seaAt) + bool[] targets, int sx, int sy, byte style, Func seaAt) { int total = n * n; var gcost = new float[total]; @@ -227,7 +264,7 @@ public static class RiverCarvePass int c = pq.Dequeue(); if (closed[c]) continue; closed[c] = true; - if (isOcean[c]) { goal = c; break; } + if (targets[c]) { goal = c; break; } int cx = c / n, cy = c % n; float hc = height[cx, cy]; for (int k = 0; k < 8; k++) @@ -281,12 +318,17 @@ public static class RiverCarvePass 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 — - // then SMOOTHED (task 23) so the carved centreline carries no routing kinks. + // Full head→mouth polyline: upland stem, then the lowland reach if any. + // 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); 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). var dense = new List<(float x, float y)>(); @@ -394,18 +436,122 @@ public static class RiverCarvePass if (target < old) { 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; stats.CarvedCells++; stats.CarvedVolumeM3 += cutM; - if (cutM > stats.MaxCutM) stats.MaxCutM = cutM; - if (cutM > rs.MaxCutM) rs.MaxCutM = cutM; + if (cumM > stats.MaxCutM) stats.MaxCutM = cumM; + if (cumM > rs.MaxCutM) rs.MaxCutM = cumM; 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; } + + /// + /// 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. + /// + 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 AddSteppedWater(float[,] height, int mapSize, + ushort[,] wbid, ushort firstId, List 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(); + 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; + } } From 645955d49ce63fc77c8083278f4f75edc0687bae Mon Sep 17 00:00:00 2001 From: beezm Date: Tue, 11 Aug 2026 19:55:04 -0400 Subject: [PATCH 3/8] docs: stepped river water, WBTB type 2, and the C0b finale in the generator README (terrain-water task 23) Co-Authored-By: Claude Fable 5 --- Core/Scripts/BLUEPRINT_FORMAT.md | 2 +- Tools/Scripts/README.md | 19 ++++++++++++++++++- Tools/Scripts/RiverCarvePass.cs.uid | 1 + 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 Tools/Scripts/RiverCarvePass.cs.uid diff --git a/Core/Scripts/BLUEPRINT_FORMAT.md b/Core/Scripts/BLUEPRINT_FORMAT.md index f905988..6e3d925 100644 --- a/Core/Scripts/BLUEPRINT_FORMAT.md +++ b/Core/Scripts/BLUEPRINT_FORMAT.md @@ -59,7 +59,7 @@ Every section: `[u32 tag][u64 payload-length in bytes][payload]`. | `TDTL` / `0x4C544454` | 30 B: `u16 detailVersion` · `f32 reliefAmpM` · `f32 reliefFreqIslands` · `i32 reliefSeedOffset` · `f32 edgeAmpM` · `f32 edgeFreqIslands` · `i32 edgeSeedOffset` · `f32 edgeMaxShiftM` | Optional — present iff the terrain detail passes shaped this blueprint's `HGTS` (config `TerrainDetail: "v1"`, requires the curve). **`detailVersion` selects the body layout and a reader that does not recognise it SKIPS the section** (leaving detail metadata null) rather than misreading a differently shaped payload — the one section whose body is versioned rather than extended, because v1's layout was retired rather than grown. **v1 (retired, never shipped)** — shelf micro-relief + D8 drainage incision, 38 B; the incision produced grid-aligned artifacts and was reverted whole, so the only v1 payloads that exist are in that batch's own tree. **v2 (current)** — shelf micro-relief (±`reliefAmpM` output metres, shelf-ness weighted) + shelf-edge variation: a per-column shift of the curve's shelf/riser knot block K3/K4/K5, drawn from a Simplex field seeded `resolvedWorldSeed + edgeSeedOffset` at `edgeFreqIslands/MapSize`, amplitude ±`edgeAmpM` **metres of INPUT height** — a displacement of the shelf boundary contour, not an elevation change. `edgeAmpM` is recorded **as applied**, after the clamp to `edgeMaxShiftM` (the preset's band-squeeze bound), so the record always describes the terrain rather than the request. **Metadata only** — heights are already detailed. Machinery: `Tools/Scripts/TerrainDetailPass.cs`. | | `EROS` / `0x534F5245` | 67 B: `u16 erosionVersion` · `i32 dropletCount` · `i32 lifetime` · `i32 brushRadius` · `i32 seedOffset` · `f32 carveCapM` · `f32 depositCapM` · `f32 seaMarginM` · `f32 inertia` · `f32 capacityFactor` · `f32 minSlopeM` · `f32 erodeRate` · `f32 depositRate` · `f32 evaporation` · `f32 gravity` · `f32 craterCoreFactor` · `f32 craterFeatherFactor` · `u8 craterMode` | Optional — present iff the droplet hydraulic-erosion pass shaped this blueprint's `HGTS` (config `Erosion: "v1"`, terrain-water tasks 17–19). **`erosionVersion` selects the body layout; an unrecognised version is SKIPPED whole** (erosion metadata left null), same rule as `TDTL`. **v1 (task 17)** — 58 B, no `depositCapM`; deposition was bilinear over 4 cells and unbounded, which built isolated cones (measured 15.5 m). **v2 (task 18)** — 62 B; deposition brush-spread and per-cell bounded. **v3 (task 19, current)** — replaces v2's single `craterExclFactor` with `craterCoreFactor` (the protected strike core), `craterFeatherFactor` and `craterMode` (`0` = full, `1` = feather). Only v2+ payloads exist outside tasks 17–18's own batch trees. The FOUR governors are `dropletCount`/`lifetime`/`carveCapM`/`depositCapM`; both caps are enforced against one per-cell NET displacement ledger (positive = carved below the height the pass found, negative = built up above it) and asserted on exit. `seaMarginM` is the flood-guard clamp — no cell is carved below sea + margin, and below-sea cells are untouched in BOTH directions, so the rendered coastline cannot move **and the crater's flooded bay can be neither carved open nor silted shut regardless of `craterMode`**. Crater radii are FACTORS of `PRMS.CraterRadius`: nothing inside `craterCoreFactor ×` it is modified; `feather` ramps erosion 0→full from there out to `craterFeatherFactor ×` it, `full` applies full strength immediately. Droplets are deterministic from `resolvedWorldSeed + seedOffset` (PCG32). Remaining fields are the droplet-model strength dials; slopes/amounts in metres (1 raw = 251 m). All values recorded **as applied** (post config clamping). **Metadata only** — heights are already eroded, and the classify-side sections (`BIOM`/`WBID`/…) never saw the pass by design. Machinery: `Tools/Scripts/HydraulicErosion.cs`. | | `WBID` / `0x44494257` | `MapSize²` × `u16` water-body id, same pixel order as `HGTS` | Optional (absent = no water data, e.g. a legacy re-encode). `0` = no water, `1` = **the** ocean body, `2..N` = lakes. Ids assigned in deterministic scan order (X outer / Y inner, first-encountered pixel), lakes labeled with the **same 4-connectivity as `CalculateTrueOcean`**. Membership is exactly the generator's water classification — the biome grid's Ocean/Lake pixels and this grid's nonzero pixels are the same set **by construction** (shared predicates). Length must equal `2·MapSize²`. | -| `WBTB` / `0x42544257` | `i32 count`, then per body (20 B): `u16 id` · `u8 type` (0 ocean, 1 lake) · `u8 salinity` (0 fresh, 1 salt) · `f32 surfaceLevel` · `i32 pixelCount` · `f32 centroidX` · `f32 centroidY` | Optional, paired with `WBID`. **`surfaceLevel` is a documented TRANSITIONAL rule:** one flat level per body — `GetSeaLevel` at the body's pixel centroid (ocean: at the map centre) under the still-live latitude field; superseded by the flat-scalar sea model (minted, lands with the coast change set). The field's per-pixel slope is deliberately NOT baked into any section. **Salinity is a provisional default** (ocean salt, lake fresh) — a placeholder for the future fresh/salt irrigation mechanic, not a mechanic. | +| `WBTB` / `0x42544257` | `i32 count`, then per body (20 B): `u16 id` · `u8 type` (0 ocean, 1 lake, 2 river — a stepped river REACH, one flat level per reach; task 23) · `u8 salinity` (0 fresh, 1 salt) · `f32 surfaceLevel` · `i32 pixelCount` · `f32 centroidX` · `f32 centroidY` | Optional, paired with `WBID`. **`surfaceLevel` is a documented TRANSITIONAL rule:** one flat level per body — `GetSeaLevel` at the body's pixel centroid (ocean: at the map centre) under the still-live latitude field; superseded by the flat-scalar sea model (minted, lands with the coast change set). The field's per-pixel slope is deliberately NOT baked into any section. **Salinity is a provisional default** (ocean salt, lake fresh) — a placeholder for the future fresh/salt irrigation mechanic, not a mechanic. | | `WSRF` / `0x46525357` | `MapSize²` × `u16` quantized water-surface elevation, same pixel order | Optional, paired with `WBID`. `0` is the reserved **no-water sentinel**; a real level `L` (raw height units) encodes as `1 + round(L × 32768)` so it can never encode to 0; decode `(q − 1)/32768` (`BlueprintFormat.EncodeWaterLevel`/`DecodeWaterLevel`). Covers `[0 … ~1.99997]` raw at `1/32768` raw ≈ **7.7 mm** of world height (1 raw = 251 m) — far finer than the 1 m voxel. Nonzero exactly where `WBID` is nonzero; the value is the pixel's body level. | | `RDHW` / `0x57484452` | `i32 pathCount`, then per path: `i32 pointCount` + `pointCount` × (`f32 X` · `f32 Y`) | Highway tier. **Tags, not file position, identify the tier** — the v1 order-fragility is gone. Road sections may legitimately be **present but empty** (zero paths): the `SkipRoads` config toggle exports a road-less iteration blueprint. | | `RDBR` / `0x52424452` | same layout | Branch tier. | diff --git a/Tools/Scripts/README.md b/Tools/Scripts/README.md index 53398ab..98e039a 100644 --- a/Tools/Scripts/README.md +++ b/Tools/Scripts/README.md @@ -109,7 +109,24 @@ Generates the entire 2D blueprint. Roughly in order: sea + `RiverSeaMargin` everywhere (the erosion flood-guard discipline: below-sea cells read-only, zero new below-sea cells, coastline provably fixed; asserted per generation). Slots AFTER towns (town placement reads the render map and must not shift) and BEFORE roads. - Crater core excluded. **NO WATER yet** — part 2b waters the gated routing style. + Crater core excluded. + + **River water (task 23, C0b finale)** — the payoff. Each carved main river becomes a chain of + **stepped flat water-body REACHES**: a new reach starts every `RiverStepDropM` (2 m) of bed + descent and sits `RiverWaterDepthM` (1.2 m) above its bed, each reach strictly lower than the + one upstream, stepping down to the sea (small vertical drops — riffles — smoothable purely by + the step dial; tilted continuous-slope water is the deferred model B). Reaches are ordinary + water bodies: WBID cells + a WBTB entry (`type 2 = river`, fresh) each; WSRF derives from body + levels exactly as for lakes, and river water renders through the C1/task-13 path with the + task-15 presence rule at its banks. Lake-enders route the last reach into their lake by the + lowground Dijkstra (a pooling terminal IS a local minimum — blind descent dead-ends there); + rivers meet lakes and the sea without repainting them (existing bodies are never overwritten). + Only the lowland reaches are ROUTE-SMOOTHED (RDP+Chaikin, the road pipeline): the upland stems + already thread carved valley floors, and smoothing them off-line cut valley walls (measured). + The BIOME oracle holds (classify untouched; `1_biomes` md5-identical); `0_water` and the + re-captured `0_height` change — that IS the river water and its beds (both snapshots are + re-captured after the river pass so the exported PNGs show them). Max-cut is now reported + CUMULATIVELY vs the pre-pass surface. 3. **Sea level and water** — sea level per the configured model (`SeaLevelModel`: `"flat"` scalar — the default, `SeaLevelValue` 0.15 — or the legacy `"field"` latitude Lerp); flood fill diff --git a/Tools/Scripts/RiverCarvePass.cs.uid b/Tools/Scripts/RiverCarvePass.cs.uid new file mode 100644 index 0000000..728bfbb --- /dev/null +++ b/Tools/Scripts/RiverCarvePass.cs.uid @@ -0,0 +1 @@ +uid://dufsppmyn4g8v From 0e3953860449ba4f5531df3e20467307d019c90c Mon Sep 17 00:00:00 2001 From: beezm Date: Tue, 11 Aug 2026 20:24:07 -0400 Subject: [PATCH 4/8] feat: tributary water (tapered), significant-lake targeting, finer steps + deeper beds (terrain-water task 24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three gate polish items — they interleave in the same files, so they land as one buildable commit rather than three that do not compile independently. 1. TRIBUTARY WATER. Tributaries carved in task 22/23 but stayed dry: they were never registered with the water stage. They now are (as CarvedRiver entries named 'trib', kept OUT of the per-river console table so it stays the 6 mains). RiverTribWaterMinFlow (40k px of along-course flow) sets where water starts; RiverTribTaperPx (120 px) makes the wet->dry transition a FADE, not a wall — across the taper the wet strip narrows (0.25x -> 1x half-width) AND its surface drops toward the bed, so a stream head thins out and vanishes. Along-course flow is modelled quadratically from headwater trickle to full drainage at the mouth (the plan records drainage per river, not per sample). RiverTribWaterMinFlow=0 waters them end to end — the documented fallback. 2. LAKE-ENDER TARGETING. The join routed to the nearest classify-water CELL, which a 322-px puddle satisfies; it stopped ~80 px short of the 197k-px lagoon beside it. It now routes to a SIGNIFICANT water mask (bodies of at least RiverLakeMinTargetPx = 20k cells, built from the water-body table), falling back to any classify water only if no significant body is reachable, so a seed with genuinely small ponds still connects. Proven by adjacency: task 23 had 63 river cells touching the puddle and 0 touching the lagoon; task 24 has 49 touching the lagoon and 0 touching the puddle. 3. FINER STEPS + DEEPER BEDS. RiverStepDropM 2.0 -> 0.6 and RiverWaterDepthM 1.2 -> 2.2, RiverDepthScale 1.0 -> 1.5. Reaches 298 -> 1034; the level gap between adjacent reaches drops from median 0.571 m / p95 2.08 m to median 0.124 m / p95 0.74 m — the pond-staircase reads as a graded descent. Reaches stay trivial bodies (median 44 px). Guards all held: flood guard 0 newly-below-sea and 0 below-sea cells modified, BIOME oracle md5-identical to the task-22 baseline (output-only unchanged), island top 457.65 m exact, crater core excluded, lowest carved cell exactly sea+margin (37.85 m). Cumulative max cut 25.98 m with 339 cells >20 m — identical to task 23, so deepening the WATER did not deepen the worst cuts. Co-Authored-By: Claude Opus 5 (1M context) --- Core/Scripts/ConfigManager.cs | 21 +++++++-- Tools/Scripts/MapGenerator.cs | 29 ++++++++++-- Tools/Scripts/RiverCarvePass.cs | 78 +++++++++++++++++++++++++++++---- 3 files changed, 113 insertions(+), 15 deletions(-) diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index cb1a953..65f6cb9 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -127,9 +127,18 @@ namespace IslaApocalypse.Core // Change this if your namespace is different public static string Rivers = "off"; public static string RiverRoutingStyle = "lowground"; 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; + // Task 24 (the gate's polish): finer steps read as a descending river rather + // than a pond staircase; deeper water sits contained in its banks. + // RiverTribWaterMinFlow is the drainage a TRIBUTARY reach needs to carry + // water (0 = water them end to end); above it the water TAPERS to dry over + // RiverTribTaperPx so a stream head fades instead of ending in a wall. + // RiverLakeMinTargetPx is the smallest water body a lake-ender may target. + public static float RiverStepDropM = 0.6f; + public static float RiverWaterDepthM = 2.2f; + public static int RiverTribWaterMinFlow = 40000; + public static int RiverTribTaperPx = 120; + public static int RiverLakeMinTargetPx = 20000; + public static float RiverDepthScale = 1.5f; // deepened at the task-24 gate's ask public static float RiverSeaMargin = 0.2f; // m above sea, bed floor // Island falloff shaping (task 11). @@ -339,6 +348,12 @@ namespace IslaApocalypse.Core // Change this if your namespace is different if (data.ContainsKey("RiverSeaMargin")) RiverSeaMargin = (float)data["RiverSeaMargin"]; if (data.ContainsKey("RiverStepDropM")) RiverStepDropM = (float)data["RiverStepDropM"]; if (data.ContainsKey("RiverWaterDepthM")) RiverWaterDepthM = (float)data["RiverWaterDepthM"]; + if (data.ContainsKey("RiverTribWaterMinFlow")) RiverTribWaterMinFlow = (int)data["RiverTribWaterMinFlow"]; + if (data.ContainsKey("RiverTribTaperPx")) RiverTribTaperPx = (int)data["RiverTribTaperPx"]; + if (data.ContainsKey("RiverLakeMinTargetPx")) RiverLakeMinTargetPx = (int)data["RiverLakeMinTargetPx"]; + RiverTribWaterMinFlow = Mathf.Max(RiverTribWaterMinFlow, 0); + RiverTribTaperPx = Mathf.Clamp(RiverTribTaperPx, 0, 2000); + RiverLakeMinTargetPx = Mathf.Max(RiverLakeMinTargetPx, 0); RiverStepDropM = Mathf.Clamp(RiverStepDropM, 0.25f, 10f); RiverWaterDepthM = Mathf.Clamp(RiverWaterDepthM, 0.2f, 5f); RiverWidthScale = Mathf.Clamp(RiverWidthScale, 0.1f, 5f); diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index 4390107..af9f073 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -845,6 +845,17 @@ public partial class MapGenerator : TextureRect isClassifyWater[x * MapSize + y] = IsWaterPixel(x, y); } + // Task 24: SIGNIFICANT water — cells of bodies at least RiverLakeMinTargetPx + // in size, from the water-bodies table the stage above already built. A + // lake-ender routes to this so it enters the lagoon, not a puddle. + var bigBodies = new System.Collections.Generic.HashSet(); + foreach (var b in _waterBodies) + if (b.PixelCount >= ConfigManager.RiverLakeMinTargetPx) bigBodies.Add(b.Id); + bool[] isSignificantWater = new bool[MapSize * MapSize]; + for (int x = 0; x < MapSize; x++) + for (int y = 0; y < MapSize; y++) + isSignificantWater[x * MapSize + y] = bigBodies.Contains(_waterBodyIds[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; } @@ -855,10 +866,13 @@ public partial class MapGenerator : TextureRect ? RiverCarvePass.STYLE_SHORT : RiverCarvePass.STYLE_LOWGROUND, WidthScale = ConfigManager.RiverWidthScale, DepthScale = ConfigManager.RiverDepthScale, - SeaMarginM = ConfigManager.RiverSeaMargin + SeaMarginM = ConfigManager.RiverSeaMargin, + TribWaterMinFlowPx = ConfigManager.RiverTribWaterMinFlow, + TribTaperPx = ConfigManager.RiverTribTaperPx, + LakeMinTargetPx = ConfigManager.RiverLakeMinTargetPx }; var st = RiverCarvePass.Apply(_heightMap, MapSize, isOcean, isClassifyWater, - southX, southY, seaMap, seaFlat, + isSignificantWater, southX, southY, seaMap, seaFlat, _impactCenter.X, _impactCenter.Y, _impactRadius * ConfigManager.CraterErosionCore, () => (Time.GetTicksMsec()) / 1000.0, p); @@ -899,8 +913,15 @@ public partial class MapGenerator : TextureRect }); 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."); + int mainCarved = 0, tribCarved = 0; + foreach (var cr in st.Carved) { if (cr.Kind == "tributary") tribCarved++; else mainCarved++; } + int tribReaches = 0; long tribWet = 0; + foreach (var reach in reaches) + if (reach.River == "trib") { tribReaches++; tribWet += reach.PixelCount; } + GD.Print($"{T()} [Rivers] water: {reaches.Count} stepped reaches ({tribReaches} on tributaries) " + + $"across {mainCarved} mains + {tribCarved} tributaries, {riverWetPx} wet px " + + $"({tribWet} tributary), step drop {ConfigManager.RiverStepDropM:F2} m, depth {ConfigManager.RiverWaterDepthM:F1} m, " + + $"trib flow threshold {ConfigManager.RiverTribWaterMinFlow} px taper {ConfigManager.RiverTribTaperPx} px."); GD.Print($"{T()} [Rivers] v1 '{ConfigManager.RiverRoutingStyle}': plan {st.AnalysisSeconds:F1}s, " + $"routing {st.RoutingSeconds:F1}s, carve {st.CarveSeconds:F1}s " + diff --git a/Tools/Scripts/RiverCarvePass.cs b/Tools/Scripts/RiverCarvePass.cs index cf4dd35..54c9e0d 100644 --- a/Tools/Scripts/RiverCarvePass.cs +++ b/Tools/Scripts/RiverCarvePass.cs @@ -66,6 +66,14 @@ public static class RiverCarvePass public float WidthScale = 1.0f; public float DepthScale = 1.0f; public float SeaMarginM = 0.2f; // bed floor above sea, everywhere + // Task 24: a tributary reach is WET where its along-course flow exceeds this; + // upstream of that it TAPERS to dry over TribTaperPx rather than ending in a + // wall of water. 0 = water tributaries end to end. + public int TribWaterMinFlowPx = 40_000; + public int TribTaperPx = 120; + // Lake-enders route to the nearest water body of at least this size — the + // nearest wet PIXEL was a puddle (task-23 gate finding). + public int LakeMinTargetPx = 20_000; public DrainageAnalysis.Params PlanParams = new(); } @@ -94,6 +102,12 @@ public static class RiverCarvePass public float[] Bed; // raw units, monotone non-increasing public float[] HalfW; // px public bool ReachedWaterTerminal; // lake-enders: extension reached classify water + // Task 24: along-course flow (px of drainage) per sample, and the first index + // that carries water. Between WetFrom-TaperPx and WetFrom the water tapers + // (narrowing and shallowing to the bed) so a stream head fades out. + public float[] Flow; + public int WetFrom; + public int TaperPx; } public class Stats @@ -107,8 +121,13 @@ public static class RiverCarvePass public double AnalysisSeconds, RoutingSeconds, CarveSeconds; } + /// Row-major mask of classify water belonging to + /// bodies of at least LakeMinTargetPx cells (task 24). Lake-enders route to THIS, + /// not to any wet pixel: the task-23 build routed one into a puddle a few hundred + /// px short of the obvious lagoon, because "nearest classify water" is satisfied + /// by a 3-cell pond. public static Stats Apply(float[,] height, int mapSize, bool[] isOcean, - bool[] isClassifyWater, float southX, float southY, + bool[] isClassifyWater, bool[] isSignificantWater, float southX, float southY, float[,] seaMap, float seaFlat, float craterCx, float craterCy, float craterCoreRadius, Func secondsNow, Params p) @@ -171,8 +190,14 @@ public static class RiverCarvePass bool reachedLake = false; if (g.Kind == "lake-ender") { - var ext = RouteToOcean(height, n, isClassifyWater, + // Target SIGNIFICANT water (task 24). Fall back to any classify water + // only if no significant body is reachable, so a seed whose lake-ender + // genuinely has only small ponds still connects rather than dead-ending. + var ext = RouteToOcean(height, n, isSignificantWater, (int)g.Terminal.x, (int)g.Terminal.y, STYLE_LOWGROUND, SeaAt); + if (ext.Count == 0) + ext = RouteToOcean(height, n, isClassifyWater, + (int)g.Terminal.x, (int)g.Terminal.y, STYLE_LOWGROUND, SeaAt); if (ext.Count > 0) { route = ext; reachedLake = true; } } @@ -304,7 +329,10 @@ public static class RiverCarvePass { var course = new List<(float x, float y)>(trib.Course); course.Reverse(); // head → confluence - CarveRiver(null, "tributary", trib.DrainageAreaPx, course, null, + // Task 24: tributaries are registered as carved rivers (name "trib") so the + // water stage sees them — they carved but stayed dry in task 23. They are NOT + // added to stats.Rivers, so the per-river console table stays the 6 mains. + CarveRiver("trib", "tributary", trib.DrainageAreaPx, course, null, height, n, seaAt, coreSq, craterCx, craterCy, p, stats); } @@ -452,11 +480,29 @@ public static class RiverCarvePass if (name != null) { - stats.Rivers.Add(rs); + if (kind != "tributary") stats.Rivers.Add(rs); + // Along-course flow: drainage grows from a headwater trickle to the full + // figure at the mouth. Quadratic in t so the substantial lower half + // dominates — a first-order stand-in for real accumulation, which the + // plan only records per-river. + var flow = new float[m]; + for (int i = 0; i < m; i++) + { + float t = m > 1 ? (float)i / (m - 1) : 1f; + flow[i] = drainagePx * (0.05f + 0.95f * t * t); + } + int wetFrom = 0; + if (kind == "tributary" && p.TribWaterMinFlowPx > 0) + { + wetFrom = m; // dry unless the threshold is met + for (int i = 0; i < m; i++) + if (flow[i] >= p.TribWaterMinFlowPx) { wetFrom = i; break; } + } stats.Carved.Add(new CarvedRiver { Name = name, Kind = kind, DrainagePx = drainagePx, - Dense = dense, Bed = bed, HalfW = halfW + Dense = dense, Bed = bed, HalfW = halfW, + Flow = flow, WetFrom = wetFrom, TaperPx = p.TribTaperPx }); } return rs; @@ -501,7 +547,11 @@ public static class RiverCarvePass { int m = r.Dense.Count; if (m < 2) continue; - int i = 0; + // Task 24: start at the wet-from index (tributary threshold; 0 for mains), + // but back up by the taper length so the transition is a FADE, not a wall. + int i = Math.Max(0, r.WetFrom - r.TaperPx); + if (i >= m) continue; // entirely below threshold: dry + int taperStart = i, taperEnd = Math.Min(m - 1, r.WetFrom); float lastLevel = float.MaxValue; while (i < m) { @@ -518,7 +568,14 @@ public static class RiverCarvePass var reach = new Reach { Id = nextId, River = r.Name, Level = level }; for (int k2 = i; k2 < j; k2++) { - float hw = r.HalfW[k2]; + // Taper: 0 at the dry end of the fade zone → 1 at full water. The + // water narrows AND its surface drops to the bed, so a stream head + // thins out and disappears instead of ending in a flat wall. + float taper = 1f; + if (taperEnd > taperStart && k2 < taperEnd) + taper = (float)(k2 - taperStart) / (taperEnd - taperStart); + if (taper <= 0.02f) continue; + float hw = r.HalfW[k2] * (0.25f + 0.75f * taper); 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++) @@ -535,7 +592,12 @@ public static class RiverCarvePass 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 + // The tapered surface sits between the bed and the reach + // level, so the wet strip shallows as it narrows. + float localLevel = taper >= 1f + ? level + : r.Bed[k2] + (level - r.Bed[k2]) * taper; + if (h >= localLevel) continue; // bank above the water line wbid[x, y] = nextId; reach.PixelCount++; reach.Cx += x; reach.Cy += y; From 3cbe4c79678f75b1ca2556bb7ef1020f6d0afc5a Mon Sep 17 00:00:00 2001 From: beezm Date: Tue, 11 Aug 2026 20:25:40 -0400 Subject: [PATCH 5/8] docs: task-24 river polish dials in the generator README (terrain-water task 24) Co-Authored-By: Claude Opus 5 (1M context) --- Tools/Scripts/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Tools/Scripts/README.md b/Tools/Scripts/README.md index 98e039a..07c8662 100644 --- a/Tools/Scripts/README.md +++ b/Tools/Scripts/README.md @@ -128,6 +128,15 @@ Generates the entire 2D blueprint. Roughly in order: re-captured after the river pass so the exported PNGs show them). Max-cut is now reported CUMULATIVELY vs the pre-pass surface. + **Polish (task 24)** — tributaries carry water too, gated by along-course flow + (`RiverTribWaterMinFlow`, 0 = water them end to end) and **tapered** at the wet→dry + transition over `RiverTribTaperPx`: the strip narrows and its surface drops to the bed, so a + stream head fades instead of ending in a wall of water. Lake-enders route to the nearest + **significant** water body (`RiverLakeMinTargetPx`, 20k cells) — "nearest wet cell" was + satisfied by a 322-px puddle beside a 197k-px lagoon. Steps are finer (`RiverStepDropM` 0.6 m) + and beds deeper (`RiverDepthScale` 1.5, `RiverWaterDepthM` 2.2 m) so the chain reads as a graded + descent rather than a pond staircase: 1034 reaches, adjacent-reach level gaps median 0.12 m. + 3. **Sea level and water** — sea level per the configured model (`SeaLevelModel`: `"flat"` scalar — the default, `SeaLevelValue` 0.15 — or the legacy `"field"` latitude Lerp); flood fill separates true ocean from inland lakes; a mainland fill guarantees one contiguous landmass. From 3fd14c038961cedea3a9fd56948b9f886cd167f8 Mon Sep 17 00:00:00 2001 From: beezm Date: Tue, 11 Aug 2026 21:11:14 -0400 Subject: [PATCH 6/8] feat: ISLA_BLUEPRINT_PATH + ISLA_EXPORT_DIR so tooling never touches the runtime root (terrain-water task 25, FILE SAFETY) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The task-25 file-safety rules make this structural rather than procedural. Two env overrides, both loud on use: ISLA_BLUEPRINT_PATH — MapDataParser.LoadMapData loads an explicit blueprint FILE instead of the seed-named one in the runtime root, so capture tooling reads straight from a batch folder. ISLA_EXPORT_DIR — MapGenerator writes EVERYTHING a run produces (both blueprints and every stage snapshot) into a task folder instead of the root. Why this is needed and not merely tidy: generation ALWAYS wrote user://MapData_Seed_.dat, and my task-22/23/24 capture scripts copied a blueprint over that path and then rm'd it — which destroyed the blueprint the developer had staged there to view a world in 3D. With these overrides a run touches nothing in the root, so there is nothing to clean up afterwards and no delete anywhere in the drivers. Verified this task: the developer's staged MapData_Seed_1280587109.dat kept md5 dc2bc61b3eb7 across four generations and a 3D capture. Co-Authored-By: Claude Opus 5 (1M context) --- Core/Scripts/MapDataParser.cs | 18 ++++++++++++++++ Tools/Scripts/MapGenerator.cs | 39 +++++++++++++++++++++++++++++------ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index b91f73a..8f05503 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -163,6 +163,24 @@ namespace IslaApocalypse.Core { public static WorldBlueprint LoadMapData(string seedStr) { + // ISLA_BLUEPRINT_PATH (task 25, FILE SAFETY): load an explicit blueprint + // FILE instead of the seed-named one in the runtime root. Capture and A/B + // tooling points this straight at a batch folder, so it never has to copy + // a blueprint over — or delete — the file the developer has staged at the + // root to view a world in 3D. Loud, so a forgotten env var cannot be + // mistaken for the root blueprint. + string envPath = OS.GetEnvironment("ISLA_BLUEPRINT_PATH"); + if (!string.IsNullOrEmpty(envPath)) + { + if (File.Exists(envPath)) + { + GD.Print($"[MapDataParser] ⚠ ISLA_BLUEPRINT_PATH override: loading '{envPath}' " + + "(NOT the runtime-root blueprint)."); + return LoadMapDataFromPath(envPath); + } + GD.PrintErr($"[MapDataParser] ISLA_BLUEPRINT_PATH set but '{envPath}' does not exist — " + + "falling back to the runtime-root blueprint."); + } string filePath = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat"); return LoadMapDataFromPath(filePath); } diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index af9f073..eaa8b7c 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -335,12 +335,12 @@ public partial class MapGenerator : TextureRect // 6. Pull the image from the invisible monitor and save it! Image capture = offscreenVP.GetTexture().GetImage(); string seedStr = _noise.Seed.ToString(); - string fileName = $"user://Map_Seed_{seedStr}_{label}.png"; + string fileName = System.IO.Path.Combine(OutputDir(), $"Map_Seed_{seedStr}_{label}.png"); Error saveResult = capture.SavePng(fileName); if (saveResult == Error.Ok) - GD.Print($"{T()} Map saved: {ProjectSettings.GlobalizePath(fileName)}"); + GD.Print($"{T()} Map saved: {fileName}"); else GD.PrintErr($"{T()} Failed to save map. Godot Error code: {saveResult}"); @@ -348,18 +348,40 @@ public partial class MapGenerator : TextureRect offscreenVP.QueueFree(); } + /// + /// Where generated artifacts go. Normally the runtime root (`user://`), but + /// ISLA_EXPORT_DIR redirects EVERYTHING this run writes — blueprints and stage + /// snapshots — into a task folder instead (task 25, FILE SAFETY). Batch and A/B + /// tooling sets it so a generation can never overwrite the blueprint the + /// developer has staged at the root to view a world in 3D, and so no cleanup + /// move/delete is needed afterwards. Loud on use. + /// + private string OutputDir() + { + string dir = OS.GetEnvironment("ISLA_EXPORT_DIR"); + if (!string.IsNullOrEmpty(dir)) + { + if (System.IO.Directory.Exists(dir)) return dir; + GD.PrintErr($"[MapGenerator] ISLA_EXPORT_DIR '{dir}' does not exist — writing to the runtime root instead."); + } + return ProjectSettings.GlobalizePath("user://"); + } + private void ExportMapData() { string seedStr = _noise.Seed.ToString(); + string outDir = OutputDir(); + if (outDir != ProjectSettings.GlobalizePath("user://")) + GD.Print($"{T()} ⚠ ISLA_EXPORT_DIR override: writing artifacts to '{outDir}' (NOT the runtime root)."); // PRIMARY: the v2 tagged-section container (Core/Scripts/BLUEPRINT_FORMAT.md), // under the name the server looks for. - string v2Path = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat"); + string v2Path = System.IO.Path.Combine(outDir, $"MapData_Seed_{seedStr}.dat"); BlueprintWriter.WriteV2(v2Path, BuildBlueprint()); // SAFETY NET: the legacy v1 format beside it, until the developer has lived // with v2 across several regenerations. Removal is a future task. - ExportMapDataV1(ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}_v1.dat")); + ExportMapDataV1(System.IO.Path.Combine(outDir, $"MapData_Seed_{seedStr}_v1.dat")); } /// @@ -869,6 +891,9 @@ public partial class MapGenerator : TextureRect SeaMarginM = ConfigManager.RiverSeaMargin, TribWaterMinFlowPx = ConfigManager.RiverTribWaterMinFlow, TribTaperPx = ConfigManager.RiverTribTaperPx, + FillFraction = ConfigManager.RiverFillFraction, + BankFlare = ConfigManager.RiverBankFlare, + BankMaxCutM = ConfigManager.RiverBankMaxCutM, LakeMinTargetPx = ConfigManager.RiverLakeMinTargetPx }; var st = RiverCarvePass.Apply(_heightMap, MapSize, isOcean, isClassifyWater, @@ -898,7 +923,8 @@ public partial class MapGenerator : TextureRect nextBodyId, st.Carved, seaMap, seaFlat, _impactCenter.X, _impactCenter.Y, _impactRadius * ConfigManager.CraterErosionCore, - ConfigManager.RiverStepDropM, ConfigManager.RiverWaterDepthM); + ConfigManager.RiverStepDropM, ConfigManager.RiverWaterDepthM, + ConfigManager.RiverFillFraction); long riverWetPx = 0; foreach (var reach in reaches) { @@ -921,7 +947,8 @@ public partial class MapGenerator : TextureRect GD.Print($"{T()} [Rivers] water: {reaches.Count} stepped reaches ({tribReaches} on tributaries) " + $"across {mainCarved} mains + {tribCarved} tributaries, {riverWetPx} wet px " + $"({tribWet} tributary), step drop {ConfigManager.RiverStepDropM:F2} m, depth {ConfigManager.RiverWaterDepthM:F1} m, " + - $"trib flow threshold {ConfigManager.RiverTribWaterMinFlow} px taper {ConfigManager.RiverTribTaperPx} px."); + $"trib flow threshold {ConfigManager.RiverTribWaterMinFlow} px taper {ConfigManager.RiverTribTaperPx} px, " + + $"fill {ConfigManager.RiverFillFraction:P0} of bed depth (min {ConfigManager.RiverWaterDepthM:F1} m), bank flare {ConfigManager.RiverBankFlare:F1}x (shoulder cut cap {ConfigManager.RiverBankMaxCutM:F1} m)."); GD.Print($"{T()} [Rivers] v1 '{ConfigManager.RiverRoutingStyle}': plan {st.AnalysisSeconds:F1}s, " + $"routing {st.RoutingSeconds:F1}s, carve {st.CarveSeconds:F1}s " + From c1f62f68992f73baba4d7866df1e67d8ec9d8019 Mon Sep 17 00:00:00 2001 From: beezm Date: Tue, 11 Aug 2026 21:11:41 -0400 Subject: [PATCH 7/8] feat: tributaries wet full length; water fills the channel; banks flare as shores (terrain-water task 25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate's two river notes. FILL THE TRIBUTARIES. RiverTribWaterMinFlow defaults to 0, so every promoted tributary carries water its whole length (800 of 1574 reaches are now on tributaries, 65,364 of 268,863 wet px). The graceful tip survives: the fade zone is now always TaperPx long starting wherever water begins, so with a threshold it spans (WetFrom-Taper -> WetFrom) and with full watering it spans the first TaperPx from the HEAD. Fill is about LENGTH, taper is about the END. FILL THE CHANNEL, NOT THE FLOOR. The water surface is now RiverFillFraction (80%) of the LOCAL bed depth rather than a fixed height, floored by RiverWaterDepthM as an absolute minimum. A fixed height is wrong at both ends — a trickle in a 13 m mouth and over the rim at a 1.5 m head — where a fraction is right at every scale and cannot spill onto the plain. BANKS AS SHORES. The shoulder flares RiverBankFlare (3.2, was a hard 2x) half-widths with a smootherstep instead of smoothstep, so the rim leaves nearly tangent to the water plane. That alone cut too much where a course crosses a ridge (cells deeper than 20 m went 339 -> 1917, max 26 -> 37 m), so the SHOULDER may not lower a cell more than RiverBankMaxCutM (3 m) — measured against the PRE-PASS surface, because a per-write cap let overlapping stamps each take another 3 m and changed the carved volume by 1 m3 out of 1.29 M. Gentle ground still flares into a shore; a ridge crossing keeps steep walls, which is what a gorge looks like. Net result is better than BOTH predecessors: max cut 16.8 m (task 24: 26.0), zero cells past 20 m (task 24: 339), carve p95 5.26 m. Guards unchanged: BIOME oracle md5-identical to the task-22 baseline, 0 newly-below-sea, 0 below-sea cells modified, lowest carved cell exactly sea+margin, island top 457.65 m exact, crater core excluded. Co-Authored-By: Claude Opus 5 (1M context) --- Core/Scripts/ConfigManager.cs | 20 ++++++++++-- Tools/Scripts/RiverCarvePass.cs | 56 ++++++++++++++++++++++++++++----- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index 65f6cb9..f23a402 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -133,9 +133,19 @@ namespace IslaApocalypse.Core // Change this if your namespace is different // water (0 = water them end to end); above it the water TAPERS to dry over // RiverTribTaperPx so a stream head fades instead of ending in a wall. // RiverLakeMinTargetPx is the smallest water body a lake-ender may target. + // Task 25: RiverFillFraction fills the channel to a fraction of its LOCAL bed + // depth (a fixed height was a trickle at deep mouths and overtopped shallow + // heads); RiverWaterDepthM stays as the absolute minimum. RiverBankFlare + // widens the bank shoulder (half-widths beyond the channel) and it now uses a + // smootherstep, so water meets land as a shore instead of a wall. + // RiverTribWaterMinFlow defaults to 0 — tributaries carry water their full + // promoted length (the gate's preference), still fading at the tip. public static float RiverStepDropM = 0.6f; - public static float RiverWaterDepthM = 2.2f; - public static int RiverTribWaterMinFlow = 40000; + public static float RiverWaterDepthM = 1.0f; + public static float RiverFillFraction = 0.80f; + public static float RiverBankFlare = 3.2f; + public static float RiverBankMaxCutM = 3.0f; // shoulder-only cut cap (task 25) + public static int RiverTribWaterMinFlow = 0; public static int RiverTribTaperPx = 120; public static int RiverLakeMinTargetPx = 20000; public static float RiverDepthScale = 1.5f; // deepened at the task-24 gate's ask @@ -351,6 +361,12 @@ namespace IslaApocalypse.Core // Change this if your namespace is different if (data.ContainsKey("RiverTribWaterMinFlow")) RiverTribWaterMinFlow = (int)data["RiverTribWaterMinFlow"]; if (data.ContainsKey("RiverTribTaperPx")) RiverTribTaperPx = (int)data["RiverTribTaperPx"]; if (data.ContainsKey("RiverLakeMinTargetPx")) RiverLakeMinTargetPx = (int)data["RiverLakeMinTargetPx"]; + if (data.ContainsKey("RiverFillFraction")) RiverFillFraction = (float)data["RiverFillFraction"]; + if (data.ContainsKey("RiverBankFlare")) RiverBankFlare = (float)data["RiverBankFlare"]; + RiverFillFraction = Mathf.Clamp(RiverFillFraction, 0.1f, 1f); + if (data.ContainsKey("RiverBankMaxCutM")) RiverBankMaxCutM = (float)data["RiverBankMaxCutM"]; + RiverBankFlare = Mathf.Clamp(RiverBankFlare, 1.2f, 8f); + RiverBankMaxCutM = Mathf.Clamp(RiverBankMaxCutM, 0f, 30f); RiverTribWaterMinFlow = Mathf.Max(RiverTribWaterMinFlow, 0); RiverTribTaperPx = Mathf.Clamp(RiverTribTaperPx, 0, 2000); RiverLakeMinTargetPx = Mathf.Max(RiverLakeMinTargetPx, 0); diff --git a/Tools/Scripts/RiverCarvePass.cs b/Tools/Scripts/RiverCarvePass.cs index 54c9e0d..60f5f10 100644 --- a/Tools/Scripts/RiverCarvePass.cs +++ b/Tools/Scripts/RiverCarvePass.cs @@ -69,8 +69,24 @@ public static class RiverCarvePass // Task 24: a tributary reach is WET where its along-course flow exceeds this; // upstream of that it TAPERS to dry over TribTaperPx rather than ending in a // wall of water. 0 = water tributaries end to end. - public int TribWaterMinFlowPx = 40_000; + public int TribWaterMinFlowPx = 0; // task 25: 0 = tributaries wet full length public int TribTaperPx = 120; + // Task 25 (the gate's "trickle at the bottom of a ditch" note): the reach's + // water surface is a FRACTION of the local bed depth rather than a fixed + // height — the channel reads FILLED at every scale and cannot overfill onto + // the plain, which a fixed depth does at the shallow heads. WaterDepthM + // survives as the absolute minimum so tiny channels still hold water. + public float FillFraction = 0.80f; + // Bank shoulders flare this many half-widths beyond the channel (was a hard + // 2×) with a gentler-than-smoothstep curve, so water meets land as a shore. + public float BankFlare = 3.2f; + // ...but the SHOULDER may never lower a cell by more than this. A gentle flare + // across a ridge would otherwise cut a big notch: widening the flare alone took + // cells deeper than 20 m from 339 to 1917 (measured). With the cap, gentle + // ground still flares into a shore — which is where the "water in a groove" + // complaint lives — while a ridge crossing keeps steep walls, which is what a + // gorge actually looks like. The channel bed itself is not affected by this. + public float BankMaxCutM = 3.0f; // Lake-enders route to the nearest water body of at least this size — the // nearest wet PIXEL was a puddle (task-23 gate finding). public int LakeMinTargetPx = 20_000; @@ -101,6 +117,7 @@ public static class RiverCarvePass 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 float[] DepthM; // local bed depth, metres (task 25 fill) public bool ReachedWaterTerminal; // lake-enders: extension reached classify water // Task 24: along-course flow (px of drainage) per sample, and the first index // that carries water. Between WetFrom-TaperPx and WetFrom the water tapers @@ -429,7 +446,7 @@ public static class RiverCarvePass for (int i = 0; i < m; i++) { float hw = halfW[i]; - float outer = hw * 2f; + float outer = hw * p.BankFlare; 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; @@ -455,9 +472,20 @@ public static class RiverCarvePass } else { - float f = (r - hw) / hw; // 0..1 across the shoulder - f = f * f * (3f - 2f * f); // smoothstep + // Shoulder: 0 at the rim → 1 at natural ground, over a flare of + // (BankFlare-1) half-widths. Smootherstep (6t⁵−15t⁴+10t³) leaves + // the rim nearly tangent to the water plane, so the bank reads as + // a shore rather than the wall a plain smoothstep left. + float f = (r - hw) / MathF.Max(1e-3f, hw * (p.BankFlare - 1f)); + if (f > 1f) f = 1f; + f = f * f * f * (f * (6f * f - 15f) + 10f); target = rimH + (old - rimH) * f; + // Clamp against the PRE-PASS surface, not the current height: + // overlapping stamps re-visit a cell, so a per-write cap lets each + // pass take another BankMaxCut (measured: capping against `old` + // changed the carved volume by 1 m³ out of 1.29 M — i.e. nothing). + float shoulderFloor = stats.PrePass[x * n + y] - p.BankMaxCutM / M_PER_UNIT; + if (target < shoulderFloor) target = shoulderFloor; } float floor = sea + p.SeaMarginM / M_PER_UNIT; if (target < floor) target = floor; @@ -501,7 +529,7 @@ public static class RiverCarvePass stats.Carved.Add(new CarvedRiver { Name = name, Kind = kind, DrainagePx = drainagePx, - Dense = dense, Bed = bed, HalfW = halfW, + Dense = dense, Bed = bed, HalfW = halfW, DepthM = depth, Flow = flow, WetFrom = wetFrom, TaperPx = p.TribTaperPx }); } @@ -535,7 +563,7 @@ public static class RiverCarvePass public static List AddSteppedWater(float[,] height, int mapSize, ushort[,] wbid, ushort firstId, List rivers, float[,] seaMap, float seaFlat, float craterCx, float craterCy, - float craterCoreRadius, float stepDropM, float waterDepthM) + float craterCoreRadius, float stepDropM, float waterDepthM, float fillFraction) { int n = mapSize; float SeaAt(int x, int y) => seaMap != null ? seaMap[x, y] : seaFlat; @@ -551,7 +579,12 @@ public static class RiverCarvePass // but back up by the taper length so the transition is a FADE, not a wall. int i = Math.Max(0, r.WetFrom - r.TaperPx); if (i >= m) continue; // entirely below threshold: dry - int taperStart = i, taperEnd = Math.Min(m - 1, r.WetFrom); + // The fade zone is always TaperPx long starting at i. With a threshold it + // spans (WetFrom-Taper → WetFrom); with tributaries fully watered + // (WetFrom = 0, task 25) it spans the first TaperPx from the HEAD, so a + // full-length tributary still fades in at its tip instead of starting as a + // wall of water. + int taperStart = i, taperEnd = Math.Min(m - 1, i + r.TaperPx); float lastLevel = float.MaxValue; while (i < m) { @@ -559,7 +592,14 @@ public static class RiverCarvePass // 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; + // Fill the channel: the surface sits at FillFraction of the LOCAL bed + // depth, never below the absolute minimum. A fixed height made deep + // mouths a trickle and overtopped shallow heads; a fraction is right at + // both ends and cannot spill onto the plain. + float localDepthM = r.DepthM != null ? r.DepthM[i] : waterDepthM; + float fillM = MathF.Max(waterDepthM, localDepthM * fillFraction); + if (fillM > localDepthM) fillM = localDepthM; // never above the rim + float level = startBed + fillM / M_PER_UNIT; if (level >= lastLevel) // enforce strict descent level = lastLevel - 0.01f / M_PER_UNIT; int j = i; From dc35389ecc3fffd4d31ace2e05874880cbfb6239 Mon Sep 17 00:00:00 2001 From: beezm Date: Tue, 11 Aug 2026 21:11:41 -0400 Subject: [PATCH 8/8] docs: task-25 river polish dials and the FILE SAFETY overrides (terrain-water task 25) Co-Authored-By: Claude Opus 5 (1M context) --- Tools/Scripts/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Tools/Scripts/README.md b/Tools/Scripts/README.md index 07c8662..2ae0acd 100644 --- a/Tools/Scripts/README.md +++ b/Tools/Scripts/README.md @@ -137,6 +137,21 @@ Generates the entire 2D blueprint. Roughly in order: and beds deeper (`RiverDepthScale` 1.5, `RiverWaterDepthM` 2.2 m) so the chain reads as a graded descent rather than a pond staircase: 1034 reaches, adjacent-reach level gaps median 0.12 m. + **Polish r2 (task 25)** — tributaries carry water their **full** promoted length + (`RiverTribWaterMinFlow` 0) while still fading at the tip (the taper zone is always + `RiverTribTaperPx` long, starting wherever water begins). The water surface is + `RiverFillFraction` (0.80) of the **local** bed depth rather than a fixed height, floored by + `RiverWaterDepthM` — a fixed height was a trickle in a 13 m mouth and over the rim at a 1.5 m + head. Bank shoulders flare `RiverBankFlare` (3.2) half-widths with a smootherstep so water meets + land as a shore, and the **shoulder** may not lower a cell more than `RiverBankMaxCutM` (3 m) + **measured against the pre-pass surface** — gentle ground flares, a ridge crossing keeps steep + walls, and max cut fell to 16.8 m with no cell past 20 m. + + **FILE SAFETY** — `ISLA_BLUEPRINT_PATH` (load a specific blueprint file) and `ISLA_EXPORT_DIR` + (write all of a run's blueprints and snapshots into a task folder) exist so tooling never writes + to, or deletes from, the runtime root — the developer stages `MapData_Seed_*.dat` there to view + worlds in 3D. Batch drivers set both and contain no delete of any kind. + 3. **Sea level and water** — sea level per the configured model (`SeaLevelModel`: `"flat"` scalar — the default, `SeaLevelValue` 0.15 — or the legacy `"field"` latitude Lerp); flood fill separates true ocean from inland lakes; a mainland fill guarantees one contiguous landmass.