islaApocalypse-v2/Core/Scripts/TerrainDetailPass.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

182 lines
9.4 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
namespace IslaApocalypse.Core
{
/// <summary>
/// The terrain DETAIL passes — the two things that make a redistributed shelf read as ground
/// rather than as a terrace. Ported from <c>REFERENCE:Tools/Scripts/TerrainDetailPass.cs</c>
/// (v1) at tag <c>pre-rewrite-reference</c> (<c>ab78883</c>).
///
/// ═══ TWO PASSES, AND THEY DO DIFFERENT KINDS OF THING ═══
///
/// PASS A — SHELF MICRO-RELIEF. A medium-frequency noise skin (±3 m by default) added to the
/// OUTPUT height, weighted by shelf-ness. The curve compresses the shelves flat; this gives
/// them their rolling texture back. Risers and peaks are untouched by construction.
///
/// PASS B — SHELF-EDGE VARIATION. A per-column shift of the shelf/riser KNOT BLOCK (K3/K4/K5)
/// by a low-frequency field.
///
/// > ### ⚠⚠ PASS B IS NOT HEIGHT PERTURBATION, AND THE DIFFERENCE IS THE WHOLE POINT.
/// >
/// > Pass A moves a column's OUTPUT HEIGHT. Pass B moves WHERE THE BANDS ARE for that column.
/// >
/// > Every shelf↔riser boundary is the contour where the raw height crosses one of K3/K4/K5. Slide
/// > those knots per column and the contour stops tracing a clean iso-height line: the shelf edge
/// > scallops into coves, notches and peninsulas. Do it by perturbing height instead and you get
/// > a fuzzy terrace edge, not an organic one — and you lose monotonicity as a STRUCTURAL
/// > property, because the curve is monotonic for any ordered knot set but nothing is monotonic
/// > after arbitrary additive noise.
/// >
/// > The amplitude is therefore stated in METRES OF INPUT HEIGHT (raw × the yardstick): how far a
/// > boundary contour is displaced in raw-height terms, NOT an output elevation change. What the
/// > eye sees is the LATERAL wander — that displacement divided by the local raw gradient.
/// > Measured on the reference (seed 1375359975): |∇raw| at the K3/K4/K5 contours is p50 0.00088
/// > raw/px, so 12 m of input height buys a median peak displacement of ~54 px and a mean of ~8 px
/// > along the boundary. 5 m — the first attempt — moved it a mean 3.7 px and was invisible at
/// > map scale.
///
/// ═══ ⚠ OUTPUT-HEIGHT ONLY. THE CLASSIFY FIELD NEVER SEES EITHER PASS. ═══
///
/// Both are consumers of the raw height, never producers of it. That is what keeps the classify
/// map — and every biome and water body that will later be derived from it — invariant under
/// every detail change.
///
/// ═══ WHAT THIS FILE DELIBERATELY DOES NOT CONTAIN ═══
///
/// The reference's task-10 draft also carried D8 flow routing, accumulation and drainage
/// INCISION. It shipped, produced the canonical grid artifact — thousands of straight,
/// disconnected, pooling scratches along the D8 neighbour directions — and was reverted whole.
/// → `Design - Terrain - D8 Incision Revert.md`. D8 returns later as ANALYSIS only.
///
/// Engine-free (System.MathF), so it sits in <c>Core/</c> beside <see cref="HeightCurve"/>: it
/// reads <see cref="CurveKnots"/> and is meaningless apart from the curve it warps.
/// </summary>
public static class TerrainDetailPass
{
/// <summary>Detail body version — the identity of this pass's layout.</summary>
public const ushort Version = 1;
// ---- pass A: micro-relief -------------------------------------------
/// <summary>Micro-relief amplitude, metres of OUTPUT height. Reference default: 3 m.</summary>
public const float ReliefAmpDefaultM = 3f;
/// <summary>
/// Micro-relief frequency, periods per MAP WIDTH. Reference: 40 — about 40 undulations
/// across the island (~200 m features at 8K). ⚠ Scale-safe by construction: stated per map
/// width, so the feature SIZE in metres holds at every map profile.
/// </summary>
public const float ReliefFreqPerMapWidth = 40f;
/// <summary>Micro-relief field seed offset. Reference: 7409. ⚠ A SEED offset, not a coordinate offset.</summary>
public const int ReliefSeedOffset = 7409;
// ---- pass B: shelf-edge variation -----------------------------------
/// <summary>Edge-warp amplitude, metres of INPUT height. Reference default: 12 m. See the type header.</summary>
public const float EdgeAmpDefaultM = 12f;
/// <summary>
/// Edge-warp frequency, periods per MAP WIDTH. Reference: 20 — ~410 px wavelength at 8K,
/// coves and notches at the scale of the developer's sketch, not a fringe of teeth.
/// </summary>
public const float EdgeFreqPerMapWidth = 20f;
/// <summary>Edge-warp field seed offset. Reference: 7507. ⚠ A SEED offset, not a coordinate offset.</summary>
public const int EdgeSeedOffset = 7507;
/// <summary>
/// The warp bound, as a fraction of the smaller adjacent band. Reference: 2/3.
///
/// ⚠ THE REAL CONSTRAINT IS BAND SQUEEZE; KNOT ORDERING FOLLOWS FROM IT. The shift compresses
/// whichever of the foothill riser / plateau it moves into. Bounding it at 2/3 of the smaller
/// band means that band never compresses below a THIRD of its nominal width — its slope never
/// more than triples, even where peak noise lands exactly on a boundary. Ordering
/// (K2 &lt; K3±d, K5±d &lt; K6) is then automatic, with a third of each band to spare.
/// </summary>
public const float EdgeSafetyFraction = 2f / 3f;
// ---- the crater's precedence ----------------------------------------
//
// ⚠ INERT UNTIL THE CRATER CARVE LANDS. No crater exists in this phase, so
// CraterDetailWeight is called with a non-positive radius and returns 1 everywhere — detail
// applies unmasked. The logic is ported now rather than later because it is part of THIS
// pass's contract, and bolting it on after the carve arrives is how the reference's 532-px
// bug happened in the first place. It is exercised when the carve lands.
/// <summary>
/// Detail exclusion radius, as a factor of CraterRadius. Reference: 0.80 — EXACTLY the
/// carve's own extent (<c>CRATER_CARVE_FACTOR</c>), so the two agree by construction rather
/// than by two constants that happen to match.
/// </summary>
public const float CraterDetailExclFactor = 0.80f;
/// <summary>Detail feather-to-full radius, factor of CraterRadius. Reference: 1.05.</summary>
public const float CraterDetailFeatherFactor = 1.05f;
/// <summary>
/// Detail weight from distance to the impact centre: 0 inside the carve, 1 well outside it,
/// linear between.
///
/// ═══ WHY IT EXISTS — measured, not precautionary ═══
///
/// Without it, detail moves a column's PRE-carve height, the carve's Lerp passes a fraction
/// of that through, and columns sitting a metre or two above sea inside the bowl get pushed
/// UNDER it: 532 px on reference seed 1158286446 in the first batch — terrain below the sea
/// scalar that the (classify-driven, and correctly unchanged) water grid calls dry. The carve
/// is the final authority on its own terrain; detail yields to it.
/// </summary>
/// <param name="craterRadius">
/// The configured crater radius. ⚠ <b>Non-positive means NO CRATER EXISTS</b> — returns 1
/// (detail unmasked). That is this phase's state, and it is a defined case, not a fallthrough.
/// </param>
public static float CraterDetailWeight(float distToCrater, float craterRadius)
{
if (craterRadius <= 0f) return 1f; // no crater in this phase — see the note above
float excl = craterRadius * CraterDetailExclFactor;
if (distToCrater <= excl) return 0f;
float feather = craterRadius * CraterDetailFeatherFactor;
if (distToCrater >= feather) return 1f;
return (distToCrater - excl) / (feather - excl);
}
/// <summary>
/// The largest per-column knot shift this knot set allows — see <see cref="EdgeSafetyFraction"/>.
/// K1/K2/K6 never move, so the toe, the orange/red bands and the summit spike are
/// bit-identical whatever the warp does; that is what makes the red-ceiling floor and the
/// peak cap exact rather than statistical.
/// </summary>
public static float MaxEdgeShift(CurveKnots k)
=> EdgeSafetyFraction * MathF.Min(k.K3 - k.K2, k.K6 - k.K5);
/// <summary>
/// Shelf-ness weight from the RAW input height: 1 mid-shelf, feathering to 0 through the
/// risers. Covers both shelves (bench and plateau).
///
/// ⚠ <paramref name="edgeShift"/> must be the SAME per-column warp the curve was evaluated
/// with, so the micro-relief skin follows the shelf wherever pass B moved its boundary.
/// Passing 0 here while the curve got a shift puts the skin on the wrong ground.
///
/// ⚠ Note the asymmetry in the second call: K6 is NOT shifted, because K6 never moves.
/// </summary>
public static float ShelfWeight(float raw, CurveKnots k, float edgeShift)
=> MathF.Max(BandBump(raw, k.K3 + edgeShift, k.K4 + edgeShift),
BandBump(raw, k.K5 + edgeShift, k.K6));
/// <summary>
/// A trapezoid over one band: full inside the central 60 %, linear feather to zero at 130 %
/// of the band half-width — so the skin dies out inside the risers rather than at the exact
/// band edge, which would put a visible seam on the boundary the warp is busy hiding.
/// </summary>
private static float BandBump(float h, float lo, float hi)
{
float half = (hi - lo) * 0.5f;
if (half <= 0f) return 0f; // degenerate band — no skin, no divide by zero
float t = MathF.Abs(h - (lo + half)) / half; // 0 at centre, 1 at the band edge
return Math.Clamp(1f - (t - 0.6f) / 0.7f, 0f, 1f);
}
}
}