islaApocalypse-v2/Tools/Scripts/Pass2Result.cs
beezm ea291eaab5 chat2/11: hydraulic erosion — the faithful droplet pass on the locked shape, render-only, judged off vs on
Step 0: tag terrain-shape-v1 on a59e52f (the frag_4-locked, gallery-confirmed state).

Core/Scripts/HydraulicErosion.cs is the reference's pass ported verbatim - the four governors
(250,000 droplets, lifetime 384, carve cap 15 m, deposit cap 6 m) on one net-displacement
ledger read both ways and PROVEN on exit (the CARVE-CAP / DEPOSIT-CAP violations throw), the
sea clamp (spawn rejected below sea, death at sea, both brushes skip below-sea cells, the 0.5 m
margin floor on the erode brush), the cone-weighted normalized brush shared by erode and
deposit, the droplet physics (inertia 0.35, capacity 4, min slope 0.02, erode 0.12, deposit
0.15, evaporation 0.004, gravity 4, brush 2), PCG32 seeded at seed + 9271, the crater
exclusion whole. Engine-free; every metres<->raw conversion through WorldScale (no literal
251 - the same multiply/divide, so bit-identical arithmetic).

Tools/Scripts/ErosionPass.cs is the caller (pass 2b): render field only (copied if aliased to
classify), governors clamped as the reference ConfigManager clamped them, the crater exclusion
passed through INERT (no crater => radius 0 => weight 1 everywhere; the reference's
"core < carve factor" warning dormant), and the FLOOD GUARD - render water pixels counted
before and after, any change throws. TerrainGenConfig gains Erosion (default OFF, the anchor
rule) and the governors/physics/crater fields. Pass2Result.WithHeight hands back the eroded
render field. ShadeRenderer is a pure-hillshade plate (land only) because the palette relief's
0.30 hillshade hides half-metre drainage. ErosionTool carries TerrainShapeV1 (the locked shape's
values, pinned once) and the batch: 4 gallery seeds x off/on at 8192, grayscale + .f32 +
relief + shade per field, a mid-slope 1024-px crop off/on (relief and shade), the stats.

Faithful first, no tune: at 8192 the pass touches ~88 % of land cells at a mean 0.14 m, carve
15.00 / deposit 6.00 m at the caps, 0.5 m mean on the massif with 18 % of its cells moved more
than a metre. It reads as dissected summits with radial gully fans and carve/deposit bands
along the slope breaks, not dendritic networks: droplets die of lifetime (170k of 224k) before
they converge. A lifetime probe at 4096 (scratch) shows 1536 and 4096 identical - every
droplet is dead of evaporation by ~1,300 steps - so lifetime is not the lever; droplet count
is. The mid-slope green->yellow transition is not softened at the faithful tune.

Oracle, all passing on 4 seeds: erosion OFF bit-identical to terrain-shape-v1 (the task-10
gallery dumps, 67 M cells each); classify bit-identical off vs on and == the pass-1 field;
region labeling + island tag identical; flood guard (27.5 M water pixels unchanged, every
seed); caps proven; tag/coastline; classify == raw; centre is land; eroded field
bit-identical across two runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
2026-08-22 12:19:20 -04:00

194 lines
8.7 KiB
C#

