using System; namespace IslaApocalypse.Tools { /// /// ⭐ THE LAND-HEIGHT DISTRIBUTION — one instrument doing two jobs. /// /// ═══ JOB 1: THE CALIBRATION ENGINE ═══ /// /// The redistribution curve's knots ARE percentiles of this distribution (P60/73/83/88/96/99). /// This class measures them. That is what makes the band shares 60/13/10/5/8/3/1 exact by /// construction rather than approximately right. /// /// > ⚠ The reference SHIPPED the six resulting literals and threw the instrument away. No /// > sampler, no histogram, no percentile helper survives at the tag — so its knots could never /// > be re-derived, only trusted. Rebuilding the measuring device is the point of this file: /// > a calibration you cannot re-run is a magic number with a good story. /// /// ═══ JOB 2: THE DIAGNOSTIC ═══ /// /// It is also what separates the three candidate causes of "flat, undramatic upper terrain", /// which look identical in a render and completely different here: /// /// 1. CURVE SQUASHING — the SHAPED histogram piles mass at the bench/plateau output /// heights. The curve is flattening ground that had relief. /// 2. NOISE EMPTY UP HIGH — the RAW histogram's top (above ~P96) is a thin sliver spread over /// a wide range. There is nothing up there to shape. /// 3. SHARE ALLOCATION — little land is TARGETED into plateau+spike (3 % + 1 %) by /// construction. The curve is doing exactly what it was told. /// /// These have different fixes — reshape the segments, change the noise, or reallocate the /// shares — so guessing which one it is costs a whole task. /// /// ═══ WHY A HISTOGRAM AND NOT A SORTED SAMPLE ARRAY ═══ /// /// Exact quantiles want every sample sorted; the reference pooled 340 M of them. At 4 bytes each /// that is 1.3 GB and a sort to match. A fine fixed-width histogram with IN-BIN LINEAR /// INTERPOLATION gives quantiles accurate to well under a bin, in flat memory, at any pool size, /// and streams across seeds without holding a single sample. At the default /// the resolution is 1e-4 raw ≈ 2.5 cm of world height — /// four decimal places on a knot, against reference literals quoted to six. /// /// ⚠ Engine-free (System only). It sits in Tools/ rather than Core/ because it is a /// MEASURING INSTRUMENT for the generator, not a contract about the world — the same reasoning /// that keeps IslandFalloff here. Core carries what the world IS; Tools carries what we /// point at it. /// public sealed class LandHistogram { /// /// Bin width for CALIBRATION, in raw height units. 1e-4 raw ≈ 2.5 cm — finer than any knot /// distinction that could matter, and 38,500 bins over the working range is 300 KB. /// public const float CalibrationBinWidth = 1e-4f; /// /// Top of the binned range, raw. Generous: pass-1 land maxes near 1.45 and the curve's tail /// can exceed the 420 m cap. Anything above lands in the overflow bin and is REPORTED, never /// silently dropped. /// public const float DefaultTop = 4.0f; /// Heights at or below this are not land and are not counted. The curve's identity threshold. public readonly float SeaLevel; /// Bin width in raw height units. public readonly float BinWidth; /// Top of the binned range; samples above it go to . public readonly float Top; private readonly long[] _counts; /// Land samples at or above . ⚠ Reported, not hidden. public long OverflowCount { get; private set; } /// Total land samples accumulated, overflow included. public long TotalLand { get; private set; } /// Lowest and highest land sample seen, exactly (not bin-quantized). public float MinLand { get; private set; } = float.MaxValue; public float MaxLand { get; private set; } = float.MinValue; /// How many fields have been pooled in. The calibration pool's size. public int FieldsPooled { get; private set; } public LandHistogram(float seaLevel, float binWidth = CalibrationBinWidth, float top = DefaultTop) { if (binWidth <= 0f) throw new ArgumentOutOfRangeException(nameof(binWidth), binWidth, "Bin width must be positive."); if (top <= seaLevel) throw new ArgumentOutOfRangeException(nameof(top), top, "Top must exceed sea level."); SeaLevel = seaLevel; BinWidth = binWidth; Top = top; _counts = new long[(int)MathF.Ceiling((top - seaLevel) / binWidth)]; } /// Number of bins (excluding overflow). public int BinCount => _counts.Length; /// Raw height at the low edge of bin . public float BinLow(int i) => SeaLevel + i * BinWidth; /// Sample count in bin . public long BinCountAt(int i) => _counts[i]; /// /// Pool one field's LAND samples in. Call repeatedly to build a multi-seed pool — the /// reference calibrated across a pooled batch, and one seed's distribution is not the /// island's. /// /// ⚠ Strictly > SeaLevel, matching the curve's own h <= Sea → identity /// test. A pixel exactly at sea is not land, and counting it would put a spike in bin 0 that /// drags every low percentile down. /// public void Accumulate(float[,] field, int mapSize) { for (int x = 0; x < mapSize; x++) { for (int y = 0; y < mapSize; y++) { 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++; } /// /// 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. /// 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++; } /// /// The quantile at (0..100) — a raw height, interpolated inside /// its bin so the answer is not quantized to . /// /// ⚠ Throws on an empty pool rather than returning sea level. An all-ocean seed silently /// calibrating every knot to 0.15 is exactly the kind of quiet nonsense that ships. /// public float Quantile(double percent) { if (TotalLand == 0) throw new InvalidOperationException( "[LandHistogram] No land samples pooled — cannot take a quantile. " + "Check the sea level and that pass 1 actually produced an island."); if (percent < 0.0 || percent > 100.0) throw new ArgumentOutOfRangeException(nameof(percent), percent, "A percentile is 0..100."); double target = percent / 100.0 * TotalLand; long cum = 0; for (int i = 0; i < _counts.Length; i++) { long c = _counts[i]; if (c == 0) continue; if (cum + c >= target) { // Linear position inside the bin: the samples in it are assumed uniform, which // is the standard histogram-quantile assumption and is a sub-bin error. double within = (target - cum) / c; return BinLow(i) + (float)(within * BinWidth); } cum += c; } // Only reachable when the quantile falls in the overflow — a real answer we cannot // resolve, so say so rather than returning Top as if it were measured. throw new InvalidOperationException( $"[LandHistogram] P{percent} falls above the binned range (top {Top}); " + $"{OverflowCount} of {TotalLand} samples overflowed. Raise `top` and re-measure."); } /// Fraction of land strictly below , interpolated within the bin. public double FractionBelow(float h) { if (TotalLand == 0) return 0.0; if (h <= SeaLevel) return 0.0; int bin = (int)((h - SeaLevel) / BinWidth); if (bin >= _counts.Length) return 1.0; long cum = 0; for (int i = 0; i < bin; i++) cum += _counts[i]; double within = (h - BinLow(bin)) / BinWidth; return (cum + within * _counts[bin]) / TotalLand; } /// Fraction of land in [lo, hi). public double FractionBetween(float lo, float hi) => Math.Max(0.0, FractionBelow(hi) - FractionBelow(lo)); /// The tallest bin's count — the y-axis a plot needs. public long PeakBinCount() { long peak = 0; foreach (long c in _counts) if (c > peak) peak = c; return peak; } /// /// Re-bin into a coarser histogram for DISPLAY. The calibration histogram has 38,500 bins; /// a plot has room for a few hundred, and drawing one bin per pixel column of a 1,200 px plot /// would alias the distribution into noise. /// public LandHistogram Rebin(float displayBinWidth) { var outH = new LandHistogram(SeaLevel, displayBinWidth, Top) { TotalLand = TotalLand, OverflowCount = OverflowCount, MinLand = MinLand, MaxLand = MaxLand, FieldsPooled = FieldsPooled, }; for (int i = 0; i < _counts.Length; i++) { if (_counts[i] == 0) continue; // Bin centre, so a sample does not systematically bias to the low edge. int j = (int)((BinLow(i) + BinWidth * 0.5f - SeaLevel) / displayBinWidth); if (j >= outH._counts.Length) outH.OverflowCount += _counts[i]; else outH._counts[j] += _counts[i]; } return outH; } public override string ToString() => $"LandHistogram({TotalLand:N0} land samples from {FieldsPooled} field(s), " + $"[{MinLand:F4} .. {MaxLand:F4}] raw, bin {BinWidth:G3}, overflow {OverflowCount})"; } }