Compare commits

..

No commits in common. "1b98fb5a17e60fc2bb32593a8b24b03452038153" and "5720f05a5b42108bd8a4e71e6fe906a2054aa2a2" have entirely different histories.

10 changed files with 214 additions and 736 deletions

View file

@ -55,8 +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 base: `u16 curveVersion` · `f32 knot1..knot4` · `f32 spikeMax` · `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 benchLo` · `f32 benchHi` · `f32 peakCap` · `f32 tailSlope`. **When `curveVersion ≥ 4`, a 36 B modulation extension follows:** `f32 benchAmp` · `f32 plateauAmp` · `f32 shelfSpanMin` · `f32 shelfSpanMax` · `f32 elevFreqIslands` · `f32 strengthFreqIslands` · `i32 benchSeedOffset` · `i32 plateauSeedOffset` · `i32 strengthSeedOffset`. **When `curveVersion ≥ 5`, a 9 B preset extension follows:** `u8 presetId` (1 = compact, 2 = balanced) · `f32 k5` · `f32 k6` — with the four base knot slots this makes the effective curve unambiguous from the record alone (record total 95 B; the length-framed section + version byte keep every layout change safe) | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v3"`); absent = raw legacy profile. **The `u16 curveVersion` selects the field semantics and the unserialized anchor set:** v1 — knots t1..t4, spikeMax = pooled calibration max, benchLo/Hi = the 50 m plateau's lo/hi. v2 — as v1 but spikeMax = the seed's effective raw pre-curve maximum (per-seed spike normalizer). v3 — knots = K1..K4 of six (K5 = 0.930304, K6 = 1.050720 are version constants, not serialized), spikeMax per-seed, benchLo/benchHi = the fixed 100 m / 220 m shelves. **v4 (current)** — as v3, but benchLo/benchHi are the shelf **BASE** anchors and the extension record carries the spatial-modulation parameters: shelf elevations vary ±benchAmp/±plateauAmp and shelf strength blends the output span across `[shelfSpanMin, shelfSpanMax]`, via Simplex fields seeded `resolvedWorldSeed + seedOffset` at `freq/MapSize` (frequencies stated in undulations per island width). Because spikeMax is per-seed (v2+), blueprints are not reproducible from curve constants alone — that is why it is recorded. **Metadata only:** exported heights are already curved; nothing re-applies the map. Calibration provenance: `HeightCurve.cs` header + the task-05/06/07 reports. | | `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). |
| `TDTL` / `0x4C544454` | 38 B: `u16 detailVersion` · `f32 reliefAmpM` · `f32 reliefFreqIslands` · `f32 incK` · `f32 incP` · `f32 incCapM` · `f32 seaClampRaw` · `f32 craterExclFactor` · `f32 shelfIncWeight` · `i32 reliefSeedOffset` | Optional — present iff the terrain detail passes shaped this blueprint's `HGTS` (config `TerrainDetail: "v1"`, requires the curve): shelf micro-relief (±reliefAmpM, shelf-ness weighted) and D8 drainage incision (`depth = K·accum^p·slope`, capped, riser-masked, sea+1 m clamped, crater-excluded). **Metadata only** — heights are already detailed; the incision channels are the designated future river routes (Phase C). Machinery: `Tools/Scripts/TerrainDetailPass.cs`. |
| `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²`. | | `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. | | `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. | | `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. |
@ -107,17 +106,12 @@ body).
which model produced it (the `WBTB` levels are the observable). which model produced it (the `WBTB` levels are the observable).
- **`SkipRoads`** (bool, default false) — exports with present-but-empty road sections (see the - **`SkipRoads`** (bool, default false) — exports with present-but-empty road sections (see the
road-section table note). road-section table note).
- **`TerrainCurve`** (`"v5"` | `"off"`, default `"v5"` — the task-09 gate's winner, the - **`TerrainCurve`** (`"v2"` | `"off"`, default `"v2"`; `"v1"` retired by the task-06
BALANCED preset; the tri-state and `"v1"``"v4"` are retired and rejected loudly) — whether recalibration and rejected loudly) — whether the height-redistribution curve (storm ladder:
the height-redistribution curve (terraced ascent, corner-eased junctions, spatially modulated 75 % orange coverage, 50 m plateau, **per-seed-normalized 420 m peaks**) shapes `HGTS`.
shelves at 100±12 m / 220±20 m, per-seed-normalized 420 m summit spires, 60 % lowland) shapes Biome/water classification is curve-invariant by construction (it classifies the retained
`HGTS`. uncurved heights); town positions and everything 3D follow the curved terrain. When on,
- **`TerrainDetail`** (`"v1"` | `"off"`, default `"v1"`; no-op without the curve) — the task-10 `TCRV` records the effective parameters including the per-seed `spikeMax`.
detail passes as one judged unit: shelf micro-relief (`ShelfReliefAmp` metres, default 3) and
drainage incision. When on, `TDTL` records the parameters. 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`) ### Deliberately NOT a section: basins (`BSIN`)

View file

@ -33,7 +33,6 @@ namespace IslaApocalypse.Core
public const uint TAG_WATER_BODY_TABLE = 0x42544257; // "WBTB" public const uint TAG_WATER_BODY_TABLE = 0x42544257; // "WBTB"
public const uint TAG_WATER_SURFACE = 0x46525357; // "WSRF" public const uint TAG_WATER_SURFACE = 0x46525357; // "WSRF"
public const uint TAG_TERRAIN_CURVE = 0x56524354; // "TCRV" public const uint TAG_TERRAIN_CURVE = 0x56524354; // "TCRV"
public const uint TAG_TERRAIN_DETAIL = 0x4C544454; // "TDTL"
// WSRF quantization: u16, 0 reserved as the no-water sentinel. A real level L // 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 // (raw blueprint height units) encodes as 1 + round(L × 32768), so a genuine

View file

@ -33,8 +33,6 @@ namespace IslaApocalypse.Core
WriteSection(writer, BlueprintFormat.TAG_PARAMS, w => WriteParams(w, bp)); WriteSection(writer, BlueprintFormat.TAG_PARAMS, w => WriteParams(w, bp));
if (bp.TerrainCurve != null) if (bp.TerrainCurve != null)
WriteSection(writer, BlueprintFormat.TAG_TERRAIN_CURVE, w => WriteTerrainCurve(w, bp.TerrainCurve)); WriteSection(writer, BlueprintFormat.TAG_TERRAIN_CURVE, w => WriteTerrainCurve(w, bp.TerrainCurve));
if (bp.TerrainDetail != null)
WriteSection(writer, BlueprintFormat.TAG_TERRAIN_DETAIL, w => WriteTerrainDetail(w, bp.TerrainDetail));
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));
@ -134,33 +132,6 @@ namespace IslaApocalypse.Core
writer.Write(c.Sea); writer.Write(c.OrangeCeil); writer.Write(c.RedCeil); 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.PlateauLo); writer.Write(c.PlateauHi); writer.Write(c.PeakCap);
writer.Write(c.TailSlope); writer.Write(c.TailSlope);
// v4+ extension: shelf-modulation parameters.
if (c.Version >= 4)
{
writer.Write(c.BenchAmp); writer.Write(c.PlateauAmp);
writer.Write(c.ShelfSpanMin); writer.Write(c.ShelfSpanMax);
writer.Write(c.ElevFreqIslands); writer.Write(c.StrengthFreqIslands);
writer.Write(c.BenchSeedOffset); writer.Write(c.PlateauSeedOffset);
writer.Write(c.StrengthSeedOffset);
}
// v5+ extension: knot preset id + K5/K6.
if (c.Version >= 5)
{
writer.Write(c.PresetId);
writer.Write(c.K5); writer.Write(c.K6);
}
}
private static void WriteTerrainDetail(BinaryWriter writer, TerrainDetailInfo d)
{
writer.Write(d.Version);
writer.Write(d.ReliefAmpM); writer.Write(d.ReliefFreqIslands);
writer.Write(d.IncK); writer.Write(d.IncP); writer.Write(d.IncCapM);
writer.Write(d.SeaClampRaw); writer.Write(d.CraterExclFactor);
writer.Write(d.ShelfIncWeight);
writer.Write(d.ReliefSeedOffset);
} }
private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp) private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp)

View file

