Compare commits
No commits in common. "5720f05a5b42108bd8a4e71e6fe906a2054aa2a2" and "fb14b0693bda1a452b3cc8a162c7e74113560157" have entirely different histories.
5720f05a5b
...
fb14b0693b
14 changed files with 40 additions and 1037 deletions
|
|
@ -17,9 +17,7 @@ dispatches on the file's first byte:
|
||||||
- **v2 (primary)** — raw `ISLA` magic, u32 version gate, then tagged sections
|
- **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
|
`[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
|
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
|
the file, biome/tier ordinal ranges.
|
||||||
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
|
- **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.
|
still loadable (with a deprecation warning) until a future removal task.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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²`. |
|
| `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²`. |
|
| `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. |
|
| `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). |
|
| `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. |
|
||||||
| `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. |
|
| `RDBR` / `0x52424452` | same layout | Branch tier. |
|
||||||
| `RDRG` / `0x47524452` | same layout | Rugged tier. |
|
| `RDRG` / `0x47524452` | same layout | Rugged tier. |
|
||||||
| `RDTL` / `0x4C544452` | same layout | Trail tier. |
|
| `RDTL` / `0x4C544452` | same layout | Trail tier. |
|
||||||
|
|
@ -89,38 +85,9 @@ the file, not the original generation.
|
||||||
|
|
||||||
### Size
|
### Size
|
||||||
|
|
||||||
`total = 8 (header) + Σ per section (12 + payload)`. The pixel grids dominate: without water
|
`total = 8 (header) + Σ per section (12 + payload)`. The pixel grid dominates: `5·MapSize²` bytes
|
||||||
sections `5·MapSize²` bytes (4 height + 1 biome) ≈ **320 MiB at 8K**, vs v1's `8·MapSize²` ≈
|
(4 height + 1 biome) ≈ **320 MiB at 8K**, vs v1's `8·MapSize²` ≈ 512 MiB — the u8 biome section
|
||||||
512 MiB — the u8 biome section saves ~192 MiB. With the water sections the per-pixel cost is
|
saves ~192 MiB at 8K.
|
||||||
`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)
|
### Adding a new section (the intended extension path)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,27 +29,6 @@ namespace IslaApocalypse.Core
|
||||||
public const uint TAG_ROADS_BRANCH = 0x52424452; // "RDBR"
|
public const uint TAG_ROADS_BRANCH = 0x52424452; // "RDBR"
|
||||||
public const uint TAG_ROADS_RUGGED = 0x47524452; // "RDRG"
|
public const uint TAG_ROADS_RUGGED = 0x47524452; // "RDRG"
|
||||||
public const uint TAG_ROADS_TRAIL = 0x4C544452; // "RDTL"
|
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.
|
// MapSize sanity bounds, checked before any allocation on read.
|
||||||
public const int MIN_MAP_SIZE = 256;
|
public const int MIN_MAP_SIZE = 256;
|
||||||
|
|
|
||||||
|
|
@ -31,20 +31,8 @@ namespace IslaApocalypse.Core
|
||||||
writer.Write(BlueprintFormat.VERSION);
|
writer.Write(BlueprintFormat.VERSION);
|
||||||
|
|
||||||
WriteSection(writer, BlueprintFormat.TAG_PARAMS, w => WriteParams(w, bp));
|
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_HEIGHTS, w => WriteHeights(w, bp));
|
||||||
WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(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_TOWNS, w => WriteTowns(w, bp));
|
||||||
WriteSection(writer, BlueprintFormat.TAG_ROADS_HIGHWAY, w => WriteRoadTier(w, bp.Highways));
|
WriteSection(writer, BlueprintFormat.TAG_ROADS_HIGHWAY, w => WriteRoadTier(w, bp.Highways));
|
||||||
WriteSection(writer, BlueprintFormat.TAG_ROADS_BRANCH, w => WriteRoadTier(w, bp.BranchRoads));
|
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)
|
private static void WriteTowns(BinaryWriter writer, WorldBlueprint bp)
|
||||||
{
|
{
|
||||||
writer.Write(bp.Towns.Count);
|
writer.Write(bp.Towns.Count);
|
||||||
|
|
|
||||||
|
|
@ -14,25 +14,6 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
|
||||||
public static float DensityMultiplier = 1.0f; // <-- Replaces TownCount
|
public static float DensityMultiplier = 1.0f; // <-- Replaces TownCount
|
||||||
public static int ChunkRadius = 24; // Default chunk size, can be overridden by config
|
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()
|
public static void LoadConfig()
|
||||||
{
|
{
|
||||||
string path = "res://ServerConfig.json";
|
string path = "res://ServerConfig.json";
|
||||||
|
|
@ -80,39 +61,7 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
|
||||||
// Extract the ChunkRadius
|
// Extract the ChunkRadius
|
||||||
if (data.ContainsKey("ChunkRadius")) {
|
if (data.ContainsKey("ChunkRadius")) {
|
||||||
ChunkRadius = (int)data["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)
|
switch (profile)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -36,46 +36,6 @@ namespace IslaApocalypse.Core
|
||||||
public string GeneratorGitHash = ""; // short hash of the generator repo, "" if unknown
|
public string GeneratorGitHash = ""; // short hash of the generator repo, "" if unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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 class WorldBlueprint
|
||||||
{
|
{
|
||||||
public int MapSize;
|
public int MapSize;
|
||||||
|
|
@ -93,16 +53,6 @@ namespace IslaApocalypse.Core
|
||||||
// generation parameters. Params is null when the source was a legacy v1 file.
|
// generation parameters. Params is null when the source was a legacy v1 file.
|
||||||
public int FormatVersion = 1;
|
public int FormatVersion = 1;
|
||||||
public BlueprintParams Params;
|
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<WaterBodyInfo> WaterBodies = new List<WaterBodyInfo>();
|
|
||||||
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
|
// 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_BRANCH) sectionOk = ParseRoadTier(reader, blueprint.BranchRoads);
|
||||||
else if (tag == BlueprintFormat.TAG_ROADS_RUGGED) sectionOk = ParseRoadTier(reader, blueprint.RuggedRoads);
|
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_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
|
else
|
||||||
{
|
{
|
||||||
// The property the redesign exists to buy: future sections (water,
|
// The property the redesign exists to buy: future sections (water,
|
||||||
|
|
@ -348,61 +294,6 @@ namespace IslaApocalypse.Core
|
||||||
return true;
|
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<Vector2[]> into)
|
private static bool ParseRoadTier(BinaryReader reader, List<Vector2[]> into)
|
||||||
{
|
{
|
||||||
int pathCount = reader.ReadInt32();
|
int pathCount = reader.ReadInt32();
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,10 @@ rendering.
|
||||||
- **`BlueprintWriter.cs`** — writes a `WorldBlueprint` as a v2 file. Blueprint-typed on purpose:
|
- **`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.
|
the map generator and the round-trip harness are both just callers.
|
||||||
- **`MapDataParser.cs`** — decodes a `.dat` blueprint into a `WorldBlueprint` (heightmap, biome map,
|
- **`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
|
towns, four road tiers, and — v2 only — the embedded generation params and per-town highway-node
|
||||||
flag, and the optional water-bodies data: `WaterBodyIds`, `WaterBodies` table, `WaterSurfaceQ`).
|
flag). Dispatches on the first byte: v2 tagged-section files get validation (version gate, size
|
||||||
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
|
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
|
- **`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.
|
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
|
- **`Constants.cs`** — chunk dimensions, `ISO_LEVEL`, `VOXEL_SCALE`, and the visual/road tunables
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,5 @@
|
||||||
"WorldSeed": 1409879727,
|
"WorldSeed": 1409879727,
|
||||||
"MapProfile": "8K",
|
"MapProfile": "8K",
|
||||||
"TownDensity": "Normal",
|
"TownDensity": "Normal",
|
||||||
"ChunkRadius": 32,
|
"ChunkRadius": 32
|
||||||
"SeaLevelModel": "flat",
|
|
||||||
"SeaLevelValue": 0.15
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
- **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
|
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).
|
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
|
- **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
|
mountain hub, and county roads daisy-chained outward with Prim's algorithm. Paths are decimated
|
||||||
(Ramer–Douglas–Peucker) then smoothed (4 Chaikin passes).
|
(Ramer–Douglas–Peucker) then smoothed (4 Chaikin passes).
|
||||||
|
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
using Godot;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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)
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
|
|
||||||
/// <summary>The effective spike-domain top for a seed's raw maximum, guard applied.</summary>
|
|
||||||
public static float EffectiveSpikeMax(float hMaxSeed)
|
|
||||||
{
|
|
||||||
return Mathf.Max(hMaxSeed, T4 + SPIKE_MIN_SPAN);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <param name="h">Raw pre-curve height.</param>
|
|
||||||
/// <param name="hMaxSeed">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).</param>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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}).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://ljv7x1fwm3os
|
|
||||||
|
|
@ -26,34 +26,12 @@ public partial class MapGenerator : TextureRect
|
||||||
private float _impactRadius;
|
private float _impactRadius;
|
||||||
|
|
||||||
private float[,] _heightMap;
|
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 float[,] _tempMap;
|
||||||
private Biome[,] _biomeMap;
|
private Biome[,] _biomeMap;
|
||||||
private bool[,] _isTrueOcean;
|
private bool[,] _isTrueOcean;
|
||||||
private bool[,] _isMainland;
|
private bool[,] _isMainland;
|
||||||
internal List<TownData> _towns = new List<TownData>();
|
internal List<TownData> _towns = new List<TownData>();
|
||||||
|
|
||||||
// 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<WaterBodyInfo> _waterBodies;
|
|
||||||
|
|
||||||
internal List<Vector2[]> _highwayPaths = new List<Vector2[]>();
|
internal List<Vector2[]> _highwayPaths = new List<Vector2[]>();
|
||||||
internal List<Vector2[]> _branchPaths = new List<Vector2[]>();
|
internal List<Vector2[]> _branchPaths = new List<Vector2[]>();
|
||||||
internal List<Vector2[]> _ruggedPaths = new List<Vector2[]>();
|
internal List<Vector2[]> _ruggedPaths = new List<Vector2[]>();
|
||||||
|
|
@ -75,10 +53,6 @@ public partial class MapGenerator : TextureRect
|
||||||
this.CustomMinimumSize = new Vector2(MapSize, MapSize);
|
this.CustomMinimumSize = new Vector2(MapSize, MapSize);
|
||||||
|
|
||||||
_heightMap = new float[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];
|
_tempMap = new float[MapSize, MapSize];
|
||||||
_biomeMap = new Biome[MapSize, MapSize];
|
_biomeMap = new Biome[MapSize, MapSize];
|
||||||
_isTrueOcean = new bool[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.
|
// A future water stage slots in as another CaptureStage call at its own boundary.
|
||||||
|
|
||||||
GenerateTopography();
|
GenerateTopography();
|
||||||
GD.Print($"{T()} Topography done (height + temperature). TerrainCurve: {(_curveOn ? "v1" : "off")}.");
|
GD.Print($"{T()} Topography done (height + temperature).");
|
||||||
|
|
||||||
DrawHeightStageTexture();
|
|
||||||
await CaptureStage("0_height");
|
|
||||||
|
|
||||||
CalculateTrueOcean();
|
CalculateTrueOcean();
|
||||||
CalculateMainland();
|
CalculateMainland();
|
||||||
GD.Print($"{T()} Ocean and mainland masks done.");
|
GD.Print($"{T()} Ocean and mainland masks done.");
|
||||||
|
|
||||||
IdentifyWaterBodies();
|
|
||||||
DrawWaterStageTexture();
|
|
||||||
await CaptureStage("0_water");
|
|
||||||
|
|
||||||
AssignBiomesAndDraw();
|
AssignBiomesAndDraw();
|
||||||
GD.Print($"{T()} Biomes done.");
|
GD.Print($"{T()} Biomes done.");
|
||||||
await CaptureStage("1_biomes");
|
await CaptureStage("1_biomes");
|
||||||
|
|
@ -125,28 +92,13 @@ public partial class MapGenerator : TextureRect
|
||||||
GD.Print($"{T()} Towns placed: {_towns.Count}.");
|
GD.Print($"{T()} Towns placed: {_towns.Count}.");
|
||||||
await CaptureStage("2_towns");
|
await CaptureStage("2_towns");
|
||||||
|
|
||||||
if (ConfigManager.SkipRoads)
|
// NEW: We await the roads so the engine doesn't freeze!
|
||||||
{
|
await GenerateRoadsAsync();
|
||||||
// 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();
|
ExportMapData();
|
||||||
GD.Print($"{T()} Blueprint exported.");
|
GD.Print($"{T()} Blueprint exported.");
|
||||||
|
|
||||||
if (!ConfigManager.SkipRoads)
|
await CaptureStage("3_roads");
|
||||||
await CaptureStage("3_roads");
|
|
||||||
GD.Print($"{T()} GENERATION COMPLETE.");
|
GD.Print($"{T()} GENERATION COMPLETE.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,17 +206,6 @@ public partial class MapGenerator : TextureRect
|
||||||
BranchRoads = _branchPaths,
|
BranchRoads = _branchPaths,
|
||||||
RuggedRoads = _ruggedPaths,
|
RuggedRoads = _ruggedPaths,
|
||||||
TrailRoads = _trailPaths,
|
TrailRoads = _trailPaths,
|
||||||
WaterBodyIds = _waterBodyIds,
|
|
||||||
WaterBodies = _waterBodies ?? new List<WaterBodyInfo>(),
|
|
||||||
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,
|
FormatVersion = 2,
|
||||||
Params = new BlueprintParams
|
Params = new BlueprintParams
|
||||||
{
|
{
|
||||||
|
|
@ -407,51 +348,23 @@ public partial class MapGenerator : TextureRect
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 4. COMBINE HEIGHT ---
|
// --- 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 rawBase = (_noise.GetNoise2D(x, y) + 1.0f) / 2.0f;
|
||||||
float finalH = rawBase + mountainSpine - (finalFalloff * FalloffStrength);
|
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!) ---
|
// --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) ---
|
||||||
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
|
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
|
||||||
|
|
||||||
// We only carve the physical hole at 80% of the radius to guarantee a landbridge!
|
// We only carve the physical hole at 80% of the radius to guarantee a landbridge!
|
||||||
|
float physicalCraterRadius = _impactRadius * 0.80f;
|
||||||
if (distToCrater < physicalCraterRadius)
|
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!
|
// Dialed back to -0.15f as per your excellent instinct!
|
||||||
float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f;
|
finalH = Mathf.Lerp(finalH, GetSeaLevel(temperature) - 0.15f, craterDepth * 0.9f);
|
||||||
classifyH = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f);
|
|
||||||
curvedH = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_heightMapClassify[x, y] = classifyH;
|
_heightMap[x, y] = finalH;
|
||||||
_heightMap[x, y] = curvedH;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -472,7 +385,7 @@ public partial class MapGenerator : TextureRect
|
||||||
Vector2I neighbor = current + dir;
|
Vector2I neighbor = current + dir;
|
||||||
if (neighbor.X >= 0 && neighbor.X < MapSize && neighbor.Y >= 0 && neighbor.Y < MapSize)
|
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;
|
_isTrueOcean[neighbor.X, neighbor.Y] = true;
|
||||||
queue.Enqueue(neighbor);
|
queue.Enqueue(neighbor);
|
||||||
|
|
@ -487,7 +400,7 @@ public partial class MapGenerator : TextureRect
|
||||||
Queue<Vector2I> queue = new Queue<Vector2I>();
|
Queue<Vector2I> queue = new Queue<Vector2I>();
|
||||||
Vector2I center = new Vector2I(MapSize / 2, MapSize / 2);
|
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);
|
queue.Enqueue(center);
|
||||||
_isMainland[center.X, center.Y] = true;
|
_isMainland[center.X, center.Y] = true;
|
||||||
|
|
@ -503,7 +416,7 @@ public partial class MapGenerator : TextureRect
|
||||||
Vector2I neighbor = current + dir;
|
Vector2I neighbor = current + dir;
|
||||||
if (neighbor.X >= 0 && neighbor.X < MapSize && neighbor.Y >= 0 && neighbor.Y < MapSize)
|
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;
|
_isMainland[neighbor.X, neighbor.Y] = true;
|
||||||
queue.Enqueue(neighbor);
|
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];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
private void IdentifyWaterBodies()
|
|
||||||
{
|
|
||||||
ulong t0 = Time.GetTicksMsec();
|
|
||||||
_waterBodyIds = new ushort[MapSize, MapSize];
|
|
||||||
_waterBodies = new List<WaterBodyInfo>();
|
|
||||||
|
|
||||||
// --- 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<Vector2I> queue = new Queue<Vector2I>();
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
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<int, float>();
|
|
||||||
var pit = new Queue<int>();
|
|
||||||
|
|
||||||
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<int>();
|
|
||||||
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<long>();
|
|
||||||
var basinDepths = new List<float>();
|
|
||||||
bool[] counted = new bool[total];
|
|
||||||
var comp = new Queue<int>();
|
|
||||||
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<long> s, double q) => s.Count == 0 ? 0 : s[Mathf.Clamp((int)(q * s.Count), 0, s.Count - 1)];
|
|
||||||
float Pf(List<float> 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}.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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()
|
private void AssignBiomesAndDraw()
|
||||||
{
|
{
|
||||||
Image mapImage = Image.CreateEmpty(MapSize, MapSize, false, Image.Format.Rgba8);
|
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++)
|
for (int y = 0; y < MapSize; y++)
|
||||||
{
|
{
|
||||||
// Biome rules classify against the UNCURVED heights (task 05) — the
|
float h = _heightMap[x, y];
|
||||||
// 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];
|
float t = _tempMap[x, y];
|
||||||
Biome b;
|
Biome b;
|
||||||
|
|
||||||
float currentSeaLevel = GetSeaLevel(t);
|
float currentSeaLevel = GetSeaLevel(t);
|
||||||
|
|
||||||
// Water classification comes from the SHARED predicates (task 03), so the
|
if (h < currentSeaLevel) b = _isTrueOcean[x, y] ? Biome.Ocean : Biome.Lake;
|
||||||
// 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
|
else
|
||||||
{
|
{
|
||||||
float baseDist = new Vector2(x, y).DistanceTo(_impactCenter);
|
float baseDist = new Vector2(x, y).DistanceTo(_impactCenter);
|
||||||
|
|
@ -1132,20 +685,12 @@ public partial class MapGenerator : TextureRect
|
||||||
if (t < minTemp || t > maxTemp) continue;
|
if (t < minTemp || t > maxTemp) continue;
|
||||||
|
|
||||||
float centerH = _heightMap[rx, ry];
|
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!
|
int slopeRadius = (int)(MapSize * 0.005f); // Automatically scales!
|
||||||
int sxHi = Mathf.Clamp(rx + slopeRadius, 0, MapSize - 1);
|
if (Mathf.Abs(_heightMap[rx+slopeRadius, ry] - centerH) > maxSlope) continue;
|
||||||
int sxLo = Mathf.Clamp(rx - slopeRadius, 0, MapSize - 1);
|
if (Mathf.Abs(_heightMap[rx-slopeRadius, ry] - centerH) > maxSlope) continue;
|
||||||
int syHi = Mathf.Clamp(ry + slopeRadius, 0, MapSize - 1);
|
if (Mathf.Abs(_heightMap[rx, ry+slopeRadius] - centerH) > maxSlope) continue;
|
||||||
int syLo = Mathf.Clamp(ry - slopeRadius, 0, MapSize - 1);
|
if (Mathf.Abs(_heightMap[rx, ry-slopeRadius] - centerH) > maxSlope) continue;
|
||||||
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 nearTrueOcean = false;
|
||||||
bool nearAnyWater = false;
|
bool nearAnyWater = false;
|
||||||
|
|
@ -1342,13 +887,7 @@ public partial class MapGenerator : TextureRect
|
||||||
return astar.GetPointPath(from, to);
|
return astar.GetPointPath(from, to);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sea-level model gate (D-033, task 04): "flat" returns the configured scalar;
|
private float GetSeaLevel(float t) => Mathf.Lerp(0.26f, 0.15f, Mathf.Clamp(t, 0f, 1f));
|
||||||
// "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<Vector2I> set) {
|
private Vector2I FindClosestPixel(Vector2 pos, HashSet<Vector2I> set) {
|
||||||
Vector2I closest = new Vector2I(0,0); float min = float.MaxValue;
|
Vector2I closest = new Vector2I(0,0); float min = float.MaxValue;
|
||||||
|
|
|
||||||
|
|
@ -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
|
2. **Topography** — FastNoiseLite base height plus a mountain spine, minus a squircle distance
|
||||||
falloff, giving a guaranteed island. Noise frequency is divided by `scaleFactor`
|
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.
|
(`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
|
3. **Sea level and water** — temperature-driven sea level; flood fill separates true ocean from
|
||||||
(`HeightCurve.cs` — flat farmable lowlands, 50 m plateau shelf, and a per-seed-normalized
|
inland lakes; a mainland fill guarantees one contiguous landmass.
|
||||||
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
|
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.
|
**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
|
5. **Biomes and towns** — biome zoning by height and temperature; tiered town placement (Capitol,
|
||||||
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.
|
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
|
6. **Roads** — see below.
|
||||||
A\* pass is the bottleneck; a skip run exports a road-less iteration blueprint in ~80 s with a
|
7. **Export** — writes the `.dat` blueprint (dual-write: the v2 tagged container under the primary
|
||||||
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
|
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
|
`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).
|
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.
|
**dark brown** = Rugged, **light brown** = Trail.
|
||||||
|
|
||||||
### Outputs
|
### Outputs
|
||||||
`MapData_Seed_<seed>.dat` (v2), `MapData_Seed_<seed>_v1.dat` (legacy dual-write), and the staged
|
`MapData_Seed_<seed>.dat` (v2), `MapData_Seed_<seed>_v1.dat` (legacy dual-write), and
|
||||||
snapshots `Map_Seed_<seed>_{0_height,0_water,1_biomes,2_towns,3_roads}.png`, all to `user://`.
|
`Map_Seed_<seed>.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`
|
## `RoundTripHarness.cs`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,8 +119,6 @@ public partial class RoundTripHarness : Node
|
||||||
ok &= CompareRoads("Branch", a.BranchRoads, b.BranchRoads);
|
ok &= CompareRoads("Branch", a.BranchRoads, b.BranchRoads);
|
||||||
ok &= CompareRoads("Rugged", a.RuggedRoads, b.RuggedRoads);
|
ok &= CompareRoads("Rugged", a.RuggedRoads, b.RuggedRoads);
|
||||||
ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads);
|
ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads);
|
||||||
ok &= CompareWater(a, b);
|
|
||||||
ok &= CompareTerrainCurve(a, b);
|
|
||||||
|
|
||||||
if (ok)
|
if (ok)
|
||||||
GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " +
|
GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " +
|
||||||
|
|
@ -129,86 +127,6 @@ public partial class RoundTripHarness : Node
|
||||||
return ok;
|
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<Vector2[]> a, List<Vector2[]> b)
|
private bool CompareRoads(string tier, List<Vector2[]> a, List<Vector2[]> b)
|
||||||
{
|
{
|
||||||
if (a.Count != b.Count)
|
if (a.Count != b.Count)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue