Compare commits

..

No commits in common. "dc35389ecc3fffd4d31ace2e05874880cbfb6239" and "1eacd2297226a01c6eb70ecc328d2d854d3d6939" have entirely different histories.

7 changed files with 25 additions and 515 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`. | | `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`. | | `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²`. | | `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, 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. | | `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. |
| `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. | | `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. | | `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. | | `RDBR` / `0x52424452` | same layout | Branch tier. |

View file

@ -113,42 +113,14 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
// terrain-aware), "lowground" follows the lowest ground and wanders like a // terrain-aware), "lowground" follows the lowest ground and wanders like a
// real river — the task-22 gate decides which ships; "lowground" is the // real river — the task-22 gate decides which ships; "lowground" is the
// provisional default pending that verdict. Width/depth scales are taste // provisional default pending that verdict. Width/depth scales are taste
// dials on the flow-proportional bed profile. LOWGROUND is the LOCKED // dials on the flow-proportional bed profile. RiverSeaMargin is the bed's
// 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 // absolute floor above sea — the erosion flood-guard discipline: no river
// bed may create inland below-sea cells, so the rendered coastline cannot // bed may create inland below-sea cells, so the rendered coastline cannot
// move even with rivers carved. // move even with rivers carved.
// RiverStepDropM/RiverWaterDepthM (task 23): the stepped-water dials — each
// river is a chain of flat water-body reaches; a new reach starts every
// StepDrop metres of bed descent and sits WaterDepth metres above its bed.
// Smaller drop = more, finer steps = smoother water (the smoothing dial;
// tilted continuous-slope water is the deferred model B).
public static string Rivers = "off"; public static string Rivers = "off";
public static string RiverRoutingStyle = "lowground"; public static string RiverRoutingStyle = "lowground";
public static float RiverWidthScale = 1.75f; // widened at the task-23 gate's ask public static float RiverWidthScale = 1.0f;
// Task 24 (the gate's polish): finer steps read as a descending river rather public static float RiverDepthScale = 1.0f;
// 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 public static float RiverSeaMargin = 0.2f; // m above sea, bed floor
// Island falloff shaping (task 11). // Island falloff shaping (task 11).
@ -356,22 +328,6 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
if (data.ContainsKey("RiverWidthScale")) RiverWidthScale = (float)data["RiverWidthScale"]; if (data.ContainsKey("RiverWidthScale")) RiverWidthScale = (float)data["RiverWidthScale"];
if (data.ContainsKey("RiverDepthScale")) RiverDepthScale = (float)data["RiverDepthScale"]; if (data.ContainsKey("RiverDepthScale")) RiverDepthScale = (float)data["RiverDepthScale"];
if (data.ContainsKey("RiverSeaMargin")) RiverSeaMargin = (float)data["RiverSeaMargin"]; if (data.ContainsKey("RiverSeaMargin")) RiverSeaMargin = (float)data["RiverSeaMargin"];
if (data.ContainsKey("RiverStepDropM")) RiverStepDropM = (float)data["RiverStepDropM"];
if (data.ContainsKey("RiverWaterDepthM")) RiverWaterDepthM = (float)data["RiverWaterDepthM"];
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); RiverWidthScale = Mathf.Clamp(RiverWidthScale, 0.1f, 5f);
RiverDepthScale = Mathf.Clamp(RiverDepthScale, 0.1f, 5f); RiverDepthScale = Mathf.Clamp(RiverDepthScale, 0.1f, 5f);
RiverSeaMargin = Mathf.Clamp(RiverSeaMargin, 0f, 5f); RiverSeaMargin = Mathf.Clamp(RiverSeaMargin, 0f, 5f);

View file

