using System; using System.Collections.Generic; using System.IO; using System.Text; using Godot; using IslaApocalypse.Core; namespace IslaApocalypse.Tools { /// /// ⭐ THE FAITHFUL-CURVE BASELINE BATCH (chat2/01) — the control the reshape will be judged /// against, plus the instrument that decides what the reshape should be. /// /// ═══ WHAT IT DOES, IN ORDER ═══ /// /// 1. CALIBRATE — pool the LAND distribution across several seeds and take /// P60/73/83/88/96/99. Those quantiles ARE the curve's knots, which is what makes the band /// shares 60/13/10/5/8/3/1 exact by construction. Reports them against the reference's /// shipped literals: that delta is the port-fidelity evidence. /// 2. VARIANTS — per seed, `curve_off` (the control) and `curve_on` (the faithful baseline). /// Grayscale + raw `.f32` + relief, because a pretty render alone cannot be argued with. /// 3. HISTOGRAMS — raw and shaped land distributions for the showpiece seed, with the knots and /// the output anchors overlaid, plus the per-band mass table. /// 4. ORACLE — the four automatic checks, run before anything is looked at. /// 5. SHOWPIECE — one render at a larger profile for the judging plate. /// /// ═══ ⚠ CALIBRATE SMALL, CONFIRM BIG ═══ /// /// Knots are measured at the iteration profile (2K) and used at every profile. That rests on the /// land distribution's SHAPE being scale-invariant — which Phase 1 established by construction /// (every frequency and distance is a fraction of MapSize). The tool does not take that on trust: /// the showpiece re-measures its own quantiles and reports the delta, so the assumption is /// evidence rather than a footnote. /// /// ═══ RUNNING IT ═══ /// /// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \ /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/CurveBaselineTool.tscn /// /// (`--headless` also works — this tool awaits no render frames — but `xvfb-run` is correct in /// both cases and costs nothing. → Tools/README.md §1.) /// /// ISLA_TASK authoring task number (default 1) /// ISLA_BATCH descriptor, NO prefix (default "curve_baseline") /// ISLA_MAPSIZE calibration/variant profile (default 2048) /// ISLA_SEEDS comma-separated positive (default: the 6 pinned below) /// ISLA_SHOWPIECE_SIZE larger confirmation profile (default 8192) /// ISLA_SHOWPIECE "0" to skip the big render /// ISLA_VARIANTS "0" to skip the per-seed variants (calibration-only probe) /// ISLA_PHASE1_SOURCE batch holding Phase-1 .f32 (default "02_pass1_port") /// ISLA_SKIP_RAW "1" to skip the .f32 dumps /// public partial class CurveBaselineTool : Node { /// /// PINNED POSITIVE SEEDS — the four Phase-1 seeds plus two, for a six-seed calibration pool. /// Pinned, not random: a calibration you cannot reproduce is a magic number with a good story. /// private static readonly int[] DefaultSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 }; private const int DefaultMapSize = 2048; private const int DefaultShowpieceSize = 8192; /// /// Band-share tolerance, percentage points. The knots come from the SAME histogram the shares /// are measured on, so the only error is in-bin interpolation — tenths, not units. A loose /// tolerance here would make check (d) unfalsifiable. /// private const double ShareTolerancePp = 0.25; public override void _Ready() { // ⚠ An exception out of _Ready does NOT stop Godot — it logs and the process sits there // with no main loop to end it, so a misconfigured run HANGS instead of failing. A hang // looks like slow work, which is worse than a crash. Catch, say what was refused, exit 2. try { Run(); } catch (Exception e) { GD.PrintErr("=================================================================="); GD.PrintErr($" REFUSED: {e.Message}"); GD.PrintErr("=================================================================="); GetTree().Quit(2); } } private void Run() { ToolingPaths.Configure(OS.GetUserDataDir()); int task = EnvInt("ISLA_TASK", 1); string descr = EnvStr("ISLA_BATCH", "curve_baseline"); int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize); int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds); int showSize = EnvInt("ISLA_SHOWPIECE_SIZE", DefaultShowpieceSize); bool showpiece = EnvStr("ISLA_SHOWPIECE", "1") == "1"; bool variants = EnvStr("ISLA_VARIANTS", "1") == "1"; string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port"); bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1"; // ⚠ Composed by BatchRoot, never free-form — it refuses a descriptor carrying its own // numeric prefix, which is how the counter drifted once already. string batchRoot = ToolingPaths.BatchRoot(task, descr); string scratch = ToolingPaths.BatchScratch(batchRoot); DirAccess.MakeDirRecursiveAbsolute(batchRoot); DirAccess.MakeDirRecursiveAbsolute(scratch); var scale = new GenerationScale(mapSize); var anchors = CurveAnchors.Default; float sea = 0.15f; GD.Print("=================================================================="); GD.Print(" CURVE BASELINE — faithful redistribution port + the histogram"); GD.Print("=================================================================="); GD.Print($"MapSize : {mapSize} (scaleFactor {scale.ScaleFactor:F3})"); GD.Print($"yardstick : {WorldScale.Describe()}"); GD.Print($"anchors : {anchors.DescribeMetres()}"); GD.Print($"seeds : {string.Join(", ", seeds)}"); GD.Print($"batch : {batchRoot}"); GD.Print("------------------------------------------------------------------"); GD.Print(ToolingPaths.Describe()); GD.Print("=================================================================="); // ═══════════════════════════════════════════════════════════════════ // 1. CALIBRATE — the knots ARE percentiles of the pooled land CDF // ═══════════════════════════════════════════════════════════════════ GD.Print("\n--- 1. CALIBRATION POOL ---"); var rawPool = new LandHistogram(sea); var pass1 = new Dictionary(); var perSeedKnots = new List(); foreach (int seed in seeds) { var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed }; Pass1Result p1 = Topography.Generate(cfg); pass1[seed] = p1; rawPool.Accumulate(p1.Height, mapSize); // Each seed's OWN knots too — the pool is the calibration, but the SPREAD across // seeds is what tells us how much of any later delta is scale and how much is just // which seed you happened to look at. Without it, the scale-invariance check below // compares a 6-seed pool against one seed and calls the difference "scale". var solo = new LandHistogram(sea); solo.Accumulate(p1.Height, mapSize); perSeedKnots.Add(MeasureKnots(solo, $"seed_{seed}")); GD.Print($" pooled seed {seed,-11} h[{p1.HMinSeed,7:F3} .. {p1.HMaxSeed,6:F3}] {p1.ElapsedMs,5} ms"); } GD.Print($" {rawPool}"); float seedSpread = KnotSpread(perSeedKnots); GD.Print($" per-seed knot spread at {mapSize}: max range across the 6 seeds = " + $"{seedSpread:F6} raw ({WorldScale.MetresFromRaw(seedSpread):F2} m)"); CurveKnots measured = MeasureKnots(rawPool, "v2_balanced"); GD.Print("\n MEASURED KNOTS vs THE REFERENCE'S SHIPPED LITERALS"); GD.Print(" knot pct v2 measured reference delta delta (m)"); for (int i = 0; i < 6; i++) { float v2 = measured[i], rf = CurveKnots.Reference[i]; GD.Print($" K{i + 1} P{CurveKnots.Percentiles[i],-5:F0} {v2,11:F6} {rf,11:F6} " + $"{v2 - rf,+9:F6} {WorldScale.MetresFromRaw(v2 - rf),+8:F2}"); } double[] shares = ShapingOracle.RealizedShares(rawPool, measured); GD.Print("\n REALIZED LAND SHARES (target 60/13/10/5/8/3/1)"); for (int i = 0; i < 7; i++) GD.Print($" {CurveKnots.BandNames[i],-16} {shares[i],6:F2} % (target {CurveKnots.BandShareTargets[i],4:F0} %)"); // ═══════════════════════════════════════════════════════════════════ // 2. THE SHAPED POOL — what the curve actually produces // ═══════════════════════════════════════════════════════════════════ GD.Print("\n--- 2. SHAPED POOL (curve on, detail on) ---"); var shapedPool = new LandHistogram(sea); var shapedResults = new Dictionary(); foreach (int seed in seeds) { var cfg = OnConfig(mapSize, seed, measured, anchors); Pass2Result p2 = Shaping.Shape(pass1[seed], cfg); shapedResults[seed] = p2; shapedPool.Accumulate(p2.Height, mapSize); if (seed == seeds[0]) foreach (string n in p2.Notes) GD.Print(" " + n); GD.Print($" shaped seed {seed,-11} h[{p2.HMin,7:F3} .. {p2.HMax,6:F3}] {p2.ElapsedMs,5} ms"); } GD.Print($" {shapedPool}"); GD.Print("\n SHAPED LAND PERCENTILES (what the palette would need to be placed on)"); double[] paletteP = { 10, 25, 50, 75, 90, 95, 99, 99.9 }; var sb2 = new StringBuilder(" "); foreach (double p in paletteP) sb2.Append($" p{p:G}={shapedPool.Quantile(p):F3}"); GD.Print(sb2.ToString()); GD.Print($" shaped max {shapedPool.MaxLand:F4} (peak cap {anchors.PeakCap:F4})"); // ⚠ THE PALETTE WAS CALIBRATED ON THE *RAW* DISTRIBUTION (Phase 1, chat1/03) and the // curve moves that distribution wholesale. This is reported, NOT fixed: re-placing the // stops now would make the render look more dramatic while the terrain is genuinely // lower — i.e. it would disguise the exact finding the histograms exist to deliver. // Whether to recalibrate is a PRESENTATION call the developer makes AFTER deciding // whether this elevation profile is the one they want. double rawLow = raw3Stop(rawPool), shapedLow = raw3Stop(shapedPool); double rawSat = 1.0 - rawPool.FractionBelow(1.450f), shapedSat = 1.0 - shapedPool.FractionBelow(1.450f); GD.Print("\n PALETTE FIT (CostaRica stops were placed on the RAW distribution)"); GD.Print($" land below stop 3 (0.310 raw): raw {rawLow * 100:F1} % -> shaped {shapedLow * 100:F1} %"); GD.Print($" land above top stop (1.450): raw {rawSat * 100:F3} % -> shaped {shapedSat * 100:F3} % (clamps to white)"); // ═══════════════════════════════════════════════════════════════════ // 3. THE ORACLE — before a single render is looked at // ═══════════════════════════════════════════════════════════════════ GD.Print("\n--- 3. ORACLE ---"); var checks = new List(); int primary = seeds[0]; Pass1Result pp1 = pass1[primary]; Pass2Result offPrimary = Shaping.Shape(pp1, OffConfig(mapSize, primary)); checks.Add(ShapingOracle.RegressionCurveOff(pp1, offPrimary)); string dumpPath = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{primary}_full", "height.f32"); float[,] phase1Dump = HeightField.Load(dumpPath, mapSize); checks.Add(ShapingOracle.RegressionAgainstDump(pp1.Height, phase1Dump, mapSize, dumpPath)); checks.Add(ShapingOracle.ClassifyFidelity(pp1, shapedResults[primary])); checks.Add(ShapingOracle.Monotonicity(shapedResults[primary])); checks.Add(ShapingOracle.BandShares(rawPool, measured, ShareTolerancePp)); // Every seed's classify field, not just the showpiece's — the invariant is per-seed. long classifyDrift = 0; foreach (int seed in seeds) { var c = ShapingOracle.ClassifyFidelity(pass1[seed], shapedResults[seed]); if (!c.Passed) { classifyDrift++; GD.PrintErr($" classify drift on seed {seed}: {c.Detail}"); } } foreach (var c in checks) GD.Print(" " + c); bool oracleOk = checks.TrueForAll(c => c.Passed) && classifyDrift == 0; GD.Print($" ORACLE: {(oracleOk ? "ALL PASS" : "FAILURES PRESENT")} " + $"(classify checked on all {seeds.Length} seeds: {seeds.Length - classifyDrift} clean)"); // ═══════════════════════════════════════════════════════════════════ // 4. VARIANTS — plain data beside the pretty render // ═══════════════════════════════════════════════════════════════════ var rows = new List(); if (variants) { GD.Print("\n--- 4. VARIANTS (curve_off / curve_on) ---"); foreach (int seed in seeds) { rows.Add(WriteVariant(batchRoot, pass1[seed], OffConfig(mapSize, seed), skipRaw, sea, 1.45f)); rows.Add(WriteVariant(batchRoot, pass1[seed], OnConfig(mapSize, seed, measured, anchors), skipRaw, sea, anchors.PeakCap)); } } else GD.Print("\n--- variants skipped (ISLA_VARIANTS=0) ---"); // ═══════════════════════════════════════════════════════════════════ // 5. HISTOGRAMS — the diagnostic, for the showpiece seed // ═══════════════════════════════════════════════════════════════════ GD.Print("\n--- 5. HISTOGRAMS ---"); var rawSeed = new LandHistogram(sea); rawSeed.Accumulate(pass1[primary].Height, mapSize); var shapedSeed = new LandHistogram(sea); shapedSeed.Accumulate(shapedResults[primary].Height, mapSize); DrawRawHistogram(rawSeed, measured, primary, mapSize, batchRoot); DrawShapedHistogram(shapedSeed, rawSeed, measured, anchors, primary, mapSize, batchRoot); // ═══════════════════════════════════════════════════════════════════ // 6. SHOWPIECE — the judging plate, and the scale-invariance check // ═══════════════════════════════════════════════════════════════════ string showNote = "skipped (ISLA_SHOWPIECE=0)"; if (showpiece) { GD.Print($"\n--- 6. SHOWPIECE at {showSize} ---"); showNote = WriteShowpiece(batchRoot, primary, showSize, measured, anchors, sea, skipRaw, rows, seedSpread); } else GD.Print("\n--- showpiece skipped (ISLA_SHOWPIECE=0) ---"); WriteIndex(batchRoot, mapSize, showSize, scale, seeds, primary, measured, anchors, rawPool, shapedPool, shares, checks, rows, oracleOk, showNote, variants, seedSpread); GD.Print("\n=================================================================="); GD.Print($" DONE — {batchRoot}"); GD.Print($" ORACLE {(oracleOk ? "ALL PASS" : "*** FAILURES — see the table ***")}"); GD.Print("=================================================================="); GetTree().Quit(oracleOk ? 0 : 3); } // ---- configs -------------------------------------------------------- private static TerrainGenConfig OffConfig(int mapSize, int seed) => new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "curve_off", Curve = false, ShelfDetail = false }; private static TerrainGenConfig OnConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a) => new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "curve_on", Curve = true, ShelfDetail = true, Knots = k, Anchors = a, }; /// /// Fraction of land below the CostaRica palette's third stop (0.310 raw). A blunt /// "how much of the island is painted with the first two colours" number — the palette's own /// stops are at measured RAW percentiles, so this is how far the curve moved the picture. /// private static double raw3Stop(LandHistogram h) => h.FractionBelow(0.310f); /// /// The widest max−min range of any single knot across a set of measurements — "how much does /// K_n move if you just change which seed you looked at". The yardstick every other knot /// delta in this batch has to be judged against. /// private static float KnotSpread(List sets) { float worst = 0f; for (int i = 0; i < 6; i++) { float lo = float.MaxValue, hi = float.MinValue; foreach (CurveKnots s in sets) { lo = MathF.Min(lo, s[i]); hi = MathF.Max(hi, s[i]); } worst = MathF.Max(worst, hi - lo); } return worst; } /// The knots ARE the quantiles. This one method is the whole calibration. private static CurveKnots MeasureKnots(LandHistogram pool, string name) => new CurveKnots( 2, name, pool.Quantile(CurveKnots.Percentiles[0]), pool.Quantile(CurveKnots.Percentiles[1]), pool.Quantile(CurveKnots.Percentiles[2]), pool.Quantile(CurveKnots.Percentiles[3]), pool.Quantile(CurveKnots.Percentiles[4]), pool.Quantile(CurveKnots.Percentiles[5])); // ---- output --------------------------------------------------------- private static string WriteVariant(string batchRoot, Pass1Result p1, TerrainGenConfig cfg, bool skipRaw, float sea, float legendTop) { Pass2Result p2 = Shaping.Shape(p1, cfg); string dir = Path.Combine(batchRoot, $"{cfg.Seed}_{cfg.VariantLabel}"); DirAccess.MakeDirRecursiveAbsolute(dir); // ⭐ PLAIN DATA BESIDE THE PRETTY RENDER, always — the point is judging the terrain, and // you cannot judge a distribution through a palette. var (gMin, gMax) = GrayscaleRenderer.SavePng(p2.Height, p2.MapSize, Path.Combine(dir, "grayscale.png")); if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32")); var look = new LookConfig { Name = "gradient_flat", Palette = ReliefPalette.Kind.CostaRica, HillshadeStrength = 0f, SeaLevel = sea, }; Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look); Image withLegend = LegendRenderer.WithLegend(map, look.Palette, sea, legendTop, cfg.VariantLabel.ToUpperInvariant()); withLegend.SavePng(Path.Combine(dir, "relief.png")); float land = p2.LandFraction(sea); GD.Print($" {cfg.VariantLabel,-10} seed {cfg.Seed,-11} h[{p2.HMin,7:F3} .. {p2.HMax,6:F3}] " + $" land {land * 100,5:F1}% {p2.ElapsedMs,5} ms"); return $"| `{cfg.Seed}_{cfg.VariantLabel}` | {cfg.Seed} | {cfg.VariantLabel} | " + $"{p2.HMin:F3} | {p2.HMax:F3} | {land * 100:F1}% | {gMin:F3}..{gMax:F3} | {p2.ElapsedMs} ms |"; } private static void DrawRawHistogram(LandHistogram raw, CurveKnots k, int seed, int mapSize, string batchRoot) { // Coarse enough to draw, fine enough to keep the shape: ~360 bins across the range. float top = MathF.Ceiling(raw.MaxLand * 20f) / 20f; var display = raw.Rebin((top - raw.SeaLevel) / 360f); var o = new HistogramRenderer.Options { Title = $"RAW LAND HEIGHTS - SEED {seed}", Subtitle = "PRE-CURVE. THE KNOTS ARE PERCENTILES OF THIS DISTRIBUTION.", XAxisLabel = "RAW HEIGHT (PRE-CURVE)", XTop = top, Footer = $"{raw.TotalLand} LAND COLUMNS AT MAPSIZE {mapSize} - BIN {display.BinWidth:F4} RAW", }; float[] edges = { raw.SeaLevel, k.K1, k.K2, k.K3, k.K4, k.K5, k.K6, top }; for (int i = 0; i < 7; i++) o.Bands.Add(new HistogramRenderer.Band { Lo = edges[i], Hi = edges[i + 1], Label = CurveKnots.BandNames[i], SharePercent = i < 6 ? raw.FractionBetween(edges[i], edges[i + 1]) * 100.0 : Math.Max(0.0, (1.0 - raw.FractionBelow(k.K6)) * 100.0), }); for (int i = 0; i < 6; i++) o.Markers.Add(new HistogramRenderer.Marker { Value = k[i], Label = $"K{i + 1} P{CurveKnots.Percentiles[i]:F0}", Strong = true, }); HistogramRenderer.SavePng(display, o, Path.Combine(batchRoot, "histogram_raw.png")); GD.Print($" histogram_raw.png ({raw.TotalLand:N0} land columns, max {raw.MaxLand:F4})"); } private static void DrawShapedHistogram(LandHistogram shaped, LandHistogram raw, CurveKnots k, CurveAnchors a, int seed, int mapSize, string batchRoot) { float top = MathF.Ceiling(shaped.MaxLand * 20f) / 20f; var display = shaped.Rebin((top - shaped.SeaLevel) / 360f); var o = new HistogramRenderer.Options { Title = $"SHAPED LAND HEIGHTS - SEED {seed}", Subtitle = "POST-CURVE. BANDS SIT AT THE STORM-LADDER OUTPUT ANCHORS.", XAxisLabel = "RAW HEIGHT (POST-CURVE)", XTop = top, Footer = $"{shaped.TotalLand} LAND COLUMNS AT MAPSIZE {mapSize} - BIN {display.BinWidth:F4} RAW", }; // The OUTPUT bands: each input band's share, drawn where the curve puts it. float benchTop = a.BenchBase + a.ShelfSpanMin; float plateauTop = a.PlateauBase + a.ShelfSpanMin; float[] outEdges = { a.Sea, a.OrangeCeil, a.RedCeil, a.BenchBase, benchTop, a.PlateauBase, plateauTop, top }; float[] inEdges = { raw.SeaLevel, k.K1, k.K2, k.K3, k.K4, k.K5, k.K6 }; for (int i = 0; i < 7; i++) o.Bands.Add(new HistogramRenderer.Band { Lo = outEdges[i], Hi = outEdges[i + 1], Label = CurveKnots.BandNames[i], SharePercent = i < 6 ? raw.FractionBetween(inEdges[i], inEdges[i + 1]) * 100.0 : Math.Max(0.0, (1.0 - raw.FractionBelow(k.K6)) * 100.0), }); o.Markers.Add(new HistogramRenderer.Marker { Value = a.OrangeCeil, Label = "ORANGE" }); o.Markers.Add(new HistogramRenderer.Marker { Value = a.RedCeil, Label = "RED" }); o.Markers.Add(new HistogramRenderer.Marker { Value = a.BenchBase, Label = "BENCH" }); o.Markers.Add(new HistogramRenderer.Marker { Value = a.PlateauBase, Label = "PLATEAU" }); o.Markers.Add(new HistogramRenderer.Marker { Value = a.PeakCap, Label = "CAP 420M" }); HistogramRenderer.SavePng(display, o, Path.Combine(batchRoot, "histogram_shaped.png")); GD.Print($" histogram_shaped.png ({shaped.TotalLand:N0} land columns, max {shaped.MaxLand:F4})"); } private static string WriteShowpiece(string batchRoot, int seed, int showSize, CurveKnots k, CurveAnchors a, float sea, bool skipRaw, List rows, float seedSpread) { var cfg = OnConfig(showSize, seed, k, a); cfg.VariantLabel = "curve_on_showpiece"; Pass1Result p1 = Topography.Generate(cfg); GD.Print($" pass1 {showSize}: h[{p1.HMinSeed:F3} .. {p1.HMaxSeed:F3}] {p1.ElapsedMs} ms"); // ⭐ THE SCALE-INVARIANCE CHECK. Knots were measured at the iteration profile; if the // land distribution's shape really is scale-free, this profile's own quantiles land on // the same numbers. Measured, not assumed. var bigPool = new LandHistogram(sea); bigPool.Accumulate(p1.Height, showSize); var bigKnots = MeasureKnots(bigPool, $"at_{showSize}"); var deltas = new StringBuilder(); float worst = 0f; for (int i = 0; i < 6; i++) { float d = bigKnots[i] - k[i]; if (MathF.Abs(d) > MathF.Abs(worst)) worst = d; deltas.Append($" K{i + 1}{d:+0.0000;-0.0000}"); } // ⚠ THE COMPARISON IS CONFOUNDED, AND SAYING SO IS THE POINT. This is ONE seed at the // big profile against a SIX-SEED POOL at the small one, so the delta mixes scale effects // with seed-to-seed variation. `seedSpread` is how far a knot moves from seed choice // alone at a fixed size — if the delta sits inside it, scale is not what moved. bool withinSeedNoise = MathF.Abs(worst) <= seedSpread; GD.Print($" scale-invariance: knots re-measured at {showSize} differ by{deltas}"); GD.Print($" worst {worst:+0.000000;-0.000000} raw = {WorldScale.MetresFromRaw(worst):+0.00;-0.00} m " + $"vs per-seed spread {seedSpread:F6} raw ({WorldScale.MetresFromRaw(seedSpread):F2} m) " + $"-> {(withinSeedNoise ? "WITHIN seed variation" : "EXCEEDS seed variation")}"); rows.Add(WriteVariant(batchRoot, p1, cfg, skipRaw, sea, a.PeakCap)); return $"seed {seed} at {showSize}; knots re-measured there differ by at most " + $"{MathF.Abs(worst):F6} raw ({MathF.Abs(WorldScale.MetresFromRaw(worst)):F2} m), " + $"{(withinSeedNoise ? "within" : "beyond")} the {WorldScale.MetresFromRaw(seedSpread):F2} m per-seed spread"; } // ---- the index ------------------------------------------------------ private static void WriteIndex(string batchRoot, int mapSize, int showSize, GenerationScale scale, int[] seeds, int primary, CurveKnots k, CurveAnchors a, LandHistogram rawPool, LandHistogram shapedPool, double[] shares, List checks, List rows, bool oracleOk, string showNote, bool variants, float seedSpread) { var sb = new StringBuilder(); sb.AppendLine("# Batch 01 — the faithful curve baseline"); sb.AppendLine(); sb.AppendLine("The redistribution curve and shelf detail, ported faithfully and **re-calibrated against"); sb.AppendLine("v2's own pass-1 output**. This is the CONTROL every later reshape is judged against —"); sb.AppendLine("not a taste gate. No erosion, no rivers, no water, no crater, no coast shelf, no islets."); sb.AppendLine(); sb.AppendLine("## ⭐ Open this first"); sb.AppendLine(); sb.AppendLine($"1. **`{primary}_curve_on_showpiece/relief.png`** — the judging plate ({showNote})."); sb.AppendLine($"2. **`histogram_raw.png`** and **`histogram_shaped.png`** — the diagnostic that says"); sb.AppendLine(" *why* the upper terrain looks the way it does. Read them before forming an opinion."); sb.AppendLine($"3. **`{primary}_curve_off/relief.png`** vs **`{primary}_curve_on/relief.png`** — the A/B."); sb.AppendLine(); sb.AppendLine("## Disposability"); sb.AppendLine(); sb.AppendLine("| Artifact | Keep? |"); sb.AppendLine("|---|---|"); sb.AppendLine("| `relief.png` | **keep** — the judging plates |"); sb.AppendLine("| `histogram_*.png` | **keep** — the finding |"); sb.AppendLine("| `INDEX.md` | **keep** |"); sb.AppendLine("| `grayscale.png` | ♻ **regenerable** from the `.f32` — safe to clear |"); sb.AppendLine("| `height.f32` | ♻ **regenerable** from seed + code — safe to clear, but it is the byte-level oracle |"); sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |"); sb.AppendLine(); sb.AppendLine("## Setup"); sb.AppendLine(); sb.AppendLine($"- **Calibration/variant profile:** {mapSize} (scaleFactor {scale.ScaleFactor:F3})"); sb.AppendLine($"- **Showpiece profile:** {showSize}"); sb.AppendLine($"- **Seeds (pooled as one calibration set):** {string.Join(", ", seeds)}"); sb.AppendLine($"- **Yardstick:** {WorldScale.Describe()}"); sb.AppendLine($"- **Output anchors:** {a.DescribeMetres()}"); sb.AppendLine($"- **Curve:** v{HeightCurve.Version} · **detail:** v{TerrainDetailPass.Version}"); sb.AppendLine(); sb.AppendLine("## ⭐ The re-measured knots"); sb.AppendLine(); sb.AppendLine($"Pooled land CDF: **{rawPool.TotalLand:N0} samples** from {rawPool.FieldsPooled} seeds, "); sb.AppendLine($"range [{rawPool.MinLand:F4} .. {rawPool.MaxLand:F4}] raw, bin {rawPool.BinWidth:G3}."); sb.AppendLine(); sb.AppendLine("| Knot | Percentile | v2 measured | reference literal | delta (raw) | delta (m) |"); sb.AppendLine("|---|---|---|---|---|---|"); for (int i = 0; i < 6; i++) { float v2 = k[i], rf = CurveKnots.Reference[i]; sb.AppendLine($"| K{i + 1} | P{CurveKnots.Percentiles[i]:F0} | `{v2:F6}` | `{rf:F6}` | " + $"{v2 - rf:+0.000000;-0.000000} | {WorldScale.MetresFromRaw(v2 - rf):+0.00;-0.00} |"); } sb.AppendLine(); sb.AppendLine("> **This delta is the port-fidelity check.** Near-identical means v2's pass-1 height"); sb.AppendLine("> distribution matches the reference's — the crown-jewel port is faithful. A large gap"); sb.AppendLine("> would mean pass 1 diverged, and would be the finding rather than a nuisance."); sb.AppendLine(); sb.AppendLine($"**Scale for judging any knot delta:** across the six seeds at {mapSize}, a single knot"); sb.AppendLine($"moves by up to **{seedSpread:F6} raw ({WorldScale.MetresFromRaw(seedSpread):F2} m)** from seed choice alone."); sb.AppendLine("Anything smaller than that is seed noise, not a difference."); sb.AppendLine(); sb.AppendLine($"**Scale invariance:** {showNote}."); sb.AppendLine(); sb.AppendLine("## Realized land shares"); sb.AppendLine(); sb.AppendLine("| Band | Realized | Target | Output lands at |"); sb.AppendLine("|---|---|---|---|"); float benchTop = a.BenchBase + a.ShelfSpanMin, plateauTop = a.PlateauBase + a.ShelfSpanMin; string[] lands = { $"{WorldScale.MetresFromRaw(a.Sea - a.Sea):F0}–{WorldScale.MetresFromRaw(a.OrangeCeil - a.Sea):F0} m", $"{WorldScale.MetresFromRaw(a.OrangeCeil - a.Sea):F0}–{WorldScale.MetresFromRaw(a.RedCeil - a.Sea):F0} m", $"{WorldScale.MetresFromRaw(a.RedCeil - a.Sea):F0}–{WorldScale.MetresFromRaw(a.BenchBase - a.Sea):F0} m", $"~{WorldScale.MetresFromRaw(a.BenchBase - a.Sea):F0} m (bench)", $"{WorldScale.MetresFromRaw(benchTop - a.Sea):F0}–{WorldScale.MetresFromRaw(a.PlateauBase - a.Sea):F0} m", $"~{WorldScale.MetresFromRaw(a.PlateauBase - a.Sea):F0} m (plateau)", $"{WorldScale.MetresFromRaw(plateauTop - a.Sea):F0}–{WorldScale.MetresFromRaw(a.PeakCap - a.Sea):F0} m", }; for (int i = 0; i < 7; i++) sb.AppendLine($"| {CurveKnots.BandNames[i]} | {shares[i]:F2} % | {CurveKnots.BandShareTargets[i]:F0} % | {lands[i]} |"); sb.AppendLine(); sb.AppendLine($"Shaped land range: **[{shapedPool.MinLand:F4} .. {shapedPool.MaxLand:F4}] raw** " + $"= {WorldScale.MetresFromRaw(shapedPool.MinLand - a.Sea):F0}–{WorldScale.MetresFromRaw(shapedPool.MaxLand - a.Sea):F0} m above sea."); sb.AppendLine(); sb.AppendLine("## ⭐ The elevation profile this produces"); sb.AppendLine(); sb.AppendLine("Land height percentiles, before and after the curve — **this is the finding**:"); sb.AppendLine(); sb.AppendLine("| Percentile | raw | shaped | shaped, metres above sea |"); sb.AppendLine("|---|---|---|---|"); foreach (double p in new[] { 10.0, 25.0, 50.0, 75.0, 90.0, 95.0, 99.0, 99.9 }) { float r = rawPool.Quantile(p), s = shapedPool.Quantile(p); sb.AppendLine($"| p{p:G} | {r:F3} | {s:F3} | **{WorldScale.MetresFromRaw(s - a.Sea):F0} m** |"); } sb.AppendLine(); double belowBench = shapedPool.FractionBelow(a.BenchBase) * 100.0; double belowPlateau = shapedPool.FractionBelow(a.PlateauBase) * 100.0; sb.AppendLine($"- **{belowBench:F1} %** of land sits below the bench " + $"({WorldScale.MetresFromRaw(a.BenchBase - a.Sea):F0} m)."); sb.AppendLine($"- **{belowPlateau:F1} %** of land sits below the plateau " + $"({WorldScale.MetresFromRaw(a.PlateauBase - a.Sea):F0} m)."); // ⚠ Do NOT state the RAW median in metres. Raw pre-curve height has no metre meaning — // the yardstick applies to CURVED output, which is what the storm-ladder anchors define. // Converting the raw median would invent a "before" elevation the world never had. sb.AppendLine($"- The median land column ends up **{WorldScale.MetresFromRaw(shapedPool.Quantile(50) - a.Sea):F0} m** " + $"above sea: the curve maps raw {rawPool.Quantile(50):F3} → {shapedPool.Quantile(50):F3}."); sb.AppendLine(); sb.AppendLine("> ⚠ **Read `histogram_shaped.png` before concluding the terrain is broken.** The curve is"); sb.AppendLine("> doing exactly what its share targets say: 60 % of land into the bottom band, 73 % below"); sb.AppendLine("> the red ceiling, 4 % above the plateau. If the island reads flat, that is a decision"); sb.AppendLine("> showing up in a render — not a bug. Changing it is the RESHAPE, and the reshape is a"); sb.AppendLine("> later task with its own gate."); sb.AppendLine(); sb.AppendLine("## ⚠ Palette fit — reported, not fixed"); sb.AppendLine(); sb.AppendLine("The CostaRica stops were placed on **measured percentiles of the RAW distribution**"); sb.AppendLine("(Phase 1). The curve moves that distribution, so the ramp no longer sits where the land is:"); sb.AppendLine(); sb.AppendLine("| | raw | shaped |"); sb.AppendLine("|---|---|---|"); sb.AppendLine($"| land below palette stop 3 (0.310 raw) | {raw3Stop(rawPool) * 100:F1} % | **{raw3Stop(shapedPool) * 100:F1} %** |"); sb.AppendLine($"| land above the top stop (1.450, clamps white) | {(1.0 - rawPool.FractionBelow(1.450f)) * 100:F3} % | {(1.0 - shapedPool.FractionBelow(1.450f)) * 100:F3} % |"); sb.AppendLine(); sb.AppendLine("**Deliberately left alone.** Re-placing the stops now would make the render look more"); sb.AppendLine("dramatic while the terrain is genuinely lower — it would disguise the very finding above."); sb.AppendLine("Recalibrating the palette is a presentation call to make *after* the elevation profile is"); sb.AppendLine("settled, not before."); sb.AppendLine(); sb.AppendLine("## The oracle"); sb.AppendLine(); sb.AppendLine(ShapingOracle.ToMarkdownTable(checks)); sb.AppendLine($"**{(oracleOk ? "ALL PASS" : "⚠⚠ FAILURES PRESENT — do not judge this batch until they are resolved")}**"); sb.AppendLine(); if (variants) { sb.AppendLine("## Results"); sb.AppendLine(); sb.AppendLine("| Folder | Seed | Variant | h min | h max | land % | grayscale range | time |"); sb.AppendLine("|---|---|---|---|---|---|---|---|"); foreach (string row in rows) sb.AppendLine(row); sb.AppendLine(); } sb.AppendLine("`scratch/` is persistent and is never cleaned."); string index = Path.Combine(batchRoot, "INDEX.md"); using var f = Godot.FileAccess.Open(index, Godot.FileAccess.ModeFlags.Write); if (f == null) { GD.PrintErr($"could not write {index}"); return; } f.StoreString(sb.ToString()); } // ---- env helpers ---------------------------------------------------- private static string EnvStr(string k, string fallback) { string v = System.Environment.GetEnvironmentVariable(k); return string.IsNullOrWhiteSpace(v) ? fallback : v; } private static int EnvInt(string k, int fallback) => int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback; private static int[] EnvSeeds(string k, int[] fallback) { string v = EnvStr(k, null); if (v == null) return fallback; var outp = new List(); foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries)) if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s); return outp.Count > 0 ? outp.ToArray() : fallback; } } }