islaApocalypse-v2/Core/Scripts/GenerationScale.cs
beezm 9107b0822b Phase 0: clean project skeleton for the v2 rewrite
Stands up the CODE_REPO the rewrite is written into (D-049, D-058). Godot 4.7.2 /
.NET 8 / Godot.NET.Sdk 4.7.2. Nothing is generated, meshed, or ported — this is the
shell and the two data contracts.

Four layers, each with its boundary stated in a directory README:
  Core/    math + data only, and engine-free — depends on nothing above it
  Server/  authoritative logic                (empty this phase)
  Client/  rendering                          (empty this phase)
  Tools/   the offline generator — may NOT reference Client   (Phase 1 fills it)

Contracts (compiling stubs, no algorithms):
  - Column model (D-053): a 2D grid of columns, each a stack of (material, thickness)
    runs, any material at any depth. Air is the TOP RUN — there is no air-vs-solid
    height branch, and no surface-height accessor exists to reintroduce one. Water is
    not a band; it stays an overlay.
  - Material schemas (D-054): terrain and building as two append-only registries,
    bridged by a recipe seam that is deliberately EMPTY. Identity is a registry key,
    never a serialized ordinal. Mesh style is per-ORIGIN and is not a material field.

Rails, so Phase 1 inherits them rather than rediscovering them:
  - GenerationScale: MapSize is a parameter, everything derives from it, nothing uses
    a raw pixel number. Tightening over the reference — decorrelation offsets are
    declared in map widths, not pixels (chat1/00 §4.2).
  - ToolingPaths: config/blueprint/output paths all env-overridable, resolved in one
    place, so a batch cannot touch the developer's live files.
  - FileSafety: the permanent no-deletion rules as throws rather than sentences.

user:// isolation: project name and use_custom_user_dir both pin the runtime dir to
~/.local/share/islaApocalypse-v2/, away from the old prototype's preserved seeds and
batches. Tools/Scenes/UserDirProbe.tscn confirms it rather than assuming it.
2026-08-19 20:21:12 -04:00

119 lines
5.9 KiB
C#
Raw 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 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})";
}
}