islaApocalypse-v2/Core/Scripts/CurveAnchors.cs
beezm 35b4818e9e Phase 2a: the faithful redistribution curve, re-measured against v2's own output
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
2026-08-20 01:38:10 -04:00

142 lines
6.8 KiB
C#

namespace IslaApocalypse.Core
{
/// <summary>
/// The redistribution curve's fixed OUTPUT anchors — the elevations the bands are mapped ONTO.
///
/// ═══ INPUT KNOTS vs OUTPUT ANCHORS — the distinction the whole curve rests on ═══
///
/// <see cref="CurveKnots"/> — WHERE the land distribution is cut. Percentiles. Measured.
/// <see cref="CurveAnchors"/> — WHAT HEIGHT each cut lands at. Storm-ladder. Chosen.
///
/// Re-measuring the knots moves how much land is in each band. Moving the anchors moves how HIGH
/// each band sits. They are independent, and conflating them is how a "recalibration" turns into
/// an unnoticed reshape.
///
/// ═══ ⚠ WHY THIS IS A PARAMETER OBJECT AND NOT A WALL OF CONSTANTS ═══
///
/// The reference held these as <c>const</c> fields on <c>HeightCurve</c> and passed only the
/// per-column modulated values (<c>benchLo</c>, <c>benchSpan</c>, …) as arguments. That made the
/// storm-ladder anchors unreachable from config: A/B-ing the 420 m cap meant editing and
/// rebuilding.
///
/// Here every anchor is an explicit parameter, in the spirit of D-035 ("every per-column input
/// and the knot set are explicit PARAMETERS"). <see cref="Default"/> reproduces the reference's
/// constants bit-for-bit, so this is an exposure, not a change.
///
/// ⚠⚠ THE BAND COUNT AND THE SEGMENT SHAPES ARE NOT EXPOSED, DELIBERATELY. Adding a knot,
/// steepening a segment or reallocating the shares is the RESHAPE — a later task with its own
/// gate. This type exposes the existing seven-band curve's dials and nothing more.
///
/// Every metre-denominated anchor is derived through <see cref="WorldScale.RawFromMetres"/> —
/// the single yardstick — never a literal <c>/251f</c>.
/// </summary>
public sealed class CurveAnchors
{
// ---- the frozen band ceilings (raw height units) ---------------------
/// <summary>
/// Sea level in raw units. ⚠ ALSO THE CURVE'S IDENTITY THRESHOLD: at and below this the
/// curve returns its input untouched, which is what keeps the waterline, the Trench
/// guarantee and (later) every water body invariant under the curve. Reference: 0.15f.
/// </summary>
public float Sea = 0.15f;
/// <summary>Top of the toe/orange band. Reference: 0.206f.</summary>
public float OrangeCeil = 0.206f;
/// <summary>Top of the red band — the floor the foothill riser climbs from. Reference: 0.27f.</summary>
public float RedCeil = 0.27f;
// ---- the modulated shelf anchors -------------------------------------
/// <summary>Bench centre, raw. Reference: <c>SEA + 100 m</c>.</summary>
public float BenchBase = 0.15f + WorldScale.RawFromMetres(100f);
/// <summary>Bench modulation amplitude, raw. Reference: <c>±12 m</c>.</summary>
public float BenchAmp = WorldScale.RawFromMetres(12f);
/// <summary>Plateau centre, raw. Reference: <c>SEA + 220 m</c>.</summary>
public float PlateauBase = 0.15f + WorldScale.RawFromMetres(220f);
/// <summary>Plateau modulation amplitude, raw. Reference: <c>±20 m</c>.</summary>
public float PlateauAmp = WorldScale.RawFromMetres(20f);
/// <summary>
/// Narrowest a shelf band may be, raw. Reference: <c>6 m</c> (v4 was 2 m — "corner fix 3":
/// a pronounced shelf keeps a gentle tilt, flat to build on but never snooker-table flat).
/// </summary>
public float ShelfSpanMin = WorldScale.RawFromMetres(6f);
/// <summary>Widest a shelf band may be, raw. Reference: 0.10f (≈ 25 m).</summary>
public float ShelfSpanMax = 0.10f;
// ---- the ceiling and its tail ---------------------------------------
/// <summary>
/// The peak cap, raw. Reference: <c>SEA + 420 m</c>. The summit spike maps
/// <c>[K6, spikeMax]</c> onto <c>[plateauTop, PeakCap]</c>, so this is the island's
/// nominal ceiling — exact, not statistical, because K6 never moves under the edge warp.
/// </summary>
public float PeakCap = 0.15f + WorldScale.RawFromMetres(420f);
/// <summary>
/// Slope above <c>spikeMax</c>. Reference: 0.25f. ⚠ A gentle TAIL, not a hard clip — a seed
/// whose max exceeds the spike range still rises, just slowly.
/// </summary>
public float TailSlope = 0.25f;
/// <summary>
/// Minimum spike span, raw. Reference: 0.01f. Guarantees a non-degenerate summit band on a
/// seed whose map-wide max lands at or below K6.
/// </summary>
public float SpikeMinSpan = 0.01f;
// ---- the modulation fields' identity ---------------------------------
//
// ⚠ THESE ARE SEED OFFSETS, NOT COORDINATE OFFSETS. The reference decorrelated its curve
// modulation fields by seeding each one at `resolvedSeed + offset` and sampling all of them
// at the bare (x, y) — there is no `GetNoise2D(x + 1000, …)` anywhere in this path. So the
// raw-pixel-offset hazard GenerationScale warns about does NOT apply here, and there is
// nothing to normalize. (Verified against every MakeModulationNoise call site; recorded
// because the absence of a bug is only reassuring if someone checked.)
/// <summary>Bench-anchor field seed offset. Reference: 7101.</summary>
public int BenchSeedOffset = 7101;
/// <summary>Plateau-anchor field seed offset. Reference: 7207.</summary>
public int PlateauSeedOffset = 7207;
/// <summary>Shelf-strength field seed offset. Reference: 7303.</summary>
public int StrengthSeedOffset = 7303;
/// <summary>
/// Anchor-field frequency, in periods per MAP WIDTH. Reference: 3.0f — a very low frequency,
/// so the bench and plateau elevations drift across the island rather than flickering.
/// ⚠ Already scale-safe by construction: stated per map width, not per pixel.
/// </summary>
public float ElevFreqPerMapWidth = 3.0f;
/// <summary>Shelf-strength field frequency, periods per map width. Reference: 5.0f.</summary>
public float StrengthFreqPerMapWidth = 5.0f;
/// <summary>
/// The reference's shipped anchors, reproduced bit-for-bit. Every metre value goes through
/// <see cref="WorldScale.RawFromMetres"/>, which divides — matching the reference's
/// <c>420f / 251f</c> exactly rather than approximating it with a reciprocal multiply.
/// </summary>
public static CurveAnchors Default => new CurveAnchors();
public CurveAnchors Clone() => (CurveAnchors)MemberwiseClone();
/// <summary>
/// The anchors as the storm ladder states them — metres above sea. For a run header, where
/// raw units mean nothing to a reader.
/// </summary>
public string DescribeMetres() =>
$"sea {Sea:F3} raw · orange {WorldScale.MetresFromRaw(OrangeCeil - Sea):F0} m · " +
$"red {WorldScale.MetresFromRaw(RedCeil - Sea):F0} m · " +
$"bench {WorldScale.MetresFromRaw(BenchBase - Sea):F0}±{WorldScale.MetresFromRaw(BenchAmp):F0} m · " +
$"plateau {WorldScale.MetresFromRaw(PlateauBase - Sea):F0}±{WorldScale.MetresFromRaw(PlateauAmp):F0} m · " +
$"cap {WorldScale.MetresFromRaw(PeakCap - Sea):F0} m";
}
}