@ -26,19 +26,12 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
public static string SeaLevelModel = "flat"; public static string SeaLevelModel = "flat";
public static float SeaLevelValue = 0.15f; public static float SeaLevelValue = 0.15f;
// Height-redistribution curve (tasks 0510, graduation M-7): "v5" is the // Height-redistribution curve (tasks 05/06, graduation M-7): "v2" applies the
// task-09 gate's winner — the BALANCED terraced ascent with corner easing and // calibrated storm-ladder curve with the per-seed peak spike (HeightCurve.cs);
// modulated shelves; "off" is the raw legacy profile. "v1""v4" and the // "off" is the raw legacy profile. "v1" was dropped with the v2 recalibration
// taste-batch tri-state ("v5-compact" lost, "v5-balanced" became plain "v5") // (task-05 blueprints are regenerable). Biome classification is curve-invariant
// are retired. Default: v5. // by construction either way. Default: v2.
public static string TerrainCurve = "v5"; public static string TerrainCurve = "v2";
// Terrain detail passes (task 10): "v1" = shelf micro-relief + drainage
// incision as one judged unit (requires the curve; no-op when it is off);
// "off" disables both. ShelfReliefAmp is the micro-relief amplitude in
// metres. Defaults: v1, 3 m.
public static string TerrainDetail = "v1";
public static float ShelfReliefAmp = 3.0f;
public static void LoadConfig() public static void LoadConfig()
{ {
@ -113,32 +106,14 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
if (data.ContainsKey("TerrainCurve")) if (data.ContainsKey("TerrainCurve"))
{ {
string curve = (string)data["TerrainCurve"]; string curve = (string)data["TerrainCurve"];
if (curve == "off" || curve == "v5") if (curve == "off" || curve == "v2")
TerrainCurve = curve; TerrainCurve = curve;
else if (curve == "v5-balanced") else if (curve == "v1")
GD.PrintErr($"[ConfigManager] TerrainCurve 'v5-balanced' won the task-09 gate and is now plain \"v5\". Keeping '{TerrainCurve}'."); GD.PrintErr($"[ConfigManager] TerrainCurve 'v1' was retired by the v2 recalibration (task 06). Keeping '{TerrainCurve}' — use \"v2\" or \"off\".");
else if (curve == "v5-compact")
GD.PrintErr($"[ConfigManager] TerrainCurve 'v5-compact' was retired by the task-09 verdict (BALANCED won). Keeping '{TerrainCurve}' — use \"v5\" or \"off\".");
else if (curve == "v1" || curve == "v2" || curve == "v3" || curve == "v4")
GD.PrintErr($"[ConfigManager] TerrainCurve '{curve}' was retired by a later recalibration (v5, tasks 09/10). Keeping '{TerrainCurve}' — use \"v5\" or \"off\".");
else else
GD.PrintErr($"[ConfigManager] Unknown TerrainCurve '{curve}'. Keeping '{TerrainCurve}'."); GD.PrintErr($"[ConfigManager] Unknown TerrainCurve '{curve}'. Keeping '{TerrainCurve}'.");
} }
// Extract the terrain-detail gate + relief amplitude
if (data.ContainsKey("TerrainDetail"))
{
string detail = (string)data["TerrainDetail"];
if (detail == "off" || detail == "v1")
TerrainDetail = detail;
else
GD.PrintErr($"[ConfigManager] Unknown TerrainDetail '{detail}'. Keeping '{TerrainDetail}'.");
}
if (data.ContainsKey("ShelfReliefAmp"))
{
ShelfReliefAmp = (float)data["ShelfReliefAmp"];
}
switch (profile) switch (profile)
{ {
case "4K": case "4K":

View file

@ -74,31 +74,6 @@ namespace IslaApocalypse.Core
// constants alone, which is exactly why it is recorded here. // constants alone, which is exactly why it is recorded here.
public float T1, T2, T3, T4, SpikeMax; public float T1, T2, T3, T4, SpikeMax;
public float Sea, OrangeCeil, RedCeil, PlateauLo, PlateauHi, PeakCap, TailSlope; // output bands public float Sea, OrangeCeil, RedCeil, PlateauLo, PlateauHi, PeakCap, TailSlope; // output bands
// v4 extension — shelf-modulation parameters (record layout versioned by the
// curve Version byte; zero-valued when parsing an older record).
public float BenchAmp, PlateauAmp, ShelfSpanMin, ShelfSpanMax;
public float ElevFreqIslands, StrengthFreqIslands;
public int BenchSeedOffset, PlateauSeedOffset, StrengthSeedOffset;
// v5 extension — knot preset (1 = compact, 2 = balanced) and the two knots the
// four base slots cannot carry; with these the effective curve is unambiguous.
public byte PresetId;
public float K5, K6;
}
/// <summary>
/// The terrain detail passes that shaped this blueprint's HGTS (v2 TDTL section,
/// terrain-water task 10): shelf micro-relief + drainage incision parameters.
/// Null when detail was off. Metadata only — heights are already detailed.
/// </summary>
public class TerrainDetailInfo
{
public ushort Version;
public float ReliefAmpM, ReliefFreqIslands;
public float IncK, IncP, IncCapM;
public float SeaClampRaw, CraterExclFactor, ShelfIncWeight;
public int ReliefSeedOffset;
} }
public class WorldBlueprint public class WorldBlueprint
@ -128,9 +103,6 @@ namespace IslaApocalypse.Core
// The curve that shaped HeightMap (v2 TCRV section); null = raw legacy profile. // The curve that shaped HeightMap (v2 TCRV section); null = raw legacy profile.
public TerrainCurveInfo TerrainCurve; public TerrainCurveInfo TerrainCurve;
// The detail passes that shaped HeightMap (TDTL section); null = no detail.
public TerrainDetailInfo TerrainDetail;
} }
// 2. The Parser Utility // 2. The Parser Utility
@ -254,7 +226,6 @@ namespace IslaApocalypse.Core
else if (tag == BlueprintFormat.TAG_WATER_SURFACE) sectionOk = ParseWaterGrid(reader, blueprint, payloadLength, isSurface: true); 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_WATER_BODY_TABLE) sectionOk = ParseWaterBodyTable(reader, blueprint);
else if (tag == BlueprintFormat.TAG_TERRAIN_CURVE) sectionOk = ParseTerrainCurve(reader, blueprint); else if (tag == BlueprintFormat.TAG_TERRAIN_CURVE) sectionOk = ParseTerrainCurve(reader, blueprint);
else if (tag == BlueprintFormat.TAG_TERRAIN_DETAIL) sectionOk = ParseTerrainDetail(reader, blueprint);
else else
{ {
// The property the redesign exists to buy: future sections (water, // The property the redesign exists to buy: future sections (water,
@ -428,39 +399,10 @@ namespace IslaApocalypse.Core
c.RedCeil = reader.ReadSingle(); c.PlateauLo = reader.ReadSingle(); c.RedCeil = reader.ReadSingle(); c.PlateauLo = reader.ReadSingle();
c.PlateauHi = reader.ReadSingle(); c.PeakCap = reader.ReadSingle(); c.PlateauHi = reader.ReadSingle(); c.PeakCap = reader.ReadSingle();
c.TailSlope = reader.ReadSingle(); c.TailSlope = reader.ReadSingle();
// v4+ extension: shelf-modulation parameters (older records simply end here —
// the section is length-framed, so the version byte is the dispatcher).
if (c.Version >= 4)
{
c.BenchAmp = reader.ReadSingle(); c.PlateauAmp = reader.ReadSingle();
c.ShelfSpanMin = reader.ReadSingle(); c.ShelfSpanMax = reader.ReadSingle();
c.ElevFreqIslands = reader.ReadSingle(); c.StrengthFreqIslands = reader.ReadSingle();
c.BenchSeedOffset = reader.ReadInt32(); c.PlateauSeedOffset = reader.ReadInt32();
c.StrengthSeedOffset = reader.ReadInt32();
}
if (c.Version >= 5)
{
c.PresetId = reader.ReadByte();
c.K5 = reader.ReadSingle(); c.K6 = reader.ReadSingle();
}
blueprint.TerrainCurve = c; blueprint.TerrainCurve = c;
return true; return true;
} }
private static bool ParseTerrainDetail(BinaryReader reader, WorldBlueprint blueprint)
{
var d = new TerrainDetailInfo();
d.Version = reader.ReadUInt16();
d.ReliefAmpM = reader.ReadSingle(); d.ReliefFreqIslands = reader.ReadSingle();
d.IncK = reader.ReadSingle(); d.IncP = reader.ReadSingle(); d.IncCapM = reader.ReadSingle();
d.SeaClampRaw = reader.ReadSingle(); d.CraterExclFactor = reader.ReadSingle();
d.ShelfIncWeight = reader.ReadSingle();
d.ReliefSeedOffset = reader.ReadInt32();
blueprint.TerrainDetail = d;
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

@ -1,183 +1,135 @@
using Godot; using Godot;
/// <summary> /// <summary>
/// One preset's input knots for the v5 curve. Two presets exist for the task-09 /// The height-redistribution curve, v2 (terrain-water task 06) — a pure, static,
/// taste batch — COMPACT (maximum lowland, cordillera-from-plains) and BALANCED /// monotonic piecewise map over raw blueprint heights (D-035: numbers in, numbers
/// (the task-07/08 lineage, gradual highland approach). The loser retires after /// out; the per-seed spike maximum is an explicit PARAMETER, not hidden state).
/// the developer's gate; the winner becomes plain "v5".
/// </summary>
public sealed class CurveKnots
{
public readonly byte PresetId;
public readonly string Name;
public readonly float K1, K2, K3, K4, K5, K6;
public CurveKnots(byte id, string name, float k1, float k2, float k3, float k4, float k5, float k6)
{
PresetId = id; Name = name;
K1 = k1; K2 = k2; K3 = k3; K4 = k4; K5 = k5; K6 = k6;
}
}
/// <summary>
/// The height-redistribution curve, v5 (terrain-water task 09) — v4's spatially
/// modulated shelves plus the THREE CORNER FIXES, with PRESET-PARAMETERIZED knots.
/// Pure, static, monotonic; every per-column input and the knot set are explicit
/// PARAMETERS (D-035).
/// ///
/// The corner fixes (both presets — the naturalness work): /// v2 changes (developer's task-05 hillshade-gate verdict; everything below the
/// 1. Riser endpoint slope floor 0.2 → 0.1 (blend 0.1u + 0.9·smoothstep): climbs /// plateau step is behaviorally byte-identical to v1):
/// decelerate into shelves and accelerate out of them — no machined edges. /// - PER-SEED SPIKE NORMALIZATION: the spike's input domain runs from t4 to the
/// 2. Summit-spike base floor 0.1 → 0.05 (blend 0.05u + 0.95·u⁴): the spike /// current seed's own raw pre-curve maximum (hMaxSeed), so every island's
/// leaves the plateau gently — no hard skirt under the peaks. /// tallest pixel reaches the ceiling — v1 mapped against the pooled
/// 3. SHELF_SPAN_MIN 2 m → 6 m: pronounced shelves keep a gentle tilt — flat to /// calibration max and mid-range seeds topped out at 110175 m.
/// build on, never snooker-table flat. /// - 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).
/// ///
/// Presets (input land-fraction targets; knots calibrated 2026-08-08 from the same /// Lower knots/bands are v1's, calibrated 2026-08-07 from batch 04's ten flat-sea
/// pooled batch-04 flat-sea land CDF as tasks 05/07, 340,618,126 samples; achieved /// heightmaps (pooled above-sea land CDF, 340,618,126 samples): P75/P90/P93/P96.
/// fractions exact by construction):
/// COMPACT — 65/12/7/4/6/3/3 (orange/red/foothill-riser/bench/mid-riser/plateau/spike)
/// BALANCED — 60/13/10/5/8/3/1
/// ///
/// Unchanged from v4: storm-ladder anchors, bench 100±12 m, plateau 220±20 m, /// Shape (strictly monotonic; every segment's normalized slope bounded below by a
/// strength modulation (span max 25 m), 420 m cap, per-seed spike normalization, /// positive constant; asserted numerically per generation against the EFFECTIVE
/// modulation fields/seed offsets, the classify-map invariant. /// 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> /// </summary>
public static class HeightCurve public static class HeightCurve
{ {
public const ushort VERSION = 5; public const ushort VERSION = 2;
// The task-09 taste gate's WINNER: BALANCED (id 2). COMPACT retired with the // Input knots — v1 calibration, unchanged (see class header).
// verdict; its knots survive only in the task-09 report/batch for the record. public const float T1 = 0.628736f; // P75 — orange coverage boundary
public static readonly CurveKnots V5 = new CurveKnots(2, "balanced", public const float T2 = 0.819152f; // P90
0.515899f, 0.612157f, 0.710472f, 0.784045f, 0.962922f, 1.119118f); // P60/73/83/88/96/99 public const float T3 = 0.879340f; // P93
public const float T4 = 0.962922f; // P96
// Fixed output anchors — storm ladder + ceiling (frozen). // Output bands — the storm ladder. Lower anchors unchanged from v1.
public const float SEA = 0.15f; public const float SEA = 0.15f;
public const float ORANGE_CEIL = 0.206f; public const float ORANGE_CEIL = 0.206f; // 1000-yr storm ceiling
public const float RED_CEIL = 0.27f; public const float RED_CEIL = 0.27f; // biblical ceiling
public const float PEAK_CAP = SEA + 420f / 251f; public const float PLATEAU_LO = SEA + 50f / 251f; // ≈ 0.34924 (50 m above sea)
public const float TAIL_SLOPE = 0.25f; 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; public const float SPIKE_MIN_SPAN = 0.01f;
// Modulated shelf anchors (unchanged from v4). /// <summary>The effective spike-domain top for a seed's raw maximum, guard applied.</summary>
public const float BENCH_BASE = SEA + 100f / 251f; public static float EffectiveSpikeMax(float hMaxSeed)
public const float BENCH_AMP = 12f / 251f;
public const float PLATEAU_BASE = SEA + 220f / 251f;
public const float PLATEAU_AMP = 20f / 251f;
// Corner fix 3: pronounced shelves keep ~6 m of tilt across the shelf band.
public const float SHELF_SPAN_MIN = 6f / 251f; // ≈ 0.0239 (v4: 0.008 ≈ 2 m)
public const float SHELF_SPAN_MAX = 0.10f;
// Modulation-field derivation (unchanged from v4).
public const int BENCH_SEED_OFFSET = 7101;
public const int PLATEAU_SEED_OFFSET = 7207;
public const int STRENGTH_SEED_OFFSET = 7303;
public const float ELEV_FREQ_ISLANDS = 3.0f;
public const float STRENGTH_FREQ_ISLANDS = 5.0f;
public static float EffectiveSpikeMax(float hMaxSeed, CurveKnots k)
{ {
return Mathf.Max(hMaxSeed, k.K6 + SPIKE_MIN_SPAN); return Mathf.Max(hMaxSeed, T4 + SPIKE_MIN_SPAN);
} }
public static float ShelfSpan(float strength01) /// <param name="h">Raw pre-curve height.</param>
{ /// <param name="hMaxSeed">The seed's raw pre-curve maximum (post noise/falloff/Trench/spine,
return Mathf.Lerp(SHELF_SPAN_MAX, SHELF_SPAN_MIN, Mathf.Clamp(strength01, 0f, 1f)); /// pre-carve) — the same field the curve consumes. Makes the map seed-dependent (v2).</param>
} public static float Apply(float h, float hMaxSeed)
public static float Apply(float h, float hMaxSeed,
float benchLo, float benchSpan, float plateauLo, float plateauSpan, CurveKnots k)
{ {
if (h <= SEA) return h; if (h <= SEA) return h;
float u, s; float u, s;
if (h < k.K1) if (h < T1)
{ {
u = (h - SEA) / (k.K1 - SEA); u = (h - SEA) / (T1 - SEA);
s = 0.3f * u + 0.7f * (u * (2f - u)); // frozen ease-out toe s = 0.3f * u + 0.7f * (u * (2f - u)); // ease-out, slope ≥ 0.3
return SEA + s * (ORANGE_CEIL - SEA); return SEA + s * (ORANGE_CEIL - SEA);
} }
if (h < k.K2) if (h < T2)
{ {
u = (h - k.K1) / (k.K2 - k.K1); u = (h - T1) / (T2 - T1);
return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // frozen linear rise return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // linear
} }
if (h < k.K3) if (h < T3)
{ {
u = (h - k.K2) / (k.K3 - k.K2); u = (h - T2) / (T3 - T2);
s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // foothill riser — corner fix 1 s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // smoothstep blend, slope ≥ 0.2
return RED_CEIL + s * (benchLo - RED_CEIL); return RED_CEIL + s * (PLATEAU_LO - RED_CEIL);
} }
if (h < k.K4) if (h < T4)
{ {
u = (h - k.K3) / (k.K4 - k.K3); u = (h - T3) / (T4 - T3);
return benchLo + u * benchSpan; // bench (min span 6 m — fix 3) return PLATEAU_LO + u * (PLATEAU_HI - PLATEAU_LO); // near-flat, small positive slope
} }
float benchTop = benchLo + benchSpan; float spikeMax = EffectiveSpikeMax(hMaxSeed);
if (h < k.K5)
{
u = (h - k.K4) / (k.K5 - k.K4);
s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // mid riser — corner fix 1
return benchTop + s * (plateauLo - benchTop);
}
if (h < k.K6)
{
u = (h - k.K5) / (k.K6 - k.K5);
return plateauLo + u * plateauSpan; // plateau
}
float plateauTop = plateauLo + plateauSpan;
float spikeMax = EffectiveSpikeMax(hMaxSeed, k);
if (h < spikeMax) if (h < spikeMax)
{ {
u = (h - k.K6) / (spikeMax - k.K6); u = (h - T4) / (spikeMax - T4);
s = 0.05f * u + 0.95f * (u * u * u * u); // summit spike — corner fix 2 s = 0.1f * u + 0.9f * (u * u * u * u); // ease-in u⁴ wall, slope ≥ 0.1
return plateauTop + s * (PEAK_CAP - plateauTop); return PLATEAU_HI + s * (PEAK_CAP - PLATEAU_HI);
} }
return PEAK_CAP + (h - spikeMax) * TAIL_SLOPE; return PEAK_CAP + (h - spikeMax) * TAIL_SLOPE;
} }
/// <summary> /// <summary>
/// Per-generation numeric strict-monotonicity check of the EFFECTIVE curve for /// Numeric strict-monotonicity check of the EFFECTIVE per-seed curve — call once
/// the selected preset: all 8 modulation-extreme corners × per-seed spikeMax. /// per generation after hMaxSeed is known, before the curve pass. A violation is
/// The corner fixes lower the slope floors (risers 0.1, spike base 0.05) — the /// a build bug, not a data condition — fail loudly and refuse to generate.
/// sweep proves they stay strictly positive everywhere. Loud throw on failure.
/// </summary> /// </summary>
public static void AssertMonotonic(float hMaxSeed, CurveKnots k) public static void AssertMonotonic(float hMaxSeed)
{ {
float[] benchLos = { BENCH_BASE - BENCH_AMP, BENCH_BASE + BENCH_AMP }; float prevH = -7f;
float[] plateauLos = { PLATEAU_BASE - PLATEAU_AMP, PLATEAU_BASE + PLATEAU_AMP }; float prev = Apply(prevH, hMaxSeed);
float[] spans = { SHELF_SPAN_MIN, SHELF_SPAN_MAX };
foreach (float bl in benchLos) // 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)
{ {
foreach (float pl in plateauLos) float h = (float)hd;
{ if (h <= prevH) return;
foreach (float sp in spans) float v = Apply(h, hMaxSeed);
{ if (v <= prev)
float prevH = -7f; throw new System.InvalidOperationException(
float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k); $"[HeightCurve] MONOTONICITY VIOLATION at h={h} (hMaxSeed={hMaxSeed}): {v} <= {prev}. Refusing to generate.");
prev = v;
void Check(double hd) prevH = h;
{
float h = (float)hd;
if (h <= prevH) return; // dedupe float32 samples (task-05 fix)
float v = Apply(h, hMaxSeed, bl, sp, pl, sp, k);
if (v <= prev)
throw new System.InvalidOperationException(
$"[HeightCurve] MONOTONICITY VIOLATION at h={h} (preset {k.Name}, hMaxSeed={hMaxSeed}, benchLo={bl}, plateauLo={pl}, span={sp}): {v} <= {prev}. Refusing to generate.");
prev = v;
prevH = h;
}
double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed, k) + 0.5);
for (double hh = -7.0 + 0.01; hh < 0.10; hh += 0.01) Check(hh);
for (double hh = 0.10; hh <= top; hh += 0.0001) Check(hh);
for (double hh = top + 0.05; hh <= top + 6.0; hh += 0.05) Check(hh);
}
}
} }
GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION} preset '{k.Name}', 8 modulation corners, effective spikeMax {EffectiveSpikeMax(hMaxSeed, k):F6}).");
// 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

