Compare commits

...

13 commits

Author SHA1 Message Date
5720f05a5b docs: curve v2 — TCRV spikeMax semantics, TerrainCurve v2 default, v1 retired (terrain-water task 06)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 00:29:34 -04:00
ac67c27952 feat: curve v2 — per-seed spike normalization, u4 wall, 420 m ceiling (terrain-water task 06)
Per the task-05 hillshade-gate verdict: lowlands frozen (identity/toe/
rise/shoulder/plateau byte-unchanged); the spike now maps [t4,
hMaxSeed] — each seed's own raw pre-curve maximum, computed in a new
pass-1 over GenerateTopography — onto the peak band, so every island
reaches the ceiling (v1's pooled-max domain left mid-range seeds at
110–175 m). Spike stiffened to 0.1u + 0.9u^4; peak cap raised to
420 m above sea (1.82869). Degenerate near-flat guard: spike domain
floored at t4 + 0.01. The curve is now SEED-DEPENDENT: hMaxSeed is a
pure parameter (D-035), recorded in TCRV (field renamed
HMaxCal -> SpikeMax, same byte layout; v1 semantics = pooled max, v2 =
per-seed), and the monotonicity assertion runs per generation against
the effective curve. TerrainCurve gate: "off"|"v2" (default v2);
"v1" retired with a loud config error (task-05 blueprints are
regenerable). Classify path untouched — the biome/water oracle must
hold unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 00:21:25 -04:00
46a991f352 fix: dedupe float samples in the monotonicity assertion (terrain-water task 05)
The coarse and fine sampling loops both touched h=0.10 and the two
doubles round to the same float32, so the strict check compared the
identity value against itself and threw on every curve-on run. Only
strictly increasing float samples are compared now. The curve itself
was never non-monotonic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:36:43 -04:00
9ebb8296c9 docs: TCRV section entry + TerrainCurve config note + 0_height snapshot (terrain-water task 05)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:26:09 -04:00
e7de292b3d feat: 0_height hillshade snapshot + TCRV blueprint section + harness (terrain-water task 05)
0_height: hypsometric storm-ladder tint x Lambert NW hillshade over
the curved heights, captured after topography in both curve modes —
relief is finally visible. Renumbers nothing.

TCRV (50 B, emitted when the curve is on): curve version + input knots
+ output bands, so blueprints are self-describing about the map their
heights went through (H7 spirit). Parsed into
WorldBlueprint.TerrainCurve (null = raw legacy profile); server treats
it as metadata. Round-trip harness compares it when present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:24:33 -04:00
67868638be feat: height-redistribution curve, gated + biome-invariant (terrain-water task 05)
HeightCurve (pure static, D-035): calibrated monotonic piecewise map —
identity at/below sea 0.15; ease-out toe to the orange ceiling 0.206
(75% coverage, knot t1=P75=0.628736 from batch-04's pooled flat-sea
land CDF, 340.6M samples); linear rise to red 0.27 (t2=P90); smooth
shoulder to the 50m plateau shelf 0.3492 (t3=P93); near-flat plateau
step (t4=P96); accelerating spike to the 220m peak cap 1.0265; linear
tail past the calibrated max. Strict monotonicity asserted numerically
at startup, loud throw on violation.

Applied in GenerateTopography AFTER noise+falloff+Trench, BEFORE the
crater carve (carve cuts curved terrain; rim/bowl untouched by the
curve). Biome oracle mechanism: a retained uncurved classify heightmap
(alias of _heightMap when off, zero cost) feeds biome rules, both
flood fills, and the shared water predicates — classification is
curve-invariant by construction. Towns/roads/diagnostics/exported
heights use curved terrain; town positions may legitimately move.

