diff --git a/Core/README.md b/Core/README.md index 8e5f52d..7654e74 100644 --- a/Core/README.md +++ b/Core/README.md @@ -17,7 +17,9 @@ dispatches on the file's first byte: - **v2 (primary)** — raw `ISLA` magic, u32 version gate, then tagged sections `[u32 tag][u64 length][payload]`. Unknown tags are skipped by length, so future sections are invisible to older readers. Validated on read: version, MapSize bounds, section lengths against - the file, biome/tier ordinal ranges. + the file, biome/tier ordinal ranges. Since the water-bodies stage, v2 files also carry the + optional `WBID`/`WBTB`/`WSRF` water sections (per-pixel body ids, the body table, quantized + surface levels) — parsed into `WorldBlueprint` and consumed by nothing at runtime yet. - **v1 (legacy)** — the positional `"ISLA_V1"` format, still written beside v2 as `_v1.dat` and still loadable (with a deprecation warning) until a future removal task. diff --git a/Core/Scripts/BLUEPRINT_FORMAT.md b/Core/Scripts/BLUEPRINT_FORMAT.md index b857c3f..87eb253 100644 --- a/Core/Scripts/BLUEPRINT_FORMAT.md +++ b/Core/Scripts/BLUEPRINT_FORMAT.md @@ -55,7 +55,11 @@ Every section: `[u32 tag][u64 payload-length in bytes][payload]`. | `HGTS` / `0x53544748` | `MapSize²` × `f32` height, **X outer / Y inner** | Mandatory. The second index is the map's north/south axis; the server consumes it as world **Z**. (This axis convention was never written down for v1 — it is now normative.) Length must equal `4·MapSize²`. | | `BIOM` / `0x4D4F4942` | `MapSize²` × `u8` biome ordinal, same pixel order | Mandatory. Ordinals from `Enums.cs::Biome` — **append-only, never reorder** (the ordinal IS the wire value). Writer refuses ordinals > 255; reader rejects ordinals ≥ the known biome count (parse-time validation; the palette's runtime Dirt fallback for in-memory values is unchanged). Length must equal `MapSize²`. | | `TOWN` / `0x4E574F54` | `i32 count`, then per town: `f32 X` · `f32 Y` · `u8 tier` · `u8 isHighwayNode` (0/1) | Tier ordinals from `Enums.cs::TownTier`, append-only, range-checked on read. The highway-node flag is what the generator's road topology was built from (v1 dropped it); carried and exposed on the parsed blueprint, consumed by nothing server-side yet. | -| `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. | +| `TCRV` / `0x56524354` | 50 B: `u16 curveVersion` · input knots `f32 t1..t4` · `f32 spikeMax` · output bands `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 plateauLo` · `f32 plateauHi` · `f32 peakCap` · `f32 tailSlope` | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v2"`); absent = raw legacy profile. **`spikeMax` semantics by version:** v1 = the pooled calibration max (identical every seed); **v2 = the SEED'S own effective raw pre-curve maximum** — the per-seed spike normalizer, which is why blueprints are no longer reproducible from curve constants alone and the value is recorded here. **Metadata only:** exported heights are already curved; nothing re-applies the map. The curve lives in `Tools/Scripts/HeightCurve.cs` (calibration provenance in its header + the task-05/06 reports). | +| `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. | +| `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. | | `RDRG` / `0x47524452` | same layout | Rugged tier. | | `RDTL` / `0x4C544452` | same layout | Trail tier. | @@ -85,9 +89,38 @@ the file, not the original generation. ### Size -`total = 8 (header) + Σ per section (12 + payload)`. The pixel grid dominates: `5·MapSize²` bytes -(4 height + 1 biome) ≈ **320 MiB at 8K**, vs v1's `8·MapSize²` ≈ 512 MiB — the u8 biome section -saves ~192 MiB at 8K. +`total = 8 (header) + Σ per section (12 + payload)`. The pixel grids dominate: without water +sections `5·MapSize²` bytes (4 height + 1 biome) ≈ **320 MiB at 8K**, vs v1's `8·MapSize²` ≈ +512 MiB — the u8 biome section saves ~192 MiB. With the water sections the per-pixel cost is +`9·MapSize²` (adds 2 B `WBID` + 2 B `WSRF`) ≈ **576 MiB at 8K** plus a trivial `WBTB` (20 B per +body). + +### Config knobs that shape blueprint content (not part of the byte format) + +- **`SeaLevelModel`** (`"flat"` | `"field"`) + **`SeaLevelValue`** (float; used when flat) — + D-033: selects what `MapGenerator.GetSeaLevel` returns. `"flat"` (the default, value 0.15) is + one scalar sea level everywhere; `"field"` is the legacy latitude Lerp (0.26 north → 0.15 + south). Generator-only — all nine `GetSeaLevel` call sites inherit the model; nothing at + runtime computes sea level. The model in force shapes the coastline, masks, biomes, water + bodies, and `WBTB` levels of the blueprint being generated; the file itself does not record + which model produced it (the `WBTB` levels are the observable). +- **`SkipRoads`** (bool, default false) — exports with present-but-empty road sections (see the + road-section table note). +- **`TerrainCurve`** (`"v2"` | `"off"`, default `"v2"`; `"v1"` retired by the task-06 + recalibration and rejected loudly) — whether the height-redistribution curve (storm ladder: + 75 % orange coverage, 50 m plateau, **per-seed-normalized 420 m peaks**) shapes `HGTS`. + Biome/water classification is curve-invariant by construction (it classifies the retained + uncurved heights); town positions and everything 3D follow the curved terrain. When on, + `TCRV` records the effective parameters including the per-seed `spikeMax`. + +### Deliberately NOT a section: basins (`BSIN`) + +The priority-flood pit-fill that runs in the water-bodies stage is **diagnostics only** and +serializes nothing. Rationale, on the record so nobody adds a basin section later without meeting +the argument: basin geometry is a pure function of the heightmap, and it goes stale the moment the +coast-smoothing change set (or any terrain change) touches heights — a serialized `BSIN` would be +a cache with a silent invalidation hazard. The rivers stage recomputes basins fresh at generation +time (measured ~12 s at 8K — cheap enough to never cache). ### Adding a new section (the intended extension path) diff --git a/Core/Scripts/BlueprintFormat.cs b/Core/Scripts/BlueprintFormat.cs index 153e166..99d4597 100644 --- a/Core/Scripts/BlueprintFormat.cs +++ b/Core/Scripts/BlueprintFormat.cs @@ -29,6 +29,27 @@ namespace IslaApocalypse.Core public const uint TAG_ROADS_BRANCH = 0x52424452; // "RDBR" public const uint TAG_ROADS_RUGGED = 0x47524452; // "RDRG" public const uint TAG_ROADS_TRAIL = 0x4C544452; // "RDTL" + public const uint TAG_WATER_BODY_IDS = 0x44494257; // "WBID" + public const uint TAG_WATER_BODY_TABLE = 0x42544257; // "WBTB" + public const uint TAG_WATER_SURFACE = 0x46525357; // "WSRF" + public const uint TAG_TERRAIN_CURVE = 0x56524354; // "TCRV" + + // WSRF quantization: u16, 0 reserved as the no-water sentinel. A real level L + // (raw blueprint height units) encodes as 1 + round(L × 32768), so a genuine + // level can never encode to 0. Decodes back via (q − 1) / 32768. Covers + // [0 .. ~1.99997] raw at 1/32768 raw resolution ≈ 7.7 mm of world height + // (1 raw unit = 251 m) — far below the 1 m voxel. + public const float WSRF_SCALE = 1f / 32768f; + + public static ushort EncodeWaterLevel(float level) + { + return (ushort)Godot.Mathf.Clamp(1 + Godot.Mathf.RoundToInt(level * 32768f), 1, ushort.MaxValue); + } + + public static float DecodeWaterLevel(ushort quantized) + { + return (quantized - 1) * WSRF_SCALE; + } // MapSize sanity bounds, checked before any allocation on read. public const int MIN_MAP_SIZE = 256; diff --git a/Core/Scripts/BlueprintWriter.cs b/Core/Scripts/BlueprintWriter.cs index d904590..56bc07c 100644 --- a/Core/Scripts/BlueprintWriter.cs +++ b/Core/Scripts/BlueprintWriter.cs @@ -31,8 +31,20 @@ namespace IslaApocalypse.Core writer.Write(BlueprintFormat.VERSION); WriteSection(writer, BlueprintFormat.TAG_PARAMS, w => WriteParams(w, bp)); + if (bp.TerrainCurve != null) + WriteSection(writer, BlueprintFormat.TAG_TERRAIN_CURVE, w => WriteTerrainCurve(w, bp.TerrainCurve)); WriteSection(writer, BlueprintFormat.TAG_HEIGHTS, w => WriteHeights(w, bp)); WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(w, bp)); + + // Water sections are written only when the blueprint carries water data + // (a legacy v1 re-encode has none — the sections are simply absent). + if (bp.WaterBodyIds != null) + { + WriteSection(writer, BlueprintFormat.TAG_WATER_BODY_IDS, w => WriteWaterBodyIds(w, bp)); + WriteSection(writer, BlueprintFormat.TAG_WATER_BODY_TABLE, w => WriteWaterBodyTable(w, bp)); + WriteSection(writer, BlueprintFormat.TAG_WATER_SURFACE, w => WriteWaterSurface(w, bp)); + } + WriteSection(writer, BlueprintFormat.TAG_TOWNS, w => WriteTowns(w, bp)); WriteSection(writer, BlueprintFormat.TAG_ROADS_HIGHWAY, w => WriteRoadTier(w, bp.Highways)); WriteSection(writer, BlueprintFormat.TAG_ROADS_BRANCH, w => WriteRoadTier(w, bp.BranchRoads)); @@ -112,6 +124,71 @@ namespace IslaApocalypse.Core } } + private static void WriteTerrainCurve(BinaryWriter writer, TerrainCurveInfo c) + { + writer.Write(c.Version); + writer.Write(c.T1); writer.Write(c.T2); writer.Write(c.T3); writer.Write(c.T4); + writer.Write(c.SpikeMax); + writer.Write(c.Sea); writer.Write(c.OrangeCeil); writer.Write(c.RedCeil); + writer.Write(c.PlateauLo); writer.Write(c.PlateauHi); writer.Write(c.PeakCap); + writer.Write(c.TailSlope); + } + + private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp) + { + int n = bp.MapSize; + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + writer.Write(bp.WaterBodyIds[x, y]); + } + + private static void WriteWaterBodyTable(BinaryWriter writer, WorldBlueprint bp) + { + writer.Write(bp.WaterBodies.Count); + foreach (WaterBodyInfo body in bp.WaterBodies) + { + writer.Write(body.Id); + writer.Write(body.Type); + writer.Write(body.Salinity); + writer.Write(body.SurfaceLevel); + writer.Write(body.PixelCount); + writer.Write(body.Centroid.X); + writer.Write(body.Centroid.Y); + } + } + + private static void WriteWaterSurface(BinaryWriter writer, WorldBlueprint bp) + { + int n = bp.MapSize; + + // A parsed blueprint carries the quantized surface verbatim; a freshly + // generated one derives it from body ids + per-body levels. + if (bp.WaterSurfaceQ != null) + { + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + writer.Write(bp.WaterSurfaceQ[x, y]); + return; + } + + // id -> encoded level lookup (ids are small and dense: 1..N) + int maxId = 0; + foreach (WaterBodyInfo body in bp.WaterBodies) + if (body.Id > maxId) maxId = body.Id; + ushort[] encoded = new ushort[maxId + 1]; + foreach (WaterBodyInfo body in bp.WaterBodies) + encoded[body.Id] = BlueprintFormat.EncodeWaterLevel(body.SurfaceLevel); + + for (int x = 0; x < n; x++) + { + for (int y = 0; y < n; y++) + { + ushort id = bp.WaterBodyIds[x, y]; + writer.Write(id == 0 ? (ushort)0 : encoded[id]); + } + } + } + private static void WriteTowns(BinaryWriter writer, WorldBlueprint bp) { writer.Write(bp.Towns.Count); diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index a9de115..a28342c 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -14,6 +14,25 @@ namespace IslaApocalypse.Core // Change this if your namespace is different public static float DensityMultiplier = 1.0f; // <-- Replaces TownCount public static int ChunkRadius = 24; // Default chunk size, can be overridden by config + // Iteration toggle: skip the ~25-min A* road pass entirely. The blueprint is + // still exported (road sections present but empty) — an iteration artifact, + // not a shippable world. Default false. + public static bool SkipRoads = false; + + // Sea-level model (D-033, terrain-water task 04): "flat" = one scalar sea + // level everywhere (SeaLevelValue); "field" = the legacy latitude Lerp + // (0.26 north .. 0.15 south). Generator-only — the runtime never computes + // sea level. Default: flat 0.15. + public static string SeaLevelModel = "flat"; + public static float SeaLevelValue = 0.15f; + + // Height-redistribution curve (tasks 05/06, graduation M-7): "v2" applies the + // calibrated storm-ladder curve with the per-seed peak spike (HeightCurve.cs); + // "off" is the raw legacy profile. "v1" was dropped with the v2 recalibration + // (task-05 blueprints are regenerable). Biome classification is curve-invariant + // by construction either way. Default: v2. + public static string TerrainCurve = "v2"; + public static void LoadConfig() { string path = "res://ServerConfig.json"; @@ -61,7 +80,39 @@ namespace IslaApocalypse.Core // Change this if your namespace is different // Extract the ChunkRadius if (data.ContainsKey("ChunkRadius")) { ChunkRadius = (int)data["ChunkRadius"]; - } + } + + // Extract the SkipRoads iteration toggle + if (data.ContainsKey("SkipRoads")) + { + SkipRoads = (bool)data["SkipRoads"]; + } + + // Extract the sea-level model + if (data.ContainsKey("SeaLevelModel")) + { + string model = (string)data["SeaLevelModel"]; + if (model == "flat" || model == "field") + SeaLevelModel = model; + else + GD.PrintErr($"[ConfigManager] Unknown SeaLevelModel '{model}'. Keeping '{SeaLevelModel}'."); + } + if (data.ContainsKey("SeaLevelValue")) + { + SeaLevelValue = (float)data["SeaLevelValue"]; + } + + // Extract the terrain-curve gate + if (data.ContainsKey("TerrainCurve")) + { + string curve = (string)data["TerrainCurve"]; + if (curve == "off" || curve == "v2") + TerrainCurve = curve; + else if (curve == "v1") + GD.PrintErr($"[ConfigManager] TerrainCurve 'v1' was retired by the v2 recalibration (task 06). Keeping '{TerrainCurve}' — use \"v2\" or \"off\"."); + else + GD.PrintErr($"[ConfigManager] Unknown TerrainCurve '{curve}'. Keeping '{TerrainCurve}'."); + } switch (profile) { diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index 1a084fe..c40ec40 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -36,6 +36,46 @@ 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 + } + + /// + /// The height-redistribution curve that shaped this blueprint's heights (v2 TCRV + /// section, terrain-water task 05). Null when the blueprint was generated with the + /// curve off (or predates it) — heights are then the raw legacy profile. Pure + /// metadata: the server never re-applies the curve; exported heights are already + /// curved. + /// + public class TerrainCurveInfo + { + public ushort Version; + // Input knots. SpikeMax is the spike domain's top: under curve v1 it was the + // pooled calibration max (identical every seed); from v2 it is the SEED'S own + // effective raw maximum — blueprints are no longer reproducible from curve + // constants alone, which is exactly why it is recorded here. + public float T1, T2, T3, T4, SpikeMax; + public float Sea, OrangeCeil, RedCeil, PlateauLo, PlateauHi, PeakCap, TailSlope; // output bands + } + public class WorldBlueprint { public int MapSize; @@ -53,6 +93,16 @@ namespace IslaApocalypse.Core // generation parameters. Params is null when the source was a legacy v1 file. public int FormatVersion = 1; public BlueprintParams Params; + + // Water-bodies data (v2 WBID/WBTB/WSRF sections, terrain-water task 03). + // Null / empty when the source carries no water sections (legacy v1, or a v2 + // written before the water stage existed). Consumed by nothing at runtime yet. + public ushort[,] WaterBodyIds; // 0 = no water, 1 = ocean, 2..N = lakes + public List WaterBodies = new List(); + public ushort[,] WaterSurfaceQ; // quantized levels — BlueprintFormat.DecodeWaterLevel + + // The curve that shaped HeightMap (v2 TCRV section); null = raw legacy profile. + public TerrainCurveInfo TerrainCurve; } // 2. The Parser Utility @@ -172,6 +222,10 @@ namespace IslaApocalypse.Core else if (tag == BlueprintFormat.TAG_ROADS_BRANCH) sectionOk = ParseRoadTier(reader, blueprint.BranchRoads); else if (tag == BlueprintFormat.TAG_ROADS_RUGGED) sectionOk = ParseRoadTier(reader, blueprint.RuggedRoads); else if (tag == BlueprintFormat.TAG_ROADS_TRAIL) sectionOk = ParseRoadTier(reader, blueprint.TrailRoads); + else if (tag == BlueprintFormat.TAG_WATER_BODY_IDS) sectionOk = ParseWaterGrid(reader, blueprint, payloadLength, isSurface: false); + else if (tag == BlueprintFormat.TAG_WATER_SURFACE) sectionOk = ParseWaterGrid(reader, blueprint, payloadLength, isSurface: true); + else if (tag == BlueprintFormat.TAG_WATER_BODY_TABLE) sectionOk = ParseWaterBodyTable(reader, blueprint); + else if (tag == BlueprintFormat.TAG_TERRAIN_CURVE) sectionOk = ParseTerrainCurve(reader, blueprint); else { // The property the redesign exists to buy: future sections (water, @@ -294,6 +348,61 @@ namespace IslaApocalypse.Core return true; } + private static bool ParseWaterGrid(BinaryReader reader, WorldBlueprint blueprint, ulong payloadLength, bool isSurface) + { + int n = blueprint.MapSize; + string name = isSurface ? "WSRF" : "WBID"; + if (payloadLength != 2UL * (ulong)n * (ulong)n) + { + GD.PrintErr($"[MapDataParser] ERROR: {name} section is {payloadLength} bytes, expected {2UL * (ulong)n * (ulong)n}."); + return false; + } + ushort[,] grid = new ushort[n, n]; + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + grid[x, y] = reader.ReadUInt16(); + if (isSurface) blueprint.WaterSurfaceQ = grid; + else blueprint.WaterBodyIds = grid; + return true; + } + + private static bool ParseWaterBodyTable(BinaryReader reader, WorldBlueprint blueprint) + { + int count = reader.ReadInt32(); + for (int i = 0; i < count; i++) + { + var body = new WaterBodyInfo(); + body.Id = reader.ReadUInt16(); + body.Type = reader.ReadByte(); + body.Salinity = reader.ReadByte(); + body.SurfaceLevel = reader.ReadSingle(); + body.PixelCount = reader.ReadInt32(); + body.Centroid = new Vector2(reader.ReadSingle(), reader.ReadSingle()); + if (body.Type > WaterBodyInfo.TYPE_LAKE) + { + GD.PrintErr($"[MapDataParser] ERROR: water body {i} has unknown type {body.Type}."); + return false; + } + blueprint.WaterBodies.Add(body); + } + return true; + } + + private static bool ParseTerrainCurve(BinaryReader reader, WorldBlueprint blueprint) + { + var c = new TerrainCurveInfo(); + c.Version = reader.ReadUInt16(); + c.T1 = reader.ReadSingle(); c.T2 = reader.ReadSingle(); + c.T3 = reader.ReadSingle(); c.T4 = reader.ReadSingle(); + c.SpikeMax = reader.ReadSingle(); + c.Sea = reader.ReadSingle(); c.OrangeCeil = reader.ReadSingle(); + c.RedCeil = reader.ReadSingle(); c.PlateauLo = reader.ReadSingle(); + c.PlateauHi = reader.ReadSingle(); c.PeakCap = reader.ReadSingle(); + c.TailSlope = reader.ReadSingle(); + blueprint.TerrainCurve = c; + return true; + } + private static bool ParseRoadTier(BinaryReader reader, List into) { int pathCount = reader.ReadInt32(); diff --git a/Core/Scripts/README.md b/Core/Scripts/README.md index 31b56f8..d645894 100644 --- a/Core/Scripts/README.md +++ b/Core/Scripts/README.md @@ -39,10 +39,11 @@ rendering. - **`BlueprintWriter.cs`** — writes a `WorldBlueprint` as a v2 file. Blueprint-typed on purpose: the map generator and the round-trip harness are both just callers. - **`MapDataParser.cs`** — decodes a `.dat` blueprint into a `WorldBlueprint` (heightmap, biome map, - towns, four road tiers, and — v2 only — the embedded generation params and per-town highway-node - flag). Dispatches on the first byte: v2 tagged-section files get validation (version gate, size + towns, four road tiers, and — v2 only — the embedded generation params, per-town highway-node + flag, and the optional water-bodies data: `WaterBodyIds`, `WaterBodies` table, `WaterSurfaceQ`). + Dispatches on the first byte: v2 tagged-section files get validation (version gate, size bounds, section-length and ordinal range checks); legacy v1 files still load, intact, with a - deprecation warning. + deprecation warning. Nothing at runtime consumes the water data yet. - **`ChunkData.cs`** — one chunk's density and block-ID fields, `+1` padded on every axis so the mesher can reach into the neighbouring chunk, plus the per-column data the renderer needs. - **`Constants.cs`** — chunk dimensions, `ISO_LEVEL`, `VOXEL_SCALE`, and the visual/road tunables diff --git a/ServerConfig.json b/ServerConfig.json index b9af832..7949049 100644 --- a/ServerConfig.json +++ b/ServerConfig.json @@ -2,5 +2,7 @@ "WorldSeed": 1409879727, "MapProfile": "8K", "TownDensity": "Normal", - "ChunkRadius": 32 + "ChunkRadius": 32, + "SeaLevelModel": "flat", + "SeaLevelValue": 0.15 } diff --git a/Tools/README.md b/Tools/README.md index 05fa653..0166168 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -17,6 +17,10 @@ Builds the topographical and logistical foundation of the island before any 3D e - **Topography:** FastNoiseLite plus distance-falloff maths for a guaranteed mainland island, a mountain spine, dynamic temperature-driven sea level, and an impact crater pushed into the northern coast (carved at 80 % of its radius so a landbridge always survives). +- **Water bodies:** the classified water promoted into explicit blueprint data (ocean + labeled + lakes with transitional per-body levels — `WBID`/`WBTB`/`WSRF` sections), plus priority-flood + basin diagnostics. The `SkipRoads` config toggle turns a full ~26-min generation into a ~80 s + road-less iteration cycle. - **Logistics:** `AStarGrid2D` pathfinding producing a looping continental highway, a branch to the mountain hub, and county roads daisy-chained outward with Prim's algorithm. Paths are decimated (Ramer–Douglas–Peucker) then smoothed (4 Chaikin passes). diff --git a/Tools/Scripts/HeightCurve.cs b/Tools/Scripts/HeightCurve.cs new file mode 100644 index 0000000..bced8b8 --- /dev/null +++ b/Tools/Scripts/HeightCurve.cs @@ -0,0 +1,135 @@ +using Godot; + +/// +/// The height-redistribution curve, v2 (terrain-water task 06) — a pure, static, +/// monotonic piecewise map over raw blueprint heights (D-035: numbers in, numbers +/// out; the per-seed spike maximum is an explicit PARAMETER, not hidden state). +/// +/// v2 changes (developer's task-05 hillshade-gate verdict; everything below the +/// plateau step is behaviorally byte-identical to v1): +/// - PER-SEED SPIKE NORMALIZATION: the spike's input domain runs from t4 to the +/// current seed's own raw pre-curve maximum (hMaxSeed), so every island's +/// tallest pixel reaches the ceiling — v1 mapped against the pooled +/// calibration max and mid-range seeds topped out at 110–175 m. +/// - STIFFER SPIKE: ease-in 0.1u + 0.9·u⁴ (was 0.2u + 0.8·u³) — a wall, not a ramp. +/// - PEAK CEILING 420 m above sea (was 220 m). +/// +/// Lower knots/bands are v1's, calibrated 2026-08-07 from batch 04's ten flat-sea +/// heightmaps (pooled above-sea land CDF, 340,618,126 samples): P75/P90/P93/P96. +/// +/// Shape (strictly monotonic; every segment's normalized slope bounded below by a +/// positive constant; asserted numerically per generation against the EFFECTIVE +/// per-seed curve once hMaxSeed is known): +/// h ≤ sea (0.15) identity — water and the below-sea world untouched +/// sea → t1 smooth toe, ease-out blend (gentle rolling, never flat) +/// t1 → t2 linear rise into the red band +/// t2 → t3 smooth shoulder up to the plateau shelf +/// t3 → t4 near-flat plateau step (small positive slope) +/// t4 → spikeMax accelerating u⁴ spike to the 420 m peak cap (per-seed domain) +/// h > spikeMax linear tail (strict monotonicity, no clamp; reachable only +/// in the degenerate near-flat guard case) +/// +public static class HeightCurve +{ + public const ushort VERSION = 2; + + // Input knots — v1 calibration, unchanged (see class header). + public const float T1 = 0.628736f; // P75 — orange coverage boundary + public const float T2 = 0.819152f; // P90 + public const float T3 = 0.879340f; // P93 + public const float T4 = 0.962922f; // P96 + + // Output bands — the storm ladder. Lower anchors unchanged from v1. + public const float SEA = 0.15f; + public const float ORANGE_CEIL = 0.206f; // 1000-yr storm ceiling + public const float RED_CEIL = 0.27f; // biblical ceiling + public const float PLATEAU_LO = SEA + 50f / 251f; // ≈ 0.34924 (50 m above sea) + public const float PLATEAU_HI = PLATEAU_LO + 0.02f; // ≈ 0.36924 (~5 m step relief) + public const float PEAK_CAP = SEA + 420f / 251f; // ≈ 1.82869 (420 m above sea; v1: 220 m) + public const float TAIL_SLOPE = 0.25f; // above spikeMax (degenerate guard only) + + // Degenerate/near-flat guard: the spike domain is [T4, max(hMaxSeed, T4 + SPIKE_MIN_SPAN)], + // so a pathological seed whose raw max sits at or below t4 still yields a positive, + // monotonic domain (its cap is then simply never reached; heights above spikeMax — none in + // practice — would ride the tail). + public const float SPIKE_MIN_SPAN = 0.01f; + + /// The effective spike-domain top for a seed's raw maximum, guard applied. + public static float EffectiveSpikeMax(float hMaxSeed) + { + return Mathf.Max(hMaxSeed, T4 + SPIKE_MIN_SPAN); + } + + /// Raw pre-curve height. + /// The seed's raw pre-curve maximum (post noise/falloff/Trench/spine, + /// pre-carve) — the same field the curve consumes. Makes the map seed-dependent (v2). + public static float Apply(float h, float hMaxSeed) + { + if (h <= SEA) return h; + + float u, s; + if (h < T1) + { + u = (h - SEA) / (T1 - SEA); + s = 0.3f * u + 0.7f * (u * (2f - u)); // ease-out, slope ≥ 0.3 + return SEA + s * (ORANGE_CEIL - SEA); + } + if (h < T2) + { + u = (h - T1) / (T2 - T1); + return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // linear + } + if (h < T3) + { + u = (h - T2) / (T3 - T2); + s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // smoothstep blend, slope ≥ 0.2 + return RED_CEIL + s * (PLATEAU_LO - RED_CEIL); + } + if (h < T4) + { + u = (h - T3) / (T4 - T3); + return PLATEAU_LO + u * (PLATEAU_HI - PLATEAU_LO); // near-flat, small positive slope + } + float spikeMax = EffectiveSpikeMax(hMaxSeed); + if (h < spikeMax) + { + u = (h - T4) / (spikeMax - T4); + s = 0.1f * u + 0.9f * (u * u * u * u); // ease-in u⁴ wall, slope ≥ 0.1 + return PLATEAU_HI + s * (PEAK_CAP - PLATEAU_HI); + } + return PEAK_CAP + (h - spikeMax) * TAIL_SLOPE; + } + + /// + /// Numeric strict-monotonicity check of the EFFECTIVE per-seed curve — call once + /// per generation after hMaxSeed is known, before the curve pass. A violation is + /// a build bug, not a data condition — fail loudly and refuse to generate. + /// + public static void AssertMonotonic(float hMaxSeed) + { + float prevH = -7f; + float prev = Apply(prevH, hMaxSeed); + + // Successive double samples can round to the SAME float32 — only strictly + // increasing float samples are compared (task-05 incident fix, kept). + void Check(double hd) + { + float h = (float)hd; + if (h <= prevH) return; + float v = Apply(h, hMaxSeed); + if (v <= prev) + throw new System.InvalidOperationException( + $"[HeightCurve] MONOTONICITY VIOLATION at h={h} (hMaxSeed={hMaxSeed}): {v} <= {prev}. Refusing to generate."); + prev = v; + prevH = h; + } + + // Coarse below the identity region, fine through every knot, out past the + // per-seed spike top and the tail. + double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed) + 0.5); + for (double h = -7.0 + 0.01; h < 0.10; h += 0.01) Check(h); + for (double h = 0.10; h <= top; h += 0.0001) Check(h); + for (double h = top + 0.05; h <= top + 6.0; h += 0.05) Check(h); + GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION}, effective spikeMax {EffectiveSpikeMax(hMaxSeed):F6})."); + } +} diff --git a/Tools/Scripts/HeightCurve.cs.uid b/Tools/Scripts/HeightCurve.cs.uid new file mode 100644 index 0000000..c9c7559 --- /dev/null +++ b/Tools/Scripts/HeightCurve.cs.uid @@ -0,0 +1 @@ +uid://ljv7x1fwm3os diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index cd75452..5206a53 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -26,12 +26,34 @@ public partial class MapGenerator : TextureRect private float _impactRadius; private float[,] _heightMap; + + // Classification heightmap (task 05): the UNCURVED heights (plus the crater + // carve), i.e. exactly what the curve-off pipeline produces. Biome rules, the + // two flood fills, and the shared water predicates read THIS map, so biome and + // water output is identical with the curve on or off — the bit-identical-biomes + // oracle holds by construction. Towns, roads, diagnostics, and the exported + // heights use the curved _heightMap (they live in the 3D world). When the curve + // is off this is the SAME array as _heightMap (aliased, no copy). + private float[,] _heightMapClassify; + private bool _curveOn; + + // The seed's raw pre-curve height maximum (post noise/falloff/Trench/spine, + // pre-carve) — the v2 curve's per-seed spike normalizer. Computed in + // GenerateTopography pass 1; recorded in TCRV (effective, guard applied). + private float _hMaxSeed = float.MinValue; + 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(); @@ -53,6 +75,10 @@ public partial class MapGenerator : TextureRect this.CustomMinimumSize = new Vector2(MapSize, MapSize); _heightMap = new float[MapSize, MapSize]; + _curveOn = ConfigManager.TerrainCurve == "v2"; + // (The monotonicity assertion now runs inside GenerateTopography, against the + // effective per-seed curve, once hMaxSeed is known.) + _heightMapClassify = _curveOn ? new float[MapSize, MapSize] : _heightMap; _tempMap = new float[MapSize, MapSize]; _biomeMap = new Biome[MapSize, MapSize]; _isTrueOcean = new bool[MapSize, MapSize]; @@ -78,12 +104,19 @@ public partial class MapGenerator : TextureRect // A future water stage slots in as another CaptureStage call at its own boundary. GenerateTopography(); - GD.Print($"{T()} Topography done (height + temperature)."); + GD.Print($"{T()} Topography done (height + temperature). TerrainCurve: {(_curveOn ? "v1" : "off")}."); + + DrawHeightStageTexture(); + await CaptureStage("0_height"); CalculateTrueOcean(); 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"); @@ -92,13 +125,28 @@ public partial class MapGenerator : TextureRect GD.Print($"{T()} Towns placed: {_towns.Count}."); await CaptureStage("2_towns"); - // NEW: We await the roads so the engine doesn't freeze! - await GenerateRoadsAsync(); + if (ConfigManager.SkipRoads) + { + // Iteration toggle (terrain-water task 03): the road pass is ~25 min of a + // ~26-min generation. Skipping it leaves all four road lists empty, so the + // export writes present-but-empty road sections. + GD.Print("=========================================================="); + GD.Print($"{T()} ⚠⚠ SKIPROADS IS ON — ROAD GENERATION SKIPPED ENTIRELY."); + GD.Print($"{T()} ⚠⚠ This blueprint has NO ROADS. It is an ITERATION"); + GD.Print($"{T()} ⚠⚠ ARTIFACT, not a world. Do not judge or ship it."); + GD.Print("=========================================================="); + } + else + { + // NEW: We await the roads so the engine doesn't freeze! + await GenerateRoadsAsync(); + } ExportMapData(); GD.Print($"{T()} Blueprint exported."); - await CaptureStage("3_roads"); + if (!ConfigManager.SkipRoads) + await CaptureStage("3_roads"); GD.Print($"{T()} GENERATION COMPLETE."); } @@ -206,6 +254,17 @@ public partial class MapGenerator : TextureRect BranchRoads = _branchPaths, RuggedRoads = _ruggedPaths, TrailRoads = _trailPaths, + WaterBodyIds = _waterBodyIds, + WaterBodies = _waterBodies ?? new List(), + TerrainCurve = _curveOn ? new TerrainCurveInfo + { + Version = HeightCurve.VERSION, + T1 = HeightCurve.T1, T2 = HeightCurve.T2, T3 = HeightCurve.T3, T4 = HeightCurve.T4, + SpikeMax = HeightCurve.EffectiveSpikeMax(_hMaxSeed), // per-seed (v2) + Sea = HeightCurve.SEA, OrangeCeil = HeightCurve.ORANGE_CEIL, RedCeil = HeightCurve.RED_CEIL, + PlateauLo = HeightCurve.PLATEAU_LO, PlateauHi = HeightCurve.PLATEAU_HI, + PeakCap = HeightCurve.PEAK_CAP, TailSlope = HeightCurve.TAIL_SLOPE + } : null, FormatVersion = 2, Params = new BlueprintParams { @@ -348,23 +407,51 @@ public partial class MapGenerator : TextureRect } // --- 4. COMBINE HEIGHT --- + // PASS 1 stores the RAW pre-curve height and tracks the seed maximum; + // the curve (which is per-seed in v2 — its spike normalizes against + // hMaxSeed) and the crater carve are applied in PASS 2 below. float rawBase = (_noise.GetNoise2D(x, y) + 1.0f) / 2.0f; float finalH = rawBase + mountainSpine - (finalFalloff * FalloffStrength); + if (finalH > _hMaxSeed) _hMaxSeed = finalH; + _heightMap[x, y] = finalH; + } + } + // The v2 curve is SEED-DEPENDENT: its spike maps [t4, hMaxSeed] onto the peak + // band, so the monotonicity assertion must run against the EFFECTIVE per-seed + // curve — after hMaxSeed is known, before any pixel is curved. + if (_curveOn) HeightCurve.AssertMonotonic(_hMaxSeed); + + // --- PASS 2: curve (task 05/06) + crater carve --- + // Curve applied AFTER noise + falloff + Trench, BEFORE the crater carve, so + // the carve cuts into curved terrain and the rim/bowl shape is untouched by + // the curve. Identity at and below sea + this ordering preserve the + // Trench/ocean-border guarantee and the crater by construction. classifyH + // stays uncurved — see _heightMapClassify; hMaxSeed never touches it. + float physicalCraterRadius = _impactRadius * 0.80f; + for (int x = 0; x < MapSize; x++) + { + for (int y = 0; y < MapSize; y++) + { + float raw = _heightMap[x, y]; + float classifyH = raw; + float curvedH = _curveOn ? HeightCurve.Apply(raw, _hMaxSeed) : raw; // --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) --- float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter); - + // We only carve the physical hole at 80% of the radius to guarantee a landbridge! - float physicalCraterRadius = _impactRadius * 0.80f; if (distToCrater < physicalCraterRadius) { - float craterDepth = 1.0f - (distToCrater / physicalCraterRadius); + float craterDepth = 1.0f - (distToCrater / physicalCraterRadius); // Dialed back to -0.15f as per your excellent instinct! - finalH = Mathf.Lerp(finalH, GetSeaLevel(temperature) - 0.15f, craterDepth * 0.9f); + float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f; + classifyH = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f); + curvedH = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f); } - - _heightMap[x, y] = finalH; + + _heightMapClassify[x, y] = classifyH; + _heightMap[x, y] = curvedH; } } } @@ -385,7 +472,7 @@ public partial class MapGenerator : TextureRect Vector2I neighbor = current + dir; if (neighbor.X >= 0 && neighbor.X < MapSize && neighbor.Y >= 0 && neighbor.Y < MapSize) { - if (!_isTrueOcean[neighbor.X, neighbor.Y] && _heightMap[neighbor.X, neighbor.Y] < GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) + if (!_isTrueOcean[neighbor.X, neighbor.Y] && _heightMapClassify[neighbor.X, neighbor.Y] < GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) { _isTrueOcean[neighbor.X, neighbor.Y] = true; queue.Enqueue(neighbor); @@ -400,7 +487,7 @@ public partial class MapGenerator : TextureRect Queue queue = new Queue(); Vector2I center = new Vector2I(MapSize / 2, MapSize / 2); - if (_heightMap[center.X, center.Y] >= GetSeaLevel(_tempMap[center.X, center.Y])) + if (_heightMapClassify[center.X, center.Y] >= GetSeaLevel(_tempMap[center.X, center.Y])) { queue.Enqueue(center); _isMainland[center.X, center.Y] = true; @@ -416,7 +503,7 @@ public partial class MapGenerator : TextureRect Vector2I neighbor = current + dir; if (neighbor.X >= 0 && neighbor.X < MapSize && neighbor.Y >= 0 && neighbor.Y < MapSize) { - if (!_isMainland[neighbor.X, neighbor.Y] && _heightMap[neighbor.X, neighbor.Y] >= GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) + if (!_isMainland[neighbor.X, neighbor.Y] && _heightMapClassify[neighbor.X, neighbor.Y] >= GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) { _isMainland[neighbor.X, neighbor.Y] = true; queue.Enqueue(neighbor); @@ -426,6 +513,358 @@ 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) => _heightMapClassify[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 height-stage snapshot (task 05): hypsometric tint by storm-ladder + /// band × Lambert hillshade from the height gradient (NW light), over the CURVED + /// heights — this is the snapshot that makes relief visible. Runs in both curve + /// modes; with the curve off it shows the legacy profile under the same bands. + /// Pure numeric pass over the heightmap. + /// + private void DrawHeightStageTexture() + { + Image img = Image.CreateEmpty(MapSize, MapSize, false, Image.Format.Rgba8); + Vector3 light = new Vector3(-0.55f, -0.55f, 0.63f).Normalized(); // NW, ~39° up + + Color deepSea = new Color(0.07f, 0.15f, 0.32f); + Color shallowSea = new Color(0.25f, 0.45f, 0.65f); + Color green = new Color(0.44f, 0.62f, 0.36f); // orange band terrain: lowland green + Color tan = new Color(0.76f, 0.70f, 0.46f); // red band: tan + Color brown = new Color(0.55f, 0.41f, 0.28f); // shoulder + plateau: brown + Color white = new Color(0.97f, 0.97f, 0.98f); // peaks + Color grey = new Color(0.72f, 0.72f, 0.70f); + + int n = MapSize; + for (int x = 0; x < n; x++) + { + for (int y = 0; y < n; y++) + { + float h = _heightMap[x, y]; + float sea = GetSeaLevel(_tempMap[x, y]); + + Color tint; + if (h < sea) + { + float depth = Mathf.Clamp((sea - h) / 0.5f, 0f, 1f); + tint = shallowSea.Lerp(deepSea, depth); + } + else if (h < HeightCurve.ORANGE_CEIL) tint = green; + else if (h < HeightCurve.RED_CEIL) tint = tan; + else if (h < HeightCurve.PLATEAU_HI) tint = brown; + else + { + float t2 = Mathf.Clamp((h - HeightCurve.PLATEAU_HI) / (HeightCurve.PEAK_CAP - HeightCurve.PLATEAU_HI), 0f, 1f); + tint = grey.Lerp(white, t2); + } + + // Lambert hillshade on the world-scale gradient (1 px = 1 m, height ×251 m). + int xm = x > 0 ? x - 1 : x, xp = x < n - 1 ? x + 1 : x; + int ym = y > 0 ? y - 1 : y, yp = y < n - 1 ? y + 1 : y; + float gx = (_heightMap[xp, y] - _heightMap[xm, y]) * 251f / (xp - xm == 0 ? 1 : xp - xm); + float gy = (_heightMap[x, yp] - _heightMap[x, ym]) * 251f / (yp - ym == 0 ? 1 : yp - ym); + Vector3 nrm = new Vector3(-gx, -gy, 1f).Normalized(); + float shade = Mathf.Clamp(nrm.Dot(light), 0f, 1f); + + Color c = tint * (0.35f + 0.65f * shade); + c.A = 1f; + img.SetPixel(x, y, c); + } + } + Texture = ImageTexture.CreateFromImage(img); + } + + /// + /// 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); @@ -433,13 +872,21 @@ public partial class MapGenerator : TextureRect { for (int y = 0; y < MapSize; y++) { - float h = _heightMap[x, y]; + // Biome rules classify against the UNCURVED heights (task 05) — the + // bit-identical-biomes oracle. Beach/mountain/snow bands and the + // water split all read the classify map. + float h = _heightMapClassify[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); @@ -685,12 +1132,20 @@ public partial class MapGenerator : TextureRect if (t < minTemp || t > maxTemp) continue; float centerH = _heightMap[rx, ry]; - // Sample slope a bit wider (10px) due to higher resolution + // Sample slope a bit wider (10px) due to higher resolution. + // Samples are clamped to map bounds (task 05): candidates spawn at + // [20, MapSize-20] but slopeRadius is 40 at 8K, so the unclamped reads + // were out of bounds near the border — previously unreachable only + // because border land stayed underwater and failed the sea test first. int slopeRadius = (int)(MapSize * 0.005f); // Automatically scales! - if (Mathf.Abs(_heightMap[rx+slopeRadius, ry] - centerH) > maxSlope) continue; - if (Mathf.Abs(_heightMap[rx-slopeRadius, ry] - centerH) > maxSlope) continue; - if (Mathf.Abs(_heightMap[rx, ry+slopeRadius] - centerH) > maxSlope) continue; - if (Mathf.Abs(_heightMap[rx, ry-slopeRadius] - centerH) > maxSlope) continue; + int sxHi = Mathf.Clamp(rx + slopeRadius, 0, MapSize - 1); + int sxLo = Mathf.Clamp(rx - slopeRadius, 0, MapSize - 1); + int syHi = Mathf.Clamp(ry + slopeRadius, 0, MapSize - 1); + int syLo = Mathf.Clamp(ry - slopeRadius, 0, MapSize - 1); + if (Mathf.Abs(_heightMap[sxHi, ry] - centerH) > maxSlope) continue; + if (Mathf.Abs(_heightMap[sxLo, ry] - centerH) > maxSlope) continue; + if (Mathf.Abs(_heightMap[rx, syHi] - centerH) > maxSlope) continue; + if (Mathf.Abs(_heightMap[rx, syLo] - centerH) > maxSlope) continue; bool nearTrueOcean = false; bool nearAnyWater = false; @@ -887,7 +1342,13 @@ public partial class MapGenerator : TextureRect return astar.GetPointPath(from, to); } - private float GetSeaLevel(float t) => Mathf.Lerp(0.26f, 0.15f, Mathf.Clamp(t, 0f, 1f)); + // Sea-level model gate (D-033, task 04): "flat" returns the configured scalar; + // "field" is the legacy latitude Lerp. Pure numeric mapping either way (D-035); + // all nine call sites inherit whichever model config selects. + private float GetSeaLevel(float t) => + ConfigManager.SeaLevelModel == "flat" + ? ConfigManager.SeaLevelValue + : Mathf.Lerp(0.26f, 0.15f, Mathf.Clamp(t, 0f, 1f)); private Vector2I FindClosestPixel(Vector2 pos, HashSet set) { Vector2I closest = new Vector2I(0,0); float min = float.MaxValue; diff --git a/Tools/Scripts/README.md b/Tools/Scripts/README.md index deaf7c8..0fb6401 100644 --- a/Tools/Scripts/README.md +++ b/Tools/Scripts/README.md @@ -11,14 +11,29 @@ Generates the entire 2D blueprint. Roughly in order: 2. **Topography** — FastNoiseLite base height plus a mountain spine, minus a squircle distance falloff, giving a guaranteed island. Noise frequency is divided by `scaleFactor` (`MapSize / 1024f`) so terrain features stay the same real-world size at any map profile. -3. **Sea level and water** — temperature-driven sea level; flood fill separates true ocean from - inland lakes; a mainland fill guarantees one contiguous landmass. + When `TerrainCurve: "v2"` (the default), the calibrated height-redistribution curve + (`HeightCurve.cs` — flat farmable lowlands, 50 m plateau shelf, and a per-seed-normalized + spike to the 420 m peak cap: every island's tallest point reaches the ceiling) reshapes + above-sea terrain after noise/falloff/Trench and before the crater carve; biome + classification reads a retained uncurved map, so biomes are identical either way. Drops the + `0_height` hillshade snapshot (hypsometric bands × NW hillshade) in both modes. +3. **Sea level and water** — sea level per the configured model (`SeaLevelModel`: `"flat"` scalar + — the default, `SeaLevelValue` 0.15 — or the legacy `"field"` latitude Lerp); flood fill + separates true ocean from inland lakes; a mainland fill guarantees one contiguous landmass. 4. **The crater** — placed along the northern coast and carved to below sea level, but only out to **80 % of its radius**, which guarantees a landbridge rather than severing the island. -5. **Biomes and towns** — biome zoning by height and temperature; tiered town placement (Capitol, +5. **Water bodies** — promotes the classified water into explicit data: the ocean as body 1, each + lake connected-component labeled (same 4-connectivity as the true-ocean fill), one transitional + surface level per body. Classification comes from the shared `IsOceanPixel`/`IsLakePixel` + predicates that the biome stage also uses, so the two can never disagree. A priority-flood + pit-fill runs here as validated diagnostics (basin statistics to console; serializes nothing). + Drops the `0_water` snapshot. +6. **Biomes and towns** — biome zoning by height and temperature; tiered town placement (Capitol, Hubs, Villages, Outposts, POIs) filtered by slope, water proximity and spacing. -6. **Roads** — see below. -7. **Export** — writes the `.dat` blueprint (dual-write: the v2 tagged container under the primary +7. **Roads** — see below. Skipped entirely when the `SkipRoads` config toggle is on (the ~25-min + A\* pass is the bottleneck; a skip run exports a road-less iteration blueprint in ~80 s with a + loud console banner). +8. **Export** — writes the `.dat` blueprint (dual-write: the v2 tagged container under the primary seed name, plus the legacy v1 format beside it as `_v1.dat` — see `Core/Scripts/BLUEPRINT_FORMAT.md`), then renders the PNG snapshot. The v2 file embeds the resolved generation params (seed, MapSize, crater radius, density, impact centre, provenance). @@ -55,8 +70,11 @@ Road colours on the snapshot, useful for identifying a road: **red** = Highway, **dark brown** = Rugged, **light brown** = Trail. ### Outputs -`MapData_Seed_.dat` (v2), `MapData_Seed__v1.dat` (legacy dual-write), and -`Map_Seed_.png`, all to `user://`. +`MapData_Seed_.dat` (v2), `MapData_Seed__v1.dat` (legacy dual-write), and the staged +snapshots `Map_Seed__{0_height,0_water,1_biomes,2_towns,3_roads}.png`, all to `user://`. +(`0_height` is the hypsometric hillshade of the curved terrain; `0_water` is painted from the +water stage's own outputs — ocean deep blue, lakes lighter blue, land neutral; `3_roads` is +absent on a SkipRoads run.) ## `RoundTripHarness.cs` diff --git a/Tools/Scripts/RoundTripHarness.cs b/Tools/Scripts/RoundTripHarness.cs index bc0c6db..7b2393a 100644 --- a/Tools/Scripts/RoundTripHarness.cs +++ b/Tools/Scripts/RoundTripHarness.cs @@ -119,6 +119,8 @@ public partial class RoundTripHarness : Node ok &= CompareRoads("Branch", a.BranchRoads, b.BranchRoads); ok &= CompareRoads("Rugged", a.RuggedRoads, b.RuggedRoads); ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads); + ok &= CompareWater(a, b); + ok &= CompareTerrainCurve(a, b); if (ok) GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " + @@ -127,6 +129,86 @@ public partial class RoundTripHarness : Node return ok; } + // Water sections (WBID/WBTB/WSRF) are compared whenever the SOURCE carries them — + // the regression test grows with the format. A source without water (legacy v1) + // must round-trip to a file without water. + private bool CompareWater(WorldBlueprint a, WorldBlueprint b) + { + if (a.WaterBodyIds == null && b.WaterBodyIds == null) + { + GD.Print("[Harness] Water sections: absent in source — nothing to compare (and none reappeared)."); + return true; + } + if (a.WaterBodyIds == null || b.WaterBodyIds == null) + { + GD.PrintErr($"[Harness] Water sections presence mismatch: source {(a.WaterBodyIds != null ? "has" : "lacks")} them, reread {(b.WaterBodyIds != null ? "has" : "lacks")} them."); + return false; + } + + bool ok = true; + long idDiffs = 0, surfDiffs = 0; + int n = a.MapSize; + for (int x = 0; x < n; x++) + { + for (int y = 0; y < n; y++) + { + if (a.WaterBodyIds[x, y] != b.WaterBodyIds[x, y]) idDiffs++; + ushort sa = a.WaterSurfaceQ != null ? a.WaterSurfaceQ[x, y] : (ushort)0; + ushort sb = b.WaterSurfaceQ != null ? b.WaterSurfaceQ[x, y] : (ushort)0; + if (sa != sb) surfDiffs++; + } + } + if (idDiffs > 0) { GD.PrintErr($"[Harness] {idDiffs} WBID pixels differ."); ok = false; } + if (surfDiffs > 0) { GD.PrintErr($"[Harness] {surfDiffs} WSRF pixels differ."); ok = false; } + + if (a.WaterBodies.Count != b.WaterBodies.Count) + { + GD.PrintErr($"[Harness] Water body count mismatch: {a.WaterBodies.Count} vs {b.WaterBodies.Count}"); + ok = false; + } + else + { + for (int i = 0; i < a.WaterBodies.Count; i++) + { + var wa = a.WaterBodies[i]; + var wb = b.WaterBodies[i]; + if (wa.Id != wb.Id || wa.Type != wb.Type || wa.Salinity != wb.Salinity || + System.BitConverter.SingleToInt32Bits(wa.SurfaceLevel) != System.BitConverter.SingleToInt32Bits(wb.SurfaceLevel) || + wa.PixelCount != wb.PixelCount || wa.Centroid != wb.Centroid) + { + GD.PrintErr($"[Harness] Water body {i} mismatch (id {wa.Id} vs {wb.Id})."); + ok = false; + } + } + } + + if (ok) GD.Print($"[Harness] Water sections equal: {a.WaterBodies.Count} bodies, WBID+WSRF grids identical."); + return ok; + } + + private bool CompareTerrainCurve(WorldBlueprint a, WorldBlueprint b) + { + if (a.TerrainCurve == null && b.TerrainCurve == null) + { + GD.Print("[Harness] TCRV: absent in source — nothing to compare (and none reappeared)."); + return true; + } + if (a.TerrainCurve == null || b.TerrainCurve == null) + { + GD.PrintErr("[Harness] TCRV presence mismatch between source and reread."); + return false; + } + var ca = a.TerrainCurve; var cb = b.TerrainCurve; + bool same = ca.Version == cb.Version; + float[] fa = { ca.T1, ca.T2, ca.T3, ca.T4, ca.SpikeMax, ca.Sea, ca.OrangeCeil, ca.RedCeil, ca.PlateauLo, ca.PlateauHi, ca.PeakCap, ca.TailSlope }; + float[] fb = { cb.T1, cb.T2, cb.T3, cb.T4, cb.SpikeMax, cb.Sea, cb.OrangeCeil, cb.RedCeil, cb.PlateauLo, cb.PlateauHi, cb.PeakCap, cb.TailSlope }; + for (int i = 0; i < fa.Length; i++) + if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i])) same = false; + if (!same) { GD.PrintErr("[Harness] TCRV fields differ."); return false; } + GD.Print($"[Harness] TCRV equal (curve v{ca.Version})."); + return true; + } + private bool CompareRoads(string tier, List a, List b) { if (a.Count != b.Count)