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
141 lines
7.2 KiB
C#
141 lines
7.2 KiB
C#
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 8k–12k (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})";
|
||
}
|
||
}
|