@ -47,7 +47,6 @@ namespace IslaApocalypse.Core
{ {
public const byte TYPE_OCEAN = 0; public const byte TYPE_OCEAN = 0;
public const byte TYPE_LAKE = 1; public const byte TYPE_LAKE = 1;
public const byte TYPE_RIVER = 2; // task 23: a stepped river REACH (one flat level)
public const byte SALINITY_FRESH = 0; public const byte SALINITY_FRESH = 0;
public const byte SALINITY_SALT = 1; public const byte SALINITY_SALT = 1;
@ -163,24 +162,6 @@ namespace IslaApocalypse.Core
{ {
public static WorldBlueprint LoadMapData(string seedStr) 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"); string filePath = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat");
return LoadMapDataFromPath(filePath); return LoadMapDataFromPath(filePath);
} }
@ -451,7 +432,7 @@ namespace IslaApocalypse.Core
body.SurfaceLevel = reader.ReadSingle(); body.SurfaceLevel = reader.ReadSingle();
body.PixelCount = reader.ReadInt32(); body.PixelCount = reader.ReadInt32();
body.Centroid = new Vector2(reader.ReadSingle(), reader.ReadSingle()); body.Centroid = new Vector2(reader.ReadSingle(), reader.ReadSingle());
if (body.Type > WaterBodyInfo.TYPE_RIVER) if (body.Type > WaterBodyInfo.TYPE_LAKE)
{ {
GD.PrintErr($"[MapDataParser] ERROR: water body {i} has unknown type {body.Type}."); GD.PrintErr($"[MapDataParser] ERROR: water body {i} has unknown type {body.Type}.");
return false; return false;

View file

@ -237,16 +237,7 @@ public partial class MapGenerator : TextureRect
// only: biomes and WBID are already computed from classify — the oracle is // only: biomes and WBID are already computed from classify — the oracle is
// untouched by construction. NO WATER — part 2b. // untouched by construction. NO WATER — part 2b.
if (_riversOn) if (_riversOn)
{
CarveRivers(); CarveRivers();
// The 0_height/0_water snapshots were captured before rivers existed —
// re-draw and re-capture so the exported PNGs show the carved beds and
// the new river water (task-22 nit 2).
DrawHeightStageTexture();
await CaptureStage("0_height");
DrawWaterStageTexture();
await CaptureStage("0_water");
}
if (ConfigManager.SkipRoads) if (ConfigManager.SkipRoads)
{ {
@ -335,12 +326,12 @@ public partial class MapGenerator : TextureRect
// 6. Pull the image from the invisible monitor and save it! // 6. Pull the image from the invisible monitor and save it!
Image capture = offscreenVP.GetTexture().GetImage(); Image capture = offscreenVP.GetTexture().GetImage();
string seedStr = _noise.Seed.ToString(); string seedStr = _noise.Seed.ToString();
string fileName = System.IO.Path.Combine(OutputDir(), $"Map_Seed_{seedStr}_{label}.png"); string fileName = $"user://Map_Seed_{seedStr}_{label}.png";
Error saveResult = capture.SavePng(fileName); Error saveResult = capture.SavePng(fileName);
if (saveResult == Error.Ok) if (saveResult == Error.Ok)
GD.Print($"{T()} Map saved: {fileName}"); GD.Print($"{T()} Map saved: {ProjectSettings.GlobalizePath(fileName)}");
else else
GD.PrintErr($"{T()} Failed to save map. Godot Error code: {saveResult}"); GD.PrintErr($"{T()} Failed to save map. Godot Error code: {saveResult}");
@ -348,40 +339,18 @@ public partial class MapGenerator : TextureRect
offscreenVP.QueueFree(); 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() private void ExportMapData()
{ {
string seedStr = _noise.Seed.ToString(); 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), // PRIMARY: the v2 tagged-section container (Core/Scripts/BLUEPRINT_FORMAT.md),
// under the name the server looks for. // under the name the server looks for.
string v2Path = System.IO.Path.Combine(outDir, $"MapData_Seed_{seedStr}.dat"); string v2Path = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat");
BlueprintWriter.WriteV2(v2Path, BuildBlueprint()); BlueprintWriter.WriteV2(v2Path, BuildBlueprint());
// SAFETY NET: the legacy v1 format beside it, until the developer has lived // SAFETY NET: the legacy v1 format beside it, until the developer has lived
// with v2 across several regenerations. Removal is a future task. // with v2 across several regenerations. Removal is a future task.
ExportMapDataV1(System.IO.Path.Combine(outDir, $"MapData_Seed_{seedStr}_v1.dat")); ExportMapDataV1(ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}_v1.dat"));
} }
/// <summary> /// <summary>
@ -867,17 +836,6 @@ public partial class MapGenerator : TextureRect
isClassifyWater[x * MapSize + y] = IsWaterPixel(x, y); 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; float southX = -1f, southY = -1f;
foreach (var t in _towns) foreach (var t in _towns)
if (t.Position.Y > southY) { southX = t.Position.X; southY = t.Position.Y; } if (t.Position.Y > southY) { southX = t.Position.X; southY = t.Position.Y; }
@ -888,16 +846,10 @@ public partial class MapGenerator : TextureRect
? RiverCarvePass.STYLE_SHORT : RiverCarvePass.STYLE_LOWGROUND, ? RiverCarvePass.STYLE_SHORT : RiverCarvePass.STYLE_LOWGROUND,
WidthScale = ConfigManager.RiverWidthScale, WidthScale = ConfigManager.RiverWidthScale,
DepthScale = ConfigManager.RiverDepthScale, 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, var st = RiverCarvePass.Apply(_heightMap, MapSize, isOcean, isClassifyWater,
isSignificantWater, southX, southY, seaMap, seaFlat, southX, southY, seaMap, seaFlat,
_impactCenter.X, _impactCenter.Y, _impactCenter.X, _impactCenter.Y,
_impactRadius * ConfigManager.CraterErosionCore, _impactRadius * ConfigManager.CraterErosionCore,
() => (Time.GetTicksMsec()) / 1000.0, p); () => (Time.GetTicksMsec()) / 1000.0, p);
@ -911,45 +863,6 @@ public partial class MapGenerator : TextureRect
for (int y = 0; y < MapSize; y++) for (int y = 0; y < MapSize; y++)
if (_heightMap[x, y] > topAfter) topAfter = _heightMap[x, y]; if (_heightMap[x, y] > topAfter) topAfter = _heightMap[x, y];
// --- Stepped river water (task 23, part 2b): reaches as flat water bodies.
// Levels-not-cells, the existing model: WBID cells + WBTB entries per reach;
// the writer derives WSRF from body levels. Touches no heights, so the
// flood-guard count above stays valid; touches no classify data, so the
// BIOME oracle holds (0_water changes — that IS the river water).
ushort nextBodyId = 1;
foreach (var b in _waterBodies)
if (b.Id >= nextBodyId) nextBodyId = (ushort)(b.Id + 1);
var reaches = RiverCarvePass.AddSteppedWater(_heightMap, MapSize, _waterBodyIds,
nextBodyId, st.Carved, seaMap, seaFlat,
_impactCenter.X, _impactCenter.Y,
_impactRadius * ConfigManager.CraterErosionCore,
ConfigManager.RiverStepDropM, ConfigManager.RiverWaterDepthM,
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, " + GD.Print($"{T()} [Rivers] v1 '{ConfigManager.RiverRoutingStyle}': plan {st.AnalysisSeconds:F1}s, " +
$"routing {st.RoutingSeconds:F1}s, carve {st.CarveSeconds:F1}s " + $"routing {st.RoutingSeconds:F1}s, carve {st.CarveSeconds:F1}s " +
$"({(Time.GetTicksMsec() - tRiv0) / 1000.0:F1}s total). " + $"({(Time.GetTicksMsec() - tRiv0) / 1000.0:F1}s total). " +

View file

@ -109,48 +109,7 @@ Generates the entire 2D blueprint. Roughly in order:
sea + `RiverSeaMargin` everywhere (the erosion flood-guard discipline: below-sea cells sea + `RiverSeaMargin` everywhere (the erosion flood-guard discipline: below-sea cells
read-only, zero new below-sea cells, coastline provably fixed; asserted per generation). 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. Slots AFTER towns (town placement reads the render map and must not shift) and BEFORE roads.
Crater core excluded. Crater core excluded. **NO WATER yet** — part 2b waters the gated routing style.
**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 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 — the default, `SeaLevelValue` 0.15 — or the legacy `"field"` latitude Lerp); flood fill

View file

@ -66,30 +66,6 @@ public static class RiverCarvePass
public float WidthScale = 1.0f; public float WidthScale = 1.0f;
public float DepthScale = 1.0f; public float DepthScale = 1.0f;
public float SeaMarginM = 0.2f; // bed floor above sea, everywhere 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(); public DrainageAnalysis.Params PlanParams = new();
} }
@ -108,43 +84,17 @@ public static class RiverCarvePass
public double VolumeM3; public double VolumeM3;
} }
/// <summary>The carved geometry the water stage consumes (main rivers only).</summary>
public class CarvedRiver
{
public string Name, Kind;
public bool Southern;
public long DrainagePx;
public List<(float x, float y)> Dense; // head → mouth, ~1-px samples
public float[] Bed; // raw units, monotone non-increasing
public float[] HalfW; // px
public 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 class Stats
{ {
public List<RiverStat> Rivers = new(); public List<RiverStat> Rivers = new();
public List<CarvedRiver> Carved = new(); // for the stepped-water stage (task 23)
internal float[] PrePass; // cumulative-cut baseline
public long CarvedCells; public long CarvedCells;
public double CarvedVolumeM3; public double CarvedVolumeM3;
public float MaxCutM; // CUMULATIVE vs pre-pass heights (task-22 nit 1 fixed) public float MaxCutM;
public double AnalysisSeconds, RoutingSeconds, CarveSeconds; 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, public static Stats Apply(float[,] height, int mapSize, bool[] isOcean,
bool[] isClassifyWater, bool[] isSignificantWater, float southX, float southY, bool[] isClassifyWater, float southX, float southY,
float[,] seaMap, float seaFlat, float[,] seaMap, float seaFlat,
float craterCx, float craterCy, float craterCoreRadius, float craterCx, float craterCy, float craterCoreRadius,
Func<double> secondsNow, Params p) Func<double> secondsNow, Params p)
@ -172,14 +122,6 @@ public static class RiverCarvePass
// --- carve --- // --- carve ---
t0 = secondsNow(); t0 = secondsNow();
// Pre-pass snapshot: max-cut is measured CUMULATIVELY against the heights
// this pass found, not per-write — overlapping stamps re-cut a cell and the
// per-write number understated the true deepest cut ~4× (task-22 nit 1).
float[] pre = new float[n * n];
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
pre[x * n + y] = height[x, y];
stats.PrePass = pre;
int riverIdx = 0; int riverIdx = 0;
foreach (var t in plan.Trunks) foreach (var t in plan.Trunks)
{ {
@ -198,31 +140,10 @@ public static class RiverCarvePass
var route = giantRoutes[gi]; gi++; var route = giantRoutes[gi]; gi++;
var course = new List<(float x, float y)>(g.Course); var course = new List<(float x, float y)>(g.Course);
course.Reverse(); // head → terminal course.Reverse(); // head → terminal
// Lake-enders (task 23): the stem pools on dry ground short of its lake
// BECAUSE its pooling point is a local minimum — a blind descent walk
// dead-ends there immediately (measured: 0 steps). Route to the nearest
// classify-water cell with the same lowground Dijkstra the routed giants
// use, so the bed (and then the water) actually joins the lake.
bool reachedLake = false;
if (g.Kind == "lake-ender")
{
// 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, var rs = CarveRiver($"giant{gi}", g.Kind, g.DrainageAreaPx,
course, route, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats); course, route, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
rs.SouthernCandidate = g.SouthernCandidate; rs.SouthernCandidate = g.SouthernCandidate;
rs.ReachedOcean = g.Kind == "routed" ? (route != null && route.Count > 0) : reachedLake; rs.ReachedOcean = g.Kind != "routed" || (route != null && route.Count > 0);
if (stats.Carved.Count > 0) stats.Carved[^1].ReachedWaterTerminal = reachedLake;
foreach (var trib in g.Tributaries) foreach (var trib in g.Tributaries)
CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats); CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
} }
@ -230,61 +151,13 @@ public static class RiverCarvePass
return stats; 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> /// <summary>
/// Deterministic Dijkstra from the start cell to the nearest ocean cell under /// 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), /// 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). /// or an empty list if no path exists (reported upstream, never asserted away).
/// </summary> /// </summary>
private static List<(float x, float y)> RouteToOcean(float[,] height, int n, private static List<(float x, float y)> RouteToOcean(float[,] height, int n,
bool[] targets, int sx, int sy, byte style, Func<int, int, float> seaAt) bool[] isOcean, int sx, int sy, byte style, Func<int, int, float> seaAt)
{ {
int total = n * n; int total = n * n;
var gcost = new float[total]; var gcost = new float[total];
@ -306,7 +179,7 @@ public static class RiverCarvePass
int c = pq.Dequeue(); int c = pq.Dequeue();
if (closed[c]) continue; if (closed[c]) continue;
closed[c] = true; closed[c] = true;
if (targets[c]) { goal = c; break; } if (isOcean[c]) { goal = c; break; }
int cx = c / n, cy = c % n; int cx = c / n, cy = c % n;
float hc = height[cx, cy]; float hc = height[cx, cy];
for (int k = 0; k < 8; k++) for (int k = 0; k < 8; k++)
@ -346,10 +219,7 @@ public static class RiverCarvePass
{ {
var course = new List<(float x, float y)>(trib.Course); var course = new List<(float x, float y)>(trib.Course);
course.Reverse(); // head → confluence course.Reverse(); // head → confluence
// Task 24: tributaries are registered as carved rivers (name "trib") so the CarveRiver(null, "tributary", trib.DrainageAreaPx, course, null,
// 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); height, n, seaAt, coreSq, craterCx, craterCy, p, stats);
} }
@ -364,16 +234,9 @@ public static class RiverCarvePass
float coreSq, float craterCx, float craterCy, Params p, Stats stats) float coreSq, float craterCx, float craterCy, Params p, Stats stats)
{ {
// Full head→mouth polyline: upland stem, then the lowland reach if any. // Full head→mouth polyline: upland stem, then the lowland reach if any.
// ONLY the lowland reach is smoothed: the Dijkstra 45° kinks live there, on
// near-flat ground where a rounded corner costs nothing. The upland stems
// already thread the carved valley FLOORS — smoothing them off-line cut
// valley walls (measured: max cut 14.6 → 27.3 m before this was split).
var pts = new List<(float x, float y)>(upland); var pts = new List<(float x, float y)>(upland);
if (lowlandRoute != null && lowlandRoute.Count > 1) if (lowlandRoute != null && lowlandRoute.Count > 1)
{ pts.AddRange(lowlandRoute.GetRange(1, lowlandRoute.Count - 1));
var smoothedRoute = SmoothCourse(lowlandRoute);
pts.AddRange(smoothedRoute.GetRange(1, smoothedRoute.Count - 1));
}
// Densify to ~1-px samples (plan courses are decimated ×4). // Densify to ~1-px samples (plan courses are decimated ×4).
var dense = new List<(float x, float y)>(); var dense = new List<(float x, float y)>();
@ -446,7 +309,7 @@ public static class RiverCarvePass
for (int i = 0; i < m; i++) for (int i = 0; i < m; i++)
{ {
float hw = halfW[i]; float hw = halfW[i];
float outer = hw * p.BankFlare; float outer = hw * 2f;
int cx0 = (int)MathF.Floor(dense[i].x - outer), cx1 = (int)MathF.Ceiling(dense[i].x + outer); 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); 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; float rimH = bed[i] + depth[i] / M_PER_UNIT;
@ -472,188 +335,27 @@ public static class RiverCarvePass
} }
else else
{ {
// Shoulder: 0 at the rim → 1 at natural ground, over a flare of float f = (r - hw) / hw; // 0..1 across the shoulder
// (BankFlare-1) half-widths. Smootherstep (6t⁵15t⁴+10t³) leaves f = f * f * (3f - 2f * f); // smoothstep
// 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; 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; float floor = sea + p.SeaMarginM / M_PER_UNIT;
if (target < floor) target = floor; if (target < floor) target = floor;
if (target < old) if (target < old)
{ {
float cutM = (old - target) * M_PER_UNIT; float cutM = (old - target) * M_PER_UNIT;
// Cumulative depth vs the PRE-PASS surface (nit 1): the
// honest "how deep did we cut here in total" number.
float cumM = (stats.PrePass[x * n + y] - target) * M_PER_UNIT;
height[x, y] = target; height[x, y] = target;
stats.CarvedCells++; stats.CarvedCells++;
stats.CarvedVolumeM3 += cutM; stats.CarvedVolumeM3 += cutM;
if (cumM > stats.MaxCutM) stats.MaxCutM = cumM; if (cutM > stats.MaxCutM) stats.MaxCutM = cutM;
if (cumM > rs.MaxCutM) rs.MaxCutM = cumM; if (cutM > rs.MaxCutM) rs.MaxCutM = cutM;
rs.VolumeM3 += cutM; rs.VolumeM3 += cutM;
} }
} }
} }
} }
if (name != null) if (name != null) stats.Rivers.Add(rs);
{
if (kind != "tributary") stats.Rivers.Add(rs);
// Along-course flow: drainage grows from a headwater trickle to the full
// figure at the mouth. Quadratic in t so the substantial lower half
// dominates — a first-order stand-in for real accumulation, which the
// plan only records per-river.
var flow = new float[m];
for (int i = 0; i < m; i++)
{
float t = m > 1 ? (float)i / (m - 1) : 1f;
flow[i] = drainagePx * (0.05f + 0.95f * t * t);
}
int wetFrom = 0;
if (kind == "tributary" && p.TribWaterMinFlowPx > 0)
{
wetFrom = m; // dry unless the threshold is met
for (int i = 0; i < m; i++)
if (flow[i] >= p.TribWaterMinFlowPx) { wetFrom = i; break; }
}
stats.Carved.Add(new CarvedRiver
{
Name = name, Kind = kind, DrainagePx = drainagePx,
Dense = dense, Bed = bed, HalfW = halfW, DepthM = depth,
Flow = flow, WetFrom = wetFrom, TaperPx = p.TribTaperPx
});
}
return rs; return rs;
} }
/// <summary>
/// The stepped-water builder (task 23, part 2b): segments each carved main
/// river into REACHES — flat water bodies stepping down the bed toward the
/// outlet — and stamps their ids into the WBID grid. Reuses the existing
/// levels-not-cells water model exactly: one body per reach, one flat level
/// each; the writer derives WSRF from body levels as it always has. The step
/// drops are the smoothing dial (smaller drop = more, finer steps); tilted
/// water is the deferred model B and is NOT built here.
///
/// Emission is plain data (no engine types): the caller turns reaches into
/// WBTB entries. Wet cells: inside the channel half-width, currently dry in
/// WBID, at/above sea (below-sea cells belong to the ocean/crater-seam rule),
/// bed below the reach level. Existing water bodies are never overwritten —
/// a river MEETS a lake or the sea, it does not repaint them.
/// </summary>
public class Reach
{
public ushort Id;
public string River;
public float Level; // raw units
public int PixelCount;
public double Cx, Cy; // centroid accumulators → mean
}
public static List<Reach> AddSteppedWater(float[,] height, int mapSize,
ushort[,] wbid, ushort firstId, List<CarvedRiver> rivers,
float[,] seaMap, float seaFlat, float craterCx, float craterCy,
float craterCoreRadius, float stepDropM, float waterDepthM, 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

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