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/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs
index 2ae8816..f23a402 100644
--- a/Core/Scripts/ConfigManager.cs
+++ b/Core/Scripts/ConfigManager.cs
@@ -113,14 +113,42 @@ 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.
+ // 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 RiverDepthScale = 1.0f;
+ public static float RiverWidthScale = 1.75f; // widened at the task-23 gate's ask
+ // 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.
+ // 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 = 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
public static float RiverSeaMargin = 0.2f; // m above sea, bed floor
// Island falloff shaping (task 11).
@@ -328,6 +356,22 @@ 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"];
+ 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);
+ 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..8f05503 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;
@@ -162,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);
}
@@ -432,7 +451,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..eaa8b7c 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)
{
@@ -326,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}");
@@ -339,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"));
}
///
@@ -836,6 +867,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; }
@@ -846,10 +888,16 @@ 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,
+ FillFraction = ConfigManager.RiverFillFraction,
+ BankFlare = ConfigManager.RiverBankFlare,
+ BankMaxCutM = ConfigManager.RiverBankMaxCutM,
+ 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);
@@ -863,6 +911,45 @@ 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,
+ ConfigManager.RiverFillFraction);
+ 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;
+ }
+ 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, " +
+ $"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 " +
$"({(Time.GetTicksMsec() - tRiv0) / 1000.0:F1}s total). " +
diff --git a/Tools/Scripts/README.md b/Tools/Scripts/README.md
index 53398ab..2ae0acd 100644
--- a/Tools/Scripts/README.md
+++ b/Tools/Scripts/README.md
@@ -109,7 +109,48 @@ 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.
+
+ **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.
+
+ **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
diff --git a/Tools/Scripts/RiverCarvePass.cs b/Tools/Scripts/RiverCarvePass.cs
index a5d803b..60f5f10 100644
--- a/Tools/Scripts/RiverCarvePass.cs
+++ b/Tools/Scripts/RiverCarvePass.cs
@@ -66,6 +66,30 @@ 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 = 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;
public DrainageAnalysis.Params PlanParams = new();
}
@@ -84,17 +108,43 @@ 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 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
+ // (narrowing and shallowing to the bed) so a stream head fades out.
+ public float[] Flow;
+ public int WetFrom;
+ public int TaperPx;
+ }
+
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;
}
+ /// 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)
@@ -122,6 +172,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 +198,31 @@ 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")
+ {
+ // 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; }
+ }
+
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);
}
@@ -151,13 +230,61 @@ 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),
/// 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];
@@ -179,7 +306,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++)
@@ -219,7 +346,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);
}
@@ -234,9 +364,16 @@ public static class RiverCarvePass
float coreSq, float craterCx, float craterCy, Params p, Stats stats)
{
// 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));
+ {
+ 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)>();
@@ -309,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;
@@ -335,27 +472,188 @@ 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;
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)
+ {
+ 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, DepthM = depth,
+ Flow = flow, WetFrom = wetFrom, TaperPx = p.TribTaperPx
+ });
+ }
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, float fillFraction)
+ {
+ 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;
+ // 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
+ // 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)
+ {
+ // 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];
+ // 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;
+ 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++)
+ {
+ // 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++)
+ {
+ 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
+ // 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;
+ }
+ }
+ }
+ if (reach.PixelCount > 0)
+ {
+ reach.Cx /= reach.PixelCount; reach.Cy /= reach.PixelCount;
+ reaches.Add(reach);
+ nextId++;
+ lastLevel = level;
+ }
+ i = j;
+ }
+ }
+ return reaches;
+ }
}
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