Ports the reference's v5 height curve and shelf-detail passes onto Phase 1's shape and re-calibrates them against this repo's actual pass-1 distribution. This is the BASELINE the reshape gets judged against, not the reshape. Core (engine-free, D-060): - WorldScale — THE vertical yardstick. One metres/raw number (251), replacing the prototype's three duplicate M_PER_UNIT constants and ~20 bare literals. The chunk-height coupling it had there is recorded as a DEFERRED vault decision, not inherited. RawFromMetres divides, matching the reference bit-for-bit. - HeightCurve — the 7 bands, the frozen corner-fix blends, the per-seed spike normalization, the 24-corner monotonicity sweep that throws and refuses. Identity at and below sea, which everything downstream rests on. - CurveKnots / CurveAnchors — input knots (measured percentiles) and output anchors (storm ladder) split apart and both made parameters, so the anchors are A/B-able without editing source. The reference's shipped knots are kept beside the measured ones as the fidelity yardstick. - TerrainDetailPass — micro-relief skin plus the shelf-edge KNOT warp (which slides K3/K4/K5, not height — that is what keeps monotonicity structural). The crater exclusion is ported and inert until the carve lands. Tools: - Shaping — pass 2a, producing the two height fields. classify is bit-for-bit the raw pass-1 field; render is curved and detailed. Aliased when the curve is off, as the reference did. Pass1Result is left immutable so the oracle can compare. - LandHistogram — the calibration engine AND the diagnostic. The reference shipped six knot literals and threw the measuring instrument away; this rebuilds it. - ShapingOracle + CurveBaselineTool — four automatic checks before anything is looked at, and the batch that runs them. Measured, not assumed: - Knots re-measured over a 6-seed / 12.8M-sample pool. They differ from the reference's by at most 5.6 m of world height, against a 44.7 m per-seed spread — the pass-1 port is faithful. - Oracle all pass, including pass 1 bit-identical to Phase 1's own .f32 dump. - Band shares land on 60/13/10/5/8/3/1 to 0.00 pp. - Knots hold across map size: the 8K delta (5.8 m) sits inside seed noise. The finding the histograms deliver: 83% of land ends below 100 m and 96% below 220 m, with the median column at 13 m. That is the share targets doing exactly what they say, not a bug — and it is the developer's call, which is why nothing here reshapes it and the palette was deliberately left mis-fitted rather than recalibrated to disguise it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DCWNaDZPfTiAy3meGNGgqt
234 lines
9.5 KiB
C#
234 lines
9.5 KiB
C#
using System;
|
|
|
|
namespace IslaApocalypse.Tools
|
|
{
|
|
/// <summary>
|
|
/// ⭐ 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
|
|
/// <see cref="CalibrationBinWidth"/> 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 <c>Tools/</c> rather than <c>Core/</c> because it is a
|
|
/// MEASURING INSTRUMENT for the generator, not a contract about the world — the same reasoning
|
|
/// that keeps <c>IslandFalloff</c> here. Core carries what the world IS; Tools carries what we
|
|
/// point at it.
|
|
/// </summary>
|
|
public sealed class LandHistogram
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public const float CalibrationBinWidth = 1e-4f;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public const float DefaultTop = 4.0f;
|
|
|
|
/// <summary>Heights at or below this are not land and are not counted. The curve's identity threshold.</summary>
|
|
public readonly float SeaLevel;
|
|
|
|
/// <summary>Bin width in raw height units.</summary>
|
|
public readonly float BinWidth;
|
|
|
|
/// <summary>Top of the binned range; samples above it go to <see cref="OverflowCount"/>.</summary>
|
|
public readonly float Top;
|
|
|
|
private readonly long[] _counts;
|
|
|
|
/// <summary>Land samples at or above <see cref="Top"/>. ⚠ Reported, not hidden.</summary>
|
|
public long OverflowCount { get; private set; }
|
|
|
|
/// <summary>Total land samples accumulated, overflow included.</summary>
|
|
public long TotalLand { get; private set; }
|
|
|
|
/// <summary>Lowest and highest land sample seen, exactly (not bin-quantized).</summary>
|
|
public float MinLand { get; private set; } = float.MaxValue;
|
|
public float MaxLand { get; private set; } = float.MinValue;
|
|
|
|
/// <summary>How many fields have been pooled in. The calibration pool's size.</summary>
|
|
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)];
|
|
}
|
|
|
|
/// <summary>Number of bins (excluding overflow).</summary>
|
|
public int BinCount => _counts.Length;
|
|
|
|
/// <summary>Raw height at the low edge of bin <paramref name="i"/>.</summary>
|
|
public float BinLow(int i) => SeaLevel + i * BinWidth;
|
|
|
|
/// <summary>Sample count in bin <paramref name="i"/>.</summary>
|
|
public long BinCountAt(int i) => _counts[i];
|
|
|
|
/// <summary>
|
|
/// 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 <c>> SeaLevel</c>, matching the curve's own <c>h <= Sea → identity</c>
|
|
/// 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.
|
|
/// </summary>
|
|
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++;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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"/>.
|
|
///
|
|
/// ⚠ 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.
|
|
/// </summary>
|
|
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.");
|
|
}
|
|
|
|
/// <summary>Fraction of land strictly below <paramref name="h"/>, interpolated within the bin.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Fraction of land in <c>[lo, hi)</c>.</summary>
|
|
public double FractionBetween(float lo, float hi) => Math.Max(0.0, FractionBelow(hi) - FractionBelow(lo));
|
|
|
|
/// <summary>The tallest bin's count — the y-axis a plot needs.</summary>
|
|
public long PeakBinCount()
|
|
{
|
|
long peak = 0;
|
|
foreach (long c in _counts) if (c > peak) peak = c;
|
|
return peak;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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})";
|
|
}
|
|
}
|