Config gate TerrainCurve: "off"|"v1" (default v1), unknown values
rejected loudly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:22:53 -04:00
9e55324668 fix: clamp town slope-check sampling to map bounds (terrain-water task 05)
PlaceTownNodes read _heightMap[rx ± slopeRadius] unclamped; candidates
spawn at [20, MapSize-20] but slopeRadius is 40 at 8K, so border
candidates indexed out of bounds. Never fired only because border land
stayed underwater and failed the above-sea test first — the
redistribution curve's flat coastal toe arms exactly that path
(D-033 consequence (b), recorded in sweep 01 and task 04).
Behavior-preserving except where it would have crashed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:20:16 -04:00
e435843e9a docs: SeaLevelModel/SeaLevelValue config notes (terrain-water task 04)
Both sea-level modes documented (flat default 0.15 / legacy field
Lerp); noted the blueprint does not record which model produced it —
the WBTB levels are the observable. Batch runner lives in /tmp
(throwaway), not in-repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 06:25:19 -04:00
200adcaf5a feat: config-gated flat-scalar sea level (D-033, terrain-water task 04)
SeaLevelModel: "flat" (one scalar, SeaLevelValue) or "field" (the
legacy latitude Lerp 0.26..0.15). Only GetSeaLevel's body changes —
all nine call sites (crater carve target, both flood fills, biome
split, beach band, Capitol window, town filters, A* zero-point)
inherit the model automatically; no consumer re-tuning. Default:
flat 0.15, pinned in ServerConfig.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 06:20:48 -04:00
1f957b3430 docs: water sections (WBID/WBTB/WSRF), no-BSIN rationale, SkipRoads + water stage in READMEs (terrain-water task 03)
Byte-accurate entries for the three water sections (transitional
per-body level rule, provisional salinity, WSRF sentinel/quantization),
updated size math (~576 MiB at 8K with water), the on-the-record
rationale for NOT serializing basins, and README updates for the new
pipeline stage, the 0_water snapshot, and the SkipRoads toggle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 01:40:48 -04:00
3bf822dac6 feat: WBID/WBTB/WSRF blueprint sections + parser + harness extension (terrain-water task 03)
Three new v2 tagged sections carrying the water-bodies stage output:
WBID (MapSize^2 u16 body ids; 0 none, 1 ocean, 2..N lakes), WBTB
(count-prefixed 20-byte records: id, type, provisional salinity,
transitional flat surface level, pixel count, centroid), WSRF
(MapSize^2 u16 quantized levels; 0 = no-water sentinel, 1 + L*32768
encoding, ~7.7 mm world resolution). Written only when the blueprint
carries water; absent on legacy re-encodes. Parser registers all
three (length + type checks) into new WorldBlueprint members —
consumed by nothing at runtime. Round-trip harness now compares the
water sections whenever the source carries them; v1-source baseline
stays green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 01:31:50 -04:00
d296ef30c4 feat: shared water predicates + water-bodies stage + priority-flood diagnostics (terrain-water task 03)
IsWaterPixel/IsOceanPixel/IsLakePixel are the single per-pixel water
truth; AssignBiomesAndDraw's water branch now calls them (verbatim
rules — behavior-preserving, proven by the bit-identical-biomes oracle
at acceptance). New IdentifyWaterBodies stage between the masks and
biomes: ocean = body 1, lakes labeled 2..N with CalculateTrueOcean's
4-connectivity in deterministic scan order; one transitional surface
level per body (GetSeaLevel at the body centroid; ocean at map
centre). 0_water snapshot painted from the stage's own outputs.

Priority-flood pit-fill (heap + pit-queue variant) runs as validated
diagnostics only — serializes nothing, asserts filled>=original and
full-map non-ascending drainage (reverse BFS, no sampling), reports
closed-basin statistics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 01:29:51 -04:00
81f7030140 feat: SkipRoads iteration toggle (terrain-water task 03)
Config flag SkipRoads (default false): when on, GenerateRoadsAsync and
the 3_roads snapshot are skipped entirely; export proceeds with all
four road sections present but empty (both v2 and legacy v1 permit
zero counts); the console states loudly that the blueprint is a
road-less iteration artifact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 01:27:36 -04:00
14 changed files with 1037 additions and 40 deletions

View file

@ -17,7 +17,9 @@ 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. the file, biome/tier ordinal ranges. Since the water-bodies stage, v2 files also carry the
optional `WBID`/`WBTB`/`WSRF` water sections (per-pixel body ids, the body table, quantized
surface levels) — parsed into `WorldBlueprint` and consumed by nothing at runtime yet.
- **v1 (legacy)** — the positional `"ISLA_V1"` format, still written beside v2 as `_v1.dat` and - **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.

