Compare commits
10 commits
8e55326a84
...
89e0f85c9f
| Author | SHA1 | Date | |
|---|---|---|---|
| 89e0f85c9f | |||
| ea291eaab5 | |||
| a59e52fcbc | |||
| bddd3636dd | |||
| 537bd3e048 | |||
| c32a3b177c | |||
| e8571b2778 | |||
| 3b96e06c1a | |||
| 5d160511fd | |||
| 639dc5f5a9 |
70 changed files with 9593 additions and 82 deletions
|
|
@ -37,6 +37,9 @@ resolution and the file-safety rails. Constants and contracts.
|
||||||
| `Scripts/CurveKnots.cs` | The six INPUT knots — percentiles of the measured land CDF, plus the reference's for comparison. |
|
| `Scripts/CurveKnots.cs` | The six INPUT knots — percentiles of the measured land CDF, plus the reference's for comparison. |
|
||||||
| `Scripts/CurveAnchors.cs` | The OUTPUT anchors — the storm-ladder elevations each band lands at. |
|
| `Scripts/CurveAnchors.cs` | The OUTPUT anchors — the storm-ladder elevations each band lands at. |
|
||||||
| `Scripts/TerrainDetailPass.cs` | Shelf micro-relief + the shelf-edge **knot warp**. Output-height only. |
|
| `Scripts/TerrainDetailPass.cs` | Shelf micro-relief + the shelf-edge **knot warp**. Output-height only. |
|
||||||
|
| `Scripts/HydraulicErosion.cs` | ⭐⭐ **Droplet (hydraulic) erosion** (chat2/11) — the reference's pass ported VERBATIM: four governors (count, lifetime, carve cap, deposit cap) on a net-displacement ledger proven on exit, the sea clamp (below-sea read-only both ways), the cone brush shared by erode and deposit, the crater exclusion (inert until the carve exists). Engine-free, own PCG32, `WorldScale`-denominated (no literal 251). Render-map only — the caller (`Tools/ErosionPass`) owns the split and the flood guard. |
|
||||||
|
| `Scripts/DrainageAnalysis.cs` | ⭐⭐ **Drainage analysis** (chat2/12) — the reference's river-PLAN pass ported verbatim: priority-flood routing fill (one ulp above the parent, terrain never written), **D8 flow directions FOR ANALYSIS ONLY** (D8 was reverted as a carving technique), Kahn accumulation, outlets ranked by drainage area, endorheic terminals credited TOTAL inflow, promoted giants. Engine-free, `WorldScale`-denominated. "The sea" = the ocean body from `RegionLabeling.OceanMask`. |
|
||||||
|
| `Scripts/RegionLabeling.cs` | ⭐⭐ **The region-labeling layer** (chat2/07) — shared infrastructure. 8-connected land components on the CLASSIFY field; mainland = the centre component; per component id / size / centroid / hemisphere (by centroid) / isMainland. Pure, engine-free, C++-candidate; a **contract** downstream phases consume (islands first; biomes, placement, rivers, the crater later). The hemisphere convention lives here. |
|
||||||
| `Scripts/ToolingPaths.cs` | Every tooling path, env-overridable, resolved in one place. |
|
| `Scripts/ToolingPaths.cs` | Every tooling path, env-overridable, resolved in one place. |
|
||||||
| `Scripts/FileSafety.cs` | The permanent file-safety rules, as throws rather than sentences. |
|
| `Scripts/FileSafety.cs` | The permanent file-safety rules, as throws rather than sentences. |
|
||||||
|
|
||||||
|
|
@ -56,9 +59,11 @@ resolution and the file-safety rails. Constants and contracts.
|
||||||
|
|
||||||
- **No water.** Water is an overlay over the columns (levels-not-cells), never a band.
|
- **No water.** Water is an overlay over the columns (levels-not-cells), never a band.
|
||||||
- **No biomes.** Biomes are a later *classification* of finished shape, not an input to it (D-049).
|
- **No biomes.** Biomes are a later *classification* of finished shape, not an input to it (D-049).
|
||||||
- **No algorithms** *beyond the height curve*. Phase 2 added `HeightCurve` and
|
- **No algorithms** *beyond the height curve and the region layer*. Phase 2 added `HeightCurve` and
|
||||||
`TerrainDetailPass` here because they are pure, engine-free, C++-candidate math that defines the
|
`TerrainDetailPass` here because they are pure, engine-free, C++-candidate math that defines the
|
||||||
world's elevation profile — a contract, not a tool's dial. Stratigraphy, feature passes, meshing
|
world's elevation profile — a contract, not a tool's dial. chat2/07 added `RegionLabeling` on the
|
||||||
|
same grounds: region identity is a contract every later phase reads, and the flood fill is a hot
|
||||||
|
path. (The speck REVERT that uses it is a pass, and lives in `Tools/` — `RegionPass`.) Stratigraphy, feature passes, meshing
|
||||||
and run-splitting on dig are still later phases.
|
and run-splitting on dig are still later phases.
|
||||||
- **No erosion, rivers, water bodies, crater carve, coast shelf or offshore islets.** Later chat2
|
- **No erosion, rivers, water bodies, crater carve, coast shelf or offshore islets.** Later chat2
|
||||||
tasks; the curve is deliberately the only pass-2 element present.
|
tasks; the curve is deliberately the only pass-2 element present.
|
||||||
|
|
|
||||||
300
Core/Scripts/ClimbCalibration.cs
Normal file
300
Core/Scripts/ClimbCalibration.cs
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Core
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ THE CLIMB'S CONTROL POINTS, MEASURED FROM THE STAIRCASE (chat2/03) — "the staircase's
|
||||||
|
/// mountain with the terraces melted out".
|
||||||
|
///
|
||||||
|
/// ═══ THE MISTAKE THIS TYPE CORRECTS ═══
|
||||||
|
///
|
||||||
|
/// chat2/02 built the climb from ANALYTIC control points (a feather and a drama knob) and got a
|
||||||
|
/// bottom-heavy curve: land above 100 m fell from ~15 % to ~4.8 %. That report concluded the loss
|
||||||
|
/// was STRUCTURAL — that a no-magnet monotone curve must preserve the raw distribution's
|
||||||
|
/// bottom-heavy shape, so only a Phase-1 noise change could restore the mountain.
|
||||||
|
///
|
||||||
|
/// > ### ⚠ THAT CONCLUSION WAS WRONG, AND THIS TYPE IS THE PROOF.
|
||||||
|
/// >
|
||||||
|
/// > A monotone curve is a free reparametrization: it may be gentle in one place and steep in
|
||||||
|
/// > another, and can LIFT bottom-heavy input into a substantial mid-massif without ever going
|
||||||
|
/// > flat. **No-flats and lift-the-mass are compatible.** The area of land above a given height
|
||||||
|
/// > is set by where the percentile→height mapping CROSSES that height, and that mapping is
|
||||||
|
/// > entirely ours to choose.
|
||||||
|
/// >
|
||||||
|
/// > The 02 sweep that "proved" the loss structural varied <c>climbFeather</c>, which shapes the
|
||||||
|
/// > JOIN, not the mass distribution. It was the wrong knob, and the conclusion generalized from
|
||||||
|
/// > it was too strong.
|
||||||
|
///
|
||||||
|
/// ═══ WHAT THE STAIRCASE'S BENCHES ACTUALLY DID ═══
|
||||||
|
///
|
||||||
|
/// They did not CREATE highland. They LIFTED land to 100 m and 220 m. The same ~27 % of land
|
||||||
|
/// above the ceiling exists in both curves; 02's analytic climb simply placed it low. So the fix
|
||||||
|
/// is not to make more high land — it is to put the land that is already there back where the
|
||||||
|
/// staircase had it, as a smooth slope.
|
||||||
|
///
|
||||||
|
/// ═══ THE METHOD — the same percentile idea as task 01's knots, one level up ═══
|
||||||
|
///
|
||||||
|
/// Task 01 measured percentiles of the raw distribution to place the curve's INPUT knots. This
|
||||||
|
/// measures percentiles of the staircase's ABOVE-CEILING land to place the climb's OUTPUT
|
||||||
|
/// heights:
|
||||||
|
///
|
||||||
|
/// for each p in {10, 30, 50, 70, 85, 95}:
|
||||||
|
/// u_p = normalized RAW position of above-ceiling land at percentile p
|
||||||
|
/// v_p = normalized OUTPUT height of above-ceiling land at percentile p (staircase)
|
||||||
|
///
|
||||||
|
/// PCHIP through <c>(0,0), (u_p, v_p)…, (1,1)</c> reproduces the staircase's elevation envelope —
|
||||||
|
/// the same land ends up at the same heights, so the mountain mass returns — while the flat bench
|
||||||
|
/// and plateau INTERIORS become smooth grade.
|
||||||
|
///
|
||||||
|
/// ═══ ⚠ WHERE THE STAIRCASE WAS FLAT, WE MUST DEVIATE — AND THAT IS THE POINT ═══
|
||||||
|
///
|
||||||
|
/// A bench maps a wide input band onto a narrow output band, so two adjacent percentiles land at
|
||||||
|
/// nearly the same height and their secant is near zero. Reproducing THAT would rebuild the
|
||||||
|
/// bench. <see cref="MinNormalizedSecant"/> floors every segment's grade and renormalizes, so the
|
||||||
|
/// curve passes THROUGH the bench height with slope instead of running ALONG it. The floor bites
|
||||||
|
/// only where the staircase was flat; everywhere else the calibration is reproduced.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClimbCalibration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The above-ceiling land percentiles sampled. Six is a handful — enough to carry the
|
||||||
|
/// staircase's envelope, few enough that PCHIP interpolates smoothly between them rather
|
||||||
|
/// than tracing every wobble of the bench.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly double[] DefaultPercentiles = { 10.0, 30.0, 50.0, 70.0, 85.0, 95.0 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The no-bench floor: no segment's grade may fall below this fraction of the climb's average
|
||||||
|
/// grade (1.0 = average). 0.35 is comfortably above <see cref="ContinuousCurve"/>'s own
|
||||||
|
/// near-flat tripwire and well below the grades the calibration produces outside the benches,
|
||||||
|
/// so it is a repair for the flats and a no-op everywhere else.
|
||||||
|
/// </summary>
|
||||||
|
public const float MinNormalizedSecant = 0.35f;
|
||||||
|
|
||||||
|
/// <summary>Iterations of floor-then-renormalize. It converges in a few; 24 is free insurance.</summary>
|
||||||
|
private const int RepairIterations = 24;
|
||||||
|
|
||||||
|
/// <summary>Normalized control points, strictly increasing in both. Includes (0,0) and (1,1).</summary>
|
||||||
|
public readonly float[] U, V;
|
||||||
|
|
||||||
|
/// <summary>The percentiles sampled, and the raw/output heights measured at each — for the report.</summary>
|
||||||
|
public readonly double[] Percentiles;
|
||||||
|
public readonly float[] RawAt, TargetHeightAt;
|
||||||
|
|
||||||
|
/// <summary>The knobs this calibration was shaped with.</summary>
|
||||||
|
public readonly float MountainLift, PeakSharpness;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalized u of the summit onset — the LAST measured percentile. Above it,
|
||||||
|
/// <see cref="PeakSharpness"/> reshapes; below it, nothing does. That is the decoupling.
|
||||||
|
/// </summary>
|
||||||
|
public readonly float SummitOnsetU;
|
||||||
|
|
||||||
|
/// <summary>How many segments the no-bench floor had to lift. Zero means the staircase had no flats.</summary>
|
||||||
|
public readonly int SegmentsFloored;
|
||||||
|
|
||||||
|
private ClimbCalibration(float[] u, float[] v, double[] pcts, float[] rawAt, float[] targetAt,
|
||||||
|
float lift, float sharp, float onsetU, int floored)
|
||||||
|
{
|
||||||
|
U = u; V = v; Percentiles = pcts; RawAt = rawAt; TargetHeightAt = targetAt;
|
||||||
|
MountainLift = lift; PeakSharpness = sharp; SummitOnsetU = onsetU; SegmentsFloored = floored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the calibration from measured quantiles.
|
||||||
|
///
|
||||||
|
/// ⚠ Takes plain arrays, not a histogram: <c>LandHistogram</c> lives in <c>Tools/</c> and Core
|
||||||
|
/// depends on nothing above it. The caller measures; this shapes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="percentiles">The percentiles sampled, ascending.</param>
|
||||||
|
/// <param name="rawQuantiles">Above-ceiling RAW height at each percentile.</param>
|
||||||
|
/// <param name="outQuantiles">Above-ceiling STAIRCASE OUTPUT height at each percentile.</param>
|
||||||
|
/// <param name="mountainLift">
|
||||||
|
/// 1.0 = reproduce the staircase's mountain. >1 lifts the mid-massif higher; <1 lowers it
|
||||||
|
/// toward chat2/02's bottom-heavy default. Applied as <c>v ← v^(1/lift)</c>, which is monotone
|
||||||
|
/// and fixes both endpoints, so it can move the massif without touching sea level or the cap.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="peakSharpness">
|
||||||
|
/// ⭐ ACTS ONLY ABOVE THE LAST MEASURED PERCENTILE. 1.0 = a straight run to the cap; higher
|
||||||
|
/// defers the rise so the final approach steepens and the peak reads pointy.
|
||||||
|
/// ⚠ Unlike chat2/02's <c>summitDrama</c>, it CANNOT lower the massif — the onset's height is
|
||||||
|
/// fixed by the calibration before this is applied. That is the §3 fix.
|
||||||
|
/// </param>
|
||||||
|
public static ClimbCalibration FromPercentiles(
|
||||||
|
double[] percentiles, float[] rawQuantiles, float[] outQuantiles,
|
||||||
|
float ceilingRaw, float spikeMax, float ceilingOut, float peakCap,
|
||||||
|
float mountainLift, float peakSharpness)
|
||||||
|
{
|
||||||
|
int n = percentiles.Length;
|
||||||
|
if (rawQuantiles.Length != n || outQuantiles.Length != n)
|
||||||
|
throw new ArgumentException("[ClimbCalibration] percentile/raw/output arrays must be the same length.");
|
||||||
|
if (mountainLift <= 0f)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(mountainLift), mountainLift, "mountainLift must be positive.");
|
||||||
|
if (peakSharpness < 1f)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(peakSharpness), peakSharpness,
|
||||||
|
"peakSharpness < 1 would make the summit's final approach SHALLOWER than its own average — a ramp, not a peak.");
|
||||||
|
|
||||||
|
float spanRaw = spikeMax - ceilingRaw;
|
||||||
|
float spanOut = peakCap - ceilingOut;
|
||||||
|
if (spanRaw <= 0f || spanOut <= 0f)
|
||||||
|
throw new InvalidOperationException("[ClimbCalibration] the climb has no room — ceiling meets the summit.");
|
||||||
|
|
||||||
|
// ---- normalize the measured points, plus the two exact endpoints ----
|
||||||
|
var u = new float[n + 2];
|
||||||
|
var v = new float[n + 2];
|
||||||
|
u[0] = 0f; v[0] = 0f;
|
||||||
|
u[n + 1] = 1f; v[n + 1] = 1f;
|
||||||
|
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
u[i + 1] = Math.Clamp((rawQuantiles[i] - ceilingRaw) / spanRaw, 0f, 1f);
|
||||||
|
v[i + 1] = Math.Clamp((outQuantiles[i] - ceilingOut) / spanOut, 0f, 1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠ u must be STRICTLY increasing for PCHIP. Percentiles of a continuous distribution
|
||||||
|
// give that naturally; a degenerate seed (a plateau in the raw CDF) could not. Nudge
|
||||||
|
// rather than throw — a hair of u-spacing is not a shape decision.
|
||||||
|
const float minDu = 1e-4f;
|
||||||
|
for (int i = 1; i < u.Length; i++)
|
||||||
|
if (u[i] <= u[i - 1] + minDu) u[i] = u[i - 1] + minDu;
|
||||||
|
// Renormalize back onto [0,1] if the nudging pushed past the end.
|
||||||
|
if (u[u.Length - 1] > 1f)
|
||||||
|
{
|
||||||
|
float s = 1f / u[u.Length - 1];
|
||||||
|
for (int i = 1; i < u.Length; i++) u[i] *= s;
|
||||||
|
u[u.Length - 1] = 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- mountainLift: v ← v^(1/lift). Monotone, endpoints fixed. ----
|
||||||
|
if (Math.Abs(mountainLift - 1f) > 1e-6f)
|
||||||
|
{
|
||||||
|
float e = 1f / mountainLift;
|
||||||
|
for (int i = 1; i <= n; i++) v[i] = MathF.Pow(v[i], e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the no-bench repair: floor every grade, renormalize to keep v(1) = 1 ----
|
||||||
|
float onsetU = u[n]; // the last measured percentile
|
||||||
|
int floored = RepairSecants(u, v, out _);
|
||||||
|
|
||||||
|
// ---- peakSharpness: reshape ONLY the segment above the onset ----
|
||||||
|
// Insert a midpoint whose height defers the rise, so the final approach steepens.
|
||||||
|
// v_mid = v_onset + (1 - v_onset) * 0.5^sharpness ⇒ sharpness 1 is exactly linear.
|
||||||
|
if (peakSharpness > 1f + 1e-6f)
|
||||||
|
{
|
||||||
|
float uS = u[n], vS = v[n];
|
||||||
|
float uMid = (uS + 1f) * 0.5f;
|
||||||
|
float vMid = vS + (1f - vS) * MathF.Pow(0.5f, peakSharpness);
|
||||||
|
|
||||||
|
var u2 = new float[u.Length + 1];
|
||||||
|
var v2 = new float[v.Length + 1];
|
||||||
|
Array.Copy(u, u2, n + 1); Array.Copy(v, v2, n + 1);
|
||||||
|
u2[n + 1] = uMid; v2[n + 1] = vMid;
|
||||||
|
u2[n + 2] = 1f; v2[n + 2] = 1f;
|
||||||
|
u = u2; v = v2;
|
||||||
|
|
||||||
|
// ⚠ The deferred first half must still not be a bench. Re-floor ONLY that segment,
|
||||||
|
// leaving the calibrated massif below the onset untouched — re-running the global
|
||||||
|
// repair here would renormalize the massif and undo the decoupling.
|
||||||
|
float du = uMid - uS;
|
||||||
|
float minDv = MinNormalizedSecant * du;
|
||||||
|
if (vMid - vS < minDv) v[n + 1] = vS + minDv;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawAt = (float[])rawQuantiles.Clone();
|
||||||
|
var outAt = (float[])outQuantiles.Clone();
|
||||||
|
|
||||||
|
var cal = new ClimbCalibration(u, v, (double[])percentiles.Clone(), rawAt, outAt,
|
||||||
|
mountainLift, peakSharpness, onsetU, floored);
|
||||||
|
|
||||||
|
cal.AssertUsable();
|
||||||
|
return cal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Floor every segment's normalized grade at <see cref="MinNormalizedSecant"/> and renormalize
|
||||||
|
/// so the last point still lands exactly on 1. Iterated, because renormalizing can push a
|
||||||
|
/// floored segment back under the floor; it converges as long as the un-floored segments have
|
||||||
|
/// room to absorb the excess.
|
||||||
|
/// </summary>
|
||||||
|
private static int RepairSecants(float[] u, float[] v, out float minSecant)
|
||||||
|
{
|
||||||
|
int m = u.Length;
|
||||||
|
var s = new float[m - 1];
|
||||||
|
var du = new float[m - 1];
|
||||||
|
for (int i = 0; i < m - 1; i++)
|
||||||
|
{
|
||||||
|
du[i] = u[i + 1] - u[i];
|
||||||
|
s[i] = (v[i + 1] - v[i]) / du[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
int flooredCount = 0;
|
||||||
|
for (int it = 0; it < RepairIterations; it++)
|
||||||
|
{
|
||||||
|
int hit = 0;
|
||||||
|
for (int i = 0; i < s.Length; i++)
|
||||||
|
if (s[i] < MinNormalizedSecant) { s[i] = MinNormalizedSecant; hit++; }
|
||||||
|
flooredCount = hit;
|
||||||
|
|
||||||
|
float total = 0f;
|
||||||
|
for (int i = 0; i < s.Length; i++) total += s[i] * du[i];
|
||||||
|
if (Math.Abs(total - 1f) < 1e-6f) break;
|
||||||
|
for (int i = 0; i < s.Length; i++) s[i] /= total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild v from the repaired grades.
|
||||||
|
minSecant = float.MaxValue;
|
||||||
|
v[0] = 0f;
|
||||||
|
for (int i = 0; i < s.Length; i++)
|
||||||
|
{
|
||||||
|
if (s[i] < minSecant) minSecant = s[i];
|
||||||
|
v[i + 1] = v[i] + s[i] * du[i];
|
||||||
|
}
|
||||||
|
v[m - 1] = 1f; // exact, against accumulated float drift
|
||||||
|
return flooredCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The invariants a calibration must satisfy before it is allowed to shape terrain. Throws
|
||||||
|
/// and refuses, rather than producing a curve nobody checked.
|
||||||
|
/// </summary>
|
||||||
|
private void AssertUsable()
|
||||||
|
{
|
||||||
|
for (int i = 1; i < U.Length; i++)
|
||||||
|
{
|
||||||
|
if (U[i] <= U[i - 1])
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"[ClimbCalibration] control point {i} is not strictly right of its predecessor " +
|
||||||
|
$"(u {U[i - 1]} → {U[i]}). Refusing to generate.");
|
||||||
|
if (V[i] <= V[i - 1])
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"[ClimbCalibration] control point {i} does not RISE (v {V[i - 1]} → {V[i]}) — that is a " +
|
||||||
|
$"bench, which is the artifact this mode exists to remove. Refusing to generate.");
|
||||||
|
}
|
||||||
|
if (Math.Abs(U[0]) > 1e-6f || Math.Abs(V[0]) > 1e-6f
|
||||||
|
|| Math.Abs(U[U.Length - 1] - 1f) > 1e-6f || Math.Abs(V[V.Length - 1] - 1f) > 1e-6f)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"[ClimbCalibration] the endpoints must be exactly (0,0) and (1,1) — the lowland handover and " +
|
||||||
|
"the peak cap are not negotiable. Refusing to generate.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The calibration as one line for the INDEX, the log and the report.</summary>
|
||||||
|
public string Describe()
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append($"lift {MountainLift:F2} sharp {PeakSharpness:F2} onsetU {SummitOnsetU:F3} " +
|
||||||
|
$"floored {SegmentsFloored} · uv ");
|
||||||
|
for (int i = 0; i < U.Length; i++) sb.Append($"({U[i]:F3},{V[i]:F3}) ");
|
||||||
|
return sb.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The measured percentile table, for the report.</summary>
|
||||||
|
public string DescribeMeasured(float ceilingOut, float peakCap)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < Percentiles.Length; i++)
|
||||||
|
sb.Append($"P{Percentiles[i]:F0}→{WorldScale.MetresFromRaw(TargetHeightAt[i] - 0.15f):F0}m ");
|
||||||
|
return sb.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Core/Scripts/ClimbCalibration.cs.uid
Normal file
1
Core/Scripts/ClimbCalibration.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cl0hijnaw76jq
|
||||||
|
|
@ -109,18 +109,106 @@ namespace IslaApocalypse.Core
|
||||||
/// <summary>The knob values this spline was built from, for the INDEX and the report.</summary>
|
/// <summary>The knob values this spline was built from, for the INDEX and the report.</summary>
|
||||||
public readonly float LowlandCeilingM, ClimbFeather, SummitDrama;
|
public readonly float LowlandCeilingM, ClimbFeather, SummitDrama;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ The measured calibration this climb was shaped from (chat2/03), or null when the climb
|
||||||
|
/// came from chat2/02's ANALYTIC feather/drama points.
|
||||||
|
///
|
||||||
|
/// Non-null is the current default: "the staircase's mountain with the terraces melted out".
|
||||||
|
/// Null survives so the 02 curve stays reproducible as a contrast variant — it is the "before"
|
||||||
|
/// in the three-way histogram story, not a fallback.
|
||||||
|
/// </summary>
|
||||||
|
public readonly ClimbCalibration Calibration;
|
||||||
|
|
||||||
|
/// <summary>Where the summit begins, normalized — the calibration's onset when calibrated, else the constant.</summary>
|
||||||
|
public float EffectiveSummitOnset => Calibration?.SummitOnsetU ?? SummitOnset;
|
||||||
|
|
||||||
// Control points (raw x, out y) and the Fritsch–Carlson tangents. x strictly increasing.
|
// Control points (raw x, out y) and the Fritsch–Carlson tangents. x strictly increasing.
|
||||||
private readonly float[] _x, _y, _m;
|
private readonly float[] _x, _y, _m;
|
||||||
|
|
||||||
private ContinuousCurve(CurveKnots k, CurveAnchors a, float ceilingRaw, float ceilingOut,
|
private ContinuousCurve(CurveKnots k, CurveAnchors a, float ceilingRaw, float ceilingOut,
|
||||||
float spikeMax, float joinSlopeRaw, float lowlandCeilingM, float climbFeather,
|
float spikeMax, float joinSlopeRaw, float lowlandCeilingM, float climbFeather,
|
||||||
float summitDrama, float[] x, float[] y, float[] m)
|
float summitDrama, float[] x, float[] y, float[] m, ClimbCalibration calibration = null)
|
||||||
{
|
{
|
||||||
Knots = k; Anchors = a;
|
Knots = k; Anchors = a;
|
||||||
CeilingRaw = ceilingRaw; CeilingOut = ceilingOut; SpikeMax = spikeMax;
|
CeilingRaw = ceilingRaw; CeilingOut = ceilingOut; SpikeMax = spikeMax;
|
||||||
JoinSlopeRaw = joinSlopeRaw;
|
JoinSlopeRaw = joinSlopeRaw;
|
||||||
LowlandCeilingM = lowlandCeilingM; ClimbFeather = climbFeather; SummitDrama = summitDrama;
|
LowlandCeilingM = lowlandCeilingM; ClimbFeather = climbFeather; SummitDrama = summitDrama;
|
||||||
_x = x; _y = y; _m = m;
|
_x = x; _y = y; _m = m; Calibration = calibration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ THE CALIBRATED CLIMB (chat2/03) — control points MEASURED from the staircase's
|
||||||
|
/// above-ceiling elevation distribution rather than invented from two shape knobs.
|
||||||
|
/// → <see cref="ClimbCalibration"/> for the method and for the chat2/02 mistake it corrects.
|
||||||
|
///
|
||||||
|
/// Everything outside the climb is identical to <see cref="Build"/>: the same lowland
|
||||||
|
/// handover pinned to the exact anchors, the same C¹ join to the red band's exit slope, the
|
||||||
|
/// same per-seed <paramref name="spikeMax"/>, the same tail. Only the interior shape changes.
|
||||||
|
///
|
||||||
|
/// ⚠ THIS PATH DOES NOT REQUIRE STRICTLY-INCREASING SECANTS, and that is deliberate. The 02
|
||||||
|
/// analytic path enforced a convex control polygon as its no-magnet rule. A curve calibrated
|
||||||
|
/// to real terrain is WAVY — gentler where the staircase had a bench, steeper through its
|
||||||
|
/// risers — so convexity is the wrong invariant here. The no-magnet guarantee instead comes
|
||||||
|
/// from <see cref="ClimbCalibration.MinNormalizedSecant"/>, which floors every grade: the
|
||||||
|
/// curve may slow down, but never to a bench.
|
||||||
|
/// </summary>
|
||||||
|
public static ContinuousCurve BuildCalibrated(CurveKnots k, CurveAnchors a, float spikeMax,
|
||||||
|
float lowlandCeilingM, ClimbCalibration calibration)
|
||||||
|
{
|
||||||
|
if (calibration == null) throw new ArgumentNullException(nameof(calibration));
|
||||||
|
|
||||||
|
var (ceilingRaw, ceilingOut, redSlope) = ResolveHandover(k, a, lowlandCeilingM);
|
||||||
|
|
||||||
|
if (ceilingRaw >= spikeMax - 1e-3f)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"[ContinuousCurve] lowland ceiling (raw {ceilingRaw:F4}) reaches this seed's summit " +
|
||||||
|
$"(spikeMax {spikeMax:F4}) — no room for a climb. Refusing.");
|
||||||
|
|
||||||
|
float spanRaw = spikeMax - ceilingRaw;
|
||||||
|
float spanOut = a.PeakCap - ceilingOut;
|
||||||
|
|
||||||
|
int n = calibration.U.Length;
|
||||||
|
var x = new float[n];
|
||||||
|
var y = new float[n];
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
x[i] = ceilingRaw + calibration.U[i] * spanRaw;
|
||||||
|
y[i] = ceilingOut + calibration.V[i] * spanOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
float[] m = FritschCarlsonTangents(x, y, startTangent: redSlope);
|
||||||
|
|
||||||
|
return new ContinuousCurve(k, a, ceilingRaw, ceilingOut, spikeMax, redSlope,
|
||||||
|
lowlandCeilingM, climbFeather: float.NaN, summitDrama: float.NaN, x, y, m, calibration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the preserved lowland hands over to the climb, and the red band's exit slope.
|
||||||
|
///
|
||||||
|
/// ⚠ THE FLOOD LINE IS PINNED TO THE EXACT ANCHORS, and "30 m" is NOMINAL: RED_CEIL − SEA is
|
||||||
|
/// 0.12 raw = 30.12 m. Any requested ceiling at or below the red ceiling hands over at
|
||||||
|
/// EXACTLY <c>(K2, RED_CEIL)</c> — no derived floats — so the linear extension is empty by
|
||||||
|
/// construction and the preserved toe+red band can never be cut by a rounding. (chat2/02's
|
||||||
|
/// first run refused its own default over that 0.12 m gap; pinning is the fix, not a wider
|
||||||
|
/// tolerance.)
|
||||||
|
/// </summary>
|
||||||
|
private static (float ceilingRaw, float ceilingOut, float redSlope) ResolveHandover(
|
||||||
|
CurveKnots k, CurveAnchors a, float lowlandCeilingM)
|
||||||
|
{
|
||||||
|
float redSlope = (a.RedCeil - a.OrangeCeil) / (k.K2 - k.K1);
|
||||||
|
|
||||||
|
if (lowlandCeilingM > MaxLowlandCeilingM)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"[ContinuousCurve] lowlandCeiling {lowlandCeilingM:F1} m is above the {MaxLowlandCeilingM:F0} m " +
|
||||||
|
"bound — close enough to the old bench (100±12 m) to preserve a flat one, which is the " +
|
||||||
|
"artifact this mode exists to remove. Refusing.");
|
||||||
|
|
||||||
|
float redCeilM = WorldScale.MetresFromRaw(a.RedCeil - a.Sea);
|
||||||
|
if (lowlandCeilingM <= redCeilM + 0.01f)
|
||||||
|
return (k.K2, a.RedCeil, redSlope);
|
||||||
|
|
||||||
|
float ceilingOut = a.Sea + WorldScale.RawFromMetres(lowlandCeilingM);
|
||||||
|
return (k.K2 + (ceilingOut - a.RedCeil) / redSlope, ceilingOut, redSlope);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -134,35 +222,8 @@ namespace IslaApocalypse.Core
|
||||||
public static ContinuousCurve Build(CurveKnots k, CurveAnchors a, float spikeMax,
|
public static ContinuousCurve Build(CurveKnots k, CurveAnchors a, float spikeMax,
|
||||||
float lowlandCeilingM, float climbFeather, float summitDrama)
|
float lowlandCeilingM, float climbFeather, float summitDrama)
|
||||||
{
|
{
|
||||||
// ---- the preserved lowland's edge ----
|
// ---- the preserved lowland's edge — shared with BuildCalibrated ----
|
||||||
float redSlope = (a.RedCeil - a.OrangeCeil) / (k.K2 - k.K1);
|
var (ceilingRaw, ceilingOut, redSlope) = ResolveHandover(k, a, lowlandCeilingM);
|
||||||
|
|
||||||
if (lowlandCeilingM > MaxLowlandCeilingM)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"[ContinuousCurve] lowlandCeiling {lowlandCeilingM:F1} m is above the {MaxLowlandCeilingM:F0} m " +
|
|
||||||
"bound — close enough to the old bench (100±12 m) to preserve a flat one, which is the " +
|
|
||||||
"artifact this mode exists to remove. Refusing.");
|
|
||||||
|
|
||||||
// ⚠ THE FLOOD LINE IS THE FLOOR, and "30 m" is NOMINAL: RED_CEIL − SEA = 0.12 raw is
|
|
||||||
// actually 30.12 m through the yardstick. Any requested ceiling at or below the red
|
|
||||||
// ceiling means "hand over exactly where the preserved lowland ends", and that handover
|
|
||||||
// is pinned to THE EXACT ANCHORS — (K2, RED_CEIL), no derived floats — so the extension
|
|
||||||
// region is empty by construction and the toe+red band can never be cut. (The first
|
|
||||||
// probe run refused its own default over this 0.12 m nominal gap; pinning is the fix,
|
|
||||||
// not widening a tolerance.)
|
|
||||||
float redCeilM = WorldScale.MetresFromRaw(a.RedCeil - a.Sea);
|
|
||||||
float ceilingOut, ceilingRaw;
|
|
||||||
if (lowlandCeilingM <= redCeilM + 0.01f)
|
|
||||||
{
|
|
||||||
ceilingOut = a.RedCeil;
|
|
||||||
ceilingRaw = k.K2;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ceilingOut = a.Sea + WorldScale.RawFromMetres(lowlandCeilingM);
|
|
||||||
// Where the linear red-slope extension reaches that output.
|
|
||||||
ceilingRaw = k.K2 + (ceilingOut - a.RedCeil) / redSlope;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ceilingRaw >= spikeMax - 1e-3f)
|
if (ceilingRaw >= spikeMax - 1e-3f)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
|
|
@ -360,7 +421,7 @@ namespace IslaApocalypse.Core
|
||||||
if (v <= prev)
|
if (v <= prev)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"[ContinuousCurve] MONOTONICITY VIOLATION at h={h}: {v} <= {prev} " +
|
$"[ContinuousCurve] MONOTONICITY VIOLATION at h={h}: {v} <= {prev} " +
|
||||||
$"(ceiling {LowlandCeilingM:F0} m, feather {ClimbFeather:F2}, drama {SummitDrama:F2}). Refusing to generate.");
|
$"(ceiling {LowlandCeilingM:F0} m, {KnobSummary()}). Refusing to generate.");
|
||||||
prev = v;
|
prev = v;
|
||||||
prevH = h;
|
prevH = h;
|
||||||
}
|
}
|
||||||
|
|
@ -380,7 +441,7 @@ namespace IslaApocalypse.Core
|
||||||
float spanRaw = SpikeMax - CeilingRaw;
|
float spanRaw = SpikeMax - CeilingRaw;
|
||||||
float spanOut = Anchors.PeakCap - CeilingOut;
|
float spanOut = Anchors.PeakCap - CeilingOut;
|
||||||
float toN = spanRaw / spanOut; // raw slope → normalized
|
float toN = spanRaw / spanOut; // raw slope → normalized
|
||||||
float onsetRaw = CeilingRaw + SummitOnset * spanRaw;
|
float onsetRaw = CeilingRaw + EffectiveSummitOnset * spanRaw;
|
||||||
float s0N = JoinSlopeRaw * toN;
|
float s0N = JoinSlopeRaw * toN;
|
||||||
|
|
||||||
float minN = float.MaxValue, maxN = float.MinValue, minAt = 0f, maxAt = 0f;
|
float minN = float.MaxValue, maxN = float.MinValue, minAt = 0f, maxAt = 0f;
|
||||||
|
|
@ -400,15 +461,24 @@ namespace IslaApocalypse.Core
|
||||||
/// <summary>Raw height where the summit onset sits, and its output — for histogram overlays.</summary>
|
/// <summary>Raw height where the summit onset sits, and its output — for histogram overlays.</summary>
|
||||||
public (float raw, float outp) SummitOnsetPoint()
|
public (float raw, float outp) SummitOnsetPoint()
|
||||||
{
|
{
|
||||||
float r = CeilingRaw + SummitOnset * (SpikeMax - CeilingRaw);
|
float r = CeilingRaw + EffectiveSummitOnset * (SpikeMax - CeilingRaw);
|
||||||
return (r, Apply(r));
|
return (r, Apply(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The shaping knobs, named for whichever path built this curve — chat2/02's analytic
|
||||||
|
/// feather/drama or chat2/03's measured lift/sharpness. ⚠ The analytic fields are NaN on a
|
||||||
|
/// calibrated curve, so nothing may print them unconditionally.
|
||||||
|
/// </summary>
|
||||||
|
public string KnobSummary() => Calibration != null
|
||||||
|
? $"lift {Calibration.MountainLift:F2} sharp {Calibration.PeakSharpness:F2} (calibrated)"
|
||||||
|
: $"feather {ClimbFeather:F2} drama {SummitDrama:F2} (analytic 02)";
|
||||||
|
|
||||||
/// <summary>The control points as one line for the INDEX and the report.</summary>
|
/// <summary>The control points as one line for the INDEX and the report.</summary>
|
||||||
public string DescribeControlPoints()
|
public string DescribeControlPoints()
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.Append($"ceiling {LowlandCeilingM:F0}m feather {ClimbFeather:F2} drama {SummitDrama:F2} · points ");
|
sb.Append($"ceiling {LowlandCeilingM:F0}m {KnobSummary()} · points ");
|
||||||
for (int i = 0; i < _x.Length; i++)
|
for (int i = 0; i < _x.Length; i++)
|
||||||
sb.Append($"({_x[i]:F4},{_y[i]:F4}{(i == 0 ? " C1" : "")}) ");
|
sb.Append($"({_x[i]:F4},{_y[i]:F4}{(i == 0 ? " C1" : "")}) ");
|
||||||
sb.Append($"· join slope {JoinSlopeRaw:F4} raw");
|
sb.Append($"· join slope {JoinSlopeRaw:F4} raw");
|
||||||
|
|
|
||||||
698
Core/Scripts/DrainageAnalysis.cs
Normal file
698
Core/Scripts/DrainageAnalysis.cs
Normal file
|
|
@ -0,0 +1,698 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Core
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ DRAINAGE ANALYSIS — THE FAITHFUL PORT (chat2/12). From the reference's
|
||||||
|
/// <c>Tools/Scripts/DrainageAnalysis.cs</c> at tag <c>pre-rewrite-reference</c> (<c>ab78883</c>), verbatim
|
||||||
|
/// in arithmetic and order (D-050). PURE ANALYSIS: it reads the ERODED render heightmap and produces a
|
||||||
|
/// river PLAN — it changes zero terrain and adds zero water.
|
||||||
|
///
|
||||||
|
/// ═══ WHAT THE PORT CHANGES (and nothing else) ═══
|
||||||
|
///
|
||||||
|
/// • Namespace + location: <c>IslaApocalypse.Core</c> — engine-free, a C++ candidate.
|
||||||
|
/// • The yardstick: metres via <see cref="WorldScale.MetresFromRaw"/> (the same <c>× 251f</c>; no literal).
|
||||||
|
/// • <see cref="Plan.FullFilled"/> and <see cref="Plan.Filled"/> are EXPOSED (the reference kept the
|
||||||
|
/// routing surfaces local) so the caller can prove the routing-fill invariants on them.
|
||||||
|
///
|
||||||
|
/// ═══ ⚠⚠ THE D8 LANDMINE ═══
|
||||||
|
///
|
||||||
|
/// D8 flow direction is CORRECT FOR ANALYSIS and is used here for exactly that. It was REVERTED as a
|
||||||
|
/// CARVING technique (the prototype's task 10 — straight, grid-aligned grooves; → `Design - Terrain -
|
||||||
|
/// D8 Incision Revert`). Use D8 to COMPUTE, never to CARVE. Nothing in this file writes terrain.
|
||||||
|
///
|
||||||
|
/// ═══ ⚠ ENDORHEIC BASINS ARE EXPECTED, FIRST-CLASS OUTPUT ═══
|
||||||
|
///
|
||||||
|
/// This terrain's biggest drainages pool inland: erosion delivers the upland network only and cannot
|
||||||
|
/// cross the flats. A screen full of endorheic basins is the CORRECT result, not a bug.
|
||||||
|
///
|
||||||
|
/// ═══ THE REFERENCE'S CLASS DOC (verbatim) ═══
|
||||||
|
///
|
||||||
|
/// Drainage-network promotion — C0b part 1 (terrain-water task 21). PURE ANALYSIS:
|
||||||
|
/// reads the ERODED render heightmap and produces a river PLAN — it changes zero
|
||||||
|
/// terrain and adds zero water. Standalone numeric (D-035 family; no Godot types).
|
||||||
|
///
|
||||||
|
/// Pipeline, built on the task-03 priority-flood family:
|
||||||
|
/// 1. Priority-flood the eroded surface from the map border (Barnes heap+pit
|
||||||
|
/// variant, 8-connected, same as RunPriorityFloodDiagnostics) — but with a
|
||||||
|
/// one-ulp epsilon on pit fills, so every filled cell keeps a STRICTLY
|
||||||
|
/// descending path to its spill. This resolves the ~15,000 erosion pits
|
||||||
|
/// (task-20 finding) for ROUTING ONLY; the terrain itself is never modified.
|
||||||
|
/// 2. Depressions that are deep AND large enough (the endorheic dials) are NOT
|
||||||
|
/// filled through: their cells revert to original heights, so flow entering
|
||||||
|
/// them terminates at the basin minimum. Real closed drainage survives;
|
||||||
|
/// micro-pits route through.
|
||||||
|
/// 3. D8 flow directions on that routing surface. D8 was reverted as a CARVING
|
||||||
|
/// technique (task 10 — grid-aligned scratches in the terrain); using it to
|
||||||
|
/// COMPUTE where water flows is standard hydrology and leaves no mark.
|
||||||
|
/// 4. Flow accumulation by topological (Kahn) propagation — no sort needed.
|
||||||
|
/// 5. Promotion: outlets to the sea ranked by drainage area, top-N (separated)
|
||||||
|
/// become trunks; main stems traced upstream by max-accumulation; the
|
||||||
|
/// mountain-exit point found from the along-stem grade; LEAN tributaries and
|
||||||
|
/// LEAN endorheic terminals marked.
|
||||||
|
///
|
||||||
|
/// The plan's lowland courses are provisional: erosion delivered the UPLAND
|
||||||
|
/// network only (task 18 §3), so below each mountain-exit the traced course is
|
||||||
|
/// "where the routing surface drains", not a designed river. Part 2 (task 22)
|
||||||
|
/// routes the lowland reach properly from the mountain-exit points — which is why
|
||||||
|
/// those points are this analysis's key output.
|
||||||
|
/// </summary>
|
||||||
|
public static class DrainageAnalysis
|
||||||
|
{
|
||||||
|
// Neighbour order is FIXED (it is the deterministic tiebreak).
|
||||||
|
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
|
||||||
|
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
|
||||||
|
private static readonly float[] DIST = {
|
||||||
|
1.41421356f, 1f, 1.41421356f, 1f, 1f, 1.41421356f, 1f, 1.41421356f };
|
||||||
|
|
||||||
|
public const sbyte D_NONE = -1, D_SEA = -2;
|
||||||
|
|
||||||
|
public class Params
|
||||||
|
{
|
||||||
|
// Endorheic qualification: a depression this deep AND this large is a real
|
||||||
|
// closed basin and terminates flow; anything smaller is a pit, filled through.
|
||||||
|
public float EndorheicMinDepthM = 2.0f;
|
||||||
|
public int EndorheicMinAreaPx = 10000;
|
||||||
|
// Endorheic REPORTING is lean: only terminals with at least this much
|
||||||
|
// upstream drainage, at most MaxCount of them.
|
||||||
|
public int EndorheicMinInflowPx = 50000;
|
||||||
|
public int EndorheicMaxCount = 3;
|
||||||
|
|
||||||
|
public int TrunkCount = 3; // ~3 sea-reaching trunks (developer)
|
||||||
|
public int GiantCount = 3; // 21b: top endorheic giants promoted
|
||||||
|
public int MinOutletSeparationPx = 400; // don't pick 3 mouths of one delta
|
||||||
|
|
||||||
|
public int StemMinAccPx = 1000; // stem tracing stops below this
|
||||||
|
public int TributaryMinAccPx = 30000; // LEAN: a branch must drain this much
|
||||||
|
public int TributaryMaxPerTrunk = 4; // ...and only the top few are marked
|
||||||
|
|
||||||
|
// Mountain-exit: furthest-downstream stem point where the upstream window
|
||||||
|
// still sustains this grade (m per px) over ExitWindowPx.
|
||||||
|
public float ExitGradeMin = 0.05f;
|
||||||
|
public int ExitWindowPx = 100;
|
||||||
|
|
||||||
|
public float SeaLevel = 0.15f; // flat sea scalar (raw units)
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Stream
|
||||||
|
{
|
||||||
|
public List<(float x, float y)> Course = new(); // downstream-first
|
||||||
|
public long DrainageAreaPx;
|
||||||
|
public (float x, float y) Head; // upstream end
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Trunk : Stream
|
||||||
|
{
|
||||||
|
public (float x, float y) Outlet; // last land cell before sea
|
||||||
|
public (float x, float y) MountainExit;
|
||||||
|
public float MountainExitElevM;
|
||||||
|
public bool ExitFound;
|
||||||
|
public List<Stream> Tributaries = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A promoted endorheic giant (task 21b): one of the island's biggest drainage
|
||||||
|
/// systems, which pools inland because erosion could not cross the flats.
|
||||||
|
/// Kind "routed" carries a PROVISIONAL route across the flats to the ocean —
|
||||||
|
/// the path part 2 would carve, drawn for the gate, not water. Kind
|
||||||
|
/// "lake-ender" keeps its lake/lagoon terminal (real geography, developer's
|
||||||
|
/// call). Terminal is where the MAIN STEM actually pools (its sub-minimum),
|
||||||
|
/// which on a flat basin floor is more truthful than the basin's deepest cell.
|
||||||
|
/// ⚠ chat2/12 computes the provisional route as the reference did (it is analysis) but does NOT
|
||||||
|
/// promote or draw it — lowland routing is a later task.
|
||||||
|
/// </summary>
|
||||||
|
public class Giant : Stream
|
||||||
|
{
|
||||||
|
public (float x, float y) Terminal;
|
||||||
|
public (float x, float y) Spill; // where the basin overtops
|
||||||
|
public float BasinDepthM;
|
||||||
|
public long BasinAreaPx;
|
||||||
|
public string Kind = "routed"; // "routed" | "lake-ender"
|
||||||
|
public bool SouthernCandidate;
|
||||||
|
public bool TerminalInClassifyWater;
|
||||||
|
public List<(float x, float y)> ProvisionalRoute; // null for lake-enders
|
||||||
|
public bool RouteReachedOcean;
|
||||||
|
public (float x, float y) MountainExit;
|
||||||
|
public float MountainExitElevM;
|
||||||
|
public bool ExitFound;
|
||||||
|
public List<Stream> Tributaries = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EndorheicTerminal
|
||||||
|
{
|
||||||
|
public (float x, float y) Terminal; // basin minimum
|
||||||
|
public long DrainageAreaPx;
|
||||||
|
public float BasinDepthM;
|
||||||
|
public long BasinAreaPx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Plan
|
||||||
|
{
|
||||||
|
public List<Trunk> Trunks = new();
|
||||||
|
public List<EndorheicTerminal> Endorheics = new();
|
||||||
|
public List<Giant> Giants = new(); // 21b: the promoted giants
|
||||||
|
public int TerminalBasinCount; // basins that qualified as sinks
|
||||||
|
public long PitsFilledCount; // depressions filled through
|
||||||
|
public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells;
|
||||||
|
public List<(float x, float y, long acc)> AllOutletsTop = new(); // top 12, pre-separation
|
||||||
|
public Params P;
|
||||||
|
|
||||||
|
// ---- exposed by the port (the reference kept these local) ----
|
||||||
|
/// <summary>D8 direction per cell (row-major x·n+y): 0..7, <see cref="D_SEA"/>, <see cref="D_NONE"/>. Analysis only.</summary>
|
||||||
|
public sbyte[] Dir;
|
||||||
|
/// <summary>Flow accumulation per cell (row-major); 0 on ocean.</summary>
|
||||||
|
public int[] Acc;
|
||||||
|
/// <summary>The routing surface after terminal basins reverted (row-major).</summary>
|
||||||
|
public float[] Filled;
|
||||||
|
/// <summary>The FULL priority-flood fill, before terminal reversion (row-major) — every cell drains to the border on it.</summary>
|
||||||
|
public float[] FullFilled;
|
||||||
|
/// <summary>Terminal-basin id per cell (row-major), 0 = none.</summary>
|
||||||
|
public int[] BasinId;
|
||||||
|
/// <summary>Per terminal basin id: total inflow (cells whose flow ends there).</summary>
|
||||||
|
public long[] BasinInflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="isOcean">Row-major mask of THE OCEAN body — the only water that counts as "the sea" for
|
||||||
|
/// sea-reaching trunks (in v2: <c>RegionLabeling.OceanMask</c>, the classify field's border-connected water).
|
||||||
|
/// Below-sea cells that are NOT ocean (enclosed lagoons, below-datum lake beds, island-fringe waters) are
|
||||||
|
/// ordinary terrain to the router: as depressions they either qualify as terminal basins or fill and spill
|
||||||
|
/// onward to the true sea.</param>
|
||||||
|
/// <param name="isClassifyWater">Row-major mask of ANY classify water: a giant whose main stem pools inside
|
||||||
|
/// classify water is a natural lake-ender; one pooling on dry ground is a route-to-sea candidate.</param>
|
||||||
|
/// <param name="southX">Southernmost-town position (or -1 for none): the giant whose terminal lies closest is
|
||||||
|
/// flagged the SOUTHERN CANDIDATE and always routed provisionally, per the 21b design — shown, not forced.</param>
|
||||||
|
public static Plan Run(float[,] height, int mapSize, bool[] isOcean,
|
||||||
|
bool[] isClassifyWater, float southX, float southY, Params p)
|
||||||
|
{
|
||||||
|
int n = mapSize;
|
||||||
|
int total = n * n;
|
||||||
|
var plan = new Plan { P = p };
|
||||||
|
|
||||||
|
// 1-D row-major copies (idx = x * n + y), same convention as the task-03 pass.
|
||||||
|
float[] original = new float[total];
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
original[x * n + y] = height[x, y];
|
||||||
|
|
||||||
|
float[] plan_fullFilled = null; // set inside step 2, used by 21b routing
|
||||||
|
|
||||||
|
// --- 1. Priority-flood with one-ulp epsilon (routing surface only) ---
|
||||||
|
float[] filled = (float[])original.Clone();
|
||||||
|
{
|
||||||
|
bool[] visited = new bool[total];
|
||||||
|
var heap = new PriorityQueue<int, (float h, int idx)>();
|
||||||
|
var pit = new Queue<int>();
|
||||||
|
void Seed(int idx)
|
||||||
|
{
|
||||||
|
if (visited[idx]) return;
|
||||||
|
visited[idx] = true;
|
||||||
|
heap.Enqueue(idx, (filled[idx], idx)); // idx tiebreak => deterministic
|
||||||
|
}
|
||||||
|
for (int x = 0; x < n; x++) { Seed(x * n); Seed(x * n + (n - 1)); }
|
||||||
|
for (int y = 0; y < n; y++) { Seed(y); Seed((n - 1) * n + y); }
|
||||||
|
|
||||||
|
while (heap.Count > 0 || pit.Count > 0)
|
||||||
|
{
|
||||||
|
int c = pit.Count > 0 ? pit.Dequeue() : heap.Dequeue();
|
||||||
|
float fc = filled[c];
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (visited[ni]) continue;
|
||||||
|
visited[ni] = true;
|
||||||
|
if (filled[ni] <= fc)
|
||||||
|
{
|
||||||
|
// One ulp above the parent: strictly descending back out, so
|
||||||
|
// D8 never meets an exact flat inside a filled pit.
|
||||||
|
filled[ni] = MathF.BitIncrement(fc);
|
||||||
|
pit.Enqueue(ni);
|
||||||
|
}
|
||||||
|
else heap.Enqueue(ni, (filled[ni], ni));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Depression components; big+deep ones become terminal sinks ---
|
||||||
|
// Components of (filled > original), 8-connected — the pools. Qualifying
|
||||||
|
// pools revert to ORIGINAL height so flow terminates at their minimum.
|
||||||
|
int[] basinId = new int[total]; // 0 = not in a pool
|
||||||
|
var basinDepthM = new List<float> { 0f };
|
||||||
|
var basinAreaPx = new List<long> { 0L };
|
||||||
|
var basinMinCell = new List<int> { -1 };
|
||||||
|
{
|
||||||
|
var stack = new Stack<int>();
|
||||||
|
int nextId = 1;
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (basinId[i] != 0 || filled[i] <= original[i]) continue;
|
||||||
|
int id = nextId++;
|
||||||
|
long area = 0; float depth = 0f; int minCell = i; float minH = original[i];
|
||||||
|
stack.Push(i); basinId[i] = id;
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
int c = stack.Pop();
|
||||||
|
area++;
|
||||||
|
float d = WorldScale.MetresFromRaw(filled[c] - original[c]);
|
||||||
|
if (d > depth) depth = d;
|
||||||
|
if (original[c] < minH) { minH = original[c]; minCell = c; }
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (basinId[ni] == 0 && filled[ni] > original[ni])
|
||||||
|
{ basinId[ni] = id; stack.Push(ni); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
basinDepthM.Add(depth); basinAreaPx.Add(area); basinMinCell.Add(minCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 21b: the FULL fill (before terminal reversion) is the provisional-
|
||||||
|
// routing surface — on it, every basin overtops at its spill and drains
|
||||||
|
// to the border, which is exactly "where the water would continue".
|
||||||
|
plan_fullFilled = (float[])filled.Clone();
|
||||||
|
|
||||||
|
bool[] terminal = new bool[nextId];
|
||||||
|
for (int id = 1; id < nextId; id++)
|
||||||
|
{
|
||||||
|
if (basinDepthM[id] >= p.EndorheicMinDepthM && basinAreaPx[id] >= p.EndorheicMinAreaPx)
|
||||||
|
{ terminal[id] = true; plan.TerminalBasinCount++; }
|
||||||
|
else plan.PitsFilledCount++;
|
||||||
|
}
|
||||||
|
// Revert terminal pools to the real surface; re-tag basinId to keep only
|
||||||
|
// terminal pools (routing needs to know "am I in a terminal basin").
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (basinId[i] == 0) continue;
|
||||||
|
if (terminal[basinId[i]]) filled[i] = original[i];
|
||||||
|
else basinId[i] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. D8 flow directions on the routing surface ---
|
||||||
|
// dir[i] = 0..7 neighbour, SEA (into a below-sea cell), or NONE (sink).
|
||||||
|
sbyte[] dir = new sbyte[total];
|
||||||
|
bool IsSea(int idx) => isOcean[idx];
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (IsSea(i)) { dir[i] = D_NONE; continue; }
|
||||||
|
int cx = i / n, cy = i % n;
|
||||||
|
float best = 0f; int bestK = -1; bool bestIsSea = false;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
float drop = (filled[i] - filled[ni]) / DIST[k];
|
||||||
|
if (drop > best) { best = drop; bestK = k; bestIsSea = IsSea(ni); }
|
||||||
|
}
|
||||||
|
dir[i] = bestK < 0 ? D_NONE : (bestIsSea ? D_SEA : (sbyte)bestK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Flow accumulation (Kahn topological propagation) ---
|
||||||
|
int Target(int i)
|
||||||
|
{
|
||||||
|
if (dir[i] < 0) return -1;
|
||||||
|
int cx = i / n, cy = i % n;
|
||||||
|
return (cx + DX[dir[i]]) * n + (cy + DY[dir[i]]);
|
||||||
|
}
|
||||||
|
int[] acc = new int[total];
|
||||||
|
{
|
||||||
|
byte[] indeg = new byte[total];
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
if (dir[i] >= 0) indeg[Target(i)]++;
|
||||||
|
var q = new Queue<int>();
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (IsSea(i)) continue;
|
||||||
|
acc[i] = 1;
|
||||||
|
if (indeg[i] == 0) q.Enqueue(i);
|
||||||
|
}
|
||||||
|
while (q.Count > 0)
|
||||||
|
{
|
||||||
|
int c = q.Dequeue();
|
||||||
|
if (dir[c] < 0) continue;
|
||||||
|
int t = Target(c);
|
||||||
|
acc[t] += acc[c];
|
||||||
|
if (--indeg[t] == 0 && !IsSea(t)) q.Enqueue(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookkeeping: where does each cell's flow END — the sea, WHICH terminal
|
||||||
|
// basin, or stuck? Memoised downstream walk. The per-basin totals matter:
|
||||||
|
// crediting a terminal basin only with acc at its deepest cell undercounts
|
||||||
|
// badly when the basin floor is flat (a lagoon bed scatters inflow across
|
||||||
|
// many sub-minima — measured: a 500k-px lagoon system reported under 50k).
|
||||||
|
long[] basinInflow = new long[basinMinCell.Count];
|
||||||
|
int[] dest = new int[total]; // 0 unknown, -1 sea, -2 stuck, >0 basin id
|
||||||
|
{
|
||||||
|
var path = new List<int>(4096);
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (IsSea(i) || dest[i] != 0) continue;
|
||||||
|
int c = i; path.Clear();
|
||||||
|
int result;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (dest[c] != 0) { result = dest[c]; break; }
|
||||||
|
path.Add(c);
|
||||||
|
if (dir[c] == D_SEA) { result = -1; break; }
|
||||||
|
if (dir[c] == D_NONE) { result = basinId[c] != 0 ? basinId[c] : -2; break; }
|
||||||
|
c = Target(c);
|
||||||
|
}
|
||||||
|
foreach (int pc in path) dest[pc] = result;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (IsSea(i)) continue;
|
||||||
|
plan.LandCells++;
|
||||||
|
if (dest[i] == -1) plan.SeaReachingCells++;
|
||||||
|
else if (dest[i] > 0) { plan.EndorheicCells++; basinInflow[dest[i]]++; }
|
||||||
|
else plan.UnroutedCells++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5a. Outlets: land cells whose flow enters the sea, ranked by acc ---
|
||||||
|
var outlets = new List<(int cell, long acc)>();
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
if (dir[i] == D_SEA) outlets.Add((i, acc[i]));
|
||||||
|
outlets.Sort((a, b) => b.acc.CompareTo(a.acc));
|
||||||
|
|
||||||
|
foreach (var (cell, a) in outlets.GetRange(0, Math.Min(12, outlets.Count)))
|
||||||
|
plan.AllOutletsTop.Add((cell / n, cell % n, a));
|
||||||
|
|
||||||
|
// Greedy top-N with separation, so three mouths of one delta can't take
|
||||||
|
// all three trunk slots.
|
||||||
|
var picked = new List<int>();
|
||||||
|
foreach (var (cell, _) in outlets)
|
||||||
|
{
|
||||||
|
if (picked.Count >= p.TrunkCount) break;
|
||||||
|
int cx = cell / n, cy = cell % n;
|
||||||
|
bool far = true;
|
||||||
|
foreach (int pcell in picked)
|
||||||
|
{
|
||||||
|
float ddx = cx - pcell / n, ddy = cy - pcell % n;
|
||||||
|
if (ddx * ddx + ddy * ddy < (float)p.MinOutletSeparationPx * p.MinOutletSeparationPx)
|
||||||
|
{ far = false; break; }
|
||||||
|
}
|
||||||
|
if (far) picked.Add(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
// upstream max-acc walk shared by trunks and tributaries
|
||||||
|
List<int> TraceStem(int fromCell, int minAcc)
|
||||||
|
{
|
||||||
|
var stem = new List<int> { fromCell };
|
||||||
|
int c = fromCell;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
int bestN = -1; long bestA = minAcc - 1;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (dir[ni] >= 0 && Target(ni) == c && acc[ni] > bestA)
|
||||||
|
{ bestA = acc[ni]; bestN = ni; }
|
||||||
|
}
|
||||||
|
if (bestN < 0) break;
|
||||||
|
stem.Add(bestN);
|
||||||
|
c = bestN;
|
||||||
|
}
|
||||||
|
return stem;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<(float x, float y)> Decimate(List<int> cells, int step = 4)
|
||||||
|
{
|
||||||
|
var pts = new List<(float, float)>();
|
||||||
|
for (int i = 0; i < cells.Count; i += step)
|
||||||
|
pts.Add((cells[i] / n, cells[i] % n));
|
||||||
|
if ((cells.Count - 1) % step != 0)
|
||||||
|
pts.Add((cells[^1] / n, cells[^1] % n));
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5b. Trunks: stems, mountain exits, LEAN tributaries ---
|
||||||
|
foreach (int outletCell in picked)
|
||||||
|
{
|
||||||
|
var t = new Trunk
|
||||||
|
{
|
||||||
|
Outlet = (outletCell / n, outletCell % n),
|
||||||
|
DrainageAreaPx = acc[outletCell]
|
||||||
|
};
|
||||||
|
var stem = TraceStem(outletCell, p.StemMinAccPx);
|
||||||
|
t.Course = Decimate(stem);
|
||||||
|
t.Head = (stem[^1] / n, stem[^1] % n);
|
||||||
|
|
||||||
|
// Mountain-exit: walk the stem downstream-first; the exit is the
|
||||||
|
// furthest-DOWNSTREAM point whose upstream window still sustains the
|
||||||
|
// grade — i.e. where the mountains hand the river to the flats.
|
||||||
|
// Elevation truth is the ORIGINAL eroded surface, not the fill.
|
||||||
|
int w = p.ExitWindowPx;
|
||||||
|
for (int i = 0; i + w < stem.Count; i++)
|
||||||
|
{
|
||||||
|
float rise = WorldScale.MetresFromRaw(original[stem[i + w]] - original[stem[i]]);
|
||||||
|
if (rise / w >= p.ExitGradeMin)
|
||||||
|
{
|
||||||
|
t.ExitFound = true;
|
||||||
|
t.MountainExit = (stem[i] / n, stem[i] % n);
|
||||||
|
t.MountainExitElevM = WorldScale.MetresFromRaw(original[stem[i]]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LEAN tributaries: junction branches off the stem with enough drainage,
|
||||||
|
// top few by accumulation.
|
||||||
|
var stemSet = new HashSet<int>(stem);
|
||||||
|
var cands = new List<(int cell, long acc)>();
|
||||||
|
foreach (int sc in stem)
|
||||||
|
{
|
||||||
|
int cx = sc / n, cy = sc % n;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (stemSet.Contains(ni)) continue;
|
||||||
|
if (dir[ni] >= 0 && Target(ni) == sc && acc[ni] >= p.TributaryMinAccPx)
|
||||||
|
cands.Add((ni, acc[ni]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cands.Sort((a, b) => b.acc.CompareTo(a.acc));
|
||||||
|
// Dedup: two inflow neighbours at adjacent stem cells are one confluence,
|
||||||
|
// not two tributaries — keep only junctions ≥ 30 px apart.
|
||||||
|
var taken = new List<int>();
|
||||||
|
foreach (var (cell, a) in cands)
|
||||||
|
{
|
||||||
|
if (taken.Count >= p.TributaryMaxPerTrunk) break;
|
||||||
|
int cx2 = cell / n, cy2 = cell % n;
|
||||||
|
bool dup = false;
|
||||||
|
foreach (int tc in taken)
|
||||||
|
{
|
||||||
|
float ddx = cx2 - tc / n, ddy = cy2 - tc % n;
|
||||||
|
if (ddx * ddx + ddy * ddy < 30f * 30f) { dup = true; break; }
|
||||||
|
}
|
||||||
|
if (!dup) taken.Add(cell);
|
||||||
|
}
|
||||||
|
foreach (int cell in taken)
|
||||||
|
{
|
||||||
|
long a = acc[cell];
|
||||||
|
var trib = new Stream { DrainageAreaPx = a };
|
||||||
|
var ts = TraceStem(cell, Math.Max(p.StemMinAccPx, (int)(a / 20)));
|
||||||
|
trib.Course = Decimate(ts);
|
||||||
|
trib.Head = (ts[^1] / n, ts[^1] % n);
|
||||||
|
t.Tributaries.Add(trib);
|
||||||
|
}
|
||||||
|
plan.Trunks.Add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5c. LEAN endorheic terminals: terminal basins ranked by TOTAL inflow ---
|
||||||
|
{
|
||||||
|
var terms = new List<(int id, long inflow)>();
|
||||||
|
for (int id = 1; id < basinMinCell.Count; id++)
|
||||||
|
{
|
||||||
|
int mc = basinMinCell[id];
|
||||||
|
if (mc < 0 || basinId[mc] != id) continue; // not a terminal basin
|
||||||
|
if (basinInflow[id] >= p.EndorheicMinInflowPx) terms.Add((id, basinInflow[id]));
|
||||||
|
}
|
||||||
|
terms.Sort((a, b) => b.inflow.CompareTo(a.inflow));
|
||||||
|
foreach (var (id, inflow) in terms.GetRange(0, Math.Min(p.EndorheicMaxCount, terms.Count)))
|
||||||
|
{
|
||||||
|
int mc = basinMinCell[id];
|
||||||
|
plan.Endorheics.Add(new EndorheicTerminal
|
||||||
|
{
|
||||||
|
Terminal = (mc / n, mc % n),
|
||||||
|
DrainageAreaPx = inflow,
|
||||||
|
BasinDepthM = basinDepthM[id],
|
||||||
|
BasinAreaPx = basinAreaPx[id]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5d. The promoted GIANTS (21b): mixed set, provisional routes ---
|
||||||
|
// Top GiantCount terminal basins by TOTAL inflow. Their upland stems are the
|
||||||
|
// island's real big rivers; whether each continues to the sea is the gate's
|
||||||
|
// decision, previewed here.
|
||||||
|
{
|
||||||
|
var giantsRanked = new List<(int id, long inflow)>();
|
||||||
|
for (int id = 1; id < basinMinCell.Count; id++)
|
||||||
|
{
|
||||||
|
int mc = basinMinCell[id];
|
||||||
|
if (mc < 0 || basinId[mc] != id) continue;
|
||||||
|
if (basinInflow[id] >= p.EndorheicMinInflowPx) giantsRanked.Add((id, basinInflow[id]));
|
||||||
|
}
|
||||||
|
giantsRanked.Sort((a, b) => b.inflow.CompareTo(a.inflow));
|
||||||
|
|
||||||
|
// Does a terminal basin HOLD classify water? The lake-ender test must look
|
||||||
|
// at the whole pool, not the stem's single pooling cell — a stem can pool on
|
||||||
|
// dry ground a few hundred px short of its lagoon and still be a lagoon river.
|
||||||
|
bool[] basinHasLake = new bool[basinMinCell.Count];
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
if (basinId[i] != 0 && isClassifyWater[i] && !isOcean[i])
|
||||||
|
basinHasLake[basinId[i]] = true;
|
||||||
|
|
||||||
|
// The main stem's ENTRY into the basin: the highest-accumulation cell
|
||||||
|
// whose flow terminates in this basin. On a flat basin floor the deepest
|
||||||
|
// cell sees only local trickles (the task-21 lesson), so the stem is
|
||||||
|
// anchored on the strongest feeder instead.
|
||||||
|
var bestEntry = new Dictionary<int, int>();
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (dest[i] <= 0) continue;
|
||||||
|
if (!bestEntry.TryGetValue(dest[i], out int cur) || acc[i] > acc[cur])
|
||||||
|
bestEntry[dest[i]] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The giant whose pooling point sits closest to the southernmost town is
|
||||||
|
// the SOUTHERN CANDIDATE — always routed provisionally (shown, not forced).
|
||||||
|
int southernPick = -1;
|
||||||
|
if (southX >= 0f)
|
||||||
|
{
|
||||||
|
float bestD = float.MaxValue;
|
||||||
|
foreach (var (id, _) in giantsRanked.GetRange(0, Math.Min(p.GiantCount, giantsRanked.Count)))
|
||||||
|
{
|
||||||
|
int mc = basinMinCell[id];
|
||||||
|
float ddx = mc / n - southX, ddy = mc % n - southY;
|
||||||
|
float d2 = ddx * ddx + ddy * ddy;
|
||||||
|
if (d2 < bestD) { bestD = d2; southernPick = id; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (id, inflow) in giantsRanked.GetRange(0, Math.Min(p.GiantCount, giantsRanked.Count)))
|
||||||
|
{
|
||||||
|
var g = new Giant { DrainageAreaPx = inflow, BasinDepthM = basinDepthM[id], BasinAreaPx = basinAreaPx[id] };
|
||||||
|
if (!bestEntry.TryGetValue(id, out int entry)) entry = basinMinCell[id];
|
||||||
|
|
||||||
|
// Downstream from the strongest feeder to where it actually pools…
|
||||||
|
int t2 = entry;
|
||||||
|
var down = new List<int> { t2 };
|
||||||
|
while (dir[t2] >= 0) { t2 = Target(t2); down.Add(t2); }
|
||||||
|
g.Terminal = (t2 / n, t2 % n);
|
||||||
|
// …then the full main stem, traced upstream from that pooling point.
|
||||||
|
var stem = TraceStem(t2, p.StemMinAccPx);
|
||||||
|
g.Course = Decimate(stem);
|
||||||
|
g.Head = (stem[^1] / n, stem[^1] % n);
|
||||||
|
g.TerminalInClassifyWater = isClassifyWater[t2];
|
||||||
|
|
||||||
|
for (int i = 0; i + p.ExitWindowPx < stem.Count; i++)
|
||||||
|
{
|
||||||
|
float rise = WorldScale.MetresFromRaw(original[stem[i + p.ExitWindowPx]] - original[stem[i]]);
|
||||||
|
if (rise / p.ExitWindowPx >= p.ExitGradeMin)
|
||||||
|
{
|
||||||
|
g.ExitFound = true;
|
||||||
|
g.MountainExit = (stem[i] / n, stem[i] % n);
|
||||||
|
g.MountainExitElevM = WorldScale.MetresFromRaw(original[stem[i]]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lean tributaries on the giant's stem, same junction rule as trunks.
|
||||||
|
var stemSet = new HashSet<int>(stem);
|
||||||
|
var cands = new List<(int cell, long acc)>();
|
||||||
|
foreach (int sc in stem)
|
||||||
|
{
|
||||||
|
int cx = sc / n, cy = sc % n;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (stemSet.Contains(ni)) continue;
|
||||||
|
if (dir[ni] >= 0 && Target(ni) == sc && acc[ni] >= p.TributaryMinAccPx)
|
||||||
|
cands.Add((ni, acc[ni]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cands.Sort((a, b) => b.acc.CompareTo(a.acc));
|
||||||
|
var takenT = new List<int>();
|
||||||
|
foreach (var (cell, _) in cands)
|
||||||
|
{
|
||||||
|
if (takenT.Count >= p.TributaryMaxPerTrunk) break;
|
||||||
|
int cx2 = cell / n, cy2 = cell % n;
|
||||||
|
bool dup = false;
|
||||||
|
foreach (int tc in takenT)
|
||||||
|
{
|
||||||
|
float ddx = cx2 - tc / n, ddy = cy2 - tc % n;
|
||||||
|
if (ddx * ddx + ddy * ddy < 30f * 30f) { dup = true; break; }
|
||||||
|
}
|
||||||
|
if (!dup) takenT.Add(cell);
|
||||||
|
}
|
||||||
|
foreach (int cell in takenT)
|
||||||
|
{
|
||||||
|
var trib = new Stream { DrainageAreaPx = acc[cell] };
|
||||||
|
var ts = TraceStem(cell, Math.Max(p.StemMinAccPx, (int)(acc[cell] / 20)));
|
||||||
|
trib.Course = Decimate(ts);
|
||||||
|
trib.Head = (ts[^1] / n, ts[^1] % n);
|
||||||
|
g.Tributaries.Add(trib);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind: the terminal BASIN holds a classify lake → natural lake-ender;
|
||||||
|
// dry pan → route to sea; the southern candidate is always routed.
|
||||||
|
g.SouthernCandidate = id == southernPick;
|
||||||
|
g.TerminalInClassifyWater = g.TerminalInClassifyWater || basinHasLake[id];
|
||||||
|
g.Kind = (basinHasLake[id] && !g.SouthernCandidate) ? "lake-ender" : "routed";
|
||||||
|
|
||||||
|
// PROVISIONAL route (routed giants): walk steepest descent on the FULL
|
||||||
|
// fill from the pooling point — the basin overtops at its spill and
|
||||||
|
// the walk continues along the terrain's own drainage to the ocean.
|
||||||
|
// DRAWN, not carved; part 2 carves along a route like this one.
|
||||||
|
// (chat2/12: computed as the reference did; not drawn, not promoted — routing is later.)
|
||||||
|
if (g.Kind == "routed")
|
||||||
|
{
|
||||||
|
var route = new List<int>();
|
||||||
|
int c = t2;
|
||||||
|
bool spillRecorded = false;
|
||||||
|
for (int guard = 0; guard < 4 * n; guard++)
|
||||||
|
{
|
||||||
|
route.Add(c);
|
||||||
|
if (isOcean[c]) { g.RouteReachedOcean = true; break; }
|
||||||
|
if (!spillRecorded && basinId[c] != id)
|
||||||
|
{ g.Spill = (c / n, c % n); spillRecorded = true; }
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
float best = float.MaxValue; int bestN = -1;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (plan_fullFilled[ni] < best) { best = plan_fullFilled[ni]; bestN = ni; }
|
||||||
|
}
|
||||||
|
if (bestN < 0 || plan_fullFilled[bestN] >= plan_fullFilled[c]) break; // stuck (report via flag)
|
||||||
|
c = bestN;
|
||||||
|
}
|
||||||
|
g.ProvisionalRoute = Decimate(route);
|
||||||
|
}
|
||||||
|
plan.Giants.Add(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.Dir = dir; plan.Acc = acc; plan.Filled = filled; plan.FullFilled = plan_fullFilled;
|
||||||
|
plan.BasinId = basinId; plan.BasinInflow = basinInflow;
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Core/Scripts/DrainageAnalysis.cs.uid
Normal file
1
Core/Scripts/DrainageAnalysis.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://c6yfvp8p5br07
|
||||||
385
Core/Scripts/HydraulicErosion.cs
Normal file
385
Core/Scripts/HydraulicErosion.cs
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Core
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ DROPLET (HYDRAULIC) EROSION — THE FAITHFUL PORT (chat2/11). Ported from the reference's
|
||||||
|
/// <c>Tools/Scripts/HydraulicErosion.cs</c> at tag <c>pre-rewrite-reference</c> (<c>ab78883</c>),
|
||||||
|
/// VERBATIM in arithmetic and order (D-050): the reference's class doc is kept below because it is
|
||||||
|
/// the design record; what this port changes is listed first.
|
||||||
|
///
|
||||||
|
/// ═══ WHAT THE PORT CHANGES (and nothing else) ═══
|
||||||
|
///
|
||||||
|
/// • Namespace + location: <c>IslaApocalypse.Core</c> — engine-free (System.MathF, own PCG32), a
|
||||||
|
/// named C++ candidate, exactly as the reference said it was.
|
||||||
|
/// • The yardstick: every metres↔raw conversion goes through <see cref="WorldScale"/>
|
||||||
|
/// (<c>MetresFromRaw</c> / <c>RawFromMetres</c>) — there is no literal 251 in this file. The
|
||||||
|
/// arithmetic is the same float multiply / divide by the same constant, so results are
|
||||||
|
/// bit-identical to the reference's <c>* M_PER_UNIT</c> / <c>/ M_PER_UNIT</c>.
|
||||||
|
/// • <c>VERSION</c> is a local constant (1): the blueprint format the reference read it from is
|
||||||
|
/// not ported yet; when it is, this becomes a read of the format's constant, as the reference
|
||||||
|
/// warned (task 18).
|
||||||
|
/// • The crater exclusion is ported whole and is INERT in v2 until the crater carve exists: the
|
||||||
|
/// caller passes radius 0, so <c>CraterWeight</c> is 1 everywhere (see <c>ErosionPass</c>).
|
||||||
|
///
|
||||||
|
/// ═══ THE REFERENCE'S CLASS DOC (verbatim) ═══
|
||||||
|
///
|
||||||
|
/// Droplet-based hydraulic erosion (terrain-water task 17, Phase C0) — the organic
|
||||||
|
/// carve-AND-deposit pass, Lague/Beyer lineage. Pure numeric over the height array
|
||||||
|
/// (D-035; a named future C++ candidate, kept standalone — no Godot types at all,
|
||||||
|
/// System.MathF only, own deterministic PCG32 RNG).
|
||||||
|
///
|
||||||
|
/// Each droplet spawns on land (spawn probability weighted toward high ground),
|
||||||
|
/// then walks downhill with inertia, carrying water and sediment. Where the ground
|
||||||
|
/// is steep and it moves fast it ERODES (up to capacity, spread over a small brush
|
||||||
|
/// so no single-cell spikes — the anti-artifact that killed the D8 predecessor);
|
||||||
|
/// where it flattens out it DEPOSITS, building valley floors and fans, over the
|
||||||
|
/// SAME brush (task 18 — bilinear 4-cell deposition built isolated cones at gully
|
||||||
|
/// mouths; carving and dumping are now symmetric). Water
|
||||||
|
/// evaporates each step; the droplet dies at its lifetime, at the map edge, or on
|
||||||
|
/// reaching the sea (its remaining sediment is lost to the ocean).
|
||||||
|
///
|
||||||
|
/// OUTPUT-ONLY: this pass is applied to the RENDER height map only; the classify
|
||||||
|
/// map never sees it (the caller owns that split — see ErosionPass).
|
||||||
|
///
|
||||||
|
/// The three hard governors (the pass provably cannot run away):
|
||||||
|
/// 1. DropletCount — total droplets (the main detail/cost dial).
|
||||||
|
/// 2. Lifetime — max steps per droplet; no infinite wandering.
|
||||||
|
/// 3. CarveCapM — max erosion depth per cell, in metres, measured from the
|
||||||
|
/// height the pass found and enforced against a per-cell NET
|
||||||
|
/// displacement ledger. The runaway-trench guard, and the
|
||||||
|
/// dial that decides how deep trunk channels may cut.
|
||||||
|
/// 4. DepositCapM — max build-up per cell, the same ledger read the other way
|
||||||
|
/// (task 18). Brush-spreading alone does not bound a spike:
|
||||||
|
/// droplets on long paths carry far more sediment, and a
|
||||||
|
/// loaded droplet meeting a rise dumps min(rise, load) at
|
||||||
|
/// once. This makes "no deposit cones" a governor rather
|
||||||
|
/// than a hope. <= 0 disables it (the reference model).
|
||||||
|
///
|
||||||
|
/// The sea clamp (the "don't over-flood" guard): erosion never lowers any cell
|
||||||
|
/// below its local sea level + SeaMarginM, and cells already below sea are
|
||||||
|
/// read-only — never eroded, never deposited on. Land stays land, sea stays sea;
|
||||||
|
/// the rendered coastline cannot move. Deposition only raises land cells.
|
||||||
|
///
|
||||||
|
/// The crater treatment (task 19): no cell within the protected strike CORE is
|
||||||
|
/// modified (droplets may traverse), and outside it either FULL strength applies
|
||||||
|
/// immediately or FEATHER ramps in across a band. The carve remains the final
|
||||||
|
/// authority on the deep bowl; the bay's sea connection is guaranteed by the sea
|
||||||
|
/// clamp rather than by the exclusion, since below-sea cells are read-only in
|
||||||
|
/// both directions.
|
||||||
|
///
|
||||||
|
/// Heights in the array are raw blueprint units (1 unit = 251 m). All sediment
|
||||||
|
/// accounting below is done in METRES and converted only when a delta is applied,
|
||||||
|
/// so untouched cells keep their exact bit pattern — the invariants above are
|
||||||
|
/// exact, not statistical.
|
||||||
|
/// </summary>
|
||||||
|
public static class HydraulicErosion
|
||||||
|
{
|
||||||
|
/// <summary>The EROS body version. ⚠ The reference read this from its blueprint format (the version byte IS the payload layout's identity); v2 has no format yet, so it is a local 1 until then.</summary>
|
||||||
|
public const ushort VERSION = 1;
|
||||||
|
|
||||||
|
/// <summary>Deterministic RNG stream: seeded from resolvedSeed + this offset, decorrelated from every noise field.</summary>
|
||||||
|
public const int SEED_OFFSET = 9271;
|
||||||
|
|
||||||
|
// --- Crater treatment (task 19) — ported whole, INERT in v2 until the crater carve lands ---
|
||||||
|
//
|
||||||
|
// Task 17 used a hard 1.2 × CraterRadius cutoff. Measured on seed 1280587109
|
||||||
|
// (task-19 radius dump): the carve writes only inside 0.80 × (640 px) and its
|
||||||
|
// displacement is EXACTLY 0 beyond that, so the 640–960 px annulus was 620,811
|
||||||
|
// land cells of ordinary terrain held smooth for no geometric reason — a
|
||||||
|
// visible un-eroded disc against dissected ground, with a hard edge.
|
||||||
|
//
|
||||||
|
// The protected core is now the deep strike zone only. The bay itself needs no
|
||||||
|
// exclusion: below-sea cells are read-only in both directions (the sea clamp),
|
||||||
|
// so erosion can neither carve the bay's sea connection open nor silt it shut.
|
||||||
|
// The core exists to stop the BOWL being dissected on seeds where it holds land.
|
||||||
|
public const float CRATER_CORE_FACTOR_DEFAULT = 0.50f; // ×CraterRadius (the pass's own default; the reference's ConfigManager shipped 0.80 — see ErosionPass)
|
||||||
|
public const float CRATER_FEATHER_FACTOR_DEFAULT = 1.05f; // ×CraterRadius, FEATHER only
|
||||||
|
|
||||||
|
public const byte CRATER_MODE_FULL = 0;
|
||||||
|
public const byte CRATER_MODE_FEATHER = 1;
|
||||||
|
|
||||||
|
// Spawn: droplets source in the mountains, never the ocean. A land point is
|
||||||
|
// accepted with probability SPAWN_FLOOR + (1-SPAWN_FLOOR) · relative elevation,
|
||||||
|
// after at most SPAWN_TRIES rejection-sampling attempts (then the droplet is
|
||||||
|
// skipped and counted — on any real island this is vanishingly rare).
|
||||||
|
private const int SPAWN_TRIES = 16;
|
||||||
|
private const float SPAWN_FLOOR = 0.15f;
|
||||||
|
|
||||||
|
private const float MIN_WATER = 0.005f; // droplet dies when effectively dry
|
||||||
|
private const float MIN_DIR = 1e-10f; // below this, direction is re-drawn at random
|
||||||
|
|
||||||
|
public struct Params
|
||||||
|
{
|
||||||
|
public int DropletCount; // governor 1
|
||||||
|
public int Lifetime; // governor 2
|
||||||
|
public float CarveCapM; // governor 3 (metres)
|
||||||
|
public float DepositCapM; // governor 4 (metres); <= 0 = unbounded
|
||||||
|
public float SeaMarginM; // sea clamp margin (metres)
|
||||||
|
public int BrushRadius; // erosion brush radius, px
|
||||||
|
public float Inertia; // 0 = pure gradient descent, 1 = never turns
|
||||||
|
public float CapacityFactor; // sediment capacity multiplier
|
||||||
|
public float MinSlopeM; // capacity slope floor, metres per px
|
||||||
|
public float ErodeRate; // fraction of remaining capacity eroded per step
|
||||||
|
public float DepositRate; // fraction of surplus sediment dropped per step
|
||||||
|
public float Evaporation; // water lost per step (fraction)
|
||||||
|
public float Gravity; // speed gain per metre of drop
|
||||||
|
public byte CraterMode; // CRATER_MODE_FULL | CRATER_MODE_FEATHER (task 19)
|
||||||
|
public int Seed; // resolvedSeed + SEED_OFFSET
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Stats
|
||||||
|
{
|
||||||
|
public int Spawned;
|
||||||
|
public int SkippedNoLand;
|
||||||
|
public long Steps;
|
||||||
|
public int DiedLifetime, DiedEdge, DiedSea, DiedDry;
|
||||||
|
public double ErodedVolumeM3; // 1 px = 1 m², so metres of depth sum to m³
|
||||||
|
public double DepositedVolumeM3;
|
||||||
|
public float MaxCellErosionM; // must end ≤ CarveCapM
|
||||||
|
public float MaxCellDepositM; // the deposit-spike metric (task 18)
|
||||||
|
public long ModifiedCells; // cells the pass touched at all
|
||||||
|
}
|
||||||
|
|
||||||
|
// PCG32 (O'Neill) — tiny, deterministic, trivially portable to C++.
|
||||||
|
private struct Pcg32
|
||||||
|
{
|
||||||
|
private ulong _state;
|
||||||
|
public Pcg32(int seed) { _state = 0; NextU(); _state += (ulong)(uint)seed; NextU(); }
|
||||||
|
public uint NextU()
|
||||||
|
{
|
||||||
|
ulong old = _state;
|
||||||
|
_state = old * 6364136223846793005UL + 1442695040888963407UL;
|
||||||
|
uint xorshifted = (uint)(((old >> 18) ^ old) >> 27);
|
||||||
|
int rot = (int)(old >> 59);
|
||||||
|
return (xorshifted >> rot) | (xorshifted << (-rot & 31));
|
||||||
|
}
|
||||||
|
public float NextF() => (NextU() >> 8) * (1f / 16777216f); // [0,1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the pass in place on <paramref name="height"/>. Sea level per cell is
|
||||||
|
/// <paramref name="seaMap"/>[x,y] when non-null, else the flat scalar
|
||||||
|
/// <paramref name="seaFlat"/>. Throws (refusing the generation) if a governor
|
||||||
|
/// bound is violated on exit — the caller treats that as a build failure.
|
||||||
|
/// </summary>
|
||||||
|
public static Stats Apply(float[,] height, int mapSize, float[,] seaMap, float seaFlat,
|
||||||
|
float craterCx, float craterCy, float craterCoreRadius, float craterFeatherRadius, Params p)
|
||||||
|
{
|
||||||
|
var stats = new Stats();
|
||||||
|
var rng = new Pcg32(p.Seed);
|
||||||
|
float capUnits = WorldScale.RawFromMetres(p.CarveCapM);
|
||||||
|
if (p.DropletCount <= 0 || capUnits <= 0f) return stats;
|
||||||
|
|
||||||
|
// Per-cell NET displacement ledger, metres, positive = carved below where the
|
||||||
|
// pass found this cell, negative = built up above it. Governor 3's enforcement
|
||||||
|
// record: the cap bounds `net`, so it bounds erosion depth measured from the
|
||||||
|
// ORIGINAL height — deposit-then-carve at one cell cannot smuggle in extra
|
||||||
|
// depth, and carve-then-deposit correctly frees the headroom back up.
|
||||||
|
float[,] net = new float[mapSize, mapSize];
|
||||||
|
|
||||||
|
// Spawn weighting needs the seed's top height.
|
||||||
|
float hTop = float.MinValue;
|
||||||
|
for (int x = 0; x < mapSize; x++)
|
||||||
|
for (int y = 0; y < mapSize; y++)
|
||||||
|
if (height[x, y] > hTop) hTop = height[x, y];
|
||||||
|
|
||||||
|
// Erosion brush: all offsets within BrushRadius, cone-weighted (1 - d/r),
|
||||||
|
// normalized. Radius 0 degrades to the single cell.
|
||||||
|
int r = Math.Max(p.BrushRadius, 0);
|
||||||
|
int brushN = 0;
|
||||||
|
for (int dx = -r; dx <= r; dx++)
|
||||||
|
for (int dy = -r; dy <= r; dy++)
|
||||||
|
if (MathF.Sqrt(dx * dx + dy * dy) <= r + 1e-4f) brushN++;
|
||||||
|
int[] brushDx = new int[brushN], brushDy = new int[brushN];
|
||||||
|
float[] brushW = new float[brushN];
|
||||||
|
{
|
||||||
|
int i = 0; float wSum = 0f;
|
||||||
|
for (int dx = -r; dx <= r; dx++)
|
||||||
|
for (int dy = -r; dy <= r; dy++)
|
||||||
|
{
|
||||||
|
float d = MathF.Sqrt(dx * dx + dy * dy);
|
||||||
|
if (d > r + 1e-4f) continue;
|
||||||
|
brushDx[i] = dx; brushDy[i] = dy;
|
||||||
|
brushW[i] = r > 0 ? 1f - d / (r + 1f) : 1f;
|
||||||
|
wSum += brushW[i]; i++;
|
||||||
|
}
|
||||||
|
for (int j = 0; j < brushN; j++) brushW[j] /= wSum;
|
||||||
|
}
|
||||||
|
|
||||||
|
float SeaAt(int cx, int cy) => seaMap != null ? seaMap[cx, cy] : seaFlat;
|
||||||
|
|
||||||
|
// Crater weight (task 19): 0 inside the protected strike core, 1 where erosion
|
||||||
|
// runs at full strength. FULL steps straight to 1 at the core boundary; FEATHER
|
||||||
|
// ramps linearly out to craterFeatherRadius, mirroring the detail pass's shape,
|
||||||
|
// so the crater reads as younger/less-weathered with no seam. Amounts are SCALED
|
||||||
|
// by this rather than skipped, which is what makes FEATHER a one-liner.
|
||||||
|
// ⚠ v2: with no crater (radius 0) this is 1 everywhere — INERT.
|
||||||
|
float coreSq = craterCoreRadius * craterCoreRadius;
|
||||||
|
bool feather = p.CraterMode == CRATER_MODE_FEATHER
|
||||||
|
&& craterFeatherRadius > craterCoreRadius;
|
||||||
|
float CraterWeight(int cx, int cy)
|
||||||
|
{
|
||||||
|
float ddx = cx - craterCx, ddy = cy - craterCy;
|
||||||
|
float d2 = ddx * ddx + ddy * ddy;
|
||||||
|
if (d2 < coreSq) return 0f;
|
||||||
|
if (!feather) return 1f;
|
||||||
|
float d = MathF.Sqrt(d2);
|
||||||
|
if (d >= craterFeatherRadius) return 1f;
|
||||||
|
return (d - craterCoreRadius) / (craterFeatherRadius - craterCoreRadius);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int drop = 0; drop < p.DropletCount; drop++)
|
||||||
|
{
|
||||||
|
// --- spawn (land only, elevation-weighted) ---
|
||||||
|
float px = -1f, py = -1f;
|
||||||
|
for (int attempt = 0; attempt < SPAWN_TRIES; attempt++)
|
||||||
|
{
|
||||||
|
float sx = 1f + rng.NextF() * (mapSize - 3);
|
||||||
|
float sy = 1f + rng.NextF() * (mapSize - 3);
|
||||||
|
int cx = (int)sx, cy = (int)sy;
|
||||||
|
float h = height[cx, cy];
|
||||||
|
float sea = SeaAt(cx, cy);
|
||||||
|
if (h < sea) { continue; }
|
||||||
|
float rel = hTop > sea ? Math.Clamp((h - sea) / (hTop - sea), 0f, 1f) : 0f;
|
||||||
|
if (rng.NextF() < SPAWN_FLOOR + (1f - SPAWN_FLOOR) * rel) { px = sx; py = sy; break; }
|
||||||
|
}
|
||||||
|
if (px < 0f) { stats.SkippedNoLand++; continue; }
|
||||||
|
stats.Spawned++;
|
||||||
|
|
||||||
|
float dirX = 0f, dirY = 0f, speed = 1f, water = 1f, sedimentM = 0f;
|
||||||
|
|
||||||
|
for (int step = 0; step < p.Lifetime; step++)
|
||||||
|
{
|
||||||
|
stats.Steps++;
|
||||||
|
int xi = (int)px, yi = (int)py;
|
||||||
|
float fx = px - xi, fy = py - yi;
|
||||||
|
|
||||||
|
// Bilinear height + gradient at the current position.
|
||||||
|
float h00 = height[xi, yi], h10 = height[xi + 1, yi];
|
||||||
|
float h01 = height[xi, yi + 1], h11 = height[xi + 1, yi + 1];
|
||||||
|
float gradX = (h10 - h00) * (1f - fy) + (h11 - h01) * fy;
|
||||||
|
float gradY = (h01 - h00) * (1f - fx) + (h11 - h10) * fx;
|
||||||
|
float hOld = h00 * (1f - fx) * (1f - fy) + h10 * fx * (1f - fy)
|
||||||
|
+ h01 * (1f - fx) * fy + h11 * fx * fy;
|
||||||
|
|
||||||
|
// Inertia blend, then one unit step.
|
||||||
|
dirX = dirX * p.Inertia - gradX * (1f - p.Inertia);
|
||||||
|
dirY = dirY * p.Inertia - gradY * (1f - p.Inertia);
|
||||||
|
float len = MathF.Sqrt(dirX * dirX + dirY * dirY);
|
||||||
|
if (len < MIN_DIR)
|
||||||
|
{
|
||||||
|
float ang = rng.NextF() * 2f * MathF.PI;
|
||||||
|
dirX = MathF.Cos(ang); dirY = MathF.Sin(ang); len = 1f;
|
||||||
|
}
|
||||||
|
dirX /= len; dirY /= len;
|
||||||
|
px += dirX; py += dirY;
|
||||||
|
|
||||||
|
if (px < 1f || px >= mapSize - 2 || py < 1f || py >= mapSize - 2)
|
||||||
|
{ stats.DiedEdge++; break; }
|
||||||
|
|
||||||
|
int nxi = (int)px, nyi = (int)py;
|
||||||
|
float nfx = px - nxi, nfy = py - nyi;
|
||||||
|
float n00 = height[nxi, nyi], n10 = height[nxi + 1, nyi];
|
||||||
|
float n01 = height[nxi, nyi + 1], n11 = height[nxi + 1, nyi + 1];
|
||||||
|
float hNew = n00 * (1f - nfx) * (1f - nfy) + n10 * nfx * (1f - nfy)
|
||||||
|
+ n01 * (1f - nfx) * nfy + n11 * nfx * nfy;
|
||||||
|
|
||||||
|
// Reached the sea: die; the sediment is the ocean's now.
|
||||||
|
if (hNew < SeaAt(nxi, nyi)) { stats.DiedSea++; break; }
|
||||||
|
|
||||||
|
float dhM = WorldScale.MetresFromRaw(hNew - hOld);
|
||||||
|
float capacityM = MathF.Max(-dhM, p.MinSlopeM) * speed * water * p.CapacityFactor;
|
||||||
|
|
||||||
|
if (dhM > 0f || sedimentM > capacityM)
|
||||||
|
{
|
||||||
|
// Moving uphill (fill the pit behind us, at most the rise) or
|
||||||
|
// over capacity (drop a fraction of the surplus): DEPOSIT over
|
||||||
|
// the SAME cone brush erosion uses (task 18). Bilinear 4-cell
|
||||||
|
// deposition — the reference model's — concentrated a whole
|
||||||
|
// droplet's load into one cell at gully mouths and built
|
||||||
|
// isolated cones (measured 15.5 m on seed 1280587109, task 17
|
||||||
|
// §6.1). Spreading it makes deposition the symmetric mirror of
|
||||||
|
// carving; total mass is unchanged, only its footprint.
|
||||||
|
float amountM = dhM > 0f ? MathF.Min(dhM, sedimentM)
|
||||||
|
: (sedimentM - capacityM) * p.DepositRate;
|
||||||
|
if (amountM > 0f)
|
||||||
|
{
|
||||||
|
for (int b = 0; b < brushN; b++)
|
||||||
|
{
|
||||||
|
int cx = xi + brushDx[b], cy = yi + brushDy[b];
|
||||||
|
if (cx < 0 || cx >= mapSize || cy < 0 || cy >= mapSize) continue;
|
||||||
|
float wCrater = CraterWeight(cx, cy);
|
||||||
|
if (wCrater <= 0f) continue;
|
||||||
|
float hCell = height[cx, cy];
|
||||||
|
// Below-sea cells are read-only in BOTH directions: no
|
||||||
|
// submarine deltas, so the rendered coastline cannot move.
|
||||||
|
if (hCell < SeaAt(cx, cy)) continue;
|
||||||
|
float give = amountM * brushW[b] * wCrater;
|
||||||
|
// Governor 4: the ledger read the other way. net is negative
|
||||||
|
// where the cell has already been built up, so the headroom
|
||||||
|
// is cap + net.
|
||||||
|
if (p.DepositCapM > 0f)
|
||||||
|
give = MathF.Min(give, MathF.Max(0f, p.DepositCapM + net[cx, cy]));
|
||||||
|
if (give <= 0f) continue;
|
||||||
|
height[cx, cy] = hCell + WorldScale.RawFromMetres(give);
|
||||||
|
if (net[cx, cy] == 0f) stats.ModifiedCells++;
|
||||||
|
net[cx, cy] -= give;
|
||||||
|
if (-net[cx, cy] > stats.MaxCellDepositM) stats.MaxCellDepositM = -net[cx, cy];
|
||||||
|
sedimentM -= give;
|
||||||
|
stats.DepositedVolumeM3 += give;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Under capacity on a downhill move: ERODE, spread over the
|
||||||
|
// brush, never more than the drop itself (no digging pits).
|
||||||
|
float amountM = MathF.Min((capacityM - sedimentM) * p.ErodeRate, -dhM);
|
||||||
|
if (amountM > 0f)
|
||||||
|
{
|
||||||
|
for (int b = 0; b < brushN; b++)
|
||||||
|
{
|
||||||
|
int cx = xi + brushDx[b], cy = yi + brushDy[b];
|
||||||
|
if (cx < 0 || cx >= mapSize || cy < 0 || cy >= mapSize) continue;
|
||||||
|
float wCrater = CraterWeight(cx, cy);
|
||||||
|
if (wCrater <= 0f) continue;
|
||||||
|
float sea = SeaAt(cx, cy);
|
||||||
|
float hCell = height[cx, cy];
|
||||||
|
if (hCell < sea) continue; // below-sea cells are read-only
|
||||||
|
float want = amountM * brushW[b] * wCrater;
|
||||||
|
float bySea = MathF.Max(0f, WorldScale.MetresFromRaw(hCell - (sea + WorldScale.RawFromMetres(p.SeaMarginM))));
|
||||||
|
float byCap = MathF.Max(0f, p.CarveCapM - net[cx, cy]);
|
||||||
|
float take = MathF.Min(want, MathF.Min(bySea, byCap));
|
||||||
|
if (take <= 0f) continue;
|
||||||
|
height[cx, cy] = hCell - WorldScale.RawFromMetres(take);
|
||||||
|
if (net[cx, cy] == 0f) stats.ModifiedCells++;
|
||||||
|
net[cx, cy] += take;
|
||||||
|
if (net[cx, cy] > stats.MaxCellErosionM) stats.MaxCellErosionM = net[cx, cy];
|
||||||
|
sedimentM += take;
|
||||||
|
stats.ErodedVolumeM3 += take;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
speed = MathF.Sqrt(MathF.Max(0f, speed * speed - dhM * p.Gravity));
|
||||||
|
water *= 1f - p.Evaporation;
|
||||||
|
if (water < MIN_WATER) { stats.DiedDry++; break; }
|
||||||
|
if (step == p.Lifetime - 1) stats.DiedLifetime++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Governor 3, proven on exit rather than assumed: the ledger's maximum must
|
||||||
|
// respect the cap (float addition of clamped takes cannot exceed it by more
|
||||||
|
// than rounding; allow one ulp-scale epsilon).
|
||||||
|
if (stats.MaxCellErosionM > p.CarveCapM * (1f + 1e-5f))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"[HydraulicErosion] CARVE-CAP VIOLATION: a cell accumulated {stats.MaxCellErosionM} m against cap {p.CarveCapM} m. Refusing to generate.");
|
||||||
|
if (p.DepositCapM > 0f && stats.MaxCellDepositM > p.DepositCapM * (1f + 1e-5f))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"[HydraulicErosion] DEPOSIT-CAP VIOLATION: a cell built up {stats.MaxCellDepositM} m against cap {p.DepositCapM} m. Refusing to generate.");
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Core/Scripts/HydraulicErosion.cs.uid
Normal file
1
Core/Scripts/HydraulicErosion.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bb4v6quey3xwl
|
||||||
273
Core/Scripts/RegionLabeling.cs
Normal file
273
Core/Scripts/RegionLabeling.cs
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Core
|
||||||
|
{
|
||||||
|
/// <summary>One maximal 8-connected component of land, as the region layer exposes it.</summary>
|
||||||
|
public sealed class LandRegion
|
||||||
|
{
|
||||||
|
/// <summary>1-based, assigned in deterministic scan order (x outer, y inner) — stable per seed across runs.</summary>
|
||||||
|
public int Id;
|
||||||
|
|
||||||
|
/// <summary>Cells in the component.</summary>
|
||||||
|
public long SizeCells;
|
||||||
|
|
||||||
|
/// <summary>Centroid in map cells.</summary>
|
||||||
|
public double CentroidX, CentroidY;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="RegionLabeling.HemiNorth"/> / <see cref="RegionLabeling.HemiSouth"/>, decided by the
|
||||||
|
/// CENTROID — one label per component; a straddler is decided by where its mass is, never per cell.
|
||||||
|
/// </summary>
|
||||||
|
public byte Hemisphere;
|
||||||
|
|
||||||
|
/// <summary>True for exactly one component: the one containing the map centre (or the flagged fallback).</summary>
|
||||||
|
public bool IsMainland;
|
||||||
|
|
||||||
|
/// <summary>Bounding box, inclusive. Convenience for overlays and guards; not part of the contract.</summary>
|
||||||
|
public int MinX, MinY, MaxX, MaxY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The result of one labeling: the per-cell id map and the per-component table.</summary>
|
||||||
|
public sealed class RegionLabels
|
||||||
|
{
|
||||||
|
public int MapSize;
|
||||||
|
|
||||||
|
/// <summary>Per cell, <c>x * MapSize + y</c>: the component id, or 0 for water.</summary>
|
||||||
|
public int[] Id;
|
||||||
|
|
||||||
|
/// <summary>Every component, indexed by <c>Id - 1</c>, in id order.</summary>
|
||||||
|
public List<LandRegion> Regions;
|
||||||
|
|
||||||
|
/// <summary>The mainland's id (0 only if there is no land at all).</summary>
|
||||||
|
public int MainlandId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⚠ Whether the map-centre cell was land. Expected always true (the massif is centred and
|
||||||
|
/// stable). When false the mainland fell back to the LARGEST component and the caller must
|
||||||
|
/// report it loudly — the contract's mainland definition did not hold on this field.
|
||||||
|
/// </summary>
|
||||||
|
public bool CentreWasLand;
|
||||||
|
|
||||||
|
public long LandCells;
|
||||||
|
public int IslandCount => Regions.Count - (MainlandId > 0 ? 1 : 0);
|
||||||
|
|
||||||
|
public LandRegion Mainland => MainlandId > 0 ? Regions[MainlandId - 1] : null;
|
||||||
|
public LandRegion Of(int id) => Regions[id - 1];
|
||||||
|
public int IdAt(int x, int y) => Id[x * MapSize + y];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ THE REGION-LABELING LAYER — shared infrastructure (chat2/07). Flood-fills land into distinct
|
||||||
|
/// components, identifies mainland vs islands, and exposes per-component data. Islands are its first
|
||||||
|
/// consumer; later phases (biomes, placement, rivers, the crater) CONSUME this layer rather than
|
||||||
|
/// rebuild it. Engine-free; pure analysis over a <c>float[,]</c>; C++-candidate.
|
||||||
|
///
|
||||||
|
/// ═══ THE CONTRACT — build to it exactly (recorded at graduation as the shared-infra contract) ═══
|
||||||
|
///
|
||||||
|
/// FIELD It runs on the CLASSIFY (raw, uncurved) height — region identity partitions on the
|
||||||
|
/// same authoritative field as water bodies and biome regions (D-046), so islands /
|
||||||
|
/// water / biomes line up by construction. Raw is authoritative for region identity.
|
||||||
|
/// It does NOT run on the render field.
|
||||||
|
///
|
||||||
|
/// CONNECTIVITY Land is 8-CONNECTED. Deliberately the complement of water's 4-connectivity —
|
||||||
|
/// foreground/background using opposite connectivity is the topologically sound
|
||||||
|
/// pairing (a diagonal isthmus reads as JOINED; the water on either side of it reads
|
||||||
|
/// as SEPARATE), not a conflict with the water model.
|
||||||
|
///
|
||||||
|
/// COMPONENT A component = a maximal 8-connected set of land cells (land = classify height ≥ sea).
|
||||||
|
///
|
||||||
|
/// MAINLAND The component containing the MAP CENTRE (the mountain/massif is always centred and
|
||||||
|
/// stable) — NOT merely the largest component, because a later fragmentation step
|
||||||
|
/// could make "largest" flip seed to seed. Every OTHER land component is an island.
|
||||||
|
/// ⚠ The crater is NOT central — it is a northern-coastline feature, unrelated to the
|
||||||
|
/// centre or the mountain, and plays no part here.
|
||||||
|
/// Defensively: if the centre cell is not land, the layer reports it (<see
|
||||||
|
/// cref="RegionLabels.CentreWasLand"/> = false) and falls back to the largest
|
||||||
|
/// component, FLAGGED — the caller asserts rather than assumes.
|
||||||
|
///
|
||||||
|
/// PER COMPONENT id · sizeCells · centroid (x, y) · hemisphere (north / south, BY THE CENTROID —
|
||||||
|
/// one label per island; a straddler is decided by its centroid, never per cell) ·
|
||||||
|
/// isMainland.
|
||||||
|
///
|
||||||
|
/// Ids are assigned in deterministic scan order (x outer, y inner, first-seen), so they are stable
|
||||||
|
/// per seed across runs. Nothing here knows about "offshore" or "stamped" — it labels land.
|
||||||
|
///
|
||||||
|
/// ═══ THE HEMISPHERE CONVENTION — read from the code, not invented (chat2/05) ═══
|
||||||
|
///
|
||||||
|
/// Pass 1's latitude scalar is <c>y / MapSize</c>; the spine's "southern fade" and the "southern
|
||||||
|
/// sinker" bite at high y. So y increases SOUTHWARD: NORTH = rows [0, MapSize/2), SOUTH = rows
|
||||||
|
/// [MapSize/2, MapSize). The clean row midline, never the wobbled latitude field.
|
||||||
|
/// </summary>
|
||||||
|
public static class RegionLabeling
|
||||||
|
{
|
||||||
|
public const byte HemiNone = 0;
|
||||||
|
public const byte HemiNorth = 1;
|
||||||
|
public const byte HemiSouth = 2;
|
||||||
|
|
||||||
|
/// <summary>The convention, in one place. Every consumer reads hemisphere through this.</summary>
|
||||||
|
public static byte HemisphereOfRow(int y, int mapSize) => y < mapSize / 2 ? HemiNorth : HemiSouth;
|
||||||
|
|
||||||
|
public static string HemisphereName(byte h) => h switch
|
||||||
|
{
|
||||||
|
HemiNorth => "north", HemiSouth => "south", _ => "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 8-connectivity, fixed order (determinism: the fill order never changes).
|
||||||
|
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
|
||||||
|
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Label every 8-connected land component of <paramref name="classify"/> (land = height ≥
|
||||||
|
/// <paramref name="sea"/>). Pure: the field is read, never written.
|
||||||
|
/// </summary>
|
||||||
|
public static RegionLabels Label(float[,] classify, int mapSize, float sea)
|
||||||
|
{
|
||||||
|
int n = mapSize;
|
||||||
|
var id = new int[n * n];
|
||||||
|
var regions = new List<LandRegion>();
|
||||||
|
var stack = new Stack<int>();
|
||||||
|
long landCells = 0;
|
||||||
|
|
||||||
|
for (int sx = 0; sx < n; sx++)
|
||||||
|
{
|
||||||
|
for (int sy = 0; sy < n; sy++)
|
||||||
|
{
|
||||||
|
if (classify[sx, sy] < sea || id[sx * n + sy] != 0) continue;
|
||||||
|
|
||||||
|
var r = new LandRegion { Id = regions.Count + 1, MinX = sx, MaxX = sx, MinY = sy, MaxY = sy };
|
||||||
|
double sumX = 0, sumY = 0;
|
||||||
|
id[sx * n + sy] = r.Id;
|
||||||
|
stack.Push(sx * n + sy);
|
||||||
|
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
int cur = stack.Pop();
|
||||||
|
int cx = cur / n, cy = cur % n;
|
||||||
|
r.SizeCells++; sumX += cx; sumY += cy;
|
||||||
|
if (cx < r.MinX) r.MinX = cx; if (cx > r.MaxX) r.MaxX = cx;
|
||||||
|
if (cy < r.MinY) r.MinY = cy; if (cy > r.MaxY) r.MaxY = cy;
|
||||||
|
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (id[ni] != 0 || classify[nx, ny] < sea) continue;
|
||||||
|
id[ni] = r.Id;
|
||||||
|
stack.Push(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.CentroidX = sumX / r.SizeCells;
|
||||||
|
r.CentroidY = sumY / r.SizeCells;
|
||||||
|
r.Hemisphere = HemisphereOfRow((int)Math.Round(r.CentroidY), n);
|
||||||
|
landCells += r.SizeCells;
|
||||||
|
regions.Add(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var labels = new RegionLabels { MapSize = n, Id = id, Regions = regions, LandCells = landCells };
|
||||||
|
|
||||||
|
// ⭐ MAINLAND = the component containing the map centre. Asserted by the caller; the
|
||||||
|
// fallback (largest) exists so a run can finish and REPORT the violation rather than crash.
|
||||||
|
int centre = (n / 2) * n + (n / 2);
|
||||||
|
labels.CentreWasLand = id[centre] != 0;
|
||||||
|
if (labels.CentreWasLand) labels.MainlandId = id[centre];
|
||||||
|
else
|
||||||
|
{
|
||||||
|
long best = -1;
|
||||||
|
foreach (var r in regions) if (r.SizeCells > best) { best = r.SizeCells; labels.MainlandId = r.Id; }
|
||||||
|
}
|
||||||
|
if (labels.MainlandId > 0) regions[labels.MainlandId - 1].IsMainland = true;
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Size statistics over the islands (non-mainland components): count, min / median / mean /
|
||||||
|
/// max cells, and a log-spaced histogram — the instrument that turns "nice pieces vs shattered
|
||||||
|
/// gravel" into numbers.
|
||||||
|
/// </summary>
|
||||||
|
public static (int count, long min, long median, double mean, long max, int[] histogram)
|
||||||
|
IslandSizes(RegionLabels labels)
|
||||||
|
{
|
||||||
|
var sizes = new List<long>();
|
||||||
|
foreach (var r in labels.Regions) if (!r.IsMainland) sizes.Add(r.SizeCells);
|
||||||
|
var hist = new int[HistogramEdges.Length + 1];
|
||||||
|
if (sizes.Count == 0) return (0, 0, 0, 0.0, 0, hist);
|
||||||
|
sizes.Sort();
|
||||||
|
double sum = 0;
|
||||||
|
foreach (long s in sizes) { sum += s; hist[HistogramBin(s)]++; }
|
||||||
|
return (sizes.Count, sizes[0], sizes[sizes.Count / 2], sum / sizes.Count, sizes[sizes.Count - 1], hist);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Histogram bin edges (cells): [0,64) [64,256) [256,1024) [1024,4096) [4096,16384) [16384,∞).</summary>
|
||||||
|
public static readonly long[] HistogramEdges = { 64, 256, 1024, 4096, 16384 };
|
||||||
|
|
||||||
|
public static int HistogramBin(long cells)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < HistogramEdges.Length; i++) if (cells < HistogramEdges[i]) return i;
|
||||||
|
return HistogramEdges.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string HistogramLabel(int bin) => bin == 0 ? $"<{HistogramEdges[0]}"
|
||||||
|
: bin < HistogramEdges.Length ? $"{HistogramEdges[bin - 1]}–{HistogramEdges[bin] - 1}"
|
||||||
|
: $"≥{HistogramEdges[^1]}";
|
||||||
|
|
||||||
|
// ═══ chat2/12 — THE OCEAN IDENTITY (the water-side complement of the land contract) ═══
|
||||||
|
//
|
||||||
|
// Water is 4-CONNECTED (the deliberate complement of land's 8 — a diagonal isthmus joins land and
|
||||||
|
// separates the water either side). THE OCEAN = the 4-connected water component that touches the
|
||||||
|
// map border (the Trench guarantees the border is water, so the corner is a safe seed). Every
|
||||||
|
// other below-sea cell — enclosed lagoons, lake beds, island-fringe pockets — is NOT ocean: to the
|
||||||
|
// drainage router it is ordinary terrain (a terminal basin or a fill-and-spill), and to a
|
||||||
|
// "sea-reaching" test it does not count as the sea. Computed on the CLASSIFY field (authoritative).
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ocean mask, row-major (<c>x·n+y</c>): true for every below-sea cell 4-connected to the map
|
||||||
|
/// border. Pure: reads <paramref name="classify"/>, writes nothing.
|
||||||
|
/// </summary>
|
||||||
|
public static bool[] OceanMask(float[,] classify, int mapSize, float sea, out long oceanCells, out long enclosedWaterCells)
|
||||||
|
{
|
||||||
|
int n = mapSize;
|
||||||
|
var ocean = new bool[n * n];
|
||||||
|
var q = new Queue<int>();
|
||||||
|
void Seed(int x, int y) { if (classify[x, y] < sea && !ocean[x * n + y]) { ocean[x * n + y] = true; q.Enqueue(x * n + y); } }
|
||||||
|
for (int x = 0; x < n; x++) { Seed(x, 0); Seed(x, n - 1); }
|
||||||
|
for (int y = 0; y < n; y++) { Seed(0, y); Seed(n - 1, y); }
|
||||||
|
int[] dx4 = { -1, 1, 0, 0 }, dy4 = { 0, 0, -1, 1 };
|
||||||
|
while (q.Count > 0)
|
||||||
|
{
|
||||||
|
int c = q.Dequeue(); int cx = c / n, cy = c % n;
|
||||||
|
for (int k = 0; k < 4; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + dx4[k], ny = cy + dy4[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (ocean[ni] || classify[nx, ny] >= sea) continue;
|
||||||
|
ocean[ni] = true; q.Enqueue(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oceanCells = 0; enclosedWaterCells = 0;
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
{
|
||||||
|
if (classify[x, y] >= sea) continue;
|
||||||
|
if (ocean[x * n + y]) oceanCells++; else enclosedWaterCells++;
|
||||||
|
}
|
||||||
|
return ocean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Island counts per hemisphere (non-mainland components, by centroid).</summary>
|
||||||
|
public static (int north, int south) IslandsByHemisphere(RegionLabels labels)
|
||||||
|
{
|
||||||
|
int nN = 0, nS = 0;
|
||||||
|
foreach (var r in labels.Regions)
|
||||||
|
{
|
||||||
|
if (r.IsMainland) continue;
|
||||||
|
if (r.Hemisphere == HemiNorth) nN++; else if (r.Hemisphere == HemiSouth) nS++;
|
||||||
|
}
|
||||||
|
return (nN, nS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Core/Scripts/RegionLabeling.cs.uid
Normal file
1
Core/Scripts/RegionLabeling.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bsb27ghrajnhc
|
||||||
|
|
@ -30,7 +30,25 @@ constants, carried over verbatim — not re-derived from a design summary** (→
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Scripts/TerrainNoise.cs` | ⭐ The FastNoiseLite config, **every fractal property pinned explicitly** |
|
| `Scripts/TerrainNoise.cs` | ⭐ The FastNoiseLite config, **every fractal property pinned explicitly** |
|
||||||
| `Scripts/Topography.cs` | ⭐⭐ Pass 1 — the six elements, in the reference's execution order |
|
| `Scripts/Topography.cs` | ⭐⭐ Pass 1 — the six elements, in the reference's execution order |
|
||||||
| `Scripts/IslandFalloff.cs` | `SmoothAbs` + `CREST_EPSILON` (pass-1 half of the reference file) |
|
| `Scripts/IslandFalloff.cs` | The whole reference file now: `SmoothAbs`, the **coast shelf**, the **offshore islets** + the reshape helpers |
|
||||||
|
| `Scripts/OffshorePass.cs` | ⭐ **Pass 1b** — shelf + the **organic islet layer** (the one island mechanism) + the slop guards, over the finished pass-1 arrays, then `HMaxSeed` is retaken (chat2/05, retuned chat2/06) |
|
||||||
|
| `Scripts/OffshoreSettings.cs` | Every islet dial in one object; `Faithful()` (the reference) and `Organic()` (the reshape, tuned: **density** + **south weight** + guards). ⚠ No floor / stamps / count guarantee — chat2/05's `Hybrid()` was reverted out in chat2/06 (git history has it) |
|
||||||
|
| `Scripts/OffshoreAnalysis.cs` | Island components, N/S counts, the moat check, the separation-guard geometry, **the hemisphere convention** |
|
||||||
|
| `Scripts/OffshoreDiagnosis.cs` | The per-hemisphere **measurement** — valid-zone area, binding gate, noise peaks over threshold (chat2/06 §2) |
|
||||||
|
| `Scripts/TagOverlayRenderer.cs` | The island-tag / hemisphere debug overlay |
|
||||||
|
| `Scripts/RegionPass.cs` | ⭐ **Pass 1c** (chat2/07) — `Core.RegionLabeling` over the classify field, the **origin-blind speck revert** (lower-only + component-only asserted, mainland never), the island tag **by construction** |
|
||||||
|
| `Scripts/RegionOverlayRenderer.cs` | The labeled-regions overlay: mainland one tint, each island its own colour, reverted specks dark red |
|
||||||
|
| `Scripts/RegionLabelingTool.cs` + `Scenes/RegionLabelingTool.tscn` | The chat2/07 batch — 3 revert thresholds + a second seed, the count/size instrument |
|
||||||
|
| `Scripts/SouthernStretch.cs` | ⭐ **The southern stretch** (chat2/08, exploration) — the one deliberate sea-identity relaxation, inside a fixed feathered latitude band: `y' = yB + (y − yB)/(1 + stretch·ramp)` for the mask geometry (and the sinker), texture untouched; north of the band bit-locked by construction |
|
||||||
|
| `Scripts/SouthernStretchTool.cs` + `Scenes/SouthernStretchTool.tscn` | The chat2/08 batch — the diagnostic (`ISLA_DIAG_ONLY`) and the 5-level × 2-seed fragmentation ladder with the hemisphere-split instrument |
|
||||||
|
| `Scripts/CoastalFragment.cs` | ⭐ **Coastal fragmentation** (chat2/09, exploration) — a perimeter-wide, band-limited, zero-mean noise on the pre-power falloff inside the coastal window (≈ 0.66 ± 0.18); thin necks flip first; interior bit-identical by construction |
|
||||||
|
| `Scripts/CoastalFragmentTool.cs` + `Scenes/CoastalFragmentTool.tscn` | The chat2/09 batch — the frequency × amplitude probe (`ISLA_PROBE`) and the 4-amplitude × 2-seed ladder at a fixed stretch |
|
||||||
|
| `Scripts/FragGalleryTool.cs` + `Scenes/FragGalleryTool.tscn` | The chat2/10 gallery — render-only: 09's `frag_4` frozen across 2 anchors + 6 fresh seeds at 8192, with the count/size table |
|
||||||
|
| `Scripts/ErosionPass.cs` | ⭐ **Pass 2b** (chat2/11) — the erosion caller: render field only (copied if aliased), governors clamped as the reference's ConfigManager did, the crater exclusion passed through INERT, and the **flood guard** (render water pixels before/after; any change throws) |
|
||||||
|
| `Scripts/ErosionTool.cs` + `Scenes/ErosionTool.tscn` | The chat2/11 batch — 4 gallery seeds × erosion off/on at 8192, the mid-slope crop, the erosion stats table; also `TerrainShapeV1` — the locked shape's values pinned once |
|
||||||
|
| `Scripts/DrainageRenderer.cs` | The drainage maps (chat2/12): log-scaled accumulation; the promoted-candidates overlay (trunks cyan, endorheic giants orange, lean terminals red) |
|
||||||
|
| `Scripts/DrainageTool.cs` + `Scenes/DrainageTool.tscn` | The chat2/12 batch — `DrainageAnalysis` on the eroded 8192 fields of 4 task-11 seeds, analysis-only oracle (terrain bit-identical, no water, fill invariants, determinism, ocean from the region layer) |
|
||||||
|
| `Scripts/OffshoreIslandsTool.cs` + `Scenes/OffshoreIslandsTool.tscn` | The offshore batch — chat2/06: 4 plates + the count table + the diagnosis (the chat2/05 version is at `3b96e06`) |
|
||||||
| `Scripts/Pass1Result.cs` | The height field **and the Phase-2 seams** |
|
| `Scripts/Pass1Result.cs` | The height field **and the Phase-2 seams** |
|
||||||
| `Scripts/TerrainGenConfig.cs` | Config + the per-element ablation toggles |
|
| `Scripts/TerrainGenConfig.cs` | Config + the per-element ablation toggles |
|
||||||
| `Scripts/HeightField.cs` | Raw `.f32` save/load — **the generation/presentation seam** |
|
| `Scripts/HeightField.cs` | Raw `.f32` save/load — **the generation/presentation seam** |
|
||||||
|
|
@ -197,17 +215,48 @@ Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
|
||||||
> reads**, and nothing else. Climate is **stage 3** and classifies finished shape (D-049 §2, D-056).
|
> reads**, and nothing else. Climate is **stage 3** and classifies finished shape (D-049 §2, D-056).
|
||||||
> Do not alias, store, or rename this into a climate map.
|
> Do not alias, store, or rename this into a climate map.
|
||||||
|
|
||||||
**Deferred, with the seam already open:** the submarine **coast shelf** and the **offshore islets**
|
**Pass 1b — the coast shelf and the offshore islets (chat2/05).** The reference continued its pass-1
|
||||||
(reference ~:621-664). Both act only below sea level and are judged once water renders.
|
loop with the submarine **coast shelf** and the **offshore islet** layer (~:621-664); v2 runs them as a
|
||||||
`Pass1Result.PreTrenchFalloff` is captured at the exact point they consume, and
|
second sweep over the finished arrays — `OffshorePass` — with the same per-pixel arithmetic in the same
|
||||||
`Pass1Result.HMaxSeed` is carried for the seed-dependent redistribution curve.
|
order, then **retakes `HMaxSeed` after them**, as the reference did (chat2/00 Drift §2, closed). The
|
||||||
|
islets exist in two presets: `OffshoreSettings.Faithful()` (the reference, verbatim — the control) and
|
||||||
|
`OffshoreSettings.Organic()` (the reshape: small / low / flat / crisp, corners allowed — **the organic
|
||||||
|
noise-field layer is the only island mechanism**, tuned for coverage by **density** and a **south
|
||||||
|
weight**, with speck / separation / blob guards; chat2/06). ⚠ **No forced count.** chat2/05's seeded
|
||||||
|
floor (`Hybrid()`: stamps guaranteeing ≥2 N / ≥4 S) was tried and **reverted out on look** in chat2/06;
|
||||||
|
the per-hemisphere counts are a statistical outcome of the tuning, read off the batch's count table.
|
||||||
|
|
||||||
> ⚠⚠ **When the coast shelf and islets land, `HMaxSeed` must move with them.** The reference takes
|
> ### ⚠ Both default OFF — deliberately, and that is a decision to revisit.
|
||||||
> its map-wide max *after* both layers have already modified the height, inside the same pass-1 loop
|
>
|
||||||
> (`MapGenerator.cs:665`). v2 currently takes it before, because the layers do not exist. Since
|
> Every oracle in this phase holds pass 1 against Phase 1's `.f32` dumps. The shelf changes every
|
||||||
> `HMaxSeed` normalizes the curve's summit spike, porting those layers without moving the max
|
> below-sea cell and the islets ADD LAND, so defaulting either ON stales every regression anchor at
|
||||||
> computation below them changes the world for a given seed — silently, with no throw and no failed
|
> once. Batch tools turn them on explicitly. **Flipping the defaults is the act that retires the
|
||||||
> assertion. → chat2/00 report, Drift §2.
|
> Phase-1 dumps — do it in a task that re-baselines the oracles.** → `TerrainGenConfig`.
|
||||||
|
|
||||||
|
**Pass 1c — region labeling + the speck revert + the island tag (chat2/07).** `RegionPass` runs
|
||||||
|
`Core.RegionLabeling` over the finished classify field — **land 8-connected, mainland = the component
|
||||||
|
containing the map centre, every other component an island** — then the config-gated **speck revert**
|
||||||
|
(`TerrainGenConfig.SpeckRevert` / `MinLandComponentFrac`): every non-mainland component below the
|
||||||
|
threshold is lowered to its ring's seabed. Origin-blind (a natural nub goes like an offshore dot),
|
||||||
|
**lower-only and component-only, asserted**, mainland never a candidate. Then the island tag, by
|
||||||
|
construction. ⚠ `SpeckRevert` defaults OFF in the bare config for the same reason as the shelf/islets
|
||||||
|
(it would move the calibration pool and every regression dump); the batch turns it on.
|
||||||
|
|
||||||
|
> ### ⭐ The island tag — data, set BY THE REGION LAYER, read by nothing yet.
|
||||||
|
>
|
||||||
|
> `Pass1Result.IsIsland` / `IslandHemisphere` (carried through `Pass2Result`) mark every cell of every
|
||||||
|
> non-mainland land component — natural detached masses and offshore-pass islands alike — with the
|
||||||
|
> COMPONENT's hemisphere (by centroid). **NORTH = rows `[0, MapSize/2)`, SOUTH = `[MapSize/2,
|
||||||
|
> MapSize)`** — y runs south. `Pass1Result.Regions` carries the full per-component table. A biome /
|
||||||
|
> fertility / placement pass reads these and never re-derives island-land from geometry. Null when
|
||||||
|
> region labeling is off. (chat2/05–06 tagged only what the offshore pass raised — fixed in 07.)
|
||||||
|
|
||||||
|
> ### ⚠ The one honest coupling: islets TURN WATER INTO LAND.
|
||||||
|
>
|
||||||
|
> Unlike the curve, which moves heights but not the waterline, the islet layer adds above-sea cells —
|
||||||
|
> new classification downstream. That is why it runs in the base shape before anything classifies:
|
||||||
|
> change an island dial, regenerate, and classification re-runs consistently. A known property, not a
|
||||||
|
> surprise.
|
||||||
|
|
||||||
**Not here at all:** erosion, rivers, water bodies, the crater carve, biomes, roads, the mesher.
|
**Not here at all:** erosion, rivers, water bodies, the crater carve, biomes, roads, the mesher.
|
||||||
|
|
||||||
|
|
|
||||||
6
Tools/Scenes/CoastalFragmentTool.tscn
Normal file
6
Tools/Scenes/CoastalFragmentTool.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3 uid="uid://cfragment09isla"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Tools/Scripts/CoastalFragmentTool.cs" id="1_cft"]
|
||||||
|
|
||||||
|
[node name="CoastalFragmentTool" type="Node"]
|
||||||
|
script = ExtResource("1_cft")
|
||||||
6
Tools/Scenes/DrainageTool.tscn
Normal file
6
Tools/Scenes/DrainageTool.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3 uid="uid://cdrainage12isla"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Tools/Scripts/DrainageTool.cs" id="1_drt"]
|
||||||
|
|
||||||
|
[node name="DrainageTool" type="Node"]
|
||||||
|
script = ExtResource("1_drt")
|
||||||
6
Tools/Scenes/ErosionTool.tscn
Normal file
6
Tools/Scenes/ErosionTool.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3 uid="uid://cerosion11isla"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Tools/Scripts/ErosionTool.cs" id="1_ert"]
|
||||||
|
|
||||||
|
[node name="ErosionTool" type="Node"]
|
||||||
|
script = ExtResource("1_ert")
|
||||||
6
Tools/Scenes/FragGalleryTool.tscn
Normal file
6
Tools/Scenes/FragGalleryTool.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3 uid="uid://cfraggal10isla"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Tools/Scripts/FragGalleryTool.cs" id="1_fgt"]
|
||||||
|
|
||||||
|
[node name="FragGalleryTool" type="Node"]
|
||||||
|
script = ExtResource("1_fgt")
|
||||||
6
Tools/Scenes/MountainRestoreTool.tscn
Normal file
6
Tools/Scenes/MountainRestoreTool.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3 uid="uid://cmtnrestore03isla"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Tools/Scripts/MountainRestoreTool.cs" id="1_mrt"]
|
||||||
|
|
||||||
|
[node name="MountainRestoreTool" type="Node"]
|
||||||
|
script = ExtResource("1_mrt")
|
||||||
6
Tools/Scenes/OffshoreIslandsTool.tscn
Normal file
6
Tools/Scenes/OffshoreIslandsTool.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3 uid="uid://coffshore05isla"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Tools/Scripts/OffshoreIslandsTool.cs" id="1_oit"]
|
||||||
|
|
||||||
|
[node name="OffshoreIslandsTool" type="Node"]
|
||||||
|
script = ExtResource("1_oit")
|
||||||
6
Tools/Scenes/RegionLabelingTool.tscn
Normal file
6
Tools/Scenes/RegionLabelingTool.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3 uid="uid://cregions07isla"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Tools/Scripts/RegionLabelingTool.cs" id="1_rlt"]
|
||||||
|
|
||||||
|
[node name="RegionLabelingTool" type="Node"]
|
||||||
|
script = ExtResource("1_rlt")
|
||||||
6
Tools/Scenes/SeedGalleryTool.tscn
Normal file
6
Tools/Scenes/SeedGalleryTool.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3 uid="uid://cseedgallery04isla"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Tools/Scripts/SeedGalleryTool.cs" id="1_sgt"]
|
||||||
|
|
||||||
|
[node name="SeedGalleryTool" type="Node"]
|
||||||
|
script = ExtResource("1_sgt")
|
||||||
6
Tools/Scenes/SouthernStretchTool.tscn
Normal file
6
Tools/Scenes/SouthernStretchTool.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3 uid="uid://cstretch08isla"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Tools/Scripts/SouthernStretchTool.cs" id="1_sst"]
|
||||||
|
|
||||||
|
[node name="SouthernStretchTool" type="Node"]
|
||||||
|
script = ExtResource("1_sst")
|
||||||
60
Tools/Scripts/CoastalFragment.cs
Normal file
60
Tools/Scripts/CoastalFragment.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ COASTAL FRAGMENTATION (chat2/09, EXPLORATION) — a perimeter-wide, band-limited, zero-mean noise
|
||||||
|
/// on the PRE-power falloff, applied only inside the coastal window.
|
||||||
|
///
|
||||||
|
/// ═══ WHY A DEDICATED TERM AND NOT THE EDGE NOISE SCALED ═══
|
||||||
|
///
|
||||||
|
/// Pass 1's edge noise is <c>(noise+1)/2 · 0.15 · squircle</c>: positive-only (it only ever PUSHES
|
||||||
|
/// the falloff up), modulated by the squircle (strongest at the rim, zero at the centre), sampled at
|
||||||
|
/// 2.5× the base frequency. Scaling it would roughen the whole rim and bias the coast inward; it is
|
||||||
|
/// the coastline's jitter, not a margin-targeted cutter. This term is separate: its own deterministic
|
||||||
|
/// field (seed offset + a coordinate offset in map widths), its own frequency (the neck/lobe scale),
|
||||||
|
/// zero-mean (it bites AND builds, so the coast is redrawn rather than eroded), and weighted by a
|
||||||
|
/// smooth WINDOW on the falloff value itself:
|
||||||
|
///
|
||||||
|
/// w(f) = 1 − smoothstep(|f − centre| / halfWidth) (exactly 0 beyond ± halfWidth)
|
||||||
|
/// falloff += amp · w(f) · noise(x + off, y + off) noise ∈ [−1, 1]
|
||||||
|
///
|
||||||
|
/// The coast sits where rawBase − falloff^2.5 crosses sea, i.e. near f ≈ 0.66 for a median rawBase
|
||||||
|
/// (chat2/08 diagnosis), so a window centred there covers the barely-land / barely-sea margin
|
||||||
|
/// around the whole perimeter, north and south. Cells whose falloff is clear of the window — the
|
||||||
|
/// interior, the massif, the deep sea — get w = 0 and are bit-identical by construction (asserted).
|
||||||
|
///
|
||||||
|
/// SELF-TARGETING: every lobe hangs off a neck of barely-land, cells whose height sits a hair above
|
||||||
|
/// sea. A bite of the same Δfalloff flips those first; solid land inside the window (a coastal hill)
|
||||||
|
/// moves in height but does not flip. No neck is detected; the margin selects itself. Amplitude is
|
||||||
|
/// the ladder; frequency is fixed and exposed as the secondary dial.
|
||||||
|
/// </summary>
|
||||||
|
public static class CoastalFragment
|
||||||
|
{
|
||||||
|
/// <summary>Periods per map width. The base noise is ≈ 4/map, the edge noise ≈ 10/map; lobes in the 08 plates are 2–7 % of the map. Chosen by the chat2/09 probe.</summary>
|
||||||
|
public const float DefaultFreqPerMapWidth = 12f;
|
||||||
|
|
||||||
|
/// <summary>The coastal window's centre in pre-power falloff units (the coast's falloff for a median base noise).</summary>
|
||||||
|
public const float DefaultBandCentre = 0.66f;
|
||||||
|
|
||||||
|
/// <summary>Half-width of the window. 0.18 spans f ∈ [0.48, 0.84] — the whole fringe, nothing of the interior.</summary>
|
||||||
|
public const float DefaultBandHalfWidth = 0.18f;
|
||||||
|
|
||||||
|
/// <summary>Bites only by default? Set by the chat2/09 probe (see the report): zero-mean redraws the margin and can bridge islands back; bites-only only cuts.</summary>
|
||||||
|
public const bool DefaultBitesOnly = false;
|
||||||
|
|
||||||
|
/// <summary>The field's seed offset (a SEED offset, like the islet layer's 7607).</summary>
|
||||||
|
public const int SeedOffset = 9109;
|
||||||
|
|
||||||
|
/// <summary>The field's coordinate offset, IN MAP WIDTHS (D-059) — decorrelates it from the base field's realization at every size.</summary>
|
||||||
|
public const float OffsetMapWidths = 0.37f;
|
||||||
|
|
||||||
|
/// <summary>The window weight for a pre-power falloff value.</summary>
|
||||||
|
public static float Window(float falloff, float centre, float halfWidth)
|
||||||
|
{
|
||||||
|
float t = MathF.Abs(falloff - centre) / halfWidth;
|
||||||
|
if (t >= 1f) return 0f;
|
||||||
|
return 1f - t * t * (3f - 2f * t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/CoastalFragment.cs.uid
Normal file
1
Tools/Scripts/CoastalFragment.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b4skxmovd0xpn
|
||||||
517
Tools/Scripts/CoastalFragmentTool.cs
Normal file
517
Tools/Scripts/CoastalFragmentTool.cs
Normal file
|
|
@ -0,0 +1,517 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE COASTAL-FRAGMENTATION BATCH (chat2/09, exploration) — at a FIXED stretch, a perimeter-wide
|
||||||
|
/// fragmentation-noise amplitude ladder: light → heavy, 4 levels × 2 seeds, options to pick from.
|
||||||
|
///
|
||||||
|
/// ═══ TWO MODES ═══
|
||||||
|
///
|
||||||
|
/// ISLA_PROBE=1 numbers only at ISLA_CALIB_SIZE: a frequency × amplitude sweep on both seeds
|
||||||
|
/// (island counts and sizes per hemisphere, mainland size) — used to fix the
|
||||||
|
/// frequency and place the amplitude ladder. Written to scratch/frag_probe.md.
|
||||||
|
/// (default) the BATCH: 4 amplitudes × 2 seeds at ISLA_MAPSIZE, lean render per field
|
||||||
|
/// (labeled-regions overlay + relief + .f32), the hemisphere-split count/size table,
|
||||||
|
/// the asymmetric oracle (interior locked, coast free).
|
||||||
|
///
|
||||||
|
/// Every field: pass 1 + stretch (FIXED) + fragmentation (the axis), region labeling ON, speck revert
|
||||||
|
/// at a LOW threshold (true 1–3-cell noise only), offshore OFF, shelf OFF. The curve is the tagged
|
||||||
|
/// curve, unchanged.
|
||||||
|
///
|
||||||
|
/// ═══ RUNNING IT ═══
|
||||||
|
///
|
||||||
|
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
|
||||||
|
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/CoastalFragmentTool.tscn
|
||||||
|
///
|
||||||
|
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
|
||||||
|
/// ISLA_MAPSIZE / ISLA_CALIB_SIZE (default 4096 / 2048)
|
||||||
|
/// ISLA_SEEDS the two seeds (default 1063685222, 999999937 — task 08's)
|
||||||
|
/// ISLA_STRETCH the fixed stretch (default 2)
|
||||||
|
/// ISLA_FRAG_LEVELS the 4 amplitudes (default: the probe-chosen ladder)
|
||||||
|
/// ISLA_FRAG_FREQ the fixed frequency, periods per map width
|
||||||
|
/// ISLA_SPECK_FRAC the speck-revert threshold, fraction of map area (default 2.5e-7 ≈ 4 cells at 4096)
|
||||||
|
/// ISLA_PROBE=1 · ISLA_PROBE_FREQS · ISLA_PROBE_AMPS the probe sweep
|
||||||
|
/// ISLA_FRAG_BITES=1 bites-only noise ([0,1]) instead of zero-mean ([-1,1])
|
||||||
|
/// ISLA_SKIP_8K=1 (no 8192 check this batch — the 08 dump at 4096 is the baseline)
|
||||||
|
/// </summary>
|
||||||
|
public partial class CoastalFragmentTool : Node
|
||||||
|
{
|
||||||
|
private static readonly int[] DefaultSeeds = { 1063685222, 999999937 };
|
||||||
|
|
||||||
|
/// <summary>⚠ Task 01's pool, verbatim — the curve's identity.</summary>
|
||||||
|
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
|
||||||
|
|
||||||
|
/// <summary>⭐ THE LADDER — set from the probe (chat2/09 report §1): light → heavy, amplitude the only axis.</summary>
|
||||||
|
private static readonly float[] DefaultLadder = { 0.06f, 0.15f, 0.30f, 0.50f };
|
||||||
|
|
||||||
|
private static readonly float[] ProbeFreqs = { 12f, 20f, 32f };
|
||||||
|
private static readonly float[] ProbeAmps = { 0.03f, 0.06f, 0.12f, 0.20f, 0.32f, 0.5f };
|
||||||
|
|
||||||
|
private const float DefaultStretch = 2f;
|
||||||
|
private const float DefaultSpeckFrac = 2.5e-7f; // ≈ 4 cells at 4096 — true noise only
|
||||||
|
private const int DefaultMapSize = 4096;
|
||||||
|
private const int DefaultCalibSize = 2048;
|
||||||
|
|
||||||
|
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 HemiStats
|
||||||
|
{
|
||||||
|
public int All, Big; public long Min, Med, Max; public double Mean; public int[] Hist; public long[] Largest = Array.Empty<long>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Row
|
||||||
|
{
|
||||||
|
public int Level; public float Amp; public int Seed;
|
||||||
|
public HemiStats N, S; public long MainlandCells; public int SpecksReverted;
|
||||||
|
public bool Ok; public ulong Ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Run()
|
||||||
|
{
|
||||||
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
|
||||||
|
int task = EnvInt("ISLA_TASK", 9);
|
||||||
|
string descr = EnvStr("ISLA_BATCH", "coastal_fragment");
|
||||||
|
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
|
||||||
|
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
|
||||||
|
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
||||||
|
float stretch = EnvFloat("ISLA_STRETCH", DefaultStretch);
|
||||||
|
float[] ladder = EnvFloats("ISLA_FRAG_LEVELS", DefaultLadder);
|
||||||
|
float freq = EnvFloat("ISLA_FRAG_FREQ", CoastalFragment.DefaultFreqPerMapWidth);
|
||||||
|
float speckFrac = EnvFloat("ISLA_SPECK_FRAC", DefaultSpeckFrac);
|
||||||
|
bool probe = EnvStr("ISLA_PROBE", "0") == "1";
|
||||||
|
bool bitesOnly = EnvStr("ISLA_FRAG_BITES", CoastalFragment.DefaultBitesOnly ? "1" : "0") == "1";
|
||||||
|
float[] probeFreqs = EnvFloats("ISLA_PROBE_FREQS", ProbeFreqs);
|
||||||
|
float[] probeAmps = EnvFloats("ISLA_PROBE_AMPS", ProbeAmps);
|
||||||
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
|
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
|
||||||
|
string t08Source = EnvStr("ISLA_T08_SOURCE", "08_southern_stretch_explore");
|
||||||
|
string t08Level = EnvStr("ISLA_T08_LEVEL", "stretch_3"); // the 08 rung with stretch 2
|
||||||
|
|
||||||
|
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||||||
|
string scratch = ToolingPaths.BatchScratch(batchRoot);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(scratch);
|
||||||
|
|
||||||
|
var anchors = CurveAnchors.Default;
|
||||||
|
float sea = 0.15f;
|
||||||
|
long big = Cells(RegionPass.ThresholdMidFrac, mapSize);
|
||||||
|
long speckCells = Cells(speckFrac, mapSize);
|
||||||
|
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print(" COASTAL FRAGMENTATION (chat2/09) — break more pieces off the edges, N + S");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print($"MapSize : {mapSize} (plates) calibration / probe at {calibSize}");
|
||||||
|
GD.Print($"seeds : {string.Join(", ", seeds)}");
|
||||||
|
GD.Print($"fixed : stretch {stretch:G3} (band {SouthernStretch.DefaultBandStartFrac:F2}/{SouthernStretch.DefaultBandFeatherFrac:F2}, sinker stretched) · frag freq {freq:G3}/map · window {CoastalFragment.DefaultBandCentre:F2} ± {CoastalFragment.DefaultBandHalfWidth:F2} · speck revert < {speckCells} cells ({speckFrac:G2})");
|
||||||
|
GD.Print($"ladder : FragmentAmp {string.Join(", ", ladder)} noise {(bitesOnly ? "BITES ONLY [0,1]" : "zero-mean [-1,1]")} (\"big\" island = ≥ {big:N0} cells at {mapSize})");
|
||||||
|
GD.Print($"batch : {batchRoot}{(probe ? " ⚠ ISLA_PROBE — numbers only" : "")}");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
|
||||||
|
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
|
||||||
|
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
|
||||||
|
GD.Print($" {knots}");
|
||||||
|
float highRaw = knots.K2; // the top of the preserved lowland (30 m output) — "above the toe+red band"
|
||||||
|
|
||||||
|
TerrainGenConfig Cfg(int size, int seed, string label, float amp, float fq, float st, bool revert)
|
||||||
|
{
|
||||||
|
return new TerrainGenConfig
|
||||||
|
{
|
||||||
|
MapSize = size, Seed = seed, VariantLabel = label,
|
||||||
|
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||||||
|
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
||||||
|
CoastShelf = false, Offshore = new OffshoreSettings(),
|
||||||
|
RegionLabeling = true, SpeckRevert = revert, MinLandComponentFrac = speckFrac,
|
||||||
|
SouthStretch = st,
|
||||||
|
FragmentAmp = amp, FragmentFreqPerMapWidth = fq, FragmentBitesOnly = bitesOnly,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ PROBE ═══
|
||||||
|
if (probe)
|
||||||
|
{
|
||||||
|
GD.Print($"\n--- PROBE at {calibSize}: frequency × amplitude, stretch {stretch:G3} ---");
|
||||||
|
long bigC = Cells(RegionPass.ThresholdMidFrac, calibSize);
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"# chat2/09 probe — fragmentation frequency × amplitude at {calibSize}, stretch {stretch:G3}, noise {(bitesOnly ? "bites only" : "zero-mean")}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"\"big\" = ≥ {bigC} cells at {calibSize}. Speck revert < {Cells(speckFrac, calibSize)} cells. Offshore off.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| seed | freq | amp | N islands all / big | N size med / max | S islands all / big | S size med / max | mainland cells | mainland Δ vs amp 0 |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (int seed in seeds)
|
||||||
|
{
|
||||||
|
var base0 = Topography.Generate(Cfg(calibSize, seed, "amp0", 0f, freq, stretch, true));
|
||||||
|
long main0 = base0.Regions.Mainland.SizeCells;
|
||||||
|
var (n0, s0) = Stats(base0.Regions, bigC);
|
||||||
|
sb.AppendLine($"| `{seed}` | — | 0 | {n0.All} / {n0.Big} | {n0.Med} / {n0.Max} | {s0.All} / {s0.Big} | {s0.Med} / {s0.Max} | {main0:N0} | 0 |");
|
||||||
|
GD.Print($" seed {seed} amp 0: N {n0.All}/{n0.Big} med {n0.Med} max {n0.Max} S {s0.All}/{s0.Big} med {s0.Med} max {s0.Max} mainland {main0:N0}");
|
||||||
|
foreach (float fq in probeFreqs)
|
||||||
|
foreach (float amp in probeAmps)
|
||||||
|
{
|
||||||
|
var p = Topography.Generate(Cfg(calibSize, seed, "probe", amp, fq, stretch, true));
|
||||||
|
var (n, s) = Stats(p.Regions, bigC);
|
||||||
|
long main = p.Regions.Mainland.SizeCells;
|
||||||
|
sb.AppendLine($"| `{seed}` | {fq:G3} | {amp:G3} | {n.All} / {n.Big} | {n.Med} / {n.Max} | {s.All} / {s.Big} | {s.Med} / {s.Max} | {main:N0} | {main - main0:+#,0;-#,0;0} |");
|
||||||
|
GD.Print($" seed {seed} freq {fq,4:G3} amp {amp,5:G3}: N {n.All,3}/{n.Big,3} med {n.Med,6} max {n.Max,7} S {s.All,3}/{s.Big,3} med {s.Med,6} max {s.Max,7} mainland {main:N0} ({main - main0:+#,0;-#,0;0})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WriteText(Path.Combine(scratch, bitesOnly ? "frag_probe_bites_only.md" : "frag_probe.md"), sb.ToString());
|
||||||
|
GD.Print($"\n probe written: {Path.Combine(scratch, "frag_probe.md")}");
|
||||||
|
GetTree().Quit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 1. REGRESSIONS ═══
|
||||||
|
GD.Print($"\n--- 1. REGRESSIONS ---");
|
||||||
|
var hard = new List<ShapingOracle.Check>();
|
||||||
|
{
|
||||||
|
var offCfg = Cfg(calibSize, seeds[0], "off", 0f, freq, 0f, false);
|
||||||
|
Pass1Result p1 = Topography.Generate(offCfg);
|
||||||
|
var curveOff = offCfg.Clone(); curveOff.Curve = false;
|
||||||
|
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{seeds[0]}_full", "height.f32");
|
||||||
|
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, stretch OFF, frag OFF == Phase-1 .f32 dump (the curve is untouched)", Shaping.Shape(p1, curveOff).Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
|
||||||
|
|
||||||
|
// ⭐ a8 — frag OFF at the fixed stretch, revert OFF == the task-08 stretch-2 field (its dump at the plate size).
|
||||||
|
foreach (int seed in seeds)
|
||||||
|
{
|
||||||
|
string t08Dump = Path.Combine(ToolingPaths.BatchesRoot, t08Source, $"{seed}_{t08Level}", "height.f32");
|
||||||
|
if (File.Exists(t08Dump) && mapSize == 4096)
|
||||||
|
{
|
||||||
|
var c8 = Cfg(mapSize, seed, "t08", 0f, freq, stretch, false);
|
||||||
|
Pass2Result q8 = Shaping.Shape(Topography.Generate(c8), c8);
|
||||||
|
hard.Add(ShapingOracle.DumpRegression("a8", $"frag OFF, stretch {stretch:G3}, revert OFF == task-08 {t08Level} dump (the baseline) [{seed}]", q8.Height, HeightField.Load(t08Dump, mapSize), mapSize, t08Dump));
|
||||||
|
}
|
||||||
|
else GD.Print($" a8 [{seed}]: ⚠ skipped — {(mapSize != 4096 ? "map size is not 4096" : $"no 08 dump at {t08Dump}")}");
|
||||||
|
}
|
||||||
|
foreach (var c in hard) GD.Print(" " + c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 2. THE LADDER — 4 amplitudes × 2 seeds ═══
|
||||||
|
GD.Print($"\n--- 2. THE LADDER at {mapSize} ---");
|
||||||
|
var rows = new List<Row>();
|
||||||
|
var baseline = new Dictionary<int, Row>();
|
||||||
|
var perField = new List<ShapingOracle.Check>();
|
||||||
|
foreach (int seed in seeds)
|
||||||
|
{
|
||||||
|
var c0 = Cfg(mapSize, seed, "frag_0", 0f, freq, stretch, true);
|
||||||
|
Pass1Result p0 = Topography.Generate(c0);
|
||||||
|
baseline[seed] = MakeRow(0, 0f, seed, p0, big, true, p0.ElapsedMs);
|
||||||
|
var b = baseline[seed];
|
||||||
|
GD.Print($" seed {seed} baseline (amp 0, stretch {stretch:G3}): N {b.N.All}/{b.N.Big} S {b.S.All}/{b.S.Big} mainland {b.MainlandCells:N0}");
|
||||||
|
|
||||||
|
for (int li = 0; li < ladder.Length; li++)
|
||||||
|
{
|
||||||
|
float amp = ladder[li];
|
||||||
|
string label = $"frag_{li + 1}";
|
||||||
|
var cfg = Cfg(mapSize, seed, label, amp, freq, stretch, true);
|
||||||
|
Pass1Result p1 = Topography.Generate(cfg);
|
||||||
|
Pass2Result p2 = Shaping.Shape(p1, cfg);
|
||||||
|
var checks = new List<ShapingOracle.Check>
|
||||||
|
{
|
||||||
|
ShapingOracle.InteriorLocked(p0, p1, cfg.FragmentBandCentre, cfg.FragmentBandHalfWidth),
|
||||||
|
ShapingOracle.HighGroundReport(p0, p1, highRaw, $"K2 ({highRaw:F3} raw, the top of the preserved lowland)"),
|
||||||
|
ShapingOracle.CentreIsLand(p1),
|
||||||
|
ShapingOracle.TagCoastlineConsistent(p2, sea),
|
||||||
|
ShapingOracle.ClassifyFidelity(p1, p2),
|
||||||
|
};
|
||||||
|
foreach (var c in checks) { c.Name += $" [{label} = {amp:G3}, {seed}]"; perField.Add(c); }
|
||||||
|
bool ok = checks.TrueForAll(c => c.Passed);
|
||||||
|
var row = MakeRow(li + 1, amp, seed, p1, big, ok, p1.ElapsedMs);
|
||||||
|
rows.Add(row);
|
||||||
|
WriteField(batchRoot, p1, p2, sea, anchors, skipRaw, amp);
|
||||||
|
GD.Print($" {label,-7} amp {amp,5:G3} seed {seed,-11} N {row.N.All,3}/{row.N.Big,3} med {row.N.Med,6} max {row.N.Max,7} S {row.S.All,3}/{row.S.Big,3} med {row.S.Med,6} max {row.S.Max,7} mainland {row.MainlandCells:N0} ({row.MainlandCells - b.MainlandCells:+#,0;-#,0;0}) specks {row.SpecksReverted} {(ok ? "ok" : "⚠ CHECK FAILED")} {p1.ElapsedMs} ms");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// determinism
|
||||||
|
{
|
||||||
|
float amp = ladder[1];
|
||||||
|
var a = Topography.Generate(Cfg(mapSize, seeds[0], "det", amp, freq, stretch, true));
|
||||||
|
var bb = Topography.Generate(Cfg(mapSize, seeds[0], "det", amp, freq, stretch, true));
|
||||||
|
var det = ShapingOracle.LabelsDeterministic(a, bb); det.Name += $" [amp {amp:G3}, {seeds[0]}]";
|
||||||
|
var bits = ShapingOracle.NorthLocked("o2", $"two generations bit-identical everywhere [amp {amp:G3}, {seeds[0]}]", a.Height, bb.Height, mapSize, mapSize);
|
||||||
|
perField.Add(det); perField.Add(bits); GD.Print(" " + det); GD.Print(" " + bits);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool allOk = hard.TrueForAll(c => c.Passed) && perField.TrueForAll(c => c.Passed);
|
||||||
|
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
|
||||||
|
foreach (var c in perField) if (!c.Passed) GD.PrintErr(" " + c);
|
||||||
|
|
||||||
|
WriteTable(batchRoot, mapSize, ladder, seeds, rows, baseline, big, stretch, freq, speckCells);
|
||||||
|
WriteIndex(batchRoot, mapSize, calibSize, ladder, seeds, rows, baseline, big, stretch, freq, speckCells, hard, perField, 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 instrument --------------------------------------------------
|
||||||
|
|
||||||
|
private static long Cells(float frac, int size) => Math.Max(1L, (long)Math.Round(frac * (double)size * size));
|
||||||
|
|
||||||
|
private static (HemiStats north, HemiStats south) Stats(RegionLabels l, long big)
|
||||||
|
{
|
||||||
|
var n = new List<long>(); var s = new List<long>();
|
||||||
|
foreach (var r in l.Regions)
|
||||||
|
{
|
||||||
|
if (r.IsMainland) continue;
|
||||||
|
if (r.Hemisphere == RegionLabeling.HemiSouth) s.Add(r.SizeCells); else n.Add(r.SizeCells);
|
||||||
|
}
|
||||||
|
return (Make(n, big), Make(s, big));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HemiStats Make(List<long> sizes, long big)
|
||||||
|
{
|
||||||
|
sizes.Sort();
|
||||||
|
var h = new HemiStats { All = sizes.Count, Hist = new int[RegionLabeling.HistogramEdges.Length + 1] };
|
||||||
|
if (sizes.Count == 0) return h;
|
||||||
|
double sum = 0;
|
||||||
|
foreach (long v in sizes) { h.Hist[RegionLabeling.HistogramBin(v)]++; if (v >= big) h.Big++; sum += v; }
|
||||||
|
h.Min = sizes[0]; h.Med = sizes[sizes.Count / 2]; h.Max = sizes[^1]; h.Mean = sum / sizes.Count;
|
||||||
|
int k = Math.Min(3, sizes.Count); h.Largest = new long[k];
|
||||||
|
for (int i = 0; i < k; i++) h.Largest[i] = sizes[sizes.Count - 1 - i];
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Row MakeRow(int level, float amp, int seed, Pass1Result p1, long big, bool ok, ulong ms)
|
||||||
|
{
|
||||||
|
var (n, s) = Stats(p1.Regions, big);
|
||||||
|
return new Row
|
||||||
|
{
|
||||||
|
Level = level, Amp = amp, Seed = seed, N = n, S = s, MainlandCells = p1.Regions.Mainland.SizeCells,
|
||||||
|
SpecksReverted = p1.RegionLedger?.RevertedComponents ?? 0, Ok = ok, Ms = ms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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, Pass1Result p1, Pass2Result p2, float sea, CurveAnchors anchors, bool skipRaw, float amp)
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||||||
|
if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
|
||||||
|
var look = new LookConfig
|
||||||
|
{
|
||||||
|
Name = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
|
||||||
|
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f, HillshadeStrength = 0.30f, SeaLevel = sea,
|
||||||
|
};
|
||||||
|
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
|
||||||
|
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, $"{p2.VariantLabel.ToUpperInvariant()} (AMP {amp:G3}) {p2.Seed}")
|
||||||
|
.SavePng(Path.Combine(dir, "relief.png"));
|
||||||
|
var led = p1.RegionLedger;
|
||||||
|
RegionOverlayRenderer.SavePng(p1.Regions, led != null && led.RevertOn ? p1.RegionsPre : null, p1.MapSize,
|
||||||
|
led?.RevertedComponents ?? 0, led?.ThresholdCells ?? 0, Path.Combine(dir, "regions.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HistRow(int[] h)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < h.Length; i++) { if (i > 0) sb.Append(" · "); sb.Append(h[i]); }
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Largest(long[] l) => l.Length == 0 ? "—" : string.Join(" / ", Array.ConvertAll(l, v => v.ToString("N0")));
|
||||||
|
|
||||||
|
private static string TableMarkdown(float[] ladder, int[] seeds, List<Row> rows, Dictionary<int, Row> baseline, long big)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var histHead = new StringBuilder();
|
||||||
|
for (int i = 0; i <= RegionLabeling.HistogramEdges.Length; i++) { if (i > 0) histHead.Append(" · "); histHead.Append(RegionLabeling.HistogramLabel(i)); }
|
||||||
|
sb.AppendLine($"| Level | amp | Seed | **N islands all / ≥ {big:N0}** | N size med / mean / max | N largest three | N histogram ({histHead}) | **S islands all / ≥ {big:N0}** | S size med / mean / max | S largest three | S histogram | mainland cells (Δ vs amp 0) | specks reverted | oracle |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (int seed in seeds)
|
||||||
|
{
|
||||||
|
var b = baseline[seed];
|
||||||
|
var all = new List<Row> { b }; all.AddRange(rows.FindAll(r => r.Seed == seed));
|
||||||
|
foreach (var r in all)
|
||||||
|
sb.AppendLine($"| {(r.Level == 0 ? "*baseline*" : $"`frag_{r.Level}`")} | {r.Amp:G3} | `{seed}` | **{r.N.All} / {r.N.Big}** | {r.N.Med} / {r.N.Mean:F0} / {r.N.Max} | {Largest(r.N.Largest)} | {HistRow(r.N.Hist)} | " +
|
||||||
|
$"**{r.S.All} / {r.S.Big}** | {r.S.Med} / {r.S.Mean:F0} / {r.S.Max} | {Largest(r.S.Largest)} | {HistRow(r.S.Hist)} | {r.MainlandCells:N0} ({r.MainlandCells - b.MainlandCells:+#,0;-#,0;0}) | {r.SpecksReverted} | {(r.Level == 0 ? "—" : r.Ok ? "pass" : "**FAIL**")} |");
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteTable(string batchRoot, int mapSize, float[] ladder, int[] seeds, List<Row> rows, Dictionary<int, Row> baseline, long big,
|
||||||
|
float stretch, float freq, long speckCells)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"# The hemisphere-split count/size table — {ladder.Length} amplitudes × {seeds.Length} seeds at {mapSize}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Fixed: stretch {stretch:G3}, fragmentation frequency {freq:G3}/map, window {CoastalFragment.DefaultBandCentre:F2} ± {CoastalFragment.DefaultBandHalfWidth:F2}, speck revert < {speckCells} cells. Offshore / shelf OFF.");
|
||||||
|
sb.AppendLine("Islands = non-mainland 8-connected land components of the classify field; hemisphere by centroid. BOTH hemispheres are signals now.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(TableMarkdown(ladder, seeds, rows, baseline, big));
|
||||||
|
WriteText(Path.Combine(batchRoot, "count_size_table.md"), sb.ToString());
|
||||||
|
|
||||||
|
var csv = new StringBuilder();
|
||||||
|
csv.AppendLine("level,amp,seed,n_all,n_big,n_med,n_mean,n_max,n_largest,n_hist,s_all,s_big,s_med,s_mean,s_max,s_largest,s_hist,mainland_cells,specks_reverted,oracle,ms");
|
||||||
|
var ic = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
|
foreach (int seed in seeds)
|
||||||
|
{
|
||||||
|
var all = new List<Row> { baseline[seed] }; all.AddRange(rows.FindAll(r => r.Seed == seed));
|
||||||
|
foreach (var r in all)
|
||||||
|
csv.AppendLine(string.Join(",", r.Level, r.Amp.ToString("G5", ic), r.Seed,
|
||||||
|
r.N.All, r.N.Big, r.N.Med, r.N.Mean.ToString("F1", ic), r.N.Max, "\"" + Largest(r.N.Largest) + "\"", "\"" + HistRow(r.N.Hist) + "\"",
|
||||||
|
r.S.All, r.S.Big, r.S.Med, r.S.Mean.ToString("F1", ic), r.S.Max, "\"" + Largest(r.S.Largest) + "\"", "\"" + HistRow(r.S.Hist) + "\"",
|
||||||
|
r.MainlandCells, r.SpecksReverted, r.Ok ? "pass" : "FAIL", r.Ms));
|
||||||
|
}
|
||||||
|
WriteText(Path.Combine(batchRoot, "count_size_table.csv"), csv.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, float[] ladder, int[] seeds, List<Row> rows, Dictionary<int, Row> baseline, long big,
|
||||||
|
float stretch, float freq, long speckCells, List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perField, bool allOk)
|
||||||
|
{
|
||||||
|
int first = seeds.Length > 1 ? seeds[1] : seeds[0];
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("# Batch 09 — coastal fragmentation: break more pieces off the edges, N + S");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("**Options, not a setting.** At a FIXED stretch, a perimeter-wide band-limited fragmentation noise on the pre-power");
|
||||||
|
sb.AppendLine("falloff — only inside the coastal window, zero-mean — is swept light → heavy. Thin necks of barely-land flip first");
|
||||||
|
sb.AppendLine("(self-targeting: nothing detected, nothing stamped); the interior is bit-identical by construction (asserted). The");
|
||||||
|
sb.AppendLine("region layer is the instrument; both hemispheres are fragmentation signals now.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⭐ Open this first");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"1. **`{first}_frag_2/regions.png`** — a mid amplitude on the seed whose natural southern islands read fragmentation best; grey mainland, each island its own colour, dark red = a reverted speck (< {speckCells} cells).");
|
||||||
|
sb.AppendLine($"2. Walk the ladder on that seed: `{first}_frag_1/` … `_frag_{ladder.Length}/` (`regions.png` beside `relief.png`); then the same on `{seeds[0]}`.");
|
||||||
|
sb.AppendLine("3. Then the table: N and S count + size side by side, down the rows as amplitude climbs — look for counts rising while the largest pieces stay healthy.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The fixed frame and the axis");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"- **Fixed:** stretch `{stretch:G3}` (task 08's `stretch_3` rung; band {SouthernStretch.DefaultBandStartFrac:F2} / feather {SouthernStretch.DefaultBandFeatherFrac:F2}, sinker stretched — untouched this round) · fragmentation frequency `{freq:G3}` periods/map (the secondary dial, fixed) · window centre {CoastalFragment.DefaultBandCentre:F2} ± {CoastalFragment.DefaultBandHalfWidth:F2} (pre-power falloff) · speck revert < {speckCells} cells (true noise only) · offshore OFF · shelf OFF.");
|
||||||
|
sb.AppendLine($"- **The axis — `FragmentAmp`:** {string.Join(" · ", Array.ConvertAll(ladder, v => v.ToString("G3")))} (levels 1–{ladder.Length}); baseline 0 = the task-08 stretch field, measured for Δ.");
|
||||||
|
sb.AppendLine($"- \"big\" island = ≥ {big:N0} cells at {mapSize} (the 07 `threshold_mid`).");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"## ⭐ The hemisphere-split count/size table — {ladder.Length} amplitudes × {seeds.Length} seeds at {mapSize}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(TableMarkdown(ladder, seeds, rows, baseline, big));
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Also as plain data: `count_size_table.md` / `.csv`. The probe that fixed the frequency and placed the ladder: `scratch/frag_probe.md`.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("`ProvisionalEven`, flagged. The individually-coloured scheme is only the `regions.png` overlay.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The oracle (asymmetric: interior locked, coast free)");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Regressions (the curve untouched; frag OFF at the fixed stretch bit-identical to the task-08 field):");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(ShapingOracle.ToMarkdownTable(hard));
|
||||||
|
sb.AppendLine("Per field (interior locked r · high-ground report s (informational) · centre-is-land m · tag/coastline k · classify b · determinism o / o2):");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(ShapingOracle.ToMarkdownTable(perField));
|
||||||
|
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("| `regions.png`, `relief.png`, `INDEX.md`, `count_size_table.md` / `.csv`, `scratch/frag_probe.md` | **keep** |");
|
||||||
|
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + code — large, clear freely |");
|
||||||
|
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Plates at {mapSize}, curve calibrated at {calibSize} with offshore off. {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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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 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 float[] EnvFloats(string k, float[] fallback)
|
||||||
|
{
|
||||||
|
string v = EnvStr(k, null);
|
||||||
|
if (v == null) return fallback;
|
||||||
|
var outp = new List<float>();
|
||||||
|
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
if (float.TryParse(part.Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float f)) outp.Add(f);
|
||||||
|
return outp.Count > 0 ? outp.ToArray() : 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/CoastalFragmentTool.cs.uid
Normal file
1
Tools/Scripts/CoastalFragmentTool.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cuwg87lli67l6
|
||||||
154
Tools/Scripts/DrainageRenderer.cs
Normal file
154
Tools/Scripts/DrainageRenderer.cs
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// THE DRAINAGE MAPS (chat2/12) — presentation only, for eyeballing that the flow is sane:
|
||||||
|
///
|
||||||
|
/// • the LOG-SCALED ACCUMULATION map — drainage spans orders of magnitude, so log(1+acc) over land;
|
||||||
|
/// the dendritic uplands and the trunks read as bright channels on dark hillslopes; the ocean is a
|
||||||
|
/// flat dark blue and enclosed (non-ocean) water a dark teal, so the ocean identity is visible too;
|
||||||
|
/// • the PROMOTED-CANDIDATES overlay — a faint grey terrain, the sea-reaching trunks in cyan (outlet
|
||||||
|
/// square, mountain-exit white ring, lean tributaries thin), the endorheic giants in orange (pooling
|
||||||
|
/// terminal disc, lean tributaries thin), the lean endorheic terminals as red rings. Provisional
|
||||||
|
/// routes are NOT drawn (routing is a later task). Nothing here touches data.
|
||||||
|
/// </summary>
|
||||||
|
public static class DrainageRenderer
|
||||||
|
{
|
||||||
|
private static readonly Color Ocean = new(0.055f, 0.110f, 0.235f);
|
||||||
|
private static readonly Color Enclosed = new(0.060f, 0.220f, 0.230f);
|
||||||
|
private static readonly Color Trunk = new(0.250f, 0.900f, 1.000f);
|
||||||
|
private static readonly Color Giant = new(1.000f, 0.600f, 0.150f);
|
||||||
|
private static readonly Color Endo = new(1.000f, 0.250f, 0.250f);
|
||||||
|
private static readonly Color Exit = new(1.000f, 1.000f, 1.000f);
|
||||||
|
private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
|
||||||
|
|
||||||
|
/// <summary>log(1 + acc) / log(1 + max) over land; ocean / enclosed water flat.</summary>
|
||||||
|
public static Image Accumulation(int[] acc, bool[] isOcean, float[,] render, int n, float sea)
|
||||||
|
{
|
||||||
|
long max = 1;
|
||||||
|
for (int i = 0; i < acc.Length; i++) if (acc[i] > max) max = acc[i];
|
||||||
|
double lmax = Math.Log(1.0 + max);
|
||||||
|
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
{
|
||||||
|
int i = x * n + y;
|
||||||
|
if (isOcean[i]) { img.SetPixel(x, y, Ocean); continue; }
|
||||||
|
if (render[x, y] < sea) { img.SetPixel(x, y, Enclosed); continue; }
|
||||||
|
float v = (float)(Math.Log(1.0 + acc[i]) / lmax);
|
||||||
|
// a dark-to-bright ramp with a cool tint in the channels
|
||||||
|
float r = 0.06f + 0.94f * v * v, g = 0.08f + 0.92f * v, b = 0.12f + 0.88f * MathF.Sqrt(v);
|
||||||
|
img.SetPixel(x, y, new Color(MathF.Min(1f, r), MathF.Min(1f, g), MathF.Min(1f, b)));
|
||||||
|
}
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The candidates over a faint terrain.</summary>
|
||||||
|
public static Image Candidates(DrainageAnalysis.Plan plan, bool[] isOcean, float[,] render, int n, float sea, float hMax, string title)
|
||||||
|
{
|
||||||
|
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
|
||||||
|
float span = MathF.Max(1e-6f, hMax - sea);
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
{
|
||||||
|
int i = x * n + y;
|
||||||
|
if (isOcean[i]) { img.SetPixel(x, y, Ocean); continue; }
|
||||||
|
if (render[x, y] < sea) { img.SetPixel(x, y, Enclosed); continue; }
|
||||||
|
float t = MathF.Min(1f, (render[x, y] - sea) / span);
|
||||||
|
float g = 0.30f + 0.45f * MathF.Sqrt(t);
|
||||||
|
img.SetPixel(x, y, new Color(g, g, g * 0.96f));
|
||||||
|
}
|
||||||
|
|
||||||
|
int thick = n >= 4096 ? 5 : 3, thin = n >= 4096 ? 3 : 2, mark = n >= 4096 ? 18 : 10;
|
||||||
|
foreach (var g in plan.Giants)
|
||||||
|
{
|
||||||
|
foreach (var tr in g.Tributaries) Polyline(img, tr.Course, n, Giant, thin);
|
||||||
|
Polyline(img, g.Course, n, Giant, thick);
|
||||||
|
Disc(img, (int)g.Terminal.x, (int)g.Terminal.y, mark, n, Giant);
|
||||||
|
Ring(img, (int)g.Terminal.x, (int)g.Terminal.y, mark + 8, n, Ink, 3);
|
||||||
|
if (g.ExitFound) Ring(img, (int)g.MountainExit.x, (int)g.MountainExit.y, mark, n, Exit, 4);
|
||||||
|
}
|
||||||
|
foreach (var t in plan.Trunks)
|
||||||
|
{
|
||||||
|
foreach (var tr in t.Tributaries) Polyline(img, tr.Course, n, Trunk, thin);
|
||||||
|
Polyline(img, t.Course, n, Trunk, thick);
|
||||||
|
Square(img, (int)t.Outlet.x, (int)t.Outlet.y, mark, n, Trunk);
|
||||||
|
if (t.ExitFound) Ring(img, (int)t.MountainExit.x, (int)t.MountainExit.y, mark, n, Exit, 4);
|
||||||
|
}
|
||||||
|
foreach (var e in plan.Endorheics)
|
||||||
|
Ring(img, (int)e.Terminal.x, (int)e.Terminal.y, mark + 4, n, Endo, 4);
|
||||||
|
|
||||||
|
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
|
||||||
|
TinyFont.Draw(img, title, 12, 12, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"CYAN: SEA-REACHING TRUNKS ({plan.Trunks.Count}) - SQUARE = OUTLET WHITE RING = MOUNTAIN EXIT", 12, 12 + lh, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"ORANGE: ENDORHEIC GIANTS ({plan.Giants.Count}) - DISC = POOLING TERMINAL (EXPECTED, NOT AN ERROR)", 12, 12 + lh * 2, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"RED RING: LEAN ENDORHEIC TERMINALS ({plan.Endorheics.Count}) THIN LINES: LEAN TRIBUTARIES NOTHING CARVED - ANALYSIS ONLY", 12, 12 + lh * 3, s, Ink);
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Polyline(Image img, List<(float x, float y)> pts, int n, Color c, int thick)
|
||||||
|
{
|
||||||
|
for (int i = 1; i < pts.Count; i++)
|
||||||
|
Line(img, (int)pts[i - 1].x, (int)pts[i - 1].y, (int)pts[i].x, (int)pts[i].y, n, c, thick);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Line(Image img, int x0, int y0, int x1, int y1, int n, Color c, int thick)
|
||||||
|
{
|
||||||
|
int dx = Math.Abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
|
||||||
|
int dy = -Math.Abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
|
||||||
|
int err = dx + dy; int r = thick / 2;
|
||||||
|
int guard = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
for (int ox = -r; ox <= r; ox++)
|
||||||
|
for (int oy = -r; oy <= r; oy++)
|
||||||
|
{
|
||||||
|
int px = x0 + ox, py = y0 + oy;
|
||||||
|
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
|
||||||
|
}
|
||||||
|
if (x0 == x1 && y0 == y1) break;
|
||||||
|
if (++guard > 4 * n) break;
|
||||||
|
int e2 = 2 * err;
|
||||||
|
if (e2 >= dy) { err += dy; x0 += sx; }
|
||||||
|
if (e2 <= dx) { err += dx; y0 += sy; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Disc(Image img, int cx, int cy, int r, int n, Color c)
|
||||||
|
{
|
||||||
|
for (int ox = -r; ox <= r; ox++)
|
||||||
|
for (int oy = -r; oy <= r; oy++)
|
||||||
|
{
|
||||||
|
if (ox * ox + oy * oy > r * r) continue;
|
||||||
|
int px = cx + ox, py = cy + oy;
|
||||||
|
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Ring(Image img, int cx, int cy, int r, int n, Color c, int w)
|
||||||
|
{
|
||||||
|
for (int ox = -r; ox <= r; ox++)
|
||||||
|
for (int oy = -r; oy <= r; oy++)
|
||||||
|
{
|
||||||
|
int d2 = ox * ox + oy * oy;
|
||||||
|
if (d2 > r * r || d2 < (r - w) * (r - w)) continue;
|
||||||
|
int px = cx + ox, py = cy + oy;
|
||||||
|
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Square(Image img, int cx, int cy, int r, int n, Color c)
|
||||||
|
{
|
||||||
|
for (int ox = -r; ox <= r; ox++)
|
||||||
|
for (int oy = -r; oy <= r; oy++)
|
||||||
|
{
|
||||||
|
int px = cx + ox, py = cy + oy;
|
||||||
|
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/DrainageRenderer.cs.uid
Normal file
1
Tools/Scripts/DrainageRenderer.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dp11721l40ixx
|
||||||
410
Tools/Scripts/DrainageTool.cs
Normal file
410
Tools/Scripts/DrainageTool.cs
Normal file
|
|
@ -0,0 +1,410 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE DRAINAGE-ANALYSIS BATCH (chat2/12) — minimal-first: is the flow sane before rivers are built
|
||||||
|
/// on it? Runs <see cref="DrainageAnalysis"/> (pure analysis) on the ERODED render field of the locked
|
||||||
|
/// shape for 4 seeds from the task-11 batch, renders the log-accumulation map + the promoted-candidates
|
||||||
|
/// overlay, writes the accumulation as .f32, and proves: terrain bit-identical before/after (nothing
|
||||||
|
/// carved), no water added, the routing-fill invariants, determinism, ocean identity from the region
|
||||||
|
/// layer. ⚠ D8 is used to COMPUTE where water flows — never to carve.
|
||||||
|
///
|
||||||
|
/// ═══ RUNNING IT ═══
|
||||||
|
///
|
||||||
|
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
|
||||||
|
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/DrainageTool.tscn
|
||||||
|
///
|
||||||
|
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
|
||||||
|
/// ISLA_MAPSIZE / ISLA_CALIB_SIZE (default 8192 / 2048)
|
||||||
|
/// ISLA_SEEDS (default the 4 task-11 seeds)
|
||||||
|
/// ISLA_SKIP_T11_CHECK=1 skip the bit-identity against the task-11 erosion_on dumps
|
||||||
|
/// </summary>
|
||||||
|
public partial class DrainageTool : 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;
|
||||||
|
|
||||||
|
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 DrainageAnalysis.Plan Plan; public long OceanCells, EnclosedWater, LandCells;
|
||||||
|
public ulong MsAnalysis; public bool Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Run()
|
||||||
|
{
|
||||||
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
|
||||||
|
int task = EnvInt("ISLA_TASK", 12);
|
||||||
|
string descr = EnvStr("ISLA_BATCH", "drainage_analysis");
|
||||||
|
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 skipT11 = EnvStr("ISLA_SKIP_T11_CHECK", "0") == "1";
|
||||||
|
string t11Source = EnvStr("ISLA_T11_SOURCE", "11_erosion");
|
||||||
|
|
||||||
|
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
|
||||||
|
|
||||||
|
var anchors = CurveAnchors.Default;
|
||||||
|
float sea = 0.15f;
|
||||||
|
var dp = new DrainageAnalysis.Params { SeaLevel = sea };
|
||||||
|
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print(" DRAINAGE ANALYSIS (chat2/12) — minimal-first: is the flow sane? (analysis only, nothing carved)");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
|
||||||
|
GD.Print($"seeds : {string.Join(", ", seeds)}");
|
||||||
|
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON (faithful tune) — the task-11 erosion_on field");
|
||||||
|
GD.Print($"params : endorheic depth ≥ {dp.EndorheicMinDepthM} m, area ≥ {dp.EndorheicMinAreaPx}, inflow ≥ {dp.EndorheicMinInflowPx}, max {dp.EndorheicMaxCount} · trunks {dp.TrunkCount} sep {dp.MinOutletSeparationPx} px · giants {dp.GiantCount} · stem ≥ {dp.StemMinAccPx} · tributary ≥ {dp.TributaryMinAccPx} (max {dp.TributaryMaxPerTrunk}) · exit grade {dp.ExitGradeMin} m/px over {dp.ExitWindowPx} px");
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var c = new TerrainGenConfig
|
||||||
|
{
|
||||||
|
MapSize = size, Seed = seed, VariantLabel = "drainage",
|
||||||
|
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||||||
|
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
||||||
|
};
|
||||||
|
TerrainShapeV1.Apply(c);
|
||||||
|
c.Erosion = true; // the faithful tune — the defaults
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hard = new List<ShapingOracle.Check>();
|
||||||
|
var perSeed = new List<ShapingOracle.Check>();
|
||||||
|
var rows = new List<Row>();
|
||||||
|
|
||||||
|
for (int si = 0; si < seeds.Length; si++)
|
||||||
|
{
|
||||||
|
int seed = seeds[si];
|
||||||
|
GD.Print($"\n--- seed {seed} ---");
|
||||||
|
var cfg = Cfg(mapSize, seed);
|
||||||
|
Pass1Result p1 = Topography.Generate(cfg);
|
||||||
|
Pass2Result shaped = Shaping.Shape(p1, cfg);
|
||||||
|
var ero = ErosionPass.Apply(shaped, cfg);
|
||||||
|
Pass2Result p2 = ero.Shaped;
|
||||||
|
GD.Print($" terrain ready ({p1.ElapsedMs} ms pass 1, erosion {ero.Ms / 1000.0:F1} s)");
|
||||||
|
|
||||||
|
if (!skipT11)
|
||||||
|
{
|
||||||
|
string dump = Path.Combine(ToolingPaths.BatchesRoot, t11Source, $"{seed}_erosion_on", "height.f32");
|
||||||
|
if (File.Exists(dump) && mapSize == 8192)
|
||||||
|
{
|
||||||
|
var a11 = ShapingOracle.DumpRegression("a11", $"the eroded render field == the task-11 erosion_on dump (the terrain the developer saw) [{seed}]", p2.Height, HeightField.Load(dump, mapSize), mapSize, dump);
|
||||||
|
hard.Add(a11); GD.Print(" " + a11);
|
||||||
|
}
|
||||||
|
else GD.Print($" a11 [{seed}]: ⚠ skipped — {(mapSize != 8192 ? "map size is not the 11 batch's 8192" : $"no dump at {dump}")}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⭐ THE OCEAN IDENTITY — from the region layer, on the CLASSIFY field.
|
||||||
|
bool[] isOcean = RegionLabeling.OceanMask(p2.HeightClassify, mapSize, sea, out long oceanCells, out long enclosed);
|
||||||
|
var isClassifyWater = new bool[mapSize * mapSize];
|
||||||
|
long waterPx = 0;
|
||||||
|
for (int x = 0; x < mapSize; x++)
|
||||||
|
for (int y = 0; y < mapSize; y++)
|
||||||
|
if (p2.HeightClassify[x, y] < sea) { isClassifyWater[x * mapSize + y] = true; waterPx++; }
|
||||||
|
GD.Print($" ocean (region layer, classify): {oceanCells:N0} cells; enclosed non-ocean water: {enclosed:N0} cells; classify water total {waterPx:N0}");
|
||||||
|
|
||||||
|
// Snapshot both fields — the analysis must write ZERO terrain cells.
|
||||||
|
var renderBefore = (float[,])p2.Height.Clone();
|
||||||
|
var classifyBefore = (float[,])p2.HeightClassify.Clone();
|
||||||
|
long wetRenderBefore = ErosionPass.CountWaterPixels(p2.Height, mapSize, sea);
|
||||||
|
|
||||||
|
ulong tA = Time.GetTicksMsec();
|
||||||
|
var plan = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp);
|
||||||
|
ulong msA = Time.GetTicksMsec() - tA;
|
||||||
|
GD.Print($" analysis {msA / 1000.0:F1} s: land {plan.LandCells:N0} — sea-reaching {plan.SeaReachingCells:N0} ({100.0 * plan.SeaReachingCells / Math.Max(1, plan.LandCells):F1} %), endorheic {plan.EndorheicCells:N0} ({100.0 * plan.EndorheicCells / Math.Max(1, plan.LandCells):F1} %), unrouted {plan.UnroutedCells:N0}; terminal basins {plan.TerminalBasinCount}, pits filled through {plan.PitsFilledCount:N0}");
|
||||||
|
foreach (var t in plan.Trunks) GD.Print($" trunk: outlet ({t.Outlet.x:F0},{t.Outlet.y:F0}) drainage {t.DrainageAreaPx:N0} px, stem {t.Course.Count * 4} px, exit {(t.ExitFound ? $"({t.MountainExit.x:F0},{t.MountainExit.y:F0}) at {t.MountainExitElevM:F0} m" : "NOT FOUND")}, tributaries {t.Tributaries.Count}");
|
||||||
|
foreach (var g in plan.Giants) GD.Print($" giant: terminal ({g.Terminal.x:F0},{g.Terminal.y:F0}) inflow {g.DrainageAreaPx:N0} px, basin {g.BasinAreaPx:N0} px / {g.BasinDepthM:F1} m deep, kind {g.Kind}, exit {(g.ExitFound ? $"{g.MountainExitElevM:F0} m" : "NOT FOUND")}, tributaries {g.Tributaries.Count}");
|
||||||
|
foreach (var e in plan.Endorheics) GD.Print($" lean terminal: ({e.Terminal.x:F0},{e.Terminal.y:F0}) inflow {e.DrainageAreaPx:N0}, basin {e.BasinAreaPx:N0} px / {e.BasinDepthM:F1} m");
|
||||||
|
|
||||||
|
// ═══ THE ORACLE ═══
|
||||||
|
var checks = new List<ShapingOracle.Check>
|
||||||
|
{
|
||||||
|
ShapingOracle.NorthLocked("t", "terrain untouched — render field bit-identical before/after the analysis", renderBefore, p2.Height, mapSize, mapSize),
|
||||||
|
ShapingOracle.NorthLocked("t2", "terrain untouched — classify field bit-identical before/after the analysis", classifyBefore, p2.HeightClassify, mapSize, mapSize),
|
||||||
|
WaterUnchanged(wetRenderBefore, p2, mapSize, sea, p1),
|
||||||
|
FillInvariants(plan, p2.Height, mapSize),
|
||||||
|
OceanFromRegionLayer(isOcean, p2.HeightClassify, mapSize, sea, oceanCells, enclosed),
|
||||||
|
};
|
||||||
|
if (si == 0)
|
||||||
|
{
|
||||||
|
var plan2 = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp);
|
||||||
|
checks.Add(Deterministic(plan, plan2));
|
||||||
|
}
|
||||||
|
foreach (var c in checks) { c.Name += $" [{seed}]"; perSeed.Add(c); GD.Print(" " + c); }
|
||||||
|
bool ok = checks.TrueForAll(c => c.Passed);
|
||||||
|
|
||||||
|
WriteSeed(batchRoot, seed, plan, isOcean, p2, mapSize, sea, skipRaw);
|
||||||
|
rows.Add(new Row { Seed = seed, Plan = plan, OceanCells = oceanCells, EnclosedWater = enclosed, LandCells = plan.LandCells, MsAnalysis = msA, Ok = ok });
|
||||||
|
}
|
||||||
|
|
||||||
|
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, dp, 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 -------------------------------------------------------
|
||||||
|
|
||||||
|
private static ShapingOracle.Check WaterUnchanged(long wetBefore, Pass2Result p2, int n, float sea, Pass1Result p1)
|
||||||
|
{
|
||||||
|
long wetAfter = ErosionPass.CountWaterPixels(p2.Height, n, sea);
|
||||||
|
var c = new ShapingOracle.Check { Id = "w", Name = "no water added — render water pixels unchanged; region labeling + island tag untouched" };
|
||||||
|
c.Passed = wetBefore == wetAfter && p1.Regions != null;
|
||||||
|
c.Detail = $"water pixels {wetBefore:N0} → {wetAfter:N0}; {p1.Regions?.IslandCount ?? 0} islands in the (untouched) region table";
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The reference's two routing-fill diagnostics: filled ≥ original everywhere; every cell has a non-ascending path to the border on the full fill.</summary>
|
||||||
|
private static ShapingOracle.Check FillInvariants(DrainageAnalysis.Plan plan, float[,] height, int n)
|
||||||
|
{
|
||||||
|
var c = new ShapingOracle.Check { Id = "r", Name = "routing fill — full fill ≥ original everywhere; every cell has a non-ascending 8-path to the border" };
|
||||||
|
long below = 0, raised = 0; string first = null;
|
||||||
|
var ff = plan.FullFilled;
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
{
|
||||||
|
int i = x * n + y; float h = height[x, y];
|
||||||
|
if (ff[i] < h) { below++; first ??= $"[{x},{y}] filled {ff[i]:G9} < original {h:G9}"; }
|
||||||
|
else if (ff[i] > h) raised++;
|
||||||
|
}
|
||||||
|
// Non-ascending path: follow the lowest neighbour; memoised. -1 unknown, 1 reaches border, 2 stuck.
|
||||||
|
var state = new sbyte[n * n]; long stuck = 0; var path = new List<int>(1 << 12);
|
||||||
|
int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 }, DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
|
||||||
|
for (int i = 0; i < n * n && stuck == 0; i++)
|
||||||
|
{
|
||||||
|
if (state[i] != 0) continue;
|
||||||
|
int cur = i; path.Clear(); sbyte result = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (state[cur] != 0) { result = state[cur]; break; }
|
||||||
|
path.Add(cur);
|
||||||
|
int cx = cur / n, cy = cur % n;
|
||||||
|
if (cx == 0 || cy == 0 || cx == n - 1 || cy == n - 1) { result = 1; break; }
|
||||||
|
float best = ff[cur]; int bestN = -1;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int ni = (cx + DX[k]) * n + (cy + DY[k]);
|
||||||
|
if (ff[ni] < best) { best = ff[ni]; bestN = ni; } // the strictly lowest neighbour
|
||||||
|
}
|
||||||
|
if (bestN < 0)
|
||||||
|
{
|
||||||
|
// no strictly lower neighbour: allow an EQUAL neighbour not yet on this path (flat), else stuck
|
||||||
|
for (int k = 0; k < 8 && bestN < 0; k++)
|
||||||
|
{
|
||||||
|
int ni = (cx + DX[k]) * n + (cy + DY[k]);
|
||||||
|
if (ff[ni] == ff[cur] && state[ni] == 1) bestN = ni;
|
||||||
|
}
|
||||||
|
if (bestN < 0) { result = 2; break; }
|
||||||
|
}
|
||||||
|
cur = bestN;
|
||||||
|
if (path.Count > 4 * n) { result = 2; break; }
|
||||||
|
}
|
||||||
|
foreach (int pc in path) state[pc] = result;
|
||||||
|
if (result == 2) { stuck++; first ??= $"cell {path[0] / n},{path[0] % n} has no non-ascending path to the border"; }
|
||||||
|
}
|
||||||
|
c.Passed = below == 0 && stuck == 0;
|
||||||
|
c.Detail = c.Passed ? $"filled ≥ original on all {(long)n * n:N0} cells ({raised:N0} raised); every cell drains to the border on the full fill"
|
||||||
|
: $"VIOLATION — {below:N0} cells filled below original, {stuck:N0} stuck — {first}";
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ShapingOracle.Check OceanFromRegionLayer(bool[] isOcean, float[,] classify, int n, float sea, long oceanCells, long enclosed)
|
||||||
|
{
|
||||||
|
var c = new ShapingOracle.Check { Id = "s", Name = "\"the sea\" = the ocean body from the region layer (classify, 4-connected to the border) — not any below-sea cell" };
|
||||||
|
long oceanLand = 0, oceanTouchBorder = 0, count = 0;
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
{
|
||||||
|
if (!isOcean[x * n + y]) continue;
|
||||||
|
count++;
|
||||||
|
if (classify[x, y] >= sea) oceanLand++;
|
||||||
|
if (x == 0 || y == 0 || x == n - 1 || y == n - 1) oceanTouchBorder++;
|
||||||
|
}
|
||||||
|
c.Passed = oceanLand == 0 && oceanTouchBorder > 0 && count == oceanCells;
|
||||||
|
c.Detail = $"{count:N0} ocean cells, all below sea, {oceanTouchBorder:N0} on the border; {enclosed:N0} below-sea cells are NOT ocean (enclosed water — ordinary terrain to the router)";
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ShapingOracle.Check Deterministic(DrainageAnalysis.Plan a, DrainageAnalysis.Plan b)
|
||||||
|
{
|
||||||
|
var c = new ShapingOracle.Check { Id = "o", Name = "deterministic — flow field, accumulation and candidate set identical across two runs" };
|
||||||
|
long dirDiff = 0, accDiff = 0;
|
||||||
|
for (int i = 0; i < a.Dir.Length; i++) { if (a.Dir[i] != b.Dir[i]) dirDiff++; if (a.Acc[i] != b.Acc[i]) accDiff++; }
|
||||||
|
bool cand = a.Trunks.Count == b.Trunks.Count && a.Giants.Count == b.Giants.Count && a.Endorheics.Count == b.Endorheics.Count;
|
||||||
|
if (cand) for (int i = 0; i < a.Trunks.Count; i++) cand &= a.Trunks[i].DrainageAreaPx == b.Trunks[i].DrainageAreaPx && a.Trunks[i].Outlet == b.Trunks[i].Outlet;
|
||||||
|
if (cand) for (int i = 0; i < a.Giants.Count; i++) cand &= a.Giants[i].DrainageAreaPx == b.Giants[i].DrainageAreaPx && a.Giants[i].Terminal == b.Giants[i].Terminal;
|
||||||
|
c.Passed = dirDiff == 0 && accDiff == 0 && cand;
|
||||||
|
c.Detail = c.Passed ? $"dir and acc identical over {a.Dir.Length:N0} cells; {a.Trunks.Count} trunks / {a.Giants.Count} giants / {a.Endorheics.Count} lean terminals identical"
|
||||||
|
: $"DIFFER — dir {dirDiff:N0} cells, acc {accDiff:N0} cells, candidates {(cand ? "same" : "DIFFER")}";
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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 WriteSeed(string batchRoot, int seed, DrainageAnalysis.Plan plan, bool[] isOcean, Pass2Result p2, int n, float sea, bool skipRaw)
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(batchRoot, $"{seed}");
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||||||
|
DrainageRenderer.Accumulation(plan.Acc, isOcean, p2.Height, n, sea).SavePng(Path.Combine(dir, "accumulation.png"));
|
||||||
|
DrainageRenderer.Candidates(plan, isOcean, p2.Height, n, sea, p2.HMax, $"DRAINAGE PLAN SEED {seed} (ERODED TERRAIN, D8 ANALYSIS)").SavePng(Path.Combine(dir, "candidates.png"));
|
||||||
|
if (!skipRaw)
|
||||||
|
{
|
||||||
|
var accF = new float[n, n];
|
||||||
|
for (int x = 0; x < n; x++) for (int y = 0; y < n; y++) accF[x, y] = plan.Acc[x * n + y];
|
||||||
|
HeightField.Save(accF, n, Path.Combine(dir, "accumulation.f32"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, int[] seeds, List<Row> rows, DrainageAnalysis.Params dp,
|
||||||
|
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perSeed, bool allOk)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"# Batch 12 — drainage analysis (minimal-first): is the flow sane? {seeds.Length} seeds at {mapSize}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("**Analysis only — nothing carved, no water added.** The reference `DrainageAnalysis` (priority-flood routing fill with a");
|
||||||
|
sb.AppendLine("one-ulp epsilon, D8 flow directions FOR ANALYSIS, Kahn accumulation, drainage-area promotion) on the eroded render field of the");
|
||||||
|
sb.AppendLine("locked shape. **\"The sea\" is the OCEAN body from the region layer** (classify, 4-connected to the border); enclosed water is");
|
||||||
|
sb.AppendLine("ordinary terrain to the router. **⚠ Endorheic basins are EXPECTED here, not errors:** erosion delivers the upland network only and");
|
||||||
|
sb.AppendLine("cannot cross the flats, so the biggest drainages pool inland. A map full of orange terminals is the correct result.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⭐ Open this first");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"1. **`{seeds[0]}/accumulation.png`** — log-scaled flow accumulation: dendritic uplands and trunks bright on dark hillslopes.");
|
||||||
|
sb.AppendLine($"2. **`{seeds[0]}/candidates.png`** — the promoted candidates over a faint terrain: cyan = sea-reaching trunks (square outlet, white ring = mountain exit), orange = endorheic giants (disc = pooling terminal), red rings = lean endorheic terminals.");
|
||||||
|
sb.AppendLine("3. The other three seeds, then the table.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The summary table — sea-reaching vs endorheic (endorheic dominance is the expected finding)");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Seed | land cells | → ocean | → endorheic | unrouted | terminal basins / pits filled | trunks (drainage px; exit) | giants (inflow px; basin px / depth; kind) | largest endorheic giant vs largest trunk | lean terminals | ocean / enclosed water cells | oracle |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
var p = r.Plan;
|
||||||
|
string trunks = p.Trunks.Count == 0 ? "—" : string.Join("<br>", p.Trunks.ConvertAll(t => $"({t.Outlet.x:F0},{t.Outlet.y:F0}) {t.DrainageAreaPx:N0}; exit {(t.ExitFound ? $"{t.MountainExitElevM:F0} m" : "none")}"));
|
||||||
|
string giants = p.Giants.Count == 0 ? "—" : string.Join("<br>", p.Giants.ConvertAll(g => $"({g.Terminal.x:F0},{g.Terminal.y:F0}) {g.DrainageAreaPx:N0}; {g.BasinAreaPx:N0} / {g.BasinDepthM:F1} m; {g.Kind}"));
|
||||||
|
long bigG = p.Giants.Count == 0 ? 0 : p.Giants[0].DrainageAreaPx, bigT = p.Trunks.Count == 0 ? 0 : p.Trunks[0].DrainageAreaPx;
|
||||||
|
sb.AppendLine($"| `{r.Seed}` | {p.LandCells:N0} | {p.SeaReachingCells:N0} ({100.0 * p.SeaReachingCells / Math.Max(1, p.LandCells):F1} %) | **{p.EndorheicCells:N0} ({100.0 * p.EndorheicCells / Math.Max(1, p.LandCells):F1} %)** | {p.UnroutedCells:N0} | {p.TerminalBasinCount} / {p.PitsFilledCount:N0} | {trunks} | {giants} | **{bigG:N0} vs {bigT:N0}** ({(bigT > 0 ? (double)bigG / bigT : 0):F1}×) | {p.Endorheics.Count} | {r.OceanCells:N0} / {r.EnclosedWater:N0} | {(r.Ok ? "pass" : "**FAIL**")} |");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Params: endorheic depth ≥ {dp.EndorheicMinDepthM} m, area ≥ {dp.EndorheicMinAreaPx:N0} px, inflow ≥ {dp.EndorheicMinInflowPx:N0} px, max {dp.EndorheicMaxCount} · trunks {dp.TrunkCount}, outlet separation {dp.MinOutletSeparationPx} px · giants {dp.GiantCount} · stem ≥ {dp.StemMinAccPx} · tributary ≥ {dp.TributaryMinAccPx:N0} (max {dp.TributaryMaxPerTrunk}) · exit grade {dp.ExitGradeMin} m/px over {dp.ExitWindowPx} px — the reference's declared defaults. Provisional routes are computed (as the reference did) but NOT drawn or promoted — routing is a later task.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The oracle (analysis-only guarantees)");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(hard.Count == 0 ? "*(the task-11 bit-identity check was skipped)*\n" : ShapingOracle.ToMarkdownTable(hard));
|
||||||
|
sb.AppendLine("Per seed (terrain untouched t / t2 · no water added w · routing-fill invariants r · ocean from the region layer s · 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("| `accumulation.png`, `candidates.png`, `INDEX.md` | **keep** |");
|
||||||
|
sb.AppendLine("| `accumulation.f32` | ♻ regenerable (analysis of a regenerable field) — 256 MB each, clear freely |");
|
||||||
|
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Analysis at {mapSize}, curve calibrated at {calibSize}. {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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/DrainageTool.cs.uid
Normal file
1
Tools/Scripts/DrainageTool.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://becalqn1wrw4k
|
||||||
126
Tools/Scripts/ErosionPass.cs
Normal file
126
Tools/Scripts/ErosionPass.cs
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ PASS 2b — HYDRAULIC EROSION, THE CALLER (chat2/11). Runs <see cref="HydraulicErosion"/> on
|
||||||
|
/// the RENDER field of a shaped result — after the curve + detail (pass 2a), before the crater
|
||||||
|
/// carve (which does not exist yet) — exactly the reference's slot (<c>MapGenerator.cs:737-805</c>).
|
||||||
|
///
|
||||||
|
/// ═══ THE THREE THINGS THE CALLER OWNS (ported from the reference caller) ═══
|
||||||
|
///
|
||||||
|
/// 1. THE SPLIT. Only the render field is eroded; the classify field is finalized before the pass
|
||||||
|
/// and never sees it — biomes / water / region labeling classify pre-erosion (the oracle).
|
||||||
|
/// If the two fields are aliased (curve off), the render field is COPIED first, as the
|
||||||
|
/// reference allocated a separate classify array when erosion was on.
|
||||||
|
/// 2. THE FLOOD GUARD. Render-map water pixels are counted BEFORE and AFTER; any change throws
|
||||||
|
/// "EROSION FLOOD-GUARD VIOLATION". With the pass's sea clamp (below-sea cells read-only in
|
||||||
|
/// both directions; carve floor at sea + margin) this is the active proof that no coastline
|
||||||
|
/// moved — mainland and every island alike.
|
||||||
|
/// 3. THE CRATER EXCLUSION, INERT. The weight is passed whole (core / feather radii, mode) but
|
||||||
|
/// with no crater in v2 the radius is 0 ⇒ weight 1 everywhere. It activates when the crater
|
||||||
|
/// carve lands; the reference's "CraterErosionCore < carve factor" warning is DORMANT until
|
||||||
|
/// then (it concerns the erosion↔carve interaction, which does not exist yet).
|
||||||
|
///
|
||||||
|
/// The governors are clamped as the reference's ConfigManager clamped them (count [0, 50 M],
|
||||||
|
/// lifetime [1, 4096], carve cap [0, 60], deposit cap [0, 60], sea margin [0, 5], inertia
|
||||||
|
/// [0, 0.99], evaporation [0, 0.5]) and a clamp is reported, not silent.
|
||||||
|
/// </summary>
|
||||||
|
public static class ErosionPass
|
||||||
|
{
|
||||||
|
public sealed class Result
|
||||||
|
{
|
||||||
|
public Pass2Result Shaped; // the result with the eroded RENDER field (classify untouched)
|
||||||
|
public HydraulicErosion.Stats Stats;
|
||||||
|
public HydraulicErosion.Params Params;
|
||||||
|
public long WetBefore, WetAfter;
|
||||||
|
public ulong Ms;
|
||||||
|
public List<string> Notes = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Count render-map water pixels (height below the flat sea) — the flood guard's instrument.</summary>
|
||||||
|
public static long CountWaterPixels(float[,] height, int mapSize, float sea)
|
||||||
|
{
|
||||||
|
long wet = 0;
|
||||||
|
for (int x = 0; x < mapSize; x++)
|
||||||
|
for (int y = 0; y < mapSize; y++)
|
||||||
|
if (height[x, y] < sea) wet++;
|
||||||
|
return wet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Erode <paramref name="p2"/>'s render field (a copy if aliased to classify) and return the
|
||||||
|
/// result. Throws on a governor-cap violation (from the pass) or a flood-guard violation.
|
||||||
|
/// </summary>
|
||||||
|
public static Result Apply(Pass2Result p2, TerrainGenConfig cfg)
|
||||||
|
{
|
||||||
|
ulong t0 = Time.GetTicksMsec();
|
||||||
|
var r = new Result();
|
||||||
|
int n = p2.MapSize;
|
||||||
|
float sea = cfg.SeaLevel;
|
||||||
|
|
||||||
|
// 1. THE SPLIT — never erode an array the classify field shares.
|
||||||
|
float[,] render = p2.Height;
|
||||||
|
if (p2.FieldsAreAliased)
|
||||||
|
{
|
||||||
|
render = (float[,])p2.Height.Clone();
|
||||||
|
r.Notes.Add("[Erosion] render and classify were aliased (curve off) — the render field was copied before eroding, as the reference allocated a separate classify array when erosion was on.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The governors, clamped as the reference's ConfigManager clamped them — reported, not silent.
|
||||||
|
int count = Math.Clamp(cfg.ErosionDropletCount, 0, 50_000_000);
|
||||||
|
int life = Math.Clamp(cfg.ErosionDropletLifetime, 1, 4096);
|
||||||
|
float carve = Math.Clamp(cfg.ErosionCarveCapM, 0f, 60f);
|
||||||
|
float deposit = Math.Clamp(cfg.ErosionDepositCapM, 0f, 60f);
|
||||||
|
float margin = Math.Clamp(cfg.ErosionSeaMarginM, 0f, 5f);
|
||||||
|
float inertia = Math.Clamp(cfg.ErosionInertia, 0f, 0.99f);
|
||||||
|
float evap = Math.Clamp(cfg.ErosionEvaporation, 0f, 0.5f);
|
||||||
|
if (count != cfg.ErosionDropletCount || life != cfg.ErosionDropletLifetime || carve != cfg.ErosionCarveCapM || deposit != cfg.ErosionDepositCapM
|
||||||
|
|| margin != cfg.ErosionSeaMarginM || inertia != cfg.ErosionInertia || evap != cfg.ErosionEvaporation)
|
||||||
|
r.Notes.Add($"[Erosion] ⚠ governor out of bounds — clamped: count {cfg.ErosionDropletCount}→{count}, lifetime {cfg.ErosionDropletLifetime}→{life}, carve {cfg.ErosionCarveCapM}→{carve} m, deposit {cfg.ErosionDepositCapM}→{deposit} m, margin {cfg.ErosionSeaMarginM}→{margin} m, inertia {cfg.ErosionInertia}→{inertia}, evaporation {cfg.ErosionEvaporation}→{evap}.");
|
||||||
|
|
||||||
|
// 3. THE CRATER EXCLUSION — inert: no crater ⇒ radius 0 ⇒ weight 1 everywhere.
|
||||||
|
float craterRadius = cfg.CraterRadius; // 0 in v2 until the crater task
|
||||||
|
float coreR = craterRadius * cfg.CraterErosionCore;
|
||||||
|
float featherR = craterRadius * cfg.CraterErosionFeather;
|
||||||
|
const float CRATER_CARVE_FACTOR = 0.80f; // the reference's carve radius factor
|
||||||
|
if (craterRadius > 0f && cfg.CraterErosionCore < CRATER_CARVE_FACTOR)
|
||||||
|
r.Notes.Add($"[Erosion] ⚠ CraterErosionCore {cfg.CraterErosionCore:F2} is inside the carve radius ({CRATER_CARVE_FACTOR:F2} × CraterRadius) — erosion will modify carve-authored terrain and the carve will amplify those deltas across the waterline (the reference's warning; live only once a crater exists).");
|
||||||
|
|
||||||
|
// 2. THE FLOOD GUARD — before.
|
||||||
|
r.WetBefore = CountWaterPixels(render, n, sea);
|
||||||
|
|
||||||
|
r.Params = new HydraulicErosion.Params
|
||||||
|
{
|
||||||
|
DropletCount = count, Lifetime = life, CarveCapM = carve, DepositCapM = deposit, SeaMarginM = margin,
|
||||||
|
BrushRadius = Math.Max(0, cfg.ErosionBrushRadius), Inertia = inertia, CapacityFactor = cfg.ErosionCapacity,
|
||||||
|
MinSlopeM = cfg.ErosionMinSlopeM, ErodeRate = cfg.ErosionErodeRate, DepositRate = cfg.ErosionDepositRate,
|
||||||
|
Evaporation = evap, Gravity = cfg.ErosionGravity,
|
||||||
|
CraterMode = cfg.CraterErosionFeatherMode ? HydraulicErosion.CRATER_MODE_FEATHER : HydraulicErosion.CRATER_MODE_FULL,
|
||||||
|
Seed = cfg.Seed + HydraulicErosion.SEED_OFFSET,
|
||||||
|
};
|
||||||
|
r.Stats = HydraulicErosion.Apply(render, n, null, sea, cfg.CraterCenterX, cfg.CraterCenterY, coreR, featherR, r.Params);
|
||||||
|
|
||||||
|
// 2. THE FLOOD GUARD — after. Any change refuses the generation.
|
||||||
|
r.WetAfter = CountWaterPixels(render, n, sea);
|
||||||
|
if (r.WetAfter != r.WetBefore)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"[ErosionPass] EROSION FLOOD-GUARD VIOLATION: render-map water pixels {r.WetBefore} -> {r.WetAfter}. Refusing to generate.");
|
||||||
|
|
||||||
|
r.Ms = Time.GetTicksMsec() - t0;
|
||||||
|
var st = r.Stats;
|
||||||
|
r.Notes.Add($"[Erosion] crater exclusion: {(craterRadius > 0f ? $"core {coreR:F0} px → feather {featherR:F0} px" : "INERT (no crater; weight 1 everywhere)")}.");
|
||||||
|
r.Notes.Add($"[Erosion] v1: {st.Spawned} droplets ({st.SkippedNoLand} skipped), {st.Steps:N0} steps, {r.Ms / 1000.0:F1}s wall. " +
|
||||||
|
$"Eroded {st.ErodedVolumeM3:F0} m³ over {st.ModifiedCells:N0} touched cells (max cell carve {st.MaxCellErosionM:F2} m vs cap {carve:F2} m), " +
|
||||||
|
$"deposited {st.DepositedVolumeM3:F0} m³ (max cell deposit {st.MaxCellDepositM:F2} m vs cap {deposit:F2} m). " +
|
||||||
|
$"Deaths: {st.DiedSea} sea / {st.DiedEdge} edge / {st.DiedDry} dry / {st.DiedLifetime} lifetime. " +
|
||||||
|
$"Water pixels {r.WetBefore:N0} -> {r.WetAfter:N0} (flood guard holds).");
|
||||||
|
|
||||||
|
r.Shaped = p2.WithHeight(render, r.Notes, r.Ms);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/ErosionPass.cs.uid
Normal file
1
Tools/Scripts/ErosionPass.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dlpkt4kkcqvg8
|
||||||
431
Tools/Scripts/ErosionTool.cs
Normal file
431
Tools/Scripts/ErosionTool.cs
Normal file
|
|
@ -0,0 +1,431 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/ErosionTool.cs.uid
Normal file
1
Tools/Scripts/ErosionTool.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://df3p1g6ktwij6
|
||||||
443
Tools/Scripts/FragGalleryTool.cs
Normal file
443
Tools/Scripts/FragGalleryTool.cs
Normal file
|
|
@ -0,0 +1,443 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE FRAG-4 SEED GALLERY (chat2/10) — RENDER-ONLY. Does the chat2/09 `frag_4` look generalize?
|
||||||
|
/// Every setting is FROZEN at frag_4 (pinned explicitly here, not left to a default), 2 anchor seeds +
|
||||||
|
/// 6 fresh seeds, at showpiece size, each with grayscale + .f32 + relief + the labeled-regions overlay,
|
||||||
|
/// and the hemisphere-split count/size table as the instrument. No knob, no ladder, no logic change.
|
||||||
|
///
|
||||||
|
/// ═══ RUNNING IT ═══
|
||||||
|
///
|
||||||
|
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
|
||||||
|
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/FragGalleryTool.tscn
|
||||||
|
///
|
||||||
|
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
|
||||||
|
/// ISLA_MAPSIZE gallery size (default 8192)
|
||||||
|
/// ISLA_CALIB_SIZE curve calibration size (default 2048)
|
||||||
|
/// ISLA_SEEDS the gallery seeds (default: the 2 anchors + 6 fresh below)
|
||||||
|
/// ISLA_SKIP_ANCHOR_CHECK=1 skip the 4096 bit-identity check against the 09 frag_4 dumps
|
||||||
|
/// </summary>
|
||||||
|
public partial class FragGalleryTool : Node
|
||||||
|
{
|
||||||
|
/// <summary>The two seeds frag_4 was judged on (chat2/09).</summary>
|
||||||
|
private static readonly int[] AnchorSeeds = { 1063685222, 999999937 };
|
||||||
|
|
||||||
|
/// <summary>Six fresh seeds, chosen BEFORE any render — constants, not picks: the date and five famous digit strings.</summary>
|
||||||
|
private static readonly int[] FreshSeeds = { 20260822, 31415926, 27182818, 16180339, 14142135, 17320508 };
|
||||||
|
|
||||||
|
/// <summary>⚠ Task 01's pool, verbatim — the curve's identity.</summary>
|
||||||
|
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
|
||||||
|
|
||||||
|
// ═══ THE FROZEN frag_4 SETTING — every value pinned explicitly (chat2/09 batch, level 4) ═══
|
||||||
|
private const float FrozenFragmentAmp = 0.5f;
|
||||||
|
private const float FrozenFragmentFreq = 12f;
|
||||||
|
private const float FrozenBandCentre = 0.66f;
|
||||||
|
private const float FrozenBandHalfWidth = 0.18f;
|
||||||
|
private const bool FrozenBitesOnly = false;
|
||||||
|
private const float FrozenStretch = 2f;
|
||||||
|
private const float FrozenBandStart = 0.70f;
|
||||||
|
private const float FrozenBandFeather = 0.05f;
|
||||||
|
private const bool FrozenStretchSinker = true;
|
||||||
|
private const float FrozenSpeckFrac = 2.5e-7f; // 09's low speck revert (≈ 4 cells at 4096, ≈ 17 at 8192)
|
||||||
|
|
||||||
|
private const int DefaultMapSize = 8192;
|
||||||
|
private const int DefaultCalibSize = 2048;
|
||||||
|
private const int AnchorCheckSize = 4096; // the 09 batch's size
|
||||||
|
|
||||||
|
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 HemiStats
|
||||||
|
{
|
||||||
|
public int All, Big; public long Min, Med, Max; public double Mean; public int[] Hist; public long[] Largest = Array.Empty<long>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Row
|
||||||
|
{
|
||||||
|
public int Seed; public bool Anchor;
|
||||||
|
public HemiStats N, S; public long MainlandCells; public double MainlandFrac; public int SpecksReverted;
|
||||||
|
public string Read; public bool Ok; public ulong Ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Run()
|
||||||
|
{
|
||||||
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
|
||||||
|
int task = EnvInt("ISLA_TASK", 10);
|
||||||
|
string descr = EnvStr("ISLA_BATCH", "frag4_seed_gallery");
|
||||||
|
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
|
||||||
|
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
|
||||||
|
int[] seedsEnv = EnvSeeds("ISLA_SEEDS", null);
|
||||||
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
|
bool skipAnchor = EnvStr("ISLA_SKIP_ANCHOR_CHECK", "0") == "1";
|
||||||
|
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
|
||||||
|
string t08Source = EnvStr("ISLA_T08_SOURCE", "08_southern_stretch_explore");
|
||||||
|
string t09Source = EnvStr("ISLA_T09_SOURCE", "09_coastal_fragment");
|
||||||
|
|
||||||
|
var seeds = new List<int>(AnchorSeeds); if (seedsEnv == null) seeds.AddRange(FreshSeeds); else { seeds.Clear(); seeds.AddRange(seedsEnv); }
|
||||||
|
var anchorSet = new HashSet<int>(AnchorSeeds);
|
||||||
|
|
||||||
|
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
|
||||||
|
|
||||||
|
var anchors = CurveAnchors.Default;
|
||||||
|
float sea = 0.15f;
|
||||||
|
long big = Cells(RegionPass.ThresholdMidFrac, mapSize);
|
||||||
|
long speckCells = Cells(FrozenSpeckFrac, mapSize);
|
||||||
|
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print(" FRAG-4 SEED GALLERY (chat2/10) — render-only: does frag_4 generalize?");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize} (offshore off)");
|
||||||
|
GD.Print($"seeds : anchors {string.Join(", ", AnchorSeeds)} · fresh {string.Join(", ", seeds.FindAll(s => !anchorSet.Contains(s)))}");
|
||||||
|
GD.Print($"FROZEN : FragmentAmp {FrozenFragmentAmp} · freq {FrozenFragmentFreq}/map · window {FrozenBandCentre} ± {FrozenBandHalfWidth} · bitesOnly {FrozenBitesOnly} · " +
|
||||||
|
$"stretch {FrozenStretch} (band {FrozenBandStart}/{FrozenBandFeather}, sinker stretched {FrozenStretchSinker}) · speck revert < {speckCells} cells ({FrozenSpeckFrac:G2}) · offshore OFF · shelf OFF · labeling ON");
|
||||||
|
GD.Print($"\"big\" : ≥ {big:N0} cells at {mapSize} (the 07 mid threshold)");
|
||||||
|
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 Frozen(int size, int seed, string label, bool frag = true, bool revert = true, bool stretch = true) => new TerrainGenConfig
|
||||||
|
{
|
||||||
|
MapSize = size, Seed = seed, VariantLabel = label,
|
||||||
|
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||||||
|
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
||||||
|
CoastShelf = false, Offshore = new OffshoreSettings(),
|
||||||
|
RegionLabeling = true, SpeckRevert = revert, MinLandComponentFrac = FrozenSpeckFrac,
|
||||||
|
SouthStretch = stretch ? FrozenStretch : 0f, SouthBandStartFrac = FrozenBandStart, SouthBandFeatherFrac = FrozenBandFeather, StretchSinker = FrozenStretchSinker,
|
||||||
|
FragmentAmp = frag ? FrozenFragmentAmp : 0f, FragmentFreqPerMapWidth = FrozenFragmentFreq,
|
||||||
|
FragmentBandCentre = FrozenBandCentre, FragmentBandHalfWidth = FrozenBandHalfWidth, FragmentBitesOnly = FrozenBitesOnly,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══ 1. THE ORACLE — no code change, same setting ═══
|
||||||
|
GD.Print($"\n--- 1. ORACLE: the setting is the 09 frag_4 setting, and nothing upstream moved ---");
|
||||||
|
var hard = new List<ShapingOracle.Check>();
|
||||||
|
{
|
||||||
|
var offCfg = Frozen(calibSize, AnchorSeeds[0], "off", frag: false, revert: false, stretch: false);
|
||||||
|
Pass1Result p1 = Topography.Generate(offCfg);
|
||||||
|
var curveOff = offCfg.Clone(); curveOff.Curve = false;
|
||||||
|
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{AnchorSeeds[0]}_full", "height.f32");
|
||||||
|
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, stretch OFF, frag OFF == Phase-1 .f32 dump (the curve is untouched)", Shaping.Shape(p1, curveOff).Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
|
||||||
|
|
||||||
|
if (!skipAnchor)
|
||||||
|
{
|
||||||
|
foreach (int seed in AnchorSeeds)
|
||||||
|
{
|
||||||
|
// ⭐ a9 — the frozen setting at the 09 batch's size reproduces the 09 frag_4 field bit for bit.
|
||||||
|
string t09Dump = Path.Combine(ToolingPaths.BatchesRoot, t09Source, $"{seed}_frag_4", "height.f32");
|
||||||
|
var c9 = Frozen(AnchorCheckSize, seed, "frag_4");
|
||||||
|
Pass1Result q9p = Topography.Generate(c9);
|
||||||
|
Pass2Result q9 = Shaping.Shape(q9p, c9);
|
||||||
|
hard.Add(ShapingOracle.DumpRegression("a9", $"frozen frag_4 at {AnchorCheckSize} == task-09 frag_4 dump [{seed}] (no code change, same setting)", q9.Height, HeightField.Load(t09Dump, AnchorCheckSize), AnchorCheckSize, t09Dump));
|
||||||
|
|
||||||
|
// a8 — the stretch-2, frag-off baseline still equals the 08 field, and the interior is still locked against it.
|
||||||
|
string t08Dump = Path.Combine(ToolingPaths.BatchesRoot, t08Source, $"{seed}_stretch_3", "height.f32");
|
||||||
|
var c8 = Frozen(AnchorCheckSize, seed, "t08", frag: false, revert: false);
|
||||||
|
Pass1Result q8p = Topography.Generate(c8);
|
||||||
|
hard.Add(ShapingOracle.DumpRegression("a8", $"frag OFF, stretch 2 at {AnchorCheckSize} == task-08 stretch_3 dump [{seed}]", Shaping.Shape(q8p, c8).Height, HeightField.Load(t08Dump, AnchorCheckSize), AnchorCheckSize, t08Dump));
|
||||||
|
var r = ShapingOracle.InteriorLocked(q8p, q9p, FrozenBandCentre, FrozenBandHalfWidth); r.Name += $" [{seed}, {AnchorCheckSize}]"; hard.Add(r);
|
||||||
|
}
|
||||||
|
// determinism at the check size
|
||||||
|
var a = Topography.Generate(Frozen(AnchorCheckSize, AnchorSeeds[0], "det"));
|
||||||
|
var b = Topography.Generate(Frozen(AnchorCheckSize, AnchorSeeds[0], "det"));
|
||||||
|
var det = ShapingOracle.LabelsDeterministic(a, b); det.Name += $" [{AnchorSeeds[0]}, {AnchorCheckSize}]"; hard.Add(det);
|
||||||
|
}
|
||||||
|
foreach (var c in hard) GD.Print(" " + c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 2. THE GALLERY ═══
|
||||||
|
GD.Print($"\n--- 2. THE GALLERY at {mapSize} ---");
|
||||||
|
var rows = new List<Row>();
|
||||||
|
var perSeed = new List<ShapingOracle.Check>();
|
||||||
|
foreach (int seed in seeds)
|
||||||
|
{
|
||||||
|
var cfg = Frozen(mapSize, seed, "frag_4");
|
||||||
|
Pass1Result p1 = Topography.Generate(cfg);
|
||||||
|
Pass2Result p2 = Shaping.Shape(p1, cfg);
|
||||||
|
var checks = new List<ShapingOracle.Check>
|
||||||
|
{
|
||||||
|
ShapingOracle.CentreIsLand(p1),
|
||||||
|
ShapingOracle.TagCoastlineConsistent(p2, sea),
|
||||||
|
ShapingOracle.ClassifyFidelity(p1, p2),
|
||||||
|
};
|
||||||
|
foreach (var c in checks) { c.Name += $" [{seed}]"; perSeed.Add(c); }
|
||||||
|
bool ok = checks.TrueForAll(c => c.Passed);
|
||||||
|
|
||||||
|
var (n, s) = Stats(p1.Regions, big);
|
||||||
|
long landCells = p1.Regions.LandCells;
|
||||||
|
var row = new Row
|
||||||
|
{
|
||||||
|
Seed = seed, Anchor = anchorSet.Contains(seed), N = n, S = s,
|
||||||
|
MainlandCells = p1.Regions.Mainland.SizeCells, MainlandFrac = landCells == 0 ? 0 : p1.Regions.Mainland.SizeCells / (double)landCells,
|
||||||
|
SpecksReverted = p1.RegionLedger?.RevertedComponents ?? 0, Ok = ok, Ms = p1.ElapsedMs,
|
||||||
|
};
|
||||||
|
row.Read = AutoRead(row);
|
||||||
|
rows.Add(row);
|
||||||
|
WriteSeed(batchRoot, p1, p2, sea, anchors, skipRaw);
|
||||||
|
GD.Print($" seed {seed,-11}{(row.Anchor ? " ⭐" : " ")} N {n.All,3}/{n.Big,3} med {n.Med,6} largest {Largest(n.Largest)} S {s.All,3}/{s.Big,3} med {s.Med,6} largest {Largest(s.Largest)} mainland {row.MainlandCells:N0} ({row.MainlandFrac:P1} of land) → {row.Read} {(ok ? "ok" : "⚠ CHECK FAILED")} {p1.ElapsedMs} ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
WriteTable(batchRoot, mapSize, rows, big, speckCells);
|
||||||
|
WriteIndex(batchRoot, mapSize, calibSize, rows, big, speckCells, 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 instrument --------------------------------------------------
|
||||||
|
|
||||||
|
private static long Cells(float frac, int size) => Math.Max(1L, (long)Math.Round(frac * (double)size * size));
|
||||||
|
|
||||||
|
private static (HemiStats north, HemiStats south) Stats(RegionLabels l, long big)
|
||||||
|
{
|
||||||
|
var n = new List<long>(); var s = new List<long>();
|
||||||
|
foreach (var r in l.Regions)
|
||||||
|
{
|
||||||
|
if (r.IsMainland) continue;
|
||||||
|
if (r.Hemisphere == RegionLabeling.HemiSouth) s.Add(r.SizeCells); else n.Add(r.SizeCells);
|
||||||
|
}
|
||||||
|
return (Make(n, big), Make(s, big));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HemiStats Make(List<long> sizes, long big)
|
||||||
|
{
|
||||||
|
sizes.Sort();
|
||||||
|
var h = new HemiStats { All = sizes.Count, Hist = new int[RegionLabeling.HistogramEdges.Length + 1] };
|
||||||
|
if (sizes.Count == 0) return h;
|
||||||
|
double sum = 0;
|
||||||
|
foreach (long v in sizes) { h.Hist[RegionLabeling.HistogramBin(v)]++; if (v >= big) h.Big++; sum += v; }
|
||||||
|
h.Min = sizes[0]; h.Med = sizes[sizes.Count / 2]; h.Max = sizes[^1]; h.Mean = sum / sizes.Count;
|
||||||
|
int k = Math.Min(3, sizes.Count); h.Largest = new long[k];
|
||||||
|
for (int i = 0; i < k; i++) h.Largest[i] = sizes[sizes.Count - 1 - i];
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one-word NUMERIC read — a stated rule, not a judgement: "too solid" if fewer than 12 big
|
||||||
|
/// (≥ the 07 mid threshold) islands detached across both hemispheres (the 09 anchors had 23–27);
|
||||||
|
/// "shredded" if fewer than a quarter of the islands are big (all specks, no bigs); else "good
|
||||||
|
/// spread". The eye's read is in the report.
|
||||||
|
/// </summary>
|
||||||
|
private static string AutoRead(Row r)
|
||||||
|
{
|
||||||
|
int all = r.N.All + r.S.All, bigs = r.N.Big + r.S.Big;
|
||||||
|
if (bigs < 12) return "too solid";
|
||||||
|
if (all > 0 && bigs < all * 0.25) return "shredded";
|
||||||
|
return "good spread";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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 WriteSeed(string batchRoot, Pass1Result p1, Pass2Result p2, float sea, CurveAnchors anchors, bool skipRaw)
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(batchRoot, $"{p2.Seed}");
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||||||
|
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 = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
|
||||||
|
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f, HillshadeStrength = 0.30f, SeaLevel = sea,
|
||||||
|
};
|
||||||
|
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
|
||||||
|
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, $"FRAG_4 {p2.Seed}").SavePng(Path.Combine(dir, "relief.png"));
|
||||||
|
var led = p1.RegionLedger;
|
||||||
|
RegionOverlayRenderer.SavePng(p1.Regions, led != null && led.RevertOn ? p1.RegionsPre : null, p1.MapSize,
|
||||||
|
led?.RevertedComponents ?? 0, led?.ThresholdCells ?? 0, Path.Combine(dir, "regions.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HistRow(int[] h)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < h.Length; i++) { if (i > 0) sb.Append(" · "); sb.Append(h[i]); }
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Largest(long[] l) => l.Length == 0 ? "—" : string.Join(" / ", Array.ConvertAll(l, v => v.ToString("N0")));
|
||||||
|
|
||||||
|
private static string TableMarkdown(List<Row> rows, long big)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var histHead = new StringBuilder();
|
||||||
|
for (int i = 0; i <= RegionLabeling.HistogramEdges.Length; i++) { if (i > 0) histHead.Append(" · "); histHead.Append(RegionLabeling.HistogramLabel(i)); }
|
||||||
|
sb.AppendLine($"| Seed | read | **N islands all / ≥ {big:N0}** | N median / largest three | N histogram ({histHead}) | **S islands all / ≥ {big:N0}** | S median / largest three | S histogram | mainland cells (% of land) | specks reverted | oracle |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (var r in rows)
|
||||||
|
sb.AppendLine($"| `{r.Seed}`{(r.Anchor ? " ⭐ anchor" : "")} | **{r.Read}** | **{r.N.All} / {r.N.Big}** | {r.N.Med} / {Largest(r.N.Largest)} | {HistRow(r.N.Hist)} | **{r.S.All} / {r.S.Big}** | {r.S.Med} / {Largest(r.S.Largest)} | {HistRow(r.S.Hist)} | {r.MainlandCells:N0} ({r.MainlandFrac:P1}) | {r.SpecksReverted} | {(r.Ok ? "pass" : "**FAIL**")} |");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteTable(string batchRoot, int mapSize, List<Row> rows, long big, long speckCells)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"# The count/size table — frag_4 frozen, {rows.Count} seeds at {mapSize}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Islands = non-mainland 8-connected land components of the classify field; hemisphere by centroid; \"big\" = ≥ {big:N0} cells; speck revert < {speckCells} cells.");
|
||||||
|
sb.AppendLine("Read rule (numeric, stated): **too solid** if < 12 big islands across both hemispheres · **shredded** if big < 25 % of all · else **good spread**. The eye's read is in the report.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(TableMarkdown(rows, big));
|
||||||
|
WriteText(Path.Combine(batchRoot, "count_size_table.md"), sb.ToString());
|
||||||
|
|
||||||
|
var csv = new StringBuilder();
|
||||||
|
csv.AppendLine("seed,anchor,read,n_all,n_big,n_med,n_mean,n_max,n_largest,n_hist,s_all,s_big,s_med,s_mean,s_max,s_largest,s_hist,mainland_cells,mainland_frac,specks_reverted,oracle,ms");
|
||||||
|
var ic = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
|
foreach (var r in rows)
|
||||||
|
csv.AppendLine(string.Join(",", r.Seed, r.Anchor ? 1 : 0, r.Read,
|
||||||
|
r.N.All, r.N.Big, r.N.Med, r.N.Mean.ToString("F1", ic), r.N.Max, "\"" + Largest(r.N.Largest) + "\"", "\"" + HistRow(r.N.Hist) + "\"",
|
||||||
|
r.S.All, r.S.Big, r.S.Med, r.S.Mean.ToString("F1", ic), r.S.Max, "\"" + Largest(r.S.Largest) + "\"", "\"" + HistRow(r.S.Hist) + "\"",
|
||||||
|
r.MainlandCells, r.MainlandFrac.ToString("F4", ic), r.SpecksReverted, r.Ok ? "pass" : "FAIL", r.Ms));
|
||||||
|
WriteText(Path.Combine(batchRoot, "count_size_table.csv"), csv.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, List<Row> rows, long big, long speckCells,
|
||||||
|
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perSeed, bool allOk)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"# Batch 10 — frag_4 seed gallery: does the look generalize? (render-only, {mapSize})");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("**A contact sheet, not a tune.** Every plate is the SAME setting — chat2/09's `frag_4`, frozen — across the two");
|
||||||
|
sb.AppendLine("seeds it was judged on (⭐ anchors) and six fresh seeds chosen before any render. The question: does a coherent");
|
||||||
|
sb.AppendLine("mainland with medium/large lobes detaching all around generalize, or do some seeds come out too solid or shredded?");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The frozen setting");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"`FragmentAmp {FrozenFragmentAmp}` · `FragmentFreqPerMapWidth {FrozenFragmentFreq}` · window `{FrozenBandCentre} ± {FrozenBandHalfWidth}` · bites-only `{FrozenBitesOnly}` · " +
|
||||||
|
$"`SouthStretch {FrozenStretch}` (band `{FrozenBandStart}` / feather `{FrozenBandFeather}`, sinker stretched `{FrozenStretchSinker}`) · speck revert < {speckCells} cells (`{FrozenSpeckFrac:G2}` of the map) · " +
|
||||||
|
"offshore OFF · shelf OFF · region labeling ON · the tagged curve (calibrated on task 01's pool at " + calibSize + "). Pinned explicitly in `FragGalleryTool` — nothing is left to a default.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⭐ The contact sheet");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Open each seed's `regions.png` (the key view — detached pieces in colour) beside its `relief.png`.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Seed | read | overlay | relief | N islands all / big | S islands all / big | largest N / S piece |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|");
|
||||||
|
foreach (var r in rows)
|
||||||
|
sb.AppendLine($"| `{r.Seed}`{(r.Anchor ? " ⭐ anchor" : "")} | **{r.Read}** | [`{r.Seed}/regions.png`]({r.Seed}/regions.png) | [`{r.Seed}/relief.png`]({r.Seed}/relief.png) | {r.N.All} / {r.N.Big} | {r.S.All} / {r.S.Big} | {r.N.Max:N0} / {r.S.Max:N0} |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"## ⭐ The count/size table — hemisphere-split, with the size distribution");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(TableMarkdown(rows, big));
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Read rule (numeric, stated): **too solid** if < 12 big islands across both hemispheres (the anchors had 23–27 at 4096) · **shredded** if big < 25 % of all · else **good spread**. The eye's read is in the report. Also as `count_size_table.md` / `.csv`.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("`ProvisionalEven`, flagged. The grayscale is the honest instrument; the overlay is the region layer's data.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The oracle (render-only: the setting is the 09 setting, nothing upstream moved)");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(ShapingOracle.ToMarkdownTable(hard));
|
||||||
|
sb.AppendLine("Per seed (centre-is-land m · tag/coastline k · classify b):");
|
||||||
|
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("| `regions.png`, `relief.png`, `INDEX.md`, `count_size_table.md` / `.csv` | **keep** |");
|
||||||
|
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
|
||||||
|
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + the frozen setting — large (256 MB each), clear freely |");
|
||||||
|
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Gallery at {mapSize}, curve calibrated at {calibSize} with offshore off. {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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/FragGalleryTool.cs.uid
Normal file
1
Tools/Scripts/FragGalleryTool.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b484d8yc32y3c
|
||||||
|
|
@ -4,17 +4,27 @@ namespace IslaApocalypse.Tools
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shape helpers for the island mask, ported from the reference's
|
/// Shape helpers for the island mask, ported from the reference's
|
||||||
/// <c>Tools/Scripts/IslandFalloff.cs</c>.
|
/// <c>Tools/Scripts/IslandFalloff.cs</c> — now the WHOLE file, in three parts:
|
||||||
///
|
///
|
||||||
/// ⚠ ONLY THE PASS-1 PARTS ARE HERE. The reference file also carries the submarine COAST SHELF
|
/// 1. the spine crest (<see cref="SmoothAbs"/>) — Phase 1, chat1
|
||||||
/// (SHELF_STRENGTH / SHELF_SCALE_M / CoastShelf) and the OFFSHORE ISLET layer (OffshoreBlob,
|
/// 2. the submarine COAST SHELF (<see cref="CoastShelf"/>) — chat2/05 stage 1
|
||||||
/// OffshoreZoneWeight, CalibrateThreshold, and their constants). Both are DEFERRED to Phase 2 —
|
/// 3. the OFFSHORE ISLET layer (<see cref="OffshoreBlob"/>,
|
||||||
/// they act on below-sea height and are judged once water renders. They will port into THIS
|
/// <see cref="OffshoreZoneWeight"/>, <see cref="CalibrateThreshold"/>) — chat2/05 stage 1
|
||||||
/// file, which is why it keeps the reference's name and shape.
|
/// + the RESHAPE helper (<see cref="RigidBlob"/>) — stage 2
|
||||||
|
///
|
||||||
|
/// The faithful functions keep the reference's names, constants and arithmetic verbatim (D-050);
|
||||||
|
/// the parameterized overloads beside them exist so the reshape can move a dial without touching
|
||||||
|
/// the faithful path — the faithful overload CALLS the parameterized one with the reference's
|
||||||
|
/// constants, so the two cannot drift apart.
|
||||||
|
///
|
||||||
|
/// The reference's own separability argument (verbatim): "Every one of these is monotone in the
|
||||||
|
/// sign of (sea − height): none of them can turn water into land or land into water ON ITS OWN."
|
||||||
|
/// That holds for the shelf, which is why it is invisible until water renders. ⚠ It does NOT hold
|
||||||
|
/// for the islets, which exist precisely to turn water into land — see the note on
|
||||||
|
/// <see cref="OffshoreBlob"/>.
|
||||||
///
|
///
|
||||||
/// This type is pure math and engine-free. It sits in Tools/ rather than Core/ so the pass-1
|
/// This type is pure math and engine-free. It sits in Tools/ rather than Core/ so the pass-1
|
||||||
/// port stays auditable as one unit against one reference file, and so the deferred Phase-2
|
/// port stays auditable as one unit against one reference file.
|
||||||
/// halves land beside their siblings rather than in a second location.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class IslandFalloff
|
public static class IslandFalloff
|
||||||
{
|
{
|
||||||
|
|
@ -47,5 +57,187 @@ namespace IslaApocalypse.Tools
|
||||||
float a = Math.Abs(d);
|
float a = Math.Abs(d);
|
||||||
return a * a / MathF.Sqrt(a * a + epsilon * epsilon);
|
return a * a / MathF.Sqrt(a * a + epsilon * epsilon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// 2. THE COAST SHELF — chat2/05 stage 1, faithful (reference ~:48-62)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
//
|
||||||
|
// depth' = depth · (1 − STRENGTH · exp(−depth / SCALE_M))
|
||||||
|
//
|
||||||
|
// The height curve is identity at and below sea, so it never reached the seabed. Measured
|
||||||
|
// on the reference: land rises from the shoreline at 0.038 m/px while the seabed drops at
|
||||||
|
// 0.258 m/px — a shelf on the land side and a ramp on the sea side. This compresses shallow
|
||||||
|
// depth so the shallows extend much further out, leaving deep water and the Trench alone.
|
||||||
|
// At the shoreline the seabed starts at (1 − STRENGTH) = 22.5 % of its former gradient.
|
||||||
|
//
|
||||||
|
// C^∞ everywhere and STRICTLY POSITIVE for positive depth — in exact arithmetic it cannot
|
||||||
|
// move the waterline by one pixel. ⚠ In float32 it can (see the call site's BitDecrement
|
||||||
|
// clamp), which is why "cannot" is enforced at the call site and not assumed here.
|
||||||
|
//
|
||||||
|
// ⚠ INVISIBLE UNTIL WATER RENDERS. Nothing in Phase 2's hypsometric plates shows it; it is
|
||||||
|
// ported faithfully now, wired in now, and judged when the water pass lands.
|
||||||
|
|
||||||
|
/// <summary>Reference: 0 = off, →1 = a flat lagoon.</summary>
|
||||||
|
public const float SHELF_STRENGTH = 0.775f;
|
||||||
|
|
||||||
|
/// <summary>Reference: metres of depth over which the shelf relaxes back to the raw seabed.</summary>
|
||||||
|
public const float SHELF_SCALE_M = 100f;
|
||||||
|
|
||||||
|
/// <summary>Remaps a positive depth in metres. Returns the new depth in metres. THE FAITHFUL FORM.</summary>
|
||||||
|
public static float CoastShelf(float depthMetres)
|
||||||
|
=> CoastShelf(depthMetres, SHELF_STRENGTH, SHELF_SCALE_M);
|
||||||
|
|
||||||
|
/// <summary>The parameterized form. With the reference constants it IS the reference — same floats, same order.</summary>
|
||||||
|
public static float CoastShelf(float depthMetres, float strength, float scaleM)
|
||||||
|
{
|
||||||
|
if (depthMetres <= 0f) return depthMetres;
|
||||||
|
return depthMetres * (1f - strength * MathF.Exp(-depthMetres / scaleM));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// 3. THE OFFSHORE ISLETS — chat2/05 stage 1, faithful (reference ~:64-142)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
//
|
||||||
|
// Islets are placed by LERPING the seabed TOWARD a target height, not by adding to it, so
|
||||||
|
// they surface at any ambient depth instead of only where the seafloor happens to be shallow.
|
||||||
|
//
|
||||||
|
// ⚠⚠ THIS IS THE ONE LAYER IN PASS 1 THAT TURNS WATER INTO LAND. Every other shaping element
|
||||||
|
// is monotone in (sea − height). Islets add above-sea land, which means they CHANGE
|
||||||
|
// CLASSIFICATION — new land is new biome/water pixels downstream. That is exactly why they
|
||||||
|
// belong in the base shape before classification runs: tweaking an island dial later and
|
||||||
|
// regenerating re-runs classification consistently. It is a known property, not a surprise.
|
||||||
|
// → chat2/05 report, "modularity".
|
||||||
|
|
||||||
|
/// <summary>Reference: ~585 px blobs at 8K — few and sizeable, not a scatter of 50 px debris.</summary>
|
||||||
|
public const float OFFSHORE_FREQ_ISLANDS = 14f;
|
||||||
|
|
||||||
|
/// <summary>Reference: the islet noise field's seed offset. A SEED offset, not a coordinate offset.</summary>
|
||||||
|
public const int OFFSHORE_SEED_OFFSET = 7607;
|
||||||
|
|
||||||
|
/// <summary>Reference: target crest, metres above sea, PRE-CURVE. The curve's toe squashes it lower.</summary>
|
||||||
|
public const float OFFSHORE_ISLAND_H_M = 34f;
|
||||||
|
|
||||||
|
/// <summary>Reference: fraction of a blob's excess over threshold that saturates to full weight.</summary>
|
||||||
|
public const float OFFSHORE_CORE = 0.45f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE MOAT. Reference: the raise is EXACTLY zero wherever the ambient water is shallower
|
||||||
|
/// than this, so the ring of water between the mainland shore and any islet cannot be
|
||||||
|
/// bridged — a continuous path from shore to islet must cross this depth contour, and every
|
||||||
|
/// pixel on it is untouched water.
|
||||||
|
/// </summary>
|
||||||
|
public const float OFFSHORE_MIN_DEPTH_M = 14f;
|
||||||
|
|
||||||
|
/// <summary>Reference: the moat's feather width, metres.</summary>
|
||||||
|
public const float OFFSHORE_DEPTH_FEATHER_M = 10f;
|
||||||
|
|
||||||
|
/// <summary>Reference: the Trench mask — zone fades from INNER to zero at OUTER (the Trench ramp starts at 0.90).</summary>
|
||||||
|
public const float OFFSHORE_TRENCH_INNER = 0.78f;
|
||||||
|
public const float OFFSHORE_TRENCH_OUTER = 0.86f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE "ACTUALLY OFFSHORE" TEST. Reference: depth alone is not enough — a deep LAKE or the
|
||||||
|
/// carved crater bay is also below sea level, and islets have no business in either. The
|
||||||
|
/// pre-Trench falloff is the honest discriminator: the mainland coast sits near f = 0.66, and
|
||||||
|
/// inland water is far below that whatever the axis ratios are, because elongation moves
|
||||||
|
/// WHERE a given f occurs, not the f at which land ends.
|
||||||
|
/// → this is precisely why <c>Pass1Result.PreTrenchFalloff</c> exists.
|
||||||
|
/// </summary>
|
||||||
|
public const float OFFSHORE_MIN_FALLOFF = 0.72f;
|
||||||
|
|
||||||
|
/// <summary>Reference: the falloff test's feather width.</summary>
|
||||||
|
public const float OFFSHORE_FALLOFF_FEATHER = 0.06f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Blob weight in [0,1] for one ocean column. THE FAITHFUL FORM.
|
||||||
|
///
|
||||||
|
/// ⚠ <paramref name="threshold"/> comes from <see cref="CalibrateThreshold"/>, NOT from the
|
||||||
|
/// density directly. Reference: "Simplex output is concentrated well inside [−1,1] (in
|
||||||
|
/// practice it rarely passes ±0.87), so treating density as a fraction of the theoretical
|
||||||
|
/// range produces a threshold almost nothing clears. That bug shipped in the first task-11
|
||||||
|
/// build and raised 171 pixels on the whole map, none of them above sea."
|
||||||
|
/// </summary>
|
||||||
|
public static float OffshoreBlob(float noise01, float threshold)
|
||||||
|
=> OffshoreBlob(noise01, threshold, OFFSHORE_CORE);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The parameterized form. <paramref name="coreFraction"/> is the fraction of the excess
|
||||||
|
/// over threshold that saturates: SMALLER ⇒ more of the blob at full weight ⇒ FLATTER top
|
||||||
|
/// and a sharper base. (The reshape's "flatter" lever lowers this, not raises it.)
|
||||||
|
/// </summary>
|
||||||
|
public static float OffshoreBlob(float noise01, float threshold, float coreFraction)
|
||||||
|
{
|
||||||
|
if (noise01 <= threshold) return 0f;
|
||||||
|
float core = MathF.Max((1f - threshold) * coreFraction, 1e-4f);
|
||||||
|
float k = Math.Clamp((noise01 - threshold) / core, 0f, 1f);
|
||||||
|
return k * k * (3f - 2f * k);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The noise value that <paramref name="density"/> of <paramref name="samples"/> exceed.
|
||||||
|
/// Sorts a COPY, so the caller's array is left alone. Verbatim.
|
||||||
|
/// </summary>
|
||||||
|
public static float CalibrateThreshold(float[] samples, float density)
|
||||||
|
{
|
||||||
|
if (samples.Length == 0 || density <= 0f) return 1f;
|
||||||
|
float[] s = (float[])samples.Clone();
|
||||||
|
Array.Sort(s);
|
||||||
|
int idx = (int)((1f - Math.Clamp(density, 0f, 1f)) * (s.Length - 1));
|
||||||
|
return s[Math.Clamp(idx, 0, s.Length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How much of the blob is allowed here: zero in shallow water near the mainland (the moat),
|
||||||
|
/// zero anywhere not genuinely outside the island body, zero in and near the Trench ramp,
|
||||||
|
/// full in the open ocean between. THE FAITHFUL FORM.
|
||||||
|
/// </summary>
|
||||||
|
public static float OffshoreZoneWeight(float ambientDepthMetres, float preTrenchFalloff,
|
||||||
|
float distX01, float distY01)
|
||||||
|
=> OffshoreZoneWeight(ambientDepthMetres, preTrenchFalloff, distX01, distY01,
|
||||||
|
OFFSHORE_MIN_DEPTH_M, OFFSHORE_DEPTH_FEATHER_M,
|
||||||
|
OFFSHORE_MIN_FALLOFF, OFFSHORE_FALLOFF_FEATHER,
|
||||||
|
OFFSHORE_TRENCH_INNER, OFFSHORE_TRENCH_OUTER);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The parameterized form. ⚠ <paramref name="distX01"/>/<paramref name="distY01"/> are
|
||||||
|
/// MAP-anchored (|x − cx| / halfSpan, no axis ratio) — the same normalization the Trench
|
||||||
|
/// itself uses, because the mask's job is to stay off the Trench, not off the island ellipse.
|
||||||
|
/// </summary>
|
||||||
|
public static float OffshoreZoneWeight(float ambientDepthMetres, float preTrenchFalloff,
|
||||||
|
float distX01, float distY01,
|
||||||
|
float minDepthM, float depthFeatherM, float minFalloff, float falloffFeather,
|
||||||
|
float trenchInner, float trenchOuter)
|
||||||
|
{
|
||||||
|
if (ambientDepthMetres < minDepthM) return 0f;
|
||||||
|
if (preTrenchFalloff < minFalloff) return 0f;
|
||||||
|
|
||||||
|
float w = Math.Clamp((ambientDepthMetres - minDepthM) / depthFeatherM, 0f, 1f);
|
||||||
|
w *= Math.Clamp((preTrenchFalloff - minFalloff) / falloffFeather, 0f, 1f);
|
||||||
|
|
||||||
|
float d = MathF.Max(distX01, distY01);
|
||||||
|
if (d >= trenchOuter) return 0f;
|
||||||
|
if (d > trenchInner)
|
||||||
|
w *= 1f - (d - trenchInner) / (trenchOuter - trenchInner);
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// 3b. THE RESHAPE HELPER — chat2/05 stage 2. Not in the reference. (The seeded-floor
|
||||||
|
// stamp that sat beside it was reverted out in chat2/06 — git history has it.)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The reshaped organic blob: the faithful smoothstep, then its weight pushed toward
|
||||||
|
/// saturation by <paramref name="edgeSharpness"/> — <c>1 − (1 − w)^sharpness</c>. At
|
||||||
|
/// sharpness 1 this IS <see cref="OffshoreBlob"/>. Higher values keep the same footprint but
|
||||||
|
/// make the top flatter and the crest-to-sea transition narrower: a distinct flat-topped
|
||||||
|
/// landmass instead of a gentle noise bump. C¹ at both ends, so it cannot alias.
|
||||||
|
/// </summary>
|
||||||
|
public static float RigidBlob(float noise01, float threshold, float coreFraction, float edgeSharpness)
|
||||||
|
{
|
||||||
|
float w = OffshoreBlob(noise01, threshold, coreFraction);
|
||||||
|
if (w <= 0f || edgeSharpness <= 1f) return w;
|
||||||
|
return 1f - MathF.Pow(1f - w, edgeSharpness);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,42 @@ namespace IslaApocalypse.Tools
|
||||||
FieldsPooled++;
|
FieldsPooled++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pool one field's samples in, but only where a SECOND field clears a threshold — "the
|
||||||
|
/// output heights of the cells whose raw height is above the climb's ceiling".
|
||||||
|
///
|
||||||
|
/// ⚠ The gate is a different field from the values. That is the whole point: chat2/03
|
||||||
|
/// calibrates the climb against the staircase's OUTPUT distribution restricted to
|
||||||
|
/// ABOVE-CEILING land, and "above the ceiling" is a fact about the RAW height. Gating on the
|
||||||
|
/// values themselves would select a different population — output above the ceiling includes
|
||||||
|
/// nothing extra here, but only because the curve is monotone, and relying on that silently
|
||||||
|
/// would break the moment a caller gated a non-monotone pair.
|
||||||
|
///
|
||||||
|
/// The sea test still applies to the VALUES, so this stays a land histogram.
|
||||||
|
/// </summary>
|
||||||
|
public void AccumulateWhere(float[,] field, float[,] gate, int mapSize, float gateAbove)
|
||||||
|
{
|
||||||
|
for (int x = 0; x < mapSize; x++)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < mapSize; y++)
|
||||||
|
{
|
||||||
|
if (gate[x, y] <= gateAbove) continue;
|
||||||
|
|
||||||
|
float h = field[x, y];
|
||||||
|
if (h <= SeaLevel) continue;
|
||||||
|
|
||||||
|
TotalLand++;
|
||||||
|
if (h < MinLand) MinLand = h;
|
||||||
|
if (h > MaxLand) MaxLand = h;
|
||||||
|
|
||||||
|
int bin = (int)((h - SeaLevel) / BinWidth);
|
||||||
|
if (bin >= _counts.Length) OverflowCount++;
|
||||||
|
else _counts[bin]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldsPooled++;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The quantile at <paramref name="percent"/> (0..100) — a raw height, interpolated inside
|
/// The quantile at <paramref name="percent"/> (0..100) — a raw height, interpolated inside
|
||||||
/// its bin so the answer is not quantized to <see cref="BinWidth"/>.
|
/// its bin so the answer is not quantized to <see cref="BinWidth"/>.
|
||||||
|
|
|
||||||
524
Tools/Scripts/MountainRestoreTool.cs
Normal file
524
Tools/Scripts/MountainRestoreTool.cs
Normal file
|
|
@ -0,0 +1,524 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE MOUNTAIN-RESTORE BATCH (chat2/03) — put the mountain back, as a smooth slope.
|
||||||
|
///
|
||||||
|
/// ═══ THE STORY THIS BATCH TELLS, IN THREE HISTOGRAMS ═══
|
||||||
|
///
|
||||||
|
/// staircase the mass is there, but parked in two spikes (bench 100 m, plateau 220 m)
|
||||||
|
/// continuous_02 the spikes are gone — and so is the mass. It fell to 30–90 m.
|
||||||
|
/// continuous_restored ⭐ the same mass as the staircase, spread as one smooth grade.
|
||||||
|
///
|
||||||
|
/// That contrast is the point, so the three are named to sort adjacent.
|
||||||
|
///
|
||||||
|
/// ═══ HOW THE RESTORATION IS MEASURED ═══
|
||||||
|
///
|
||||||
|
/// The climb's control points are no longer invented from shape knobs. They are MEASURED off the
|
||||||
|
/// staircase itself, on the same 6-seed pool tasks 01/02 use:
|
||||||
|
///
|
||||||
|
/// for p in {10,30,50,70,85,95} of ABOVE-CEILING land:
|
||||||
|
/// u_p ← that percentile of the RAW height (normalized into the climb's span)
|
||||||
|
/// v_p ← that percentile of the OUTPUT height (staircase, normalized)
|
||||||
|
///
|
||||||
|
/// PCHIP through those points reproduces the staircase's elevation envelope; the flat bench and
|
||||||
|
/// plateau interiors become grade because <c>ClimbCalibration.MinNormalizedSecant</c> floors
|
||||||
|
/// every segment. → <see cref="ClimbCalibration"/>.
|
||||||
|
///
|
||||||
|
/// ⚠ The two quantile sets are paired by percentile across the SAME cell population, which is
|
||||||
|
/// exact only if the staircase were strictly monotone per column. It is monotone in raw, but the
|
||||||
|
/// per-column bench/plateau modulation (±12 / ±20 m) blurs the pairing by about that much. That
|
||||||
|
/// is well inside the envelope being targeted, and calibrating on the MEASURED output (rather
|
||||||
|
/// than a nominal unmodulated curve) is what makes oracle (g)'s land-above-100 m figure the thing
|
||||||
|
/// actually being aimed at.
|
||||||
|
///
|
||||||
|
/// ═══ RUNNING IT ═══
|
||||||
|
///
|
||||||
|
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
|
||||||
|
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/MountainRestoreTool.tscn
|
||||||
|
///
|
||||||
|
/// ISLA_TASK / ISLA_BATCH / ISLA_MAPSIZE / ISLA_SEEDS / ISLA_SHOWPIECE_SIZE / ISLA_SHOWPIECE
|
||||||
|
/// ISLA_PHASE1_SOURCE (default "02_pass1_port") · ISLA_T01_SOURCE (default "01_curve_baseline")
|
||||||
|
/// ISLA_SKIP_RAW
|
||||||
|
/// ISLA_LIFT_BIG probe: the `continuous_bigger` lift (default 1.35)
|
||||||
|
/// ISLA_SHARP probe: the `continuous_sharper_peak` knob (default 2.5)
|
||||||
|
/// </summary>
|
||||||
|
public partial class MountainRestoreTool : Node
|
||||||
|
{
|
||||||
|
private static readonly int[] DefaultSeeds = { 1063685222, 777001 };
|
||||||
|
|
||||||
|
/// <summary>⚠ Task 01's pool, verbatim — the knots' identity, and with it the staircase control's.</summary>
|
||||||
|
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
|
||||||
|
|
||||||
|
private const int DefaultMapSize = 2048;
|
||||||
|
private const int DefaultShowpieceSize = 8192;
|
||||||
|
|
||||||
|
/// <summary>Oracle (g)'s PASS/NOTE threshold, percentage points of land above 100 m. Reported either way.</summary>
|
||||||
|
private const double MountainTolerancePp = 2.0;
|
||||||
|
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
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", 3);
|
||||||
|
string descr = EnvStr("ISLA_BATCH", "mountain_restore");
|
||||||
|
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";
|
||||||
|
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
|
||||||
|
string t01Source = EnvStr("ISLA_T01_SOURCE", "01_curve_baseline");
|
||||||
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
|
float liftBig = EnvFloat("ISLA_LIFT_BIG", 1.35f);
|
||||||
|
float sharpKnob = EnvFloat("ISLA_SHARP", 2.5f);
|
||||||
|
|
||||||
|
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
|
||||||
|
|
||||||
|
var anchors = CurveAnchors.Default;
|
||||||
|
float sea = 0.15f;
|
||||||
|
int primary = seeds[0];
|
||||||
|
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print(" MOUNTAIN RESTORE (chat2/03) — the staircase's mountain,");
|
||||||
|
GD.Print(" de-terraced. Calibrated, not invented.");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print($"MapSize : {mapSize} showpiece {(showpiece ? showSize.ToString() : "off")}");
|
||||||
|
GD.Print($"yardstick : {WorldScale.Describe()}");
|
||||||
|
GD.Print($"seeds : {string.Join(", ", seeds)} (calibration pool: {string.Join(", ", CalibrationSeeds)})");
|
||||||
|
GD.Print($"batch : {batchRoot}");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
|
||||||
|
// ═══ 0. KNOTS — task 01's pool, re-measured for bit-identity ═══
|
||||||
|
GD.Print("\n--- 0. KNOTS ---");
|
||||||
|
var rawPool = new LandHistogram(sea);
|
||||||
|
var pass1 = new Dictionary<int, Pass1Result>();
|
||||||
|
foreach (int seed in CalibrationSeeds)
|
||||||
|
{
|
||||||
|
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = mapSize, Seed = seed });
|
||||||
|
pass1[seed] = p1;
|
||||||
|
rawPool.Accumulate(p1.Height, mapSize);
|
||||||
|
}
|
||||||
|
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]));
|
||||||
|
GD.Print($" {rawPool}");
|
||||||
|
GD.Print($" {knots}");
|
||||||
|
|
||||||
|
// ═══ 1. CALIBRATE — measure the staircase's above-ceiling elevation distribution ═══
|
||||||
|
//
|
||||||
|
// ⚠ The ceiling is the DEFAULT 30 m handover, which is exactly (K2, RED_CEIL). So
|
||||||
|
// "above-ceiling" is simply "raw > K2" — no derived float, and the same population the
|
||||||
|
// climb will later be responsible for.
|
||||||
|
GD.Print("\n--- 1. CALIBRATION (staircase above-ceiling distribution) ---");
|
||||||
|
float ceilingRaw = knots.K2;
|
||||||
|
|
||||||
|
var rawAbove = new LandHistogram(sea);
|
||||||
|
var outAbove = new LandHistogram(sea);
|
||||||
|
var stairPool = new Dictionary<int, Pass2Result>();
|
||||||
|
|
||||||
|
foreach (int seed in CalibrationSeeds)
|
||||||
|
{
|
||||||
|
var scfg = MakeConfig(mapSize, seed, knots, anchors, "staircase");
|
||||||
|
scfg.CurveMode = CurveModeKind.Staircase;
|
||||||
|
scfg.ShelfDetail = true;
|
||||||
|
Pass2Result st = Shaping.Shape(pass1[seed], scfg);
|
||||||
|
stairPool[seed] = st;
|
||||||
|
|
||||||
|
rawAbove.AccumulateWhere(pass1[seed].Height, pass1[seed].Height, mapSize, ceilingRaw);
|
||||||
|
outAbove.AccumulateWhere(st.Height, pass1[seed].Height, mapSize, ceilingRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
double shareAbove = 100.0 * rawAbove.TotalLand / rawPool.TotalLand;
|
||||||
|
GD.Print($" above-ceiling land: {rawAbove.TotalLand:N0} cells = {shareAbove:F1}% of all land");
|
||||||
|
|
||||||
|
var pcts = ClimbCalibration.DefaultPercentiles;
|
||||||
|
var rawQ = new float[pcts.Length];
|
||||||
|
var outQ = new float[pcts.Length];
|
||||||
|
GD.Print(" percentile → raw → staircase output");
|
||||||
|
for (int i = 0; i < pcts.Length; i++)
|
||||||
|
{
|
||||||
|
rawQ[i] = rawAbove.Quantile(pcts[i]);
|
||||||
|
outQ[i] = outAbove.Quantile(pcts[i]);
|
||||||
|
GD.Print($" P{pcts[i],-4:F0} raw {rawQ[i]:F4} → {WorldScale.MetresFromRaw(outQ[i] - sea),6:F1} m");
|
||||||
|
}
|
||||||
|
|
||||||
|
ClimbCalibration Calib(float lift, float sharp) => ClimbCalibration.FromPercentiles(
|
||||||
|
pcts, rawQ, outQ, ceilingRaw, HeightCurve.EffectiveSpikeMax(pass1[primary].HMaxSeed, knots, anchors),
|
||||||
|
anchors.RedCeil, anchors.PeakCap, lift, sharp);
|
||||||
|
|
||||||
|
// ⚠ ONE calibration object per knob pair, shared across seeds. spikeMax differs slightly
|
||||||
|
// per seed, but the calibration is NORMALIZED (u, v in [0,1]) — BuildCalibrated
|
||||||
|
// denormalizes against each seed's own spikeMax. So the shape is shared; the extent is
|
||||||
|
// per-seed, exactly as the per-seed peak normalization requires.
|
||||||
|
var calRestored = Calib(1.0f, 1.0f);
|
||||||
|
var calBigger = Calib(liftBig, 1.0f);
|
||||||
|
var calSharper = Calib(1.0f, sharpKnob);
|
||||||
|
GD.Print($" restored: {calRestored.Describe()}");
|
||||||
|
GD.Print($" bigger : {calBigger.Describe()}");
|
||||||
|
GD.Print($" sharper : {calSharper.Describe()}");
|
||||||
|
|
||||||
|
// ═══ 2. VARIANTS ═══
|
||||||
|
var variants = new List<(string label, Action<TerrainGenConfig> mutate)>
|
||||||
|
{
|
||||||
|
("staircase", c => { c.CurveMode = CurveModeKind.Staircase; c.ShelfDetail = true; }),
|
||||||
|
("continuous_02default", c => { c.CurveMode = CurveModeKind.Continuous;
|
||||||
|
c.ClimbCalibration = null; // the analytic 02 curve
|
||||||
|
c.ClimbFeather = 0.4f; c.SummitDrama = 2.5f; }),
|
||||||
|
("continuous_restored", c => { c.CurveMode = CurveModeKind.Continuous; c.ClimbCalibration = calRestored; }),
|
||||||
|
("continuous_bigger", c => { c.CurveMode = CurveModeKind.Continuous; c.ClimbCalibration = calBigger; }),
|
||||||
|
("continuous_sharper_peak", c => { c.CurveMode = CurveModeKind.Continuous; c.ClimbCalibration = calSharper; }),
|
||||||
|
};
|
||||||
|
|
||||||
|
GD.Print("\n--- 2. VARIANTS ---");
|
||||||
|
var results = new Dictionary<(int, string), Pass2Result>();
|
||||||
|
var offs = new Dictionary<int, Pass2Result>();
|
||||||
|
var rows = new List<string>();
|
||||||
|
bool notesPrinted = false;
|
||||||
|
|
||||||
|
foreach (int seed in seeds)
|
||||||
|
{
|
||||||
|
var offCfg = MakeConfig(mapSize, seed, knots, anchors, "curve_off");
|
||||||
|
offCfg.Curve = false;
|
||||||
|
offs[seed] = Shaping.Shape(pass1[seed], offCfg);
|
||||||
|
|
||||||
|
foreach (var (label, mutate) in variants)
|
||||||
|
{
|
||||||
|
var cfg = MakeConfig(mapSize, seed, knots, anchors, label);
|
||||||
|
mutate(cfg);
|
||||||
|
Pass2Result p2 = Shaping.Shape(pass1[seed], cfg);
|
||||||
|
results[(seed, label)] = p2;
|
||||||
|
if (!notesPrinted) foreach (string nt in p2.Notes) GD.Print(" " + nt);
|
||||||
|
rows.Add(WriteVariant(batchRoot, p2, sea, anchors, skipRaw));
|
||||||
|
}
|
||||||
|
notesPrinted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 3. ORACLE ═══
|
||||||
|
GD.Print("\n--- 3. ORACLE ---");
|
||||||
|
var hard = new List<ShapingOracle.Check>();
|
||||||
|
var soft = new List<ShapingOracle.Check>();
|
||||||
|
|
||||||
|
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{primary}_full", "height.f32");
|
||||||
|
string t01Dump = Path.Combine(ToolingPaths.BatchesRoot, t01Source, $"{primary}_curve_on", "height.f32");
|
||||||
|
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF == Phase-1 .f32 dump",
|
||||||
|
offs[primary].Height, HeightField.Load(p1Dump, mapSize), mapSize, p1Dump));
|
||||||
|
hard.Add(ShapingOracle.DumpRegression("a2", "staircase == task-01 curve_on .f32 dump",
|
||||||
|
results[(primary, "staircase")].Height, HeightField.Load(t01Dump, mapSize), mapSize, t01Dump));
|
||||||
|
|
||||||
|
long bFail = 0;
|
||||||
|
foreach (int seed in seeds)
|
||||||
|
foreach (var (label, _) in variants)
|
||||||
|
if (!ShapingOracle.ClassifyFidelity(pass1[seed], results[(seed, label)]).Passed) bFail++;
|
||||||
|
hard.Add(new ShapingOracle.Check
|
||||||
|
{
|
||||||
|
Id = "b", Name = "classify == raw, all seeds × all variants",
|
||||||
|
Passed = bFail == 0,
|
||||||
|
Detail = bFail == 0 ? $"bit-identical on {seeds.Length} seeds × {variants.Count} variants"
|
||||||
|
: $"{bFail} (seed, variant) pairs drifted",
|
||||||
|
});
|
||||||
|
|
||||||
|
bool cOk = results[(primary, "continuous_restored")].Notes
|
||||||
|
.Exists(n => n.Contains("strict-increase sample passed"));
|
||||||
|
hard.Add(new ShapingOracle.Check
|
||||||
|
{
|
||||||
|
Id = "c", Name = "monotone — Fritsch–Carlson + per-seed sampled",
|
||||||
|
Passed = cOk,
|
||||||
|
Detail = cOk ? "confirmed on the calibrated climb (throws and refuses on violation)"
|
||||||
|
: "no strict-increase confirmation recorded",
|
||||||
|
});
|
||||||
|
|
||||||
|
string[] continuous = { "continuous_02default", "continuous_restored", "continuous_bigger", "continuous_sharper_peak" };
|
||||||
|
foreach (int seed in seeds)
|
||||||
|
foreach (string label in continuous)
|
||||||
|
{
|
||||||
|
var d = ShapingOracle.LowlandsPreserved(pass1[seed], results[(seed, "staircase")], results[(seed, label)]);
|
||||||
|
d.Name += $" [seed {seed}]";
|
||||||
|
hard.Add(d);
|
||||||
|
|
||||||
|
var e = ShapingOracle.UpperClimbProfile(results[(seed, label)]);
|
||||||
|
e.Name += $" [seed {seed}]";
|
||||||
|
soft.Add(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (int seed in seeds)
|
||||||
|
foreach (var (label, _) in variants)
|
||||||
|
{
|
||||||
|
var f = ShapingOracle.SeaIdentity(offs[seed], results[(seed, label)], sea);
|
||||||
|
f.Name += $" [seed {seed}]";
|
||||||
|
hard.Add(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (g) the restoration, measured — reported for every variant, gated for none.
|
||||||
|
var mountain = new List<ShapingOracle.Check>();
|
||||||
|
foreach (int seed in seeds)
|
||||||
|
foreach (string label in continuous)
|
||||||
|
{
|
||||||
|
var g = ShapingOracle.MountainRestored(results[(seed, label)], results[(seed, "staircase")],
|
||||||
|
sea, MountainTolerancePp);
|
||||||
|
g.Name += $" [seed {seed}]";
|
||||||
|
mountain.Add(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var c in hard) GD.Print(" " + c);
|
||||||
|
foreach (var c in soft) { if (c.Passed) GD.Print(" " + c); else GD.PrintErr(" ⚠ SLOPE: " + c); }
|
||||||
|
GD.Print(" --- (g) mountain, reported not gated ---");
|
||||||
|
foreach (var c in mountain) GD.Print(" " + c);
|
||||||
|
|
||||||
|
bool hardOk = hard.TrueForAll(c => c.Passed);
|
||||||
|
GD.Print($" ORACLE: {(hardOk ? "ALL HARD CHECKS PASS" : "*** HARD FAILURES ***")}");
|
||||||
|
|
||||||
|
// ═══ 4. HISTOGRAMS — the three-way contrast, adjacent by filename ═══
|
||||||
|
GD.Print("\n--- 4. HISTOGRAMS ---");
|
||||||
|
foreach (int seed in seeds)
|
||||||
|
{
|
||||||
|
var rawSeed = new LandHistogram(sea);
|
||||||
|
rawSeed.Accumulate(pass1[seed].Height, mapSize);
|
||||||
|
int order = 1;
|
||||||
|
foreach (var (label, _) in variants)
|
||||||
|
DrawShaped(results[(seed, label)], anchors, seed, mapSize, batchRoot, order++, sea);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 5. SHOWPIECE ═══
|
||||||
|
string showNote = "skipped (ISLA_SHOWPIECE=0)";
|
||||||
|
if (showpiece)
|
||||||
|
{
|
||||||
|
GD.Print($"\n--- 5. SHOWPIECE at {showSize} (continuous_restored, seed {primary}) ---");
|
||||||
|
var cfg = MakeConfig(showSize, primary, knots, anchors, "continuous_restored_showpiece");
|
||||||
|
cfg.CurveMode = CurveModeKind.Continuous;
|
||||||
|
cfg.ClimbCalibration = calRestored;
|
||||||
|
|
||||||
|
Pass1Result p1 = Topography.Generate(cfg);
|
||||||
|
Pass2Result big = Shaping.Shape(p1, cfg);
|
||||||
|
foreach (string nt in big.Notes) GD.Print(" " + nt);
|
||||||
|
|
||||||
|
var cb = ShapingOracle.ClassifyFidelity(p1, big);
|
||||||
|
var offBigCfg = MakeConfig(showSize, primary, knots, anchors, "off"); offBigCfg.Curve = false;
|
||||||
|
var cf = ShapingOracle.SeaIdentity(Shaping.Shape(p1, offBigCfg), big, sea);
|
||||||
|
GD.Print($" {cb}");
|
||||||
|
GD.Print($" {cf}");
|
||||||
|
if (!cb.Passed || !cf.Passed) hardOk = false;
|
||||||
|
|
||||||
|
var (b100, b220) = ShapingOracle.LandAbove(big, sea);
|
||||||
|
GD.Print($" land >100 m {b100:F2}% >220 m {b220:F2}% (at {showSize})");
|
||||||
|
rows.Add(WriteVariant(batchRoot, big, sea, anchors, skipRaw));
|
||||||
|
showNote = $"seed {primary} at {showSize}; >100 m {b100:F2}%, >220 m {b220:F2}%";
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteIndex(batchRoot, mapSize, showSize, seeds, primary, anchors, results, calRestored,
|
||||||
|
calBigger, calSharper, pcts, rawQ, outQ, hard, soft, mountain, rows, hardOk, showNote, sea);
|
||||||
|
|
||||||
|
GD.Print("\n==================================================================");
|
||||||
|
GD.Print($" DONE — {batchRoot}");
|
||||||
|
GD.Print($" ORACLE {(hardOk ? "HARD CHECKS ALL PASS" : "*** HARD FAILURES ***")}");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GetTree().Quit(hardOk ? 0 : 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TerrainGenConfig MakeConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a, string label)
|
||||||
|
=> new TerrainGenConfig
|
||||||
|
{
|
||||||
|
MapSize = mapSize, Seed = seed, VariantLabel = label,
|
||||||
|
Curve = true, ShelfDetail = false, Knots = k, Anchors = a,
|
||||||
|
LowlandCeilingM = 30f,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- output ---------------------------------------------------------
|
||||||
|
|
||||||
|
private static string WriteVariant(string batchRoot, Pass2Result p2, float sea,
|
||||||
|
CurveAnchors anchors, bool skipRaw)
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||||||
|
|
||||||
|
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 = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
|
||||||
|
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f,
|
||||||
|
HillshadeStrength = 0.30f, SeaLevel = sea,
|
||||||
|
};
|
||||||
|
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
|
||||||
|
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, p2.VariantLabel.ToUpperInvariant())
|
||||||
|
.SavePng(Path.Combine(dir, "relief.png"));
|
||||||
|
|
||||||
|
var (a100, a220) = ShapingOracle.LandAbove(p2, sea);
|
||||||
|
GD.Print($" {p2.VariantLabel,-30} seed {p2.Seed,-11} h[{p2.HMin,7:F3} .. {p2.HMax,6:F3}] " +
|
||||||
|
$" >100m {a100,5:F2}% >220m {a220,5:F2}% {p2.ElapsedMs,5} ms");
|
||||||
|
|
||||||
|
return $"| `{p2.Seed}_{p2.VariantLabel}` | {p2.Seed} | {p2.VariantLabel} | {p2.HMin:F3} | {p2.HMax:F3} | " +
|
||||||
|
$"{a100:F2}% | {a220:F2}% | {gMin:F3}..{gMax:F3} | {p2.ElapsedMs} ms |";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawShaped(Pass2Result p2, CurveAnchors a, int seed, int mapSize,
|
||||||
|
string batchRoot, int order, float sea)
|
||||||
|
{
|
||||||
|
var shaped = new LandHistogram(sea);
|
||||||
|
shaped.Accumulate(p2.Height, mapSize);
|
||||||
|
|
||||||
|
float top = MathF.Ceiling(shaped.MaxLand * 20f) / 20f;
|
||||||
|
var display = shaped.Rebin((top - shaped.SeaLevel) / 360f);
|
||||||
|
var (a100, a220) = ShapingOracle.LandAbove(p2, sea);
|
||||||
|
|
||||||
|
var o = new HistogramRenderer.Options
|
||||||
|
{
|
||||||
|
Title = $"{p2.VariantLabel.ToUpperInvariant()} - SEED {seed}",
|
||||||
|
Subtitle = $"LAND ABOVE 100M {a100:F2} PCT - ABOVE 220M {a220:F2} PCT",
|
||||||
|
XAxisLabel = "RAW HEIGHT (POST-CURVE)",
|
||||||
|
XTop = top,
|
||||||
|
Footer = $"{shaped.TotalLand} LAND COLUMNS AT MAPSIZE {mapSize}",
|
||||||
|
};
|
||||||
|
|
||||||
|
// The two heights the restoration is measured at, on every plate, so the three-way
|
||||||
|
// contrast can be read off the same reference lines.
|
||||||
|
o.Markers.Add(new HistogramRenderer.Marker { Value = a.Sea + WorldScale.RawFromMetres(100f), Label = "100M" });
|
||||||
|
o.Markers.Add(new HistogramRenderer.Marker { Value = a.Sea + WorldScale.RawFromMetres(220f), Label = "220M" });
|
||||||
|
o.Markers.Add(new HistogramRenderer.Marker { Value = a.PeakCap, Label = "CAP 420M", Strong = false });
|
||||||
|
if (p2.Continuous != null)
|
||||||
|
o.Markers.Add(new HistogramRenderer.Marker { Value = p2.Continuous.CeilingOut, Label = "LOWLAND", Strong = false });
|
||||||
|
|
||||||
|
string file = $"hist_{seed}_{order}_{p2.VariantLabel}.png";
|
||||||
|
HistogramRenderer.SavePng(display, o, Path.Combine(batchRoot, file));
|
||||||
|
GD.Print($" {file}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteIndex(string batchRoot, int mapSize, int showSize, int[] seeds, int primary,
|
||||||
|
CurveAnchors a, Dictionary<(int, string), Pass2Result> results,
|
||||||
|
ClimbCalibration calRestored, ClimbCalibration calBigger, ClimbCalibration calSharper,
|
||||||
|
double[] pcts, float[] rawQ, float[] outQ,
|
||||||
|
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> soft, List<ShapingOracle.Check> mountain,
|
||||||
|
List<string> rows, bool hardOk, string showNote, float sea)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("# Batch 03 — restore the mountain, as a smooth slope");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("The continuous climb's control points are now **measured off the staircase** instead of");
|
||||||
|
sb.AppendLine("invented from shape knobs. Same mountain mass, zero terraces. The lowlands are still");
|
||||||
|
sb.AppendLine("preserved bit-for-bit (oracle d).");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⭐ Open this first");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"1. **`{primary}_continuous_restored_showpiece/relief.png`** — the centerpiece ({showNote}).");
|
||||||
|
sb.AppendLine("2. **The three-way histogram contrast**, adjacent by filename:");
|
||||||
|
sb.AppendLine($" - `hist_{primary}_1_staircase.png` — the mass, parked in two spikes");
|
||||||
|
sb.AppendLine($" - `hist_{primary}_2_continuous_02default.png` — spikes gone, **and so is the mass**");
|
||||||
|
sb.AppendLine($" - `hist_{primary}_3_continuous_restored.png` — ⭐ **the mass back, spread smooth**");
|
||||||
|
sb.AppendLine(" Every plate carries the same 100 m / 220 m reference lines.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The restoration, measured");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Variant | land >100 m | land >220 m |");
|
||||||
|
sb.AppendLine("|---|---|---|");
|
||||||
|
foreach (string label in new[] { "staircase", "continuous_02default", "continuous_restored", "continuous_bigger", "continuous_sharper_peak" })
|
||||||
|
{
|
||||||
|
var (x100, x220) = ShapingOracle.LandAbove(results[(primary, label)], sea);
|
||||||
|
string star = label == "continuous_restored" ? " ⭐" : label == "staircase" ? " *(target)*" : "";
|
||||||
|
sb.AppendLine($"| `{label}`{star} | {x100:F2} % | {x220:F2} % |");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"*(seed {primary} at {mapSize}; per-seed rows in Results below.)*");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The calibration");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Measured on the 6-seed pool, above-ceiling land only:");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| percentile | raw | staircase output |");
|
||||||
|
sb.AppendLine("|---|---|---|");
|
||||||
|
for (int i = 0; i < pcts.Length; i++)
|
||||||
|
sb.AppendLine($"| P{pcts[i]:F0} | {rawQ[i]:F4} | **{WorldScale.MetresFromRaw(outQ[i] - sea):F0} m** |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Variant | knobs | control points (u,v) |");
|
||||||
|
sb.AppendLine("|---|---|---|");
|
||||||
|
sb.AppendLine($"| `continuous_restored` | {calRestored.Describe().Split('·')[0].Trim()} | `{calRestored.Describe().Split('·')[1].Trim()}` |");
|
||||||
|
sb.AppendLine($"| `continuous_bigger` | {calBigger.Describe().Split('·')[0].Trim()} | `{calBigger.Describe().Split('·')[1].Trim()}` |");
|
||||||
|
sb.AppendLine($"| `continuous_sharper_peak` | {calSharper.Describe().Split('·')[0].Trim()} | `{calSharper.Describe().Split('·')[1].Trim()}` |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"`floored` counts segments the no-bench floor had to lift — i.e. where the staircase was flat.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("`ProvisionalEven` — the CostaRica colours re-spaced evenly SEA → 420 m. Final calibration");
|
||||||
|
sb.AppendLine("waits for the chosen profile. **Grayscale + the histograms are the honest instruments.**");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The oracle");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(ShapingOracle.ToMarkdownTable(hard));
|
||||||
|
sb.AppendLine($"**{(hardOk ? "ALL HARD CHECKS PASS" : "⚠⚠ HARD FAILURES — do not judge this batch")}**");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Soft — upper climb slope profile (e):");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(ShapingOracle.ToMarkdownTable(soft));
|
||||||
|
sb.AppendLine("(g) mountain restored — **reported, not gated** (it is a taste target the developer tunes):");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(ShapingOracle.ToMarkdownTable(mountain));
|
||||||
|
sb.AppendLine("## Disposability");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Artifact | Keep? |");
|
||||||
|
sb.AppendLine("|---|---|");
|
||||||
|
sb.AppendLine("| `relief.png`, `hist_*.png`, `INDEX.md` | **keep** |");
|
||||||
|
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
|
||||||
|
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + code (the byte-level oracle) |");
|
||||||
|
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## Results");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Folder | Seed | Variant | h min | h max | >100 m | >220 m | grayscale range | time |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (string row in rows) sb.AppendLine(row);
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"MapSize {mapSize}, showpiece {showSize}, seeds {string.Join(", ", seeds)}. {WorldScale.Describe()}.");
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/MountainRestoreTool.cs.uid
Normal file
1
Tools/Scripts/MountainRestoreTool.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dsg3y4a75vwmu
|
||||||
235
Tools/Scripts/OffshoreAnalysis.cs
Normal file
235
Tools/Scripts/OffshoreAnalysis.cs
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>One connected island of tagged offshore land, as the analysis sees it.</summary>
|
||||||
|
public sealed class IslandComponent
|
||||||
|
{
|
||||||
|
public int Id;
|
||||||
|
public long Cells;
|
||||||
|
public double CentroidX, CentroidY;
|
||||||
|
public int MinX, MinY, MaxX, MaxY;
|
||||||
|
|
||||||
|
/// <summary>Hemisphere by CENTROID (an island straddling the midline is counted once, where its mass is).</summary>
|
||||||
|
public byte Hemisphere;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⚠ True if any cell of this island is 8-adjacent to land that is NOT tagged offshore —
|
||||||
|
/// i.e. the island touches the mainland. The moat exists to make this impossible; this is
|
||||||
|
/// the check that it did.
|
||||||
|
/// </summary>
|
||||||
|
public bool BridgedToMainland;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE OFFSHORE ANALYSIS — counts islands, reads their hemisphere, and catches a land bridge.
|
||||||
|
/// Engine-free; used by the pass (to prove its own floor) and by the oracle (to prove it again,
|
||||||
|
/// independently, on the finished field).
|
||||||
|
///
|
||||||
|
/// ═══ THE HEMISPHERE CONVENTION — read from the code, not invented ═══
|
||||||
|
///
|
||||||
|
/// Pass 1's latitude scalar is <c>y / MapSize</c> (+ a ±0.1 wobble). The spine fades out where
|
||||||
|
/// that scalar exceeds 0.65 — "the southern fade" — and the "southern sinker" bites in the
|
||||||
|
/// BOTTOM 25 % of rows. So in this codebase, and in the lore it encodes (snow-town north,
|
||||||
|
/// shipwreck south): <b>y increases SOUTHWARD. North is the top half of the image.</b>
|
||||||
|
///
|
||||||
|
/// NORTH y ∈ [0, MapSize/2)
|
||||||
|
/// SOUTH y ∈ [MapSize/2, MapSize)
|
||||||
|
///
|
||||||
|
/// ⚠ The tag uses the clean row midline, NOT the wobbled latitude field. A hemisphere tag keyed
|
||||||
|
/// to a field that wanders ±10 % of the map would put the same island in different hemispheres
|
||||||
|
/// on different seeds for no geographic reason. The field's ORIENTATION is what is borrowed; its
|
||||||
|
/// wobble is not.
|
||||||
|
/// </summary>
|
||||||
|
public static class OffshoreAnalysis
|
||||||
|
{
|
||||||
|
// The convention now lives in Core (RegionLabeling, chat2/07) — one definition; these are aliases.
|
||||||
|
public const byte HemiNone = RegionLabeling.HemiNone;
|
||||||
|
public const byte HemiNorth = RegionLabeling.HemiNorth;
|
||||||
|
public const byte HemiSouth = RegionLabeling.HemiSouth;
|
||||||
|
|
||||||
|
/// <summary>→ <see cref="RegionLabeling.HemisphereOfRow"/>.</summary>
|
||||||
|
public static byte HemisphereOfRow(int y, int mapSize) => RegionLabeling.HemisphereOfRow(y, mapSize);
|
||||||
|
|
||||||
|
public static string HemisphereName(byte h) => RegionLabeling.HemisphereName(h);
|
||||||
|
|
||||||
|
// 8-connectivity, fixed order.
|
||||||
|
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
|
||||||
|
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Label the 8-connected components of tagged offshore land, and for each, whether it
|
||||||
|
/// touches untagged land (a bridge). <paramref name="height"/> + <paramref name="sea"/>
|
||||||
|
/// define "land"; <paramref name="tag"/> defines "offshore". Both are needed: the bridge test
|
||||||
|
/// is "tagged cell next to a land cell that is not tagged".
|
||||||
|
/// </summary>
|
||||||
|
public static List<IslandComponent> Components(bool[,] tag, float[,] height, float sea, int mapSize)
|
||||||
|
=> Components(tag, height, sea, mapSize, out _);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// As above, also returning the per-cell component id map (<c>x * mapSize + y</c>; 0 = not
|
||||||
|
/// tagged) — the debris guard needs membership, not just the list.
|
||||||
|
/// </summary>
|
||||||
|
public static List<IslandComponent> Components(bool[,] tag, float[,] height, float sea, int mapSize,
|
||||||
|
out int[] idMap)
|
||||||
|
{
|
||||||
|
var comps = new List<IslandComponent>();
|
||||||
|
int n = mapSize;
|
||||||
|
var id = new int[n * n]; // 0 = unvisited / not tagged
|
||||||
|
idMap = id;
|
||||||
|
if (tag == null) return comps;
|
||||||
|
|
||||||
|
var stack = new Stack<int>();
|
||||||
|
int next = 0;
|
||||||
|
|
||||||
|
for (int sx = 0; sx < n; sx++)
|
||||||
|
{
|
||||||
|
for (int sy = 0; sy < n; sy++)
|
||||||
|
{
|
||||||
|
if (!tag[sx, sy] || id[sx * n + sy] != 0) continue;
|
||||||
|
|
||||||
|
var c = new IslandComponent
|
||||||
|
{
|
||||||
|
Id = ++next, MinX = sx, MaxX = sx, MinY = sy, MaxY = sy,
|
||||||
|
};
|
||||||
|
double sumX = 0, sumY = 0;
|
||||||
|
|
||||||
|
id[sx * n + sy] = c.Id;
|
||||||
|
stack.Push(sx * n + sy);
|
||||||
|
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
int cur = stack.Pop();
|
||||||
|
int cx = cur / n, cy = cur % n;
|
||||||
|
c.Cells++; sumX += cx; sumY += cy;
|
||||||
|
if (cx < c.MinX) c.MinX = cx; if (cx > c.MaxX) c.MaxX = cx;
|
||||||
|
if (cy < c.MinY) c.MinY = cy; if (cy > c.MaxY) c.MaxY = cy;
|
||||||
|
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
|
||||||
|
if (tag[nx, ny])
|
||||||
|
{
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (id[ni] != 0) continue;
|
||||||
|
id[ni] = c.Id;
|
||||||
|
stack.Push(ni);
|
||||||
|
}
|
||||||
|
else if (height[nx, ny] >= sea)
|
||||||
|
{
|
||||||
|
// Land, not tagged offshore ⇒ mainland (or a lake-shore) touching
|
||||||
|
// this island. The moat should have made this impossible.
|
||||||
|
c.BridgedToMainland = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.CentroidX = sumX / c.Cells;
|
||||||
|
c.CentroidY = sumY / c.Cells;
|
||||||
|
c.Hemisphere = HemisphereOfRow((int)Math.Round(c.CentroidY), mapSize);
|
||||||
|
comps.Add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return comps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Island counts per hemisphere, by component centroid.</summary>
|
||||||
|
public static (int north, int south) CountByHemisphere(List<IslandComponent> comps)
|
||||||
|
{
|
||||||
|
int nN = 0, nS = 0;
|
||||||
|
foreach (var c in comps)
|
||||||
|
{
|
||||||
|
if (c.Hemisphere == HemiNorth) nN++;
|
||||||
|
else if (c.Hemisphere == HemiSouth) nS++;
|
||||||
|
}
|
||||||
|
return (nN, nS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Island SIZE statistics — the thing a count alone hides. 189 islands averaging 66 cells is
|
||||||
|
/// noise debris, not an archipelago; 12 islands averaging 900 cells is what the developer
|
||||||
|
/// asked for. Cells are map cells (1 column = 1 m at the target scale).
|
||||||
|
/// </summary>
|
||||||
|
public static (long min, long median, double mean, long max, int belowThreshold)
|
||||||
|
SizeSummary(List<IslandComponent> comps, long threshold)
|
||||||
|
{
|
||||||
|
if (comps.Count == 0) return (0, 0, 0.0, 0, 0);
|
||||||
|
var sizes = new List<long>(comps.Count);
|
||||||
|
double sum = 0; int below = 0;
|
||||||
|
foreach (var c in comps) { sizes.Add(c.Cells); sum += c.Cells; if (c.Cells < threshold) below++; }
|
||||||
|
sizes.Sort();
|
||||||
|
return (sizes[0], sizes[sizes.Count / 2], sum / sizes.Count, sizes[sizes.Count - 1], below);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How many components touch the mainland. Zero is the only acceptable answer.</summary>
|
||||||
|
public static int BridgedCount(List<IslandComponent> comps)
|
||||||
|
{
|
||||||
|
int b = 0;
|
||||||
|
foreach (var c in comps) if (c.BridgedToMainland) b++;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ chat2/06 — the separation guard's geometry ═══
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per component id, its BOUNDARY cells — tagged cells with at least one 8-neighbour that is
|
||||||
|
/// not tagged (or the map edge). The nearest approach between two islands is between
|
||||||
|
/// boundary cells, so the separation guard compares boundaries, not bodies: a few hundred
|
||||||
|
/// cells per island instead of thousands. <paramref name="surfaced"/> is the pass's list of
|
||||||
|
/// every surfaced cell (the bodies), walked once.
|
||||||
|
/// </summary>
|
||||||
|
public static Dictionary<int, List<(int x, int y)>> BoundaryCells(bool[,] tag, int[] compId, int mapSize,
|
||||||
|
IEnumerable<(int x, int y, float h0)> surfaced)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<int, List<(int x, int y)>>();
|
||||||
|
foreach (var (x, y, _) in surfaced)
|
||||||
|
{
|
||||||
|
if (!tag[x, y]) continue;
|
||||||
|
int id = compId[x * mapSize + y];
|
||||||
|
if (id == 0) continue;
|
||||||
|
bool edge = false;
|
||||||
|
for (int k = 0; k < 8 && !edge; k++)
|
||||||
|
{
|
||||||
|
int nx = x + DX[k], ny = y + DY[k];
|
||||||
|
if (nx < 0 || nx >= mapSize || ny < 0 || ny >= mapSize || !tag[nx, ny]) edge = true;
|
||||||
|
}
|
||||||
|
if (!edge) continue;
|
||||||
|
if (!result.TryGetValue(id, out var list)) result[id] = list = new List<(int, int)>();
|
||||||
|
list.Add((x, y));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Chebyshev gap between two components' bounding boxes (0 if they overlap). A lower bound on their true distance.</summary>
|
||||||
|
public static int BoxGap(IslandComponent a, IslandComponent b)
|
||||||
|
{
|
||||||
|
int gx = Math.Max(0, Math.Max(a.MinX - b.MaxX, b.MinX - a.MaxX));
|
||||||
|
int gy = Math.Max(0, Math.Max(a.MinY - b.MaxY, b.MinY - a.MaxY));
|
||||||
|
return Math.Max(gx, gy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The minimum Chebyshev distance between two boundary sets, early-exiting once it is
|
||||||
|
/// known to be below <paramref name="below"/> (the caller only needs "closer than the
|
||||||
|
/// minimum or not").
|
||||||
|
/// </summary>
|
||||||
|
public static int MinChebyshev(List<(int x, int y)> a, List<(int x, int y)> b, int below)
|
||||||
|
{
|
||||||
|
int best = int.MaxValue;
|
||||||
|
if (a == null || b == null) return best;
|
||||||
|
foreach (var (ax, ay) in a)
|
||||||
|
{
|
||||||
|
foreach (var (bx, by) in b)
|
||||||
|
{
|
||||||
|
int d = Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by));
|
||||||
|
if (d < best) { best = d; if (best < below) return best; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/OffshoreAnalysis.cs.uid
Normal file
1
Tools/Scripts/OffshoreAnalysis.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bw21tp5wa5vxt
|
||||||
231
Tools/Scripts/OffshoreDiagnosis.cs
Normal file
231
Tools/Scripts/OffshoreDiagnosis.cs
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>One hemisphere's share of the offshore zone, the gates, and the noise peaks.</summary>
|
||||||
|
public sealed class HemisphereDiagnosis
|
||||||
|
{
|
||||||
|
public string Name;
|
||||||
|
|
||||||
|
// ---- the valid offshore zone ----
|
||||||
|
public long Sea; // below-sea cells in this hemisphere (after the shelf)
|
||||||
|
public long Zone; // cells with zone weight > 0 (island-eligible)
|
||||||
|
public long ZoneFull; // cells with zone weight == 1 (clear of every feather)
|
||||||
|
|
||||||
|
// ---- which gate blocks (non-exclusive: a cell may fail several) ----
|
||||||
|
public long BlockDepth; // ambient depth < moat
|
||||||
|
public long BlockFalloff; // pre-Trench falloff < min (not "actually offshore")
|
||||||
|
public long BlockTrench; // at/past the outer bound
|
||||||
|
// ---- the SOLE blocker (a cell that fails exactly one gate — loosen that gate and it joins the zone) ----
|
||||||
|
public long SoleDepth, SoleFalloff, SoleTrench;
|
||||||
|
|
||||||
|
// ---- band geometry: per column, how many rows are ocean / zone in this hemisphere ----
|
||||||
|
public double OceanRowsPerColumn, ZoneRowsPerColumn;
|
||||||
|
public int ColumnsWithNoZone;
|
||||||
|
|
||||||
|
// ---- the noise peaks (strict 8-neighbour local maxima of the islet field, on sea cells) ----
|
||||||
|
public int PeaksSea; // all maxima over sea
|
||||||
|
public int PeaksInZone; // maxima inside the zone — the CAPACITY for islands at this frequency
|
||||||
|
public int PeaksInZoneOverThr; // inside the zone AND clearing the threshold — the island candidates
|
||||||
|
public int PeaksOverThrLost; // clearing the threshold but OUTSIDE the zone — killed by a gate:
|
||||||
|
public int LostDepth, LostFalloff, LostTrench; // …which one(s) (non-exclusive)
|
||||||
|
|
||||||
|
public double ZoneShareOfSea => Sea == 0 ? 0 : (double)Zone / Sea;
|
||||||
|
public double CandidatesPerMegacell => Zone == 0 ? 0 : PeaksInZoneOverThr * 1e6 / Zone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE SOUTH-SUPPRESSION DIAGNOSIS (chat2/06 §2) — MEASURE, DON'T GUESS. Before a knob moves,
|
||||||
|
/// answer per hemisphere: how much island-eligible ocean is there, how many noise peaks clear the
|
||||||
|
/// threshold in it, and which gate is the binding one. Read-only: it evaluates the islet noise
|
||||||
|
/// field and the zone mask exactly as <see cref="OffshorePass"/> does (same noise factory, same
|
||||||
|
/// calibration samples, same blended threshold, same gate arithmetic) over the post-shelf,
|
||||||
|
/// pre-islet field, and counts. It raises nothing.
|
||||||
|
///
|
||||||
|
/// Hemisphere is the row midline (<see cref="OffshoreAnalysis.HemisphereOfRow"/>), as the tag.
|
||||||
|
/// </summary>
|
||||||
|
public static class OffshoreDiagnosis
|
||||||
|
{
|
||||||
|
public sealed class Report
|
||||||
|
{
|
||||||
|
public int Seed, MapSize;
|
||||||
|
public float ThresholdNorth, ThresholdSouth;
|
||||||
|
public HemisphereDiagnosis North = new() { Name = "north" };
|
||||||
|
public HemisphereDiagnosis South = new() { Name = "south" };
|
||||||
|
public HemisphereDiagnosis Of(byte hemi) => hemi == OffshoreAnalysis.HemiNorth ? North : South;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="height">The post-shelf, pre-islet field (offshore OFF, shelf as the batch runs it).</param>
|
||||||
|
public static Report Run(float[,] height, float[,] preTrench, int mapSize, int seed, float sea,
|
||||||
|
OffshoreSettings s, GenerationScale scale)
|
||||||
|
{
|
||||||
|
var rep = new Report { Seed = seed, MapSize = mapSize };
|
||||||
|
FastNoiseLite noise = TerrainNoise.CreateModulation(seed, s.SeedOffset, s.FreqPerMapWidth, scale);
|
||||||
|
float[] samples = OffshorePass.CalibrationSamples(noise, mapSize);
|
||||||
|
float thrN = IslandFalloff.CalibrateThreshold(samples, s.Density);
|
||||||
|
float thrS = s.Mode == OffshoreMode.Faithful ? thrN : IslandFalloff.CalibrateThreshold(samples, s.DensitySouth);
|
||||||
|
rep.ThresholdNorth = thrN; rep.ThresholdSouth = thrS;
|
||||||
|
|
||||||
|
float centerX = mapSize / 2.0f, centerY = mapSize / 2.0f, halfSpan = mapSize / 2.0f;
|
||||||
|
float mid = mapSize * 0.5f;
|
||||||
|
float band = MathF.Max(1f, s.HemisphereBlendHalfWidth * mapSize);
|
||||||
|
|
||||||
|
// The islet field over the whole map (a peak's neighbours may be land or out of zone).
|
||||||
|
var v = new float[mapSize, mapSize];
|
||||||
|
for (int x = 0; x < mapSize; x++)
|
||||||
|
for (int y = 0; y < mapSize; y++)
|
||||||
|
v[x, y] = (noise.GetNoise2D(x, y) + 1f) * 0.5f;
|
||||||
|
|
||||||
|
// Zone weight per cell, and the gate ledger. -1 = land.
|
||||||
|
var zone = new float[mapSize, mapSize];
|
||||||
|
int half = mapSize / 2;
|
||||||
|
long[] oceanRows = new long[2], zoneRows = new long[2];
|
||||||
|
int[] colsNoZone = new int[2];
|
||||||
|
for (int x = 0; x < mapSize; x++)
|
||||||
|
{
|
||||||
|
long[] colZone = new long[2];
|
||||||
|
for (int y = 0; y < mapSize; y++)
|
||||||
|
{
|
||||||
|
float h = height[x, y];
|
||||||
|
if (h >= sea) { zone[x, y] = -1f; continue; }
|
||||||
|
int hi = y < half ? 0 : 1;
|
||||||
|
var d = hi == 0 ? rep.North : rep.South;
|
||||||
|
d.Sea++; oceanRows[hi]++;
|
||||||
|
|
||||||
|
float depthM = WorldScale.MetresFromRaw(sea - h);
|
||||||
|
float dist = MathF.Max(MathF.Abs(x - centerX) / halfSpan, MathF.Abs(y - centerY) / halfSpan);
|
||||||
|
bool bDepth = depthM < s.MinDepthM;
|
||||||
|
bool bFall = preTrench[x, y] < s.MinFalloff;
|
||||||
|
bool bTr = dist >= s.TrenchOuter;
|
||||||
|
if (bDepth) d.BlockDepth++;
|
||||||
|
if (bFall) d.BlockFalloff++;
|
||||||
|
if (bTr) d.BlockTrench++;
|
||||||
|
int fails = (bDepth ? 1 : 0) + (bFall ? 1 : 0) + (bTr ? 1 : 0);
|
||||||
|
if (fails == 1)
|
||||||
|
{
|
||||||
|
if (bDepth) d.SoleDepth++; else if (bFall) d.SoleFalloff++; else d.SoleTrench++;
|
||||||
|
}
|
||||||
|
|
||||||
|
float z = IslandFalloff.OffshoreZoneWeight(depthM, preTrench[x, y],
|
||||||
|
MathF.Abs(x - centerX) / halfSpan, MathF.Abs(y - centerY) / halfSpan,
|
||||||
|
s.MinDepthM, s.DepthFeatherM, s.MinFalloff, s.FalloffFeather, s.TrenchInner, s.TrenchOuter);
|
||||||
|
zone[x, y] = z;
|
||||||
|
if (z > 0f) { d.Zone++; zoneRows[hi]++; colZone[hi]++; }
|
||||||
|
if (z >= 1f) d.ZoneFull++;
|
||||||
|
}
|
||||||
|
for (int hi = 0; hi < 2; hi++) if (colZone[hi] == 0) colsNoZone[hi]++;
|
||||||
|
}
|
||||||
|
rep.North.OceanRowsPerColumn = oceanRows[0] / (double)mapSize;
|
||||||
|
rep.South.OceanRowsPerColumn = oceanRows[1] / (double)mapSize;
|
||||||
|
rep.North.ZoneRowsPerColumn = zoneRows[0] / (double)mapSize;
|
||||||
|
rep.South.ZoneRowsPerColumn = zoneRows[1] / (double)mapSize;
|
||||||
|
rep.North.ColumnsWithNoZone = colsNoZone[0];
|
||||||
|
rep.South.ColumnsWithNoZone = colsNoZone[1];
|
||||||
|
|
||||||
|
// Peaks: strict local maxima of v over the 8-neighbourhood, on sea cells.
|
||||||
|
for (int x = 0; x < mapSize; x++)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < mapSize; y++)
|
||||||
|
{
|
||||||
|
if (zone[x, y] < 0f) continue; // land
|
||||||
|
float c = v[x, y];
|
||||||
|
bool isMax = true;
|
||||||
|
for (int dx = -1; dx <= 1 && isMax; dx++)
|
||||||
|
{
|
||||||
|
int nx = x + dx; if (nx < 0 || nx >= mapSize) continue;
|
||||||
|
for (int dy = -1; dy <= 1; dy++)
|
||||||
|
{
|
||||||
|
if (dx == 0 && dy == 0) continue;
|
||||||
|
int ny = y + dy; if (ny < 0 || ny >= mapSize) continue;
|
||||||
|
if (v[nx, ny] >= c) { isMax = false; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isMax) continue;
|
||||||
|
|
||||||
|
var d = y < half ? rep.North : rep.South;
|
||||||
|
d.PeaksSea++;
|
||||||
|
float thr = s.Mode == OffshoreMode.Faithful ? thrN : OffshorePass.BlendedThreshold(y, mid, band, thrN, thrS);
|
||||||
|
bool over = c > thr;
|
||||||
|
if (zone[x, y] > 0f)
|
||||||
|
{
|
||||||
|
d.PeaksInZone++;
|
||||||
|
if (over) d.PeaksInZoneOverThr++;
|
||||||
|
}
|
||||||
|
else if (over)
|
||||||
|
{
|
||||||
|
d.PeaksOverThrLost++;
|
||||||
|
float h = height[x, y];
|
||||||
|
float depthM = WorldScale.MetresFromRaw(sea - h);
|
||||||
|
float dist = MathF.Max(MathF.Abs(x - centerX) / halfSpan, MathF.Abs(y - centerY) / halfSpan);
|
||||||
|
if (depthM < s.MinDepthM) d.LostDepth++;
|
||||||
|
if (preTrench[x, y] < s.MinFalloff) d.LostFalloff++;
|
||||||
|
if (dist >= s.TrenchOuter) d.LostTrench++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rep;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One markdown row per hemisphere for a report table (see <see cref="TableHeader"/>).</summary>
|
||||||
|
public static string TableHeader() =>
|
||||||
|
"| seed | hemi | sea cells | zone cells | zone / sea | zone == 1 | ocean rows/col | zone rows/col | cols w/o zone | " +
|
||||||
|
"blocked: depth / falloff / trench | sole blocker: depth / falloff / trench | peaks: sea / in zone / in zone > thr | lost > thr (depth / falloff / trench) | candidates per Mcell |\n" +
|
||||||
|
"|---|---|---|---|---|---|---|---|---|---|---|---|---|---|";
|
||||||
|
|
||||||
|
public static string TableRow(Report r, HemisphereDiagnosis d) =>
|
||||||
|
$"| `{r.Seed}` | **{d.Name}** | {d.Sea:N0} | {d.Zone:N0} | {d.ZoneShareOfSea:P1} | {d.ZoneFull:N0} | {d.OceanRowsPerColumn:F0} | {d.ZoneRowsPerColumn:F0} | {d.ColumnsWithNoZone} | " +
|
||||||
|
$"{d.BlockDepth:N0} / {d.BlockFalloff:N0} / {d.BlockTrench:N0} | {d.SoleDepth:N0} / {d.SoleFalloff:N0} / {d.SoleTrench:N0} | " +
|
||||||
|
$"{d.PeaksSea} / {d.PeaksInZone} / **{d.PeaksInZoneOverThr}** | {d.PeaksOverThrLost} ({d.LostDepth} / {d.LostFalloff} / {d.LostTrench}) | {d.CandidatesPerMegacell:F1} |";
|
||||||
|
|
||||||
|
/// <summary>Sum a pool of reports per hemisphere (means for the per-column numbers).</summary>
|
||||||
|
public static (HemisphereDiagnosis north, HemisphereDiagnosis south) Pool(IReadOnlyList<Report> reports)
|
||||||
|
{
|
||||||
|
var n = new HemisphereDiagnosis { Name = "north (pool)" };
|
||||||
|
var s = new HemisphereDiagnosis { Name = "south (pool)" };
|
||||||
|
foreach (var r in reports) { Add(n, r.North); Add(s, r.South); }
|
||||||
|
int k = Math.Max(1, reports.Count);
|
||||||
|
n.OceanRowsPerColumn /= k; s.OceanRowsPerColumn /= k;
|
||||||
|
n.ZoneRowsPerColumn /= k; s.ZoneRowsPerColumn /= k;
|
||||||
|
n.ColumnsWithNoZone /= k; s.ColumnsWithNoZone /= k;
|
||||||
|
return (n, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Add(HemisphereDiagnosis a, HemisphereDiagnosis b)
|
||||||
|
{
|
||||||
|
a.Sea += b.Sea; a.Zone += b.Zone; a.ZoneFull += b.ZoneFull;
|
||||||
|
a.BlockDepth += b.BlockDepth; a.BlockFalloff += b.BlockFalloff; a.BlockTrench += b.BlockTrench;
|
||||||
|
a.SoleDepth += b.SoleDepth; a.SoleFalloff += b.SoleFalloff; a.SoleTrench += b.SoleTrench;
|
||||||
|
a.OceanRowsPerColumn += b.OceanRowsPerColumn; a.ZoneRowsPerColumn += b.ZoneRowsPerColumn; a.ColumnsWithNoZone += b.ColumnsWithNoZone;
|
||||||
|
a.PeaksSea += b.PeaksSea; a.PeaksInZone += b.PeaksInZone; a.PeaksInZoneOverThr += b.PeaksInZoneOverThr;
|
||||||
|
a.PeaksOverThrLost += b.PeaksOverThrLost; a.LostDepth += b.LostDepth; a.LostFalloff += b.LostFalloff; a.LostTrench += b.LostTrench;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A one-paragraph reading of the pooled numbers: which hemisphere has less eligible ocean, and which gate binds it.</summary>
|
||||||
|
public static string Interpret(HemisphereDiagnosis n, HemisphereDiagnosis s)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
string smaller = n.Zone < s.Zone ? "NORTH" : "SOUTH";
|
||||||
|
double ratio = n.Zone == 0 || s.Zone == 0 ? 0 : (double)Math.Max(n.Zone, s.Zone) / Math.Min(n.Zone, s.Zone);
|
||||||
|
sb.Append($"Valid offshore zone: north {n.Zone:N0} cells ({n.ZoneShareOfSea:P1} of its ocean, {n.ZoneRowsPerColumn:F0} rows/col), " +
|
||||||
|
$"south {s.Zone:N0} cells ({s.ZoneShareOfSea:P1} of its ocean, {s.ZoneRowsPerColumn:F0} rows/col) — the {smaller} has " +
|
||||||
|
$"{ratio:F2}× less island-eligible ocean. ");
|
||||||
|
sb.Append($"Island candidates (peaks in zone clearing the threshold): north {n.PeaksInZoneOverThr}, south {s.PeaksInZoneOverThr}; " +
|
||||||
|
$"capacity (all peaks in zone): north {n.PeaksInZone}, south {s.PeaksInZone}. ");
|
||||||
|
string Bind(HemisphereDiagnosis d)
|
||||||
|
{
|
||||||
|
long max = Math.Max(d.SoleDepth, Math.Max(d.SoleFalloff, d.SoleTrench));
|
||||||
|
string g = max == d.SoleFalloff ? "the falloff test" : max == d.SoleDepth ? "the moat (ambient depth)" : "the outer/trench bound";
|
||||||
|
long lostMax = Math.Max(d.LostDepth, Math.Max(d.LostFalloff, d.LostTrench));
|
||||||
|
string lg = d.PeaksOverThrLost == 0 ? "none" : lostMax == d.LostFalloff ? "falloff" : lostMax == d.LostDepth ? "moat" : "outer bound";
|
||||||
|
return $"{d.Name}: binding gate by sole-blocked cells = {g} (depth {d.SoleDepth:N0} / falloff {d.SoleFalloff:N0} / trench {d.SoleTrench:N0}); " +
|
||||||
|
$"over-threshold peaks lost to gates = {d.PeaksOverThrLost} (mostly {lg})";
|
||||||
|
}
|
||||||
|
sb.Append(Bind(n)).Append(". ").Append(Bind(s)).Append('.');
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/OffshoreDiagnosis.cs.uid
Normal file
1
Tools/Scripts/OffshoreDiagnosis.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://mriontjn1ihq
|
||||||
679
Tools/Scripts/OffshoreIslandsTool.cs
Normal file
679
Tools/Scripts/OffshoreIslandsTool.cs
Normal file
|
|
@ -0,0 +1,679 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE OFFSHORE-ISLANDS BATCH — chat2/06: ORGANIC-ONLY, TUNED FOR COVERAGE, SOUTH-WEIGHTED,
|
||||||
|
/// NO FORCED COUNT. (chat2/05's version of this tool — faithful / floor_only / hybrid / dense —
|
||||||
|
/// is in git history at 3b96e06; the forced floor it batched was reverted out on look.)
|
||||||
|
///
|
||||||
|
/// ⚠ Since chat2/07 the island tag is set BY THE REGION LAYER (every non-mainland land component,
|
||||||
|
/// natural islets included), so this tool's counts now include natural islands; the chat2/06 batch
|
||||||
|
/// of record (offshore-pass islands only) was produced at e8571b2.
|
||||||
|
///
|
||||||
|
/// ═══ WHAT IT PRODUCES — a fixed budget: 4 plates + a count table + a diagnosis ═══
|
||||||
|
///
|
||||||
|
/// PLATES (4, at ISLA_MAPSIZE, grayscale + .f32 + relief + tags overlay):
|
||||||
|
/// {plate}_density_low / _mid / _high three densities on ONE seed — the developer picks the
|
||||||
|
/// look by eye (more islands vs slop), apples to apples.
|
||||||
|
/// {bulge}_density_mid the preset on the southern-bulge seed — the table seed
|
||||||
|
/// whose south was SPARSEST at density_mid (auto-picked,
|
||||||
|
/// or ISLA_BULGE_SEED) — the south fix is not seed-specific.
|
||||||
|
///
|
||||||
|
/// THE COUNT TABLE (data, not plates): per seed, north / south island counts across ~12 seeds for
|
||||||
|
/// each of the 3 density levels, with min / mean / max per hemisphere per level, the guard
|
||||||
|
/// ledger and the island sizes. This is how "a few south / a couple north, consistently" is
|
||||||
|
/// READ — as statistics of the tuning, never as a floor.
|
||||||
|
///
|
||||||
|
/// THE DIAGNOSIS (data): per hemisphere over the calibration seed pool — valid-zone area, which
|
||||||
|
/// gate binds, noise peaks clearing the threshold — measured BEFORE any knob moved (§2 of the
|
||||||
|
/// task). → <see cref="OffshoreDiagnosis"/>.
|
||||||
|
///
|
||||||
|
/// ═══ THE CURVE IS THE TAGGED CURVE, UNCHANGED ═══
|
||||||
|
///
|
||||||
|
/// `continuous_restored` (tag terrain-curve-v1), calibrated on task 01's pool at the iteration
|
||||||
|
/// size WITH OFFSHORE OFF — islands are additive land on top of a curve that does not know they
|
||||||
|
/// exist. Oracle a1 / a3 / a4 prove offshore-off is bit-identical to Phase 1, task 03 and the
|
||||||
|
/// tag's own 04 gallery dump.
|
||||||
|
///
|
||||||
|
/// ═══ RUNNING IT ═══
|
||||||
|
///
|
||||||
|
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
|
||||||
|
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/OffshoreIslandsTool.tscn
|
||||||
|
///
|
||||||
|
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
|
||||||
|
/// ISLA_MAPSIZE plate + table size (default 4096 — islands need pixels to read)
|
||||||
|
/// ISLA_TABLE_SIZE count-table size (default = ISLA_MAPSIZE; a probe may drop it)
|
||||||
|
/// ISLA_CALIB_SIZE curve calibration size (default 2048, task 01's) — also the diagnosis size
|
||||||
|
/// ISLA_TABLE_SEEDS the count-table seeds (default 12 below)
|
||||||
|
/// ISLA_PLATE_SEED the three-density seed (default 1063685222)
|
||||||
|
/// ISLA_BULGE_SEED the south-bulge seed (default 0 = auto: sparsest south at density_mid)
|
||||||
|
/// ISLA_DENS_LOW / ISLA_DENS_MID / ISLA_DENS_HIGH / ISLA_SOUTH_WEIGHT the tuning (probe overrides)
|
||||||
|
/// ISLA_OFF_MINAREA / ISLA_OFF_MINSEP / ISLA_OFF_MAXAREA the guards (probe overrides)
|
||||||
|
/// ISLA_OFF_FREQ / ISLA_OFF_CORE / ISLA_OFF_SHARP / ISLA_OFF_CREST the shape (probe overrides)
|
||||||
|
/// ISLA_TABLE_ONLY=1 probe: diagnosis + count table only (no regressions, no plates)
|
||||||
|
/// ISLA_SKIP_8K=1 skip the 8192 regression against the 04 gallery dump (a4)
|
||||||
|
/// ISLA_PHASE1_SOURCE / ISLA_T03_SOURCE / ISLA_T04_SOURCE the regression dumps' batches
|
||||||
|
/// </summary>
|
||||||
|
public partial class OffshoreIslandsTool : Node
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The count-table pool: chat2/05's six hybrid seeds + task 01's calibration pool (minus the
|
||||||
|
/// shared anchor) + one more. Twelve draws; the anchor first.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly int[] DefaultTableSeeds =
|
||||||
|
{
|
||||||
|
1063685222, 20260821, 8675309, 123456789, 271828182, 999999937,
|
||||||
|
20260819, 777001, 424242, 90210, 31337, 55555,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>⚠ Task 01's pool, verbatim — the curve's identity. Also the diagnosis pool.</summary>
|
||||||
|
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
|
||||||
|
|
||||||
|
private const int DefaultMapSize = 4096;
|
||||||
|
private const int DefaultCalibSize = 2048;
|
||||||
|
private const int GallerySize = 8192; // the 04 gallery's render size
|
||||||
|
|
||||||
|
/// <summary>The consistency targets the table is read against: "a couple north, a few south".</summary>
|
||||||
|
private const int TargetNorth = 2, TargetSouth = 3;
|
||||||
|
|
||||||
|
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 string Level; public int Seed;
|
||||||
|
public int CountN, CountS, PreN, PreS, SpecksN, SpecksS, ClustersN, ClustersS, BlobsN, BlobsS;
|
||||||
|
public long Lifted, SizeMin, SizeMed, SizeMax; public double SizeMean;
|
||||||
|
public float HMaxBefore, HMaxAfter;
|
||||||
|
public bool Ok; public ulong Ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Level
|
||||||
|
{
|
||||||
|
public string Label; public OffshoreSettings Settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Run()
|
||||||
|
{
|
||||||
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
|
||||||
|
int task = EnvInt("ISLA_TASK", 6);
|
||||||
|
string descr = EnvStr("ISLA_BATCH", "offshore_organic_tune");
|
||||||
|
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
|
||||||
|
int tableSize = EnvInt("ISLA_TABLE_SIZE", mapSize);
|
||||||
|
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
|
||||||
|
int[] tableSeeds = EnvSeeds("ISLA_TABLE_SEEDS", DefaultTableSeeds);
|
||||||
|
int plateSeed = EnvInt("ISLA_PLATE_SEED", 1063685222);
|
||||||
|
int bulgeSeedEnv = EnvInt("ISLA_BULGE_SEED", 0);
|
||||||
|
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
|
||||||
|
string t03Source = EnvStr("ISLA_T03_SOURCE", "03_mountain_restore");
|
||||||
|
string t04Source = EnvStr("ISLA_T04_SOURCE", "04_seed_gallery");
|
||||||
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
|
bool tableOnly = EnvStr("ISLA_TABLE_ONLY", "0") == "1";
|
||||||
|
bool skip8k = EnvStr("ISLA_SKIP_8K", "0") == "1";
|
||||||
|
|
||||||
|
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
|
||||||
|
|
||||||
|
var anchors = CurveAnchors.Default;
|
||||||
|
float sea = 0.15f;
|
||||||
|
|
||||||
|
// ═══ THE THREE DENSITY LEVELS — the preset of record is `mid` ═══
|
||||||
|
OffshoreSettings LevelSettings(float density)
|
||||||
|
{
|
||||||
|
var o = OffshoreSettings.Organic();
|
||||||
|
o.Density = density;
|
||||||
|
o.SouthWeight = EnvFloat("ISLA_SOUTH_WEIGHT", o.SouthWeight);
|
||||||
|
o.MinIslandAreaFrac = EnvFloat("ISLA_OFF_MINAREA", o.MinIslandAreaFrac);
|
||||||
|
o.MinSeparationFrac = EnvFloat("ISLA_OFF_MINSEP", o.MinSeparationFrac);
|
||||||
|
o.MaxIslandAreaFrac = EnvFloat("ISLA_OFF_MAXAREA", o.MaxIslandAreaFrac);
|
||||||
|
o.FreqPerMapWidth = EnvFloat("ISLA_OFF_FREQ", o.FreqPerMapWidth);
|
||||||
|
o.CoreFraction = EnvFloat("ISLA_OFF_CORE", o.CoreFraction);
|
||||||
|
o.EdgeSharpness = EnvFloat("ISLA_OFF_SHARP", o.EdgeSharpness);
|
||||||
|
o.CrestM = EnvFloat("ISLA_OFF_CREST", o.CrestM);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
var levels = new List<Level>
|
||||||
|
{
|
||||||
|
new() { Label = "density_low", Settings = LevelSettings(EnvFloat("ISLA_DENS_LOW", OffshoreSettings.OrganicDensityLow)) },
|
||||||
|
new() { Label = "density_mid", Settings = LevelSettings(EnvFloat("ISLA_DENS_MID", OffshoreSettings.OrganicDensityMid)) },
|
||||||
|
new() { Label = "density_high", Settings = LevelSettings(EnvFloat("ISLA_DENS_HIGH", OffshoreSettings.OrganicDensityHigh)) },
|
||||||
|
};
|
||||||
|
Level mid = levels[1];
|
||||||
|
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print(" OFFSHORE ISLANDS (chat2/06) — organic-only, tuned for coverage, south-weighted, no forced count");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print($"MapSize : {mapSize} (plates) table at {tableSize} curve calibrated at {calibSize} (offshore OFF) diagnosis at {calibSize}");
|
||||||
|
GD.Print($"table : {string.Join(", ", tableSeeds)}");
|
||||||
|
GD.Print($"plate seed: {plateSeed} bulge seed: {(bulgeSeedEnv > 0 ? bulgeSeedEnv.ToString() : "auto (sparsest south at density_mid)")}");
|
||||||
|
foreach (var l in levels) GD.Print($" {l.Label,-13} {l.Settings.Describe()}");
|
||||||
|
GD.Print($"hemisphere: NORTH = rows [0, {mapSize / 2}) SOUTH = rows [{mapSize / 2}, {mapSize}) (y runs south)");
|
||||||
|
GD.Print($"batch : {batchRoot}{(tableOnly ? " ⚠ ISLA_TABLE_ONLY — a probe, not the batch of record" : "")}");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
|
||||||
|
// ═══ 0. THE CURVE — continuous_restored, calibrated with offshore off ═══
|
||||||
|
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
|
||||||
|
var (knots, calibration, _) = CalibrateCurve(calibSize, sea, anchors);
|
||||||
|
GD.Print($" {knots}");
|
||||||
|
GD.Print($" {calibration.Describe()}");
|
||||||
|
|
||||||
|
// ═══ 1. REGRESSIONS — the things that must not have moved ═══
|
||||||
|
var hard = new List<ShapingOracle.Check>();
|
||||||
|
if (!tableOnly)
|
||||||
|
{
|
||||||
|
GD.Print($"\n--- 1. REGRESSIONS at {calibSize}, seed {plateSeed} ---");
|
||||||
|
var offCfg = BaseConfig(calibSize, plateSeed, knots, anchors, calibration, "off");
|
||||||
|
Pass1Result p1 = Topography.Generate(offCfg);
|
||||||
|
|
||||||
|
var curveOff = offCfg.Clone(); curveOff.Curve = false;
|
||||||
|
Pass2Result pOff = Shaping.Shape(p1, curveOff);
|
||||||
|
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{plateSeed}_full", "height.f32");
|
||||||
|
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, offshore OFF == Phase-1 .f32 dump",
|
||||||
|
pOff.Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
|
||||||
|
|
||||||
|
Pass2Result pRest = Shaping.Shape(p1, offCfg);
|
||||||
|
string t03Dump = Path.Combine(ToolingPaths.BatchesRoot, t03Source, $"{plateSeed}_continuous_restored", "height.f32");
|
||||||
|
hard.Add(ShapingOracle.DumpRegression("a3", "continuous_restored, offshore OFF == task-03 .f32 dump (lowlands + curve untouched)",
|
||||||
|
pRest.Height, HeightField.Load(t03Dump, calibSize), calibSize, t03Dump));
|
||||||
|
|
||||||
|
// Shelf ON, islets OFF: land must be bit-identical (the shelf touches only sea).
|
||||||
|
var shelfCfg = offCfg.Clone(); shelfCfg.CoastShelf = true; shelfCfg.VariantLabel = "shelf_only";
|
||||||
|
Pass1Result p1Shelf = Topography.Generate(shelfCfg);
|
||||||
|
var j0 = ShapingOracle.MainlandUnmoved(p1, p1Shelf, sea);
|
||||||
|
j0.Name = "shelf alone: every land cell bit-identical (shelf is below-sea only)";
|
||||||
|
hard.Add(j0);
|
||||||
|
hard.Add(ShapingOracle.HMaxAfterOffshore(p1Shelf));
|
||||||
|
foreach (var c in hard) GD.Print(" " + c);
|
||||||
|
|
||||||
|
// ⭐ a4 — offshore OFF at the 04 gallery's size == the terrain-curve-v1 tag's OWN output.
|
||||||
|
// The literal "offshore-off is bit-identical to terrain-curve-v1", at full size.
|
||||||
|
if (!skip8k)
|
||||||
|
{
|
||||||
|
string t04Dump = Path.Combine(ToolingPaths.BatchesRoot, t04Source, $"{plateSeed}", "height.f32");
|
||||||
|
if (File.Exists(t04Dump))
|
||||||
|
{
|
||||||
|
GD.Print($" a4: generating {plateSeed} at {GallerySize}, offshore OFF, against {t04Dump} …");
|
||||||
|
var gCfg = BaseConfig(GallerySize, plateSeed, knots, anchors, calibration, "off");
|
||||||
|
Pass2Result pG = Shaping.Shape(Topography.Generate(gCfg), gCfg);
|
||||||
|
var a4 = ShapingOracle.DumpRegression("a4", $"offshore OFF at {GallerySize} == terrain-curve-v1's 04 gallery .f32 dump",
|
||||||
|
pG.Height, HeightField.Load(t04Dump, GallerySize), GallerySize, t04Dump);
|
||||||
|
hard.Add(a4);
|
||||||
|
GD.Print(" " + a4);
|
||||||
|
}
|
||||||
|
else GD.Print($" a4: ⚠ skipped — no 04 gallery dump at {t04Dump}");
|
||||||
|
}
|
||||||
|
else GD.Print(" a4: skipped (ISLA_SKIP_8K)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 2. THE DIAGNOSIS — measure the south before touching a knob ═══
|
||||||
|
GD.Print($"\n--- 2. DIAGNOSIS (calibration pool at {calibSize}, shelf on, {mid.Label} thresholds) ---");
|
||||||
|
var diag = new List<OffshoreDiagnosis.Report>();
|
||||||
|
foreach (int s in CalibrationSeeds)
|
||||||
|
{
|
||||||
|
var dCfg = BaseConfig(calibSize, s, knots, anchors, calibration, "shelf_only");
|
||||||
|
dCfg.CoastShelf = true;
|
||||||
|
Pass1Result pShelf = Topography.Generate(dCfg);
|
||||||
|
var rep = OffshoreDiagnosis.Run(pShelf.Height, pShelf.PreTrenchFalloff, calibSize, s, sea, mid.Settings, dCfg.Scale);
|
||||||
|
diag.Add(rep);
|
||||||
|
GD.Print($" seed {s,-11} N: zone {rep.North.Zone,9:N0} ({rep.North.ZoneShareOfSea,6:P1}) peaks in zone {rep.North.PeaksInZone,3} > thr {rep.North.PeaksInZoneOverThr,3} " +
|
||||||
|
$"S: zone {rep.South.Zone,9:N0} ({rep.South.ZoneShareOfSea,6:P1}) peaks in zone {rep.South.PeaksInZone,3} > thr {rep.South.PeaksInZoneOverThr,3} " +
|
||||||
|
$"sole-blocked N d/f/t {rep.North.SoleDepth:N0}/{rep.North.SoleFalloff:N0}/{rep.North.SoleTrench:N0} S {rep.South.SoleDepth:N0}/{rep.South.SoleFalloff:N0}/{rep.South.SoleTrench:N0}");
|
||||||
|
}
|
||||||
|
var (poolN, poolS) = OffshoreDiagnosis.Pool(diag);
|
||||||
|
string interpretation = OffshoreDiagnosis.Interpret(poolN, poolS);
|
||||||
|
GD.Print(" " + interpretation);
|
||||||
|
|
||||||
|
// ═══ 3. THE COUNT TABLE — ~12 seeds × 3 levels ═══
|
||||||
|
GD.Print($"\n--- 3. COUNT TABLE at {tableSize} ---");
|
||||||
|
var rows = new List<Row>();
|
||||||
|
var perFieldChecks = new List<ShapingOracle.Check>();
|
||||||
|
bool notesShown = false;
|
||||||
|
foreach (int seed in tableSeeds)
|
||||||
|
{
|
||||||
|
Pass1Result p1Off = Topography.Generate(BaseConfig(tableSize, seed, knots, anchors, calibration, "off"));
|
||||||
|
foreach (var lv in levels)
|
||||||
|
{
|
||||||
|
var cfg = BaseConfig(tableSize, seed, knots, anchors, calibration, lv.Label);
|
||||||
|
cfg.CoastShelf = true; cfg.Offshore = lv.Settings.Clone();
|
||||||
|
Pass1Result p1 = Topography.Generate(cfg);
|
||||||
|
Pass2Result p2 = Shaping.Shape(p1, cfg);
|
||||||
|
if (!notesShown) { foreach (string n in p1.Notes) GD.Print(" " + n); notesShown = true; }
|
||||||
|
|
||||||
|
var comps = OffshoreAnalysis.Components(p1.IsIsland, p1.Height, sea, tableSize);
|
||||||
|
var (cn, cs) = OffshoreAnalysis.CountByHemisphere(comps);
|
||||||
|
var (szMin, szMed, szMean, szMax, _) = OffshoreAnalysis.SizeSummary(comps, 0);
|
||||||
|
var checks = new List<ShapingOracle.Check>
|
||||||
|
{
|
||||||
|
ShapingOracle.MoatIntact(p1, comps),
|
||||||
|
ShapingOracle.MainlandUnmoved(p1Off, p1, sea),
|
||||||
|
ShapingOracle.TagCoastlineConsistent(p2, sea),
|
||||||
|
ShapingOracle.HMaxAfterOffshore(p1),
|
||||||
|
ShapingOracle.ClassifyFidelity(p1, p2),
|
||||||
|
};
|
||||||
|
foreach (var c in checks) { c.Name += $" [{lv.Label} {seed}]"; perFieldChecks.Add(c); }
|
||||||
|
bool ok = checks.TrueForAll(c => c.Passed);
|
||||||
|
|
||||||
|
var row = new Row
|
||||||
|
{
|
||||||
|
Level = lv.Label, Seed = seed, CountN = cn, CountS = cs,
|
||||||
|
Lifted = p1.OffshoreLiftedCells, HMaxBefore = p1.HMaxSeedBeforeOffshore, HMaxAfter = p1.HMaxSeed,
|
||||||
|
SizeMin = szMin, SizeMed = szMed, SizeMean = szMean, SizeMax = szMax, Ok = ok, Ms = p1.ElapsedMs,
|
||||||
|
};
|
||||||
|
ReadGuardLedger(p1, row);
|
||||||
|
rows.Add(row);
|
||||||
|
GD.Print($" {lv.Label,-13} seed {seed,-11} N {cn,2} S {cs,2} (pre-guard N {row.PreN,2} S {row.PreS,2}; specks {row.SpecksN + row.SpecksS,2} clusters {row.ClustersN + row.ClustersS,2} blobs {row.BlobsN + row.BlobsS,2}) " +
|
||||||
|
$"size med {szMed,5} max {szMax,6} {(ok ? "ok" : "⚠ CHECK FAILED")} {p1.ElapsedMs} ms");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var stats = LevelStats(levels, rows);
|
||||||
|
GD.Print("\n per level (min / mean / max):");
|
||||||
|
foreach (var st in stats)
|
||||||
|
GD.Print($" {st.Label,-13} N {st.MinN} / {st.MeanN:F1} / {st.MaxN} S {st.MinS} / {st.MeanS:F1} / {st.MaxS} " +
|
||||||
|
$"seeds with N≥{TargetNorth} & S≥{TargetSouth}: {st.MeetBoth}/{st.Seeds} S≥N: {st.SouthAtLeastNorth}/{st.Seeds} " +
|
||||||
|
$"guards: specks {st.Specks} clusters {st.Clusters} blobs {st.Blobs}");
|
||||||
|
|
||||||
|
// ═══ 4. THE PLATES — exactly four ═══
|
||||||
|
int bulgeSeed = bulgeSeedEnv > 0 ? bulgeSeedEnv : PickBulgeSeed(rows, mid.Label, plateSeed);
|
||||||
|
GD.Print($"\n southern-bulge seed: {bulgeSeed}{(bulgeSeedEnv > 0 ? " (ISLA_BULGE_SEED)" : " (auto: sparsest south at density_mid among the table seeds)")}");
|
||||||
|
var plates = new List<(int seed, Level level)> { (plateSeed, levels[0]), (plateSeed, levels[1]), (plateSeed, levels[2]), (bulgeSeed, mid) };
|
||||||
|
var plateRows = new List<Row>();
|
||||||
|
if (!tableOnly)
|
||||||
|
{
|
||||||
|
GD.Print($"\n--- 4. PLATES at {mapSize} ---");
|
||||||
|
foreach (var (seed, lv) in plates)
|
||||||
|
{
|
||||||
|
var cfg = BaseConfig(mapSize, seed, knots, anchors, calibration, lv.Label);
|
||||||
|
cfg.CoastShelf = true; cfg.Offshore = lv.Settings.Clone();
|
||||||
|
Pass1Result p1 = Topography.Generate(cfg);
|
||||||
|
Pass2Result p2 = Shaping.Shape(p1, cfg);
|
||||||
|
var comps = OffshoreAnalysis.Components(p1.IsIsland, p1.Height, sea, mapSize);
|
||||||
|
var (cn, cs) = OffshoreAnalysis.CountByHemisphere(comps);
|
||||||
|
var moat = ShapingOracle.MoatIntact(p1, comps); moat.Name += $" [plate {lv.Label} {seed}]";
|
||||||
|
var tag = ShapingOracle.TagCoastlineConsistent(p2, sea); tag.Name += $" [plate {lv.Label} {seed}]";
|
||||||
|
perFieldChecks.Add(moat); perFieldChecks.Add(tag);
|
||||||
|
WritePlate(batchRoot, p1, p2, sea, anchors, skipRaw, cn, cs);
|
||||||
|
var row = new Row { Level = lv.Label, Seed = seed, CountN = cn, CountS = cs, Lifted = p1.OffshoreLiftedCells, Ok = moat.Passed && tag.Passed };
|
||||||
|
ReadGuardLedger(p1, row);
|
||||||
|
plateRows.Add(row);
|
||||||
|
GD.Print($" plate {seed}_{lv.Label}: N {cn} S {cs} lifted {p1.OffshoreLiftedCells:N0} {(row.Ok ? "ok" : "⚠ CHECK FAILED")} {p1.ElapsedMs} ms");
|
||||||
|
foreach (string n in p1.Notes) if (n.Contains("guards") || n.Contains("islands:")) GD.Print(" " + n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool allOk = hard.TrueForAll(c => c.Passed) && perFieldChecks.TrueForAll(c => c.Passed);
|
||||||
|
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
|
||||||
|
foreach (var c in perFieldChecks) if (!c.Passed) GD.PrintErr(" " + c);
|
||||||
|
|
||||||
|
// ═══ 5. THE DATA FILES + INDEX ═══
|
||||||
|
WriteCountTable(batchRoot, tableSize, levels, rows, stats);
|
||||||
|
WriteDiagnosis(batchRoot, calibSize, mid, diag, poolN, poolS, interpretation);
|
||||||
|
WriteIndex(batchRoot, mapSize, tableSize, calibSize, plateSeed, bulgeSeed, tableSeeds, levels, rows, stats, plateRows,
|
||||||
|
diag, poolN, poolS, interpretation, hard, perFieldChecks, allOk, tableOnly);
|
||||||
|
|
||||||
|
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 table's statistics -------------------------------------------
|
||||||
|
|
||||||
|
private sealed class LevelStat
|
||||||
|
{
|
||||||
|
public string Label; public int Seeds;
|
||||||
|
public int MinN, MaxN, MinS, MaxS; public double MeanN, MeanS;
|
||||||
|
public int MeetBoth, SouthAtLeastNorth, Specks, Clusters, Blobs;
|
||||||
|
public long SizeMed, SizeMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<LevelStat> LevelStats(List<Level> levels, List<Row> rows)
|
||||||
|
{
|
||||||
|
var outp = new List<LevelStat>();
|
||||||
|
foreach (var lv in levels)
|
||||||
|
{
|
||||||
|
var st = new LevelStat { Label = lv.Label, MinN = int.MaxValue, MinS = int.MaxValue };
|
||||||
|
double sumN = 0, sumS = 0; var meds = new List<long>();
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
if (r.Level != lv.Label) continue;
|
||||||
|
st.Seeds++;
|
||||||
|
st.MinN = Math.Min(st.MinN, r.CountN); st.MaxN = Math.Max(st.MaxN, r.CountN); sumN += r.CountN;
|
||||||
|
st.MinS = Math.Min(st.MinS, r.CountS); st.MaxS = Math.Max(st.MaxS, r.CountS); sumS += r.CountS;
|
||||||
|
if (r.CountN >= TargetNorth && r.CountS >= TargetSouth) st.MeetBoth++;
|
||||||
|
if (r.CountS >= r.CountN) st.SouthAtLeastNorth++;
|
||||||
|
st.Specks += r.SpecksN + r.SpecksS; st.Clusters += r.ClustersN + r.ClustersS; st.Blobs += r.BlobsN + r.BlobsS;
|
||||||
|
meds.Add(r.SizeMed); st.SizeMax = Math.Max(st.SizeMax, r.SizeMax);
|
||||||
|
}
|
||||||
|
if (st.Seeds == 0) { st.MinN = st.MinS = 0; }
|
||||||
|
else { st.MeanN = sumN / st.Seeds; st.MeanS = sumS / st.Seeds; meds.Sort(); st.SizeMed = meds[meds.Count / 2]; }
|
||||||
|
outp.Add(st);
|
||||||
|
}
|
||||||
|
return outp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The table seed whose SOUTH count at the preset level is lowest (ties → lower total, then first in the pool), excluding the plate seed.</summary>
|
||||||
|
private static int PickBulgeSeed(List<Row> rows, string midLabel, int plateSeed)
|
||||||
|
{
|
||||||
|
int best = 0, bestS = int.MaxValue, bestTotal = int.MaxValue;
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
if (r.Level != midLabel || r.Seed == plateSeed) continue;
|
||||||
|
int total = r.CountN + r.CountS;
|
||||||
|
if (r.CountS < bestS || (r.CountS == bestS && total < bestTotal)) { best = r.Seed; bestS = r.CountS; bestTotal = total; }
|
||||||
|
}
|
||||||
|
return best == 0 ? plateSeed : best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Copy the pass's guard ledger into a table row (the pass reports it; the tool does not recompute it).</summary>
|
||||||
|
private static void ReadGuardLedger(Pass1Result p1, Row row)
|
||||||
|
{
|
||||||
|
var l = p1.OffshoreLedger;
|
||||||
|
if (l == null) return;
|
||||||
|
row.PreN = l.PreGuardNorth; row.PreS = l.PreGuardSouth;
|
||||||
|
row.SpecksN = l.SpecksNorth; row.SpecksS = l.SpecksSouth;
|
||||||
|
row.ClustersN = l.ClustersNorth; row.ClustersS = l.ClustersSouth;
|
||||||
|
row.BlobsN = l.BlobsNorth; row.BlobsS = l.BlobsSouth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the curve, measured exactly as tasks 03/04/05 did ----------------
|
||||||
|
|
||||||
|
private static (CurveKnots, ClimbCalibration, Dictionary<int, Pass1Result>) 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 }); // offshore OFF by default
|
||||||
|
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, pass1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a,
|
||||||
|
ClimbCalibration cal, string label) => new TerrainGenConfig
|
||||||
|
{
|
||||||
|
MapSize = mapSize, Seed = seed, VariantLabel = label,
|
||||||
|
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||||||
|
Knots = k, Anchors = a, ClimbCalibration = cal, LowlandCeilingM = 30f,
|
||||||
|
CoastShelf = false, Offshore = new OffshoreSettings(), // OFF unless the variant turns it on
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- output -----------------------------------------------------------
|
||||||
|
|
||||||
|
private static void WritePlate(string batchRoot, Pass1Result p1, Pass2Result p2, float sea,
|
||||||
|
CurveAnchors anchors, bool skipRaw, int countN, int countS)
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||||||
|
|
||||||
|
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 = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
|
||||||
|
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f,
|
||||||
|
HillshadeStrength = 0.30f, SeaLevel = sea,
|
||||||
|
};
|
||||||
|
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
|
||||||
|
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, $"{p2.VariantLabel.ToUpperInvariant()} {p2.Seed}")
|
||||||
|
.SavePng(Path.Combine(dir, "relief.png"));
|
||||||
|
|
||||||
|
// ⭐ The tag overlay — the one artifact that shows the DATA this pass set.
|
||||||
|
TagOverlayRenderer.SavePng(p2.Height, p2.IsIsland, p2.IslandHemisphere, p2.MapSize, sea,
|
||||||
|
countN, countS, Path.Combine(dir, "tags.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CountTableMarkdown(List<Level> levels, List<Row> rows, List<LevelStat> stats)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("| Level | Seed | **N** | **S** | total | pre-guard N / S | specks | clusters | blobs | size cells min / median / mean / max | lifted cells | HMaxSeed before → after | oracle | ms |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (var lv in levels)
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
if (r.Level != lv.Label) continue;
|
||||||
|
sb.AppendLine($"| `{r.Level}` | `{r.Seed}` | **{r.CountN}** | **{r.CountS}** | {r.CountN + r.CountS} | {r.PreN} / {r.PreS} | " +
|
||||||
|
$"{r.SpecksN + r.SpecksS} | {r.ClustersN + r.ClustersS} | {r.BlobsN + r.BlobsS} | " +
|
||||||
|
$"{r.SizeMin} / {r.SizeMed} / {r.SizeMean:F0} / {r.SizeMax} | {r.Lifted:N0} | " +
|
||||||
|
$"{r.HMaxBefore:F4} → {r.HMaxAfter:F4}{(r.HMaxBefore != r.HMaxAfter ? " ⚠" : "")} | {(r.Ok ? "pass" : "**FAIL**")} | {r.Ms} |");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("**Per level — the consistency read (min / mean / max over the seeds):**");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"| Level | density N / S | seeds | **N min / mean / max** | **S min / mean / max** | seeds with N ≥ {TargetNorth} & S ≥ {TargetSouth} | seeds with S ≥ N | specks / clusters / blobs reverted | median island (cells) | largest island (cells) |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (var st in stats)
|
||||||
|
{
|
||||||
|
var lv = levels.Find(l => l.Label == st.Label);
|
||||||
|
sb.AppendLine($"| `{st.Label}` | {lv.Settings.Density:F4} / {lv.Settings.DensitySouth:F4} | {st.Seeds} | **{st.MinN} / {st.MeanN:F1} / {st.MaxN}** | **{st.MinS} / {st.MeanS:F1} / {st.MaxS}** | " +
|
||||||
|
$"**{st.MeetBoth} / {st.Seeds}** | {st.SouthAtLeastNorth} / {st.Seeds} | {st.Specks} / {st.Clusters} / {st.Blobs} | {st.SizeMed} | {st.SizeMax} |");
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteCountTable(string batchRoot, int tableSize, List<Level> levels, List<Row> rows, List<LevelStat> stats)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"# The count table — {rows.Count / Math.Max(1, levels.Count)} seeds × {levels.Count} density levels at {tableSize}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Tagged 8-connected island components per hemisphere (centroid), after the guards. **No count is forced**;");
|
||||||
|
sb.AppendLine("this is the statistical outcome of the tuning. NORTH = rows [0, N/2), SOUTH = rows [N/2, N); y runs south.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(CountTableMarkdown(levels, rows, stats));
|
||||||
|
WriteText(Path.Combine(batchRoot, "count_table.md"), sb.ToString());
|
||||||
|
|
||||||
|
var csv = new StringBuilder();
|
||||||
|
csv.AppendLine("level,seed,density_n,density_s,north,south,total,preguard_n,preguard_s,specks,clusters,blobs,size_min,size_median,size_mean,size_max,lifted_cells,hmax_before,hmax_after,oracle,ms");
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
var lv = levels.Find(l => l.Label == r.Level);
|
||||||
|
csv.AppendLine(string.Join(",", r.Level, r.Seed, lv.Settings.Density.ToString("F5", System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
lv.Settings.DensitySouth.ToString("F5", System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
r.CountN, r.CountS, r.CountN + r.CountS, r.PreN, r.PreS, r.SpecksN + r.SpecksS, r.ClustersN + r.ClustersS, r.BlobsN + r.BlobsS,
|
||||||
|
r.SizeMin, r.SizeMed, r.SizeMean.ToString("F1", System.Globalization.CultureInfo.InvariantCulture), r.SizeMax, r.Lifted,
|
||||||
|
r.HMaxBefore.ToString("G9", System.Globalization.CultureInfo.InvariantCulture), r.HMaxAfter.ToString("G9", System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
r.Ok ? "pass" : "FAIL", r.Ms));
|
||||||
|
}
|
||||||
|
WriteText(Path.Combine(batchRoot, "count_table.csv"), csv.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DiagnosisMarkdown(int calibSize, Level mid, List<OffshoreDiagnosis.Report> diag,
|
||||||
|
HemisphereDiagnosis poolN, HemisphereDiagnosis poolS, string interpretation)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"Measured on the calibration pool at {calibSize} (shelf on, offshore off — the field the islet layer sees), with the");
|
||||||
|
sb.AppendLine($"`{mid.Label}` thresholds (density N {mid.Settings.Density:F4} / S {mid.Settings.DensitySouth:F4}). *Zone* = cells with zone weight > 0");
|
||||||
|
sb.AppendLine("(pass the falloff test + moat + outer bound); *peaks* = strict 8-neighbour local maxima of the islet noise field on sea cells;");
|
||||||
|
sb.AppendLine("*sole blocker* = sea cells that fail exactly one gate (loosen that gate and they join the zone); *lost > thr* = over-threshold");
|
||||||
|
sb.AppendLine("peaks outside the zone, with the gate(s) that excluded them.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(OffshoreDiagnosis.TableHeader());
|
||||||
|
foreach (var r in diag)
|
||||||
|
{
|
||||||
|
sb.AppendLine(OffshoreDiagnosis.TableRow(r, r.North));
|
||||||
|
sb.AppendLine(OffshoreDiagnosis.TableRow(r, r.South));
|
||||||
|
}
|
||||||
|
var poolRep = new OffshoreDiagnosis.Report { Seed = 0 };
|
||||||
|
sb.AppendLine(OffshoreDiagnosis.TableRow(poolRep, poolN).Replace("| `0` |", "| **pool** |"));
|
||||||
|
sb.AppendLine(OffshoreDiagnosis.TableRow(poolRep, poolS).Replace("| `0` |", "| **pool** |"));
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"**Reading:** {interpretation}");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteDiagnosis(string batchRoot, int calibSize, Level mid, List<OffshoreDiagnosis.Report> diag,
|
||||||
|
HemisphereDiagnosis poolN, HemisphereDiagnosis poolS, string interpretation)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("# The south diagnosis — zone area, gates and peaks per hemisphere");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(DiagnosisMarkdown(calibSize, mid, diag, poolN, poolS, interpretation));
|
||||||
|
WriteText(Path.Combine(batchRoot, "diagnosis.md"), sb.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteIndex(string batchRoot, int mapSize, int tableSize, int calibSize, int plateSeed, int bulgeSeed,
|
||||||
|
int[] tableSeeds, List<Level> levels, List<Row> rows, List<LevelStat> stats, List<Row> plateRows,
|
||||||
|
List<OffshoreDiagnosis.Report> diag, HemisphereDiagnosis poolN, HemisphereDiagnosis poolS, string interpretation,
|
||||||
|
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perField, bool allOk, bool tableOnly)
|
||||||
|
{
|
||||||
|
var mid = levels[1];
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("# Batch 06 — offshore islands: organic-only, tuned for coverage, south-weighted, no forced count");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("The chat2/05 forced floor (seeded stamps, guaranteed ≥2 N / ≥4 S) is **reverted out** — it looked stamped.");
|
||||||
|
sb.AppendLine("The **organic noise-field layer is the only island mechanism**; this batch tunes its **density** (the main");
|
||||||
|
sb.AppendLine("knob) and a **south weight** so it yields *a few south / a couple north* **consistently across seeds, as a");
|
||||||
|
sb.AppendLine("statistical outcome** — never a hard-coded count. Guards against slop: specks, clusters and blobs are");
|
||||||
|
sb.AppendLine("reverted whole; every surviving island is a noise outline, small, low, crisp.");
|
||||||
|
sb.AppendLine();
|
||||||
|
if (tableOnly) sb.AppendLine("> ⚠ **ISLA_TABLE_ONLY** — a probe run: diagnosis + count table only, no regressions, no plates. Not the batch of record.\n");
|
||||||
|
sb.AppendLine("## ⭐ Open this first");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"1. **`{plateSeed}_density_mid/tags.png`** — the preset of record: grey mainland, cyan = island N, orange = island S.");
|
||||||
|
sb.AppendLine(" No rings any more — nothing is seeded. Then its `relief.png` for the shape.");
|
||||||
|
sb.AppendLine($"2. **`{plateSeed}_density_low/`** and **`{plateSeed}_density_high/`** beside it — the same seed, less and more density;");
|
||||||
|
sb.AppendLine(" pick the look by eye (more islands vs slop).");
|
||||||
|
sb.AppendLine($"3. **`{bulgeSeed}_density_mid/tags.png`** — the preset on the southern-bulge seed (the table seed whose south was");
|
||||||
|
sb.AppendLine(" sparsest at `density_mid`): the south tuning is not seed-specific.");
|
||||||
|
sb.AppendLine("4. Then the count table below — *does it consistently give a few south / a couple north?*");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"**Hemisphere convention (from the code, not invented):** y runs SOUTH. NORTH = rows `[0, {mapSize / 2})`,");
|
||||||
|
sb.AppendLine($"SOUTH = rows `[{mapSize / 2}, {mapSize})`. Component hemisphere is by centroid; the tag per cell is by row.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The four plates");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Plate | N | S | total | pre-guard N / S | specks / clusters / blobs | lifted cells | oracle |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (var r in plateRows)
|
||||||
|
sb.AppendLine($"| `{r.Seed}_{r.Level}/` | **{r.CountN}** | **{r.CountS}** | {r.CountN + r.CountS} | {r.PreN} / {r.PreS} | {r.SpecksN + r.SpecksS} / {r.ClustersN + r.ClustersS} / {r.BlobsN + r.BlobsS} | {r.Lifted:N0} | {(r.Ok ? "pass" : "**FAIL**")} |");
|
||||||
|
if (plateRows.Count == 0) sb.AppendLine("| *(no plates — probe run)* | | | | | | | |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"## ⭐ The count table — {tableSeeds.Length} seeds × 3 levels at {tableSize} (the consistency evidence)");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(CountTableMarkdown(levels, rows, stats));
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Also as plain data: `count_table.md` / `count_table.csv`.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The levels — density and south weight");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Level | settings |");
|
||||||
|
sb.AppendLine("|---|---|");
|
||||||
|
foreach (var lv in levels) sb.AppendLine($"| `{lv.Label}`{(lv == mid ? " ⭐ preset of record" : "")} | {lv.Settings.Describe()} |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("The coast shelf is ON for every field (strength 0.775, scale 100 m, BitDecrement-clamped); invisible on these");
|
||||||
|
sb.AppendLine("hypsometric plates — ported faithfully, judged when water renders. Moat / falloff test / outer bound unchanged from chat2/05.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The south diagnosis — measured before tuning");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(DiagnosisMarkdown(calibSize, mid, diag, poolN, poolS, interpretation));
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Also as `diagnosis.md`.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("`ProvisionalEven`, flagged. Grayscale + `tags.png` are the honest instruments here.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The oracle");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Regressions (offshore OFF must be bit-identical to Phase 1, task 03 and the `terrain-curve-v1` gallery dump):");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(hard.Count == 0 ? "*(skipped — probe run)*\n" : ShapingOracle.ToMarkdownTable(hard));
|
||||||
|
sb.AppendLine("Per field (moat i · mainland unmoved j · tag/coastline k · HMaxSeed l · classify b):");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(ShapingOracle.ToMarkdownTable(perField));
|
||||||
|
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("| `tags.png`, `relief.png`, `INDEX.md`, `count_table.md` / `.csv`, `diagnosis.md` | **keep** |");
|
||||||
|
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
|
||||||
|
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + code — large, clear freely |");
|
||||||
|
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Plates at {mapSize}, table at {tableSize}, curve calibrated at {calibSize} with offshore off. {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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/OffshoreIslandsTool.cs.uid
Normal file
1
Tools/Scripts/OffshoreIslandsTool.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bvrfybmk7pvyn
|
||||||
404
Tools/Scripts/OffshorePass.cs
Normal file
404
Tools/Scripts/OffshorePass.cs
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ PASS 1b — THE COAST SHELF AND THE OFFSHORE ISLETS (chat2/05, retuned chat2/06). Runs over
|
||||||
|
/// the finished pass-1 arrays, IN PLACE, before <c>HMaxSeed</c> is taken and before anything
|
||||||
|
/// classifies.
|
||||||
|
///
|
||||||
|
/// ═══ WHERE THIS SITS, AND WHY IT IS A SECOND SWEEP ═══
|
||||||
|
///
|
||||||
|
/// The reference did all of this INSIDE the pass-1 pixel loop (<c>MapGenerator.cs:621-664</c>):
|
||||||
|
/// combine → shelf → islets → <c>_hMaxSeed</c> → write. v2 runs the same arithmetic as a second
|
||||||
|
/// sweep over the arrays the first loop produced. Per pixel the inputs are identical — the raw
|
||||||
|
/// height, the sea level, the pre-Trench falloff, (x, y) — and the operations are applied in the
|
||||||
|
/// same order on the same floats, so the FAITHFUL mode reproduces the reference cell for cell.
|
||||||
|
/// The second sweep is also what lets the slop guards see whole islands: a component rule needs
|
||||||
|
/// the finished field.
|
||||||
|
///
|
||||||
|
/// ⚠⚠ THE ORDERING THAT CLOSES chat2/00 DRIFT §2: the caller recomputes <c>HMaxSeed</c> AFTER this
|
||||||
|
/// pass, as the reference did, so the curve's per-seed peak normalization sees the same maximum
|
||||||
|
/// the reference saw. Expected to be unchanged (a ~34 m crest is far below any peak) — reported,
|
||||||
|
/// not assumed.
|
||||||
|
///
|
||||||
|
/// ═══ THE SUB-PASSES ═══
|
||||||
|
///
|
||||||
|
/// 1. SHELF every below-sea cell; depth-preserving; held strictly below sea by BitDecrement.
|
||||||
|
/// 2. ORGANIC ⭐ THE ONE ISLAND MECHANISM — the reference's noise layer, faithful or reshaped
|
||||||
|
/// (smaller / lower / flatter / crisper, density + south weight), every below-sea
|
||||||
|
/// cell inside the zone mask. Nothing places an island from a centre; nothing
|
||||||
|
/// guarantees a count. (chat2/05's seeded floor was reverted out in chat2/06 — it
|
||||||
|
/// looked stamped. It lives in git history.)
|
||||||
|
/// 3. GUARDS (Organic only) specks, clusters and blobs reverted BY COMPONENT, then the
|
||||||
|
/// reference's submerged humps outside any surviving island's skirt.
|
||||||
|
///
|
||||||
|
/// ═══ THE TWO PROTECTIONS ═══
|
||||||
|
///
|
||||||
|
/// The moat (min depth) and the "actually offshore" test (pre-Trench falloff) gate the organic
|
||||||
|
/// layer per pixel — that is the reference. Nothing in this pass can raise a cell outside the
|
||||||
|
/// zone, so no island can bridge to shore or appear in a lake / the crater bay.
|
||||||
|
///
|
||||||
|
/// ═══ ⚠ THE ONE HONEST COUPLING ═══
|
||||||
|
///
|
||||||
|
/// Unlike the curve, this pass TURNS WATER INTO LAND. New land is new classification downstream
|
||||||
|
/// (biome/water pixels that did not exist before). That is precisely why it runs here, in the base
|
||||||
|
/// shape, before anything classifies: change an island dial, regenerate, and classification
|
||||||
|
/// re-runs consistently. Known property, stated in code, not a surprise.
|
||||||
|
///
|
||||||
|
/// Every cell lifted from below sea to at/above sea is TAGGED (<c>Result.Tag</c>) with its
|
||||||
|
/// hemisphere (<c>Result.Hemi</c>). That tag is data the shape pass sets and carries; nothing in
|
||||||
|
/// this phase reads it. → <see cref="OffshoreAnalysis"/> for the hemisphere convention.
|
||||||
|
/// </summary>
|
||||||
|
public static class OffshorePass
|
||||||
|
{
|
||||||
|
public sealed class Result
|
||||||
|
{
|
||||||
|
public bool[,] Tag; // null when the islet layer is off (shelf only)
|
||||||
|
public byte[,] Hemi;
|
||||||
|
public long ShelfCells, LiftedOrganic;
|
||||||
|
public float ThresholdNorth = float.NaN, ThresholdSouth = float.NaN;
|
||||||
|
public List<string> Notes = new();
|
||||||
|
public int CountNorth, CountSouth, Bridged;
|
||||||
|
|
||||||
|
// ---- the guards' ledger (Organic only) ----
|
||||||
|
public int PreGuardNorth, PreGuardSouth; // islands before any guard
|
||||||
|
public int SpecksNorth, SpecksSouth; // reverted as specks
|
||||||
|
public int ClustersNorth, ClustersSouth; // reverted as too-close-to-a-larger-island
|
||||||
|
public int BlobsNorth, BlobsSouth; // reverted as oversize
|
||||||
|
public long LiftedReverted; // surfaced cells the guards put back
|
||||||
|
public long RaisedReverted; // submerged bump cells the guard put back
|
||||||
|
|
||||||
|
internal List<(int x, int y, float h0)> LiftedOrigin = new(); // every SURFACED lift
|
||||||
|
internal List<(int x, int y, float h0)> RaisedOrigin = new(); // every organic raise, surfaced or not (guards on)
|
||||||
|
|
||||||
|
public OffshoreLedger ToLedger() => Tag == null ? null : new OffshoreLedger
|
||||||
|
{
|
||||||
|
ThresholdNorth = ThresholdNorth, ThresholdSouth = ThresholdSouth,
|
||||||
|
PreGuardNorth = PreGuardNorth, PreGuardSouth = PreGuardSouth,
|
||||||
|
SpecksNorth = SpecksNorth, SpecksSouth = SpecksSouth,
|
||||||
|
ClustersNorth = ClustersNorth, ClustersSouth = ClustersSouth,
|
||||||
|
BlobsNorth = BlobsNorth, BlobsSouth = BlobsSouth,
|
||||||
|
LiftedOrganic = LiftedOrganic, LiftedReverted = LiftedReverted, RaisedReverted = RaisedReverted,
|
||||||
|
CountNorth = CountNorth, CountSouth = CountSouth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply the shelf and/or islets to <paramref name="height"/> IN PLACE. Returns null when
|
||||||
|
/// both are off (nothing touched, nothing allocated).
|
||||||
|
/// </summary>
|
||||||
|
public static Result Apply(float[,] height, float[,] preTrench, int mapSize, int seed,
|
||||||
|
float sea, TerrainGenConfig cfg)
|
||||||
|
{
|
||||||
|
OffshoreSettings s = cfg.Offshore;
|
||||||
|
bool shelfOn = cfg.CoastShelf;
|
||||||
|
bool isletsOn = s != null && s.Mode != OffshoreMode.Off;
|
||||||
|
if (!shelfOn && !isletsOn) return null;
|
||||||
|
|
||||||
|
var r = new Result();
|
||||||
|
GenerationScale scale = cfg.Scale;
|
||||||
|
|
||||||
|
// ⚠ MAP-anchored, exactly as the reference: `center = MapSize / 2.0f`, `distX = |x − cx| / (MapSize / 2.0f)`.
|
||||||
|
float centerX = mapSize / 2.0f, centerY = mapSize / 2.0f;
|
||||||
|
float halfSpan = mapSize / 2.0f;
|
||||||
|
|
||||||
|
// ═══ 1. THE COAST SHELF ═══
|
||||||
|
if (shelfOn)
|
||||||
|
{
|
||||||
|
// ⭐ THE CLAMP THAT MAKES "CANNOT MOVE THE WATERLINE" EXACT. Reference: the remap is
|
||||||
|
// strictly positive on positive depth, so in exact arithmetic the waterline cannot
|
||||||
|
// move; in float32 it can — a pixel a few microns under water rounds back up to
|
||||||
|
// exactly sea and `h < sea` then calls it land. That cost 5 px of 67 M on the
|
||||||
|
// reference's first batch. Hold the result strictly below sea and the invariant is
|
||||||
|
// exact. Do not port this without the clamp.
|
||||||
|
float strictlyBelowSea = MathF.BitDecrement(sea);
|
||||||
|
for (int x = 0; x < mapSize; x++)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < mapSize; y++)
|
||||||
|
{
|
||||||
|
float h = height[x, y];
|
||||||
|
if (h >= sea) continue; // below-sea ONLY
|
||||||
|
float depthM = WorldScale.MetresFromRaw(sea - h); // (seaHere − finalH) * 251f
|
||||||
|
height[x, y] = MathF.Min(
|
||||||
|
sea - WorldScale.RawFromMetres(IslandFalloff.CoastShelf(depthM, cfg.ShelfStrength, cfg.ShelfScaleM)),
|
||||||
|
strictlyBelowSea);
|
||||||
|
r.ShelfCells++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.Notes.Add($"[Offshore] coast shelf: {r.ShelfCells:N0} below-sea cells remapped " +
|
||||||
|
$"(strength {cfg.ShelfStrength:F3}, scale {cfg.ShelfScaleM:F0} m) — held strictly below sea.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isletsOn) return r;
|
||||||
|
|
||||||
|
r.Tag = new bool[mapSize, mapSize];
|
||||||
|
r.Hemi = new byte[mapSize, mapSize];
|
||||||
|
|
||||||
|
bool faithful = s.Mode == OffshoreMode.Faithful;
|
||||||
|
bool guardsOn = !faithful && (s.MinIslandAreaFrac > 0f || s.MinSeparationFrac > 0f || s.MaxIslandAreaFrac > 0f);
|
||||||
|
float crest = sea + WorldScale.RawFromMetres(s.CrestM); // seaHere + OFFSHORE_ISLAND_H_M / 251f
|
||||||
|
|
||||||
|
// ═══ 2. THE ORGANIC LAYER — the one island mechanism ═══
|
||||||
|
{
|
||||||
|
FastNoiseLite noise = TerrainNoise.CreateModulation(seed, s.SeedOffset, s.FreqPerMapWidth, scale);
|
||||||
|
|
||||||
|
// ⭐ Calibrate against the field's ACTUAL distribution, not the theoretical [−1,1]:
|
||||||
|
// sample on a stride grid and take the quantile. Deterministic from the seed, and it
|
||||||
|
// makes the density dial mean what it says whatever FastNoiseLite's range turns out
|
||||||
|
// to be. Verbatim: stride 8, (side)² samples, (noise + 1) * 0.5.
|
||||||
|
float[] samples = CalibrationSamples(noise, mapSize);
|
||||||
|
float thrN = IslandFalloff.CalibrateThreshold(samples, s.Density);
|
||||||
|
float thrS = faithful ? thrN : IslandFalloff.CalibrateThreshold(samples, s.DensitySouth);
|
||||||
|
r.ThresholdNorth = thrN; r.ThresholdSouth = thrS;
|
||||||
|
|
||||||
|
float mid = mapSize * 0.5f;
|
||||||
|
float band = MathF.Max(1f, s.HemisphereBlendHalfWidth * mapSize);
|
||||||
|
|
||||||
|
for (int x = 0; x < mapSize; x++)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < mapSize; y++)
|
||||||
|
{
|
||||||
|
float h = height[x, y];
|
||||||
|
if (h >= sea) continue; // below-sea ONLY — never touches land
|
||||||
|
|
||||||
|
float ambientDepthM = WorldScale.MetresFromRaw(sea - h);
|
||||||
|
float zone = IslandFalloff.OffshoreZoneWeight(
|
||||||
|
ambientDepthM, preTrench[x, y],
|
||||||
|
MathF.Abs(x - centerX) / halfSpan, MathF.Abs(y - centerY) / halfSpan,
|
||||||
|
s.MinDepthM, s.DepthFeatherM, s.MinFalloff, s.FalloffFeather,
|
||||||
|
s.TrenchInner, s.TrenchOuter);
|
||||||
|
if (zone <= 0f) continue;
|
||||||
|
|
||||||
|
float v = (noise.GetNoise2D(x, y) + 1.0f) * 0.5f;
|
||||||
|
|
||||||
|
// The threshold: one number in faithful mode; north/south blended smoothly
|
||||||
|
// across the midline in the reshape, so a straddling island is not sliced.
|
||||||
|
float thr = faithful ? thrN : BlendedThreshold(y, mid, band, thrN, thrS);
|
||||||
|
|
||||||
|
float blob = (faithful
|
||||||
|
? IslandFalloff.OffshoreBlob(v, thr)
|
||||||
|
: IslandFalloff.RigidBlob(v, thr, s.CoreFraction, s.EdgeSharpness)) * zone;
|
||||||
|
if (blob <= 0f) continue;
|
||||||
|
|
||||||
|
// ⭐ LERP TOWARD THE CREST, never add — surfaces at any ambient depth.
|
||||||
|
float before = h;
|
||||||
|
h = h + (crest - h) * blob; // Mathf.Lerp, written out
|
||||||
|
height[x, y] = h;
|
||||||
|
if (guardsOn) r.RaisedOrigin.Add((x, y, before)); // for the submerged-bump guard
|
||||||
|
|
||||||
|
if (before < sea && h >= sea)
|
||||||
|
{
|
||||||
|
r.LiftedOrganic++;
|
||||||
|
r.Tag[x, y] = true;
|
||||||
|
r.Hemi[x, y] = OffshoreAnalysis.HemisphereOfRow(y, mapSize);
|
||||||
|
r.LiftedOrigin.Add((x, y, before));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Notes.Add($"[Offshore] organic ({(faithful ? "faithful" : "reshaped")}): threshold N {thrN:F4}" +
|
||||||
|
$"{(faithful ? "" : $" S {thrS:F4}")} from {samples.Length:N0} samples " +
|
||||||
|
$"(range {Min(samples):F3}..{Max(samples):F3}); lifted {r.LiftedOrganic:N0} cells above sea.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 3. THE SLOP GUARDS — reshape only; over everything the pass raised ═══
|
||||||
|
//
|
||||||
|
// Four kinds of slop, three component rules and one location rule:
|
||||||
|
// (a) SPECKS a local maximum that barely clears the threshold surfaces a cap of a few
|
||||||
|
// cells — not an island. Reverted by MEMBERSHIP (a speck inside a real
|
||||||
|
// island's skirt is still a speck).
|
||||||
|
// (b) BLOBS a superlevel region that merged several maxima into one sprawling
|
||||||
|
// landmass. Reverted by membership. The cap sits well above the natural
|
||||||
|
// size so it is a net, not a sculptor; how often it bites is reported.
|
||||||
|
// (c) CLUSTERS two islands whose shores are closer than the minimum separation read as
|
||||||
|
// one; the SMALLER goes (greedy by size, so the largest of a cluster stays).
|
||||||
|
// (d) SUBMERGED BUMPS — every blob with any weight lerps the seabed toward the crest
|
||||||
|
// whether or not it surfaces, so the ocean fills with shallow humps (the
|
||||||
|
// reference's character; the water pass would draw a reef field nobody
|
||||||
|
// asked for). Attributed by HUMP: a hump (connected raised region) that
|
||||||
|
// holds a kept island is that island's own skirt and stays; one that holds
|
||||||
|
// none goes back to seabed; a dropped island's own cap goes too.
|
||||||
|
// None of these places, shapes or counts anything. (The faithful control keeps all four;
|
||||||
|
// that is the reference.)
|
||||||
|
if (guardsOn && (r.RaisedOrigin.Count > 0 || r.LiftedOrigin.Count > 0))
|
||||||
|
{
|
||||||
|
double area = (double)mapSize * mapSize;
|
||||||
|
long minCells = s.MinIslandAreaFrac > 0f ? Math.Max(1L, (long)Math.Round(s.MinIslandAreaFrac * area)) : 0;
|
||||||
|
long maxCells = s.MaxIslandAreaFrac > 0f ? Math.Max(1L, (long)Math.Round(s.MaxIslandAreaFrac * area)) : long.MaxValue;
|
||||||
|
int minSep = s.MinSeparationFrac > 0f ? Math.Max(1, (int)Math.Round(s.MinSeparationFrac * mapSize)) : 0;
|
||||||
|
|
||||||
|
var pre = OffshoreAnalysis.Components(r.Tag, height, sea, mapSize, out int[] compId);
|
||||||
|
(r.PreGuardNorth, r.PreGuardSouth) = OffshoreAnalysis.CountByHemisphere(pre);
|
||||||
|
|
||||||
|
var dropped = new HashSet<int>();
|
||||||
|
var byId = new Dictionary<int, IslandComponent>();
|
||||||
|
foreach (var c in pre) byId[c.Id] = c;
|
||||||
|
|
||||||
|
// (a) specks and (b) blobs — by size.
|
||||||
|
foreach (var c in pre)
|
||||||
|
{
|
||||||
|
if (c.Cells < minCells) { dropped.Add(c.Id); Bump(r, c.Hemisphere, ref r.SpecksNorth, ref r.SpecksSouth); }
|
||||||
|
else if (c.Cells > maxCells) { dropped.Add(c.Id); Bump(r, c.Hemisphere, ref r.BlobsNorth, ref r.BlobsSouth); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// (c) clusters — greedy by size among the survivors of (a)/(b).
|
||||||
|
if (minSep > 0)
|
||||||
|
{
|
||||||
|
var survivors = new List<IslandComponent>();
|
||||||
|
foreach (var c in pre) if (!dropped.Contains(c.Id)) survivors.Add(c);
|
||||||
|
survivors.Sort((a, b) => b.Cells.CompareTo(a.Cells)); // largest first
|
||||||
|
var boundary = OffshoreAnalysis.BoundaryCells(r.Tag, compId, mapSize, r.LiftedOrigin);
|
||||||
|
var kept = new List<IslandComponent>();
|
||||||
|
foreach (var c in survivors)
|
||||||
|
{
|
||||||
|
bool tooClose = false;
|
||||||
|
foreach (var k in kept)
|
||||||
|
{
|
||||||
|
if (OffshoreAnalysis.BoxGap(c, k) >= minSep) continue; // cannot be closer than the bbox gap
|
||||||
|
if (OffshoreAnalysis.MinChebyshev(boundary[c.Id], boundary[k.Id], minSep) < minSep) { tooClose = true; break; }
|
||||||
|
}
|
||||||
|
if (tooClose) { dropped.Add(c.Id); Bump(r, c.Hemisphere, ref r.ClustersNorth, ref r.ClustersSouth); }
|
||||||
|
else kept.Add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (d) THE SUBMERGED BUMPS — by HUMP, not by box. A hump is one 8-connected region of
|
||||||
|
// raised cells (the blob's footprint, surfaced or not). A hump that holds a kept island
|
||||||
|
// is that island's own skirt and stays whole; a hump that holds none is a reef nobody
|
||||||
|
// asked for and goes back to seabed whole. A DROPPED island's own cap — its bbox plus a
|
||||||
|
// margin — is reverted even inside a kept hump, or its rim would stay as a hollow ring
|
||||||
|
// beside its neighbour (the first probe plate showed exactly those ghost outlines).
|
||||||
|
var raised = new bool[mapSize, mapSize];
|
||||||
|
foreach (var (x, y, _) in r.RaisedOrigin) raised[x, y] = true;
|
||||||
|
var humpId = new int[mapSize * mapSize];
|
||||||
|
var humpKept = new List<bool> { false }; // index 0 unused
|
||||||
|
{
|
||||||
|
var stack = new Stack<int>();
|
||||||
|
foreach (var (sx, sy, _) in r.RaisedOrigin)
|
||||||
|
{
|
||||||
|
if (humpId[sx * mapSize + sy] != 0) continue;
|
||||||
|
int id = humpKept.Count; humpKept.Add(false);
|
||||||
|
humpId[sx * mapSize + sy] = id; stack.Push(sx * mapSize + sy);
|
||||||
|
bool kept = false;
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
int cur = stack.Pop(); int cx = cur / mapSize, cy = cur % mapSize;
|
||||||
|
if (r.Tag[cx, cy] && !dropped.Contains(compId[cur])) kept = true;
|
||||||
|
for (int dx = -1; dx <= 1; dx++)
|
||||||
|
{
|
||||||
|
int nx = cx + dx; if (nx < 0 || nx >= mapSize) continue;
|
||||||
|
for (int dy = -1; dy <= 1; dy++)
|
||||||
|
{
|
||||||
|
int ny = cy + dy; if (ny < 0 || ny >= mapSize || (dx == 0 && dy == 0)) continue;
|
||||||
|
if (!raised[nx, ny]) continue;
|
||||||
|
int ni = nx * mapSize + ny;
|
||||||
|
if (humpId[ni] != 0) continue;
|
||||||
|
humpId[ni] = id; stack.Push(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
humpKept[id] = kept;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var dropBoxes = new List<(int x0, int y0, int x1, int y1)>();
|
||||||
|
foreach (var c in pre)
|
||||||
|
{
|
||||||
|
if (!dropped.Contains(c.Id)) continue;
|
||||||
|
int w = c.MaxX - c.MinX + 1, hgt = c.MaxY - c.MinY + 1;
|
||||||
|
int margin = Math.Max(4, Math.Max(w, hgt) / 2);
|
||||||
|
dropBoxes.Add((c.MinX - margin, c.MinY - margin, c.MaxX + margin, c.MaxY + margin));
|
||||||
|
}
|
||||||
|
bool InDropBox(int x, int y)
|
||||||
|
{
|
||||||
|
foreach (var (x0, y0, x1, y1) in dropBoxes)
|
||||||
|
if (x >= x0 && x <= x1 && y >= y0 && y <= y1) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
long revertedLifted = 0, revertedRaised = 0;
|
||||||
|
foreach (var (x, y, h0) in r.LiftedOrigin)
|
||||||
|
{
|
||||||
|
if (!dropped.Contains(compId[x * mapSize + y])) continue;
|
||||||
|
height[x, y] = h0; r.Tag[x, y] = false; r.Hemi[x, y] = OffshoreAnalysis.HemiNone;
|
||||||
|
revertedLifted++;
|
||||||
|
}
|
||||||
|
foreach (var (x, y, h0) in r.RaisedOrigin)
|
||||||
|
{
|
||||||
|
if (r.Tag[x, y]) continue; // a kept island's own surfaced cell
|
||||||
|
bool keepIt = humpKept[humpId[x * mapSize + y]] && !InDropBox(x, y);
|
||||||
|
if (keepIt) continue;
|
||||||
|
if (height[x, y] > h0) { height[x, y] = h0; revertedRaised++; }
|
||||||
|
}
|
||||||
|
r.LiftedReverted = revertedLifted;
|
||||||
|
r.RaisedReverted = revertedRaised;
|
||||||
|
r.Notes.Add($"[Offshore] guards: of {pre.Count} islands (N {r.PreGuardNorth} / S {r.PreGuardSouth}) reverted " +
|
||||||
|
$"{r.SpecksNorth + r.SpecksSouth} specks (< {minCells:N0} cells), " +
|
||||||
|
$"{r.ClustersNorth + r.ClustersSouth} clustered (< {minSep} px from a larger island), " +
|
||||||
|
$"{r.BlobsNorth + r.BlobsSouth} blobs (> {(maxCells == long.MaxValue ? "∞" : maxCells.ToString("N0"))} cells) " +
|
||||||
|
$"— {revertedLifted:N0} surfaced cells, plus {revertedRaised:N0} submerged bump cells outside any kept island's hump.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ PROVE THE MOAT ON THE FINISHED FIELD — in the pass, before anyone looks ═══
|
||||||
|
var comps = OffshoreAnalysis.Components(r.Tag, height, sea, mapSize);
|
||||||
|
(r.CountNorth, r.CountSouth) = OffshoreAnalysis.CountByHemisphere(comps);
|
||||||
|
r.Bridged = OffshoreAnalysis.BridgedCount(comps);
|
||||||
|
var (szMin, szMed, szMean, szMax, _) = OffshoreAnalysis.SizeSummary(comps, 0);
|
||||||
|
r.Notes.Add($"[Offshore] islands: {r.CountNorth} north, {r.CountSouth} south ({comps.Count} components, " +
|
||||||
|
$"{r.Bridged} bridged to mainland); lifted {r.LiftedOrganic - r.LiftedReverted:N0} cells net" +
|
||||||
|
$"{(r.RaisedReverted > 0 ? $", {r.RaisedReverted:N0} submerged debris cells reverted" : "")}; " +
|
||||||
|
$"size cells min {szMin} median {szMed} mean {szMean:F0} max {szMax}.");
|
||||||
|
|
||||||
|
if (r.Bridged > 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"[OffshorePass] MOAT VIOLATION: {r.Bridged} island(s) touch mainland land. Refusing.");
|
||||||
|
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The reference's calibration sample: stride 8, (side)² samples, (noise + 1) · 0.5. Shared with the diagnosis.</summary>
|
||||||
|
public static float[] CalibrationSamples(FastNoiseLite noise, int mapSize)
|
||||||
|
{
|
||||||
|
const int stride = 8;
|
||||||
|
int side = mapSize / stride;
|
||||||
|
var samples = new float[side * side];
|
||||||
|
for (int i = 0; i < side; i++)
|
||||||
|
for (int j = 0; j < side; j++)
|
||||||
|
samples[i * side + j] = (noise.GetNoise2D(i * stride, j * stride) + 1f) * 0.5f;
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The north/south threshold, smoothstep-blended across the midline. Shared with the diagnosis.</summary>
|
||||||
|
public static float BlendedThreshold(int y, float mid, float band, float thrN, float thrS)
|
||||||
|
{
|
||||||
|
float t = Math.Clamp((y - mid) / band * 0.5f + 0.5f, 0f, 1f);
|
||||||
|
t = t * t * (3f - 2f * t);
|
||||||
|
return thrN + (thrS - thrN) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Bump(Result r, byte hemi, ref int north, ref int south)
|
||||||
|
{
|
||||||
|
if (hemi == OffshoreAnalysis.HemiNorth) north++; else south++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float Min(float[] a) { float m = float.MaxValue; foreach (float v in a) if (v < m) m = v; return m; }
|
||||||
|
private static float Max(float[] a) { float m = float.MinValue; foreach (float v in a) if (v > m) m = v; return m; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The islet layer's ledger, carried on <c>Pass1Result</c> for the report: thresholds, the
|
||||||
|
/// pre-guard island count, what each guard reverted, the final count. Numbers only — the tag
|
||||||
|
/// arrays are carried separately.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OffshoreLedger
|
||||||
|
{
|
||||||
|
public float ThresholdNorth, ThresholdSouth;
|
||||||
|
public int PreGuardNorth, PreGuardSouth;
|
||||||
|
public int SpecksNorth, SpecksSouth, ClustersNorth, ClustersSouth, BlobsNorth, BlobsSouth;
|
||||||
|
public long LiftedOrganic, LiftedReverted, RaisedReverted;
|
||||||
|
public int CountNorth, CountSouth;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/OffshorePass.cs.uid
Normal file
1
Tools/Scripts/OffshorePass.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cseifxt2bd820
|
||||||
220
Tools/Scripts/OffshoreSettings.cs
Normal file
220
Tools/Scripts/OffshoreSettings.cs
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>Which offshore-islet system pass 1 runs. → <see cref="OffshoreSettings"/>.</summary>
|
||||||
|
public enum OffshoreMode
|
||||||
|
{
|
||||||
|
/// <summary>No islets. The shelf is gated separately (<c>TerrainGenConfig.CoastShelf</c>).</summary>
|
||||||
|
Off,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ chat2/05 stage 1 — the reference's probabilistic layer, verbatim: one noise field, one
|
||||||
|
/// calibrated threshold, the faithful blob, the faithful zone mask. Sparse, no corners, no
|
||||||
|
/// guards. THE CONTROL.
|
||||||
|
/// </summary>
|
||||||
|
Faithful,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ chat2/06 — THE ONE ISLAND MECHANISM: the organic noise-field layer, reshaped small /
|
||||||
|
/// low / flat / crisp (chat2/05 stage 2's shape, unchanged), tuned for coverage by DENSITY
|
||||||
|
/// and a SOUTH WEIGHT, with the speck / separation / blob guards. No seeded floor, no
|
||||||
|
/// stamps, no count guarantee — every island is a noise outline, and the per-hemisphere
|
||||||
|
/// counts are a statistical outcome of the tuning (the chat2/05 forced floor was tried and
|
||||||
|
/// reverted on look; it is in git history, one checkout away).
|
||||||
|
/// </summary>
|
||||||
|
Organic,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ EVERY OFFSHORE-ISLET DIAL, IN ONE OBJECT — config-gated and isolated, per the developer's
|
||||||
|
/// standing modularity concern. Nothing above <see cref="OffshorePass"/> hardcodes an island
|
||||||
|
/// specific; the whole system is this object + <c>IslandFalloff</c> + <c>OffshorePass</c>.
|
||||||
|
///
|
||||||
|
/// ═══ TWO PRESETS, AND WHY BOTH EXIST ═══
|
||||||
|
///
|
||||||
|
/// <see cref="Faithful"/> the reference's constants, verbatim. The control in every batch.
|
||||||
|
/// <see cref="Organic"/> the reshape + the chat2/06 coverage tuning — the deliverable.
|
||||||
|
///
|
||||||
|
/// The reshape does not EDIT the faithful constants; it sets different values on the same
|
||||||
|
/// dials. So <c>Faithful()</c> stays bit-reproducible however far the organic layer is tuned.
|
||||||
|
///
|
||||||
|
/// ═══ THE TWO PROTECTIONS THAT ARE NOT DIALS ═══
|
||||||
|
///
|
||||||
|
/// <see cref="MinDepthM"/> (the moat) and <see cref="MinFalloff"/> (the "actually offshore" test)
|
||||||
|
/// are exposed here because every knob is, but they are the MAIN-ISLAND AND LAKE PROTECTIONS and
|
||||||
|
/// the presets do not move them. They are what makes "no island can bridge to shore" and "no
|
||||||
|
/// island in a lake or the crater bay" true by construction.
|
||||||
|
///
|
||||||
|
/// ═══ ⚠ WHAT IS DELIBERATELY NOT HERE (chat2/06) ═══
|
||||||
|
///
|
||||||
|
/// No floor, no stamp radius/core/jitter, no separation-of-seeded-centres, no placement RNG —
|
||||||
|
/// nothing that guarantees a count or places an island from a centre. The forced mechanism
|
||||||
|
/// (chat2/05 <c>Hybrid()</c>) was reverted out whole; if a hard count ever comes back it is a
|
||||||
|
/// design decision, not a knob that was left lying around.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OffshoreSettings
|
||||||
|
{
|
||||||
|
public OffshoreMode Mode = OffshoreMode.Off;
|
||||||
|
|
||||||
|
// ---- the organic (noise) layer ----------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Islet noise frequency, periods per map width. Higher ⇒ SMALLER blobs. Reference 14.</summary>
|
||||||
|
public float FreqPerMapWidth = IslandFalloff.OFFSHORE_FREQ_ISLANDS;
|
||||||
|
|
||||||
|
/// <summary>Islet noise seed offset. Reference 7607.</summary>
|
||||||
|
public int SeedOffset = IslandFalloff.OFFSHORE_SEED_OFFSET;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE MAIN KNOB — organic density: the fraction of the noise field's ACTUAL sampled
|
||||||
|
/// distribution that clears the calibrated threshold. More ⇒ more of the field becomes
|
||||||
|
/// island. This is the NORTH density; the south is this × <see cref="SouthWeight"/>.
|
||||||
|
/// Reference 0.02.
|
||||||
|
/// </summary>
|
||||||
|
public float Density = 0.02f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE SOUTH WEIGHT — a per-hemisphere density bias. South density =
|
||||||
|
/// <see cref="Density"/> × this. 1 ⇒ one dial (the reference); > 1 ⇒ "weighted south",
|
||||||
|
/// the developer's preference. It is applied to the DENSITY (i.e. the calibration quantile),
|
||||||
|
/// not to the blob shape, so a south island looks exactly like a north island — there are
|
||||||
|
/// simply more of them.
|
||||||
|
/// </summary>
|
||||||
|
public float SouthWeight = 1f;
|
||||||
|
|
||||||
|
/// <summary>The effective south density.</summary>
|
||||||
|
public float DensitySouth => Density * SouthWeight;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Half-width of the smooth threshold blend across the hemisphere midline, as a fraction of
|
||||||
|
/// the map. ⚠ Without it a north/south density difference would cut any organic island that
|
||||||
|
/// straddles the midline along a dead-straight line. 0 ⇒ hard step (never wanted).
|
||||||
|
/// </summary>
|
||||||
|
public float HemisphereBlendHalfWidth = 0.05f;
|
||||||
|
|
||||||
|
/// <summary>Islet crest, metres above sea, PRE-CURVE. Reference 34. The curve's toe squashes it lower.</summary>
|
||||||
|
public float CrestM = IslandFalloff.OFFSHORE_ISLAND_H_M;
|
||||||
|
|
||||||
|
/// <summary>Fraction of a blob's excess over threshold that saturates. LOWER ⇒ flatter. Reference 0.45.</summary>
|
||||||
|
public float CoreFraction = IslandFalloff.OFFSHORE_CORE;
|
||||||
|
|
||||||
|
/// <summary>Crest-to-sea edge sharpening exponent. 1 ⇒ the faithful smoothstep. Higher ⇒ crisper shore.</summary>
|
||||||
|
public float EdgeSharpness = 1f;
|
||||||
|
|
||||||
|
// ---- the slop guards (Organic only; all scale-free fractions of the map) --------------
|
||||||
|
//
|
||||||
|
// More density must not buy speck-debris or merged blobs. Three rules, each a revert BY
|
||||||
|
// COMPONENT MEMBERSHIP (the island goes back to seabed whole, and is untagged) — none of
|
||||||
|
// them places, shapes or counts anything. 0 ⇒ that rule is off. The faithful preset has none
|
||||||
|
// (the reference had no such rules).
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⚠ THE SPECK GUARD. An organic island smaller than this fraction of the map's AREA is
|
||||||
|
/// noise debris — a local maximum that barely cleared the threshold and surfaced a cap of a
|
||||||
|
/// few cells — and is reverted to seabed and untagged. 3e-5 is ~126 cells at 2048, ~500 at
|
||||||
|
/// 4096, ~2,000 cells (a ~50 m islet) at 8192.
|
||||||
|
/// </summary>
|
||||||
|
public float MinIslandAreaFrac = 0f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⚠ THE SEPARATION GUARD — "enough separation that they read as distinct islands". Two
|
||||||
|
/// surviving islands whose nearest shores are closer than this fraction of the map WIDTH
|
||||||
|
/// read as one cluster; the SMALLER is reverted. Greedy by size, so the largest island in a
|
||||||
|
/// cluster always stays. Chebyshev distance between boundary cells.
|
||||||
|
/// </summary>
|
||||||
|
public float MinSeparationFrac = 0f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⚠ THE BLOB GUARD. An organic island LARGER than this fraction of the map's area is a
|
||||||
|
/// superlevel region that merged several maxima into one sprawling landmass — the "blob"
|
||||||
|
/// the developer does not want — and is reverted whole. Set well above the natural size at
|
||||||
|
/// the preset density so it is a safety net, not a sculptor; how often it bites is reported.
|
||||||
|
/// </summary>
|
||||||
|
public float MaxIslandAreaFrac = 0f;
|
||||||
|
|
||||||
|
// ---- the zone mask: protections + the Trench bound --------------------
|
||||||
|
|
||||||
|
/// <summary>⭐ THE MOAT. Reference 14 m. The presets do not move it.</summary>
|
||||||
|
public float MinDepthM = IslandFalloff.OFFSHORE_MIN_DEPTH_M;
|
||||||
|
public float DepthFeatherM = IslandFalloff.OFFSHORE_DEPTH_FEATHER_M;
|
||||||
|
|
||||||
|
/// <summary>⭐ THE "ACTUALLY OFFSHORE" TEST on the pre-Trench falloff. Reference 0.72. The presets do not move it.</summary>
|
||||||
|
public float MinFalloff = IslandFalloff.OFFSHORE_MIN_FALLOFF;
|
||||||
|
public float FalloffFeather = IslandFalloff.OFFSHORE_FALLOFF_FEATHER;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The outer bound, as a fraction of the half-span (map-anchored, like the Trench). Zone
|
||||||
|
/// fades from INNER to zero at OUTER. Reference 0.78 / 0.86 keeps islets well off the
|
||||||
|
/// Trench ramp (which starts at 0.90) and out of the corners. The reshape pushes both OUT so
|
||||||
|
/// islands populate the corners and may sit over the outer edge — the developer accepts
|
||||||
|
/// islands clipped by the map edge. OUTER < 1 keeps every island's centre on the playable
|
||||||
|
/// map.
|
||||||
|
/// </summary>
|
||||||
|
public float TrenchInner = IslandFalloff.OFFSHORE_TRENCH_INNER;
|
||||||
|
public float TrenchOuter = IslandFalloff.OFFSHORE_TRENCH_OUTER;
|
||||||
|
|
||||||
|
// ---- presets ----------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>The reference, verbatim. Single density, no guards, original footprint/crest/mask.</summary>
|
||||||
|
public static OffshoreSettings Faithful() => new OffshoreSettings
|
||||||
|
{
|
||||||
|
Mode = OffshoreMode.Faithful,
|
||||||
|
FreqPerMapWidth = IslandFalloff.OFFSHORE_FREQ_ISLANDS,
|
||||||
|
SeedOffset = IslandFalloff.OFFSHORE_SEED_OFFSET,
|
||||||
|
Density = 0.02f, SouthWeight = 1f, // ConfigManager.OffshoreIslandDensity
|
||||||
|
CrestM = IslandFalloff.OFFSHORE_ISLAND_H_M,
|
||||||
|
CoreFraction = IslandFalloff.OFFSHORE_CORE,
|
||||||
|
EdgeSharpness = 1f,
|
||||||
|
TrenchInner = IslandFalloff.OFFSHORE_TRENCH_INNER,
|
||||||
|
TrenchOuter = IslandFalloff.OFFSHORE_TRENCH_OUTER,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE DELIVERABLE (chat2/06) — the organic layer, reshaped (chat2/05 stage 2's shape:
|
||||||
|
/// small / low / flat / crisp, corners on) and tuned for coverage: density up from 05's
|
||||||
|
/// 0.007, south-weighted, guarded against specks / clusters / blobs.
|
||||||
|
///
|
||||||
|
/// ⚠ TUNED BY MEASUREMENT, not by feel (chat2/06 batch — the count table over 12 seeds ×
|
||||||
|
/// 3 levels and the per-hemisphere diagnosis). This preset is the batch's `density_mid`.
|
||||||
|
/// </summary>
|
||||||
|
public static OffshoreSettings Organic() => new OffshoreSettings
|
||||||
|
{
|
||||||
|
Mode = OffshoreMode.Organic,
|
||||||
|
FreqPerMapWidth = 16f, // a little smaller than the reference's 14 (chat2/05)
|
||||||
|
Density = OrganicDensityMid,
|
||||||
|
SouthWeight = OrganicSouthWeight,
|
||||||
|
CrestM = 24f, // lower than the reference's 34 (pre-curve) — lands in the curve's preserved toe
|
||||||
|
CoreFraction = 0.25f, // flatter than 0.45
|
||||||
|
EdgeSharpness = 2.5f, // crisper shore than the faithful smoothstep
|
||||||
|
MinIslandAreaFrac = OrganicMinIslandAreaFrac,
|
||||||
|
MinSeparationFrac = OrganicMinSeparationFrac,
|
||||||
|
MaxIslandAreaFrac = OrganicMaxIslandAreaFrac,
|
||||||
|
TrenchInner = 0.90f, TrenchOuter = 0.97f, // corners + outer edge allowed; centre stays on-map
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The three density levels of the chat2/06 batch, in one place so the tool and the preset
|
||||||
|
/// cannot disagree. <c>Mid</c> is the preset of record.
|
||||||
|
/// </summary>
|
||||||
|
public const float OrganicDensityLow = 0.016f; // the edge: every seed of the 12 still clears N ≥ 2 / S ≥ 3, but N's minimum IS 2
|
||||||
|
public const float OrganicDensityMid = 0.022f; // ⭐ the preset: N min 3 / mean 6.3, S min ~15 / mean ~20 over 12 seeds at 4096
|
||||||
|
public const float OrganicDensityHigh = 0.030f; // the "how many is too many" bookend
|
||||||
|
public const float OrganicSouthWeight = 1.25f; // preference, not a fix — the diagnosis found the south UN-suppressed (see the report)
|
||||||
|
public const float OrganicMinIslandAreaFrac = 3e-5f;
|
||||||
|
public const float OrganicMinSeparationFrac = 0.008f;
|
||||||
|
public const float OrganicMaxIslandAreaFrac = 6e-4f;
|
||||||
|
|
||||||
|
public OffshoreSettings Clone() => (OffshoreSettings)MemberwiseClone();
|
||||||
|
|
||||||
|
public string Describe()
|
||||||
|
{
|
||||||
|
if (Mode == OffshoreMode.Off) return "offshore OFF";
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append($"{Mode}: freq {FreqPerMapWidth:F0}/map density {Density:F4} × south {SouthWeight:F2} (= S {DensitySouth:F4}) · ");
|
||||||
|
sb.Append($"crest {CrestM:F0} m core {CoreFraction:F2} sharp {EdgeSharpness:F1} · ");
|
||||||
|
sb.Append($"guards minArea {MinIslandAreaFrac:G2} minSep {MinSeparationFrac:G2} maxArea {MaxIslandAreaFrac:G2} · ");
|
||||||
|
sb.Append($"moat {MinDepthM:F0}+{DepthFeatherM:F0} m falloff {MinFalloff:F2}+{FalloffFeather:F2} trench {TrenchInner:F2}→{TrenchOuter:F2}");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/OffshoreSettings.cs.uid
Normal file
1
Tools/Scripts/OffshoreSettings.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cv2f5uho1c3km
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
namespace IslaApocalypse.Tools
|
namespace IslaApocalypse.Tools
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -59,11 +62,61 @@ namespace IslaApocalypse.Tools
|
||||||
/// <summary>The minimum height. Not a reference field — carried for the renderer's ramp and the report.</summary>
|
/// <summary>The minimum height. Not a reference field — carried for the renderer's ramp and the report.</summary>
|
||||||
public readonly float HMinSeed;
|
public readonly float HMinSeed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⚠ The map-wide max BEFORE the coast shelf and offshore islets ran (chat2/05). The
|
||||||
|
/// reference takes <c>_hMaxSeed</c> AFTER both, inside the same loop; v2 now does too —
|
||||||
|
/// <see cref="HMaxSeed"/> is the post-shelf/offshore value the curve normalizes against.
|
||||||
|
/// This one is carried so the report can state whether the two differed (expected: no, an
|
||||||
|
/// islet crest of ~34 m is far below any peak — but "expected" is measured, not assumed).
|
||||||
|
/// </summary>
|
||||||
|
public readonly float HMaxSeedBeforeOffshore;
|
||||||
|
|
||||||
|
// ═══ ⭐ THE ISLAND TAG — BY CONSTRUCTION from the region layer (chat2/07; chat2/05's tag, fixed) ═══
|
||||||
|
//
|
||||||
|
// DATA, set here, carried downstream, READ BY NOTHING IN THIS PHASE. It exists so a later
|
||||||
|
// pass (biome, fertility, placement) can find island land without re-deriving it from
|
||||||
|
// geometry. Since chat2/07 it is a CONSEQUENCE OF LABELING: every cell of every non-mainland
|
||||||
|
// land component is tagged — the big organic detached masses the same as an offshore-pass
|
||||||
|
// dot (chat2/05–06 tagged only what the offshore pass raised; that was the bug). Both arrays
|
||||||
|
// are NULL when region labeling is off — a consumer checks for null, not for all-false.
|
||||||
|
|
||||||
|
/// <summary>Per column: is this land an island (any non-mainland land component)? Null when labeling is off.</summary>
|
||||||
|
public readonly bool[,] IsIsland;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per column: <see cref="RegionLabeling.HemiNorth"/> / <see cref="RegionLabeling.HemiSouth"/>
|
||||||
|
/// for tagged cells (the COMPONENT's hemisphere, by centroid), <see cref="RegionLabeling.HemiNone"/>
|
||||||
|
/// otherwise. Null when labeling is off.
|
||||||
|
/// </summary>
|
||||||
|
public readonly byte[,] IslandHemisphere;
|
||||||
|
|
||||||
|
/// <summary>⭐ The region layer's output for this field: id map + per-component table (post-revert). Null when labeling is off.</summary>
|
||||||
|
public readonly RegionLabels Regions;
|
||||||
|
|
||||||
|
/// <summary>The labeling BEFORE the speck revert (== <see cref="Regions"/> when the revert is off or removed nothing). For the overlay's "where a speck was". Null when labeling is off.</summary>
|
||||||
|
public readonly RegionLabels RegionsPre;
|
||||||
|
|
||||||
|
/// <summary>The region pass's numbers — pre/post-revert counts and sizes, what was reverted. Null when labeling is off.</summary>
|
||||||
|
public readonly RegionLedger RegionLedger;
|
||||||
|
|
||||||
|
/// <summary>Cells the islet layer lifted above sea. The reference printed this too.</summary>
|
||||||
|
public readonly long OffshoreLiftedCells;
|
||||||
|
|
||||||
|
/// <summary>The islet layer's numbers — thresholds, pre-guard count, guard ledger, final count. Null when offshore is off.</summary>
|
||||||
|
public readonly OffshoreLedger OffshoreLedger;
|
||||||
|
|
||||||
|
/// <summary>Lines worth printing from the shelf/offshore pass: thresholds, guard ledger, lifted counts.</summary>
|
||||||
|
public readonly IReadOnlyList<string> Notes;
|
||||||
|
|
||||||
/// <summary>Wall-clock milliseconds the pass took.</summary>
|
/// <summary>Wall-clock milliseconds the pass took.</summary>
|
||||||
public readonly ulong ElapsedMs;
|
public readonly ulong ElapsedMs;
|
||||||
|
|
||||||
public Pass1Result(int mapSize, int seed, float[,] height, float[,] preTrenchFalloff,
|
public Pass1Result(int mapSize, int seed, float[,] height, float[,] preTrenchFalloff,
|
||||||
float[,] latitudeField, float hMaxSeed, float hMinSeed, ulong elapsedMs)
|
float[,] latitudeField, float hMaxSeed, float hMinSeed, ulong elapsedMs,
|
||||||
|
float hMaxSeedBeforeOffshore = float.NaN,
|
||||||
|
bool[,] isIsland = null, byte[,] islandHemisphere = null,
|
||||||
|
long offshoreLiftedCells = 0, IReadOnlyList<string> notes = null, OffshoreLedger offshoreLedger = null,
|
||||||
|
RegionLabels regions = null, RegionLedger regionLedger = null, RegionLabels regionsPre = null)
|
||||||
{
|
{
|
||||||
MapSize = mapSize;
|
MapSize = mapSize;
|
||||||
Seed = seed;
|
Seed = seed;
|
||||||
|
|
@ -73,8 +126,20 @@ namespace IslaApocalypse.Tools
|
||||||
HMaxSeed = hMaxSeed;
|
HMaxSeed = hMaxSeed;
|
||||||
HMinSeed = hMinSeed;
|
HMinSeed = hMinSeed;
|
||||||
ElapsedMs = elapsedMs;
|
ElapsedMs = elapsedMs;
|
||||||
|
HMaxSeedBeforeOffshore = float.IsNaN(hMaxSeedBeforeOffshore) ? hMaxSeed : hMaxSeedBeforeOffshore;
|
||||||
|
IsIsland = isIsland;
|
||||||
|
IslandHemisphere = islandHemisphere;
|
||||||
|
Regions = regions;
|
||||||
|
RegionsPre = regionsPre;
|
||||||
|
RegionLedger = regionLedger;
|
||||||
|
OffshoreLiftedCells = offshoreLiftedCells;
|
||||||
|
OffshoreLedger = offshoreLedger;
|
||||||
|
Notes = notes ?? System.Array.Empty<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the region layer ran on this field (the tag arrays are present).</summary>
|
||||||
|
public bool HasIslandTag => IsIsland != null;
|
||||||
|
|
||||||
/// <summary>Fraction of the map at or above the sea threshold. A cheap shape sanity number.</summary>
|
/// <summary>Fraction of the map at or above the sea threshold. A cheap shape sanity number.</summary>
|
||||||
public float LandFraction(float seaLevel)
|
public float LandFraction(float seaLevel)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,24 @@ namespace IslaApocalypse.Tools
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly ContinuousCurve Continuous;
|
public readonly ContinuousCurve Continuous;
|
||||||
|
|
||||||
|
// ═══ ⭐ THE OFFSHORE TAG, CARRIED (chat2/05) ═══
|
||||||
|
//
|
||||||
|
// Pass 1 sets it; pass 2 carries it UNCHANGED beside the two height fields, because this is
|
||||||
|
// where the shaped-terrain result flows and where a downstream consumer would pick it up.
|
||||||
|
// The curve is identity at sea and monotone above, so a cell that was offshore-island LAND
|
||||||
|
// in pass 1 is still land in the render field — the tag stays valid for both fields without
|
||||||
|
// being recomputed (oracle: "classify/render coastline consistent").
|
||||||
|
//
|
||||||
|
// ⚠ NO LOGIC READS IT THIS PHASE. It is a data layer. A biome/fertility/placement pass reads
|
||||||
|
// it from here, checks for null (offshore off), and never re-derives island-land from
|
||||||
|
// geometry.
|
||||||
|
|
||||||
|
/// <summary>→ <see cref="Pass1Result.IsIsland"/>, the same array. Null when region labeling is off.</summary>
|
||||||
|
public readonly bool[,] IsIsland;
|
||||||
|
|
||||||
|
/// <summary>→ <see cref="Pass1Result.IslandHemisphere"/>, the same array. Null when region labeling is off.</summary>
|
||||||
|
public readonly byte[,] IslandHemisphere;
|
||||||
|
|
||||||
/// <summary>Was shelf detail applied? Requires <see cref="CurveOn"/> — it warps the curve's knots.</summary>
|
/// <summary>Was shelf detail applied? Requires <see cref="CurveOn"/> — it warps the curve's knots.</summary>
|
||||||
public readonly bool DetailOn;
|
public readonly bool DetailOn;
|
||||||
|
|
||||||
|
|
@ -118,8 +136,10 @@ namespace IslaApocalypse.Tools
|
||||||
bool curveOn, bool detailOn, string curveModeLabel, string variantLabel,
|
bool curveOn, bool detailOn, string curveModeLabel, string variantLabel,
|
||||||
ContinuousCurve continuous, CurveKnots knots, CurveAnchors anchors, float hMaxSeed,
|
ContinuousCurve continuous, CurveKnots knots, CurveAnchors anchors, float hMaxSeed,
|
||||||
float edgeAmpRaw, float maxEdgeShiftRaw, float hMin, float hMax, ulong elapsedMs,
|
float edgeAmpRaw, float maxEdgeShiftRaw, float hMin, float hMax, ulong elapsedMs,
|
||||||
List<string> notes)
|
List<string> notes, bool[,] isIsland = null, byte[,] islandHemisphere = null)
|
||||||
{
|
{
|
||||||
|
IsIsland = isIsland;
|
||||||
|
IslandHemisphere = islandHemisphere;
|
||||||
MapSize = mapSize;
|
MapSize = mapSize;
|
||||||
Seed = seed;
|
Seed = seed;
|
||||||
Height = height;
|
Height = height;
|
||||||
|
|
@ -146,6 +166,21 @@ namespace IslaApocalypse.Tools
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool FieldsAreAliased => ReferenceEquals(Height, HeightClassify);
|
public bool FieldsAreAliased => ReferenceEquals(Height, HeightClassify);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The same result with a different RENDER field — how a render-only pass (erosion, chat2/11)
|
||||||
|
/// hands back its output without touching the classify field or anything else carried here.
|
||||||
|
/// </summary>
|
||||||
|
public Pass2Result WithHeight(float[,] newHeight, List<string> extraNotes, ulong extraMs)
|
||||||
|
{
|
||||||
|
var notes = new List<string>(Notes); if (extraNotes != null) notes.AddRange(extraNotes);
|
||||||
|
float hMin = float.MaxValue, hMax = float.MinValue;
|
||||||
|
for (int x = 0; x < MapSize; x++)
|
||||||
|
for (int y = 0; y < MapSize; y++) { float h = newHeight[x, y]; if (h < hMin) hMin = h; if (h > hMax) hMax = h; }
|
||||||
|
return new Pass2Result(MapSize, Seed, newHeight, HeightClassify, CurveOn, DetailOn, CurveModeLabel, VariantLabel,
|
||||||
|
Continuous, Knots, Anchors, HMaxSeed, EdgeAmpRaw, MaxEdgeShiftRaw, hMin, hMax, ElapsedMs + extraMs, notes,
|
||||||
|
IsIsland, IslandHemisphere);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Fraction of the RENDER field at or above the sea threshold.</summary>
|
/// <summary>Fraction of the RENDER field at or above the sea threshold.</summary>
|
||||||
public float LandFraction(float seaLevel)
|
public float LandFraction(float seaLevel)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
611
Tools/Scripts/RegionLabelingTool.cs
Normal file
611
Tools/Scripts/RegionLabelingTool.cs
Normal file
|
|
@ -0,0 +1,611 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE REGION-LABELING BATCH (chat2/07) — the general region layer on the current terrain, the
|
||||||
|
/// island tag fixed by construction, and the tunable speck revert swept.
|
||||||
|
///
|
||||||
|
/// ═══ WHAT IT PRODUCES — a fixed budget: 4 plates + the count/size table ═══
|
||||||
|
///
|
||||||
|
/// PLATES (4 fields, each grayscale + .f32 + relief + the LABELED-REGIONS overlay + the tag overlay):
|
||||||
|
/// {plate}_threshold_low / _mid / _high three revert thresholds on ONE seed — the developer
|
||||||
|
/// dials "where too-small-to-keep sits" by eye.
|
||||||
|
/// {second}_threshold_mid the preset on a second seed — the table seed with the
|
||||||
|
/// most NATURAL islands (offshore off), auto-picked or
|
||||||
|
/// ISLA_SECOND_SEED — labeling + threshold are not
|
||||||
|
/// seed-specific; the big organic masses tag correctly.
|
||||||
|
///
|
||||||
|
/// THE COUNT/SIZE TABLE (data): per seed, natural islands (offshore off), pre-revert islands, and
|
||||||
|
/// post-revert islands at each threshold, with size min / median / mean / max and a log-spaced
|
||||||
|
/// size histogram — the instrument the later southern-stretch step tunes against.
|
||||||
|
///
|
||||||
|
/// Every "current terrain" field = shelf ON + the chat2/06 organic preset (`density_mid`) + region
|
||||||
|
/// labeling ON; the revert is the variable. The curve is the tagged curve, unchanged.
|
||||||
|
///
|
||||||
|
/// ═══ RUNNING IT ═══
|
||||||
|
///
|
||||||
|
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
|
||||||
|
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/RegionLabelingTool.tscn
|
||||||
|
///
|
||||||
|
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
|
||||||
|
/// ISLA_MAPSIZE plate + table size (default 4096)
|
||||||
|
/// ISLA_TABLE_SIZE count-table size (default = ISLA_MAPSIZE; a probe may drop it)
|
||||||
|
/// ISLA_CALIB_SIZE curve calibration size (default 2048, task 01's)
|
||||||
|
/// ISLA_TABLE_SEEDS the table seeds (default 8 below)
|
||||||
|
/// ISLA_PLATE_SEED the three-threshold seed (default 1063685222)
|
||||||
|
/// ISLA_SECOND_SEED the second plate seed (default 0 = auto: most natural islands)
|
||||||
|
/// ISLA_THR_LOW / ISLA_THR_MID / ISLA_THR_HIGH thresholds, fraction of map area (probe overrides)
|
||||||
|
/// ISLA_TABLE_ONLY=1 probe: table only (no regressions, no plates)
|
||||||
|
/// ISLA_SKIP_8K=1 skip the 8192 regression (a4)
|
||||||
|
/// ISLA_PHASE1_SOURCE / ISLA_T03_SOURCE / ISLA_T04_SOURCE / ISLA_T06_SOURCE the regression dumps' batches
|
||||||
|
/// </summary>
|
||||||
|
public partial class RegionLabelingTool : Node
|
||||||
|
{
|
||||||
|
private static readonly int[] DefaultTableSeeds =
|
||||||
|
{
|
||||||
|
1063685222, 20260821, 8675309, 123456789, 271828182, 999999937, 90210, 424242,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>⚠ Task 01's pool, verbatim — the curve's identity.</summary>
|
||||||
|
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
|
||||||
|
|
||||||
|
private const int DefaultMapSize = 4096;
|
||||||
|
private const int DefaultCalibSize = 2048;
|
||||||
|
private const int GallerySize = 8192;
|
||||||
|
|
||||||
|
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 Level { public string Label; public float Frac; }
|
||||||
|
|
||||||
|
private sealed class Row
|
||||||
|
{
|
||||||
|
public string Level; public int Seed; public long ThresholdCells;
|
||||||
|
public int Natural, NaturalN, NaturalS; // offshore OFF, revert OFF
|
||||||
|
public int Pre, PreN, PreS; // offshore ON, revert OFF
|
||||||
|
public int Post, PostN, PostS; // offshore ON, revert at this level
|
||||||
|
public int RevertedComps; public long RevertedCells;
|
||||||
|
public long PreMin, PreMed, PreMax, PostMin, PostMed, PostMax; public double PreMean, PostMean;
|
||||||
|
public int[] PreHist, PostHist;
|
||||||
|
public long MainlandCells; public bool Ok; public ulong Ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Run()
|
||||||
|
{
|
||||||
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
|
||||||
|
int task = EnvInt("ISLA_TASK", 7);
|
||||||
|
string descr = EnvStr("ISLA_BATCH", "region_labeling");
|
||||||
|
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
|
||||||
|
int tableSize = EnvInt("ISLA_TABLE_SIZE", mapSize);
|
||||||
|
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
|
||||||
|
int[] tableSeeds = EnvSeeds("ISLA_TABLE_SEEDS", DefaultTableSeeds);
|
||||||
|
int plateSeed = EnvInt("ISLA_PLATE_SEED", 1063685222);
|
||||||
|
int secondEnv = EnvInt("ISLA_SECOND_SEED", 0);
|
||||||
|
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
|
||||||
|
string t03Source = EnvStr("ISLA_T03_SOURCE", "03_mountain_restore");
|
||||||
|
string t04Source = EnvStr("ISLA_T04_SOURCE", "04_seed_gallery");
|
||||||
|
string t06Source = EnvStr("ISLA_T06_SOURCE", "06_offshore_organic_tune");
|
||||||
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
|
bool tableOnly = EnvStr("ISLA_TABLE_ONLY", "0") == "1";
|
||||||
|
bool skip8k = EnvStr("ISLA_SKIP_8K", "0") == "1";
|
||||||
|
|
||||||
|
var levels = new List<Level>
|
||||||
|
{
|
||||||
|
new() { Label = "threshold_low", Frac = EnvFloat("ISLA_THR_LOW", RegionPass.ThresholdLowFrac) },
|
||||||
|
new() { Label = "threshold_mid", Frac = EnvFloat("ISLA_THR_MID", RegionPass.ThresholdMidFrac) },
|
||||||
|
new() { Label = "threshold_high", Frac = EnvFloat("ISLA_THR_HIGH", RegionPass.ThresholdHighFrac) },
|
||||||
|
};
|
||||||
|
Level mid = levels[1];
|
||||||
|
|
||||||
|
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(" REGION LABELING (chat2/07) — label all land, fix the tag, tunable speck revert");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print($"MapSize : {mapSize} (plates) table at {tableSize} curve calibrated at {calibSize} (offshore OFF)");
|
||||||
|
GD.Print($"table : {string.Join(", ", tableSeeds)}");
|
||||||
|
GD.Print($"plate seed: {plateSeed} second seed: {(secondEnv > 0 ? secondEnv.ToString() : "auto (most natural islands)")}");
|
||||||
|
foreach (var l in levels) GD.Print($" {l.Label,-15} {l.Frac:G3} of map area = {Cells(l.Frac, mapSize):N0} cells at {mapSize} ({Cells(l.Frac, tableSize):N0} at {tableSize})");
|
||||||
|
GD.Print($"terrain : shelf ON + offshore {OffshoreSettings.Organic().Describe()}");
|
||||||
|
GD.Print($"contract : classify field · land 8-connected · mainland = centre component · id/size/centroid/hemisphere(centroid)/isMainland");
|
||||||
|
GD.Print($"batch : {batchRoot}{(tableOnly ? " ⚠ ISLA_TABLE_ONLY — a probe, not the batch of record" : "")}");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
|
||||||
|
// ═══ 0. THE CURVE ═══
|
||||||
|
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
|
||||||
|
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
|
||||||
|
GD.Print($" {knots}");
|
||||||
|
GD.Print($" {calibration.Describe()}");
|
||||||
|
|
||||||
|
TerrainGenConfig Cfg(int size, int seed, string label, bool offshoreOn, bool revertOn, float frac)
|
||||||
|
{
|
||||||
|
var c = BaseConfig(size, seed, knots, anchors, calibration, label);
|
||||||
|
if (offshoreOn) { c.CoastShelf = true; c.Offshore = OffshoreSettings.Organic(); }
|
||||||
|
c.RegionLabeling = true;
|
||||||
|
c.SpeckRevert = revertOn;
|
||||||
|
c.MinLandComponentFrac = frac;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 1. REGRESSIONS ═══
|
||||||
|
var hard = new List<ShapingOracle.Check>();
|
||||||
|
if (!tableOnly)
|
||||||
|
{
|
||||||
|
GD.Print($"\n--- 1. REGRESSIONS at {calibSize}, seed {plateSeed} ---");
|
||||||
|
var offCfg = Cfg(calibSize, plateSeed, "off", offshoreOn: false, revertOn: false, mid.Frac);
|
||||||
|
Pass1Result p1 = Topography.Generate(offCfg);
|
||||||
|
|
||||||
|
var curveOff = offCfg.Clone(); curveOff.Curve = false;
|
||||||
|
Pass2Result pOff = Shaping.Shape(p1, curveOff);
|
||||||
|
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{plateSeed}_full", "height.f32");
|
||||||
|
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, offshore OFF, revert OFF (labeling on) == Phase-1 .f32 dump",
|
||||||
|
pOff.Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
|
||||||
|
|
||||||
|
Pass2Result pRest = Shaping.Shape(p1, offCfg);
|
||||||
|
string t03Dump = Path.Combine(ToolingPaths.BatchesRoot, t03Source, $"{plateSeed}_continuous_restored", "height.f32");
|
||||||
|
float[,] t03 = HeightField.Load(t03Dump, calibSize);
|
||||||
|
hard.Add(ShapingOracle.DumpRegression("a3", "continuous_restored, offshore OFF, revert OFF (labeling on) == task-03 .f32 dump",
|
||||||
|
pRest.Height, t03, calibSize, t03Dump));
|
||||||
|
|
||||||
|
// Informational: offshore OFF, revert ON — how many NATURAL speck cells the revert removes
|
||||||
|
// from the bare field. Allowed to differ (the revert may change terrain); reported, not asserted.
|
||||||
|
var revCfg = Cfg(calibSize, plateSeed, "off_revert", offshoreOn: false, revertOn: true, mid.Frac);
|
||||||
|
Pass1Result p1Rev = Topography.Generate(revCfg);
|
||||||
|
Pass2Result pRev = Shaping.Shape(p1Rev, revCfg);
|
||||||
|
var info = ShapingOracle.DumpRegression("a3r", "(informational) offshore OFF, revert ON at threshold_mid vs task-03 dump — the natural specks removed", pRev.Height, t03, calibSize, t03Dump);
|
||||||
|
info.Detail = (info.Passed ? "no natural speck below the threshold on this seed — " : "") + info.Detail +
|
||||||
|
$" · reverted {p1Rev.RegionLedger.RevertedComponents} natural components / {p1Rev.RegionLedger.RevertedCells:N0} cells";
|
||||||
|
info.Passed = true;
|
||||||
|
hard.Add(info);
|
||||||
|
|
||||||
|
var shelfCfg = offCfg.Clone(); shelfCfg.CoastShelf = true; shelfCfg.VariantLabel = "shelf_only";
|
||||||
|
Pass1Result p1Shelf = Topography.Generate(shelfCfg);
|
||||||
|
var j0 = ShapingOracle.MainlandUnmoved(p1, p1Shelf, sea);
|
||||||
|
j0.Name = "shelf alone: every land cell bit-identical (shelf is below-sea only)";
|
||||||
|
hard.Add(j0);
|
||||||
|
hard.Add(ShapingOracle.CentreIsLand(p1));
|
||||||
|
foreach (var c in hard) GD.Print(" " + c);
|
||||||
|
|
||||||
|
if (!skip8k)
|
||||||
|
{
|
||||||
|
string t04Dump = Path.Combine(ToolingPaths.BatchesRoot, t04Source, $"{plateSeed}", "height.f32");
|
||||||
|
if (File.Exists(t04Dump))
|
||||||
|
{
|
||||||
|
GD.Print($" a4: generating {plateSeed} at {GallerySize}, offshore OFF, revert OFF …");
|
||||||
|
var gCfg = Cfg(GallerySize, plateSeed, "off", offshoreOn: false, revertOn: false, mid.Frac);
|
||||||
|
Pass2Result pG = Shaping.Shape(Topography.Generate(gCfg), gCfg);
|
||||||
|
var a4 = ShapingOracle.DumpRegression("a4", $"offshore OFF, revert OFF at {GallerySize} == terrain-curve-v1's 04 gallery .f32 dump",
|
||||||
|
pG.Height, HeightField.Load(t04Dump, GallerySize), GallerySize, t04Dump);
|
||||||
|
hard.Add(a4); GD.Print(" " + a4);
|
||||||
|
}
|
||||||
|
else GD.Print($" a4: ⚠ skipped — no 04 gallery dump at {t04Dump}");
|
||||||
|
}
|
||||||
|
else GD.Print(" a4: skipped (ISLA_SKIP_8K)");
|
||||||
|
|
||||||
|
// ⭐ a6 — labeling ON, revert OFF, on the chat2/06 preset: bit-identical to the 06 batch's
|
||||||
|
// render field. Labeling is pure analysis; only the revert may change terrain.
|
||||||
|
string t06Dump = Path.Combine(ToolingPaths.BatchesRoot, t06Source, $"{plateSeed}_density_mid", "height.f32");
|
||||||
|
if (File.Exists(t06Dump) && mapSize == 4096)
|
||||||
|
{
|
||||||
|
var c6 = Cfg(mapSize, plateSeed, "density_mid", offshoreOn: true, revertOn: false, mid.Frac);
|
||||||
|
Pass2Result p6 = Shaping.Shape(Topography.Generate(c6), c6);
|
||||||
|
var a6 = ShapingOracle.DumpRegression("a6", "offshore density_mid ON, labeling ON, revert OFF == task-06 .f32 dump (labeling is pure analysis)",
|
||||||
|
p6.Height, HeightField.Load(t06Dump, mapSize), mapSize, t06Dump);
|
||||||
|
hard.Add(a6); GD.Print(" " + a6);
|
||||||
|
}
|
||||||
|
else GD.Print($" a6: ⚠ skipped — {(mapSize != 4096 ? "map size is not the 06 batch's 4096" : $"no 06 dump at {t06Dump}")}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 2. DETERMINISM ═══
|
||||||
|
GD.Print($"\n--- 2. DETERMINISM at {tableSize}, seed {plateSeed}, {mid.Label} ---");
|
||||||
|
var perFieldChecks = new List<ShapingOracle.Check>();
|
||||||
|
{
|
||||||
|
var cA = Cfg(tableSize, plateSeed, mid.Label, true, true, mid.Frac);
|
||||||
|
var cB = Cfg(tableSize, plateSeed, mid.Label, true, true, mid.Frac);
|
||||||
|
var det = ShapingOracle.LabelsDeterministic(Topography.Generate(cA), Topography.Generate(cB));
|
||||||
|
det.Name += $" [{plateSeed}]";
|
||||||
|
perFieldChecks.Add(det); GD.Print(" " + det);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 3. THE COUNT/SIZE TABLE ═══
|
||||||
|
GD.Print($"\n--- 3. COUNT/SIZE TABLE at {tableSize} ---");
|
||||||
|
var rows = new List<Row>();
|
||||||
|
var naturalCount = new Dictionary<int, int>();
|
||||||
|
bool notesShown = false;
|
||||||
|
foreach (int seed in tableSeeds)
|
||||||
|
{
|
||||||
|
// natural: offshore OFF, revert OFF
|
||||||
|
Pass1Result pNat = Topography.Generate(Cfg(tableSize, seed, "natural", false, false, mid.Frac));
|
||||||
|
var (natN, natS) = RegionLabeling.IslandsByHemisphere(pNat.Regions);
|
||||||
|
naturalCount[seed] = pNat.Regions.IslandCount;
|
||||||
|
// pre: offshore ON, revert OFF
|
||||||
|
var cPre = Cfg(tableSize, seed, "pre", true, false, mid.Frac);
|
||||||
|
Pass1Result pPre = Topography.Generate(cPre);
|
||||||
|
{
|
||||||
|
var cj = ShapingOracle.MainlandUnmoved(pNat, pPre, sea); cj.Name += $" [offshore on vs off, {seed}]"; perFieldChecks.Add(cj);
|
||||||
|
var cm = ShapingOracle.CentreIsLand(pPre); cm.Name += $" [pre {seed}]"; perFieldChecks.Add(cm);
|
||||||
|
}
|
||||||
|
if (!notesShown) { foreach (string n in pPre.Notes) GD.Print(" " + n); }
|
||||||
|
GD.Print($" seed {seed,-11} natural islands {pNat.Regions.IslandCount,3} (N {natN} / S {natS}) pre-revert {pPre.Regions.IslandCount,3} (N {pPre.RegionLedger.PostNorth} / S {pPre.RegionLedger.PostSouth}) mainland {pPre.Regions.Mainland.SizeCells:N0} cells");
|
||||||
|
|
||||||
|
foreach (var lv in levels)
|
||||||
|
{
|
||||||
|
var cfg = Cfg(tableSize, seed, lv.Label, true, true, lv.Frac);
|
||||||
|
Pass1Result p1 = Topography.Generate(cfg);
|
||||||
|
Pass2Result p2 = Shaping.Shape(p1, cfg);
|
||||||
|
if (!notesShown) { foreach (string n in p1.Notes) if (n.StartsWith("[Regions]")) GD.Print(" " + n); notesShown = true; }
|
||||||
|
var led = p1.RegionLedger;
|
||||||
|
long thr = led.ThresholdCells;
|
||||||
|
var comps = OffshoreAnalysis.Components(p1.IsIsland, p1.Height, sea, tableSize);
|
||||||
|
var checks = new List<ShapingOracle.Check>
|
||||||
|
{
|
||||||
|
ShapingOracle.CentreIsLand(p1),
|
||||||
|
ShapingOracle.RevertGuards(pPre, p1, sea, thr),
|
||||||
|
ShapingOracle.MoatIntact(p1, comps),
|
||||||
|
ShapingOracle.TagCoastlineConsistent(p2, sea),
|
||||||
|
ShapingOracle.HMaxAfterOffshore(p1),
|
||||||
|
ShapingOracle.ClassifyFidelity(p1, p2),
|
||||||
|
};
|
||||||
|
foreach (var c in checks) { c.Name += $" [{lv.Label} {seed}]"; perFieldChecks.Add(c); }
|
||||||
|
bool ok = checks.TrueForAll(c => c.Passed);
|
||||||
|
var row = new Row
|
||||||
|
{
|
||||||
|
Level = lv.Label, Seed = seed, ThresholdCells = thr,
|
||||||
|
Natural = pNat.Regions.IslandCount, NaturalN = natN, NaturalS = natS,
|
||||||
|
Pre = led.PreIslands, PreN = led.PreNorth, PreS = led.PreSouth,
|
||||||
|
Post = led.PostIslands, PostN = led.PostNorth, PostS = led.PostSouth,
|
||||||
|
RevertedComps = led.RevertedComponents, RevertedCells = led.RevertedCells,
|
||||||
|
PreMin = led.PreMin, PreMed = led.PreMedian, PreMean = led.PreMean, PreMax = led.PreMax,
|
||||||
|
PostMin = led.PostMin, PostMed = led.PostMedian, PostMean = led.PostMean, PostMax = led.PostMax,
|
||||||
|
PreHist = led.PreHistogram, PostHist = led.PostHistogram,
|
||||||
|
MainlandCells = p1.Regions.Mainland.SizeCells, Ok = ok, Ms = p1.ElapsedMs,
|
||||||
|
};
|
||||||
|
rows.Add(row);
|
||||||
|
GD.Print($" {lv.Label,-15} seed {seed,-11} thr {thr,5} pre {row.Pre,3} → post {row.Post,3} (N {row.PostN,2} / S {row.PostS,2}) reverted {row.RevertedComps,3} comps / {row.RevertedCells,7:N0} cells " +
|
||||||
|
$"post size min {row.PostMin,5} med {row.PostMed,5} max {row.PostMax,6} {(ok ? "ok" : "⚠ CHECK FAILED")} {p1.ElapsedMs} ms");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 4. THE PLATES ═══
|
||||||
|
int secondSeed = secondEnv > 0 ? secondEnv : PickSecondSeed(naturalCount, tableSeeds, plateSeed);
|
||||||
|
GD.Print($"\n second seed: {secondSeed}{(secondEnv > 0 ? " (ISLA_SECOND_SEED)" : $" (auto: most natural islands among the table seeds — {naturalCount.GetValueOrDefault(secondSeed)})")}");
|
||||||
|
var plateRows = new List<Row>();
|
||||||
|
if (!tableOnly)
|
||||||
|
{
|
||||||
|
GD.Print($"\n--- 4. PLATES at {mapSize} ---");
|
||||||
|
var plates = new List<(int seed, Level lv)> { (plateSeed, levels[0]), (plateSeed, levels[1]), (plateSeed, levels[2]), (secondSeed, mid) };
|
||||||
|
foreach (var (seed, lv) in plates)
|
||||||
|
{
|
||||||
|
var cfg = Cfg(mapSize, seed, lv.Label, true, true, lv.Frac);
|
||||||
|
Pass1Result p1 = Topography.Generate(cfg);
|
||||||
|
Pass2Result p2 = Shaping.Shape(p1, cfg);
|
||||||
|
var led = p1.RegionLedger;
|
||||||
|
var cm = ShapingOracle.CentreIsLand(p1); cm.Name += $" [plate {lv.Label} {seed}]";
|
||||||
|
var ck = ShapingOracle.TagCoastlineConsistent(p2, sea); ck.Name += $" [plate {lv.Label} {seed}]";
|
||||||
|
perFieldChecks.Add(cm); perFieldChecks.Add(ck);
|
||||||
|
WritePlate(batchRoot, p1, p2, sea, anchors, skipRaw);
|
||||||
|
plateRows.Add(new Row
|
||||||
|
{
|
||||||
|
Level = lv.Label, Seed = seed, ThresholdCells = led.ThresholdCells,
|
||||||
|
Pre = led.PreIslands, PreN = led.PreNorth, PreS = led.PreSouth, Post = led.PostIslands, PostN = led.PostNorth, PostS = led.PostSouth,
|
||||||
|
RevertedComps = led.RevertedComponents, RevertedCells = led.RevertedCells,
|
||||||
|
PostMin = led.PostMin, PostMed = led.PostMedian, PostMean = led.PostMean, PostMax = led.PostMax,
|
||||||
|
MainlandCells = p1.Regions.Mainland.SizeCells, Ok = cm.Passed && ck.Passed, Ms = p1.ElapsedMs,
|
||||||
|
});
|
||||||
|
GD.Print($" plate {seed}_{lv.Label}: pre {led.PreIslands} → post {led.PostIslands} (N {led.PostNorth} / S {led.PostSouth}), reverted {led.RevertedComponents} comps {(cm.Passed && ck.Passed ? "ok" : "⚠ CHECK FAILED")} {p1.ElapsedMs} ms");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool allOk = hard.TrueForAll(c => c.Passed) && perFieldChecks.TrueForAll(c => c.Passed);
|
||||||
|
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
|
||||||
|
foreach (var c in perFieldChecks) if (!c.Passed) GD.PrintErr(" " + c);
|
||||||
|
|
||||||
|
WriteTable(batchRoot, tableSize, levels, rows);
|
||||||
|
WriteIndex(batchRoot, mapSize, tableSize, calibSize, plateSeed, secondSeed, tableSeeds, levels, rows, plateRows, hard, perFieldChecks, allOk, tableOnly);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long Cells(float frac, int size) => Math.Max(1L, (long)Math.Round(frac * (double)size * size));
|
||||||
|
|
||||||
|
private static int PickSecondSeed(Dictionary<int, int> natural, int[] seeds, int plateSeed)
|
||||||
|
{
|
||||||
|
int best = 0, bestN = -1;
|
||||||
|
foreach (int s in seeds)
|
||||||
|
{
|
||||||
|
if (s == plateSeed) continue;
|
||||||
|
int n = natural.GetValueOrDefault(s);
|
||||||
|
if (n > bestN) { best = s; bestN = n; }
|
||||||
|
}
|
||||||
|
return best == 0 ? plateSeed : best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the curve, measured exactly as tasks 03–06 did --------------------
|
||||||
|
|
||||||
|
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 }); // offshore OFF, revert OFF by default
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a,
|
||||||
|
ClimbCalibration cal, string label) => new TerrainGenConfig
|
||||||
|
{
|
||||||
|
MapSize = mapSize, Seed = seed, VariantLabel = label,
|
||||||
|
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||||||
|
Knots = k, Anchors = a, ClimbCalibration = cal, LowlandCeilingM = 30f,
|
||||||
|
CoastShelf = false, Offshore = new OffshoreSettings(), // OFF unless the variant turns it on
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- output -----------------------------------------------------------
|
||||||
|
|
||||||
|
private static void WritePlate(string batchRoot, Pass1Result p1, Pass2Result p2, float sea, CurveAnchors anchors, bool skipRaw)
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||||||
|
|
||||||
|
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 = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
|
||||||
|
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f,
|
||||||
|
HillshadeStrength = 0.30f, SeaLevel = sea,
|
||||||
|
};
|
||||||
|
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
|
||||||
|
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, $"{p2.VariantLabel.ToUpperInvariant()} {p2.Seed}")
|
||||||
|
.SavePng(Path.Combine(dir, "relief.png"));
|
||||||
|
|
||||||
|
// ⭐ The labeled-regions overlay — the point of this task.
|
||||||
|
var led = p1.RegionLedger;
|
||||||
|
RegionOverlayRenderer.SavePng(p1.Regions, led.RevertOn ? p1.RegionsPre : null, p1.MapSize,
|
||||||
|
led.RevertedComponents, led.ThresholdCells, Path.Combine(dir, "regions.png"));
|
||||||
|
|
||||||
|
// The hemisphere tag overlay (chat2/05's), now showing the tag by construction.
|
||||||
|
var (n, s) = RegionLabeling.IslandsByHemisphere(p1.Regions);
|
||||||
|
TagOverlayRenderer.SavePng(p2.Height, p2.IsIsland, p2.IslandHemisphere, p2.MapSize, sea, n, s, Path.Combine(dir, "tags.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HistRow(int[] h)
|
||||||
|
{
|
||||||
|
if (h == null) return "—";
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < h.Length; i++) { if (i > 0) sb.Append(" · "); sb.Append(h[i]); }
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TableMarkdown(List<Level> levels, List<Row> rows, int tableSize)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var histHead = new StringBuilder();
|
||||||
|
for (int i = 0; i <= RegionLabeling.HistogramEdges.Length; i++) { if (i > 0) histHead.Append(" · "); histHead.Append(RegionLabeling.HistogramLabel(i)); }
|
||||||
|
sb.AppendLine($"| Level | Seed | threshold (cells) | natural islands (offshore off) N / S | pre-revert islands N / S | **post-revert islands N / S** | reverted comps / cells | pre size min / med / mean / max | **post size min / med / mean / max** | post histogram ({histHead}) | mainland cells | oracle |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (var lv in levels)
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
if (r.Level != lv.Label) continue;
|
||||||
|
sb.AppendLine($"| `{r.Level}` | `{r.Seed}` | {r.ThresholdCells:N0} | {r.Natural} ({r.NaturalN} / {r.NaturalS}) | {r.Pre} ({r.PreN} / {r.PreS}) | **{r.Post} ({r.PostN} / {r.PostS})** | {r.RevertedComps} / {r.RevertedCells:N0} | " +
|
||||||
|
$"{r.PreMin} / {r.PreMed} / {r.PreMean:F0} / {r.PreMax} | **{r.PostMin} / {r.PostMed} / {r.PostMean:F0} / {r.PostMax}** | {HistRow(r.PostHist)} | {r.MainlandCells:N0} | {(r.Ok ? "pass" : "**FAIL**")} |");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("**Per level (over the seeds):**");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Level | threshold | post islands min / mean / max | post N min / mean / max | post S min / mean / max | reverted comps (total) | reverted cells (total) | post median island (median over seeds) | smallest surviving island |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (var lv in levels)
|
||||||
|
{
|
||||||
|
int cnt = 0, minP = int.MaxValue, maxP = 0, minN = int.MaxValue, maxN = 0, minS = int.MaxValue, maxS = 0; double sumP = 0, sumN = 0, sumS = 0;
|
||||||
|
long revC = 0, revCells = 0, smallest = long.MaxValue; var meds = new List<long>(); long thr = 0;
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
if (r.Level != lv.Label) continue;
|
||||||
|
cnt++; thr = r.ThresholdCells;
|
||||||
|
minP = Math.Min(minP, r.Post); maxP = Math.Max(maxP, r.Post); sumP += r.Post;
|
||||||
|
minN = Math.Min(minN, r.PostN); maxN = Math.Max(maxN, r.PostN); sumN += r.PostN;
|
||||||
|
minS = Math.Min(minS, r.PostS); maxS = Math.Max(maxS, r.PostS); sumS += r.PostS;
|
||||||
|
revC += r.RevertedComps; revCells += r.RevertedCells; meds.Add(r.PostMed);
|
||||||
|
if (r.Post > 0) smallest = Math.Min(smallest, r.PostMin);
|
||||||
|
}
|
||||||
|
if (cnt == 0) continue;
|
||||||
|
meds.Sort();
|
||||||
|
sb.AppendLine($"| `{lv.Label}` | {lv.Frac:G3} = {thr:N0} cells | {minP} / {sumP / cnt:F1} / {maxP} | {minN} / {sumN / cnt:F1} / {maxN} | {minS} / {sumS / cnt:F1} / {maxS} | {revC} | {revCells:N0} | {meds[meds.Count / 2]} | {(smallest == long.MaxValue ? 0 : smallest)} |");
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteTable(string batchRoot, int tableSize, List<Level> levels, List<Row> rows)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"# The count/size table — {rows.Count / Math.Max(1, levels.Count)} seeds × {levels.Count} revert thresholds at {tableSize}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Islands = non-mainland 8-connected land components of the CLASSIFY field (mainland = the centre component).");
|
||||||
|
sb.AppendLine("*natural* = offshore off, revert off; *pre-revert* = offshore `density_mid` on, revert off; *post-revert* = the same with");
|
||||||
|
sb.AppendLine("the speck revert on at the level's threshold. Sizes in cells. Histogram bins are cells, log-spaced.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(TableMarkdown(levels, rows, tableSize));
|
||||||
|
WriteText(Path.Combine(batchRoot, "count_size_table.md"), sb.ToString());
|
||||||
|
|
||||||
|
var csv = new StringBuilder();
|
||||||
|
csv.AppendLine("level,seed,threshold_cells,natural,natural_n,natural_s,pre,pre_n,pre_s,post,post_n,post_s,reverted_comps,reverted_cells,pre_min,pre_median,pre_mean,pre_max,post_min,post_median,post_mean,post_max,post_hist,mainland_cells,oracle,ms");
|
||||||
|
var ic = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
|
foreach (var r in rows)
|
||||||
|
csv.AppendLine(string.Join(",", r.Level, r.Seed, r.ThresholdCells, r.Natural, r.NaturalN, r.NaturalS, r.Pre, r.PreN, r.PreS, r.Post, r.PostN, r.PostS,
|
||||||
|
r.RevertedComps, r.RevertedCells, r.PreMin, r.PreMed, r.PreMean.ToString("F1", ic), r.PreMax, r.PostMin, r.PostMed, r.PostMean.ToString("F1", ic), r.PostMax,
|
||||||
|
"\"" + HistRow(r.PostHist) + "\"", r.MainlandCells, r.Ok ? "pass" : "FAIL", r.Ms));
|
||||||
|
WriteText(Path.Combine(batchRoot, "count_size_table.csv"), csv.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteIndex(string batchRoot, int mapSize, int tableSize, int calibSize, int plateSeed, int secondSeed,
|
||||||
|
int[] tableSeeds, List<Level> levels, List<Row> rows, List<Row> plateRows,
|
||||||
|
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perField, bool allOk, bool tableOnly)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("# Batch 07 — region labeling: label all land, fix the tag, tunable speck revert");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("The **region-labeling layer** (`Core.RegionLabeling`) flood-fills the CLASSIFY field's land into 8-connected");
|
||||||
|
sb.AppendLine("components, names the **centre component** the mainland, and exposes id / size / centroid / hemisphere (by");
|
||||||
|
sb.AppendLine("centroid) / isMainland. The **island tag is now a consequence of labeling** — every non-mainland component,");
|
||||||
|
sb.AppendLine("natural detached masses included. The **speck revert** (origin-blind, lower-only, component-only, mainland never)");
|
||||||
|
sb.AppendLine("lowers sub-threshold islands to their ring's seabed; the threshold is the dial swept here.");
|
||||||
|
sb.AppendLine();
|
||||||
|
if (tableOnly) sb.AppendLine("> ⚠ **ISLA_TABLE_ONLY** — a probe run: table only, no regressions, no plates. Not the batch of record.\n");
|
||||||
|
sb.AppendLine("## ⭐ Open this first");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"1. **`{plateSeed}_threshold_mid/regions.png`** — the labeled-regions overlay: grey = mainland (the centre component),");
|
||||||
|
sb.AppendLine(" every island its own colour, dark red = where a reverted speck was. Then `relief.png` for the clean ocean.");
|
||||||
|
sb.AppendLine($"2. **`{plateSeed}_threshold_low/`** and **`{plateSeed}_threshold_high/`** beside it — same seed, lower / higher cutoff.");
|
||||||
|
sb.AppendLine($"3. **`{secondSeed}_threshold_mid/regions.png`** + `tags.png` — the second seed (most natural islands): the big organic");
|
||||||
|
sb.AppendLine(" detached masses are labeled and TAGGED (cyan / orange), which task 06's overlay left grey.");
|
||||||
|
sb.AppendLine("4. Then the count/size table — the instrument for the later southern-stretch step.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("**The contract (verbatim):** field = classify (raw, uncurved) · land 8-connected (the complement of water's 4) ·");
|
||||||
|
sb.AppendLine("component = maximal 8-connected set of land cells (classify ≥ sea) · mainland = the component containing the map");
|
||||||
|
sb.AppendLine("centre (not merely the largest; the crater is NOT central) · per component: id, sizeCells, centroid, hemisphere");
|
||||||
|
sb.AppendLine($"(by centroid), isMainland. NORTH = rows `[0, {mapSize / 2})`, SOUTH = rows `[{mapSize / 2}, {mapSize})`; y runs south.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The four plates");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Plate | threshold (cells) | pre-revert islands N / S | **post-revert islands N / S** | reverted comps / cells | post size min / med / mean / max | mainland cells | oracle |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (var r in plateRows)
|
||||||
|
sb.AppendLine($"| `{r.Seed}_{r.Level}/` | {r.ThresholdCells:N0} | {r.Pre} ({r.PreN} / {r.PreS}) | **{r.Post} ({r.PostN} / {r.PostS})** | {r.RevertedComps} / {r.RevertedCells:N0} | {r.PostMin} / {r.PostMed} / {r.PostMean:F0} / {r.PostMax} | {r.MainlandCells:N0} | {(r.Ok ? "pass" : "**FAIL**")} |");
|
||||||
|
if (plateRows.Count == 0) sb.AppendLine("| *(no plates — probe run)* | | | | | | | |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"## ⭐ The count/size table — {tableSeeds.Length} seeds × 3 thresholds at {tableSize}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(TableMarkdown(levels, rows, tableSize));
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Also as plain data: `count_size_table.md` / `.csv`.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The levels");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Level | `MinLandComponentFrac` | cells at the plate size | cells at 8192 |");
|
||||||
|
sb.AppendLine("|---|---|---|---|");
|
||||||
|
foreach (var lv in levels) sb.AppendLine($"| `{lv.Label}`{(lv.Label == "threshold_mid" ? " ⭐ config default" : "")} | {lv.Frac:G3} | {Cells(lv.Frac, mapSize):N0} | {Cells(lv.Frac, 8192):N0} |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Every field: coast shelf ON + offshore `{OffshoreSettings.Organic().Describe()}` + region labeling ON. The revert is the variable.");
|
||||||
|
sb.AppendLine("The revert is **origin-blind**: it removes small natural nubs as well as offshore-pass dots (fewer / bigger, intended). An offshore");
|
||||||
|
sb.AppendLine("island it removes leaves its submerged skirt (not this component — component-only) as a shoal.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("`ProvisionalEven`, flagged. The individually-coloured scheme is ONLY the `regions.png` overlay.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The oracle");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Regressions (offshore OFF + revert OFF must be bit-identical to Phase 1, task 03 and the `terrain-curve-v1` gallery dump; labeling ON + revert OFF bit-identical to the task-06 dump):");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(hard.Count == 0 ? "*(skipped — probe run)*\n" : ShapingOracle.ToMarkdownTable(hard));
|
||||||
|
sb.AppendLine("Per field (centre-is-land m · revert guards n · determinism o · moat i · mainland unmoved j · tag/coastline k · HMaxSeed l · classify b):");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(ShapingOracle.ToMarkdownTable(perField));
|
||||||
|
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("| `regions.png`, `tags.png`, `relief.png`, `INDEX.md`, `count_size_table.md` / `.csv` | **keep** |");
|
||||||
|
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
|
||||||
|
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + code — large, clear freely |");
|
||||||
|
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Plates at {mapSize}, table at {tableSize}, curve calibrated at {calibSize} with offshore off. {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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/RegionLabelingTool.cs.uid
Normal file
1
Tools/Scripts/RegionLabelingTool.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://ceeyncymgtjpm
|
||||||
77
Tools/Scripts/RegionOverlayRenderer.cs
Normal file
77
Tools/Scripts/RegionOverlayRenderer.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
using System;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// THE LABELED-REGIONS OVERLAY (chat2/07): the mainland one tint, EACH island component an
|
||||||
|
/// individually distinct colour, the components the speck revert removed in a dim red, the midline
|
||||||
|
/// drawn — so the developer can SEE that components are identified correctly and catch an
|
||||||
|
/// 8-connectivity mislabel (two touching blobs coloured as one, one mass coloured as two).
|
||||||
|
///
|
||||||
|
/// ⚠ A DIAGNOSTIC, NOT A MAP. It draws the region layer's id map, which is DATA. No hypsometry, no
|
||||||
|
/// hillshade — flat tints on purpose. The relief / grayscale plates stay on the provisional palette.
|
||||||
|
/// Presentation only: it is handed arrays and returns a PNG.
|
||||||
|
/// </summary>
|
||||||
|
public static class RegionOverlayRenderer
|
||||||
|
{
|
||||||
|
private static readonly Color Sea = new(0.055f, 0.110f, 0.235f);
|
||||||
|
private static readonly Color Mainland = new(0.340f, 0.380f, 0.330f);
|
||||||
|
private static readonly Color Reverted = new(0.420f, 0.080f, 0.080f); // where a reverted speck WAS (now sea)
|
||||||
|
private static readonly Color Midline = new(0.700f, 0.720f, 0.760f);
|
||||||
|
private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
|
||||||
|
|
||||||
|
/// <summary>A distinct, saturated colour per island id — golden-angle hue walk, three value steps.</summary>
|
||||||
|
public static Color IslandColor(int id)
|
||||||
|
{
|
||||||
|
float hue = (id * 137.508f) % 360f / 360f;
|
||||||
|
float val = 0.70f + 0.15f * (id % 3);
|
||||||
|
float sat = 0.85f - 0.15f * ((id / 3) % 2);
|
||||||
|
return Color.FromHsv(hue, sat, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="labels">The finished field's labeling (post-revert).</param>
|
||||||
|
/// <param name="labelsPre">The pre-revert labeling, or null — its reverted components are painted <see cref="Reverted"/>.</param>
|
||||||
|
public static void SavePng(RegionLabels labels, RegionLabels labelsPre, int mapSize, int revertedCount, long thresholdCells,
|
||||||
|
string absolutePath)
|
||||||
|
{
|
||||||
|
var img = Image.CreateEmpty(mapSize, mapSize, false, Image.Format.Rgb8);
|
||||||
|
int n = mapSize;
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
{
|
||||||
|
int id = labels.Id[x * n + y];
|
||||||
|
Color c;
|
||||||
|
if (id == 0)
|
||||||
|
{
|
||||||
|
c = Sea;
|
||||||
|
if (labelsPre != null)
|
||||||
|
{
|
||||||
|
int pid = labelsPre.Id[x * n + y];
|
||||||
|
if (pid != 0 && pid != labelsPre.MainlandId) c = Reverted; // was land, was not mainland, is sea now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (id == labels.MainlandId) c = Mainland;
|
||||||
|
else c = IslandColor(id);
|
||||||
|
img.SetPixel(x, y, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int mid = n / 2;
|
||||||
|
for (int x = 0; x < n; x += 3) img.SetPixel(x, mid, Midline);
|
||||||
|
|
||||||
|
var (north, south) = RegionLabeling.IslandsByHemisphere(labels);
|
||||||
|
int s = n >= 4096 ? 4 : 3;
|
||||||
|
int lh = TinyFont.Height(s) + 6;
|
||||||
|
TinyFont.Draw(img, "LABELED REGIONS - CLASSIFY FIELD, LAND 8-CONNECTED", 12, 12, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"GREY: MAINLAND (CENTRE COMPONENT{(labels.CentreWasLand ? "" : " - FALLBACK, CENTRE NOT LAND")}) EACH ISLAND: ITS OWN COLOUR", 12, 12 + lh, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"ISLANDS: {labels.IslandCount} ({north} N / {south} S BY CENTROID) DARK RED: {revertedCount} REVERTED < {thresholdCells} CELLS", 12, 12 + lh * 2, s, Ink);
|
||||||
|
TinyFont.Draw(img, "N ABOVE THE LINE - S BELOW - Y RUNS SOUTH", 12, 12 + lh * 3, s, Ink);
|
||||||
|
|
||||||
|
Error err = img.SavePng(absolutePath);
|
||||||
|
if (err != Error.Ok) GD.PrintErr($"[RegionOverlayRenderer] SavePng failed ({err}) for {absolutePath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/RegionOverlayRenderer.cs.uid
Normal file
1
Tools/Scripts/RegionOverlayRenderer.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://c05blbudak0as
|
||||||
203
Tools/Scripts/RegionPass.cs
Normal file
203
Tools/Scripts/RegionPass.cs
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>The region pass's numbers, carried on <c>Pass1Result</c> for the report.</summary>
|
||||||
|
public sealed class RegionLedger
|
||||||
|
{
|
||||||
|
public bool RevertOn;
|
||||||
|
public long ThresholdCells;
|
||||||
|
public int PreIslands, PreNorth, PreSouth; // before the speck revert
|
||||||
|
public int PostIslands, PostNorth, PostSouth; // after
|
||||||
|
public int RevertedComponents; public long RevertedCells;
|
||||||
|
public bool CentreWasLand = true, CentreWasLandPre = true;
|
||||||
|
public long PreMin, PreMedian, PreMax, PostMin, PostMedian, PostMax; public double PreMean, PostMean;
|
||||||
|
public int[] PreHistogram, PostHistogram;
|
||||||
|
public List<(int id, long cells, byte hemi, float newHeight)> Reverted = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ PASS 1c — REGION LABELING + THE SPECK REVERT + THE ISLAND TAG (chat2/07). Runs over the
|
||||||
|
/// finished pass-1/1b classify field, IN PLACE, after the shelf/islets and before <c>HMaxSeed</c>
|
||||||
|
/// is retaken and anything classifies.
|
||||||
|
///
|
||||||
|
/// ═══ WHAT IT DOES, IN ORDER ═══
|
||||||
|
///
|
||||||
|
/// 1. LABEL <see cref="RegionLabeling.Label"/> over the classify field — the general layer.
|
||||||
|
/// Pure analysis: no height changes. (The pre-revert table is kept for the instrument.)
|
||||||
|
/// 2. REVERT (config-gated: <c>TerrainGenConfig.SpeckRevert</c>; threshold
|
||||||
|
/// <c>MinLandComponentFrac</c> × map area) — every NON-MAINLAND component below the
|
||||||
|
/// threshold is lowered to seabed. ORIGIN-BLIND: it judges components by size, not by
|
||||||
|
/// who made them — a small natural nub goes the same way as an offshore-pass dot.
|
||||||
|
/// Fewer, bigger. Two guards, ASSERTED per component, hard failure on violation:
|
||||||
|
/// LOWER-ONLY — every touched cell goes DOWN (land → below sea), never up;
|
||||||
|
/// COMPONENT-ONLY — only cells of the sub-threshold component are touched, never a
|
||||||
|
/// neighbour (the submerged skirt an offshore island leaves behind
|
||||||
|
/// is NOT this component and stays — a shoal, by the rule).
|
||||||
|
/// Together they make it impossible for "revert" to move the mainland coast.
|
||||||
|
/// MAINLAND IS NEVER A CANDIDATE (asserted), however small a pathological seed made it.
|
||||||
|
/// The seabed a cell is lowered to is the MEAN height of the component's adjacent sea
|
||||||
|
/// cells (its ring), held strictly below sea by BitDecrement — a flat shoal at the
|
||||||
|
/// local depth, not a pit and not a reef.
|
||||||
|
/// 3. RELABEL after a revert the layer is run again, so the exposed table and ids are those of
|
||||||
|
/// the finished field.
|
||||||
|
/// 4. TAG BY CONSTRUCTION: every cell of every non-mainland component is an island cell,
|
||||||
|
/// hemisphere from its component's centroid. The big organic detached masses are
|
||||||
|
/// tagged the same as an offshore-pass dot. Nothing here knows which pass made a cell.
|
||||||
|
///
|
||||||
|
/// The classify field IS the pass-1 array; pass 2 derives the render field from it and the curve is
|
||||||
|
/// identity at and below sea, so a reverted cell is seabed in both — asserted downstream by oracle (k).
|
||||||
|
/// </summary>
|
||||||
|
public static class RegionPass
|
||||||
|
{
|
||||||
|
public sealed class Result
|
||||||
|
{
|
||||||
|
public RegionLabels LabelsPre; // before the revert (== Labels when the revert is off or reverted nothing)
|
||||||
|
public RegionLabels Labels; // the finished field's labeling
|
||||||
|
public bool[,] IsIsland; // the tag, by construction
|
||||||
|
public byte[,] IslandHemisphere;
|
||||||
|
public RegionLedger Ledger = new();
|
||||||
|
public List<string> Notes = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The three thresholds of the chat2/07 batch, fractions of the map's area; Mid is the config default.</summary>
|
||||||
|
public const float ThresholdLowFrac = 1e-5f; // 168 cells at 4096 — only the smallest natural specks
|
||||||
|
public const float ThresholdMidFrac = 3e-5f; // 503 cells at 4096 — the offshore pass's own speck guard, applied to all land
|
||||||
|
public const float ThresholdHighFrac = 1e-4f; // 1,678 cells at 4096 — "fewer, bigger": takes small offshore islands too
|
||||||
|
|
||||||
|
public static Result Apply(float[,] height, int mapSize, float sea, TerrainGenConfig cfg)
|
||||||
|
{
|
||||||
|
var r = new Result();
|
||||||
|
var pre = RegionLabeling.Label(height, mapSize, sea);
|
||||||
|
r.LabelsPre = pre;
|
||||||
|
var led = r.Ledger;
|
||||||
|
led.CentreWasLandPre = pre.CentreWasLand;
|
||||||
|
FillPre(led, pre);
|
||||||
|
r.Notes.Add($"[Regions] labeled {pre.Regions.Count} land components ({pre.LandCells:N0} land cells): mainland id {pre.MainlandId} " +
|
||||||
|
$"({(pre.Mainland == null ? 0 : pre.Mainland.SizeCells):N0} cells, centre {(pre.CentreWasLand ? "is land" : "⚠ NOT LAND — fell back to the largest component")}), " +
|
||||||
|
$"{pre.IslandCount} islands (N {led.PreNorth} / S {led.PreSouth}); island cells min {led.PreMin} median {led.PreMedian} mean {led.PreMean:F0} max {led.PreMax}.");
|
||||||
|
|
||||||
|
RegionLabels final = pre;
|
||||||
|
led.RevertOn = cfg.SpeckRevert;
|
||||||
|
if (cfg.SpeckRevert)
|
||||||
|
{
|
||||||
|
long threshold = Math.Max(1L, (long)Math.Round(cfg.MinLandComponentFrac * (double)mapSize * mapSize));
|
||||||
|
led.ThresholdCells = threshold;
|
||||||
|
float strictlyBelowSea = MathF.BitDecrement(sea);
|
||||||
|
int n = mapSize;
|
||||||
|
|
||||||
|
// Which components go: non-mainland, below the threshold. Mainland is never a candidate.
|
||||||
|
var revert = new Dictionary<int, LandRegion>();
|
||||||
|
foreach (var c in pre.Regions)
|
||||||
|
if (!c.IsMainland && c.SizeCells < threshold) revert[c.Id] = c;
|
||||||
|
if (pre.Mainland != null && revert.ContainsKey(pre.MainlandId))
|
||||||
|
throw new InvalidOperationException("[RegionPass] the mainland was selected for revert. Refusing.");
|
||||||
|
|
||||||
|
if (revert.Count > 0)
|
||||||
|
{
|
||||||
|
// The ring: the mean height of each doomed component's adjacent SEA cells.
|
||||||
|
var ringSum = new Dictionary<int, double>();
|
||||||
|
var ringCnt = new Dictionary<int, long>();
|
||||||
|
foreach (int id in revert.Keys) { ringSum[id] = 0; ringCnt[id] = 0; }
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
{
|
||||||
|
int id = pre.Id[x * n + y];
|
||||||
|
if (id == 0 || !revert.ContainsKey(id)) continue;
|
||||||
|
for (int dx = -1; dx <= 1; dx++)
|
||||||
|
{
|
||||||
|
int nx = x + dx; if (nx < 0 || nx >= n) continue;
|
||||||
|
for (int dy = -1; dy <= 1; dy++)
|
||||||
|
{
|
||||||
|
int ny = y + dy; if (ny < 0 || ny >= n || (dx == 0 && dy == 0)) continue;
|
||||||
|
if (pre.Id[nx * n + ny] != 0) continue; // land (this or another component)
|
||||||
|
ringSum[id] += height[nx, ny]; ringCnt[id]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = new Dictionary<int, float>();
|
||||||
|
foreach (var (id, c) in revert)
|
||||||
|
{
|
||||||
|
float t = ringCnt[id] > 0 ? (float)(ringSum[id] / ringCnt[id]) : strictlyBelowSea;
|
||||||
|
target[id] = MathF.Min(t, strictlyBelowSea); // strictly below sea, whatever the ring says
|
||||||
|
}
|
||||||
|
|
||||||
|
// The revert, with both guards asserted cell by cell.
|
||||||
|
var touched = new Dictionary<int, long>();
|
||||||
|
foreach (int id in revert.Keys) touched[id] = 0;
|
||||||
|
long cells = 0;
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
{
|
||||||
|
int id = pre.Id[x * n + y];
|
||||||
|
if (id == 0 || !target.TryGetValue(id, out float t)) continue; // COMPONENT-ONLY: nothing else is ever touched
|
||||||
|
float h = height[x, y];
|
||||||
|
if (h < sea)
|
||||||
|
throw new InvalidOperationException($"[RegionPass] component {id} cell ({x},{y}) is not land (h {h:G9} < sea {sea:G9}) — the labeling and the field disagree. Refusing.");
|
||||||
|
if (t >= h)
|
||||||
|
throw new InvalidOperationException($"[RegionPass] LOWER-ONLY violated at ({x},{y}): {h:G9} → {t:G9}. Refusing.");
|
||||||
|
height[x, y] = t;
|
||||||
|
touched[id]++; cells++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var (id, c) in revert)
|
||||||
|
{
|
||||||
|
if (touched[id] != c.SizeCells)
|
||||||
|
throw new InvalidOperationException($"[RegionPass] COMPONENT-ONLY violated: component {id} has {c.SizeCells} cells, {touched[id]} touched. Refusing.");
|
||||||
|
led.Reverted.Add((id, c.SizeCells, c.Hemisphere, target[id]));
|
||||||
|
}
|
||||||
|
led.RevertedComponents = revert.Count;
|
||||||
|
led.RevertedCells = cells;
|
||||||
|
|
||||||
|
final = RegionLabeling.Label(height, mapSize, sea);
|
||||||
|
if (final.Mainland == null || pre.Mainland == null || final.Mainland.SizeCells != pre.Mainland.SizeCells)
|
||||||
|
throw new InvalidOperationException("[RegionPass] the mainland's size changed across the revert. Refusing.");
|
||||||
|
}
|
||||||
|
r.Notes.Add($"[Regions] speck revert ON (threshold {threshold:N0} cells = {cfg.MinLandComponentFrac:G2} of the map): " +
|
||||||
|
$"{led.RevertedComponents} sub-threshold non-mainland components ({led.RevertedCells:N0} cells) lowered to their ring's mean seabed; " +
|
||||||
|
$"lower-only and component-only asserted; mainland untouched by definition.");
|
||||||
|
}
|
||||||
|
else r.Notes.Add("[Regions] speck revert OFF.");
|
||||||
|
|
||||||
|
r.Labels = final;
|
||||||
|
led.CentreWasLand = final.CentreWasLand;
|
||||||
|
FillPost(led, final);
|
||||||
|
|
||||||
|
// ═══ THE TAG, BY CONSTRUCTION ═══
|
||||||
|
var tag = new bool[mapSize, mapSize];
|
||||||
|
var hemi = new byte[mapSize, mapSize];
|
||||||
|
for (int x = 0; x < mapSize; x++)
|
||||||
|
for (int y = 0; y < mapSize; y++)
|
||||||
|
{
|
||||||
|
int id = final.Id[x * mapSize + y];
|
||||||
|
if (id == 0 || id == final.MainlandId) continue;
|
||||||
|
tag[x, y] = true;
|
||||||
|
hemi[x, y] = final.Regions[id - 1].Hemisphere;
|
||||||
|
}
|
||||||
|
r.IsIsland = tag; r.IslandHemisphere = hemi;
|
||||||
|
r.Notes.Add($"[Regions] tag by construction: {final.IslandCount} islands (N {led.PostNorth} / S {led.PostSouth}), " +
|
||||||
|
$"{final.LandCells - (final.Mainland?.SizeCells ?? 0):N0} island cells tagged; island cells min {led.PostMin} median {led.PostMedian} mean {led.PostMean:F0} max {led.PostMax}.");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FillPre(RegionLedger l, RegionLabels lab)
|
||||||
|
{
|
||||||
|
(l.PreNorth, l.PreSouth) = RegionLabeling.IslandsByHemisphere(lab);
|
||||||
|
var (c, mn, med, mean, mx, h) = RegionLabeling.IslandSizes(lab);
|
||||||
|
l.PreIslands = c; l.PreMin = mn; l.PreMedian = med; l.PreMean = mean; l.PreMax = mx; l.PreHistogram = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FillPost(RegionLedger l, RegionLabels lab)
|
||||||
|
{
|
||||||
|
(l.PostNorth, l.PostSouth) = RegionLabeling.IslandsByHemisphere(lab);
|
||||||
|
var (c, mn, med, mean, mx, h) = RegionLabeling.IslandSizes(lab);
|
||||||
|
l.PostIslands = c; l.PostMin = mn; l.PostMedian = med; l.PostMean = mean; l.PostMax = mx; l.PostHistogram = h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/RegionPass.cs.uid
Normal file
1
Tools/Scripts/RegionPass.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://btgwrycrjllr4
|
||||||
580
Tools/Scripts/SeedGalleryTool.cs
Normal file
580
Tools/Scripts/SeedGalleryTool.cs
Normal file
|
|
@ -0,0 +1,580 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE SEED GALLERY (chat2/04) — is <c>continuous_restored</c> good across seeds, or is
|
||||||
|
/// 1063685222 a lucky draw?
|
||||||
|
///
|
||||||
|
/// ═══ ⚠⚠ THIS TOOL RENDERS. IT DOES NOT TUNE. ═══
|
||||||
|
///
|
||||||
|
/// The curve is the one committed at tag <c>terrain-curve-v1</c> and it is PINNED HERE IN CODE —
|
||||||
|
/// <see cref="MountainLift"/>, <see cref="PeakSharpness"/> and <see cref="LowlandCeilingM"/> are
|
||||||
|
/// constants with no environment override, deliberately. Every other tool in this phase exposes
|
||||||
|
/// its knobs to <c>ISLA_*</c> so they can be probed; a GALLERY must not, because a stray
|
||||||
|
/// environment variable left over from a probe would silently render eight plates of a curve
|
||||||
|
/// nobody chose and they would look exactly like the real thing. The knobs are printed in the
|
||||||
|
/// run header and written into the INDEX so the plates can always be traced to a curve.
|
||||||
|
///
|
||||||
|
/// ═══ THE CALIBRATION IS RE-MEASURED, NOT RE-INVENTED ═══
|
||||||
|
///
|
||||||
|
/// <c>continuous_restored</c> is defined by a calibration measured on task 01's six-seed pool at
|
||||||
|
/// the ITERATION size, then applied at any size. This tool reproduces that measurement exactly —
|
||||||
|
/// same pool, same size, same primary seed for the normalization anchor — so the gallery renders
|
||||||
|
/// the same curve task 03 gated, not a look-alike. → <see cref="ClimbCalibration"/>.
|
||||||
|
///
|
||||||
|
/// ⚠ The gallery seeds are NOT the calibration pool. The pool stays fixed at task 01's six; the
|
||||||
|
/// gallery is eight separate draws, seven of them never previously rendered.
|
||||||
|
///
|
||||||
|
/// ═══ THE CHARACTER NOTE IS A HUMAN JUDGEMENT, AND IS TREATED AS ONE ═══
|
||||||
|
///
|
||||||
|
/// The INDEX carries a one-word note per seed ("clean" / "sharp mid-slope" / "flat draw"). That is
|
||||||
|
/// an EYE call, so this tool will not invent it. It reads the notes from
|
||||||
|
/// <c>scratch/character_notes.tsv</c> if that file exists and prints "(pending)" if it does not.
|
||||||
|
/// Write the notes after looking at the plates, then re-run with <c>ISLA_INDEX_ONLY=1</c> to
|
||||||
|
/// rebuild the INDEX from <c>scratch/metrics.tsv</c> without re-rendering a single pixel.
|
||||||
|
///
|
||||||
|
/// What the tool DOES contribute is an objective companion, and getting it right took one correction
|
||||||
|
/// worth recording:
|
||||||
|
///
|
||||||
|
/// ⚠ THE CURVE'S NORMALIZED MID-SLOPE CANNOT DISCRIMINATE BETWEEN SEEDS. It is
|
||||||
|
/// <c>dv/du</c> on a fixed set of control points, and each seed's denormalization is an affine
|
||||||
|
/// rescale of both axes — which leaves <c>dv/du</c> untouched. It is the SAME NUMBER on every
|
||||||
|
/// seed by construction, so measuring it per seed answers nothing. (The first cut of this tool
|
||||||
|
/// reported exactly that and printed an identical 1.86 for every draw.)
|
||||||
|
///
|
||||||
|
/// So the metric that ships is the one the eye is actually reacting to: the **spatial height
|
||||||
|
/// gradient through the 100–220 m band**, in metres per pixel — how fast the ground climbs
|
||||||
|
/// through the heights where the yellow→orange transition sits. That is
|
||||||
|
/// <c>(dOutput/dRaw) × (dRaw/dPixel)</c>: the first factor varies by seed because a taller
|
||||||
|
/// <c>spikeMax</c> spreads the same curve over more raw range, and the second is pure terrain.
|
||||||
|
/// Both are seed-dependent, and their product is what a render shows.
|
||||||
|
///
|
||||||
|
/// ═══ RUNNING IT ═══
|
||||||
|
///
|
||||||
|
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
|
||||||
|
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/SeedGalleryTool.tscn
|
||||||
|
///
|
||||||
|
/// ISLA_TASK / ISLA_BATCH / ISLA_SEEDS / ISLA_SKIP_RAW
|
||||||
|
/// ISLA_MAPSIZE gallery render size (default 8192 — judging size, not calibration size)
|
||||||
|
/// ISLA_CALIB_SIZE calibration pool size (default 2048 — task 01's, do not change casually)
|
||||||
|
/// ISLA_INDEX_ONLY "1" to rebuild INDEX.md from scratch/metrics.tsv and skip all rendering
|
||||||
|
/// </summary>
|
||||||
|
public partial class SeedGalleryTool : Node
|
||||||
|
{
|
||||||
|
// ═══ THE CURVE, PINNED. No environment override, by design — see the type header. ═══
|
||||||
|
|
||||||
|
/// <summary>task-03 `continuous_restored`: reproduce the staircase's mountain.</summary>
|
||||||
|
private const float MountainLift = 1.0f;
|
||||||
|
|
||||||
|
/// <summary>task-03 `continuous_restored`: a straight run to the cap, no extra summit steepening.</summary>
|
||||||
|
private const float PeakSharpness = 1.0f;
|
||||||
|
|
||||||
|
/// <summary>The flood line — hands over at exactly (K2, RED_CEIL).</summary>
|
||||||
|
private const float LowlandCeilingM = 30f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⚠ TASK 01'S CALIBRATION POOL, VERBATIM. Not the gallery seeds. Changing this changes the
|
||||||
|
/// curve, which is the one thing this task must not do.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE GALLERY. `1063685222` is the ANCHOR — the plate every previous task was judged on —
|
||||||
|
/// and the other seven are fresh draws never rendered before.
|
||||||
|
///
|
||||||
|
/// ⚠⚠ THESE WERE FIXED BEFORE THE FIRST RENDER AND WERE NEVER SCREENED OR REPLACED. That is
|
||||||
|
/// the whole point of a spread: hand-picking flattering draws would answer the question
|
||||||
|
/// "can this curve ever look good?" when the question asked is "does it look good generally?"
|
||||||
|
/// If a seed in this list turns out ugly, it stays in the gallery and goes in the report.
|
||||||
|
///
|
||||||
|
/// None of the seven appear in the calibration pool, so none of them shaped the curve they
|
||||||
|
/// are being used to test.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly int[] GallerySeeds =
|
||||||
|
{
|
||||||
|
1063685222, // ⭐ the anchor — Phase 1's primary, the plate 01/02/03 were judged on
|
||||||
|
20260821, // the date this gallery was drawn
|
||||||
|
8675309,
|
||||||
|
123456789,
|
||||||
|
271828182,
|
||||||
|
42424242,
|
||||||
|
555000111,
|
||||||
|
999999937, // a large prime, for no reason beyond being an unconsidered draw
|
||||||
|
};
|
||||||
|
|
||||||
|
private const int DefaultGallerySize = 8192;
|
||||||
|
private const int DefaultCalibSize = 2048;
|
||||||
|
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
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", 4);
|
||||||
|
string descr = EnvStr("ISLA_BATCH", "seed_gallery");
|
||||||
|
int gallerySize = EnvInt("ISLA_MAPSIZE", DefaultGallerySize);
|
||||||
|
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
|
||||||
|
int[] seeds = EnvSeeds("ISLA_SEEDS", GallerySeeds);
|
||||||
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
|
bool indexOnly = EnvStr("ISLA_INDEX_ONLY", "0") == "1";
|
||||||
|
|
||||||
|
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
||||||
|
string scratch = ToolingPaths.BatchScratch(batchRoot);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(scratch);
|
||||||
|
|
||||||
|
string metricsPath = Path.Combine(scratch, "metrics.tsv");
|
||||||
|
string notesPath = Path.Combine(scratch, "character_notes.tsv");
|
||||||
|
|
||||||
|
var anchors = CurveAnchors.Default;
|
||||||
|
float sea = 0.15f;
|
||||||
|
int anchor = seeds[0];
|
||||||
|
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print(" SEED GALLERY (chat2/04) — the committed curve, across 8 draws");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print($"curve : continuous_restored, PINNED — lift {MountainLift:F2}, " +
|
||||||
|
$"sharpness {PeakSharpness:F2}, ceiling {LowlandCeilingM:F0} m (no env override)");
|
||||||
|
GD.Print($"gallery : {gallerySize} calibration pool at {calibSize}");
|
||||||
|
GD.Print($"seeds : {string.Join(", ", seeds)} (anchor: {anchor})");
|
||||||
|
GD.Print($"batch : {batchRoot}");
|
||||||
|
GD.Print($"yardstick : {WorldScale.Describe()}");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
|
||||||
|
// ═══ INDEX-ONLY: rebuild the contact sheet from the recorded metrics ═══
|
||||||
|
if (indexOnly)
|
||||||
|
{
|
||||||
|
var recorded = ReadMetrics(metricsPath);
|
||||||
|
if (recorded.Count == 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"ISLA_INDEX_ONLY=1 but no metrics at {metricsPath}. Run the gallery first — " +
|
||||||
|
"the index is rebuilt FROM a render, never instead of one.");
|
||||||
|
GD.Print($"\n--- INDEX ONLY — {recorded.Count} seeds from {metricsPath} ---");
|
||||||
|
WriteIndex(batchRoot, recorded, ReadNotes(notesPath), anchor, gallerySize, calibSize, notesPath);
|
||||||
|
GD.Print($" INDEX.md rebuilt. No pixels were rendered.");
|
||||||
|
GetTree().Quit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 1. THE CURVE — re-measured exactly as task 03 did ═══
|
||||||
|
GD.Print($"\n--- 1. CALIBRATION (task-01 pool at {calibSize}, as task 03) ---");
|
||||||
|
|
||||||
|
var rawPool = new LandHistogram(sea);
|
||||||
|
var poolPass1 = new Dictionary<int, Pass1Result>();
|
||||||
|
foreach (int s in CalibrationSeeds)
|
||||||
|
{
|
||||||
|
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
|
||||||
|
poolPass1[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]));
|
||||||
|
|
||||||
|
// Above-ceiling land, gated on RAW height, output measured off the staircase.
|
||||||
|
float ceilingRaw = knots.K2;
|
||||||
|
var rawAbove = new LandHistogram(sea);
|
||||||
|
var outAbove = new LandHistogram(sea);
|
||||||
|
foreach (int s in CalibrationSeeds)
|
||||||
|
{
|
||||||
|
var scfg = BaseConfig(calibSize, s, knots, anchors, "staircase");
|
||||||
|
scfg.CurveMode = CurveModeKind.Staircase;
|
||||||
|
scfg.ShelfDetail = true;
|
||||||
|
Pass2Result st = Shaping.Shape(poolPass1[s], scfg);
|
||||||
|
rawAbove.AccumulateWhere(poolPass1[s].Height, poolPass1[s].Height, calibSize, ceilingRaw);
|
||||||
|
outAbove.AccumulateWhere(st.Height, poolPass1[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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠ Anchored on the POOL PRIMARY's spikeMax, exactly as task 03 did — reproducing the
|
||||||
|
// gated curve matters more here than improving it. (Task 03 §7.4 flags the anchor choice
|
||||||
|
// as worth revisiting; a gallery is not the place to revisit it.)
|
||||||
|
var calibration = ClimbCalibration.FromPercentiles(
|
||||||
|
pcts, rawQ, outQ, ceilingRaw,
|
||||||
|
HeightCurve.EffectiveSpikeMax(poolPass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
|
||||||
|
anchors.RedCeil, anchors.PeakCap, MountainLift, PeakSharpness);
|
||||||
|
|
||||||
|
GD.Print($" {knots}");
|
||||||
|
GD.Print($" {calibration.Describe()}");
|
||||||
|
|
||||||
|
// ═══ 2. THE GALLERY ═══
|
||||||
|
GD.Print($"\n--- 2. GALLERY at {gallerySize} ---");
|
||||||
|
var metrics = new List<SeedMetrics>();
|
||||||
|
|
||||||
|
foreach (int seed in seeds)
|
||||||
|
{
|
||||||
|
ulong t0 = Time.GetTicksMsec();
|
||||||
|
|
||||||
|
var cfg = BaseConfig(gallerySize, seed, knots, anchors, "restored");
|
||||||
|
cfg.CurveMode = CurveModeKind.Continuous;
|
||||||
|
cfg.ClimbCalibration = calibration;
|
||||||
|
|
||||||
|
Pass1Result p1 = Topography.Generate(cfg);
|
||||||
|
Pass2Result p2 = Shaping.Shape(p1, cfg);
|
||||||
|
|
||||||
|
// ⚠ The two invariants that must hold on EVERY plate, checked per seed rather than
|
||||||
|
// assumed from task 03's two. A gallery that quietly rendered a broken seed would be
|
||||||
|
// the worst possible artifact: eight plates, one of them lying.
|
||||||
|
var classify = ShapingOracle.ClassifyFidelity(p1, p2);
|
||||||
|
var offCfg = BaseConfig(gallerySize, seed, knots, anchors, "off"); offCfg.Curve = false;
|
||||||
|
var seaId = ShapingOracle.SeaIdentity(Shaping.Shape(p1, offCfg), p2, sea);
|
||||||
|
if (!classify.Passed) GD.PrintErr($" ⚠⚠ seed {seed}: {classify}");
|
||||||
|
if (!seaId.Passed) GD.PrintErr($" ⚠⚠ seed {seed}: {seaId}");
|
||||||
|
|
||||||
|
var m = WriteSeed(batchRoot, p1, p2, sea, anchors, skipRaw);
|
||||||
|
m.ClassifyOk = classify.Passed;
|
||||||
|
m.SeaOk = seaId.Passed;
|
||||||
|
m.ElapsedMs = Time.GetTicksMsec() - t0;
|
||||||
|
metrics.Add(m);
|
||||||
|
|
||||||
|
GD.Print($" {seed,-11} land {m.LandPct,5:F1}% >100m {m.Above100,5:F2}% >220m {m.Above220,5:F2}% " +
|
||||||
|
$"p90 {WorldScale.MetresFromRaw(m.P90 - sea),6:F1}m " +
|
||||||
|
$"grad100-220 p50 {m.BandGradP50:F2} p95 {m.BandGradP95:F2} m/px hMax {p1.HMaxSeed:F3} " +
|
||||||
|
$"{(m.ClassifyOk && m.SeaOk ? "ok" : "⚠ CHECK FAILED")} {m.ElapsedMs / 1000.0:F0}s");
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteMetrics(metricsPath, metrics);
|
||||||
|
WriteIndex(batchRoot, metrics, ReadNotes(notesPath), anchor, gallerySize, calibSize, notesPath);
|
||||||
|
|
||||||
|
bool allOk = metrics.TrueForAll(m => m.ClassifyOk && m.SeaOk);
|
||||||
|
GD.Print("\n==================================================================");
|
||||||
|
GD.Print($" DONE — {batchRoot}");
|
||||||
|
GD.Print($" per-seed invariants: {(allOk ? "ALL PASS" : "*** A SEED FAILED — see above ***")}");
|
||||||
|
GD.Print($" ⚠ character notes are a HUMAN call — write {notesPath}");
|
||||||
|
GD.Print($" then re-run with ISLA_INDEX_ONLY=1 to fill them in.");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GetTree().Quit(allOk ? 0 : 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- per-seed record -------------------------------------------------
|
||||||
|
|
||||||
|
private sealed class SeedMetrics
|
||||||
|
{
|
||||||
|
public int Seed;
|
||||||
|
public float HMin, HMax, HMaxSeed;
|
||||||
|
public double LandPct, Above100, Above220;
|
||||||
|
public float P50, P75, P90, P99;
|
||||||
|
public float MaxMidSlope, MinSlope; // curve-space; identical every seed by construction
|
||||||
|
public float BandGradP50, BandGradP95; // ⭐ metres per pixel through the 100-220 m band
|
||||||
|
public long BandCells;
|
||||||
|
public float GrayMin, GrayMax;
|
||||||
|
public bool ClassifyOk = true, SeaOk = true;
|
||||||
|
public ulong ElapsedMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a, string label)
|
||||||
|
=> new TerrainGenConfig
|
||||||
|
{
|
||||||
|
MapSize = mapSize, Seed = seed, VariantLabel = label,
|
||||||
|
Curve = true, ShelfDetail = false, Knots = k, Anchors = a,
|
||||||
|
LowlandCeilingM = LowlandCeilingM,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static SeedMetrics WriteSeed(string batchRoot, Pass1Result p1, Pass2Result p2,
|
||||||
|
float sea, CurveAnchors anchors, bool skipRaw)
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(batchRoot, $"{p2.Seed}");
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||||||
|
|
||||||
|
// ⭐ Plain data beside the pretty render. The grayscale is the honest instrument; the
|
||||||
|
// relief is for eye-appeal and hillshade.
|
||||||
|
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 = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
|
||||||
|
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f,
|
||||||
|
HillshadeStrength = 0.30f, SeaLevel = sea,
|
||||||
|
};
|
||||||
|
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
|
||||||
|
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, $"SEED {p2.Seed}")
|
||||||
|
.SavePng(Path.Combine(dir, "relief.png"));
|
||||||
|
|
||||||
|
var (a100, a220) = ShapingOracle.LandAbove(p2, sea);
|
||||||
|
var land = new LandHistogram(sea);
|
||||||
|
land.Accumulate(p2.Height, p2.MapSize);
|
||||||
|
|
||||||
|
// ⚠ Curve-space slope: recorded for completeness, but it is the SAME on every seed by
|
||||||
|
// construction (see the type header). It is NOT the discriminator.
|
||||||
|
var (minN, _, maxMid, _, _, _) = p2.Continuous.SampleClimbSlopes();
|
||||||
|
|
||||||
|
// ⭐ THE ACTUAL DISCRIMINATOR: how fast the ground climbs through 100-220 m, in metres
|
||||||
|
// per pixel. This is what "the yellow-to-orange step looks abrupt" is a reaction to.
|
||||||
|
var (bandP50, bandP95, bandCells) = MidBandGradient(p2.Height, p2.MapSize, sea);
|
||||||
|
|
||||||
|
return new SeedMetrics
|
||||||
|
{
|
||||||
|
Seed = p2.Seed, HMin = p2.HMin, HMax = p2.HMax, HMaxSeed = p1.HMaxSeed,
|
||||||
|
LandPct = p2.LandFraction(sea) * 100.0, Above100 = a100, Above220 = a220,
|
||||||
|
P50 = land.Quantile(50), P75 = land.Quantile(75),
|
||||||
|
P90 = land.Quantile(90), P99 = land.Quantile(99),
|
||||||
|
MaxMidSlope = maxMid, MinSlope = minN, GrayMin = gMin, GrayMax = gMax,
|
||||||
|
BandGradP50 = bandP50, BandGradP95 = bandP95, BandCells = bandCells,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Median and p95 of the spatial height gradient, in METRES PER PIXEL, over land cells whose
|
||||||
|
/// height falls in the 100-220 m band — the stretch the developer flagged as reading abrupt.
|
||||||
|
///
|
||||||
|
/// Central differences on the interior; edge cells are skipped rather than one-sided, because
|
||||||
|
/// the map border is the Trench's synthetic wall and its gradient is not terrain.
|
||||||
|
///
|
||||||
|
/// ⚠ Gradients are collected into a coarse histogram rather than a list: at 8192 the band can
|
||||||
|
/// hold millions of cells and sorting them all to take two quantiles would cost more than the
|
||||||
|
/// render. 0.01 m/px bins are far finer than any difference worth reading.
|
||||||
|
/// </summary>
|
||||||
|
private static (float p50, float p95, long cells) MidBandGradient(float[,] h, int n, float sea)
|
||||||
|
{
|
||||||
|
float lo = sea + WorldScale.RawFromMetres(100f);
|
||||||
|
float hi = sea + WorldScale.RawFromMetres(220f);
|
||||||
|
|
||||||
|
const float binW = 0.01f; // metres per pixel
|
||||||
|
const int bins = 4000; // up to 40 m/px — far beyond anything real
|
||||||
|
var hist = new long[bins + 1];
|
||||||
|
long cells = 0;
|
||||||
|
|
||||||
|
for (int x = 1; x < n - 1; x++)
|
||||||
|
{
|
||||||
|
for (int y = 1; y < n - 1; y++)
|
||||||
|
{
|
||||||
|
float v = h[x, y];
|
||||||
|
if (v < lo || v > hi) continue;
|
||||||
|
|
||||||
|
float gx = (h[x + 1, y] - h[x - 1, y]) * 0.5f;
|
||||||
|
float gy = (h[x, y + 1] - h[x, y - 1]) * 0.5f;
|
||||||
|
float g = WorldScale.MetresFromRaw(MathF.Sqrt(gx * gx + gy * gy));
|
||||||
|
|
||||||
|
int b = (int)(g / binW);
|
||||||
|
hist[b >= bins ? bins : b]++;
|
||||||
|
cells++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cells == 0) return (0f, 0f, 0);
|
||||||
|
|
||||||
|
float Q(double q)
|
||||||
|
{
|
||||||
|
long target = (long)(q * cells), cum = 0;
|
||||||
|
for (int i = 0; i <= bins; i++)
|
||||||
|
{
|
||||||
|
cum += hist[i];
|
||||||
|
if (cum >= target) return (i + 0.5f) * binW;
|
||||||
|
}
|
||||||
|
return bins * binW;
|
||||||
|
}
|
||||||
|
return (Q(0.50), Q(0.95), cells);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- metrics + notes round-trip -------------------------------------
|
||||||
|
|
||||||
|
private static void WriteMetrics(string path, List<SeedMetrics> ms)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("seed\tland\ta100\ta220\tp50\tp75\tp90\tp99\tmidslope\tminslope\thmaxseed\tgmin\tgmax\tok\tms\tgradp50\tgradp95\tbandcells");
|
||||||
|
foreach (var m in ms)
|
||||||
|
sb.AppendLine($"{m.Seed}\t{m.LandPct:F2}\t{m.Above100:F3}\t{m.Above220:F3}\t{m.P50:F4}\t{m.P75:F4}\t" +
|
||||||
|
$"{m.P90:F4}\t{m.P99:F4}\t{m.MaxMidSlope:F4}\t{m.MinSlope:F4}\t{m.HMaxSeed:F4}\t" +
|
||||||
|
$"{m.GrayMin:F4}\t{m.GrayMax:F4}\t{(m.ClassifyOk && m.SeaOk ? 1 : 0)}\t{m.ElapsedMs}\t" +
|
||||||
|
$"{m.BandGradP50:F4}\t{m.BandGradP95:F4}\t{m.BandCells}");
|
||||||
|
|
||||||
|
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
|
||||||
|
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
|
||||||
|
f.StoreString(sb.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<SeedMetrics> ReadMetrics(string path)
|
||||||
|
{
|
||||||
|
var outp = new List<SeedMetrics>();
|
||||||
|
if (!Godot.FileAccess.FileExists(path)) return outp;
|
||||||
|
|
||||||
|
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read);
|
||||||
|
if (f == null) return outp;
|
||||||
|
|
||||||
|
string all = f.GetAsText();
|
||||||
|
bool header = true;
|
||||||
|
foreach (string line in all.Split('\n'))
|
||||||
|
{
|
||||||
|
if (header) { header = false; continue; }
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
|
string[] c = line.Split('\t');
|
||||||
|
if (c.Length < 15) continue; // older rows without the gradient columns still load
|
||||||
|
outp.Add(new SeedMetrics
|
||||||
|
{
|
||||||
|
Seed = int.Parse(c[0]), LandPct = double.Parse(c[1]),
|
||||||
|
Above100 = double.Parse(c[2]), Above220 = double.Parse(c[3]),
|
||||||
|
P50 = float.Parse(c[4]), P75 = float.Parse(c[5]),
|
||||||
|
P90 = float.Parse(c[6]), P99 = float.Parse(c[7]),
|
||||||
|
MaxMidSlope = float.Parse(c[8]), MinSlope = float.Parse(c[9]),
|
||||||
|
HMaxSeed = float.Parse(c[10]), GrayMin = float.Parse(c[11]), GrayMax = float.Parse(c[12]),
|
||||||
|
ClassifyOk = c[13].Trim() == "1", SeaOk = c[13].Trim() == "1",
|
||||||
|
ElapsedMs = ulong.Parse(c[14].Trim()),
|
||||||
|
BandGradP50 = c.Length > 15 ? float.Parse(c[15]) : 0f,
|
||||||
|
BandGradP95 = c.Length > 16 ? float.Parse(c[16]) : 0f,
|
||||||
|
BandCells = c.Length > 17 ? long.Parse(c[17].Trim()) : 0L,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return outp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The human character notes, if they have been written yet. Format: <c>seed\tnote</c>.
|
||||||
|
/// Absent is a normal state, not an error — the first run cannot have them.
|
||||||
|
/// </summary>
|
||||||
|
private static Dictionary<int, string> ReadNotes(string path)
|
||||||
|
{
|
||||||
|
var notes = new Dictionary<int, string>();
|
||||||
|
if (!Godot.FileAccess.FileExists(path)) return notes;
|
||||||
|
|
||||||
|
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read);
|
||||||
|
if (f == null) return notes;
|
||||||
|
|
||||||
|
foreach (string line in f.GetAsText().Split('\n'))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#")) continue;
|
||||||
|
string[] c = line.Split('\t');
|
||||||
|
if (c.Length < 2) continue;
|
||||||
|
if (int.TryParse(c[0].Trim(), out int s)) notes[s] = c[1].Trim();
|
||||||
|
}
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the contact sheet ----------------------------------------------
|
||||||
|
|
||||||
|
private static void WriteIndex(string batchRoot, List<SeedMetrics> ms, Dictionary<int, string> notes,
|
||||||
|
int anchor, int gallerySize, int calibSize, string notesPath)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("# Batch 04 — seed gallery: the committed curve across 8 draws");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("**A contact sheet, not a tuning batch.** Every plate is the SAME curve — the one committed");
|
||||||
|
sb.AppendLine("at tag `terrain-curve-v1` (chat2/03 `continuous_restored`). The question is whether it holds");
|
||||||
|
sb.AppendLine("up across draws, or whether the anchor seed was lucky.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"- **Curve (pinned, no env override):** `mountainLift {MountainLift:F2}` · " +
|
||||||
|
$"`peakSharpness {PeakSharpness:F2}` · `lowlandCeiling {LowlandCeilingM:F0} m`");
|
||||||
|
sb.AppendLine($"- **Rendered at:** {gallerySize} · **calibration pool measured at:** {calibSize} (task 01's six seeds)");
|
||||||
|
sb.AppendLine($"- **Anchor:** `{anchor}` — the plate tasks 01–03 were judged on. **Compare the others to it.**");
|
||||||
|
sb.AppendLine($"- **Palette:** ⚠ `ProvisionalEven` — evenly spaced SEA → 420 m, **provisional**. The");
|
||||||
|
sb.AppendLine(" grayscale is the honest instrument; the relief is for shape and hillshade.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⭐ The contact sheet");
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
bool anyNotes = notes.Count > 0;
|
||||||
|
sb.AppendLine("| Seed | Character | Relief | Grayscale | land | >100 m | >220 m | p90 | 100–220 m grade (p50 / p95) |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (var m in ms)
|
||||||
|
{
|
||||||
|
string tag = m.Seed == anchor ? " ⭐" : "";
|
||||||
|
string note = notes.TryGetValue(m.Seed, out string n) ? n : "*(pending)*";
|
||||||
|
string warn = m.ClassifyOk && m.SeaOk ? "" : " ⚠⚠ INVARIANT FAILED";
|
||||||
|
sb.AppendLine($"| `{m.Seed}`{tag} | {note}{warn} | [`{m.Seed}/relief.png`]({m.Seed}/relief.png) | " +
|
||||||
|
$"[`grayscale.png`]({m.Seed}/grayscale.png) | {m.LandPct:F1}% | {m.Above100:F2}% | " +
|
||||||
|
$"{m.Above220:F2}% | {WorldScale.MetresFromRaw(m.P90 - 0.15f):F0} m | " +
|
||||||
|
$"{m.BandGradP50:F2} / {m.BandGradP95:F2} m per px |");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
if (!anyNotes)
|
||||||
|
{
|
||||||
|
sb.AppendLine("> ⚠ **Character notes are pending.** They are an EYE call and this tool will not invent");
|
||||||
|
sb.AppendLine($"> them. Write `{Path.GetFileName(notesPath)}` in `scratch/` as `seed<TAB>note` lines, then");
|
||||||
|
sb.AppendLine("> re-run with `ISLA_INDEX_ONLY=1` to rebuild this table without re-rendering.");
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine("**100–220 m grade** is the spatial height gradient through the band where the yellow→orange");
|
||||||
|
sb.AppendLine("transition sits — metres of climb per pixel, median and p95 over the land in that band. It is");
|
||||||
|
sb.AppendLine("the objective companion to the eye's *\"is that step abrupt?\"*: near-constant across seeds");
|
||||||
|
sb.AppendLine("means abruptness is a **curve trait** worth a tuning pass; a wide spread means it is a **draw**.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("> ⚠ The curve's own normalized mid-slope is deliberately NOT tabulated: it is `dv/du` on a");
|
||||||
|
sb.AppendLine("> fixed control polygon, and each seed's denormalization rescales both axes, so it is the same");
|
||||||
|
sb.AppendLine("> number on every seed **by construction** and can discriminate nothing.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## Spread");
|
||||||
|
sb.AppendLine();
|
||||||
|
if (ms.Count > 0)
|
||||||
|
{
|
||||||
|
double lo100 = double.MaxValue, hi100 = double.MinValue, sum100 = 0;
|
||||||
|
float loMid = float.MaxValue, hiMid = float.MinValue;
|
||||||
|
double sumG = 0;
|
||||||
|
foreach (var m in ms)
|
||||||
|
{
|
||||||
|
lo100 = Math.Min(lo100, m.Above100); hi100 = Math.Max(hi100, m.Above100); sum100 += m.Above100;
|
||||||
|
loMid = MathF.Min(loMid, m.BandGradP50); hiMid = MathF.Max(hiMid, m.BandGradP50);
|
||||||
|
sumG += m.BandGradP50;
|
||||||
|
}
|
||||||
|
sb.AppendLine($"- **land >100 m:** {lo100:F2} % … {hi100:F2} % (mean {sum100 / ms.Count:F2} %)");
|
||||||
|
sb.AppendLine($"- **100–220 m grade (p50):** {loMid:F2} … {hiMid:F2} m per px " +
|
||||||
|
$"(mean {sumG / ms.Count:F2}) — spread {(hiMid - loMid) / (sumG / ms.Count) * 100:F0}% of the mean");
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
sb.AppendLine("## Disposability");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Artifact | Keep? |");
|
||||||
|
sb.AppendLine("|---|---|");
|
||||||
|
sb.AppendLine("| `relief.png`, `INDEX.md` | **keep** — the gallery |");
|
||||||
|
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
|
||||||
|
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + the tagged curve — **large, clear freely** |");
|
||||||
|
sb.AppendLine("| `scratch/metrics.tsv`, `scratch/character_notes.tsv` | **keep** — the INDEX is rebuilt from them |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Every `.f32` here is reproducible from `git checkout terrain-curve-v1` plus the seed, so the");
|
||||||
|
sb.AppendLine("bulk of this batch is safe to delete once the gallery has been judged.");
|
||||||
|
|
||||||
|
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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/SeedGalleryTool.cs.uid
Normal file
1
Tools/Scripts/SeedGalleryTool.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bkh7iu47is60q
|
||||||
34
Tools/Scripts/ShadeRenderer.cs
Normal file
34
Tools/Scripts/ShadeRenderer.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// PURE HILLSHADE (chat2/11) — a grayscale slope/aspect plate with no hypsometric tint, for
|
||||||
|
/// reading low-amplitude surface detail (erosion's drainage is half a metre on average) that the
|
||||||
|
/// palette-blended relief hides. Land shaded; sea a flat dark. ⚠ PRESENTATION ONLY — a look dial,
|
||||||
|
/// never a claim about the world (→ Hillshade).
|
||||||
|
/// </summary>
|
||||||
|
public static class ShadeRenderer
|
||||||
|
{
|
||||||
|
public static Image Render(float[,] height, int mapSize, float sea, float zExaggeration, float azimuth, float altitude)
|
||||||
|
{
|
||||||
|
var (lx, ly, lz) = Hillshade.LightVector(azimuth, altitude);
|
||||||
|
var img = Image.CreateEmpty(mapSize, mapSize, false, Image.Format.Rgb8);
|
||||||
|
var seaColor = new Color(0.10f, 0.14f, 0.22f);
|
||||||
|
for (int x = 0; x < mapSize; x++)
|
||||||
|
for (int y = 0; y < mapSize; y++)
|
||||||
|
{
|
||||||
|
if (height[x, y] < sea) { img.SetPixel(x, y, seaColor); continue; }
|
||||||
|
float s = Hillshade.At(height, mapSize, x, y, zExaggeration, lx, ly, lz);
|
||||||
|
img.SetPixel(x, y, new Color(s, s, s));
|
||||||
|
}
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SavePng(float[,] height, int mapSize, float sea, float zExaggeration, float azimuth, float altitude, string absolutePath)
|
||||||
|
{
|
||||||
|
Error err = Render(height, mapSize, sea, zExaggeration, azimuth, altitude).SavePng(absolutePath);
|
||||||
|
if (err != Error.Ok) GD.PrintErr($"[ShadeRenderer] SavePng failed ({err}) for {absolutePath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/ShadeRenderer.cs.uid
Normal file
1
Tools/Scripts/ShadeRenderer.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://4k3liidno15g
|
||||||
|
|
@ -74,7 +74,8 @@ namespace IslaApocalypse.Tools
|
||||||
curveOn: false, detailOn: false, curveModeLabel: "off", variantLabel: cfg.VariantLabel, continuous: null,
|
curveOn: false, detailOn: false, curveModeLabel: "off", variantLabel: cfg.VariantLabel, continuous: null,
|
||||||
knots: null, anchors: null, hMaxSeed: p1.HMaxSeed,
|
knots: null, anchors: null, hMaxSeed: p1.HMaxSeed,
|
||||||
edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: p1.HMinSeed, hMax: p1.HMaxSeed,
|
edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: p1.HMinSeed, hMax: p1.HMaxSeed,
|
||||||
elapsedMs: Time.GetTicksMsec() - t0, notes: notes);
|
elapsedMs: Time.GetTicksMsec() - t0, notes: notes,
|
||||||
|
isIsland: p1.IsIsland, islandHemisphere: p1.IslandHemisphere);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ WHICH CURVE (chat2/02) ═══
|
// ═══ WHICH CURVE (chat2/02) ═══
|
||||||
|
|
@ -210,7 +211,8 @@ namespace IslaApocalypse.Tools
|
||||||
curveOn: true, detailOn: detailOn, curveModeLabel: "staircase", variantLabel: cfg.VariantLabel, continuous: null,
|
curveOn: true, detailOn: detailOn, curveModeLabel: "staircase", variantLabel: cfg.VariantLabel, continuous: null,
|
||||||
knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
|
knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
|
||||||
edgeAmpRaw: edgeAmpRaw, maxEdgeShiftRaw: maxEdgeShiftRaw, hMin: hMin, hMax: hMax,
|
edgeAmpRaw: edgeAmpRaw, maxEdgeShiftRaw: maxEdgeShiftRaw, hMin: hMin, hMax: hMax,
|
||||||
elapsedMs: Time.GetTicksMsec() - t0, notes: notes);
|
elapsedMs: Time.GetTicksMsec() - t0, notes: notes,
|
||||||
|
isIsland: p1.IsIsland, islandHemisphere: p1.IslandHemisphere);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -238,9 +240,14 @@ namespace IslaApocalypse.Tools
|
||||||
// stays hard for the same reason.
|
// stays hard for the same reason.
|
||||||
float spikeMax = HeightCurve.EffectiveSpikeMax(p1.HMaxSeed, knots, anchors);
|
float spikeMax = HeightCurve.EffectiveSpikeMax(p1.HMaxSeed, knots, anchors);
|
||||||
|
|
||||||
// Build throws (refusing the generation) on any config that cannot hit the target
|
// ⭐ CALIBRATED when a measurement is supplied (chat2/03 — the staircase's mountain with
|
||||||
// silhouette; the tool's _Ready catches and Quit(2)s.
|
// the terraces melted out), ANALYTIC otherwise (chat2/02's feather/drama curve, kept as
|
||||||
var curve = ContinuousCurve.Build(knots, anchors, spikeMax,
|
// the "before" contrast). Both throw and refuse rather than degrade; the tool's _Ready
|
||||||
|
// catches and Quit(2)s.
|
||||||
|
var curve = cfg.ClimbCalibration != null
|
||||||
|
? ContinuousCurve.BuildCalibrated(knots, anchors, spikeMax,
|
||||||
|
cfg.LowlandCeilingM, cfg.ClimbCalibration)
|
||||||
|
: ContinuousCurve.Build(knots, anchors, spikeMax,
|
||||||
cfg.LowlandCeilingM, cfg.ClimbFeather, cfg.SummitDrama);
|
cfg.LowlandCeilingM, cfg.ClimbFeather, cfg.SummitDrama);
|
||||||
|
|
||||||
// Monotone by construction — and proven anyway, per seed, because "cannot fail" is
|
// Monotone by construction — and proven anyway, per seed, because "cannot fail" is
|
||||||
|
|
@ -275,7 +282,8 @@ namespace IslaApocalypse.Tools
|
||||||
curveOn: true, detailOn: false, curveModeLabel: "continuous", variantLabel: cfg.VariantLabel, continuous: curve,
|
curveOn: true, detailOn: false, curveModeLabel: "continuous", variantLabel: cfg.VariantLabel, continuous: curve,
|
||||||
knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
|
knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
|
||||||
edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: hMin, hMax: hMax,
|
edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: hMin, hMax: hMax,
|
||||||
elapsedMs: Time.GetTicksMsec() - t0, notes: notes);
|
elapsedMs: Time.GetTicksMsec() - t0, notes: notes,
|
||||||
|
isIsland: p1.IsIsland, islandHemisphere: p1.IslandHemisphere);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -328,7 +336,8 @@ namespace IslaApocalypse.Tools
|
||||||
curveOn: true, detailOn: false, curveModeLabel: "lifted_WRONG", variantLabel: cfg.VariantLabel, continuous: null,
|
curveOn: true, detailOn: false, curveModeLabel: "lifted_WRONG", variantLabel: cfg.VariantLabel, continuous: null,
|
||||||
knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
|
knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed,
|
||||||
edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: hMin, hMax: hMax,
|
edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: hMin, hMax: hMax,
|
||||||
elapsedMs: Time.GetTicksMsec() - t0, notes: notes);
|
elapsedMs: Time.GetTicksMsec() - t0, notes: notes,
|
||||||
|
isIsland: p1.IsIsland, islandHemisphere: p1.IslandHemisphere);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,332 @@ namespace IslaApocalypse.Tools
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// (g) ⭐ MOUNTAIN RESTORED (chat2/03) — how much land ends up above 100 m and 220 m,
|
||||||
|
/// against the staircase's own figures.
|
||||||
|
///
|
||||||
|
/// ═══ ⚠ REPORTED, NOT GATED ═══
|
||||||
|
///
|
||||||
|
/// This is a taste target the developer tunes, so a miss is a FINDING, not a build failure —
|
||||||
|
/// gating it would make `mountainLift` unusable as a knob, since every value but one would
|
||||||
|
/// fail the run. What it must never do is stay silent: chat2/02 lost two thirds of the
|
||||||
|
/// mountain and only found out because someone went looking at the dumps afterwards. This
|
||||||
|
/// check is that look, made automatic.
|
||||||
|
///
|
||||||
|
/// <paramref name="tolerancePp"/> only decides whether the row reads PASS or NOTE; the
|
||||||
|
/// numbers are always printed.
|
||||||
|
/// </summary>
|
||||||
|
public static Check MountainRestored(Pass2Result variant, Pass2Result staircase,
|
||||||
|
float seaLevel, double tolerancePp)
|
||||||
|
{
|
||||||
|
var c = new Check { Id = "g", Name = $"mountain restored vs staircase [{variant.VariantLabel}]" };
|
||||||
|
|
||||||
|
var (v100, v220) = LandAbove(variant, seaLevel);
|
||||||
|
var (s100, s220) = LandAbove(staircase, seaLevel);
|
||||||
|
|
||||||
|
double d100 = v100 - s100;
|
||||||
|
c.Passed = Math.Abs(d100) <= tolerancePp;
|
||||||
|
c.Detail = $">100 m: {v100:F2} % vs staircase {s100:F2} % ({d100:+0.00;-0.00} pp) · " +
|
||||||
|
$">220 m: {v220:F2} % vs {s220:F2} % ({v220 - s220:+0.00;-0.00} pp)";
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Percentage of LAND above 100 m and 220 m of world height. Land = at/above sea.</summary>
|
||||||
|
public static (double above100, double above220) LandAbove(Pass2Result p2, float seaLevel)
|
||||||
|
{
|
||||||
|
float t100 = seaLevel + WorldScale.RawFromMetres(100f);
|
||||||
|
float t220 = seaLevel + WorldScale.RawFromMetres(220f);
|
||||||
|
|
||||||
|
long land = 0, a100 = 0, a220 = 0;
|
||||||
|
for (int x = 0; x < p2.MapSize; x++)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < p2.MapSize; y++)
|
||||||
|
{
|
||||||
|
float h = p2.Height[x, y];
|
||||||
|
if (h < seaLevel) continue;
|
||||||
|
land++;
|
||||||
|
if (h > t100) a100++;
|
||||||
|
if (h > t220) a220++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return land == 0 ? (0.0, 0.0) : (100.0 * a100 / land, 100.0 * a220 / land);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ chat2/05 — the offshore checks ═══
|
||||||
|
|
||||||
|
// (h) was the chat2/05 seeded-floor check — reverted out with the floor in chat2/06. No
|
||||||
|
// count is guaranteed any more, so there is nothing for an oracle to assert; the count
|
||||||
|
// table is the evidence, and it is statistics, not a check.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// (i) ⭐ MOAT INTACT — no offshore island is 8-connected to mainland land. The moat exists
|
||||||
|
/// to make a land bridge impossible; this is the proof that it did.
|
||||||
|
/// </summary>
|
||||||
|
public static Check MoatIntact(Pass1Result p1, List<IslandComponent> comps)
|
||||||
|
{
|
||||||
|
var c = new Check { Id = "i", Name = "moat intact — no island touches the mainland" };
|
||||||
|
int bridged = OffshoreAnalysis.BridgedCount(comps);
|
||||||
|
c.Passed = p1.HasIslandTag && bridged == 0;
|
||||||
|
c.Detail = !p1.HasIslandTag ? "no island tag — nothing to check"
|
||||||
|
: bridged == 0 ? $"all {comps.Count} islands are separated from mainland by water"
|
||||||
|
: $"{bridged} island(s) BRIDGE to mainland land";
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// (j) ⭐ MAINLAND UNMOVED — with offshore on vs off, every cell that was LAND with it off is
|
||||||
|
/// BIT-IDENTICAL with it on. The shelf touches only below-sea cells, the islets only lift
|
||||||
|
/// below-sea cells; neither may touch existing land. (The falloff test and the moat did their
|
||||||
|
/// job if this holds.) Reports how many sea cells the shelf moved and how many were lifted.
|
||||||
|
/// </summary>
|
||||||
|
public static Check MainlandUnmoved(Pass1Result off, Pass1Result on, float sea)
|
||||||
|
{
|
||||||
|
var c = new Check { Id = "j", Name = "mainland unmoved — every offshore-OFF land cell bit-identical with offshore ON" };
|
||||||
|
long land = 0, landDiff = 0, seaChanged = 0, lifted = 0;
|
||||||
|
string first = null;
|
||||||
|
for (int x = 0; x < off.MapSize; x++)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < off.MapSize; y++)
|
||||||
|
{
|
||||||
|
float a = off.Height[x, y], b = on.Height[x, y];
|
||||||
|
if (a >= sea)
|
||||||
|
{
|
||||||
|
land++;
|
||||||
|
if (BitConverter.SingleToInt32Bits(a) != BitConverter.SingleToInt32Bits(b))
|
||||||
|
{
|
||||||
|
landDiff++;
|
||||||
|
first ??= $"first at [{x},{y}]: {a:G9} → {b:G9}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (a != b) seaChanged++;
|
||||||
|
if (b >= sea) lifted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Passed = landDiff == 0;
|
||||||
|
c.Detail = landDiff == 0
|
||||||
|
? $"all {land:N0} land cells bit-identical; {seaChanged:N0} sea cells remapped by the shelf, {lifted:N0} lifted to land"
|
||||||
|
: $"{landDiff:N0} of {land:N0} land cells CHANGED — {first}";
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// (k) TAG ↔ COASTLINE CONSISTENT — per cell, classify-land ⇔ render-land (the curve is
|
||||||
|
/// identity at sea and monotone above, so it must be — D-046), and every TAGGED cell is land
|
||||||
|
/// in both fields. This is what lets the tag be carried through pass 2 without recomputation.
|
||||||
|
/// </summary>
|
||||||
|
public static Check TagCoastlineConsistent(Pass2Result p2, float sea)
|
||||||
|
{
|
||||||
|
var c = new Check { Id = "k", Name = "offshore tag: classify/render coastline consistent, every tagged cell is land" };
|
||||||
|
long mismatch = 0, tagNotLand = 0, tagged = 0;
|
||||||
|
for (int x = 0; x < p2.MapSize; x++)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < p2.MapSize; y++)
|
||||||
|
{
|
||||||
|
bool cl = p2.HeightClassify[x, y] >= sea;
|
||||||
|
bool rl = p2.Height[x, y] >= sea;
|
||||||
|
if (cl != rl) mismatch++;
|
||||||
|
if (p2.IsIsland != null && p2.IsIsland[x, y])
|
||||||
|
{
|
||||||
|
tagged++;
|
||||||
|
if (!cl || !rl) tagNotLand++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Passed = mismatch == 0 && tagNotLand == 0;
|
||||||
|
c.Detail = $"{mismatch:N0} classify/render landness mismatches; {tagNotLand:N0} of {tagged:N0} tagged cells not land";
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// (l) HMaxSeed RECOMPUTED AFTER SHELF + OFFSHORE — reported. Expected unchanged (a ~34 m
|
||||||
|
/// crest vs a ~290 m peak), but the ORDER is the fix (chat2/00 Drift §2), and the value is
|
||||||
|
/// measured rather than assumed. Always passes; the detail is the point.
|
||||||
|
/// </summary>
|
||||||
|
public static Check HMaxAfterOffshore(Pass1Result p1)
|
||||||
|
{
|
||||||
|
bool moved = p1.HMaxSeed != p1.HMaxSeedBeforeOffshore;
|
||||||
|
return new Check
|
||||||
|
{
|
||||||
|
Id = "l", Name = "HMaxSeed recomputed after shelf + offshore",
|
||||||
|
Passed = true,
|
||||||
|
Detail = $"before {p1.HMaxSeedBeforeOffshore:F6} → after {p1.HMaxSeed:F6} " +
|
||||||
|
(moved ? "— ⚠ MOVED (an islet outran the peak?)" : "— unchanged, as expected; the ORDER is now right by construction"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ chat2/07 — the region checks ═══
|
||||||
|
|
||||||
|
/// <summary>(m) CENTRE IS LAND — the mainland definition held (the massif is centred); the fallback was not needed.</summary>
|
||||||
|
public static Check CentreIsLand(Pass1Result p1)
|
||||||
|
{
|
||||||
|
var c = new Check { Id = "m", Name = "mainland = centre component (centre cell is land, no fallback)" };
|
||||||
|
if (p1.Regions == null) { c.Passed = false; c.Detail = "no region labeling on this field"; return c; }
|
||||||
|
var m = p1.Regions.Mainland;
|
||||||
|
c.Passed = p1.Regions.CentreWasLand && m != null && (p1.RegionLedger == null || p1.RegionLedger.CentreWasLandPre);
|
||||||
|
c.Detail = c.Passed
|
||||||
|
? $"centre is land; mainland id {p1.Regions.MainlandId}, {m.SizeCells:N0} cells, centroid ({m.CentroidX:F0},{m.CentroidY:F0}); {p1.Regions.IslandCount} islands"
|
||||||
|
: "⚠ CENTRE CELL IS NOT LAND — fell back to the largest component";
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// (n) ⭐ THE REVERT GUARDS, RE-PROVEN ON THE FIELDS — filter OFF vs ON: every cell of the OFF
|
||||||
|
/// field's MAINLAND component is bit-identical; every cell that changed was land in a
|
||||||
|
/// sub-threshold NON-MAINLAND component of the OFF labeling (component-only) and went DOWN, to
|
||||||
|
/// below sea (lower-only); nothing else moved. The pass asserted this as it ran; this is the
|
||||||
|
/// independent proof on the finished fields.
|
||||||
|
/// </summary>
|
||||||
|
public static Check RevertGuards(Pass1Result off, Pass1Result on, float sea, long thresholdCells)
|
||||||
|
{
|
||||||
|
var c = new Check { Id = "n", Name = "speck revert: mainland bit-identical, every change is in a sub-threshold island and lower-only" };
|
||||||
|
if (off.Regions == null) { c.Passed = false; c.Detail = "the OFF field has no region labeling"; return c; }
|
||||||
|
int n = off.MapSize;
|
||||||
|
var lab = off.Regions;
|
||||||
|
long mainland = 0, mainlandDiff = 0, changed = 0, notIsland = 0, notSmall = 0, raised = 0, notSea = 0;
|
||||||
|
string first = null;
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
{
|
||||||
|
float a = off.Height[x, y], b = on.Height[x, y];
|
||||||
|
int id = lab.Id[x * n + y];
|
||||||
|
bool isMain = id != 0 && id == lab.MainlandId;
|
||||||
|
if (isMain) mainland++;
|
||||||
|
if (BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b)) continue;
|
||||||
|
changed++;
|
||||||
|
if (isMain) { mainlandDiff++; first ??= $"mainland cell [{x},{y}] {a:G9} → {b:G9}"; continue; }
|
||||||
|
if (id == 0) { notIsland++; first ??= $"sea cell [{x},{y}] changed {a:G9} → {b:G9}"; continue; }
|
||||||
|
if (lab.Regions[id - 1].SizeCells >= thresholdCells) { notSmall++; first ??= $"cell [{x},{y}] of component {id} ({lab.Regions[id - 1].SizeCells} cells ≥ {thresholdCells}) changed"; }
|
||||||
|
if (b >= a) { raised++; first ??= $"cell [{x},{y}] RAISED {a:G9} → {b:G9}"; }
|
||||||
|
if (b >= sea) { notSea++; first ??= $"cell [{x},{y}] still land after revert ({b:G9})"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Passed = mainlandDiff == 0 && notIsland == 0 && notSmall == 0 && raised == 0 && notSea == 0;
|
||||||
|
c.Detail = c.Passed
|
||||||
|
? $"all {mainland:N0} mainland cells bit-identical; {changed:N0} cells changed, every one in a sub-threshold island, lowered below sea"
|
||||||
|
: $"VIOLATION — mainland {mainlandDiff:N0} / non-island {notIsland:N0} / over-threshold {notSmall:N0} / raised {raised:N0} / still land {notSea:N0} — {first}";
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>(o) LABELS DETERMINISTIC — two generations of the same seed: id maps and component tables identical.</summary>
|
||||||
|
public static Check LabelsDeterministic(Pass1Result a, Pass1Result b)
|
||||||
|
{
|
||||||
|
var c = new Check { Id = "o", Name = "region ids deterministic per seed (two runs, id map + table identical)" };
|
||||||
|
if (a.Regions == null || b.Regions == null) { c.Passed = false; c.Detail = "no region labeling"; return c; }
|
||||||
|
long diff = 0; int n = a.MapSize;
|
||||||
|
for (int i = 0; i < n * n; i++) if (a.Regions.Id[i] != b.Regions.Id[i]) diff++;
|
||||||
|
bool table = a.Regions.Regions.Count == b.Regions.Regions.Count && a.Regions.MainlandId == b.Regions.MainlandId;
|
||||||
|
if (table)
|
||||||
|
for (int i = 0; i < a.Regions.Regions.Count; i++)
|
||||||
|
{
|
||||||
|
var ra = a.Regions.Regions[i]; var rb = b.Regions.Regions[i];
|
||||||
|
if (ra.SizeCells != rb.SizeCells || ra.CentroidX != rb.CentroidX || ra.CentroidY != rb.CentroidY || ra.Hemisphere != rb.Hemisphere || ra.IsMainland != rb.IsMainland) { table = false; break; }
|
||||||
|
}
|
||||||
|
c.Passed = diff == 0 && table;
|
||||||
|
c.Detail = c.Passed ? $"{a.Regions.Regions.Count} components, id map identical over {(long)n * n:N0} cells, tables identical"
|
||||||
|
: $"{diff:N0} id cells differ; tables {(table ? "identical" : "DIFFER")}";
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ chat2/08 — the southern-stretch band checks ═══
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// (p) ⭐ NORTH BIT-LOCKED, BOTH DIRECTIONS — every cell with y < <paramref name="bandRow"/> is
|
||||||
|
/// bit-identical between the two fields (no land lowered, no sea raised); reports how many cells
|
||||||
|
/// differ at/below the band (allowed — that is the relaxation). The surgical guarantee, per cell.
|
||||||
|
/// </summary>
|
||||||
|
public static Check NorthLocked(string id, string name, float[,] a, float[,] b, int mapSize, int bandRow)
|
||||||
|
{
|
||||||
|
var c = new Check { Id = id, Name = name };
|
||||||
|
long northDiff = 0, southDiff = 0, north = 0; string first = null;
|
||||||
|
for (int x = 0; x < mapSize; x++)
|
||||||
|
for (int y = 0; y < mapSize; y++)
|
||||||
|
{
|
||||||
|
bool same = BitConverter.SingleToInt32Bits(a[x, y]) == BitConverter.SingleToInt32Bits(b[x, y]);
|
||||||
|
if (y < bandRow) { north++; if (!same) { northDiff++; first ??= $"[{x},{y}] {a[x, y]:G9} vs {b[x, y]:G9}"; } }
|
||||||
|
else if (!same) southDiff++;
|
||||||
|
}
|
||||||
|
c.Passed = northDiff == 0;
|
||||||
|
c.Detail = c.Passed
|
||||||
|
? $"all {north:N0} cells north of row {bandRow} bit-identical; {southDiff:N0} cells differ in the band or below (the relaxation)"
|
||||||
|
: $"{northDiff:N0} cells north of row {bandRow} DIFFER — {first}";
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>(q) ⭐ NORTHERN ISLANDS INVARIANT — the multiset of north-hemisphere island components (size, centroid) is identical between two labelings.</summary>
|
||||||
|
public static Check NorthIslandsInvariant(string name, RegionLabels a, RegionLabels b)
|
||||||
|
{
|
||||||
|
var c = new Check { Id = "q", Name = name };
|
||||||
|
if (a == null || b == null) { c.Passed = false; c.Detail = "no region labeling"; return c; }
|
||||||
|
var sa = NorthSet(a); var sb = NorthSet(b);
|
||||||
|
bool same = sa.Count == sb.Count;
|
||||||
|
if (same) for (int i = 0; i < sa.Count; i++) if (sa[i] != sb[i]) { same = false; break; }
|
||||||
|
c.Passed = same;
|
||||||
|
c.Detail = same ? $"{sa.Count} northern islands, identical (size + centroid)" : $"DIFFER — {sa.Count} vs {sb.Count} northern islands, or a size/centroid moved";
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> NorthSet(RegionLabels l)
|
||||||
|
{
|
||||||
|
var list = new List<string>();
|
||||||
|
foreach (var r in l.Regions)
|
||||||
|
if (!r.IsMainland && r.Hemisphere == RegionLabeling.HemiNorth)
|
||||||
|
list.Add($"{r.SizeCells}:{r.CentroidX:F3}:{r.CentroidY:F3}");
|
||||||
|
list.Sort(StringComparer.Ordinal);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ chat2/09 — the coastal-fragmentation checks ═══
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// (r) ⭐ INTERIOR LOCKED — every cell whose BASELINE pre-trench falloff is clear of the coastal
|
||||||
|
/// window (weight exactly 0: the interior, the massif, the deep sea) is bit-identical between the
|
||||||
|
/// baseline and the fragmented field. Reports how many cells changed inside the window (the
|
||||||
|
/// coast, allowed). The proof that fragmentation cannot reach inland.
|
||||||
|
/// </summary>
|
||||||
|
public static Check InteriorLocked(Pass1Result baseline, Pass1Result frag, float centre, float halfWidth)
|
||||||
|
{
|
||||||
|
var c = new Check { Id = "r", Name = "interior locked — every cell clear of the coastal window bit-identical (classify)" };
|
||||||
|
int n = baseline.MapSize; long outside = 0, outsideDiff = 0, inside = 0, insideDiff = 0; string first = null;
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
{
|
||||||
|
bool inWindow = MathF.Abs(baseline.PreTrenchFalloff[x, y] - centre) < halfWidth;
|
||||||
|
bool same = BitConverter.SingleToInt32Bits(baseline.Height[x, y]) == BitConverter.SingleToInt32Bits(frag.Height[x, y]);
|
||||||
|
if (inWindow) { inside++; if (!same) insideDiff++; }
|
||||||
|
else { outside++; if (!same) { outsideDiff++; first ??= $"[{x},{y}] f {baseline.PreTrenchFalloff[x, y]:F3}: {baseline.Height[x, y]:G9} → {frag.Height[x, y]:G9}"; } }
|
||||||
|
}
|
||||||
|
c.Passed = outsideDiff == 0;
|
||||||
|
c.Detail = c.Passed
|
||||||
|
? $"all {outside:N0} cells outside the window bit-identical; {insideDiff:N0} of {inside:N0} window cells changed (the coast)"
|
||||||
|
: $"{outsideDiff:N0} cells OUTSIDE the window changed — {first}";
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// (s) informational — HIGH GROUND: of the cells whose BASELINE raw height is at or above
|
||||||
|
/// <paramref name="rawThreshold"/>, how many changed, and the largest change. A coastal hill
|
||||||
|
/// inside the window may legitimately move in height without flipping; this reports it.
|
||||||
|
/// </summary>
|
||||||
|
public static Check HighGroundReport(Pass1Result baseline, Pass1Result frag, float rawThreshold, string thresholdLabel)
|
||||||
|
{
|
||||||
|
var c = new Check { Id = "s", Name = $"(informational) high ground ≥ {thresholdLabel}: cells changed / largest |Δ|", Passed = true };
|
||||||
|
int n = baseline.MapSize; long high = 0, changed = 0; float maxAbs = 0f;
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
{
|
||||||
|
float a = baseline.Height[x, y]; if (a < rawThreshold) continue;
|
||||||
|
high++;
|
||||||
|
float b = frag.Height[x, y];
|
||||||
|
if (a != b) { changed++; float d = MathF.Abs(b - a); if (d > maxAbs) maxAbs = d; }
|
||||||
|
}
|
||||||
|
c.Detail = $"{changed:N0} of {high:N0} high cells changed; largest |Δ| {maxAbs:G4} raw ({Core.WorldScale.MetresFromRaw(maxAbs):F1} m)";
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Render the whole oracle as a markdown table for the INDEX and the report.</summary>
|
/// <summary>Render the whole oracle as a markdown table for the INDEX and the report.</summary>
|
||||||
public static string ToMarkdownTable(IEnumerable<Check> checks)
|
public static string ToMarkdownTable(IEnumerable<Check> checks)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
57
Tools/Scripts/SouthernStretch.cs
Normal file
57
Tools/Scripts/SouthernStretch.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE SOUTHERN STRETCH (chat2/08, EXPLORATION) — the one deliberate relaxation of sea identity,
|
||||||
|
/// confined to a FIXED feathered latitude band.
|
||||||
|
///
|
||||||
|
/// ═══ THE MECHANISM (read from pass 1, chat2/08 diagnostic) ═══
|
||||||
|
///
|
||||||
|
/// Pass 1's mask is falloff = ½·ellipse + ½·squircle (+ edge noise · squircle), then the SOUTHERN
|
||||||
|
/// SINKER adds 0.6 · (y − 0.75N)/(0.25N) for y > 0.75N, BEFORE the 2.5 power; the coast sits
|
||||||
|
/// where rawBase − falloff^2.5 crosses sea, i.e. near falloff ≈ 0.66. Every term that grows with y
|
||||||
|
/// is a function of the SOUTHWARD DISTANCE. The stretch compresses that distance inside the band:
|
||||||
|
///
|
||||||
|
/// y' = yB + (y − yB) / (1 + stretch · ramp(y)) ramp = smoothstep over the feather
|
||||||
|
///
|
||||||
|
/// so a cell at y takes the mask geometry of the row y' north of it — the mass reaches further
|
||||||
|
/// south AND keeps the elevation profile of the rows it came from (the base noise, edge noise and
|
||||||
|
/// latitude field keep the real y — texture stays, only the mask's geometry stretches). Where the
|
||||||
|
/// stretched thin edge drops below sea it fragments organically. Nothing is stamped.
|
||||||
|
///
|
||||||
|
/// ⚠ North of the band (y ≤ yB) the caller takes the untouched code path: bit-identical by
|
||||||
|
/// construction, asserted by the oracle. The band line and feather are constants for a batch;
|
||||||
|
/// <c>TerrainGenConfig.SouthStretch</c> is the only swept axis.
|
||||||
|
///
|
||||||
|
/// The SINKER: <c>TerrainGenConfig.StretchSinker</c> decides whether it rides y' (pushed out with
|
||||||
|
/// the geometry — held back inside the band) or the real y (keeps pulling the extended mass down
|
||||||
|
/// where it always did). The chat2/08 diagnostic measured both — see the report.
|
||||||
|
/// </summary>
|
||||||
|
public static class SouthernStretch
|
||||||
|
{
|
||||||
|
/// <summary>The band's fixed latitude line, fraction of the map. Chosen by the chat2/08 diagnostic (see the report).</summary>
|
||||||
|
public const float DefaultBandStartFrac = 0.70f;
|
||||||
|
|
||||||
|
/// <summary>The feather width, fraction of the map.</summary>
|
||||||
|
public const float DefaultBandFeatherFrac = 0.05f;
|
||||||
|
|
||||||
|
/// <summary>Whether the sinker rides the stretched distance by default. Set by the chat2/08 diagnostic.</summary>
|
||||||
|
public const bool DefaultStretchSinker = true;
|
||||||
|
|
||||||
|
/// <summary>The smoothstep ramp across the feather: 0 at the band line, 1 a feather-width below it.</summary>
|
||||||
|
public static float Ramp(float y, float bandStart, float bandFeather)
|
||||||
|
{
|
||||||
|
float t = Math.Clamp((y - bandStart) / bandFeather, 0f, 1f);
|
||||||
|
return t * t * (3f - 2f * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The y the mask geometry sees. For y ≤ bandStart returns y unchanged.</summary>
|
||||||
|
public static float StretchedY(float y, float bandStart, float bandFeather, float stretch)
|
||||||
|
{
|
||||||
|
if (y <= bandStart || stretch <= 0f) return y;
|
||||||
|
float r = Ramp(y, bandStart, bandFeather);
|
||||||
|
return bandStart + (y - bandStart) / (1f + stretch * r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/SouthernStretch.cs.uid
Normal file
1
Tools/Scripts/SouthernStretch.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dttggsatk4hh0
|
||||||
631
Tools/Scripts/SouthernStretchTool.cs
Normal file
631
Tools/Scripts/SouthernStretchTool.cs
Normal file
|
|
@ -0,0 +1,631 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE SOUTHERN-STRETCH EXPLORATION BATCH (chat2/08) — map the fragmentation knob space: a
|
||||||
|
/// peninsula → few-big-pieces → gravel ladder, 5 stretch levels × 2 seeds, with the region layer
|
||||||
|
/// as the instrument. NOT a converged setting.
|
||||||
|
///
|
||||||
|
/// ═══ TWO MODES ═══
|
||||||
|
///
|
||||||
|
/// ISLA_DIAG_ONLY=1 the DIAGNOSTIC (numbers, no plates): the three southern forces along a
|
||||||
|
/// south-running profile (falloff blend / edge noise / sinker, each alone),
|
||||||
|
/// the reach table for the candidate seeds, and a stretch sweep under both
|
||||||
|
/// sinker modes — written to scratch/southern_diagnosis.md. Run first; it
|
||||||
|
/// sets the ladder.
|
||||||
|
/// (default) the BATCH: 5 levels × 2 seeds at ISLA_MAPSIZE, lean render per field
|
||||||
|
/// (labeled-regions overlay + relief + .f32), the hemisphere-split count/size
|
||||||
|
/// table, the asymmetric oracle.
|
||||||
|
///
|
||||||
|
/// Every field: pass 1 + the stretch, region labeling ON, offshore OFF, shelf OFF, speck revert OFF
|
||||||
|
/// — the pure fragmentation signal (the instrument counts "all islands" and "islands ≥ the 07
|
||||||
|
/// mid threshold" side by side). The curve is the tagged curve, unchanged.
|
||||||
|
///
|
||||||
|
/// ═══ RUNNING IT ═══
|
||||||
|
///
|
||||||
|
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
|
||||||
|
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/SouthernStretchTool.tscn
|
||||||
|
///
|
||||||
|
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
|
||||||
|
/// ISLA_MAPSIZE plate size (default 4096)
|
||||||
|
/// ISLA_CALIB_SIZE curve calibration + diagnostic size (default 2048)
|
||||||
|
/// ISLA_SEEDS the two batch seeds (default: auto — the two 07 seeds with the most southern mass)
|
||||||
|
/// ISLA_CANDIDATE_SEEDS the pool the auto-pick reads (default: the 07 table seeds)
|
||||||
|
/// ISLA_STRETCH_LEVELS the 5 stretch values (default: the diagnostic-chosen ladder below)
|
||||||
|
/// ISLA_BAND_START / ISLA_BAND_FEATHER the fixed band (fractions of the map; constants for the batch)
|
||||||
|
/// ISLA_STRETCH_SINKER 1 = the sinker rides the stretched distance (default), 0 = real y
|
||||||
|
/// ISLA_DIAG_ONLY=1 diagnostic only
|
||||||
|
/// ISLA_SKIP_8K=1 skip the 8192 band regression (a4b)
|
||||||
|
/// </summary>
|
||||||
|
public partial class SouthernStretchTool : Node
|
||||||
|
{
|
||||||
|
private static readonly int[] DefaultCandidateSeeds =
|
||||||
|
{
|
||||||
|
1063685222, 20260821, 8675309, 123456789, 271828182, 999999937, 90210, 424242,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>⚠ Task 01's pool, verbatim — the curve's identity.</summary>
|
||||||
|
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE LADDER — chosen by the diagnostic (chat2/08 report §1), not linear: the stretch
|
||||||
|
/// bites unevenly, so the steps are spaced where the southern count/size actually moves.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly float[] DefaultLadder = { 0.5f, 1.0f, 2.0f, 3.0f, 5.0f };
|
||||||
|
|
||||||
|
/// <summary>The diagnostic's sweep (both sinker modes).</summary>
|
||||||
|
private static readonly float[] DiagSweep = { 0.25f, 0.5f, 1f, 1.5f, 2f, 3f, 5f, 8f, 16f };
|
||||||
|
|
||||||
|
private const int DefaultMapSize = 4096;
|
||||||
|
private const int DefaultCalibSize = 2048;
|
||||||
|
private const int GallerySize = 8192;
|
||||||
|
|
||||||
|
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 Level; public float Stretch; public int Seed;
|
||||||
|
public double ReachFrac, MedianCoastFrac; public long MainlandCells, MainlandSouthOfBand;
|
||||||
|
public int SouthAll, SouthBig, NorthAll, NorthBig;
|
||||||
|
public long SMin, SMed, SMax, NMax; public double SMean; public int[] SHist;
|
||||||
|
public bool Ok; public ulong Ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Run()
|
||||||
|
{
|
||||||
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
|
||||||
|
int task = EnvInt("ISLA_TASK", 8);
|
||||||
|
string descr = EnvStr("ISLA_BATCH", "southern_stretch_explore");
|
||||||
|
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
|
||||||
|
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
|
||||||
|
int[] candidates = EnvSeeds("ISLA_CANDIDATE_SEEDS", DefaultCandidateSeeds);
|
||||||
|
int[] seedsEnv = EnvSeeds("ISLA_SEEDS", null);
|
||||||
|
float[] ladder = EnvFloats("ISLA_STRETCH_LEVELS", DefaultLadder);
|
||||||
|
float bandStart = EnvFloat("ISLA_BAND_START", SouthernStretch.DefaultBandStartFrac);
|
||||||
|
float bandFeather = EnvFloat("ISLA_BAND_FEATHER", SouthernStretch.DefaultBandFeatherFrac);
|
||||||
|
bool stretchSinker = EnvStr("ISLA_STRETCH_SINKER", SouthernStretch.DefaultStretchSinker ? "1" : "0") == "1";
|
||||||
|
bool diagOnly = EnvStr("ISLA_DIAG_ONLY", "0") == "1";
|
||||||
|
bool skip8k = EnvStr("ISLA_SKIP_8K", "0") == "1";
|
||||||
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
|
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
|
||||||
|
string t03Source = EnvStr("ISLA_T03_SOURCE", "03_mountain_restore");
|
||||||
|
string t04Source = EnvStr("ISLA_T04_SOURCE", "04_seed_gallery");
|
||||||
|
|
||||||
|
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||||||
|
string scratch = ToolingPaths.BatchScratch(batchRoot);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(scratch);
|
||||||
|
|
||||||
|
var anchors = CurveAnchors.Default;
|
||||||
|
float sea = 0.15f;
|
||||||
|
long bigCells4k = Cells(RegionPass.ThresholdMidFrac, mapSize);
|
||||||
|
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print(" SOUTHERN STRETCH (chat2/08) — EXPLORATION: map the fragmentation knob space");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print($"MapSize : {mapSize} (plates) calibration + diagnostic at {calibSize}");
|
||||||
|
GD.Print($"band : start {bandStart:F3} of the map (row {(int)(bandStart * mapSize)} at {mapSize}), feather {bandFeather:F3} — FIXED for the batch");
|
||||||
|
GD.Print($"sinker : {(stretchSinker ? "rides the stretched distance (held back inside the band)" : "real y (keeps pulling the extended mass down)")}");
|
||||||
|
GD.Print($"ladder : {string.Join(", ", ladder)}");
|
||||||
|
GD.Print($"fields : pass 1 + stretch · labeling ON · offshore OFF · shelf OFF · speck revert OFF (\"big\" island = ≥ {bigCells4k:N0} cells at {mapSize}, the 07 mid threshold)");
|
||||||
|
GD.Print($"batch : {batchRoot}{(diagOnly ? " ⚠ ISLA_DIAG_ONLY — the diagnostic, no plates" : "")}");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
|
||||||
|
// ═══ 0. THE CURVE ═══
|
||||||
|
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, float stretch, bool sinkerStretched, bool sinkerOn = true, bool edgeOn = true)
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
CoastShelf = false, Offshore = new OffshoreSettings(),
|
||||||
|
RegionLabeling = true, SpeckRevert = false,
|
||||||
|
SouthStretch = stretch, SouthBandStartFrac = bandStart, SouthBandFeatherFrac = bandFeather, StretchSinker = sinkerStretched,
|
||||||
|
SouthernSinker = sinkerOn, EdgeNoise = edgeOn,
|
||||||
|
};
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 1. THE SEEDS — southern mass, measured ═══
|
||||||
|
GD.Print($"\n--- 1. SOUTHERN REACH of the candidate seeds at {calibSize} (offshore off, stretch off) ---");
|
||||||
|
var reachRows = new List<(int seed, double reach, double median, long southCells, long mainland, int southIslands)>();
|
||||||
|
int bandRowC = (int)(bandStart * calibSize);
|
||||||
|
foreach (int s in candidates)
|
||||||
|
{
|
||||||
|
var p = Topography.Generate(Cfg(calibSize, s, "reach", 0f, stretchSinker));
|
||||||
|
var (reach, median, southCells) = Reach(p.Regions, calibSize, bandRowC);
|
||||||
|
var (n, so) = RegionLabeling.IslandsByHemisphere(p.Regions);
|
||||||
|
reachRows.Add((s, reach, median, southCells, p.Regions.Mainland.SizeCells, so));
|
||||||
|
GD.Print($" seed {s,-11} southernmost mainland row {reach:F3} of map, median coast {median:F3}, mainland cells south of band {southCells,9:N0} ({100.0 * southCells / p.Regions.Mainland.SizeCells:F1} % of mainland), natural S islands {so}");
|
||||||
|
}
|
||||||
|
int[] seeds = seedsEnv;
|
||||||
|
if (seeds == null)
|
||||||
|
{
|
||||||
|
reachRows.Sort((a, b) => b.southCells.CompareTo(a.southCells));
|
||||||
|
seeds = new[] { reachRows[0].seed, reachRows[1].seed };
|
||||||
|
}
|
||||||
|
GD.Print($" → batch seeds: {seeds[0]}, {seeds[1]}{(seedsEnv == null ? " (auto: the two with the most mainland south of the band)" : " (ISLA_SEEDS)")}");
|
||||||
|
|
||||||
|
// ═══ 2. THE DIAGNOSTIC ═══
|
||||||
|
var diag = new StringBuilder();
|
||||||
|
diag.AppendLine("# The southern diagnosis — chat2/08 (measured by SouthernStretchTool, ISLA_DIAG_ONLY)");
|
||||||
|
diag.AppendLine();
|
||||||
|
diag.AppendLine($"Size {calibSize}. Band start {bandStart:F3} (row {bandRowC}), feather {bandFeather:F3}. y runs south; fractions are y / MapSize.");
|
||||||
|
diag.AppendLine();
|
||||||
|
diag.AppendLine("## 1. Southern reach of the candidate seeds (offshore off, stretch off)");
|
||||||
|
diag.AppendLine();
|
||||||
|
diag.AppendLine("| seed | southernmost mainland row | median coast row (per column, central 60 %) | mainland cells south of band | % of mainland | natural S islands |");
|
||||||
|
diag.AppendLine("|---|---|---|---|---|---|");
|
||||||
|
foreach (var r in reachRows) diag.AppendLine($"| `{r.seed}` | {r.reach:F3} | {r.median:F3} | {r.southCells:N0} | {100.0 * r.southCells / r.mainland:F1} % | {r.southIslands} |");
|
||||||
|
diag.AppendLine();
|
||||||
|
diag.AppendLine($"**Batch seeds:** `{seeds[0]}`, `{seeds[1]}`.");
|
||||||
|
diag.AppendLine();
|
||||||
|
|
||||||
|
if (diagOnly)
|
||||||
|
{
|
||||||
|
GD.Print($"\n--- 2. THE THREE FORCES along a south-running profile (central 20 % of x, averaged) ---");
|
||||||
|
foreach (int s in seeds)
|
||||||
|
{
|
||||||
|
var full = Topography.Generate(Cfg(calibSize, s, "full", 0f, stretchSinker));
|
||||||
|
var noSink = Topography.Generate(Cfg(calibSize, s, "nosink", 0f, stretchSinker, sinkerOn: false));
|
||||||
|
var bare = Topography.Generate(Cfg(calibSize, s, "bare", 0f, stretchSinker, sinkerOn: false, edgeOn: false));
|
||||||
|
diag.AppendLine($"## 2. The three forces — seed `{s}` (central 20 % of x averaged; falloff terms are PRE-power)");
|
||||||
|
diag.AppendLine();
|
||||||
|
diag.AppendLine("| y / N | blend (ellipse+squircle) | edge noise | sinker | total pre-trench | total^2.5 | mean height | land fraction of row |");
|
||||||
|
diag.AppendLine("|---|---|---|---|---|---|---|---|");
|
||||||
|
int x0 = (int)(calibSize * 0.40), x1 = (int)(calibSize * 0.60);
|
||||||
|
for (int yi = 50; yi <= 100; yi += 2)
|
||||||
|
{
|
||||||
|
int y = Math.Min(calibSize - 1, yi * calibSize / 100);
|
||||||
|
double sb = 0, sn = 0, sf = 0, sh = 0; long land = 0; int cnt = 0;
|
||||||
|
for (int x = x0; x < x1; x++)
|
||||||
|
{
|
||||||
|
sb += bare.PreTrenchFalloff[x, y]; sn += noSink.PreTrenchFalloff[x, y]; sf += full.PreTrenchFalloff[x, y];
|
||||||
|
sh += full.Height[x, y]; if (full.Height[x, y] >= sea) land++; cnt++;
|
||||||
|
}
|
||||||
|
double blend = sb / cnt, edge = sn / cnt - blend, sink = sf / cnt - sn / cnt, total = sf / cnt;
|
||||||
|
diag.AppendLine($"| {y / (double)calibSize:F2} | {blend:F3} | {edge:F3} | {sink:F3} | {total:F3} | {Math.Pow(Math.Max(0, total), 2.5):F3} | {sh / cnt:F3} | {land / (double)cnt:P0} |");
|
||||||
|
GD.Print($" seed {s} y {y / (double)calibSize:F2}: blend {blend:F3} edge {edge:F3} sinker {sink:F3} total {total:F3} pow {Math.Pow(Math.Max(0, total), 2.5):F3} height {sh / cnt:F3} land {land / (double)cnt:P0}");
|
||||||
|
}
|
||||||
|
diag.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
GD.Print($"\n--- 3. THE STRETCH SWEEP (both sinker modes) ---");
|
||||||
|
diag.AppendLine("## 3. The stretch sweep — reach, southern islands (all / ≥ mid threshold), sizes, and the northern control");
|
||||||
|
diag.AppendLine();
|
||||||
|
long bigC = Cells(RegionPass.ThresholdMidFrac, calibSize);
|
||||||
|
diag.AppendLine($"\"big\" = ≥ {bigC} cells at {calibSize} (the 07 `threshold_mid`). Reach = southernmost mainland row / N; median coast over the central 60 % of columns.");
|
||||||
|
diag.AppendLine();
|
||||||
|
foreach (bool mode in new[] { false, true })
|
||||||
|
{
|
||||||
|
diag.AppendLine($"### Sinker on {(mode ? "the STRETCHED distance (held back with the geometry)" : "the REAL y (unchanged)")}");
|
||||||
|
diag.AppendLine();
|
||||||
|
diag.AppendLine("| seed | stretch | reach | median coast | mainland cells south of band | S islands all / big | S size med / max | S largest island | N islands (control) |");
|
||||||
|
diag.AppendLine("|---|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (int s in seeds)
|
||||||
|
{
|
||||||
|
var sweep = new List<float> { 0f }; sweep.AddRange(DiagSweep);
|
||||||
|
foreach (float st in sweep)
|
||||||
|
{
|
||||||
|
var p = Topography.Generate(Cfg(calibSize, s, $"sweep_{st}", st, mode));
|
||||||
|
var (reach, median, southCells) = Reach(p.Regions, calibSize, bandRowC);
|
||||||
|
var stats = Hemi(p.Regions, bigC);
|
||||||
|
diag.AppendLine($"| `{s}` | {st:G3} | {reach:F3} | {median:F3} | {southCells:N0} | {stats.southAll} / {stats.southBig} | {stats.sMed} / {stats.sMax} | {stats.sMax} | {stats.northAll} |");
|
||||||
|
GD.Print($" sinker {(mode ? "stretched" : "real ")} seed {s,-11} stretch {st,5:G3} reach {reach:F3} median {median:F3} southCells {southCells,8:N0} S {stats.southAll,3}/{stats.southBig,3} med {stats.sMed,6} max {stats.sMax,7} N {stats.northAll}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diag.AppendLine();
|
||||||
|
}
|
||||||
|
WriteText(Path.Combine(scratch, "southern_diagnosis.md"), diag.ToString());
|
||||||
|
GD.Print($"\n diagnosis written: {Path.Combine(scratch, "southern_diagnosis.md")}");
|
||||||
|
GD.Print(" ISLA_DIAG_ONLY — done; no plates.");
|
||||||
|
GetTree().Quit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 3. REGRESSIONS — north bit-identical to terrain-curve-v1; stretch-off bit-identical everywhere ═══
|
||||||
|
GD.Print($"\n--- 3. REGRESSIONS at {calibSize}, seed {seeds[0]} ---");
|
||||||
|
var hard = new List<ShapingOracle.Check>();
|
||||||
|
int plate = seeds[0];
|
||||||
|
float maxStretch = ladder[ladder.Length - 1];
|
||||||
|
{
|
||||||
|
var offCfg = Cfg(calibSize, plate, "off", 0f, stretchSinker);
|
||||||
|
Pass1Result p1 = Topography.Generate(offCfg);
|
||||||
|
var curveOff = offCfg.Clone(); curveOff.Curve = false;
|
||||||
|
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{plate}_full", "height.f32");
|
||||||
|
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, stretch OFF == Phase-1 .f32 dump", Shaping.Shape(p1, curveOff).Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
|
||||||
|
string t03Dump = Path.Combine(ToolingPaths.BatchesRoot, t03Source, $"{plate}_continuous_restored", "height.f32");
|
||||||
|
float[,] t03 = HeightField.Load(t03Dump, calibSize);
|
||||||
|
hard.Add(ShapingOracle.DumpRegression("a3", "continuous_restored, stretch OFF == task-03 .f32 dump", Shaping.Shape(p1, offCfg).Height, t03, calibSize, t03Dump));
|
||||||
|
|
||||||
|
// ⭐ a3b — stretch ON at the ladder's TOP: north of the band bit-identical to the tag's own dump.
|
||||||
|
var topCfg = Cfg(calibSize, plate, "top", maxStretch, stretchSinker);
|
||||||
|
Pass2Result pTop = Shaping.Shape(Topography.Generate(topCfg), topCfg);
|
||||||
|
if (t03 != null)
|
||||||
|
hard.Add(ShapingOracle.NorthLocked("a3b", $"stretch {maxStretch:G3} ON: north of the band bit-identical to task-03 dump (terrain-curve-v1); changes only in/below the band", pTop.Height, t03, calibSize, bandRowC));
|
||||||
|
foreach (var c in hard) GD.Print(" " + c);
|
||||||
|
|
||||||
|
if (!skip8k)
|
||||||
|
{
|
||||||
|
string t04Dump = Path.Combine(ToolingPaths.BatchesRoot, t04Source, $"{plate}", "height.f32");
|
||||||
|
if (File.Exists(t04Dump))
|
||||||
|
{
|
||||||
|
GD.Print($" a4b: generating {plate} at {GallerySize}, stretch {maxStretch:G3} …");
|
||||||
|
var g = Cfg(GallerySize, plate, "top", maxStretch, stretchSinker);
|
||||||
|
Pass2Result pG = Shaping.Shape(Topography.Generate(g), g);
|
||||||
|
var a4b = ShapingOracle.NorthLocked("a4b", $"stretch {maxStretch:G3} ON at {GallerySize}: north of the band bit-identical to terrain-curve-v1's 04 gallery dump",
|
||||||
|
pG.Height, HeightField.Load(t04Dump, GallerySize), GallerySize, (int)(bandStart * GallerySize));
|
||||||
|
hard.Add(a4b); GD.Print(" " + a4b);
|
||||||
|
}
|
||||||
|
else GD.Print($" a4b: ⚠ skipped — no 04 gallery dump at {t04Dump}");
|
||||||
|
}
|
||||||
|
else GD.Print(" a4b: skipped (ISLA_SKIP_8K)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 4. THE LADDER — 5 levels × 2 seeds ═══
|
||||||
|
GD.Print($"\n--- 4. THE LADDER at {mapSize} ---");
|
||||||
|
int bandRow = (int)(bandStart * mapSize);
|
||||||
|
var rows = new List<Row>();
|
||||||
|
var perField = new List<ShapingOracle.Check>();
|
||||||
|
var baselineRows = new Dictionary<int, Row>();
|
||||||
|
foreach (int seed in seeds)
|
||||||
|
{
|
||||||
|
var c0 = Cfg(mapSize, seed, "stretch_0", 0f, stretchSinker);
|
||||||
|
Pass1Result p0 = Topography.Generate(c0);
|
||||||
|
Pass2Result q0 = Shaping.Shape(p0, c0);
|
||||||
|
baselineRows[seed] = MakeRow(0, 0f, seed, p0, mapSize, bandRow, bigCells4k, true, p0.ElapsedMs);
|
||||||
|
var r0 = baselineRows[seed];
|
||||||
|
GD.Print($" seed {seed} baseline (stretch 0): reach {r0.ReachFrac:F3}, S islands {r0.SouthAll} / big {r0.SouthBig}, N islands {r0.NorthAll}");
|
||||||
|
|
||||||
|
for (int li = 0; li < ladder.Length; li++)
|
||||||
|
{
|
||||||
|
float st = ladder[li];
|
||||||
|
string label = $"stretch_{li + 1}";
|
||||||
|
var cfg = Cfg(mapSize, seed, label, st, stretchSinker);
|
||||||
|
Pass1Result p1 = Topography.Generate(cfg);
|
||||||
|
Pass2Result p2 = Shaping.Shape(p1, cfg);
|
||||||
|
|
||||||
|
var checks = new List<ShapingOracle.Check>
|
||||||
|
{
|
||||||
|
ShapingOracle.NorthLocked("p", "north of the band bit-locked (classify) vs stretch 0", p1.Height, p0.Height, mapSize, bandRow),
|
||||||
|
ShapingOracle.NorthLocked("p2", "north of the band bit-locked (render) vs stretch 0", p2.Height, q0.Height, mapSize, bandRow),
|
||||||
|
ShapingOracle.NorthIslandsInvariant("northern islands invariant vs stretch 0", p0.Regions, p1.Regions),
|
||||||
|
ShapingOracle.CentreIsLand(p1),
|
||||||
|
ShapingOracle.TagCoastlineConsistent(p2, sea),
|
||||||
|
ShapingOracle.ClassifyFidelity(p1, p2),
|
||||||
|
};
|
||||||
|
foreach (var c in checks) { c.Name += $" [{label} = {st:G3}, {seed}]"; perField.Add(c); }
|
||||||
|
bool ok = checks.TrueForAll(c => c.Passed);
|
||||||
|
|
||||||
|
var row = MakeRow(li + 1, st, seed, p1, mapSize, bandRow, bigCells4k, ok, p1.ElapsedMs);
|
||||||
|
rows.Add(row);
|
||||||
|
WriteField(batchRoot, p1, p2, sea, anchors, skipRaw, st);
|
||||||
|
GD.Print($" {label,-10} {st,5:G3} seed {seed,-11} reach {row.ReachFrac:F3} (median coast {row.MedianCoastFrac:F3}) mainland S-of-band {row.MainlandSouthOfBand,9:N0} " +
|
||||||
|
$"S islands {row.SouthAll,3} / big {row.SouthBig,3} S size med {row.SMed,6} max {row.SMax,7} N islands {row.NorthAll,3} {(ok ? "ok" : "⚠ CHECK FAILED")} {p1.ElapsedMs} ms");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// determinism: the middle level on the first seed, twice
|
||||||
|
{
|
||||||
|
float st = ladder[ladder.Length / 2];
|
||||||
|
var cA = Cfg(mapSize, plate, "det", st, stretchSinker); var cB = Cfg(mapSize, plate, "det", st, stretchSinker);
|
||||||
|
var a = Topography.Generate(cA); var b = Topography.Generate(cB);
|
||||||
|
var det = ShapingOracle.LabelsDeterministic(a, b); det.Name += $" [stretch {st:G3}, {plate}]";
|
||||||
|
var bits = ShapingOracle.NorthLocked("o2", $"two generations bit-identical everywhere [stretch {st:G3}, {plate}]", a.Height, b.Height, mapSize, mapSize);
|
||||||
|
perField.Add(det); perField.Add(bits);
|
||||||
|
GD.Print(" " + det); GD.Print(" " + bits);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool allOk = hard.TrueForAll(c => c.Passed) && perField.TrueForAll(c => c.Passed);
|
||||||
|
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
|
||||||
|
foreach (var c in perField) if (!c.Passed) GD.PrintErr(" " + c);
|
||||||
|
|
||||||
|
WriteTable(batchRoot, mapSize, ladder, seeds, rows, baselineRows, bigCells4k, bandStart, bandFeather, stretchSinker);
|
||||||
|
WriteIndex(batchRoot, mapSize, calibSize, ladder, seeds, rows, baselineRows, bigCells4k, bandStart, bandFeather, stretchSinker, hard, perField, allOk, diag.ToString());
|
||||||
|
|
||||||
|
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 instrument --------------------------------------------------
|
||||||
|
|
||||||
|
private static long Cells(float frac, int size) => Math.Max(1L, (long)Math.Round(frac * (double)size * size));
|
||||||
|
|
||||||
|
/// <summary>Southernmost mainland row / N, the median per-column coast row (central 60 % of columns) / N, and mainland cells at/below the band row.</summary>
|
||||||
|
private static (double reach, double median, long southCells) Reach(RegionLabels l, int n, int bandRow)
|
||||||
|
{
|
||||||
|
int main = l.MainlandId; int maxY = -1; long south = 0;
|
||||||
|
var coast = new List<int>();
|
||||||
|
int x0 = (int)(n * 0.2), x1 = (int)(n * 0.8);
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
{
|
||||||
|
int colMax = -1;
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
{
|
||||||
|
if (l.Id[x * n + y] != main) continue;
|
||||||
|
if (y > colMax) colMax = y;
|
||||||
|
if (y >= bandRow) south++;
|
||||||
|
}
|
||||||
|
if (colMax > maxY) maxY = colMax;
|
||||||
|
if (x >= x0 && x < x1 && colMax >= 0) coast.Add(colMax);
|
||||||
|
}
|
||||||
|
coast.Sort();
|
||||||
|
double median = coast.Count == 0 ? 0 : coast[coast.Count / 2] / (double)n;
|
||||||
|
return (maxY / (double)n, median, south);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (int southAll, int southBig, int northAll, int northBig, long sMin, long sMed, double sMean, long sMax, long nMax, int[] sHist)
|
||||||
|
Hemi(RegionLabels l, long big)
|
||||||
|
{
|
||||||
|
var south = new List<long>(); int northAll = 0, northBig = 0; long nMax = 0;
|
||||||
|
foreach (var r in l.Regions)
|
||||||
|
{
|
||||||
|
if (r.IsMainland) continue;
|
||||||
|
if (r.Hemisphere == RegionLabeling.HemiSouth) south.Add(r.SizeCells);
|
||||||
|
else { northAll++; if (r.SizeCells >= big) northBig++; nMax = Math.Max(nMax, r.SizeCells); }
|
||||||
|
}
|
||||||
|
south.Sort();
|
||||||
|
var hist = new int[RegionLabeling.HistogramEdges.Length + 1];
|
||||||
|
int sBig = 0; double sum = 0;
|
||||||
|
foreach (long s in south) { hist[RegionLabeling.HistogramBin(s)]++; if (s >= big) sBig++; sum += s; }
|
||||||
|
return (south.Count, sBig, northAll, northBig,
|
||||||
|
south.Count == 0 ? 0 : south[0], south.Count == 0 ? 0 : south[south.Count / 2], south.Count == 0 ? 0 : sum / south.Count,
|
||||||
|
south.Count == 0 ? 0 : south[south.Count - 1], nMax, hist);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Row MakeRow(int level, float st, int seed, Pass1Result p1, int mapSize, int bandRow, long big, bool ok, ulong ms)
|
||||||
|
{
|
||||||
|
var (reach, median, southCells) = Reach(p1.Regions, mapSize, bandRow);
|
||||||
|
var h = Hemi(p1.Regions, big);
|
||||||
|
return new Row
|
||||||
|
{
|
||||||
|
Level = level, Stretch = st, Seed = seed, ReachFrac = reach, MedianCoastFrac = median,
|
||||||
|
MainlandCells = p1.Regions.Mainland.SizeCells, MainlandSouthOfBand = southCells,
|
||||||
|
SouthAll = h.southAll, SouthBig = h.southBig, NorthAll = h.northAll, NorthBig = h.northBig,
|
||||||
|
SMin = h.sMin, SMed = h.sMed, SMean = h.sMean, SMax = h.sMax, NMax = h.nMax, SHist = h.sHist, Ok = ok, Ms = ms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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 }); // bare default: offshore / revert / stretch OFF
|
||||||
|
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, Pass1Result p1, Pass2Result p2, float sea, CurveAnchors anchors, bool skipRaw, float stretch)
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||||||
|
if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
|
||||||
|
var look = new LookConfig
|
||||||
|
{
|
||||||
|
Name = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
|
||||||
|
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f, HillshadeStrength = 0.30f, SeaLevel = sea,
|
||||||
|
};
|
||||||
|
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
|
||||||
|
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, $"{p2.VariantLabel.ToUpperInvariant()} ({stretch:G3}) {p2.Seed}")
|
||||||
|
.SavePng(Path.Combine(dir, "relief.png"));
|
||||||
|
RegionOverlayRenderer.SavePng(p1.Regions, null, p1.MapSize, 0, 0, Path.Combine(dir, "regions.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HistRow(int[] h)
|
||||||
|
{
|
||||||
|
if (h == null) return "—";
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < h.Length; i++) { if (i > 0) sb.Append(" · "); sb.Append(h[i]); }
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TableMarkdown(float[] ladder, int[] seeds, List<Row> rows, Dictionary<int, Row> baseline, long big)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var histHead = new StringBuilder();
|
||||||
|
for (int i = 0; i <= RegionLabeling.HistogramEdges.Length; i++) { if (i > 0) histHead.Append(" · "); histHead.Append(RegionLabeling.HistogramLabel(i)); }
|
||||||
|
sb.AppendLine($"| Level | stretch | Seed | reach (southernmost mainland row / N) | median coast / N | mainland cells south of band | **SOUTH islands: all / ≥ {big:N0} cells** | **S size min / med / mean / max** | S histogram ({histHead}) | **NORTH islands (control)** | N largest | oracle |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (int seed in seeds)
|
||||||
|
{
|
||||||
|
var b = baseline[seed];
|
||||||
|
sb.AppendLine($"| *baseline* | 0 | `{seed}` | {b.ReachFrac:F3} | {b.MedianCoastFrac:F3} | {b.MainlandSouthOfBand:N0} | **{b.SouthAll} / {b.SouthBig}** | **{b.SMin} / {b.SMed} / {b.SMean:F0} / {b.SMax}** | {HistRow(b.SHist)} | **{b.NorthAll}** | {b.NMax} | — |");
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
if (r.Seed != seed) continue;
|
||||||
|
sb.AppendLine($"| `stretch_{r.Level}` | {r.Stretch:G3} | `{seed}` | {r.ReachFrac:F3} | {r.MedianCoastFrac:F3} | {r.MainlandSouthOfBand:N0} | **{r.SouthAll} / {r.SouthBig}** | **{r.SMin} / {r.SMed} / {r.SMean:F0} / {r.SMax}** | {HistRow(r.SHist)} | **{r.NorthAll}**{(r.NorthAll != b.NorthAll ? " ⚠ MOVED" : "")} | {r.NMax} | {(r.Ok ? "pass" : "**FAIL**")} |");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteTable(string batchRoot, int mapSize, float[] ladder, int[] seeds, List<Row> rows, Dictionary<int, Row> baseline, long big,
|
||||||
|
float bandStart, float bandFeather, bool stretchSinker)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"# The hemisphere-split count/size table — {ladder.Length} stretch levels × {seeds.Length} seeds at {mapSize}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Band start {bandStart:F3} (row {(int)(bandStart * mapSize)}), feather {bandFeather:F3} — FIXED. Sinker {(stretchSinker ? "rides the stretched distance" : "on the real y")}.");
|
||||||
|
sb.AppendLine("Islands = non-mainland 8-connected land components of the classify field (region layer); hemisphere by centroid. Offshore OFF, speck revert OFF.");
|
||||||
|
sb.AppendLine("SOUTH = the fragmentation signal; NORTH = the should-stay-flat control (flagged if it moves).");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(TableMarkdown(ladder, seeds, rows, baseline, big));
|
||||||
|
WriteText(Path.Combine(batchRoot, "count_size_table.md"), sb.ToString());
|
||||||
|
|
||||||
|
var csv = new StringBuilder();
|
||||||
|
csv.AppendLine("level,stretch,seed,reach,median_coast,mainland_cells,mainland_south_of_band,south_all,south_big,s_min,s_median,s_mean,s_max,s_hist,north_all,north_big,n_max,oracle,ms");
|
||||||
|
var ic = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
|
foreach (int seed in seeds)
|
||||||
|
{
|
||||||
|
var all = new List<Row> { baseline[seed] }; all.AddRange(rows.FindAll(r => r.Seed == seed));
|
||||||
|
foreach (var r in all)
|
||||||
|
csv.AppendLine(string.Join(",", r.Level, r.Stretch.ToString("G5", ic), r.Seed, r.ReachFrac.ToString("F4", ic), r.MedianCoastFrac.ToString("F4", ic), r.MainlandCells, r.MainlandSouthOfBand,
|
||||||
|
r.SouthAll, r.SouthBig, r.SMin, r.SMed, r.SMean.ToString("F1", ic), r.SMax, "\"" + HistRow(r.SHist) + "\"", r.NorthAll, r.NorthBig, r.NMax, r.Ok ? "pass" : "FAIL", r.Ms));
|
||||||
|
}
|
||||||
|
WriteText(Path.Combine(batchRoot, "count_size_table.csv"), csv.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, float[] ladder, int[] seeds, List<Row> rows, Dictionary<int, Row> baseline, long big,
|
||||||
|
float bandStart, float bandFeather, bool stretchSinker, List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perField, bool allOk, string diagSummary)
|
||||||
|
{
|
||||||
|
int midLevel = ladder.Length / 2 + 1;
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("# Batch 08 — southern stretch, EXPLORATION: the fragmentation knob space");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("**A ladder, not a setting.** Inside a FIXED feathered latitude band the falloff's southward distance is compressed");
|
||||||
|
sb.AppendLine("(`y' = yB + (y − yB) / (1 + stretch · ramp)`), so the mainland reaches further south with the elevation of the rows it came");
|
||||||
|
sb.AppendLine("from, and where the stretched thin edge thins below sea it fragments organically. North of the band the classify field is");
|
||||||
|
sb.AppendLine("bit-locked in both directions (asserted). Nothing is stamped. The region layer is the instrument: SOUTH island count/size");
|
||||||
|
sb.AppendLine("is the fragmentation signal, NORTH is the should-stay-flat control.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⭐ Open this first");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"1. **`{seeds[0]}_stretch_{midLevel}/regions.png`** — the middle of the ladder on the first seed: grey mainland, each island its own colour.");
|
||||||
|
sb.AppendLine($"2. Walk the ladder on that seed: `{seeds[0]}_stretch_1/` … `_stretch_{ladder.Length}/` (`regions.png` beside `relief.png`).");
|
||||||
|
sb.AppendLine($"3. Then the same five on `{seeds[1]}` — what repeats is the knob; what does not is the seed.");
|
||||||
|
sb.AppendLine("4. Then the table: southern count/size down the rows, the northern control beside it.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The fixed frame and the axis");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"- **Band (constant for the batch):** start `{bandStart:F3}` of the map (row {(int)(bandStart * mapSize)} at {mapSize}), feather `{bandFeather:F3}` (smoothstep). Sea identity is hard above it; ramped across; extended below.");
|
||||||
|
sb.AppendLine($"- **Sinker:** {(stretchSinker ? "rides the stretched distance (pushed out with the geometry — held back inside the band)" : "on the real y (keeps pulling the extended mass down where it always did)")}.");
|
||||||
|
sb.AppendLine($"- **The axis — stretch strength:** {string.Join(" · ", Array.ConvertAll(ladder, v => v.ToString("G3")))} (levels 1–{ladder.Length}); baseline 0 measured for the control.");
|
||||||
|
sb.AppendLine($"- Every field: pass 1 + stretch, region labeling ON, offshore OFF, shelf OFF, speck revert OFF. \"big\" island = ≥ {big:N0} cells at {mapSize} (the 07 `threshold_mid`).");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"## ⭐ The hemisphere-split count/size table — {ladder.Length} levels × {seeds.Length} seeds at {mapSize}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(TableMarkdown(ladder, seeds, rows, baseline, big));
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Also as plain data: `count_size_table.md` / `.csv`.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The diagnosis (summary — full tables in `scratch/southern_diagnosis.md`)");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(diagSummary);
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("`ProvisionalEven`, flagged. The individually-coloured scheme is only the `regions.png` overlay.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## The oracle (asymmetric)");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Regressions (stretch OFF bit-identical everywhere; stretch ON at the ladder's top bit-identical NORTH OF THE BAND to the `terrain-curve-v1` dumps):");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(ShapingOracle.ToMarkdownTable(hard));
|
||||||
|
sb.AppendLine("Per field (north bit-locked classify p / render p2 · northern islands invariant q · centre-is-land m · tag/coastline k · classify b · determinism o):");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(ShapingOracle.ToMarkdownTable(perField));
|
||||||
|
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("| `regions.png`, `relief.png`, `INDEX.md`, `count_size_table.md` / `.csv`, `scratch/southern_diagnosis.md` | **keep** |");
|
||||||
|
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + code — large, clear freely |");
|
||||||
|
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Plates at {mapSize}, curve calibrated at {calibSize} with offshore off. {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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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 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 float[] EnvFloats(string k, float[] fallback)
|
||||||
|
{
|
||||||
|
string v = EnvStr(k, null);
|
||||||
|
if (v == null) return fallback;
|
||||||
|
var outp = new List<float>();
|
||||||
|
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
if (float.TryParse(part.Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float f)) outp.Add(f);
|
||||||
|
return outp.Count > 0 ? outp.ToArray() : 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/SouthernStretchTool.cs.uid
Normal file
1
Tools/Scripts/SouthernStretchTool.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cbo4e7he3x7o3
|
||||||
72
Tools/Scripts/TagOverlayRenderer.cs
Normal file
72
Tools/Scripts/TagOverlayRenderer.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The offshore TAG / HEMISPHERE debug overlay (chat2/05): mainland one tint, offshore-island
|
||||||
|
/// land tinted by hemisphere, the midline drawn — so the island count,
|
||||||
|
/// the N/S split and the tag's correctness are all visible at one glance.
|
||||||
|
///
|
||||||
|
/// ⚠ A DIAGNOSTIC, NOT A MAP. It draws the tag layer, which is DATA the shape pass set; it is
|
||||||
|
/// the one artifact in the batch that shows what a downstream consumer of the tag would see.
|
||||||
|
/// No hypsometry, no hillshade — three flat tints and some rings, on purpose.
|
||||||
|
///
|
||||||
|
/// Presentation only: it is handed arrays and returns a PNG. It cannot change them.
|
||||||
|
/// </summary>
|
||||||
|
public static class TagOverlayRenderer
|
||||||
|
{
|
||||||
|
private static readonly Color Sea = new(0.055f, 0.110f, 0.235f);
|
||||||
|
private static readonly Color Mainland = new(0.310f, 0.360f, 0.300f);
|
||||||
|
private static readonly Color IslandN = new(0.250f, 0.850f, 0.950f); // cool — north
|
||||||
|
private static readonly Color IslandS = new(0.980f, 0.600f, 0.200f); // warm — south
|
||||||
|
private static readonly Color Untagged = new(0.950f, 0.150f, 0.800f); // ⚠ land that is neither — must never appear
|
||||||
|
private static readonly Color Midline = new(0.700f, 0.720f, 0.760f);
|
||||||
|
private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
|
||||||
|
|
||||||
|
/// <param name="isMainlandLand">
|
||||||
|
/// Per cell, land that is NOT offshore (from the offshore-OFF field, so a tag bug cannot hide
|
||||||
|
/// by mis-tagging mainland). Null ⇒ derived as "land and not tagged", which is weaker.
|
||||||
|
/// </param>
|
||||||
|
public static void SavePng(float[,] height, bool[,] tag, byte[,] hemi, int mapSize, float sea,
|
||||||
|
int countN, int countS, string absolutePath)
|
||||||
|
{
|
||||||
|
var img = Image.CreateEmpty(mapSize, mapSize, false, Image.Format.Rgb8);
|
||||||
|
|
||||||
|
for (int x = 0; x < mapSize; x++)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < mapSize; y++)
|
||||||
|
{
|
||||||
|
Color c;
|
||||||
|
bool land = height[x, y] >= sea;
|
||||||
|
bool tagged = tag != null && tag[x, y];
|
||||||
|
|
||||||
|
if (!land) c = Sea;
|
||||||
|
else if (!tagged) c = Mainland;
|
||||||
|
else if (hemi == null) c = Untagged;
|
||||||
|
else c = hemi[x, y] switch
|
||||||
|
{
|
||||||
|
OffshoreAnalysis.HemiNorth => IslandN,
|
||||||
|
OffshoreAnalysis.HemiSouth => IslandS,
|
||||||
|
_ => Untagged,
|
||||||
|
};
|
||||||
|
img.SetPixel(x, y, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hemisphere midline — the tag's convention, drawn where it bites.
|
||||||
|
int mid = mapSize / 2;
|
||||||
|
for (int x = 0; x < mapSize; x += 3) img.SetPixel(x, mid, Midline);
|
||||||
|
|
||||||
|
// A legend that cannot be separated from the picture.
|
||||||
|
int s = mapSize >= 4096 ? 4 : 3;
|
||||||
|
int lh = TinyFont.Height(s) + 6;
|
||||||
|
TinyFont.Draw(img, "OFFSHORE TAG OVERLAY", 12, 12, s, Ink);
|
||||||
|
TinyFont.Draw(img, "GREY: MAINLAND CYAN: ISLAND N ORANGE: ISLAND S", 12, 12 + lh, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"ISLANDS: {countN} NORTH {countS} SOUTH - ALL ORGANIC, NONE FORCED", 12, 12 + lh * 2, s, Ink);
|
||||||
|
TinyFont.Draw(img, "N ABOVE THE LINE - S BELOW - Y RUNS SOUTH", 12, 12 + lh * 3, s, Ink);
|
||||||
|
|
||||||
|
Error err = img.SavePng(absolutePath);
|
||||||
|
if (err != Error.Ok) GD.PrintErr($"[TagOverlayRenderer] SavePng failed ({err}) for {absolutePath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/TagOverlayRenderer.cs.uid
Normal file
1
Tools/Scripts/TagOverlayRenderer.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dxsgyj0fg77rr
|
||||||
|
|
@ -145,13 +145,51 @@ namespace IslaApocalypse.Tools
|
||||||
public float ClimbFeather = 0.4f;
|
public float ClimbFeather = 0.4f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The summit's steepening, ≥ 1: the secant slope of the top 15 % of the climb, in units of
|
/// ⚠ chat2/02's ANALYTIC summit knob — SUPERSEDED by <see cref="PeakSharpness"/>.
|
||||||
/// the climb's average grade. 1 = a ramp (refused); 2.5 = the default pointed peak; higher =
|
///
|
||||||
/// more dramatic. The peak reads pointy, never a needle-on-a-hump — there is no plateau
|
/// It steepened the peak by pulling the summit ONSET DOWN, which lowered the whole mid-massif
|
||||||
/// under it any more.
|
/// with it: at 4.5 the p99 land height collapsed from 199 m to 121 m. A bad trade, and the
|
||||||
|
/// bug chat2/03 §3 exists to fix. It survives ONLY so the 02 curve stays reproducible as a
|
||||||
|
/// contrast variant; it is read only when <see cref="ClimbCalibration"/> is null.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float SummitDrama = 2.5f;
|
public float SummitDrama = 2.5f;
|
||||||
|
|
||||||
|
// ---- chat2/03: the CALIBRATED climb ---------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ The measured climb calibration. Non-null ⇒ the climb reproduces the staircase's
|
||||||
|
/// above-ceiling elevation distribution as a smooth slope. Null ⇒ chat2/02's analytic
|
||||||
|
/// feather/drama curve (kept only as the "before" contrast).
|
||||||
|
///
|
||||||
|
/// ⚠ Not a value knob — it is MEASURED, per calibration pool, by the batch tool. Two configs
|
||||||
|
/// may share one instance safely: it is immutable.
|
||||||
|
/// </summary>
|
||||||
|
public ClimbCalibration ClimbCalibration = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ How big the mountain is, relative to the staircase's.
|
||||||
|
///
|
||||||
|
/// 1.0 reproduce the staircase's mountain (the default — the least-surprising baseline)
|
||||||
|
/// >1 lift the mid-massif higher: more land at 150–300 m
|
||||||
|
/// <1 a smaller mountain, toward chat2/02's bottom-heavy climb
|
||||||
|
///
|
||||||
|
/// Applied as <c>v ← v^(1/lift)</c> on the calibrated control points: monotone, and it fixes
|
||||||
|
/// both endpoints, so it moves the massif without touching the lowland handover or the cap.
|
||||||
|
/// ⚠ It scales the CLIMB only. It cannot move a lowland cell — oracle (d) proves that.
|
||||||
|
/// </summary>
|
||||||
|
public float MountainLift = 1.0f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ How pointy the summit is — and, unlike <see cref="SummitDrama"/>, <b>nothing else</b>.
|
||||||
|
///
|
||||||
|
/// It reshapes only the span above the last measured percentile, leaving that percentile's
|
||||||
|
/// height fixed. Raising it therefore cannot reduce the land below the onset: peak sharpness
|
||||||
|
/// and mountain mass are independent knobs. → chat2/03 §3.
|
||||||
|
///
|
||||||
|
/// 1.0 = a straight run to the cap; higher defers the rise so the final approach steepens.
|
||||||
|
/// </summary>
|
||||||
|
public float PeakSharpness = 1.0f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ⭐ Pass 2a rung 2: the shelf detail passes — micro-relief skin + shelf-edge knot warp.
|
/// ⭐ Pass 2a rung 2: the shelf detail passes — micro-relief skin + shelf-edge knot warp.
|
||||||
/// ⚠ REQUIRES <see cref="Curve"/>: the edge warp slides the CURVE's knots, so with no curve
|
/// ⚠ REQUIRES <see cref="Curve"/>: the edge warp slides the CURVE's knots, so with no curve
|
||||||
|
|
@ -202,6 +240,161 @@ namespace IslaApocalypse.Tools
|
||||||
/// <summary>Crater centre Y, columns. Unused while <see cref="CraterRadius"/> is 0.</summary>
|
/// <summary>Crater centre Y, columns. Unused while <see cref="CraterRadius"/> is 0.</summary>
|
||||||
public float CraterCenterY = 0f;
|
public float CraterCenterY = 0f;
|
||||||
|
|
||||||
|
// ---- PASS 1b — the coast shelf + offshore islets (chat2/05) ----------
|
||||||
|
//
|
||||||
|
// ⚠⚠ BOTH DEFAULT OFF, DELIBERATELY — and that is a decision to revisit, not an oversight.
|
||||||
|
//
|
||||||
|
// Every oracle in this phase holds pass 1 against Phase 1's `.f32` dumps (curve-off ==
|
||||||
|
// `02_pass1_port`), and the curve tools hold it against task 01/03's. The shelf changes every
|
||||||
|
// below-sea cell and the islets ADD LAND, so the moment either defaults ON, every one of
|
||||||
|
// those regression anchors goes stale at once. The batch tools that want them turn them on
|
||||||
|
// explicitly. FLIPPING THESE DEFAULTS IS THE ACT THAT RETIRES THE PHASE-1 REGRESSION DUMPS —
|
||||||
|
// do it deliberately, in a task that re-baselines the oracles, not as a side effect here.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The submarine coast shelf (<c>IslandFalloff.CoastShelf</c>). Below-sea only,
|
||||||
|
/// depth-preserving, held strictly below sea by <c>MathF.BitDecrement</c>. Invisible until
|
||||||
|
/// water renders; ported faithfully now, judged then.
|
||||||
|
/// </summary>
|
||||||
|
public bool CoastShelf = false;
|
||||||
|
|
||||||
|
// ⚠ Fully qualified: this class's own `IslandFalloff` ablation toggle shadows the static
|
||||||
|
// type of the same name inside field initializers.
|
||||||
|
|
||||||
|
/// <summary>Shelf strength, 0 = off → 1 = a flat lagoon. Reference 0.775.</summary>
|
||||||
|
public float ShelfStrength = IslaApocalypse.Tools.IslandFalloff.SHELF_STRENGTH;
|
||||||
|
|
||||||
|
/// <summary>Metres of depth over which the shelf relaxes. Reference 100.</summary>
|
||||||
|
public float ShelfScaleM = IslaApocalypse.Tools.IslandFalloff.SHELF_SCALE_M;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ The offshore islet system — every dial in one object. <c>Mode = Off</c> by default
|
||||||
|
/// (see the note above). <see cref="OffshoreSettings.Faithful"/> is the reference verbatim;
|
||||||
|
/// <see cref="OffshoreSettings.Organic"/> is the reshape, tuned (chat2/06).
|
||||||
|
/// </summary>
|
||||||
|
public OffshoreSettings Offshore = new OffshoreSettings();
|
||||||
|
|
||||||
|
// ---- PASS 1c — region labeling + the speck revert (chat2/07) ----------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE REGION-LABELING LAYER (<c>Core.RegionLabeling</c>, via <c>RegionPass</c>): 8-connected
|
||||||
|
/// land components on the classify field, mainland = the centre component, the island tag BY
|
||||||
|
/// CONSTRUCTION. Pure analysis — it changes no height — so it is ON by default without touching
|
||||||
|
/// any regression anchor. Off ⇒ no tag, no region table (the tag arrays are null).
|
||||||
|
/// </summary>
|
||||||
|
public bool RegionLabeling = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE SPECK REVERT — lower every non-mainland land component smaller than
|
||||||
|
/// <see cref="MinLandComponentFrac"/> of the map to seabed. Origin-blind; lower-only and
|
||||||
|
/// component-only, asserted; mainland never a candidate.
|
||||||
|
///
|
||||||
|
/// ⚠ DEFAULT OFF IN THE BARE CONFIG, for exactly the reason the shelf and the islets are: the
|
||||||
|
/// raw field has small natural nubs, so with this ON the calibration pool's land histogram, the
|
||||||
|
/// curve knots and every Phase-1 / task-03 / task-04 regression dump would move at once.
|
||||||
|
/// The region batch turns it on explicitly (its preset is ON); flipping the bare default is
|
||||||
|
/// the act that re-baselines the oracles — own task, not a side effect.
|
||||||
|
/// </summary>
|
||||||
|
public bool SpeckRevert = false;
|
||||||
|
|
||||||
|
/// <summary>The revert threshold, as a fraction of the map's AREA (scale-free). → <see cref="RegionPass.ThresholdMidFrac"/>.</summary>
|
||||||
|
public float MinLandComponentFrac = RegionPass.ThresholdMidFrac;
|
||||||
|
|
||||||
|
// ---- PASS 1 — THE SOUTHERN STRETCH (chat2/08, exploration) ----------------
|
||||||
|
//
|
||||||
|
// ⚠ THE ONE DELIBERATE RELAXATION OF SEA IDENTITY — and only below a FIXED latitude band.
|
||||||
|
// Inside the band (feathered, keyed off a fixed y, never distance-from-coast) the falloff's
|
||||||
|
// southward distance is compressed: y' = yB + (y − yB) / (1 + stretch · ramp). The mask
|
||||||
|
// geometry is stretched south; the base noise, edge noise and latitude field are NOT — so
|
||||||
|
// the extended mass keeps the elevation/relief of the rows it came from (preserve height
|
||||||
|
// as the mass extends), and where the stretched thin edge thins below sea it fragments
|
||||||
|
// organically. Cells north of the band take the UNTOUCHED code path, so the classify field
|
||||||
|
// there is bit-identical by construction (asserted). Nothing is stamped.
|
||||||
|
|
||||||
|
/// <summary>⭐ THE SWEPT AXIS. 0 = off (bit-identical to the unstretched field everywhere). Stretch factor inside the band: 1 ⇒ the southward distance is halved, 3 ⇒ quartered.</summary>
|
||||||
|
public float SouthStretch = 0f;
|
||||||
|
|
||||||
|
/// <summary>The band's FIXED latitude line, fraction of the map (y runs south). Sea identity is hard above it. A constant for a whole batch.</summary>
|
||||||
|
public float SouthBandStartFrac = SouthernStretch.DefaultBandStartFrac;
|
||||||
|
|
||||||
|
/// <summary>The feather width across which the stretch ramps 0 → 1 (smoothstep), fraction of the map. A constant for a whole batch.</summary>
|
||||||
|
public float SouthBandFeatherFrac = SouthernStretch.DefaultBandFeatherFrac;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Does the SOUTHERN SINKER ride the stretched distance (true — it is part of the southern
|
||||||
|
/// geometry and is pushed out with it, i.e. held back inside the band) or the real y (false —
|
||||||
|
/// it keeps pulling the extended mass down where it always did)? The chat2/08 diagnostic
|
||||||
|
/// measured both; → <see cref="SouthernStretch"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool StretchSinker = SouthernStretch.DefaultStretchSinker;
|
||||||
|
|
||||||
|
// ---- PASS 1 — COASTAL FRAGMENTATION (chat2/09, exploration) ------------------
|
||||||
|
//
|
||||||
|
// A band-limited, zero-mean noise added to the PRE-power falloff only where the falloff sits in
|
||||||
|
// the coastal window (≈ the barely-land / barely-sea margin, around the whole perimeter). It
|
||||||
|
// self-targets thin necks: the cells closest to the sea threshold flip first, so lobes pinch off
|
||||||
|
// into islands while the interior — window weight exactly zero — is bit-identical by
|
||||||
|
// construction. Nothing is detected, nothing is stamped. → CoastalFragment.
|
||||||
|
|
||||||
|
/// <summary>⭐ THE SWEPT AXIS. 0 = off (bit-identical everywhere). Peak |Δfalloff| (pre-power) at the window's centre.</summary>
|
||||||
|
public float FragmentAmp = 0f;
|
||||||
|
|
||||||
|
/// <summary>The fragmentation noise's frequency, periods per map width — the neck/lobe scale. The secondary dial (fixed this round). → <see cref="CoastalFragment.DefaultFreqPerMapWidth"/>.</summary>
|
||||||
|
public float FragmentFreqPerMapWidth = CoastalFragment.DefaultFreqPerMapWidth;
|
||||||
|
|
||||||
|
/// <summary>The coastal window's centre and half-width in PRE-power falloff units. Weight 1 at the centre, smooth to 0 at ± half-width; exactly 0 beyond.</summary>
|
||||||
|
public float FragmentBandCentre = CoastalFragment.DefaultBandCentre;
|
||||||
|
public float FragmentBandHalfWidth = CoastalFragment.DefaultBandHalfWidth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// false (default) ⇒ zero-mean noise: the margin is redrawn — bites AND builds (which can also
|
||||||
|
/// bridge an island back onto the mainland). true ⇒ bites only ((noise+1)/2 ≥ 0): land can only
|
||||||
|
/// recede, necks are cut, nothing is bridged, the coast net-recedes. → <see cref="CoastalFragment"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool FragmentBitesOnly = CoastalFragment.DefaultBitesOnly;
|
||||||
|
|
||||||
|
// ---- PASS 2b — HYDRAULIC EROSION (chat2/11) — RENDER MAP ONLY ------------------
|
||||||
|
//
|
||||||
|
// The reference's droplet erosion, ported verbatim (Core.HydraulicErosion), run on the render
|
||||||
|
// field AFTER shaping (after detail, before the crater carve — which does not exist yet). The
|
||||||
|
// classify field never sees it (D-046); the caller's flood guard proves no waterline moved.
|
||||||
|
// ⚠ DEFAULT OFF in the bare config for the usual reason (regression anchors); the batch turns
|
||||||
|
// it on. The governors + physics are the reference ConfigManager's declared defaults, clamped
|
||||||
|
// as it clamped them (→ ErosionPass).
|
||||||
|
|
||||||
|
/// <summary>⭐ Erosion on/off. Render only. Default OFF (see above).</summary>
|
||||||
|
public bool Erosion = false;
|
||||||
|
|
||||||
|
/// <summary>Governor 1 — droplet count. Reference 250000, clamp [0, 50,000,000].</summary>
|
||||||
|
public int ErosionDropletCount = 250000;
|
||||||
|
/// <summary>Governor 2 — max steps per droplet. Reference 384, clamp [1, 4096].</summary>
|
||||||
|
public int ErosionDropletLifetime = 384;
|
||||||
|
/// <summary>Governor 3 — max carve per cell, metres (net ledger). Reference 15, clamp [0, 60].</summary>
|
||||||
|
public float ErosionCarveCapM = 15.0f;
|
||||||
|
/// <summary>Governor 4 — max build-up per cell, metres (the ledger read the other way). Reference 6, clamp [0, 60]; ≤ 0 = unbounded.</summary>
|
||||||
|
public float ErosionDepositCapM = 6.0f;
|
||||||
|
/// <summary>The sea clamp's carve floor above sea, metres. Reference 0.5, clamp [0, 5].</summary>
|
||||||
|
public float ErosionSeaMarginM = 0.5f;
|
||||||
|
/// <summary>Brush radius, px (the cone brush shared by erode and deposit). Reference 2.</summary>
|
||||||
|
public int ErosionBrushRadius = 2;
|
||||||
|
public float ErosionInertia = 0.35f; // clamp [0, 0.99]
|
||||||
|
public float ErosionCapacity = 4.0f;
|
||||||
|
public float ErosionMinSlopeM = 0.02f; // metres per px
|
||||||
|
public float ErosionErodeRate = 0.12f;
|
||||||
|
public float ErosionDepositRate = 0.15f;
|
||||||
|
public float ErosionEvaporation = 0.004f; // clamp [0, 0.5]
|
||||||
|
public float ErosionGravity = 4.0f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The crater exclusion (task 19), PORTED BUT INERT: with no crater (<see cref="CraterRadius"/> 0)
|
||||||
|
/// the weight is 1 everywhere. Core ×radius — the reference's ConfigManager shipped 0.80 (the
|
||||||
|
/// pass's own default constant is 0.50); feather ×radius 1.05; mode feather. Activates when
|
||||||
|
/// the crater carve lands; the reference's "core < carve factor" warning is dormant until then.
|
||||||
|
/// </summary>
|
||||||
|
public float CraterErosionCore = 0.80f;
|
||||||
|
public float CraterErosionFeather = 1.05f;
|
||||||
|
public bool CraterErosionFeatherMode = true;
|
||||||
|
|
||||||
/// <summary>A short label for this variant, used in output filenames. E.g. "full", "base_only".</summary>
|
/// <summary>A short label for this variant, used in output filenames. E.g. "full", "base_only".</summary>
|
||||||
public string VariantLabel = "full";
|
public string VariantLabel = "full";
|
||||||
|
|
||||||
|
|
@ -218,6 +411,7 @@ namespace IslaApocalypse.Tools
|
||||||
{
|
{
|
||||||
var c = (TerrainGenConfig)MemberwiseClone();
|
var c = (TerrainGenConfig)MemberwiseClone();
|
||||||
c.Anchors = Anchors?.Clone();
|
c.Anchors = Anchors?.Clone();
|
||||||
|
c.Offshore = Offshore?.Clone(); // same reason: a mutable dial object, deep-copied
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
using Godot;
|
using Godot;
|
||||||
using IslaApocalypse.Core;
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
|
@ -27,12 +28,13 @@ namespace IslaApocalypse.Tools
|
||||||
/// so it is a raw additive wall the exponent never softens. preTrenchFalloff is captured
|
/// so it is a raw additive wall the exponent never softens. preTrenchFalloff is captured
|
||||||
/// between them. Reordering any of it changes the island.
|
/// between them. Reordering any of it changes the island.
|
||||||
///
|
///
|
||||||
/// ═══ ⚠ WHAT IS DELIBERATELY NOT PORTED HERE ═══
|
/// ═══ PASS 1b — THE SHELF AND THE ISLETS (chat2/05) ═══
|
||||||
///
|
///
|
||||||
/// The reference's pass-1 loop continues past the height write with two more task-11 passes:
|
/// The reference's pass-1 loop continues past the height write with two more task-11 passes:
|
||||||
/// the submarine COAST SHELF (~:621-640) and the OFFSHORE ISLET layer (~:641-664). Both are
|
/// the submarine COAST SHELF (~:621-640) and the OFFSHORE ISLET layer (~:641-664). v2 runs them
|
||||||
/// DEFERRED to Phase 2 by the developer's ruling — they act only on below-sea height and are
|
/// as a second sweep over the finished arrays — <see cref="OffshorePass"/> — with the same
|
||||||
/// judged once water renders. <see cref="Pass1Result.PreTrenchFalloff"/> is exposed for them.
|
/// per-pixel arithmetic in the same order, and then recomputes <c>HMaxSeed</c> AFTER them, as
|
||||||
|
/// the reference did. Config-gated; both default off (see <c>TerrainGenConfig</c> for why).
|
||||||
///
|
///
|
||||||
/// Nothing from pass 2 is here at all: no redistribution curve, no shelf detail, no erosion,
|
/// Nothing from pass 2 is here at all: no redistribution curve, no shelf detail, no erosion,
|
||||||
/// no rivers, no water bodies, no crater carve, no biomes.
|
/// no rivers, no water bodies, no crater carve, no biomes.
|
||||||
|
|
@ -145,6 +147,18 @@ namespace IslaApocalypse.Tools
|
||||||
float hMax = float.MinValue;
|
float hMax = float.MinValue;
|
||||||
float hMin = float.MaxValue;
|
float hMin = float.MaxValue;
|
||||||
|
|
||||||
|
// ═══ COASTAL FRAGMENTATION (chat2/09) — its own deterministic field, precomputed ═══
|
||||||
|
bool fragOn = cfg.FragmentAmp > 0f && cfg.IslandFalloff;
|
||||||
|
FastNoiseLite fragNoise = fragOn
|
||||||
|
? TerrainNoise.CreateModulation(cfg.Seed, CoastalFragment.SeedOffset, cfg.FragmentFreqPerMapWidth, scale)
|
||||||
|
: null;
|
||||||
|
float fragOffset = scale.OffsetInMapWidths(CoastalFragment.OffsetMapWidths); // D-059: an offset in MAP WIDTHS, never raw pixels
|
||||||
|
|
||||||
|
// ═══ THE SOUTHERN STRETCH (chat2/08) — band constants, precomputed ═══
|
||||||
|
bool stretchOn = cfg.SouthStretch > 0f;
|
||||||
|
float bandStart = cfg.SouthBandStartFrac * mapSize;
|
||||||
|
float bandFeather = Mathf.Max(1f, cfg.SouthBandFeatherFrac * mapSize);
|
||||||
|
|
||||||
// ⚠ x IS THE OUTER LOOP, as in the reference. Numerically irrelevant here, but a
|
// ⚠ x IS THE OUTER LOOP, as in the reference. Numerically irrelevant here, but a
|
||||||
// PARALLEL port must reduce hMax/hMin rather than share them — noted before someone
|
// PARALLEL port must reduce hMax/hMin rather than share them — noted before someone
|
||||||
// reaches for Parallel.For and quietly races on the running max.
|
// reaches for Parallel.For and quietly races on the running max.
|
||||||
|
|
@ -173,6 +187,21 @@ namespace IslaApocalypse.Tools
|
||||||
lat += (latNoise * LatitudeWobbleSpan) - (LatitudeWobbleSpan / 2.0f);
|
lat += (latNoise * LatitudeWobbleSpan) - (LatitudeWobbleSpan / 2.0f);
|
||||||
latitudeField[x, y] = lat;
|
latitudeField[x, y] = lat;
|
||||||
|
|
||||||
|
// ═══ THE SOUTHERN STRETCH (chat2/08) — the y the MASK GEOMETRY sees ═══
|
||||||
|
//
|
||||||
|
// North of the band fy == y exactly and every expression below is the untouched
|
||||||
|
// original, so the classify field there is bit-identical by construction. Inside
|
||||||
|
// the band the southward distance is compressed by (1 + stretch · ramp); the base
|
||||||
|
// noise, edge noise and latitude field keep the real y. → SouthernStretch.
|
||||||
|
float fy = y; // the mask's y (squircle + ellipse)
|
||||||
|
float sy = y; // the sinker's y
|
||||||
|
if (stretchOn && y > bandStart)
|
||||||
|
{
|
||||||
|
float stretched = SouthernStretch.StretchedY(y, bandStart, bandFeather, cfg.SouthStretch);
|
||||||
|
fy = stretched;
|
||||||
|
if (cfg.StretchSinker) sy = stretched;
|
||||||
|
}
|
||||||
|
|
||||||
// ═══ 2. THE ISLAND FALLOFF / MASK (ref ~:567-574) ═══
|
// ═══ 2. THE ISLAND FALLOFF / MASK (ref ~:567-574) ═══
|
||||||
float finalFalloff = 0f;
|
float finalFalloff = 0f;
|
||||||
float squircleFalloff = 0f;
|
float squircleFalloff = 0f;
|
||||||
|
|
@ -182,11 +211,11 @@ namespace IslaApocalypse.Tools
|
||||||
// Squircle — Max(nx, ny), giving squared-off corners. ISLAND-anchored:
|
// Squircle — Max(nx, ny), giving squared-off corners. ISLAND-anchored:
|
||||||
// the axis ratios are applied here.
|
// the axis ratios are applied here.
|
||||||
float nx = Mathf.Abs(x - centerX) / (halfSpan * axisX);
|
float nx = Mathf.Abs(x - centerX) / (halfSpan * axisX);
|
||||||
float ny = Mathf.Abs(y - centerY) / (halfSpan * axisY);
|
float ny = Mathf.Abs(fy - centerY) / (halfSpan * axisY);
|
||||||
squircleFalloff = Mathf.Max(nx, ny);
|
squircleFalloff = Mathf.Max(nx, ny);
|
||||||
|
|
||||||
// Ellipse — vector length, giving a rounded shape.
|
// Ellipse — vector length, giving a rounded shape.
|
||||||
var ellipticalPos = new Vector2((x - centerX) / axisX, (y - centerY) / axisY);
|
var ellipticalPos = new Vector2((x - centerX) / axisX, (fy - centerY) / axisY);
|
||||||
float ellipticalFalloff = ellipticalPos.Length() / (mapSize / EllipseScaleDivisor);
|
float ellipticalFalloff = ellipticalPos.Length() / (mapSize / EllipseScaleDivisor);
|
||||||
|
|
||||||
// 50/50 blend.
|
// 50/50 blend.
|
||||||
|
|
@ -215,12 +244,29 @@ namespace IslaApocalypse.Tools
|
||||||
// Sinks the stretched land bridges in the bottom 25%. ⚠ BEFORE the power, so its
|
// Sinks the stretched land bridges in the bottom 25%. ⚠ BEFORE the power, so its
|
||||||
// effect is superlinear — +0.6 on a falloff already near 1 costs far more height
|
// effect is superlinear — +0.6 on a falloff already near 1 costs far more height
|
||||||
// than +0.6 near 0. Moving it after the power would change the southern coast.
|
// than +0.6 near 0. Moving it after the power would change the southern coast.
|
||||||
if (cfg.IslandFalloff && cfg.SouthernSinker && y > southThreshold)
|
if (cfg.IslandFalloff && cfg.SouthernSinker && sy > southThreshold)
|
||||||
{
|
{
|
||||||
float southDepth = (y - southThreshold) / (mapSize - southThreshold);
|
float southDepth = (sy - southThreshold) / (mapSize - southThreshold);
|
||||||
finalFalloff += southDepth * SouthSinkAmount;
|
finalFalloff += southDepth * SouthSinkAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ COASTAL FRAGMENTATION (chat2/09) — in the coastal window only, pre-power ═══
|
||||||
|
//
|
||||||
|
// window(falloff) is exactly zero where the falloff is clear of the coastal margin,
|
||||||
|
// so the interior never sees this term (bit-identical by construction); inside the
|
||||||
|
// window a zero-mean noise bites or builds the margin, and the thinnest necks — the
|
||||||
|
// cells nearest the sea threshold — flip first. → CoastalFragment.
|
||||||
|
if (fragOn)
|
||||||
|
{
|
||||||
|
float w = CoastalFragment.Window(finalFalloff, cfg.FragmentBandCentre, cfg.FragmentBandHalfWidth);
|
||||||
|
if (w > 0f)
|
||||||
|
{
|
||||||
|
float nz = fragNoise.GetNoise2D(x + fragOffset, y + fragOffset); // [-1, 1]
|
||||||
|
if (cfg.FragmentBitesOnly) nz = (nz + 1f) * 0.5f; // [0, 1] — bites only
|
||||||
|
finalFalloff += cfg.FragmentAmp * w * nz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ═══ ⭐ THE PHASE-2 SEAM — captured BEFORE the power and BEFORE the Trench ═══
|
// ═══ ⭐ THE PHASE-2 SEAM — captured BEFORE the power and BEFORE the Trench ═══
|
||||||
// (ref ~:591). See Pass1Result.PreTrenchFalloff for why this exact point.
|
// (ref ~:591). See Pass1Result.PreTrenchFalloff for why this exact point.
|
||||||
preTrenchFalloff[x, y] = finalFalloff;
|
preTrenchFalloff[x, y] = finalFalloff;
|
||||||
|
|
@ -279,14 +325,64 @@ namespace IslaApocalypse.Tools
|
||||||
if (finalH < hMin) hMin = finalH;
|
if (finalH < hMin) hMin = finalH;
|
||||||
height[x, y] = finalH;
|
height[x, y] = finalH;
|
||||||
|
|
||||||
// ⚠ THE REFERENCE'S PASS 1 CONTINUES HERE with the coast shelf (~:621-640) and
|
// The reference's pass 1 CONTINUED HERE with the coast shelf (~:621-640) and the
|
||||||
// the offshore islets (~:641-664). Both DEFERRED to Phase 2 — below-sea only,
|
// offshore islets (~:641-664). v2 runs them as PASS 1b, a second sweep over these
|
||||||
// judged once water renders. preTrenchFalloff above is their inlet.
|
// arrays, immediately below — same per-pixel arithmetic, same order, and the
|
||||||
|
// slop guards need the whole field to see whole islands. → OffshorePass.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ PASS 1b — THE COAST SHELF + OFFSHORE ISLETS (chat2/05) ═══
|
||||||
|
//
|
||||||
|
// In place, on `height`. Config-gated; returns null when both are off, in which case
|
||||||
|
// nothing above is touched and this pass-1 output is bit-identical to Phase 1's.
|
||||||
|
//
|
||||||
|
// ⚠⚠ HMaxSeed IS RECOMPUTED AFTER THIS — closing chat2/00 Drift §2. The reference took
|
||||||
|
// `_hMaxSeed` after the shelf and islets inside the same loop; v2 used to take it before
|
||||||
|
// they existed. The curve normalizes its summit spike against this value, so the order is
|
||||||
|
// load-bearing even when the number does not move (an islet crest is ~34 m; a peak is
|
||||||
|
// ~290 m). Both values are carried so the report states whether it moved, not guesses.
|
||||||
|
float hMaxBeforeOffshore = hMax;
|
||||||
|
OffshorePass.Result offshore = OffshorePass.Apply(height, preTrenchFalloff, mapSize, cfg.Seed, cfg.SeaLevel, cfg);
|
||||||
|
|
||||||
|
// ═══ PASS 1c — REGION LABELING + THE SPECK REVERT + THE ISLAND TAG (chat2/07) ═══
|
||||||
|
//
|
||||||
|
// The general region layer over the classify field (this array), then the origin-blind
|
||||||
|
// speck revert (config-gated, lower-only, component-only), then the island tag BY
|
||||||
|
// CONSTRUCTION from the finished labeling. Labeling alone changes nothing; only the revert
|
||||||
|
// may, and only downward, and only inside a sub-threshold non-mainland component. → RegionPass.
|
||||||
|
RegionPass.Result regions = cfg.RegionLabeling
|
||||||
|
? RegionPass.Apply(height, mapSize, cfg.SeaLevel, cfg)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (offshore != null || regions != null)
|
||||||
|
{
|
||||||
|
hMax = float.MinValue;
|
||||||
|
hMin = float.MaxValue;
|
||||||
|
for (int x = 0; x < mapSize; x++)
|
||||||
|
for (int y = 0; y < mapSize; y++)
|
||||||
|
{
|
||||||
|
float h = height[x, y];
|
||||||
|
if (h > hMax) hMax = h;
|
||||||
|
if (h < hMin) hMin = h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var notes = new List<string>();
|
||||||
|
if (offshore != null) notes.AddRange(offshore.Notes);
|
||||||
|
if (regions != null) notes.AddRange(regions.Notes);
|
||||||
|
|
||||||
return new Pass1Result(mapSize, cfg.Seed, height, preTrenchFalloff, latitudeField,
|
return new Pass1Result(mapSize, cfg.Seed, height, preTrenchFalloff, latitudeField,
|
||||||
hMax, hMin, Time.GetTicksMsec() - t0);
|
hMax, hMin, Time.GetTicksMsec() - t0,
|
||||||
|
hMaxSeedBeforeOffshore: hMaxBeforeOffshore,
|
||||||
|
isIsland: regions?.IsIsland,
|
||||||
|
islandHemisphere: regions?.IslandHemisphere,
|
||||||
|
offshoreLiftedCells: offshore == null ? 0 : offshore.LiftedOrganic - offshore.LiftedReverted,
|
||||||
|
notes: notes,
|
||||||
|
offshoreLedger: offshore?.ToLedger(),
|
||||||
|
regions: regions?.Labels,
|
||||||
|
regionLedger: regions?.Ledger,
|
||||||
|
regionsPre: regions?.LabelsPre);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue