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

141 lines
7.2 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 SCALING DISCIPLINE, IN CODE. → `Design - Tooling - Scaling Discipline.md`, D-002.
///
/// ═══ THE RULE ═══
///
/// NOTHING IN THE GENERATOR USES A RAW PIXEL NUMBER.
/// Every distance, radius, threshold and noise frequency is a FRACTION OF MapSize,
/// or derived from ScaleFactor.
///
/// ═══ WHY IT IS A RULE AND NOT A PREFERENCE ═══
///
/// These failures are SILENT. A hardcoded pixel count does not throw — the generator still
/// runs and still produces a map, and the map is just subtly wrong in ways that are hard to
/// attribute. The prototype learned this the expensive way: moving to 1:1 scale made the world
/// four times wider in pixels while the noise settings stayed put, and the terrain became
/// "TV static" — the same features packed into a quarter of the space. The same transition
/// broke a long tail of checks like "look 10 pixels away to see if there's water", which
/// quietly started measuring a quarter of the distance they used to.
///
/// In the developer's own words: "These kind of numbers I'm getting tired of changing because
/// of map size. We need to make sure these scale!"
///
/// ═══ ⚠ ONE BASELINE, DELIBERATELY ═══
///
/// The prototype ran TWO reference scales side by side: /1024 for noise and map drawing,
/// /4096 for town counts and road repulsion. Neither was wrong, but a reader — or a resize —
/// had to know which applied where, and the design doc's standing instruction is to "pick
/// deliberately and say which". This repo picks: ONE baseline, 1024, for everything. If a
/// second is ever genuinely needed it gets its own named type and its own reason, not a
/// bare divisor at a call site.
///
/// ═══ ⚠⚠ THE TIGHTENING OVER THE REFERENCE — NORMALIZED ADDITIVE OFFSETS ═══
///
/// The reference decorrelated noise layers with offsets in RAW PIXELS — GetNoise2D(x + 1000, …),
/// GetNoise2D(x * 1.5f + 5000, …). Because frequency already carries a 1/MapSize normalization,
/// a pixel-space offset does NOT hold still across map sizes: 1000 px is 1.0 normalized units
/// at 4K but 0.5 at 8K, so a resize silently samples a DIFFERENT SLICE of the noise field. The
/// feature scale is preserved; the REALIZATION is not. The same seed at two map sizes gives two
/// different worlds, for no reason anyone wrote down. (Found by the chat1/00 reference
/// inventory, §4.2 — where it also corrected the vault's standing diagnosis that the detail
/// noises were unscaled. They are scaled; it is the OFFSETS that are not.)
///
/// So in this repo: DECORRELATION OFFSETS ARE DECLARED IN MAP WIDTHS, via
/// <see cref="OffsetInMapWidths"/>. Never write a bare pixel constant into a noise coordinate.
///
/// No noise is built this phase. The convention is established before the generator exists so
/// that nothing is ever added unscaled — which is the only time this rule is cheap to hold.
/// </summary>
public readonly struct GenerationScale
{
/// <summary>
/// The reference map width the scaling baseline is anchored to. The world's noise looked
/// right at 1024 columns; every larger map stretches its features back out to match.
/// </summary>
public const int BaselineMapSize = 1024;
/// <summary>
/// The map's side in columns. 1 column = 1 metre (D-002, 1:1 scale).
///
/// ⚠ A GENERATION PARAMETER. Never a constant, never baked in. The rewrite's default target
/// is 10k x 10k, configurable 8k12k (D-056) — but that is a DEFAULT chosen by config, and
/// no code below this line may assume it.
/// </summary>
public readonly int MapSize;
public GenerationScale(int mapSize)
{
if (mapSize < BaselineMapSize)
throw new ArgumentOutOfRangeException(nameof(mapSize), mapSize,
$"MapSize must be at least the {BaselineMapSize} baseline.");
MapSize = mapSize;
}
/// <summary>
/// MapSize / 1024. Divide a baseline-tuned noise frequency by this to hold feature size
/// constant in METRES as the map grows. A mountain range is the same physical size at any
/// map profile — which is what makes the map-size setting safe to change at all.
/// </summary>
public float ScaleFactor => MapSize / (float)BaselineMapSize;
/// <summary>
/// A distance, from a fraction of the map. Use this instead of writing a pixel count.
/// <c>Fraction(0.75f)</c> is "three quarters of the way across", at every map size.
/// </summary>
public float Fraction(float fractionOfMap) => fractionOfMap * MapSize;
/// <summary>
/// The inverse: what fraction of the map a distance in columns represents. For turning a
/// measured pixel distance back into a scale-free constant before you commit it to code.
/// </summary>
public float AsFraction(float distanceInColumns) => distanceInColumns / MapSize;
/// <summary>
/// A noise frequency, from one tuned at the 1024 baseline.
/// <c>NoiseFrequency(0.004f)</c> reproduces the reference's base terrain frequency at any
/// map size. → the reference's <c>0.004f / scaleFactor</c>, chat1/00 §2.1.
/// </summary>
public float NoiseFrequency(float baselineFrequency) => baselineFrequency / ScaleFactor;
/// <summary>
/// A noise frequency stated as PERIODS PER MAP WIDTH — the reference's second frequency
/// convention, used by every curve/detail modulation field.
///
/// <c>NoiseFrequencyPerMapWidth(40f)</c> gives ~40 undulations across the island at any map
/// size, which is exactly what the reference's <c>periodsPerIsland / MapSize</c> computed.
///
/// ═══ ⚠ WHY THIS IS NOT A SECOND BASELINE ═══
///
/// It carries no baseline at all, so it does not reopen the /1024-vs-/4096 question this type
/// exists to close. The two conventions answer different questions and both are scale-safe:
///
/// <see cref="NoiseFrequency"/> "the frequency that looked right at 1024 columns"
/// — a tuned number, normalized by ScaleFactor.
/// <see cref="NoiseFrequencyPerMapWidth"/> "this many features across the island"
/// — a stated intent, already size-independent.
///
/// Reach for this one when the feature COUNT across the map is the thing being specified, and
/// for <see cref="NoiseFrequency"/> when porting a frequency someone tuned by eye.
/// </summary>
public float NoiseFrequencyPerMapWidth(float periodsPerMapWidth) => periodsPerMapWidth / MapSize;
/// <summary>
/// ⭐ A decorrelation offset for a noise coordinate, declared in MAP WIDTHS.
///
/// <c>OffsetInMapWidths(0.25f)</c> shifts the sample by a quarter of the map at EVERY map
/// size — so two noise layers stay exactly as decorrelated at 12K as they were at 8K, and
/// the same seed produces the same world shape at any size.
///
/// ⚠ This is the one place a decorrelation offset may come from. A bare pixel constant in a
/// noise coordinate is the bug described in this type's summary; there is no correct value
/// for one.
/// </summary>
public float OffsetInMapWidths(float mapWidths) => mapWidths * MapSize;
public override string ToString() => $"GenerationScale(MapSize={MapSize}, ScaleFactor={ScaleFactor:F3})";
}
}