View file

@ -55,7 +55,11 @@ Every section: `[u32 tag][u64 payload-length in bytes][payload]`.
| `HGTS` / `0x53544748` | `MapSize²` × `f32` height, **X outer / Y inner** | Mandatory. The second index is the map's north/south axis; the server consumes it as world **Z**. (This axis convention was never written down for v1 — it is now normative.) Length must equal `4·MapSize²`. | | `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. |
| `RDHW` / `0x57484452` | `i32 pathCount`, then per path: `i32 pointCount` + `pointCount` × (`f32 X` · `f32 Y`) | Highway tier. **Tags, not file position, identify the tier** — the v1 order-fragility is gone. | | `TCRV` / `0x56524354` | 50 B: `u16 curveVersion` · input knots `f32 t1..t4` · `f32 spikeMax` · output bands `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 plateauLo` · `f32 plateauHi` · `f32 peakCap` · `f32 tailSlope` | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v2"`); absent = raw legacy profile. **`spikeMax` semantics by version:** v1 = the pooled calibration max (identical every seed); **v2 = the SEED'S own effective raw pre-curve maximum** — the per-seed spike normalizer, which is why blueprints are no longer reproducible from curve constants alone and the value is recorded here. **Metadata only:** exported heights are already curved; nothing re-applies the map. The curve lives in `Tools/Scripts/HeightCurve.cs` (calibration provenance in its header + the task-05/06 reports). |
| `WBID` / `0x44494257` | `MapSize²` × `u16` water-body id, same pixel order as `HGTS` | Optional (absent = no water data, e.g. a legacy re-encode). `0` = no water, `1` = **the** ocean body, `2..N` = lakes. Ids assigned in deterministic scan order (X outer / Y inner, first-encountered pixel), lakes labeled with the **same 4-connectivity as `CalculateTrueOcean`**. Membership is exactly the generator's water classification — the biome grid's Ocean/Lake pixels and this grid's nonzero pixels are the same set **by construction** (shared predicates). Length must equal `2·MapSize²`. |
| `WBTB` / `0x42544257` | `i32 count`, then per body (20 B): `u16 id` · `u8 type` (0 ocean, 1 lake) · `u8 salinity` (0 fresh, 1 salt) · `f32 surfaceLevel` · `i32 pixelCount` · `f32 centroidX` · `f32 centroidY` | Optional, paired with `WBID`. **`surfaceLevel` is a documented TRANSITIONAL rule:** one flat level per body — `GetSeaLevel` at the body's pixel centroid (ocean: at the map centre) under the still-live latitude field; superseded by the flat-scalar sea model (minted, lands with the coast change set). The field's per-pixel slope is deliberately NOT baked into any section. **Salinity is a provisional default** (ocean salt, lake fresh) — a placeholder for the future fresh/salt irrigation mechanic, not a mechanic. |
| `WSRF` / `0x46525357` | `MapSize²` × `u16` quantized water-surface elevation, same pixel order | Optional, paired with `WBID`. `0` is the reserved **no-water sentinel**; a real level `L` (raw height units) encodes as `1 + round(L × 32768)` so it can never encode to 0; decode `(q 1)/32768` (`BlueprintFormat.EncodeWaterLevel`/`DecodeWaterLevel`). Covers `[0 … ~1.99997]` raw at `1/32768` raw ≈ **7.7 mm** of world height (1 raw = 251 m) — far finer than the 1 m voxel. Nonzero exactly where `WBID` is nonzero; the value is the pixel's body level. |
| `RDHW` / `0x57484452` | `i32 pathCount`, then per path: `i32 pointCount` + `pointCount` × (`f32 X` · `f32 Y`) | Highway tier. **Tags, not file position, identify the tier** — the v1 order-fragility is gone. Road sections may legitimately be **present but empty** (zero paths): the `SkipRoads` config toggle exports a road-less iteration blueprint. |
| `RDBR` / `0x52424452` | same layout | Branch tier. | | `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. |
@ -85,9 +89,38 @@ the file, not the original generation.
### Size ### Size
`total = 8 (header) + Σ per section (12 + payload)`. The pixel grid dominates: `5·MapSize²` bytes `total = 8 (header) + Σ per section (12 + payload)`. The pixel grids dominate: without water
(4 height + 1 biome) ≈ **320 MiB at 8K**, vs v1's `8·MapSize²` ≈ 512 MiB — the u8 biome section sections `5·MapSize²` bytes (4 height + 1 biome) ≈ **320 MiB at 8K**, vs v1's `8·MapSize²`
saves ~192 MiB at 8K. 512 MiB — the u8 biome section saves ~192 MiB. With the water sections the per-pixel cost is
`9·MapSize²` (adds 2 B `WBID` + 2 B `WSRF`) ≈ **576 MiB at 8K** plus a trivial `WBTB` (20 B per
body).
### Config knobs that shape blueprint content (not part of the byte format)
- **`SeaLevelModel`** (`"flat"` | `"field"`) + **`SeaLevelValue`** (float; used when flat) —
D-033: selects what `MapGenerator.GetSeaLevel` returns. `"flat"` (the default, value 0.15) is
one scalar sea level everywhere; `"field"` is the legacy latitude Lerp (0.26 north → 0.15
south). Generator-only — all nine `GetSeaLevel` call sites inherit the model; nothing at
runtime computes sea level. The model in force shapes the coastline, masks, biomes, water
bodies, and `WBTB` levels of the blueprint being generated; the file itself does not record
which model produced it (the `WBTB` levels are the observable).
- **`SkipRoads`** (bool, default false) — exports with present-but-empty road sections (see the
road-section table note).
- **`TerrainCurve`** (`"v2"` | `"off"`, default `"v2"`; `"v1"` retired by the task-06
recalibration and rejected loudly) — whether the height-redistribution curve (storm ladder:
75 % orange coverage, 50 m plateau, **per-seed-normalized 420 m peaks**) shapes `HGTS`.
Biome/water classification is curve-invariant by construction (it classifies the retained
uncurved heights); town positions and everything 3D follow the curved terrain. When on,
`TCRV` records the effective parameters including the per-seed `spikeMax`.
### Deliberately NOT a section: basins (`BSIN`)
The priority-flood pit-fill that runs in the water-bodies stage is **diagnostics only** and
serializes nothing. Rationale, on the record so nobody adds a basin section later without meeting
the argument: basin geometry is a pure function of the heightmap, and it goes stale the moment the
coast-smoothing change set (or any terrain change) touches heights — a serialized `BSIN` would be
a cache with a silent invalidation hazard. The rivers stage recomputes basins fresh at generation
time (measured ~12 s at 8K — cheap enough to never cache).
### Adding a new section (the intended extension path) ### Adding a new section (the intended extension path)

View file

@ -29,6 +29,27 @@ 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;

View file

@ -31,8 +31,20 @@ 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));
@ -112,6 +124,71 @@ namespace IslaApocalypse.Core
} }
} }
private static void WriteTerrainCurve(BinaryWriter writer, TerrainCurveInfo c)
{
writer.Write(c.Version);
writer.Write(c.T1); writer.Write(c.T2); writer.Write(c.T3); writer.Write(c.T4);
writer.Write(c.SpikeMax);
writer.Write(c.Sea); writer.Write(c.OrangeCeil); writer.Write(c.RedCeil);
writer.Write(c.PlateauLo); writer.Write(c.PlateauHi); writer.Write(c.PeakCap);
writer.Write(c.TailSlope);
}
private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp)
{
int n = bp.MapSize;
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
writer.Write(bp.WaterBodyIds[x, y]);
}
private static void WriteWaterBodyTable(BinaryWriter writer, WorldBlueprint bp)
{
writer.Write(bp.WaterBodies.Count);
foreach (WaterBodyInfo body in bp.WaterBodies)
{
writer.Write(body.Id);
writer.Write(body.Type);
writer.Write(body.Salinity);
writer.Write(body.SurfaceLevel);
writer.Write(body.PixelCount);
writer.Write(body.Centroid.X);
writer.Write(body.Centroid.Y);
}
}
private static void WriteWaterSurface(BinaryWriter writer, WorldBlueprint bp)
{
int n = bp.MapSize;
// A parsed blueprint carries the quantized surface verbatim; a freshly
// generated one derives it from body ids + per-body levels.
if (bp.WaterSurfaceQ != null)
{
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
writer.Write(bp.WaterSurfaceQ[x, y]);
return;
}
// id -> encoded level lookup (ids are small and dense: 1..N)
int maxId = 0;
foreach (WaterBodyInfo body in bp.WaterBodies)
if (body.Id > maxId) maxId = body.Id;
ushort[] encoded = new ushort[maxId + 1];
foreach (WaterBodyInfo body in bp.WaterBodies)
encoded[body.Id] = BlueprintFormat.EncodeWaterLevel(body.SurfaceLevel);
for (int x = 0; x < n; x++)
{
for (int y = 0; y < n; y++)
{
ushort id = bp.WaterBodyIds[x, y];
writer.Write(id == 0 ? (ushort)0 : encoded[id]);
}
}
}
private static void WriteTowns(BinaryWriter writer, WorldBlueprint bp) private static void WriteTowns(BinaryWriter writer, WorldBlueprint bp)
{ {
writer.Write(bp.Towns.Count); writer.Write(bp.Towns.Count);

View file

@ -14,6 +14,25 @@ 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";
@ -63,6 +82,38 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
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)
{ {
case "4K": case "4K":

View file

@ -36,6 +36,46 @@ 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;
@ -53,6 +93,16 @@ 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
@ -172,6 +222,10 @@ namespace IslaApocalypse.Core
else if (tag == BlueprintFormat.TAG_ROADS_BRANCH) sectionOk = ParseRoadTier(reader, blueprint.BranchRoads); else if (tag == BlueprintFormat.TAG_ROADS_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,
@ -294,6 +348,61 @@ 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();

View file

@ -39,10 +39,11 @@ 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 and per-town highway-node towns, four road tiers, and — v2 only — the embedded generation params, per-town highway-node
flag). Dispatches on the first byte: v2 tagged-section files get validation (version gate, size flag, and the optional water-bodies data: `WaterBodyIds`, `WaterBodies` table, `WaterSurfaceQ`).
Dispatches on the first byte: v2 tagged-section files get validation (version gate, size
bounds, section-length and ordinal range checks); legacy v1 files still load, intact, with a bounds, section-length and ordinal range checks); legacy v1 files still load, intact, with a
deprecation warning. deprecation warning. Nothing at runtime consumes the water data yet.
- **`ChunkData.cs`** — one chunk's density and block-ID fields, `+1` padded on every axis so the - **`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