using System.Collections.Generic;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// Everything pass 2 produces: THE TWO HEIGHT FIELDS. → <see cref="Shaping"/>.
///
/// ═══ ⭐⭐ THE TWO-FIELD SPLIT (D-046) — THE DISCIPLINE THIS CLASS EXISTS TO HOLD ═══
///
/// <see cref="HeightClassify"/> RAW. Uncurved, un-detailed. The ORACLE.
/// <see cref="Height"/> RENDER. Curved, detailed, and later eroded and carved.
///
/// Everything that CLASSIFIES the world — biomes, water bodies, the ocean flood fill — reads the
/// classify field. Everything that DRAWS or MESHES it reads the render field. The reference's
/// hardest-won lesson is that this split is what made five rounds of taste-iteration safe: the
/// biome and water maps stayed md5-identical across every shaping change, so correctness was
/// never being judged by eye. → `Design - Tooling - Iteration and Batching.md`,
/// "build the oracle before the taste-iteration".
///
/// ⚠ NOTHING CONSUMES THE CLASSIFY FIELD YET. No water, no biomes exist in this phase. The split
/// is established HERE, at the curve, because the curve is where the second field is BORN — and
/// retrofitting a classify path after three passes already ran on one array is how the two
/// silently diverge. The field is produced and asserted now so that when water lands it has
/// something correct to read.
///
/// ═══ ⚠ WHEN THE TWO FIELDS ARE THE SAME ARRAY ═══
///
/// With the curve OFF there is nothing to separate, so both properties reference ONE array —
/// exactly as the reference did (<c>_heightMapClassify = (_curveOn || _erosionOn) ? new float[…]
/// : _heightMap</c>). <see cref="FieldsAreAliased"/> says so out loud, because a later pass that
/// writes through one reference while reading the other MUST know: the reference's crater carve
/// reads both into locals before writing either for precisely this reason, and that is the trap
/// this flag is here to keep visible until the carve lands.
/// </summary>
public sealed class Pass2Result
{
/// <summary>Map side in columns.</summary>
public readonly int MapSize;
/// <summary>The resolved seed. Same seed, same two fields.</summary>
public readonly int Seed;
/// <summary>
/// ⭐ THE RENDER FIELD, <c>[x, y]</c> — curved and detailed. What gets drawn, dumped and
/// (later) eroded, carved and meshed.
/// </summary>
public readonly float[,] Height;
/// <summary>
/// ⭐ THE CLASSIFY FIELD, <c>[x, y]</c> — bit-for-bit the raw pre-curve pass-1 height.
///
/// ⚠ Nothing may write to this after pass 2 except the crater carve, which is the one pass
/// that legitimately moves both fields. Erosion, rivers and detail are render-only.
/// </summary>
public readonly float[,] HeightClassify;
/// <summary>Was the curve applied? The primary A/B gate.</summary>
public readonly bool CurveOn;
/// <summary>
/// Which curve shaped the render field: "off", "staircase", "continuous" or "lifted_WRONG".
/// A label, not logic — the oracle and the INDEX read it so a result can never be mistaken
/// for the wrong mode's.
/// </summary>
public readonly string CurveModeLabel;
/// <summary>
/// The batch variant this result belongs to, carried straight from
/// <see cref="TerrainGenConfig.VariantLabel"/>.
///
/// ⚠ CARRIED, NOT INFERRED. The first cut of the chat2/02 tool reconstructed this from the
/// knob values ("ceiling &gt; 31 ⇒ hold_higher"), which works only for exactly today's
/// variant set: add a second variant sharing a knob value and two different runs silently
/// write to one folder. The label is an identity, so it travels with the result.
/// </summary>
public readonly string VariantLabel;
/// <summary>
/// The per-seed continuous spline, when <see cref="CurveModeLabel"/> is "continuous" — the
/// oracle samples its slopes (check e) and the INDEX prints its control points. Null for
/// every other mode.
/// </summary>
public readonly ContinuousCurve Continuous;
// ═══ ⭐ THE OFFSHORE TAG, CARRIED (chat2/05) ═══
//
// Pass 1 sets it; pass 2 carries it UNCHANGED beside the two height fields, because this is
// where the shaped-terrain result flows and where a downstream consumer would pick it up.
// The curve is identity at sea and monotone above, so a cell that was offshore-island LAND
// in pass 1 is still land in the render field — the tag stays valid for both fields without
// being recomputed (oracle: "classify/render coastline consistent").
//
// ⚠ NO LOGIC READS IT THIS PHASE. It is a data layer. A biome/fertility/placement pass reads
// it from here, checks for null (offshore off), and never re-derives island-land from
// geometry.
/// <summary>→ <see cref="Pass1Result.IsIsland"/>, the same array. Null when region labeling is off.</summary>
public readonly bool[,] IsIsland;
/// <summary>→ <see cref="Pass1Result.IslandHemisphere"/>, the same array. Null when region labeling is off.</summary>
public readonly byte[,] IslandHemisphere;
/// <summary>Was shelf detail applied? Requires <see cref="CurveOn"/> — it warps the curve's knots.</summary>
public readonly bool DetailOn;
/// <summary>The knot set used. Null when the curve is off.</summary>
public readonly CurveKnots Knots;
/// <summary>The output anchors used. Null when the curve is off.</summary>
public readonly CurveAnchors Anchors;
/// <summary>The per-seed spike input, carried through from pass 1.</summary>
public readonly float HMaxSeed;
/// <summary>The edge-warp amplitude actually APPLIED, raw units (post-clamp). Zero when detail is off.</summary>
public readonly float EdgeAmpRaw;
/// <summary>The knot set's safe warp bound, raw units — what <see cref="EdgeAmpRaw"/> was clamped to.</summary>
public readonly float MaxEdgeShiftRaw;
/// <summary>Render-field extremes after shaping. For the ramp and the report.</summary>
public readonly float HMin, HMax;
/// <summary>Wall-clock milliseconds pass 2 took.</summary>
public readonly ulong ElapsedMs;
/// <summary>
/// Lines worth printing: the monotonicity confirmation, any loud clamp. Collected rather than
/// printed inside the pass so the shaping code stays a pure function of its inputs and the
/// tool owns the console.
/// </summary>
public readonly List<string> Notes;
public Pass2Result(int mapSize, int seed, float[,] height, float[,] heightClassify,
bool curveOn, bool detailOn, string curveModeLabel, string variantLabel,
ContinuousCurve continuous, CurveKnots knots, CurveAnchors anchors, float hMaxSeed,
float edgeAmpRaw, float maxEdgeShiftRaw, float hMin, float hMax, ulong elapsedMs,
List<string> notes, bool[,] isIsland = null, byte[,] islandHemisphere = null)
{
IsIsland = isIsland;
IslandHemisphere = islandHemisphere;
MapSize = mapSize;
Seed = seed;
Height = height;
HeightClassify = heightClassify;
CurveOn = curveOn;
DetailOn = detailOn;
CurveModeLabel = curveModeLabel;
VariantLabel = variantLabel;
Continuous = continuous;
Knots = knots;
Anchors = anchors;
HMaxSeed = hMaxSeed;
EdgeAmpRaw = edgeAmpRaw;
MaxEdgeShiftRaw = maxEdgeShiftRaw;
HMin = hMin;
HMax = hMax;
ElapsedMs = elapsedMs;
Notes = notes;
}
/// <summary>
/// ⚠ True when the two fields ARE the same array (curve off). Any pass that writes one while
/// reading the other must read both into locals first. See the type header.
/// </summary>
public bool FieldsAreAliased => ReferenceEquals(Height, HeightClassify);
/// <summary>
/// The same result with a different RENDER field — how a render-only pass (erosion, chat2/11)
/// hands back its output without touching the classify field or anything else carried here.
/// </summary>
public Pass2Result WithHeight(float[,] newHeight, List<string> extraNotes, ulong extraMs)
{
var notes = new List<string>(Notes); if (extraNotes != null) notes.AddRange(extraNotes);
float hMin = float.MaxValue, hMax = float.MinValue;
for (int x = 0; x < MapSize; x++)
for (int y = 0; y < MapSize; y++) { float h = newHeight[x, y]; if (h < hMin) hMin = h; if (h > hMax) hMax = h; }
return new Pass2Result(MapSize, Seed, newHeight, HeightClassify, CurveOn, DetailOn, CurveModeLabel, VariantLabel,
Continuous, Knots, Anchors, HMaxSeed, EdgeAmpRaw, MaxEdgeShiftRaw, hMin, hMax, ElapsedMs + extraMs, notes,
IsIsland, IslandHemisphere);
}
/// <summary>Fraction of the RENDER field at or above the sea threshold.</summary>
public float LandFraction(float seaLevel)
{
long land = 0;
for (int x = 0; x < MapSize; x++)
for (int y = 0; y < MapSize; y++)
if (Height[x, y] >= seaLevel) land++;
return land / (float)((long)MapSize * MapSize);
}
}
}