diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs
index 1a084fe..7234950 100644
--- a/Core/Scripts/MapDataParser.cs
+++ b/Core/Scripts/MapDataParser.cs
@@ -36,6 +36,28 @@ namespace IslaApocalypse.Core
public string GeneratorGitHash = ""; // short hash of the generator repo, "" if unknown
}
+ ///
+ /// One water body from the blueprint's WBTB section: the ocean (exactly one, id 1)
+ /// or a lake (ids 2..N). SurfaceLevel is the documented TRANSITIONAL rule — one flat
+ /// level per body, GetSeaLevel at the body's pixel centroid under the still-live
+ /// latitude field; superseded when the flat-scalar sea model lands. Salinity is a
+ /// provisional placeholder for the future fresh/salt mechanic (ocean salt, lake fresh).
+ ///
+ public class WaterBodyInfo
+ {
+ public const byte TYPE_OCEAN = 0;
+ public const byte TYPE_LAKE = 1;
+ public const byte SALINITY_FRESH = 0;
+ public const byte SALINITY_SALT = 1;
+
+ public ushort Id;
+ public byte Type;
+ public byte Salinity;
+ public float SurfaceLevel; // raw blueprint height units
+ public int PixelCount;
+ public Vector2 Centroid; // map pixels
+ }
+
public class WorldBlueprint
{
public int MapSize;
diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs
index d0cceca..9eb05ad 100644
--- a/Tools/Scripts/MapGenerator.cs
+++ b/Tools/Scripts/MapGenerator.cs
@@ -29,9 +29,15 @@ public partial class MapGenerator : TextureRect
private float[,] _tempMap;
private Biome[,] _biomeMap;
private bool[,] _isTrueOcean;
- private bool[,] _isMainland;
+ private bool[,] _isMainland;
internal List _towns = new List();
+ // Water-bodies stage outputs (terrain-water task 03): 0 = no water, 1 = the
+ // ocean, 2..N = lakes. Filled by IdentifyWaterBodies, serialized by the v2
+ // writer, consumed by nothing at runtime yet.
+ internal ushort[,] _waterBodyIds;
+ internal List _waterBodies;
+
internal List _highwayPaths = new List();
internal List _branchPaths = new List();
internal List _ruggedPaths = new List();
@@ -84,6 +90,10 @@ public partial class MapGenerator : TextureRect
CalculateMainland();
GD.Print($"{T()} Ocean and mainland masks done.");
+ IdentifyWaterBodies();
+ DrawWaterStageTexture();
+ await CaptureStage("0_water");
+
AssignBiomesAndDraw();
GD.Print($"{T()} Biomes done.");
await CaptureStage("1_biomes");
@@ -441,6 +451,299 @@ public partial class MapGenerator : TextureRect
}
}
+ // =====================================================================
+ // SHARED WATER PREDICATES (terrain-water task 03)
+ // The single source of per-pixel water truth. AssignBiomesAndDraw and
+ // IdentifyWaterBodies both call these; correspondence between the biome
+ // grid and the water-body grid holds BY CONSTRUCTION, not by parallel
+ // implementations agreeing.
+ // =====================================================================
+ private bool IsWaterPixel(int x, int y) => _heightMap[x, y] < GetSeaLevel(_tempMap[x, y]);
+ private bool IsOceanPixel(int x, int y) => IsWaterPixel(x, y) && _isTrueOcean[x, y];
+ private bool IsLakePixel(int x, int y) => IsWaterPixel(x, y) && !_isTrueOcean[x, y];
+
+ ///
+ /// The water-bodies stage (terrain-water task 03). Promotes the world's EXISTING
+ /// water into explicit data: body 1 = the ocean (all IsOceanPixel pixels), bodies
+ /// 2..N = lakes, connected-component labeled with the SAME 4-connectivity as
+ /// CalculateTrueOcean's flood fill (Up/Down/Left/Right), in deterministic scan
+ /// order (X outer, Y inner; a body's id is fixed by its first-encountered pixel).
+ /// Membership comes only from the shared predicates — this stage groups pixels,
+ /// it never adds or removes any.
+ ///
+ /// Each body carries ONE surface level: GetSeaLevel at the body's pixel centroid
+ /// (ocean: at the map centre). This is the documented TRANSITIONAL rule — see
+ /// BLUEPRINT_FORMAT.md (WBTB) — superseded when the flat-scalar sea model lands.
+ ///
+ private void IdentifyWaterBodies()
+ {
+ ulong t0 = Time.GetTicksMsec();
+ _waterBodyIds = new ushort[MapSize, MapSize];
+ _waterBodies = new List();
+
+ // --- Body 1: the ocean, one body, first-class ---
+ long oceanCount = 0;
+ double oceanCx = 0, oceanCy = 0;
+ for (int x = 0; x < MapSize; x++)
+ {
+ for (int y = 0; y < MapSize; y++)
+ {
+ if (IsOceanPixel(x, y))
+ {
+ _waterBodyIds[x, y] = 1;
+ oceanCount++;
+ oceanCx += x; oceanCy += y;
+ }
+ }
+ }
+ Vector2 oceanCentroid = oceanCount > 0
+ ? new Vector2((float)(oceanCx / oceanCount), (float)(oceanCy / oceanCount))
+ : Vector2.Zero;
+ _waterBodies.Add(new WaterBodyInfo
+ {
+ Id = 1,
+ Type = WaterBodyInfo.TYPE_OCEAN,
+ Salinity = WaterBodyInfo.SALINITY_SALT, // provisional default
+ SurfaceLevel = GetSeaLevel(_tempMap[MapSize / 2, MapSize / 2]), // ocean: map centre
+ PixelCount = (int)oceanCount,
+ Centroid = oceanCentroid
+ });
+
+ // --- Bodies 2..N: lakes, 4-connected like CalculateTrueOcean ---
+ Vector2I[] directions = { Vector2I.Up, Vector2I.Down, Vector2I.Left, Vector2I.Right };
+ Queue queue = new Queue();
+ int nextId = 2;
+ long lakePixels = 0;
+
+ for (int x = 0; x < MapSize; x++)
+ {
+ for (int y = 0; y < MapSize; y++)
+ {
+ if (_waterBodyIds[x, y] != 0 || !IsLakePixel(x, y)) continue;
+
+ if (nextId > ushort.MaxValue)
+ {
+ GD.PrintErr($"[WaterBodies] ⚠⚠ More than {ushort.MaxValue - 1} water bodies — u16 id space exhausted. Remaining lakes left unlabeled.");
+ x = MapSize; break;
+ }
+
+ ushort id = (ushort)nextId++;
+ long count = 0;
+ double cx = 0, cy = 0;
+
+ _waterBodyIds[x, y] = id;
+ queue.Enqueue(new Vector2I(x, y));
+ while (queue.Count > 0)
+ {
+ Vector2I current = queue.Dequeue();
+ count++; cx += current.X; cy += current.Y;
+ foreach (var dir in directions)
+ {
+ Vector2I nb = current + dir;
+ if (nb.X < 0 || nb.X >= MapSize || nb.Y < 0 || nb.Y >= MapSize) continue;
+ if (_waterBodyIds[nb.X, nb.Y] != 0 || !IsLakePixel(nb.X, nb.Y)) continue;
+ _waterBodyIds[nb.X, nb.Y] = id;
+ queue.Enqueue(nb);
+ }
+ }
+
+ lakePixels += count;
+ int centX = Mathf.Clamp((int)Mathf.Round((float)(cx / count)), 0, MapSize - 1);
+ int centY = Mathf.Clamp((int)Mathf.Round((float)(cy / count)), 0, MapSize - 1);
+ _waterBodies.Add(new WaterBodyInfo
+ {
+ Id = id,
+ Type = WaterBodyInfo.TYPE_LAKE,
+ Salinity = WaterBodyInfo.SALINITY_FRESH, // provisional default
+ SurfaceLevel = GetSeaLevel(_tempMap[centX, centY]),
+ PixelCount = (int)count,
+ Centroid = new Vector2((float)(cx / count), (float)(cy / count))
+ });
+ }
+ }
+
+ double seconds = (Time.GetTicksMsec() - t0) / 1000.0;
+ GD.Print($"{T()} [WaterBodies] {_waterBodies.Count} bodies in {seconds:F1}s: ocean {oceanCount} px, {_waterBodies.Count - 1} lakes totalling {lakePixels} px.");
+
+ RunPriorityFloodDiagnostics();
+ }
+
+ ///
+ /// Priority-flood pit-filling over the post-topography heightmap — VALIDATED
+ /// DIAGNOSTICS ONLY. Serializes nothing (deliberately no BSIN section: basin data
+ /// goes stale the moment coast smoothing changes terrain; the rivers stage
+ /// recomputes fresh — see BLUEPRINT_FORMAT.md). Reports closed-basin statistics
+ /// and asserts two invariants: (a) filled ≥ original everywhere; (b) on the filled
+ /// surface every pixel has a non-ascending 8-neighbour path to the map border
+ /// (checked in full via a reverse BFS, not a sample).
+ ///
+ private void RunPriorityFloodDiagnostics()
+ {
+ ulong t0 = Time.GetTicksMsec();
+ int n = MapSize;
+ int total = n * n;
+
+ // 1-D row-major copies (idx = x * n + y) for speed.
+ float[] original = new float[total];
+ for (int x = 0; x < n; x++)
+ for (int y = 0; y < n; y++)
+ original[x * n + y] = _heightMap[x, y];
+ float[] filled = (float[])original.Clone();
+
+ // --- Priority-flood (Barnes et al. variant: heap + plain pit queue) ---
+ bool[] visited = new bool[total];
+ var heap = new PriorityQueue();
+ var pit = new Queue();
+
+ void Seed(int idx) { if (!visited[idx]) { visited[idx] = true; heap.Enqueue(idx, filled[idx]); } }
+ for (int x = 0; x < n; x++) { Seed(x * n); Seed(x * n + (n - 1)); }
+ for (int y = 0; y < n; y++) { Seed(y); Seed((n - 1) * n + y); }
+
+ while (heap.Count > 0 || pit.Count > 0)
+ {
+ int c = pit.Count > 0 ? pit.Dequeue() : heap.Dequeue();
+ float fc = filled[c];
+ int cx = c / n, cy = c % n;
+ for (int dx = -1; dx <= 1; dx++)
+ {
+ for (int dy = -1; dy <= 1; dy++)
+ {
+ if (dx == 0 && dy == 0) continue;
+ int nx = cx + dx, ny = cy + dy;
+ if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
+ int ni = nx * n + ny;
+ if (visited[ni]) continue;
+ visited[ni] = true;
+ if (filled[ni] <= fc) { filled[ni] = fc; pit.Enqueue(ni); }
+ else heap.Enqueue(ni, filled[ni]);
+ }
+ }
+ }
+ double floodSeconds = (Time.GetTicksMsec() - t0) / 1000.0;
+
+ // --- Invariant (a): filled ≥ original everywhere ---
+ long invariantAViolations = 0;
+ for (int i = 0; i < total; i++)
+ if (filled[i] < original[i]) invariantAViolations++;
+
+ // --- Invariant (b): full reverse BFS from the border over non-descending
+ // edges; a pixel is reachable iff it has a non-ascending 8-neighbour path
+ // down to the border on the filled surface. ---
+ bool[] reachable = new bool[total];
+ var bfs = new Queue();
+ void SeedB(int idx) { if (!reachable[idx]) { reachable[idx] = true; bfs.Enqueue(idx); } }
+ for (int x = 0; x < n; x++) { SeedB(x * n); SeedB(x * n + (n - 1)); }
+ for (int y = 0; y < n; y++) { SeedB(y); SeedB((n - 1) * n + y); }
+ while (bfs.Count > 0)
+ {
+ int c = bfs.Dequeue();
+ float fc = filled[c];
+ int cx = c / n, cy = c % n;
+ for (int dx = -1; dx <= 1; dx++)
+ {
+ for (int dy = -1; dy <= 1; dy++)
+ {
+ if (dx == 0 && dy == 0) continue;
+ int nx = cx + dx, ny = cy + dy;
+ if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
+ int ni = nx * n + ny;
+ if (reachable[ni] || filled[ni] < fc) continue;
+ reachable[ni] = true;
+ bfs.Enqueue(ni);
+ }
+ }
+ }
+ long invariantBViolations = 0;
+ for (int i = 0; i < total; i++)
+ if (!reachable[i]) invariantBViolations++;
+
+ if (invariantAViolations > 0)
+ GD.PrintErr($"[PriorityFlood] ⚠⚠ INVARIANT (a) VIOLATED: {invariantAViolations} pixels have filled < original.");
+ if (invariantBViolations > 0)
+ GD.PrintErr($"[PriorityFlood] ⚠⚠ INVARIANT (b) VIOLATED: {invariantBViolations} pixels lack a non-ascending path to the border.");
+
+ // --- Closed-basin statistics on LAND (shared predicate), 8-connected ---
+ long landPixels = 0, basinLandPixels = 0;
+ var basinAreas = new List();
+ var basinDepths = new List();
+ bool[] counted = new bool[total];
+ var comp = new Queue();
+ for (int x = 0; x < n; x++)
+ {
+ for (int y = 0; y < n; y++)
+ {
+ int i = x * n + y;
+ bool land = !IsWaterPixel(x, y);
+ if (land) landPixels++;
+ if (!land || counted[i] || filled[i] <= original[i]) continue;
+
+ long area = 0; float maxDepth = 0f;
+ counted[i] = true;
+ comp.Enqueue(i);
+ while (comp.Count > 0)
+ {
+ int c = comp.Dequeue();
+ area++;
+ float d = filled[c] - original[c];
+ if (d > maxDepth) maxDepth = d;
+ int cx2 = c / n, cy2 = c % n;
+ for (int dx = -1; dx <= 1; dx++)
+ {
+ for (int dy = -1; dy <= 1; dy++)
+ {
+ if (dx == 0 && dy == 0) continue;
+ int nx = cx2 + dx, ny = cy2 + dy;
+ if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
+ int ni = nx * n + ny;
+ if (counted[ni] || filled[ni] <= original[ni] || IsWaterPixel(nx, ny)) continue;
+ counted[ni] = true;
+ comp.Enqueue(ni);
+ }
+ }
+ }
+ basinLandPixels += area;
+ basinAreas.Add(area);
+ basinDepths.Add(maxDepth);
+ }
+ }
+
+ basinAreas.Sort();
+ basinDepths.Sort();
+ long P(List s, double q) => s.Count == 0 ? 0 : s[Mathf.Clamp((int)(q * s.Count), 0, s.Count - 1)];
+ float Pf(List s, double q) => s.Count == 0 ? 0 : s[Mathf.Clamp((int)(q * s.Count), 0, s.Count - 1)];
+
+ double totalSeconds = (Time.GetTicksMsec() - t0) / 1000.0;
+ GD.Print($"{T()} [PriorityFlood] flood {floodSeconds:F1}s, total (with invariants+stats) {totalSeconds:F1}s.");
+ GD.Print($"{T()} [PriorityFlood] invariants: (a) {(invariantAViolations == 0 ? "PASS" : "FAIL")}, (b) {(invariantBViolations == 0 ? "PASS" : "FAIL")} (full check, no sampling).");
+ GD.Print($"{T()} [PriorityFlood] closed basins on land: {basinAreas.Count}; land px in basins {basinLandPixels}/{landPixels} ({(landPixels > 0 ? 100.0 * basinLandPixels / landPixels : 0):F1}%).");
+ GD.Print($"{T()} [PriorityFlood] area px: p50 {P(basinAreas, 0.5)}, p90 {P(basinAreas, 0.9)}, max {(basinAreas.Count > 0 ? basinAreas[basinAreas.Count - 1] : 0)}; " +
+ $"count ≥100px {basinAreas.FindAll(a => a >= 100).Count}, ≥1000px {basinAreas.FindAll(a => a >= 1000).Count}, ≥10000px {basinAreas.FindAll(a => a >= 10000).Count}.");
+ GD.Print($"{T()} [PriorityFlood] max depth (raw h): p50 {Pf(basinDepths, 0.5):F4}, p90 {Pf(basinDepths, 0.9):F4}, max {(basinDepths.Count > 0 ? basinDepths[basinDepths.Count - 1] : 0):F4}; " +
+ $"deeper than 0.01: {basinDepths.FindAll(d => d > 0.01f).Count}, deeper than 0.05: {basinDepths.FindAll(d => d > 0.05f).Count}.");
+ }
+
+ ///
+ /// Paints the water-stage snapshot from the stage's own outputs (the biome grid
+ /// does not exist yet at this point in the pipeline): ocean deep blue, lakes a
+ /// distinct lighter blue, land neutral grey.
+ ///
+ private void DrawWaterStageTexture()
+ {
+ Image img = Image.CreateEmpty(MapSize, MapSize, false, Image.Format.Rgba8);
+ Color land = new Color(0.45f, 0.45f, 0.42f);
+ Color ocean = new Color(0.05f, 0.2f, 0.45f);
+ Color lake = new Color(0.35f, 0.7f, 0.9f);
+ for (int x = 0; x < MapSize; x++)
+ {
+ for (int y = 0; y < MapSize; y++)
+ {
+ ushort id = _waterBodyIds[x, y];
+ img.SetPixel(x, y, id == 0 ? land : (id == 1 ? ocean : lake));
+ }
+ }
+ Texture = ImageTexture.CreateFromImage(img);
+ }
+
private void AssignBiomesAndDraw()
{
Image mapImage = Image.CreateEmpty(MapSize, MapSize, false, Image.Format.Rgba8);
@@ -451,10 +754,15 @@ public partial class MapGenerator : TextureRect
float h = _heightMap[x, y];
float t = _tempMap[x, y];
Biome b;
-
+
float currentSeaLevel = GetSeaLevel(t);
- if (h < currentSeaLevel) b = _isTrueOcean[x, y] ? Biome.Ocean : Biome.Lake;
+ // Water classification comes from the SHARED predicates (task 03), so the
+ // water-bodies stage and the biome classifier cannot disagree. Same rules
+ // as before, verbatim: below local sea level -> Ocean if true-ocean
+ // connected, else Lake.
+ if (IsOceanPixel(x, y)) b = Biome.Ocean;
+ else if (IsLakePixel(x, y)) b = Biome.Lake;
else
{
float baseDist = new Vector2(x, y).DistanceTo(_impactCenter);