Step 0: tag terrain-shape-v1 on a59e52f (the frag_4-locked, gallery-confirmed state).
Core/Scripts/HydraulicErosion.cs is the reference's pass ported verbatim - the four governors
(250,000 droplets, lifetime 384, carve cap 15 m, deposit cap 6 m) on one net-displacement
ledger read both ways and PROVEN on exit (the CARVE-CAP / DEPOSIT-CAP violations throw), the
sea clamp (spawn rejected below sea, death at sea, both brushes skip below-sea cells, the 0.5 m
margin floor on the erode brush), the cone-weighted normalized brush shared by erode and
deposit, the droplet physics (inertia 0.35, capacity 4, min slope 0.02, erode 0.12, deposit
0.15, evaporation 0.004, gravity 4, brush 2), PCG32 seeded at seed + 9271, the crater
exclusion whole. Engine-free; every metres<->raw conversion through WorldScale (no literal
251 - the same multiply/divide, so bit-identical arithmetic).
Tools/Scripts/ErosionPass.cs is the caller (pass 2b): render field only (copied if aliased to
classify), governors clamped as the reference ConfigManager clamped them, the crater exclusion
passed through INERT (no crater => radius 0 => weight 1 everywhere; the reference's
"core < carve factor" warning dormant), and the FLOOD GUARD - render water pixels counted
before and after, any change throws. TerrainGenConfig gains Erosion (default OFF, the anchor
rule) and the governors/physics/crater fields. Pass2Result.WithHeight hands back the eroded
render field. ShadeRenderer is a pure-hillshade plate (land only) because the palette relief's
0.30 hillshade hides half-metre drainage. ErosionTool carries TerrainShapeV1 (the locked shape's
values, pinned once) and the batch: 4 gallery seeds x off/on at 8192, grayscale + .f32 +
relief + shade per field, a mid-slope 1024-px crop off/on (relief and shade), the stats.
Faithful first, no tune: at 8192 the pass touches ~88 % of land cells at a mean 0.14 m, carve
15.00 / deposit 6.00 m at the caps, 0.5 m mean on the massif with 18 % of its cells moved more
than a metre. It reads as dissected summits with radial gully fans and carve/deposit bands
along the slope breaks, not dendritic networks: droplets die of lifetime (170k of 224k) before
they converge. A lifetime probe at 4096 (scratch) shows 1536 and 4096 identical - every
droplet is dead of evaporation by ~1,300 steps - so lifetime is not the lever; droplet count
is. The mid-slope green->yellow transition is not softened at the faithful tune.
Oracle, all passing on 4 seeds: erosion OFF bit-identical to terrain-shape-v1 (the task-10
gallery dumps, 67 M cells each); classify bit-identical off vs on and == the pass-1 field;
region labeling + island tag identical; flood guard (27.5 M water pixels unchanged, every
seed); caps proven; tag/coastline; classify == raw; centre is land; eroded field
bit-identical across two runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
431 lines
24 KiB
C#
431 lines
24 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Text;
|
||
using Godot;
|
||
using IslaApocalypse.Core;
|
||
|
||
namespace IslaApocalypse.Tools
|
||
{
|
||
/// <summary>
|
||
/// ⭐ THE LOCKED SHAPE — `terrain-shape-v1` (chat2/10 gallery-confirmed): the continuous curve + the
|
||
/// frag_4 organic islands. Every later pass (erosion, rivers, …) starts from exactly these values,
|
||
/// pinned here once so no tool re-types them.
|
||
/// </summary>
|
||
public static class TerrainShapeV1
|
||
{
|
||
public const float FragmentAmp = 0.5f, FragmentFreq = 12f, BandCentre = 0.66f, BandHalfWidth = 0.18f;
|
||
public const bool BitesOnly = false;
|
||
public const float Stretch = 2f, BandStart = 0.70f, BandFeather = 0.05f;
|
||
public const bool StretchSinker = true;
|
||
public const float SpeckFrac = 2.5e-7f;
|
||
|
||
/// <summary>Apply the locked shape to a config (curve settings are the caller's — they come from the calibration).</summary>
|
||
public static void Apply(TerrainGenConfig c)
|
||
{
|
||
c.CoastShelf = false; c.Offshore = new OffshoreSettings();
|
||
c.RegionLabeling = true; c.SpeckRevert = true; c.MinLandComponentFrac = SpeckFrac;
|
||
c.SouthStretch = Stretch; c.SouthBandStartFrac = BandStart; c.SouthBandFeatherFrac = BandFeather; c.StretchSinker = StretchSinker;
|
||
c.FragmentAmp = FragmentAmp; c.FragmentFreqPerMapWidth = FragmentFreq;
|
||
c.FragmentBandCentre = BandCentre; c.FragmentBandHalfWidth = BandHalfWidth; c.FragmentBitesOnly = BitesOnly;
|
||
}
|
||
|
||
public static string Describe() =>
|
||
$"terrain-shape-v1: frag amp {FragmentAmp} freq {FragmentFreq} window {BandCentre}±{BandHalfWidth} · stretch {Stretch} (band {BandStart}/{BandFeather}, sinker stretched) · speck revert {SpeckFrac:G2} · offshore OFF · shelf OFF · labeling ON";
|
||
}
|
||
|
||
/// <summary>
|
||
/// ⭐ THE EROSION BATCH (chat2/11) — the faithful droplet erosion on the locked shape, judged across
|
||
/// seeds, erosion OFF vs ON. 4 seeds from the task-10 gallery × {off, on} = 8 fields at showpiece
|
||
/// size; per field grayscale + .f32 + hillshaded relief; a mid-slope close-up off/on on the first
|
||
/// seed (the green→yellow feather gate); the erosion stats table; the asymmetric oracle.
|
||
///
|
||
/// ═══ RUNNING IT ═══
|
||
///
|
||
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
|
||
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/ErosionTool.tscn
|
||
///
|
||
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
|
||
/// ISLA_MAPSIZE / ISLA_CALIB_SIZE (default 8192 / 2048)
|
||
/// ISLA_SEEDS (default 4 gallery seeds)
|
||
/// ISLA_ERO_COUNT / _LIFETIME / _CARVE / _DEPOSIT governor overrides (the faithful tune is the default)
|
||
/// ISLA_SKIP_TAG_CHECK=1 skip the bit-identity against the 10 gallery dumps (terrain-shape-v1)
|
||
/// </summary>
|
||
public partial class ErosionTool : Node
|
||
{
|
||
private static readonly int[] DefaultSeeds = { 1063685222, 999999937, 31415926, 17320508 };
|
||
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
|
||
private const int DefaultMapSize = 8192;
|
||
private const int DefaultCalibSize = 2048;
|
||
|
||
/// <summary>The pure-shade plate's vertical exaggeration — stronger than the relief's 18 so half-metre drainage reads. A look dial.</summary>
|
||
private const float ShadeZ = 60f;
|
||
|
||
public override void _Ready()
|
||
{
|
||
try { Run(); }
|
||
catch (Exception e)
|
||
{
|
||
GD.PrintErr("==================================================================");
|
||
GD.PrintErr($" REFUSED: {e.Message}");
|
||
GD.PrintErr(e.StackTrace);
|
||
GD.PrintErr("==================================================================");
|
||
GetTree().Quit(2);
|
||
}
|
||
}
|
||
|
||
private sealed class Row
|
||
{
|
||
public int Seed; public HydraulicErosion.Stats St; public HydraulicErosion.Params P;
|
||
public long WetBefore, WetAfter; public ulong MsErosion, MsGen;
|
||
public double ErodedMeanM, DepositedMeanM; public long LandCells;
|
||
public bool Ok;
|
||
}
|
||
|
||
private void Run()
|
||
{
|
||
ToolingPaths.Configure(OS.GetUserDataDir());
|
||
|
||
int task = EnvInt("ISLA_TASK", 11);
|
||
string descr = EnvStr("ISLA_BATCH", "erosion");
|
||
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
|
||
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
|
||
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
||
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||
bool skipTag = EnvStr("ISLA_SKIP_TAG_CHECK", "0") == "1";
|
||
string t10Source = EnvStr("ISLA_T10_SOURCE", "10_frag4_seed_gallery");
|
||
|
||
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
||
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
|
||
|
||
var anchors = CurveAnchors.Default;
|
||
float sea = 0.15f;
|
||
|
||
GD.Print("==================================================================");
|
||
GD.Print(" HYDRAULIC EROSION (chat2/11) — the faithful port on the locked shape, off vs on");
|
||
GD.Print("==================================================================");
|
||
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
|
||
GD.Print($"seeds : {string.Join(", ", seeds)}");
|
||
GD.Print($"shape : {TerrainShapeV1.Describe()}");
|
||
GD.Print($"batch : {batchRoot}");
|
||
GD.Print("==================================================================");
|
||
|
||
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
|
||
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
|
||
GD.Print($" {knots}");
|
||
|
||
TerrainGenConfig Cfg(int size, int seed, string label, bool erosion)
|
||
{
|
||
var c = new TerrainGenConfig
|
||
{
|
||
MapSize = size, Seed = seed, VariantLabel = label,
|
||
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
||
};
|
||
TerrainShapeV1.Apply(c);
|
||
c.Erosion = erosion;
|
||
c.ErosionDropletCount = EnvInt("ISLA_ERO_COUNT", c.ErosionDropletCount);
|
||
c.ErosionDropletLifetime = EnvInt("ISLA_ERO_LIFETIME", c.ErosionDropletLifetime);
|
||
c.ErosionCarveCapM = EnvFloat("ISLA_ERO_CARVE", c.ErosionCarveCapM);
|
||
c.ErosionDepositCapM = EnvFloat("ISLA_ERO_DEPOSIT", c.ErosionDepositCapM);
|
||
return c;
|
||
}
|
||
var tuneCfg = Cfg(mapSize, seeds[0], "tune", true);
|
||
GD.Print($" tune : droplets {tuneCfg.ErosionDropletCount:N0} · lifetime {tuneCfg.ErosionDropletLifetime} · carve cap {tuneCfg.ErosionCarveCapM} m · deposit cap {tuneCfg.ErosionDepositCapM} m · sea margin {tuneCfg.ErosionSeaMarginM} m · brush {tuneCfg.ErosionBrushRadius} px · " +
|
||
$"inertia {tuneCfg.ErosionInertia} · capacity {tuneCfg.ErosionCapacity} · min slope {tuneCfg.ErosionMinSlopeM} m/px · erode {tuneCfg.ErosionErodeRate} · deposit {tuneCfg.ErosionDepositRate} · evaporation {tuneCfg.ErosionEvaporation} · gravity {tuneCfg.ErosionGravity} · crater exclusion INERT");
|
||
|
||
// ═══ THE FIELDS ═══
|
||
var hard = new List<ShapingOracle.Check>();
|
||
var perSeed = new List<ShapingOracle.Check>();
|
||
var rows = new List<Row>();
|
||
var look = new LookConfig
|
||
{
|
||
Name = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
|
||
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f, HillshadeStrength = 0.30f, SeaLevel = sea,
|
||
};
|
||
float bandLo = sea + WorldScale.RawFromMetres(30f), bandHi = sea + WorldScale.RawFromMetres(100f); // the green→yellow mid-slope band
|
||
bool cropDone = false;
|
||
|
||
for (int si = 0; si < seeds.Length; si++)
|
||
{
|
||
int seed = seeds[si];
|
||
GD.Print($"\n--- seed {seed} ---");
|
||
|
||
// OFF — the locked shape, the reference half.
|
||
var cOff = Cfg(mapSize, seed, "erosion_off", false);
|
||
Pass1Result p1Off = Topography.Generate(cOff);
|
||
Pass2Result p2Off = Shaping.Shape(p1Off, cOff);
|
||
if (!skipTag)
|
||
{
|
||
string dump = Path.Combine(ToolingPaths.BatchesRoot, t10Source, $"{seed}", "height.f32");
|
||
if (File.Exists(dump) && mapSize == 8192)
|
||
hard.Add(ShapingOracle.DumpRegression("a10", $"erosion OFF == terrain-shape-v1 (the task-10 gallery dump) [{seed}]", p2Off.Height, HeightField.Load(dump, mapSize), mapSize, dump));
|
||
else GD.Print($" a10 [{seed}]: ⚠ skipped — {(mapSize != 8192 ? "map size is not the gallery's 8192" : $"no gallery dump at {dump}")}");
|
||
}
|
||
Image reliefOff = ReliefRenderer.Render(p2Off.Height, mapSize, look);
|
||
Image shadeOff = ShadeRenderer.Render(p2Off.Height, mapSize, sea, ShadeZ, look.LightAzimuth, look.LightAltitude);
|
||
WriteField(batchRoot, p2Off, sea, anchors, skipRaw, reliefOff, shadeOff, "OFF");
|
||
|
||
// ON — an independent generation, then the pass.
|
||
var cOn = Cfg(mapSize, seed, "erosion_on", true);
|
||
Pass1Result p1On = Topography.Generate(cOn);
|
||
Pass2Result p2Shaped = Shaping.Shape(p1On, cOn);
|
||
ulong tE = Time.GetTicksMsec();
|
||
var ero = ErosionPass.Apply(p2Shaped, cOn);
|
||
Pass2Result p2On = ero.Shaped;
|
||
foreach (string nline in ero.Notes) GD.Print(" " + nline);
|
||
Image reliefOn = ReliefRenderer.Render(p2On.Height, mapSize, look);
|
||
Image shadeOn = ShadeRenderer.Render(p2On.Height, mapSize, sea, ShadeZ, look.LightAzimuth, look.LightAltitude);
|
||
WriteField(batchRoot, p2On, sea, anchors, skipRaw, reliefOn, shadeOn, "ON");
|
||
|
||
// The oracle, per seed.
|
||
var checks = new List<ShapingOracle.Check>
|
||
{
|
||
ShapingOracle.NorthLocked("c", "classify field bit-identical, erosion OFF vs ON (erosion is render-only)", p1Off.Height, p1On.Height, mapSize, mapSize),
|
||
ShapingOracle.NorthLocked("c2", "the ON result's classify field IS the pass-1 field (untouched by the pass)", p2On.HeightClassify, p1On.Height, mapSize, mapSize),
|
||
ShapingOracle.LabelsDeterministic(p1Off, p1On),
|
||
FloodGuard(ero, p2Off.Height, mapSize, sea),
|
||
Caps(ero),
|
||
ShapingOracle.TagCoastlineConsistent(p2On, sea),
|
||
ShapingOracle.ClassifyFidelity(p1On, p2On),
|
||
ShapingOracle.CentreIsLand(p1On),
|
||
};
|
||
checks[2].Name = "region labeling + island tag identical, erosion OFF vs ON";
|
||
foreach (var c in checks) { c.Name += $" [{seed}]"; perSeed.Add(c); GD.Print(" " + c); }
|
||
bool ok = checks.TrueForAll(c => c.Passed);
|
||
|
||
// Determinism — the first seed: shape again from the same pass 1, erode again, compare bit for bit.
|
||
if (si == 0)
|
||
{
|
||
var again = ErosionPass.Apply(Shaping.Shape(p1On, cOn), cOn);
|
||
var det = ShapingOracle.NorthLocked("o", "eroded render field bit-identical across two runs (determinism)", p2On.Height, again.Shaped.Height, mapSize, mapSize);
|
||
det.Name += $" [{seed}]"; perSeed.Add(det); GD.Print(" " + det);
|
||
ok &= det.Passed;
|
||
}
|
||
|
||
// The mid-slope crop — the first seed: the 1024² window with the most green→yellow band cells.
|
||
if (!cropDone)
|
||
{
|
||
var (cx, cy, cw) = FindMidSlopeWindow(p2Off.Height, mapSize, bandLo, bandHi, Math.Min(1024, mapSize / 4));
|
||
WriteCrop(batchRoot, reliefOff, reliefOn, cx, cy, cw, seed, "midslope");
|
||
WriteCrop(batchRoot, shadeOff, shadeOn, cx, cy, cw, seed, "midslope_shade");
|
||
GD.Print($" mid-slope crop: window ({cx},{cy}) {cw}² — {Path.Combine(batchRoot, "midslope_pair.png")}");
|
||
cropDone = true;
|
||
}
|
||
|
||
long land = 0; for (int x = 0; x < mapSize; x++) for (int y = 0; y < mapSize; y++) if (p1Off.Height[x, y] >= sea) land++;
|
||
rows.Add(new Row
|
||
{
|
||
Seed = seed, St = ero.Stats, P = ero.Params, WetBefore = ero.WetBefore, WetAfter = ero.WetAfter, MsErosion = ero.Ms, MsGen = p1On.ElapsedMs,
|
||
LandCells = land, ErodedMeanM = ero.Stats.ModifiedCells == 0 ? 0 : ero.Stats.ErodedVolumeM3 / ero.Stats.ModifiedCells,
|
||
DepositedMeanM = ero.Stats.ModifiedCells == 0 ? 0 : ero.Stats.DepositedVolumeM3 / ero.Stats.ModifiedCells, Ok = ok,
|
||
});
|
||
GD.Print($" seed {seed}: {(ok ? "ok" : "⚠ CHECK FAILED")} erosion {ero.Ms / 1000.0:F1}s");
|
||
}
|
||
|
||
bool allOk = hard.TrueForAll(c => c.Passed) && perSeed.TrueForAll(c => c.Passed);
|
||
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
|
||
foreach (var c in perSeed) if (!c.Passed) GD.PrintErr(" " + c);
|
||
|
||
WriteIndex(batchRoot, mapSize, calibSize, seeds, rows, tuneCfg, hard, perSeed, allOk);
|
||
GD.Print("\n==================================================================");
|
||
GD.Print($" DONE — {batchRoot}");
|
||
GD.Print($" ORACLE {(allOk ? "HARD CHECKS ALL PASS" : "*** FAILURES — see the table ***")}");
|
||
GD.Print("==================================================================");
|
||
GetTree().Quit(allOk ? 0 : 3);
|
||
}
|
||
|
||
// ---- the checks only this batch needs -----------------------------------
|
||
|
||
private static ShapingOracle.Check FloodGuard(ErosionPass.Result ero, float[,] offRender, int n, float sea)
|
||
{
|
||
long wetOff = ErosionPass.CountWaterPixels(offRender, n, sea);
|
||
var c = new ShapingOracle.Check { Id = "f", Name = "flood guard — render water pixels unchanged (OFF field, before, after)" };
|
||
c.Passed = wetOff == ero.WetBefore && ero.WetBefore == ero.WetAfter;
|
||
c.Detail = $"OFF {wetOff:N0} · before {ero.WetBefore:N0} · after {ero.WetAfter:N0}" + (c.Passed ? " — no coastline moved" : " — MOVED");
|
||
return c;
|
||
}
|
||
|
||
private static ShapingOracle.Check Caps(ErosionPass.Result ero)
|
||
{
|
||
var st = ero.Stats; var p = ero.Params;
|
||
var c = new ShapingOracle.Check { Id = "g", Name = "governor caps proven on exit (the pass re-checked here)" };
|
||
bool carveOk = st.MaxCellErosionM <= p.CarveCapM * (1f + 1e-5f);
|
||
bool depOk = p.DepositCapM <= 0f || st.MaxCellDepositM <= p.DepositCapM * (1f + 1e-5f);
|
||
c.Passed = carveOk && depOk;
|
||
c.Detail = $"max cell carve {st.MaxCellErosionM:F3} m ≤ {p.CarveCapM} m; max cell deposit {st.MaxCellDepositM:F3} m ≤ {p.DepositCapM} m; {st.ModifiedCells:N0} cells touched";
|
||
return c;
|
||
}
|
||
|
||
private static (int x, int y, int w) FindMidSlopeWindow(float[,] h, int n, float lo, float hi, int w)
|
||
{
|
||
int best = -1, bx = 0, by = 0; int step = Math.Max(64, w / 4);
|
||
for (int x0 = 0; x0 + w <= n; x0 += step)
|
||
for (int y0 = 0; y0 + w <= n; y0 += step)
|
||
{
|
||
int cnt = 0;
|
||
for (int x = x0; x < x0 + w; x += 4)
|
||
for (int y = y0; y < y0 + w; y += 4)
|
||
{ float v = h[x, y]; if (v >= lo && v <= hi) cnt++; }
|
||
if (cnt > best) { best = cnt; bx = x0; by = y0; }
|
||
}
|
||
return (bx, by, w);
|
||
}
|
||
|
||
// ---- the curve --------------------------------------------------------
|
||
|
||
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
|
||
{
|
||
var rawPool = new LandHistogram(sea);
|
||
var pass1 = new Dictionary<int, Pass1Result>();
|
||
foreach (int s in CalibrationSeeds)
|
||
{
|
||
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
|
||
pass1[s] = p1;
|
||
rawPool.Accumulate(p1.Height, calibSize);
|
||
}
|
||
var knots = new CurveKnots(2, "v2_balanced",
|
||
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
|
||
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
|
||
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
|
||
float ceilingRaw = knots.K2;
|
||
var rawAbove = new LandHistogram(sea);
|
||
var outAbove = new LandHistogram(sea);
|
||
foreach (int s in CalibrationSeeds)
|
||
{
|
||
var scfg = new TerrainGenConfig
|
||
{
|
||
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
||
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
||
};
|
||
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
||
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
||
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
||
}
|
||
var pcts = ClimbCalibration.DefaultPercentiles;
|
||
var rawQ = new float[pcts.Length]; var outQ = new float[pcts.Length];
|
||
for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
|
||
var cal = ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
|
||
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
|
||
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f);
|
||
return (knots, cal);
|
||
}
|
||
|
||
// ---- output -----------------------------------------------------------
|
||
|
||
private static void WriteField(string batchRoot, Pass2Result p2, float sea, CurveAnchors anchors, bool skipRaw, Image relief, Image shade, string tag)
|
||
{
|
||
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
|
||
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||
shade.SavePng(Path.Combine(dir, "shade.png"));
|
||
GrayscaleRenderer.SavePng(p2.Height, p2.MapSize, Path.Combine(dir, "grayscale.png"));
|
||
if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
|
||
LegendRenderer.WithLegend((Image)relief.Duplicate(), ReliefPalette.Kind.ProvisionalEven, sea, anchors.PeakCap, $"EROSION {tag} {p2.Seed}")
|
||
.SavePng(Path.Combine(dir, "relief.png"));
|
||
}
|
||
|
||
private static void WriteCrop(string batchRoot, Image off, Image on, int x, int y, int w, int seed, string name)
|
||
{
|
||
var rect = new Rect2I(x, y, w, w);
|
||
Image a = off.GetRegion(rect), b = on.GetRegion(rect);
|
||
a.SavePng(Path.Combine(batchRoot, $"{name}_off.png"));
|
||
b.SavePng(Path.Combine(batchRoot, $"{name}_on.png"));
|
||
var pair = Image.CreateEmpty(w * 2 + 16, w, false, Image.Format.Rgb8);
|
||
pair.Fill(new Color(0, 0, 0));
|
||
pair.BlitRect(a, new Rect2I(0, 0, w, w), new Vector2I(0, 0));
|
||
pair.BlitRect(b, new Rect2I(0, 0, w, w), new Vector2I(w + 16, 0));
|
||
int s = 3; int lh = TinyFont.Height(s) + 6;
|
||
TinyFont.Draw(pair, $"MID-SLOPE (30-100 M BAND) SEED {seed} WINDOW ({x},{y}) {w}PX", 12, 12, s, new Color(0.94f, 0.95f, 0.96f));
|
||
TinyFont.Draw(pair, "LEFT: EROSION OFF", 12, 12 + lh, s, new Color(0.94f, 0.95f, 0.96f));
|
||
TinyFont.Draw(pair, "RIGHT: EROSION ON", w + 16 + 12, 12 + lh, s, new Color(0.94f, 0.95f, 0.96f));
|
||
pair.SavePng(Path.Combine(batchRoot, $"{name}_pair.png"));
|
||
}
|
||
|
||
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, int[] seeds, List<Row> rows, TerrainGenConfig tune,
|
||
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perSeed, bool allOk)
|
||
{
|
||
int best = seeds[0];
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine($"# Batch 11 — hydraulic erosion on the locked shape: off vs on, {seeds.Length} seeds at {mapSize}");
|
||
sb.AppendLine();
|
||
sb.AppendLine("**The faithful droplet erosion** (the reference's `HydraulicErosion`, ported verbatim into `Core/`, `WorldScale`-denominated),");
|
||
sb.AppendLine("run on the RENDER map only after shaping. OFF is the locked shape `terrain-shape-v1` (bit-identical to the task-10 gallery);");
|
||
sb.AppendLine("ON is the star; the pair is the before/after. Hillshade is what makes the dendritic drainage read.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## ⭐ Open this first");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"1. **`{best}_erosion_on/relief.png`** — then `{best}_erosion_off/relief.png` beside it.");
|
||
sb.AppendLine("2. **`midslope_pair.png`** — the mid-slope (30–100 m, green→yellow) close-up, OFF left / ON right: the feather gate; **`midslope_shade_pair.png`** is the same window in pure hillshade (z×60), where half-metre drainage reads.");
|
||
sb.AppendLine(" Every field also has **`shade.png`** — pure hillshade, land only — beside its `relief.png`.");
|
||
sb.AppendLine("3. The other three pairs below.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## The contact sheet — OFF / ON side by side");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| Seed | erosion OFF (relief · shade) | erosion ON (relief · shade) | droplets · steps | eroded m³ / deposited m³ | max cell carve / deposit (m) | cells touched | water px before → after | erosion wall | oracle |");
|
||
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|");
|
||
foreach (var r in rows)
|
||
sb.AppendLine($"| `{r.Seed}` | [`relief`]({r.Seed}_erosion_off/relief.png) · [`shade`]({r.Seed}_erosion_off/shade.png) | [`relief`]({r.Seed}_erosion_on/relief.png) · [`shade`]({r.Seed}_erosion_on/shade.png) | " +
|
||
$"{r.St.Spawned:N0} · {r.St.Steps:N0} | {r.St.ErodedVolumeM3:N0} / {r.St.DepositedVolumeM3:N0} | {r.St.MaxCellErosionM:F2} / {r.St.MaxCellDepositM:F2} | {r.St.ModifiedCells:N0} ({100.0 * r.St.ModifiedCells / Math.Max(1, r.LandCells):F1} % of land) | {r.WetBefore:N0} → {r.WetAfter:N0} | {r.MsErosion / 1000.0:F0} s | {(r.Ok ? "pass" : "**FAIL**")} |");
|
||
sb.AppendLine();
|
||
sb.AppendLine("Deaths per seed (sea / edge / dry / lifetime): " + string.Join(" · ", rows.ConvertAll(r => $"`{r.Seed}` {r.St.DiedSea} / {r.St.DiedEdge} / {r.St.DiedDry} / {r.St.DiedLifetime}")));
|
||
sb.AppendLine();
|
||
sb.AppendLine("## The tune (faithful — the reference's declared defaults, unchanged unless stated)");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"droplets `{tune.ErosionDropletCount:N0}` · lifetime `{tune.ErosionDropletLifetime}` · carve cap `{tune.ErosionCarveCapM} m` · deposit cap `{tune.ErosionDepositCapM} m` · sea margin `{tune.ErosionSeaMarginM} m` · brush `{tune.ErosionBrushRadius} px` · " +
|
||
$"inertia `{tune.ErosionInertia}` · capacity `{tune.ErosionCapacity}` · min slope `{tune.ErosionMinSlopeM} m/px` · erode `{tune.ErosionErodeRate}` · deposit `{tune.ErosionDepositRate}` · evaporation `{tune.ErosionEvaporation}` · gravity `{tune.ErosionGravity}` · " +
|
||
$"seed offset `{HydraulicErosion.SEED_OFFSET}` · crater exclusion **INERT** (no crater; core ×{tune.CraterErosionCore}, feather ×{tune.CraterErosionFeather}, feather mode — activates with the crater task).");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"Shape: {TerrainShapeV1.Describe()}. Curve calibrated at {calibSize}.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
|
||
sb.AppendLine();
|
||
sb.AppendLine("`ProvisionalEven` + hillshade (z-exaggeration 18, strength 0.30), flagged. The grayscale is the honest instrument.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## The oracle (render-only: classify, labels and every island untouched; no coastline moved)");
|
||
sb.AppendLine();
|
||
sb.AppendLine(hard.Count == 0 ? "*(the terrain-shape-v1 check was skipped)*\n" : ShapingOracle.ToMarkdownTable(hard));
|
||
sb.AppendLine("Per seed (classify bit-identical c / c2 · labels identical · flood guard f · caps g · tag/coastline k · classify b · centre m · determinism o):");
|
||
sb.AppendLine();
|
||
sb.AppendLine(ShapingOracle.ToMarkdownTable(perSeed));
|
||
sb.AppendLine($"**{(allOk ? "ALL HARD CHECKS PASS" : "⚠⚠ FAILURES — do not judge this batch")}**");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## Disposability");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| Artifact | Keep? |");
|
||
sb.AppendLine("|---|---|");
|
||
sb.AppendLine("| `relief.png`, `shade.png` (both), `midslope_*.png`, `INDEX.md` | **keep** |");
|
||
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
|
||
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + the locked shape (+ the tune) — 256 MB each, clear freely |");
|
||
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"{WorldScale.Describe()}.");
|
||
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
|
||
}
|
||
|
||
private static void WriteText(string path, string text)
|
||
{
|
||
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
|
||
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
|
||
f.StoreString(text);
|
||
}
|
||
|
||
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 float EnvFloat(string k, float fallback)
|
||
=> float.TryParse(EnvStr(k, null) ?? "", System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float 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<int>();
|
||
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;
|
||
}
|
||
}
|
||
}
|