View file

@ -2,5 +2,7 @@
"WorldSeed": 1409879727, "WorldSeed": 1409879727,
"MapProfile": "8K", "MapProfile": "8K",
"TownDensity": "Normal", "TownDensity": "Normal",
"ChunkRadius": 32 "ChunkRadius": 32,
"SeaLevelModel": "flat",
"SeaLevelValue": 0.15
} }

View file

@ -17,6 +17,10 @@ Builds the topographical and logistical foundation of the island before any 3D e
- **Topography:** FastNoiseLite plus distance-falloff maths for a guaranteed mainland island, a - **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
(RamerDouglasPeucker) then smoothed (4 Chaikin passes). (RamerDouglasPeucker) then smoothed (4 Chaikin passes).

View file

@ -0,0 +1,135 @@
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 110175 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}).");
}
}

View file

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

View file

@ -26,12 +26,34 @@ 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[]>();
@ -53,6 +75,10 @@ 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];
@ -78,12 +104,19 @@ 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)."); GD.Print($"{T()} Topography done (height + temperature). TerrainCurve: {(_curveOn ? "v1" : "off")}.");
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");
@ -92,12 +125,27 @@ 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)
{
// 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! // NEW: We await the roads so the engine doesn't freeze!
await GenerateRoadsAsync(); 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.");
} }
@ -206,6 +254,17 @@ 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
{ {
@ -348,23 +407,51 @@ 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!
finalH = Mathf.Lerp(finalH, GetSeaLevel(temperature) - 0.15f, craterDepth * 0.9f); float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f;
classifyH = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f);
curvedH = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f);
} }
_heightMap[x, y] = finalH; _heightMapClassify[x, y] = classifyH;
_heightMap[x, y] = curvedH;
} }
} }
} }
@ -385,7 +472,7 @@ public partial class MapGenerator : TextureRect
Vector2I neighbor = current + dir; 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] && _heightMap[neighbor.X, neighbor.Y] < GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) if (!_isTrueOcean[neighbor.X, neighbor.Y] && _heightMapClassify[neighbor.X, neighbor.Y] < GetSeaLevel(_tempMap[neighbor.X, neighbor.Y]))
{ {
_isTrueOcean[neighbor.X, neighbor.Y] = true; _isTrueOcean[neighbor.X, neighbor.Y] = true;
queue.Enqueue(neighbor); queue.Enqueue(neighbor);
@ -400,7 +487,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 (_heightMap[center.X, center.Y] >= GetSeaLevel(_tempMap[center.X, center.Y])) if (_heightMapClassify[center.X, center.Y] >= GetSeaLevel(_tempMap[center.X, center.Y]))
{ {
queue.Enqueue(center); queue.Enqueue(center);
_isMainland[center.X, center.Y] = true; _isMainland[center.X, center.Y] = true;
@ -416,7 +503,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] && _heightMap[neighbor.X, neighbor.Y] >= GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) if (!_isMainland[neighbor.X, neighbor.Y] && _heightMapClassify[neighbor.X, neighbor.Y] >= GetSeaLevel(_tempMap[neighbor.X, neighbor.Y]))
{ {
_isMainland[neighbor.X, neighbor.Y] = true; _isMainland[neighbor.X, neighbor.Y] = true;
queue.Enqueue(neighbor); queue.Enqueue(neighbor);
@ -426,6 +513,358 @@ public partial class MapGenerator : TextureRect
} }
} }
// =====================================================================
// SHARED WATER PREDICATES (terrain-water task 03)
// The single source of per-pixel water truth. AssignBiomesAndDraw and
// IdentifyWaterBodies both call these; correspondence between the biome
// grid and the water-body grid holds BY CONSTRUCTION, not by parallel
// implementations agreeing.
// =====================================================================
private bool IsWaterPixel(int x, int y) => _heightMapClassify[x, y] < GetSeaLevel(_tempMap[x, y]);
private bool IsOceanPixel(int x, int y) => IsWaterPixel(x, y) && _isTrueOcean[x, y];
private bool IsLakePixel(int x, int y) => IsWaterPixel(x, y) && !_isTrueOcean[x, y];
/// <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);
@ -433,13 +872,21 @@ public partial class MapGenerator : TextureRect
{ {
for (int y = 0; y < MapSize; y++) for (int y = 0; y < MapSize; y++)
{ {
float h = _heightMap[x, y]; // Biome rules classify against the UNCURVED heights (task 05) — the
// bit-identical-biomes oracle. Beach/mountain/snow bands and the
// water split all read the classify map.
float h = _heightMapClassify[x, y];
float t = _tempMap[x, y]; float t = _tempMap[x, y];
Biome b; Biome b;
float currentSeaLevel = GetSeaLevel(t); float currentSeaLevel = GetSeaLevel(t);
if (h < currentSeaLevel) b = _isTrueOcean[x, y] ? Biome.Ocean : Biome.Lake; // Water classification comes from the SHARED predicates (task 03), so the
// water-bodies stage and the biome classifier cannot disagree. Same rules
// as before, verbatim: below local sea level -> Ocean if true-ocean
// connected, else Lake.
if (IsOceanPixel(x, y)) b = Biome.Ocean;
else if (IsLakePixel(x, y)) b = Biome.Lake;
else else
{ {
float baseDist = new Vector2(x, y).DistanceTo(_impactCenter); float baseDist = new Vector2(x, y).DistanceTo(_impactCenter);
@ -685,12 +1132,20 @@ 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!
if (Mathf.Abs(_heightMap[rx+slopeRadius, ry] - centerH) > maxSlope) continue; 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, ry+slopeRadius] - 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[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;
@ -887,7 +1342,13 @@ public partial class MapGenerator : TextureRect
return astar.GetPointPath(from, to); return astar.GetPointPath(from, to);
} }
private float GetSeaLevel(float t) => Mathf.Lerp(0.26f, 0.15f, Mathf.Clamp(t, 0f, 1f)); // Sea-level model gate (D-033, task 04): "flat" returns the configured scalar;
// "field" is the legacy latitude Lerp. Pure numeric mapping either way (D-035);
// all nine call sites inherit whichever model config selects.
private float GetSeaLevel(float t) =>
ConfigManager.SeaLevelModel == "flat"
? ConfigManager.SeaLevelValue
: Mathf.Lerp(0.26f, 0.15f, Mathf.Clamp(t, 0f, 1f));
private Vector2I FindClosestPixel(Vector2 pos, HashSet<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;

View file

@ -11,14 +11,29 @@ Generates the entire 2D blueprint. Roughly in order:
2. **Topography** — FastNoiseLite base height plus a mountain spine, minus a squircle distance 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.
3. **Sea level and water** — temperature-driven sea level; flood fill separates true ocean from When `TerrainCurve: "v2"` (the default), the calibrated height-redistribution curve
inland lakes; a mainland fill guarantees one contiguous landmass. (`HeightCurve.cs` — flat farmable lowlands, 50 m plateau shelf, and a per-seed-normalized
spike to the 420 m peak cap: every island's tallest point reaches the ceiling) reshapes
above-sea terrain after noise/falloff/Trench and before the crater carve; biome
classification reads a retained uncurved map, so biomes are identical either way. Drops the
`0_height` hillshade snapshot (hypsometric bands × NW hillshade) in both modes.
3. **Sea level and water** — sea level per the configured model (`SeaLevelModel`: `"flat"` scalar
— the default, `SeaLevelValue` 0.15 — or the legacy `"field"` latitude Lerp); flood fill
separates true ocean from inland lakes; a mainland fill guarantees one contiguous landmass.
4. **The crater** — placed along the northern coast and carved to below sea level, but only out to 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. **Biomes and towns** — biome zoning by height and temperature; tiered town placement (Capitol, 5. **Water bodies** — promotes the classified water into explicit data: the ocean as body 1, each
lake connected-component labeled (same 4-connectivity as the true-ocean fill), one transitional
surface level per body. Classification comes from the shared `IsOceanPixel`/`IsLakePixel`
predicates that the biome stage also uses, so the two can never disagree. A priority-flood
pit-fill runs here as validated diagnostics (basin statistics to console; serializes nothing).
Drops the `0_water` snapshot.
6. **Biomes and towns** — biome zoning by height and temperature; tiered town placement (Capitol,
Hubs, Villages, Outposts, POIs) filtered by slope, water proximity and spacing. Hubs, Villages, Outposts, POIs) filtered by slope, water proximity and spacing.
6. **Roads** — see below. 7. **Roads** — see below. Skipped entirely when the `SkipRoads` config toggle is on (the ~25-min
7. **Export** — writes the `.dat` blueprint (dual-write: the v2 tagged container under the primary A\* pass is the bottleneck; a skip run exports a road-less iteration blueprint in ~80 s with a
loud console banner).
8. **Export** — writes the `.dat` blueprint (dual-write: the v2 tagged container under the primary
seed name, plus the legacy v1 format beside it as `_v1.dat` — see 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).
@ -55,8 +70,11 @@ 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 `MapData_Seed_<seed>.dat` (v2), `MapData_Seed_<seed>_v1.dat` (legacy dual-write), and the staged
`Map_Seed_<seed>.png`, all to `user://`. snapshots `Map_Seed_<seed>_{0_height,0_water,1_biomes,2_towns,3_roads}.png`, all to `user://`.
(`0_height` is the hypsometric hillshade of the curved terrain; `0_water` is painted from the
water stage's own outputs — ocean deep blue, lakes lighter blue, land neutral; `3_roads` is
absent on a SkipRoads run.)
## `RoundTripHarness.cs` ## `RoundTripHarness.cs`

View file

@ -119,6 +119,8 @@ 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, " +
@ -127,6 +129,86 @@ 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)