@ -37,24 +37,6 @@ public partial class MapGenerator : TextureRect
private float[,] _heightMapClassify; private float[,] _heightMapClassify;
private bool _curveOn; private bool _curveOn;
// Curve-v4 shelf-modulation fields (task 08): two decorrelated elevation fields
// (bench 100±12 m, plateau 220±20 m) plus a strength field blending each shelf
// between pronounced-flat and barely-a-hint. Seeds derive from the RESOLVED
// noise seed + fixed offsets (no config knob); frequencies scale with MapSize.
// Sampled per column in pass 2 only — the classify path never sees them.
private FastNoiseLite _benchNoise;
private FastNoiseLite _plateauNoise;
private FastNoiseLite _strengthNoise;
// v5: the selected knot preset; null = curve off.
private CurveKnots _curveKnots;
// Task-10 detail passes (shelf micro-relief + drainage incision): gated by
// TerrainDetail, active only with the curve on (the masks are curve-band
// defined). The relief noise seeds from resolvedSeed + 7409.
private bool _detailOn;
private FastNoiseLite _reliefNoise;
// The seed's raw pre-curve height maximum (post noise/falloff/Trench/spine, // 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 // pre-carve) — the v2 curve's per-seed spike normalizer. Computed in
// GenerateTopography pass 1; recorded in TCRV (effective, guard applied). // GenerateTopography pass 1; recorded in TCRV (effective, guard applied).
@ -93,8 +75,7 @@ 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];
_curveKnots = ConfigManager.TerrainCurve == "v5" ? HeightCurve.V5 : null; _curveOn = ConfigManager.TerrainCurve == "v2";
_curveOn = _curveKnots != null;
// (The monotonicity assertion now runs inside GenerateTopography, against the // (The monotonicity assertion now runs inside GenerateTopography, against the
// effective per-seed curve, once hMaxSeed is known.) // effective per-seed curve, once hMaxSeed is known.)
_heightMapClassify = _curveOn ? new float[MapSize, MapSize] : _heightMap; _heightMapClassify = _curveOn ? new float[MapSize, MapSize] : _heightMap;
@ -105,24 +86,12 @@ public partial class MapGenerator : TextureRect
float scaleFactor = MapSize / 1024f; // Equals 4 float scaleFactor = MapSize / 1024f; // Equals 4
// --- MASTER SEED & DETAILED NOISE (Restored!) --- // --- MASTER SEED & DETAILED NOISE (Restored!) ---
_noise = new FastNoiseLite(); _noise = new FastNoiseLite();
_noise.Seed = ConfigManager.WorldSeed == 0 ? (int)GD.Randi() : ConfigManager.WorldSeed; _noise.Seed = ConfigManager.WorldSeed == 0 ? (int)GD.Randi() : ConfigManager.WorldSeed;
_noise.NoiseType = FastNoiseLite.NoiseTypeEnum.Simplex; _noise.NoiseType = FastNoiseLite.NoiseTypeEnum.Simplex;
_noise.Frequency = 0.004f / scaleFactor; _noise.Frequency = 0.004f / scaleFactor;
if (_curveOn)
{
_benchNoise = MakeModulationNoise(HeightCurve.BENCH_SEED_OFFSET, HeightCurve.ELEV_FREQ_ISLANDS);
_plateauNoise = MakeModulationNoise(HeightCurve.PLATEAU_SEED_OFFSET, HeightCurve.ELEV_FREQ_ISLANDS);
_strengthNoise = MakeModulationNoise(HeightCurve.STRENGTH_SEED_OFFSET, HeightCurve.STRENGTH_FREQ_ISLANDS);
}
_detailOn = _curveOn && ConfigManager.TerrainDetail == "v1";
if (_detailOn)
_reliefNoise = MakeModulationNoise(TerrainDetailPass.RELIEF_SEED_OFFSET, TerrainDetailPass.RELIEF_FREQ_ISLANDS);
else if (ConfigManager.TerrainDetail == "v1" && !_curveOn)
GD.Print("[MapGenerator] TerrainDetail v1 requires the curve — no-op with TerrainCurve off.");
// THE CRATER FIX: Push it into the ocean (scales via percentage of MapSize!) // THE CRATER FIX: Push it into the ocean (scales via percentage of MapSize!)
// Supposedly! We will have to test this manually on other map sizes to confirm the crater is properly scaled and submerged on the north coast! // Supposedly! We will have to test this manually on other map sizes to confirm the crater is properly scaled and submerged on the north coast!
float randomX = (float)GD.RandRange(0.35f, 0.65f); float randomX = (float)GD.RandRange(0.35f, 0.65f);
@ -287,39 +256,14 @@ public partial class MapGenerator : TextureRect
TrailRoads = _trailPaths, TrailRoads = _trailPaths,
WaterBodyIds = _waterBodyIds, WaterBodyIds = _waterBodyIds,
WaterBodies = _waterBodies ?? new List<WaterBodyInfo>(), WaterBodies = _waterBodies ?? new List<WaterBodyInfo>(),
// TCRV under curve v4: knot slots carry K1..K4 (K5/K6 are version constants);
// the bench slots carry the two BASE anchors; the v4 extension record carries
// the modulation parameters (amplitudes, spans, frequencies, seed offsets) —
// the blueprint stays self-describing.
// TCRV v5: knot slots carry the PRESET's K1..K4; the extension carries the
// modulation params plus preset id and K5/K6 — the effective curve is
// unambiguous from the record alone.
TerrainCurve = _curveOn ? new TerrainCurveInfo TerrainCurve = _curveOn ? new TerrainCurveInfo
{ {
Version = HeightCurve.VERSION, Version = HeightCurve.VERSION,
T1 = _curveKnots.K1, T2 = _curveKnots.K2, T3 = _curveKnots.K3, T4 = _curveKnots.K4, T1 = HeightCurve.T1, T2 = HeightCurve.T2, T3 = HeightCurve.T3, T4 = HeightCurve.T4,
SpikeMax = HeightCurve.EffectiveSpikeMax(_hMaxSeed, _curveKnots), // per-seed SpikeMax = HeightCurve.EffectiveSpikeMax(_hMaxSeed), // per-seed (v2)
Sea = HeightCurve.SEA, OrangeCeil = HeightCurve.ORANGE_CEIL, RedCeil = HeightCurve.RED_CEIL, Sea = HeightCurve.SEA, OrangeCeil = HeightCurve.ORANGE_CEIL, RedCeil = HeightCurve.RED_CEIL,
PlateauLo = HeightCurve.BENCH_BASE, PlateauHi = HeightCurve.PLATEAU_BASE, PlateauLo = HeightCurve.PLATEAU_LO, PlateauHi = HeightCurve.PLATEAU_HI,
PeakCap = HeightCurve.PEAK_CAP, TailSlope = HeightCurve.TAIL_SLOPE, PeakCap = HeightCurve.PEAK_CAP, TailSlope = HeightCurve.TAIL_SLOPE
BenchAmp = HeightCurve.BENCH_AMP, PlateauAmp = HeightCurve.PLATEAU_AMP,
ShelfSpanMin = HeightCurve.SHELF_SPAN_MIN, ShelfSpanMax = HeightCurve.SHELF_SPAN_MAX,
ElevFreqIslands = HeightCurve.ELEV_FREQ_ISLANDS, StrengthFreqIslands = HeightCurve.STRENGTH_FREQ_ISLANDS,
BenchSeedOffset = HeightCurve.BENCH_SEED_OFFSET, PlateauSeedOffset = HeightCurve.PLATEAU_SEED_OFFSET,
StrengthSeedOffset = HeightCurve.STRENGTH_SEED_OFFSET,
PresetId = _curveKnots.PresetId, K5 = _curveKnots.K5, K6 = _curveKnots.K6
} : null,
TerrainDetail = _detailOn ? new TerrainDetailInfo
{
Version = TerrainDetailPass.VERSION,
ReliefAmpM = ConfigManager.ShelfReliefAmp,
ReliefFreqIslands = TerrainDetailPass.RELIEF_FREQ_ISLANDS,
IncK = TerrainDetailPass.INC_K, IncP = TerrainDetailPass.INC_P,
IncCapM = TerrainDetailPass.INC_CAP_M,
SeaClampRaw = TerrainDetailPass.SEA_CLAMP,
CraterExclFactor = TerrainDetailPass.CRATER_EXCL_FACTOR,
ShelfIncWeight = TerrainDetailPass.SHELF_INC_WEIGHT,
ReliefSeedOffset = TerrainDetailPass.RELIEF_SEED_OFFSET
} : null, } : null,
FormatVersion = 2, FormatVersion = 2,
Params = new BlueprintParams Params = new BlueprintParams
@ -476,7 +420,7 @@ public partial class MapGenerator : TextureRect
// The v2 curve is SEED-DEPENDENT: its spike maps [t4, hMaxSeed] onto the peak // 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 // band, so the monotonicity assertion must run against the EFFECTIVE per-seed
// curve — after hMaxSeed is known, before any pixel is curved. // curve — after hMaxSeed is known, before any pixel is curved.
if (_curveOn) HeightCurve.AssertMonotonic(_hMaxSeed, _curveKnots); if (_curveOn) HeightCurve.AssertMonotonic(_hMaxSeed);
// --- PASS 2: curve (task 05/06) + crater carve --- // --- PASS 2: curve (task 05/06) + crater carve ---
// Curve applied AFTER noise + falloff + Trench, BEFORE the crater carve, so // Curve applied AFTER noise + falloff + Trench, BEFORE the crater carve, so
@ -484,54 +428,16 @@ public partial class MapGenerator : TextureRect
// the curve. Identity at and below sea + this ordering preserve the // the curve. Identity at and below sea + this ordering preserve the
// Trench/ocean-border guarantee and the crater by construction. classifyH // Trench/ocean-border guarantee and the crater by construction. classifyH
// stays uncurved — see _heightMapClassify; hMaxSeed never touches it. // stays uncurved — see _heightMapClassify; hMaxSeed never touches it.
// --- PASS 2a: curve + shelf micro-relief (task 10 pass A) ---
// classify stays RAW; curved gets the v5 curve plus, when TerrainDetail is on,
// the shelf-ness-weighted noise skin (risers and peaks untouched).
float reliefAmpRaw = ConfigManager.ShelfReliefAmp / 251f;
for (int x = 0; x < MapSize; x++)
{
for (int y = 0; y < MapSize; y++)
{
float raw = _heightMap[x, y];
float curvedH;
if (_curveOn)
{
// per-column shelf modulation (v4) — anchors and strength from the
// low-frequency fields; ordering safety by construction.
float benchLo = HeightCurve.BENCH_BASE + _benchNoise.GetNoise2D(x, y) * HeightCurve.BENCH_AMP;
float plateauLo = HeightCurve.PLATEAU_BASE + _plateauNoise.GetNoise2D(x, y) * HeightCurve.PLATEAU_AMP;
float shelfSpan = HeightCurve.ShelfSpan((_strengthNoise.GetNoise2D(x, y) + 1f) * 0.5f);
curvedH = HeightCurve.Apply(raw, _hMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan, _curveKnots);
if (_detailOn)
{
float wShelf = TerrainDetailPass.ShelfWeight(raw, _curveKnots);
if (wShelf > 0f)
curvedH += _reliefNoise.GetNoise2D(x, y) * reliefAmpRaw * wShelf;
}
}
else
{
curvedH = raw;
}
_heightMapClassify[x, y] = raw; // uncurved; carve joins in pass 2c
_heightMap[x, y] = curvedH;
}
}
// --- PASS 2b: drainage incision (task 10 pass B) ---
if (_curveOn && _detailOn)
RunIncisionPass();
// --- PASS 2c: THE CRATER CARVE (The Flooded Bay & Landbridge Fix!) ---
// The carve remains the FINAL authority on its own terrain: applied after
// curve/relief/incision, to both maps, with the original expression.
float physicalCraterRadius = _impactRadius * 0.80f; float physicalCraterRadius = _impactRadius * 0.80f;
for (int x = 0; x < MapSize; x++) for (int x = 0; x < MapSize; x++)
{ {
for (int y = 0; y < MapSize; y++) for (int y = 0; y < MapSize; y++)
{ {
float raw = _heightMap[x, y];
float classifyH = raw;
float curvedH = _curveOn ? HeightCurve.Apply(raw, _hMaxSeed) : raw;
// --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) ---
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter); 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!
@ -540,82 +446,16 @@ public partial class MapGenerator : TextureRect
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; float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f;
// Read BOTH before writing EITHER: with the curve off the two maps classifyH = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f);
// alias the same array, and a sequential read-modify-write carved curvedH = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f);
// the crater twice (caught by the task-10 continuity oracle).
float preClassify = _heightMapClassify[x, y];
float preCurved = _heightMap[x, y];
_heightMapClassify[x, y] = Mathf.Lerp(preClassify, carveTarget, craterDepth * 0.9f);
_heightMap[x, y] = Mathf.Lerp(preCurved, carveTarget, craterDepth * 0.9f);
} }
_heightMapClassify[x, y] = classifyH;
_heightMap[x, y] = curvedH;
} }
} }
} }
/// <summary>
/// Task-10 pass B: D8 flow accumulation over the curved+relieved land, then
/// depth = K · accum^p · slope, masked to the risers (shelves feathered to 30 %,
/// toe and peaks zero, crater excluded), capped, and clamped to sea + 1 m.
/// Prints its own MEASURED depth distribution — the tuning/report source.
/// </summary>
private void RunIncisionPass()
{
ulong t0 = Time.GetTicksMsec();
int n = MapSize;
int total = n * n;
float[] flat = new float[total];
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
flat[x * n + y] = _heightMap[x, y];
int[] accum = TerrainDetailPass.FlowAccumulation(flat, n, out float[] drop);
double accumSeconds = (Time.GetTicksMsec() - t0) / 1000.0;
float capRaw = TerrainDetailPass.INC_CAP_M / 251f;
float exclR = _impactRadius * TerrainDetailPass.CRATER_EXCL_FACTOR;
float featherR = _impactRadius * TerrainDetailPass.CRATER_FEATHER_FACTOR;
long incised = 0, clampHits = 0;
var depthsM = new System.Collections.Generic.List<float>(1 << 20);
for (int x = 0; x < n; x++)
{
for (int y = 0; y < n; y++)
{
float raw = _heightMapClassify[x, y];
float w = TerrainDetailPass.IncisionWeight(raw, _curveKnots);
if (w <= 0f) continue;
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
if (distToCrater < exclR) continue;
if (distToCrater < featherR)
w *= (distToCrater - exclR) / (featherR - exclR);
int i = x * n + y;
float depth = TerrainDetailPass.INC_K
* Mathf.Pow(accum[i], TerrainDetailPass.INC_P) * drop[i];
depth = Mathf.Min(depth, capRaw) * w;
if (depth <= 0f) continue;
float nh = _heightMap[x, y] - depth;
if (nh < TerrainDetailPass.SEA_CLAMP)
{
nh = TerrainDetailPass.SEA_CLAMP;
clampHits++;
}
float realized = _heightMap[x, y] - nh;
if (realized * 251f >= 0.5f) { incised++; depthsM.Add(realized * 251f); }
_heightMap[x, y] = nh;
}
}
depthsM.Sort();
float P(double q) => depthsM.Count == 0 ? 0 : depthsM[Mathf.Clamp((int)(q * depthsM.Count), 0, depthsM.Count - 1)];
double totalSeconds = (Time.GetTicksMsec() - t0) / 1000.0;
GD.Print($"{T()} [Incision] accumulation {accumSeconds:F1}s, total {totalSeconds:F1}s.");
GD.Print($"{T()} [Incision] incised cells (≥0.5 m): {incised}; depth m: p50 {P(0.5):F1}, p90 {P(0.9):F1}, p99 {P(0.99):F1}, max {(depthsM.Count > 0 ? depthsM[depthsM.Count - 1] : 0):F1}; sea-clamp hits {clampHits}.");
}
private void CalculateTrueOcean() private void CalculateTrueOcean()
{ {
Queue<Vector2I> queue = new Queue<Vector2I>(); Queue<Vector2I> queue = new Queue<Vector2I>();
@ -680,15 +520,6 @@ public partial class MapGenerator : TextureRect
// grid and the water-body grid holds BY CONSTRUCTION, not by parallel // grid and the water-body grid holds BY CONSTRUCTION, not by parallel
// implementations agreeing. // implementations agreeing.
// ===================================================================== // =====================================================================
private FastNoiseLite MakeModulationNoise(int seedOffset, float periodsPerIsland)
{
var n = new FastNoiseLite();
n.Seed = _noise.Seed + seedOffset; // deterministic from the RESOLVED seed
n.NoiseType = FastNoiseLite.NoiseTypeEnum.Simplex;
n.Frequency = periodsPerIsland / MapSize; // frequency stated in island-widths
return n;
}
private bool IsWaterPixel(int x, int y) => _heightMapClassify[x, y] < GetSeaLevel(_tempMap[x, y]); 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 IsOceanPixel(int x, int y) => IsWaterPixel(x, y) && _isTrueOcean[x, y];
private bool IsLakePixel(int x, int y) => IsWaterPixel(x, y) && !_isTrueOcean[x, y]; private bool IsLakePixel(int x, int y) => IsWaterPixel(x, y) && !_isTrueOcean[x, y];
@ -969,8 +800,7 @@ public partial class MapGenerator : TextureRect
Color shallowSea = new Color(0.25f, 0.45f, 0.65f); 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 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 tan = new Color(0.76f, 0.70f, 0.46f); // red band: tan
Color brown = new Color(0.55f, 0.41f, 0.28f); // foothill riser + bench: brown Color brown = new Color(0.55f, 0.41f, 0.28f); // shoulder + plateau: brown
Color darkBrown = new Color(0.42f, 0.32f, 0.24f); // mid riser: darker brown
Color white = new Color(0.97f, 0.97f, 0.98f); // peaks Color white = new Color(0.97f, 0.97f, 0.98f); // peaks
Color grey = new Color(0.72f, 0.72f, 0.70f); Color grey = new Color(0.72f, 0.72f, 0.70f);
@ -990,14 +820,10 @@ public partial class MapGenerator : TextureRect
} }
else if (h < HeightCurve.ORANGE_CEIL) tint = green; else if (h < HeightCurve.ORANGE_CEIL) tint = green;
else if (h < HeightCurve.RED_CEIL) tint = tan; else if (h < HeightCurve.RED_CEIL) tint = tan;
// v4 note: shelf anchors are spatially modulated, so these tint bands else if (h < HeightCurve.PLATEAU_HI) tint = brown;
// use the base±amplitude envelopes — approximate banding, visualization only.
else if (h < HeightCurve.BENCH_BASE + HeightCurve.BENCH_AMP) tint = brown; // riser + bench envelope
else if (h < HeightCurve.PLATEAU_BASE - HeightCurve.PLATEAU_AMP) tint = darkBrown; // mid riser
else else
{ {
// Plateau envelope through the summit spike to the 420 m cap. float t2 = Mathf.Clamp((h - HeightCurve.PLATEAU_HI) / (HeightCurve.PEAK_CAP - HeightCurve.PLATEAU_HI), 0f, 1f);
float t2 = Mathf.Clamp((h - (HeightCurve.PLATEAU_BASE - HeightCurve.PLATEAU_AMP)) / (HeightCurve.PEAK_CAP - HeightCurve.PLATEAU_BASE + HeightCurve.PLATEAU_AMP), 0f, 1f);
tint = grey.Lerp(white, t2); tint = grey.Lerp(white, t2);
} }
@ -1541,40 +1367,40 @@ public partial class MapGenerator : TextureRect
} }
private void SetBaseAStarWeights(AStarGrid2D astar) { private void SetBaseAStarWeights(AStarGrid2D astar) {
for (int x = 0; x < MapSize; x++) { for (int x = 0; x < MapSize; x++) {
for (int y = 0; y < MapSize; y++) { for (int y = 0; y < MapSize; y++) {
Biome b = _biomeMap[x, y]; Biome b = _biomeMap[x, y];
// 1. CRITICAL FIX: Make the crater physically impassable first! // 1. CRITICAL FIX: Make the crater physically impassable first!
if (b == Biome.Crater) { if (b == Biome.Crater) {
astar.SetPointSolid(new Vector2I(x, y), true); astar.SetPointSolid(new Vector2I(x, y), true);
continue; // "Skip the rest, go to next pixel" continue; // "Skip the rest, go to next pixel"
} }
// 2. Existing water/mainland checks // 2. Existing water/mainland checks
if (b == Biome.Ocean || b == Biome.Lake || !_isMainland[x, y]) { if (b == Biome.Ocean || b == Biome.Lake || !_isMainland[x, y]) {
astar.SetPointSolid(new Vector2I(x, y), true); astar.SetPointSolid(new Vector2I(x, y), true);
continue; // "Skip the rest, go to next pixel" continue; // "Skip the rest, go to next pixel"
} }
// 3. We only reach this point if it's mainland AND not a crater! // 3. We only reach this point if it's mainland AND not a crater!
float h = _heightMap[x, y]; float h = _heightMap[x, y];
float localSea = GetSeaLevel(_tempMap[x, y]); float localSea = GetSeaLevel(_tempMap[x, y]);
float normalizedElevation = Mathf.Clamp((h - localSea) / (1.0f - localSea), 0.0f, 1.0f); float normalizedElevation = Mathf.Clamp((h - localSea) / (1.0f - localSea), 0.0f, 1.0f);
// Simple, steep curve to avoid mountains // Simple, steep curve to avoid mountains
float weight = 1.0f + Mathf.Pow(normalizedElevation, 3.0f) * 400.0f; float weight = 1.0f + Mathf.Pow(normalizedElevation, 3.0f) * 400.0f;
// Push off the beach (not an insane wall, just enough to prefer grass) // Push off the beach (not an insane wall, just enough to prefer grass)
if (b == Biome.Beach) weight += 15.0f; if (b == Biome.Beach) weight += 15.0f;
// Let it path through the wasteland normally // Let it path through the wasteland normally
if (b == Biome.Wasteland) weight += 3.0f; if (b == Biome.Wasteland) weight += 3.0f;
astar.SetPointWeightScale(new Vector2I(x, y), weight); astar.SetPointWeightScale(new Vector2I(x, y), weight);
} }
} }
} }
private void PenalizePath(AStarGrid2D astar, Vector2[] path, int radius) private void PenalizePath(AStarGrid2D astar, Vector2[] path, int radius)
{ {
@ -1587,7 +1413,7 @@ public partial class MapGenerator : TextureRect
int ny = Mathf.Clamp((int)p.Y + dy, 0, MapSize - 1); int ny = Mathf.Clamp((int)p.Y + dy, 0, MapSize - 1);
var cell = new Vector2I(nx, ny); var cell = new Vector2I(nx, ny);
if (!astar.IsPointSolid(cell)) { if (!astar.IsPointSolid(cell)) {
// The true Iron Curtain // The true Iron Curtain
astar.SetPointWeightScale(cell, astar.GetPointWeightScale(cell) + 10000f); astar.SetPointWeightScale(cell, astar.GetPointWeightScale(cell) + 10000f);
} }
} }
@ -1662,67 +1488,67 @@ public partial class MapGenerator : TextureRect
{ {
float scaleMod = MapSize / 1024f; // equals 4 at 4096 float scaleMod = MapSize / 1024f; // equals 4 at 4096
// Trails: Thin brown // Trails: Thin brown
foreach (var path in _trailPaths) DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true); foreach (var path in _trailPaths) DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true);
// Rugged: Thicker dark brown // Rugged: Thicker dark brown
foreach (var path in _ruggedPaths) DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); foreach (var path in _ruggedPaths) DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true);
// Main Roads: Solid Black // Main Roads: Solid Black
foreach (var path in _branchPaths) DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); foreach (var path in _branchPaths) DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true);
// Highway: Thick Red // Highway: Thick Red
foreach (var path in _highwayPaths) DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); foreach (var path in _highwayPaths) DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true);
// Draw Towns // Draw Towns
foreach (var town in _towns) { foreach (var town in _towns) {
float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f);
radius *= scaleMod; // Scale the circles up! radius *= scaleMod; // Scale the circles up!
Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange); Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange);
if (town.Tier == TownTier.IslandLoot) c = Colors.Red; if (town.Tier == TownTier.IslandLoot) c = Colors.Red;
if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; }
DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black);
DrawCircle(town.Position, radius, c); DrawCircle(town.Position, radius, c);
} }
} }
} }
// Place this at the very bottom of the file! // Place this at the very bottom of the file!
public partial class MapDrawProxy : Control public partial class MapDrawProxy : Control
{ {
public MapGenerator Source; public MapGenerator Source;
public override void _Draw() public override void _Draw()
{ {
if (Source == null) return; if (Source == null) return;
float scaleMod = Source.MapSize / 1024f; float scaleMod = Source.MapSize / 1024f;
foreach (var path in Source._trailPaths) foreach (var path in Source._trailPaths)
DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true); DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true);
foreach (var path in Source._ruggedPaths) foreach (var path in Source._ruggedPaths)
DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true);
foreach (var path in Source._branchPaths) foreach (var path in Source._branchPaths)
DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true);
foreach (var path in Source._highwayPaths) foreach (var path in Source._highwayPaths)
DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true);
foreach (var town in Source._towns) foreach (var town in Source._towns)
{ {
float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f);
radius *= scaleMod; radius *= scaleMod;
Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange); Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange);
if (town.Tier == TownTier.IslandLoot) c = Colors.Red; if (town.Tier == TownTier.IslandLoot) c = Colors.Red;
if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; }
DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black);
DrawCircle(town.Position, radius, c); DrawCircle(town.Position, radius, c);
} }
} }
} }

