Compare commits

..

8 commits

Author SHA1 Message Date
dc35389ecc docs: task-25 river polish dials and the FILE SAFETY overrides (terrain-water task 25)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:11:41 -04:00
c1f62f6899 feat: tributaries wet full length; water fills the channel; banks flare as shores (terrain-water task 25)
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) <noreply@anthropic.com>
2026-08-11 21:11:41 -04:00
3fd14c0389 feat: ISLA_BLUEPRINT_PATH + ISLA_EXPORT_DIR so tooling never touches the runtime root (terrain-water task 25, FILE SAFETY)
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_<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) <noreply@anthropic.com>
2026-08-11 21:11:14 -04:00
3cbe4c7967 docs: task-24 river polish dials in the generator README (terrain-water task 24)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 20:25:40 -04:00
0e39538604 feat: tributary water (tapered), significant-lake targeting, finer steps + deeper beds (terrain-water task 24)
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) <noreply@anthropic.com>
2026-08-11 20:24:38 -04:00
645955d49c 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 <noreply@anthropic.com>
2026-08-11 19:57:25 -04:00
8f5767308a feat: stepped river water + widen + lake-ender join + the task-22 nits (terrain-water task 23, C0b finale)
THE PAYOFF: rivers now carry water. Each carved main river becomes a chain of
stepped flat water-body reaches — a new reach every RiverStepDropM (2 m) of bed
descent, sitting RiverWaterDepthM (1.2 m) above its bed, strictly descending to
the outlet. Reaches are ordinary water bodies (WBID cells + WBTB type 2 'river',
fresh; WSRF derives from body levels as ever), so river water renders through
the C1 path with the task-15 presence rule at its banks, untouched. Existing
bodies are never overwritten — a river MEETS its lake or the sea. Measured on
1280587109: 298 reaches across 6 rivers, 184,620 wet px, levels stepping 278 m
down to 39.5 m; in-engine, one valley frame renders surfaces 46.2-114.5 m.

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 19:55:04 -04:00
148602b4c5 feat: lock lowground routing; smooth river courses with the road pass's RDP+Chaikin (terrain-water task 23)
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 <noreply@anthropic.com>
2026-08-11 19:37:29 -04:00
7 changed files with 515 additions and 25 deletions

View file

@ -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 1719). **`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 1718'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. |

View file

@ -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);

View file

@ -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;

View file

@ -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();
}
/// <summary>
/// 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.
/// </summary>
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"));
}
/// <summary>
@ -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<ushort>();
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). " +

View file

@ -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

View file

@ -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;
}
/// <summary>The carved geometry the water stage consumes (main rivers only).</summary>
public class CarvedRiver
{
public string Name, Kind;
public bool Southern;
public long DrainagePx;
public List<(float x, float y)> Dense; // head → mouth, ~1-px samples
public float[] Bed; // raw units, monotone non-increasing
public float[] HalfW; // px
public 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<RiverStat> Rivers = new();
public List<CarvedRiver> Carved = new(); // for the stepped-water stage (task 23)
internal float[] PrePass; // cumulative-cut baseline
public long CarvedCells;
public double CarvedVolumeM3;
public float MaxCutM;
public float MaxCutM; // CUMULATIVE vs pre-pass heights (task-22 nit 1 fixed)
public double AnalysisSeconds, RoutingSeconds, CarveSeconds;
}
/// <param name="isSignificantWater">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.</param>
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<double> 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;
}
/// <summary>
/// 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).
/// </summary>
private static List<(float x, float y)> RouteToOcean(float[,] height, int n,
bool[] isOcean, int sx, int sy, byte style, Func<int, int, float> seaAt)
bool[] targets, int sx, int sy, byte style, Func<int, int, float> seaAt)
{
int total = n * n;
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;
}
/// <summary>
/// The stepped-water builder (task 23, part 2b): segments each carved main
/// river into REACHES — flat water bodies stepping down the bed toward the
/// outlet — and stamps their ids into the WBID grid. Reuses the existing
/// levels-not-cells water model exactly: one body per reach, one flat level
/// each; the writer derives WSRF from body levels as it always has. The step
/// drops are the smoothing dial (smaller drop = more, finer steps); tilted
/// water is the deferred model B and is NOT built here.
///
/// Emission is plain data (no engine types): the caller turns reaches into
/// WBTB entries. Wet cells: inside the channel half-width, currently dry in
/// WBID, at/above sea (below-sea cells belong to the ocean/crater-seam rule),
/// bed below the reach level. Existing water bodies are never overwritten —
/// a river MEETS a lake or the sea, it does not repaint them.
/// </summary>
public class Reach
{
public ushort Id;
public string River;
public float Level; // raw units
public int PixelCount;
public double Cx, Cy; // centroid accumulators → mean
}
public static List<Reach> AddSteppedWater(float[,] height, int mapSize,
ushort[,] wbid, ushort firstId, List<CarvedRiver> rivers,
float[,] seaMap, float seaFlat, float craterCx, float craterCy,
float craterCoreRadius, float stepDropM, float waterDepthM, 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<Reach>();
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;
}
}

View file

@ -0,0 +1 @@
uid://dufsppmyn4g8v