diff --git a/Core/README.md b/Core/README.md index 7654e74..8e5f52d 100644 --- a/Core/README.md +++ b/Core/README.md @@ -17,9 +17,7 @@ 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. 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. + the file, biome/tier ordinal ranges. - **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 87eb253..b857c3f 100644 --- a/Core/Scripts/BLUEPRINT_FORMAT.md +++ b/Core/Scripts/BLUEPRINT_FORMAT.md @@ -55,11 +55,7 @@ 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. | -| `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. | +| `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. | | `RDBR` / `0x52424452` | same layout | Branch tier. | | `RDRG` / `0x47524452` | same layout | Rugged tier. | | `RDTL` / `0x4C544452` | same layout | Trail tier. | @@ -89,38 +85,9 @@ the file, not the original generation. ### Size -`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). +`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. ### Adding a new section (the intended extension path) diff --git a/Core/Scripts/BlueprintFormat.cs b/Core/Scripts/BlueprintFormat.cs index 99d4597..153e166 100644 --- a/Core/Scripts/BlueprintFormat.cs +++ b/Core/Scripts/BlueprintFormat.cs @@ -29,27 +29,6 @@ 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 56bc07c..d904590 100644 --- a/Core/Scripts/BlueprintWriter.cs +++ b/Core/Scripts/BlueprintWriter.cs @@ -31,20 +31,8 @@ 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)); @@ -124,71 +112,6 @@ 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 a28342c..a9de115 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -14,25 +14,6 @@ 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"; @@ -80,39 +61,7 @@ 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 c40ec40..1a084fe 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -36,46 +36,6 @@ 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; @@ -93,16 +53,6 @@ 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 @@ -222,10 +172,6 @@ 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, @@ -348,61 +294,6 @@ 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 d645894..31b56f8 100644 --- a/Core/Scripts/README.md +++ b/Core/Scripts/README.md @@ -39,11 +39,10 @@ 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, 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 + 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 bounds, section-length and ordinal range checks); legacy v1 files still load, intact, with a - deprecation warning. Nothing at runtime consumes the water data yet. + deprecation warning. - **`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 7949049..b9af832 100644 --- a/ServerConfig.json +++ b/ServerConfig.json @@ -2,7 +2,5 @@ "WorldSeed": 1409879727, "MapProfile": "8K", "TownDensity": "Normal", - "ChunkRadius": 32, - "SeaLevelModel": "flat", - "SeaLevelValue": 0.15 + "ChunkRadius": 32 } diff --git a/Tools/README.md b/Tools/README.md index 0166168..05fa653 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -17,10 +17,6 @@ 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 deleted file mode 100644 index bced8b8..0000000 --- a/Tools/Scripts/HeightCurve.cs +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index c9c7559..0000000 --- a/Tools/Scripts/HeightCurve.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ljv7x1fwm3os diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index 5206a53..cd75452 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -26,34 +26,12 @@ 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(); @@ -75,10 +53,6 @@ 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]; @@ -104,19 +78,12 @@ 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). TerrainCurve: {(_curveOn ? "v1" : "off")}."); - - DrawHeightStageTexture(); - await CaptureStage("0_height"); + GD.Print($"{T()} Topography done (height + temperature)."); 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"); @@ -125,28 +92,13 @@ public partial class MapGenerator : TextureRect GD.Print($"{T()} Towns placed: {_towns.Count}."); await CaptureStage("2_towns"); - 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(); - } + // NEW: We await the roads so the engine doesn't freeze! + await GenerateRoadsAsync(); ExportMapData(); GD.Print($"{T()} Blueprint exported."); - if (!ConfigManager.SkipRoads) - await CaptureStage("3_roads"); + await CaptureStage("3_roads"); GD.Print($"{T()} GENERATION COMPLETE."); } @@ -254,17 +206,6 @@ 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 { @@ -407,51 +348,23 @@ 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! - float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f; - classifyH = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f); - curvedH = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f); + finalH = Mathf.Lerp(finalH, GetSeaLevel(temperature) - 0.15f, craterDepth * 0.9f); } - - _heightMapClassify[x, y] = classifyH; - _heightMap[x, y] = curvedH; + + _heightMap[x, y] = finalH; } } } @@ -472,7 +385,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] && _heightMapClassify[neighbor.X, neighbor.Y] < GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) + if (!_isTrueOcean[neighbor.X, neighbor.Y] && _heightMap[neighbor.X, neighbor.Y] < GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) { _isTrueOcean[neighbor.X, neighbor.Y] = true; queue.Enqueue(neighbor); @@ -487,7 +400,7 @@ public partial class MapGenerator : TextureRect Queue queue = new Queue(); Vector2I center = new Vector2I(MapSize / 2, MapSize / 2); - if (_heightMapClassify[center.X, center.Y] >= GetSeaLevel(_tempMap[center.X, center.Y])) + if (_heightMap[center.X, center.Y] >= GetSeaLevel(_tempMap[center.X, center.Y])) { queue.Enqueue(center); _isMainland[center.X, center.Y] = true; @@ -503,7 +416,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] && _heightMapClassify[neighbor.X, neighbor.Y] >= GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) + if (!_isMainland[neighbor.X, neighbor.Y] && _heightMap[neighbor.X, neighbor.Y] >= GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) { _isMainland[neighbor.X, neighbor.Y] = true; queue.Enqueue(neighbor); @@ -513,358 +426,6 @@ 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); @@ -872,21 +433,13 @@ public partial class MapGenerator : TextureRect { for (int y = 0; y < MapSize; 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 h = _heightMap[x, y]; float t = _tempMap[x, y]; Biome b; - + float currentSeaLevel = GetSeaLevel(t); - // 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; + if (h < currentSeaLevel) b = _isTrueOcean[x, y] ? Biome.Ocean : Biome.Lake; else { float baseDist = new Vector2(x, y).DistanceTo(_impactCenter); @@ -1132,20 +685,12 @@ 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. - // 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. + // Sample slope a bit wider (10px) due to higher resolution int slopeRadius = (int)(MapSize * 0.005f); // Automatically scales! - 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; + 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; bool nearTrueOcean = false; bool nearAnyWater = false; @@ -1342,13 +887,7 @@ public partial class MapGenerator : TextureRect return astar.GetPointPath(from, to); } - // 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 float GetSeaLevel(float t) => 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 0fb6401..deaf7c8 100644 --- a/Tools/Scripts/README.md +++ b/Tools/Scripts/README.md @@ -11,29 +11,14 @@ 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. - 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. +3. **Sea level and water** — temperature-driven sea level; 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. **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, +5. **Biomes and towns** — biome zoning by height and temperature; tiered town placement (Capitol, Hubs, Villages, Outposts, POIs) filtered by slope, water proximity and spacing. -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 +6. **Roads** — see below. +7. **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). @@ -70,11 +55,8 @@ 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 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.) +`MapData_Seed_.dat` (v2), `MapData_Seed__v1.dat` (legacy dual-write), and +`Map_Seed_.png`, all to `user://`. ## `RoundTripHarness.cs` diff --git a/Tools/Scripts/RoundTripHarness.cs b/Tools/Scripts/RoundTripHarness.cs index 7b2393a..bc0c6db 100644 --- a/Tools/Scripts/RoundTripHarness.cs +++ b/Tools/Scripts/RoundTripHarness.cs @@ -119,8 +119,6 @@ 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, " + @@ -129,86 +127,6 @@ 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)