View file

@ -11,18 +11,12 @@ 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: "v5"` (the default — the task-09 gate's BALANCED winner), the calibrated When `TerrainCurve: "v2"` (the default), the calibrated height-redistribution curve
height-redistribution curve (`HeightCurve.cs` — flat farmable lowlands, 50 m plateau shelf, and a per-seed-normalized
(`HeightCurve.cs` — the terraced ascent with spatially modulated shelves: flat farmable spike to the 420 m peak cap: every island's tallest point reaches the ceiling) reshapes
lowlands, a walkable foothill bench at 100±12 m, the white mountain-town plateau at above-sea terrain after noise/falloff/Trench and before the crater carve; biome
220±20 m — shelf heights and strength drift across the island so no two flanks wear the same classification reads a retained uncurved map, so biomes are identical either way. Drops the
ring — and a per-seed-normalized summit spike to the 420 m cap) reshapes above-sea terrain `0_height` hillshade snapshot (hypsometric bands × NW hillshade) in both modes.
after noise/falloff/Trench and before the crater carve; biome classification reads a
retained uncurved map, so biomes are identical either way. With `TerrainDetail: "v1"` (the
default) two detail passes follow the curve: shelf micro-relief (±3 m rolling skin on the
benches/plateaus) and D8 drainage incision (rain-cut ravines in the risers — the future
river routes; `TerrainDetailPass.cs`). 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 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 — 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. separates true ocean from inland lakes; a mainland fill guarantees one contiguous landmass.

View file

@ -121,7 +121,6 @@ public partial class RoundTripHarness : Node
ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads); ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads);
ok &= CompareWater(a, b); ok &= CompareWater(a, b);
ok &= CompareTerrainCurve(a, b); ok &= CompareTerrainCurve(a, b);
ok &= CompareTerrainDetail(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, " +
@ -201,14 +200,8 @@ public partial class RoundTripHarness : Node
} }
var ca = a.TerrainCurve; var cb = b.TerrainCurve; var ca = a.TerrainCurve; var cb = b.TerrainCurve;
bool same = ca.Version == cb.Version; 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[] 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 };
ca.BenchAmp, ca.PlateauAmp, ca.ShelfSpanMin, ca.ShelfSpanMax, ca.ElevFreqIslands, ca.StrengthFreqIslands, 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 };
ca.BenchSeedOffset, ca.PlateauSeedOffset, ca.StrengthSeedOffset,
ca.PresetId, ca.K5, ca.K6 };
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,
cb.BenchAmp, cb.PlateauAmp, cb.ShelfSpanMin, cb.ShelfSpanMax, cb.ElevFreqIslands, cb.StrengthFreqIslands,
cb.BenchSeedOffset, cb.PlateauSeedOffset, cb.StrengthSeedOffset,
cb.PresetId, cb.K5, cb.K6 };
for (int i = 0; i < fa.Length; i++) for (int i = 0; i < fa.Length; i++)
if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i])) same = false; if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i])) same = false;
if (!same) { GD.PrintErr("[Harness] TCRV fields differ."); return false; } if (!same) { GD.PrintErr("[Harness] TCRV fields differ."); return false; }
@ -216,31 +209,6 @@ public partial class RoundTripHarness : Node
return true; return true;
} }
private bool CompareTerrainDetail(WorldBlueprint a, WorldBlueprint b)
{
if (a.TerrainDetail == null && b.TerrainDetail == null)
{
GD.Print("[Harness] TDTL: absent in source — nothing to compare (and none reappeared).");
return true;
}
if (a.TerrainDetail == null || b.TerrainDetail == null)
{
GD.PrintErr("[Harness] TDTL presence mismatch between source and reread.");
return false;
}
var da = a.TerrainDetail; var db = b.TerrainDetail;
float[] fa = { da.Version, da.ReliefAmpM, da.ReliefFreqIslands, da.IncK, da.IncP, da.IncCapM, da.SeaClampRaw, da.CraterExclFactor, da.ShelfIncWeight, da.ReliefSeedOffset };
float[] fb = { db.Version, db.ReliefAmpM, db.ReliefFreqIslands, db.IncK, db.IncP, db.IncCapM, db.SeaClampRaw, db.CraterExclFactor, db.ShelfIncWeight, db.ReliefSeedOffset };
for (int i = 0; i < fa.Length; i++)
if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i]))
{
GD.PrintErr("[Harness] TDTL fields differ.");
return false;
}
GD.Print($"[Harness] TDTL equal (detail v{da.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)

View file

@ -1,143 +0,0 @@
using Godot;
using System;
/// <summary>
/// The terrain DETAIL passes (terrain-water task 10) — pure numeric array machinery
/// (D-035; a named future C++ candidate, kept standalone):
///
/// PASS A — shelf micro-relief: a medium-frequency noise skin (±ShelfReliefAmp,
/// default 3 m) weighted by shelf-ness, so the compressed shelves get their
/// rolling texture back while risers and peaks stay untouched.
///
/// PASS B — drainage incision: D8 steepest-descent flow routing + accumulation
/// over the curved terrain; depth = K · accum^p · localSlope (capped), masked to
/// the risers (feathered ~30 % onto shelves, zero on the toe and above the
/// plateau top, zero near the crater), clamped so carved terrain never drops
/// below sea + 1 m. The channels double as the future river routes (Phase C).
///
/// Ordering (enforced by the caller): curve → micro-relief → incision → crater
/// carve. The classify map never sees any of it.
/// </summary>
public static class TerrainDetailPass
{
public const ushort VERSION = 1;
// Pass A — micro-relief.
public const float RELIEF_AMP_DEFAULT_M = 3f; // config dial: ShelfReliefAmp (metres)
public const float RELIEF_FREQ_ISLANDS = 40f; // ~40 undulations per island width (~200 m features)
public const int RELIEF_SEED_OFFSET = 7409;
// Pass B — incision. K/p tuned against the depth targets (gullies 815 m,
// trunks ~25 m, cap 30 m); the tuning run's achieved distribution is in the
// task-10 report.
public const float INC_K = 1.40f;
public const float INC_P = 0.45f; // concave: many fingers, few deep trunks
public const float INC_CAP_M = 30f; // IncisionMax
public const float SEA_CLAMP = 0.15f + 1f / 251f; // carved height ≥ sea + 1 m
public const float SHELF_INC_WEIGHT = 0.3f; // shelves get washes, not gorges
public const float CRATER_EXCL_FACTOR = 1.2f; // zero incision inside this × CraterRadius
public const float CRATER_FEATHER_FACTOR = 1.4f; // ...feathering to full by this × CraterRadius
/// <summary>
/// Shelf-ness weight from the RAW input height: 1 mid-shelf, feathering to 0
/// through the risers (feather extends 30 % of the band half-width past each
/// shelf edge). Covers both shelves.
/// </summary>
public static float ShelfWeight(float raw, CurveKnots k)
{
return Mathf.Max(BandBump(raw, k.K3, k.K4), BandBump(raw, k.K5, k.K6));
}
private static float BandBump(float h, float lo, float hi)
{
float half = (hi - lo) * 0.5f;
float t = Mathf.Abs(h - (lo + half)) / half; // 0 centre, 1 at band edge
// full inside 60 % of the band, linear feather to zero at 130 %
return Mathf.Clamp(1f - (t - 0.6f) / 0.7f, 0f, 1f);
}
/// <summary>
/// Incision mask from the RAW input height: 0 below the red-ceiling input (K2)
/// and above the plateau top (K6); 1 on the riser bands; SHELF_INC_WEIGHT on the
/// shelf bands; smooth feathers (15 % of the local band width) at every boundary.
/// </summary>
public static float IncisionWeight(float raw, CurveKnots k)
{
if (raw <= k.K2 || raw >= k.K6) return 0f;
if (raw < k.K3) // foothill riser: feather in from K2, feather toward shelf weight at K3
return EdgeBlend(raw, k.K2, k.K3, 0f, 1f, SHELF_INC_WEIGHT);
if (raw < k.K4) // bench
return SHELF_INC_WEIGHT;
if (raw < k.K5) // mid riser
return EdgeBlend(raw, k.K4, k.K5, SHELF_INC_WEIGHT, 1f, SHELF_INC_WEIGHT);
// plateau band: shelf weight, feathering to zero at K6
float w = (k.K6 - raw) / ((k.K6 - k.K5) * 0.15f);
return Mathf.Min(SHELF_INC_WEIGHT, Mathf.Clamp(w, 0f, 1f) * SHELF_INC_WEIGHT);
}
private static float EdgeBlend(float h, float lo, float hi, float wIn, float wMid, float wOut)
{
float f = (hi - lo) * 0.15f;
if (h < lo + f) return Mathf.Lerp(wIn, wMid, (h - lo) / f);
if (h > hi - f) return Mathf.Lerp(wMid, wOut, (h - (hi - f)) / f);
return wMid;
}
/// <summary>
/// D8 flow accumulation over a height field (row-major idx = x·n + y).
/// Steepest-descent routing (drop / distance, diagonals ÷√2), deterministic
/// tie-break (fixed neighbour order, first winner). Cells with no lower
/// neighbour are pits/outlets (no outflow). accum = upslope contributing cells
/// including self; steepestDrop = drop per pixel toward the chosen neighbour.
/// </summary>
public static int[] FlowAccumulation(float[] h, int n, out float[] steepestDrop)
{
int total = n * n;
int[] downstream = new int[total];
steepestDrop = new float[total];
int[] dx = { 1, -1, 0, 0, 1, 1, -1, -1 };
int[] dy = { 0, 0, 1, -1, 1, -1, 1, -1 };
float[] invDist = { 1f, 1f, 1f, 1f, 0.7071068f, 0.7071068f, 0.7071068f, 0.7071068f };
for (int x = 0; x < n; x++)
{
for (int y = 0; y < n; y++)
{
int i = x * n + y;
float hc = h[i];
float best = 0f;
int bestIdx = -1;
for (int d = 0; d < 8; d++)
{
int nx = x + dx[d], ny = y + dy[d];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
float grade = (hc - h[ni]) * invDist[d];
if (grade > best)
{
best = grade;
bestIdx = ni;
}
}
downstream[i] = bestIdx;
steepestDrop[i] = best;
}
}
// Height-descending order: each cell pushes its accumulated count downstream.
float[] keys = (float[])h.Clone();
int[] order = new int[total];
for (int i = 0; i < total; i++) order[i] = i;
Array.Sort(keys, order); // ascending
int[] accum = new int[total];
for (int i = 0; i < total; i++) accum[i] = 1;
for (int i = total - 1; i >= 0; i--)
{
int c = order[i];
int d = downstream[c];
if (d >= 0) accum[d] += accum[c];
}
return accum;
}
}