diff --git a/Core/Scripts/BLUEPRINT_FORMAT.md b/Core/Scripts/BLUEPRINT_FORMAT.md index 87eb253..6c0da54 100644 --- a/Core/Scripts/BLUEPRINT_FORMAT.md +++ b/Core/Scripts/BLUEPRINT_FORMAT.md @@ -55,7 +55,8 @@ Every section: `[u32 tag][u64 payload-length in bytes][payload]`. | `HGTS` / `0x53544748` | `MapSize²` × `f32` height, **X outer / Y inner** | Mandatory. The second index is the map's north/south axis; the server consumes it as world **Z**. (This axis convention was never written down for v1 — it is now normative.) Length must equal `4·MapSize²`. | | `BIOM` / `0x4D4F4942` | `MapSize²` × `u8` biome ordinal, same pixel order | Mandatory. Ordinals from `Enums.cs::Biome` — **append-only, never reorder** (the ordinal IS the wire value). Writer refuses ordinals > 255; reader rejects ordinals ≥ the known biome count (parse-time validation; the palette's runtime Dirt fallback for in-memory values is unchanged). Length must equal `MapSize²`. | | `TOWN` / `0x4E574F54` | `i32 count`, then per town: `f32 X` · `f32 Y` · `u8 tier` · `u8 isHighwayNode` (0/1) | Tier ordinals from `Enums.cs::TownTier`, append-only, range-checked on read. The highway-node flag is what the generator's road topology was built from (v1 dropped it); carried and exposed on the parsed blueprint, consumed by nothing server-side yet. | -| `TCRV` / `0x56524354` | 50 B: `u16 curveVersion` · input knots `f32 t1..t4` · `f32 spikeMax` · output bands `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 plateauLo` · `f32 plateauHi` · `f32 peakCap` · `f32 tailSlope` | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v2"`); absent = raw legacy profile. **`spikeMax` semantics by version:** v1 = the pooled calibration max (identical every seed); **v2 = the SEED'S own effective raw pre-curve maximum** — the per-seed spike normalizer, which is why blueprints are no longer reproducible from curve constants alone and the value is recorded here. **Metadata only:** exported heights are already curved; nothing re-applies the map. The curve lives in `Tools/Scripts/HeightCurve.cs` (calibration provenance in its header + the task-05/06 reports). | +| `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. | +| `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²`. | | `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. | @@ -106,12 +107,17 @@ body). 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`. +- **`TerrainCurve`** (`"v5"` | `"off"`, default `"v5"` — the task-09 gate's winner, the + BALANCED preset; the tri-state and `"v1"`–`"v4"` are retired and rejected loudly) — whether + the height-redistribution curve (terraced ascent, corner-eased junctions, spatially modulated + shelves at 100±12 m / 220±20 m, per-seed-normalized 420 m summit spires, 60 % lowland) shapes + `HGTS`. +- **`TerrainDetail`** (`"v1"` | `"off"`, default `"v1"`; no-op without the curve) — the task-10 + 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`) diff --git a/Core/Scripts/BlueprintFormat.cs b/Core/Scripts/BlueprintFormat.cs index 99d4597..5aad3c7 100644 --- a/Core/Scripts/BlueprintFormat.cs +++ b/Core/Scripts/BlueprintFormat.cs @@ -33,6 +33,7 @@ namespace IslaApocalypse.Core 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" + public const uint TAG_TERRAIN_DETAIL = 0x4C544454; // "TDTL" // 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 diff --git a/Core/Scripts/BlueprintWriter.cs b/Core/Scripts/BlueprintWriter.cs index 56bc07c..27fe859 100644 --- a/Core/Scripts/BlueprintWriter.cs +++ b/Core/Scripts/BlueprintWriter.cs @@ -33,6 +33,8 @@ namespace IslaApocalypse.Core WriteSection(writer, BlueprintFormat.TAG_PARAMS, w => WriteParams(w, bp)); if (bp.TerrainCurve != null) 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_BIOMES, w => WriteBiomes(w, bp)); @@ -132,6 +134,33 @@ namespace IslaApocalypse.Core 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); + + // 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) diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index a28342c..14e233e 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -26,12 +26,19 @@ namespace IslaApocalypse.Core // Change this if your namespace is different 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"; + // Height-redistribution curve (tasks 05–10, graduation M-7): "v5" is the + // task-09 gate's winner — the BALANCED terraced ascent with corner easing and + // modulated shelves; "off" is the raw legacy profile. "v1"–"v4" and the + // taste-batch tri-state ("v5-compact" lost, "v5-balanced" became plain "v5") + // are retired. Default: v5. + public static string TerrainCurve = "v5"; + + // 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() { @@ -106,14 +113,32 @@ namespace IslaApocalypse.Core // Change this if your namespace is different if (data.ContainsKey("TerrainCurve")) { string curve = (string)data["TerrainCurve"]; - if (curve == "off" || curve == "v2") + if (curve == "off" || curve == "v5") 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 if (curve == "v5-balanced") + GD.PrintErr($"[ConfigManager] TerrainCurve 'v5-balanced' won the task-09 gate and is now plain \"v5\". Keeping '{TerrainCurve}'."); + 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 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) { case "4K": diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index c40ec40..35e4695 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -74,6 +74,31 @@ namespace IslaApocalypse.Core // 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 + + // 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; + } + + /// + /// 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. + /// + 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 @@ -103,6 +128,9 @@ namespace IslaApocalypse.Core // The curve that shaped HeightMap (v2 TCRV section); null = raw legacy profile. public TerrainCurveInfo TerrainCurve; + + // The detail passes that shaped HeightMap (TDTL section); null = no detail. + public TerrainDetailInfo TerrainDetail; } // 2. The Parser Utility @@ -226,6 +254,7 @@ namespace IslaApocalypse.Core 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 if (tag == BlueprintFormat.TAG_TERRAIN_DETAIL) sectionOk = ParseTerrainDetail(reader, blueprint); else { // The property the redesign exists to buy: future sections (water, @@ -399,10 +428,39 @@ namespace IslaApocalypse.Core c.RedCeil = reader.ReadSingle(); c.PlateauLo = reader.ReadSingle(); c.PlateauHi = reader.ReadSingle(); c.PeakCap = 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; 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 into) { int pathCount = reader.ReadInt32(); diff --git a/Tools/Scripts/HeightCurve.cs b/Tools/Scripts/HeightCurve.cs index bced8b8..2e18ab0 100644 --- a/Tools/Scripts/HeightCurve.cs +++ b/Tools/Scripts/HeightCurve.cs @@ -1,135 +1,183 @@ using Godot; /// -/// The height-redistribution curve, v2 (terrain-water task 06) — a pure, static, -/// monotonic piecewise map over raw blueprint heights (D-035: numbers in, numbers -/// out; the per-seed spike maximum is an explicit PARAMETER, not hidden state). +/// One preset's input knots for the v5 curve. Two presets exist for the task-09 +/// taste batch — COMPACT (maximum lowland, cordillera-from-plains) and BALANCED +/// (the task-07/08 lineage, gradual highland approach). The loser retires after +/// the developer's gate; the winner becomes plain "v5". +/// +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; + } +} + +/// +/// 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). /// -/// v2 changes (developer's task-05 hillshade-gate verdict; everything below the -/// plateau step is behaviorally byte-identical to v1): -/// - PER-SEED SPIKE NORMALIZATION: the spike's input domain runs from t4 to the -/// current seed's own raw pre-curve maximum (hMaxSeed), so every island's -/// tallest pixel reaches the ceiling — v1 mapped against the pooled -/// calibration max and mid-range seeds topped out at 110–175 m. -/// - STIFFER SPIKE: ease-in 0.1u + 0.9·u⁴ (was 0.2u + 0.8·u³) — a wall, not a ramp. -/// - PEAK CEILING 420 m above sea (was 220 m). +/// The corner fixes (both presets — the naturalness work): +/// 1. Riser endpoint slope floor 0.2 → 0.1 (blend 0.1u + 0.9·smoothstep): climbs +/// decelerate into shelves and accelerate out of them — no machined edges. +/// 2. Summit-spike base floor 0.1 → 0.05 (blend 0.05u + 0.95·u⁴): the spike +/// leaves the plateau gently — no hard skirt under the peaks. +/// 3. SHELF_SPAN_MIN 2 m → 6 m: pronounced shelves keep a gentle tilt — flat to +/// build on, never snooker-table flat. /// -/// 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. +/// Presets (input land-fraction targets; knots calibrated 2026-08-08 from the same +/// pooled batch-04 flat-sea land CDF as tasks 05/07, 340,618,126 samples; achieved +/// 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 /// -/// 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) +/// Unchanged from v4: storm-ladder anchors, bench 100±12 m, plateau 220±20 m, +/// strength modulation (span max 25 m), 420 m cap, per-seed spike normalization, +/// modulation fields/seed offsets, the classify-map invariant. /// public static class HeightCurve { - public const ushort VERSION = 2; + public const ushort VERSION = 5; - // 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 + // The task-09 taste gate's WINNER: BALANCED (id 2). COMPACT retired with the + // verdict; its knots survive only in the task-09 report/batch for the record. + public static readonly CurveKnots V5 = new CurveKnots(2, "balanced", + 0.515899f, 0.612157f, 0.710472f, 0.784045f, 0.962922f, 1.119118f); // P60/73/83/88/96/99 - // Output bands — the storm ladder. Lower anchors unchanged from v1. + // Fixed output anchors — storm ladder + ceiling (frozen). 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 ORANGE_CEIL = 0.206f; + public const float RED_CEIL = 0.27f; + public const float PEAK_CAP = SEA + 420f / 251f; + public const float TAIL_SLOPE = 0.25f; public const float SPIKE_MIN_SPAN = 0.01f; - /// The effective spike-domain top for a seed's raw maximum, guard applied. - public static float EffectiveSpikeMax(float hMaxSeed) + // Modulated shelf anchors (unchanged from v4). + public const float BENCH_BASE = SEA + 100f / 251f; + 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, T4 + SPIKE_MIN_SPAN); + return Mathf.Max(hMaxSeed, k.K6 + SPIKE_MIN_SPAN); } - /// Raw pre-curve height. - /// The seed's raw pre-curve maximum (post noise/falloff/Trench/spine, - /// pre-carve) — the same field the curve consumes. Makes the map seed-dependent (v2). - public static float Apply(float h, float hMaxSeed) + public static float ShelfSpan(float strength01) + { + return Mathf.Lerp(SHELF_SPAN_MAX, SHELF_SPAN_MIN, Mathf.Clamp(strength01, 0f, 1f)); + } + + public static float Apply(float h, float hMaxSeed, + float benchLo, float benchSpan, float plateauLo, float plateauSpan, CurveKnots k) { if (h <= SEA) return h; float u, s; - if (h < T1) + if (h < k.K1) { - u = (h - SEA) / (T1 - SEA); - s = 0.3f * u + 0.7f * (u * (2f - u)); // ease-out, slope ≥ 0.3 + u = (h - SEA) / (k.K1 - SEA); + s = 0.3f * u + 0.7f * (u * (2f - u)); // frozen ease-out toe return SEA + s * (ORANGE_CEIL - SEA); } - if (h < T2) + if (h < k.K2) { - u = (h - T1) / (T2 - T1); - return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // linear + u = (h - k.K1) / (k.K2 - k.K1); + return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // frozen linear rise } - if (h < T3) + if (h < k.K3) { - 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); + u = (h - k.K2) / (k.K3 - k.K2); + s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // foothill riser — corner fix 1 + return RED_CEIL + s * (benchLo - RED_CEIL); } - if (h < T4) + if (h < k.K4) { - u = (h - T3) / (T4 - T3); - return PLATEAU_LO + u * (PLATEAU_HI - PLATEAU_LO); // near-flat, small positive slope + u = (h - k.K3) / (k.K4 - k.K3); + return benchLo + u * benchSpan; // bench (min span 6 m — fix 3) } - float spikeMax = EffectiveSpikeMax(hMaxSeed); + float benchTop = benchLo + benchSpan; + 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) { - 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); + u = (h - k.K6) / (spikeMax - k.K6); + s = 0.05f * u + 0.95f * (u * u * u * u); // summit spike — corner fix 2 + return plateauTop + s * (PEAK_CAP - plateauTop); } return PEAK_CAP + (h - spikeMax) * TAIL_SLOPE; } /// - /// Numeric strict-monotonicity check of the EFFECTIVE per-seed curve — call once - /// per generation after hMaxSeed is known, before the curve pass. A violation is - /// a build bug, not a data condition — fail loudly and refuse to generate. + /// Per-generation numeric strict-monotonicity check of the EFFECTIVE curve for + /// the selected preset: all 8 modulation-extreme corners × per-seed spikeMax. + /// The corner fixes lower the slope floors (risers 0.1, spike base 0.05) — the + /// sweep proves they stay strictly positive everywhere. Loud throw on failure. /// - public static void AssertMonotonic(float hMaxSeed) + public static void AssertMonotonic(float hMaxSeed, CurveKnots k) { - float prevH = -7f; - float prev = Apply(prevH, hMaxSeed); + float[] benchLos = { BENCH_BASE - BENCH_AMP, BENCH_BASE + BENCH_AMP }; + float[] plateauLos = { PLATEAU_BASE - PLATEAU_AMP, PLATEAU_BASE + PLATEAU_AMP }; + float[] spans = { SHELF_SPAN_MIN, SHELF_SPAN_MAX }; - // 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 bl in benchLos) { - 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; - } + foreach (float pl in plateauLos) + { + foreach (float sp in spans) + { + float prevH = -7f; + float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k); - // 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})."); + void Check(double hd) + { + 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})."); } } diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index 5206a53..40528cb 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -37,6 +37,24 @@ public partial class MapGenerator : TextureRect private float[,] _heightMapClassify; 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, // pre-carve) — the v2 curve's per-seed spike normalizer. Computed in // GenerateTopography pass 1; recorded in TCRV (effective, guard applied). @@ -75,7 +93,8 @@ public partial class MapGenerator : TextureRect this.CustomMinimumSize = new Vector2(MapSize, MapSize); _heightMap = new float[MapSize, MapSize]; - _curveOn = ConfigManager.TerrainCurve == "v2"; + _curveKnots = ConfigManager.TerrainCurve == "v5" ? HeightCurve.V5 : null; + _curveOn = _curveKnots != null; // (The monotonicity assertion now runs inside GenerateTopography, against the // effective per-seed curve, once hMaxSeed is known.) _heightMapClassify = _curveOn ? new float[MapSize, MapSize] : _heightMap; @@ -86,11 +105,23 @@ public partial class MapGenerator : TextureRect float scaleFactor = MapSize / 1024f; // Equals 4 - // --- MASTER SEED & DETAILED NOISE (Restored!) --- + // --- MASTER SEED & DETAILED NOISE (Restored!) --- _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.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!) // 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! @@ -256,14 +287,39 @@ public partial class MapGenerator : TextureRect TrailRoads = _trailPaths, WaterBodyIds = _waterBodyIds, WaterBodies = _waterBodies ?? new List(), + // 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 { Version = HeightCurve.VERSION, - T1 = HeightCurve.T1, T2 = HeightCurve.T2, T3 = HeightCurve.T3, T4 = HeightCurve.T4, - SpikeMax = HeightCurve.EffectiveSpikeMax(_hMaxSeed), // per-seed (v2) + T1 = _curveKnots.K1, T2 = _curveKnots.K2, T3 = _curveKnots.K3, T4 = _curveKnots.K4, + SpikeMax = HeightCurve.EffectiveSpikeMax(_hMaxSeed, _curveKnots), // per-seed 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 + PlateauLo = HeightCurve.BENCH_BASE, PlateauHi = HeightCurve.PLATEAU_BASE, + 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, FormatVersion = 2, Params = new BlueprintParams @@ -420,7 +476,7 @@ public partial class MapGenerator : TextureRect // 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); + if (_curveOn) HeightCurve.AssertMonotonic(_hMaxSeed, _curveKnots); // --- PASS 2: curve (task 05/06) + crater carve --- // Curve applied AFTER noise + falloff + Trench, BEFORE the crater carve, so @@ -428,16 +484,54 @@ public partial class MapGenerator : TextureRect // 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; + // --- 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 classifyH = raw; - float curvedH = _curveOn ? HeightCurve.Apply(raw, _hMaxSeed) : raw; + 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); - // --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) --- + 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; + for (int x = 0; x < MapSize; x++) + { + for (int y = 0; y < MapSize; y++) + { float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter); // We only carve the physical hole at 80% of the radius to guarantee a landbridge! @@ -446,16 +540,82 @@ public partial class MapGenerator : TextureRect float craterDepth = 1.0f - (distToCrater / physicalCraterRadius); // Dialed back to -0.15f as per your excellent instinct! float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f; - classifyH = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f); - curvedH = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f); + // Read BOTH before writing EITHER: with the curve off the two maps + // alias the same array, and a sequential read-modify-write carved + // 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; } } } + /// + /// 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. + /// + 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(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() { Queue queue = new Queue(); @@ -520,6 +680,15 @@ public partial class MapGenerator : TextureRect // grid and the water-body grid holds BY CONSTRUCTION, not by parallel // 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 IsOceanPixel(int x, int y) => IsWaterPixel(x, y) && _isTrueOcean[x, y]; private bool IsLakePixel(int x, int y) => IsWaterPixel(x, y) && !_isTrueOcean[x, y]; @@ -800,7 +969,8 @@ public partial class MapGenerator : TextureRect 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 brown = new Color(0.55f, 0.41f, 0.28f); // foothill riser + bench: 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 grey = new Color(0.72f, 0.72f, 0.70f); @@ -820,10 +990,14 @@ public partial class MapGenerator : TextureRect } else if (h < HeightCurve.ORANGE_CEIL) tint = green; else if (h < HeightCurve.RED_CEIL) tint = tan; - else if (h < HeightCurve.PLATEAU_HI) tint = brown; + // v4 note: shelf anchors are spatially modulated, so these tint bands + // 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 { - float t2 = Mathf.Clamp((h - HeightCurve.PLATEAU_HI) / (HeightCurve.PEAK_CAP - HeightCurve.PLATEAU_HI), 0f, 1f); + // Plateau envelope through the summit spike to the 420 m cap. + 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); } @@ -1367,40 +1541,40 @@ public partial class MapGenerator : TextureRect } private void SetBaseAStarWeights(AStarGrid2D astar) { - for (int x = 0; x < MapSize; x++) { - for (int y = 0; y < MapSize; y++) { - Biome b = _biomeMap[x, y]; - - // 1. CRITICAL FIX: Make the crater physically impassable first! - if (b == Biome.Crater) { - astar.SetPointSolid(new Vector2I(x, y), true); - continue; // "Skip the rest, go to next pixel" - } + for (int x = 0; x < MapSize; x++) { + for (int y = 0; y < MapSize; y++) { + Biome b = _biomeMap[x, y]; + + // 1. CRITICAL FIX: Make the crater physically impassable first! + if (b == Biome.Crater) { + astar.SetPointSolid(new Vector2I(x, y), true); + continue; // "Skip the rest, go to next pixel" + } - // 2. Existing water/mainland checks - if (b == Biome.Ocean || b == Biome.Lake || !_isMainland[x, y]) { - astar.SetPointSolid(new Vector2I(x, y), true); - continue; // "Skip the rest, go to next pixel" - } - - // 3. We only reach this point if it's mainland AND not a crater! - float h = _heightMap[x, y]; - float localSea = GetSeaLevel(_tempMap[x, y]); - float normalizedElevation = Mathf.Clamp((h - localSea) / (1.0f - localSea), 0.0f, 1.0f); + // 2. Existing water/mainland checks + if (b == Biome.Ocean || b == Biome.Lake || !_isMainland[x, y]) { + astar.SetPointSolid(new Vector2I(x, y), true); + continue; // "Skip the rest, go to next pixel" + } + + // 3. We only reach this point if it's mainland AND not a crater! + float h = _heightMap[x, y]; + float localSea = GetSeaLevel(_tempMap[x, y]); + float normalizedElevation = Mathf.Clamp((h - localSea) / (1.0f - localSea), 0.0f, 1.0f); - // Simple, steep curve to avoid mountains - float weight = 1.0f + Mathf.Pow(normalizedElevation, 3.0f) * 400.0f; + // Simple, steep curve to avoid mountains + float weight = 1.0f + Mathf.Pow(normalizedElevation, 3.0f) * 400.0f; - // Push off the beach (not an insane wall, just enough to prefer grass) - if (b == Biome.Beach) weight += 15.0f; - - // Let it path through the wasteland normally - if (b == Biome.Wasteland) weight += 3.0f; + // Push off the beach (not an insane wall, just enough to prefer grass) + if (b == Biome.Beach) weight += 15.0f; + + // Let it path through the wasteland normally + 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) { @@ -1413,7 +1587,7 @@ public partial class MapGenerator : TextureRect int ny = Mathf.Clamp((int)p.Y + dy, 0, MapSize - 1); var cell = new Vector2I(nx, ny); if (!astar.IsPointSolid(cell)) { - // The true Iron Curtain + // The true Iron Curtain astar.SetPointWeightScale(cell, astar.GetPointWeightScale(cell) + 10000f); } } @@ -1488,67 +1662,67 @@ public partial class MapGenerator : TextureRect { float scaleMod = MapSize / 1024f; // equals 4 at 4096 - // Trails: Thin brown - 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 - foreach (var path in _ruggedPaths) DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); + // Trails: Thin brown + 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 + foreach (var path in _ruggedPaths) DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); - // Main Roads: Solid Black - foreach (var path in _branchPaths) DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); + // Main Roads: Solid Black + foreach (var path in _branchPaths) DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); - // Highway: Thick Red - foreach (var path in _highwayPaths) DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); + // Highway: Thick Red + foreach (var path in _highwayPaths) DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); - // Draw Towns - foreach (var town in _towns) { - float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); - radius *= scaleMod; // Scale the circles up! - - 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.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } + // Draw Towns + foreach (var town in _towns) { + float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); + radius *= scaleMod; // Scale the circles up! + + 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.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } - DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); - DrawCircle(town.Position, radius, c); - } + DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); + DrawCircle(town.Position, radius, c); + } } } // Place this at the very bottom of the file! public partial class MapDrawProxy : Control { - public MapGenerator Source; + public MapGenerator Source; - public override void _Draw() - { - if (Source == null) return; - - float scaleMod = Source.MapSize / 1024f; + public override void _Draw() + { + if (Source == null) return; + + float scaleMod = Source.MapSize / 1024f; - foreach (var path in Source._trailPaths) - DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true); + foreach (var path in Source._trailPaths) + DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true); - foreach (var path in Source._ruggedPaths) - DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); + foreach (var path in Source._ruggedPaths) + DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); - foreach (var path in Source._branchPaths) - DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); + foreach (var path in Source._branchPaths) + DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); - foreach (var path in Source._highwayPaths) - DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); + foreach (var path in Source._highwayPaths) + DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); - foreach (var town in Source._towns) - { - float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); - radius *= scaleMod; + foreach (var town in Source._towns) + { + float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); + radius *= scaleMod; - 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.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } + 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.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } - DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); - DrawCircle(town.Position, radius, c); - } - } -} \ No newline at end of file + DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); + DrawCircle(town.Position, radius, c); + } + } +} diff --git a/Tools/Scripts/README.md b/Tools/Scripts/README.md index 0fb6401..7b5b18a 100644 --- a/Tools/Scripts/README.md +++ b/Tools/Scripts/README.md @@ -11,12 +11,18 @@ Generates the entire 2D blueprint. Roughly in order: 2. **Topography** — FastNoiseLite base height plus a mountain spine, minus a squircle distance falloff, giving a guaranteed island. Noise frequency is divided by `scaleFactor` (`MapSize / 1024f`) so terrain features stay the same real-world size at any map profile. - When `TerrainCurve: "v2"` (the default), the calibrated height-redistribution curve - (`HeightCurve.cs` — flat farmable lowlands, 50 m plateau shelf, and a per-seed-normalized - spike to the 420 m peak cap: every island's tallest point reaches the ceiling) reshapes - above-sea terrain after noise/falloff/Trench and before the crater carve; biome - classification reads a retained uncurved map, so biomes are identical either way. Drops the - `0_height` hillshade snapshot (hypsometric bands × NW hillshade) in both modes. + When `TerrainCurve: "v5"` (the default — the task-09 gate's BALANCED winner), the calibrated + height-redistribution curve + (`HeightCurve.cs` — the terraced ascent with spatially modulated shelves: flat farmable + lowlands, a walkable foothill bench at 100±12 m, the white mountain-town plateau at + 220±20 m — shelf heights and strength drift across the island so no two flanks wear the same + ring — and a per-seed-normalized summit spike to the 420 m cap) 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. 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 — 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. diff --git a/Tools/Scripts/RoundTripHarness.cs b/Tools/Scripts/RoundTripHarness.cs index 7b2393a..10128cb 100644 --- a/Tools/Scripts/RoundTripHarness.cs +++ b/Tools/Scripts/RoundTripHarness.cs @@ -121,6 +121,7 @@ public partial class RoundTripHarness : Node ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads); ok &= CompareWater(a, b); ok &= CompareTerrainCurve(a, b); + ok &= CompareTerrainDetail(a, b); if (ok) GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " + @@ -200,8 +201,14 @@ public partial class RoundTripHarness : Node } 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 }; + 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, + 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++) if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i])) same = false; if (!same) { GD.PrintErr("[Harness] TCRV fields differ."); return false; } @@ -209,6 +216,31 @@ public partial class RoundTripHarness : Node 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 a, List b) { if (a.Count != b.Count) diff --git a/Tools/Scripts/TerrainDetailPass.cs b/Tools/Scripts/TerrainDetailPass.cs new file mode 100644 index 0000000..b7cf84f --- /dev/null +++ b/Tools/Scripts/TerrainDetailPass.cs @@ -0,0 +1,143 @@ +using Godot; +using System; + +/// +/// 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. +/// +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 8–15 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 + + /// + /// 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. + /// + 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); + } + + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + 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; + } +}