using System.Collections.Generic; using Godot; using IslaApocalypse.Core; namespace IslaApocalypse.Tools { /// /// ⭐⭐ PASS 2a — the redistribution curve and the shelf detail, applied per column, producing the /// TWO HEIGHT FIELDS. Ported from the reference's MapGenerator.GenerateTopography pass-2 /// loop (Tools/Scripts/MapGenerator.cs ~:692-735 at tag pre-rewrite-reference, /// commit ab78883). → D-050. /// /// ═══ WHAT RUNS HERE, AND WHAT DELIBERATELY DOES NOT ═══ /// /// The reference's pass 2 is three sub-passes in a fixed order: /// /// 2a curve + detail ← THIS FILE /// 2b hydraulic erosion (render map only) — a later chat2 task /// 2c the crater carve (both maps, last word) — a later chat2 task /// /// Only 2a is ported. The ORDER matters and is recorded here so the later two land in the right /// place: erosion runs AFTER detail and BEFORE the carve, and the carve stays last because it is /// the final authority on its own terrain. /// /// ═══ ⚠ THE PASS-1/PASS-2 BOUNDARY IS HARD, NOT AN INTERLEAVE ═══ /// /// The curve's summit spike maps [K6, hMaxSeed] onto the peak band, so no pixel can be /// curved until every pixel has been scanned. carries that /// number across the boundary, and AssertMonotonic runs in between — after the max is /// known, before the first column is shaped. /// /// ═══ ⚠ ONE DELIBERATE DEVIATION FROM THE REFERENCE, AND WHY ═══ /// /// The reference shaped IN PLACE: _heightMap held pass-1 raw, then pass 2 overwrote it /// column by column. This port leaves untouched and allocates /// the render field. /// /// The arithmetic is identical — every column still reads raw and writes curvedH. /// What changes is that the raw field SURVIVES the pass, which is what lets oracle checks (a) and /// (b) compare against it directly instead of regenerating and trusting that the regeneration /// matched. An invariant you can check by construction beats one you have to believe. /// /// (With the curve OFF nothing is allocated at all and both fields alias the pass-1 array, /// exactly as the reference did — see .) /// public static class Shaping { /// /// Apply pass 2a to a pass-1 field. /// /// ⚠ Pure with respect to : nothing here writes to its arrays. /// public static Pass2Result Shape(Pass1Result p1, TerrainGenConfig cfg) { ulong t0 = Time.GetTicksMsec(); int mapSize = p1.MapSize; var notes = new List(); // ═══ THE OFF PATH — the A/B control ═══ // // Nothing to separate, so nothing is allocated: both fields reference the pass-1 array, // as the reference's `_heightMapClassify = (_curveOn || _erosionOn) ? new[…] : _heightMap` // did. This path must be BIT-IDENTICAL to Phase 1's output — oracle (a). if (!cfg.Curve) { notes.Add("[Shaping] curve OFF — render and classify alias the pass-1 field (the A/B control)."); // ⚠ Detail REQUIRES the curve — it slides the curve's KNOTS, so there is nothing to // warp without one. The reference said so out loud rather than silently no-op'ing, // because a dial that does nothing is worth a line in the log. if (cfg.ShelfDetail) notes.Add("[Shaping] shelf detail requested but the curve is off — no-op (detail warps the curve's knots)."); return new Pass2Result(mapSize, p1.Seed, p1.Height, p1.Height, curveOn: false, detailOn: false, curveModeLabel: "off", variantLabel: cfg.VariantLabel, continuous: null, knots: null, anchors: null, hMaxSeed: p1.HMaxSeed, edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: p1.HMinSeed, hMax: p1.HMaxSeed, elapsedMs: Time.GetTicksMsec() - t0, notes: notes); } // ═══ WHICH CURVE (chat2/02) ═══ // // Staircase falls through to the faithful task-01 path below, UNTOUCHED — it is the // control, and controls do not get refactored while they are being compared against. if (cfg.CurveMode == CurveModeKind.Continuous) return ShapeContinuous(p1, cfg, notes, t0); if (cfg.CurveMode == CurveModeKind.LiftedWrong) return ShapeLifted(p1, cfg, notes, t0); CurveKnots knots = cfg.Knots; CurveAnchors anchors = cfg.Anchors; GenerationScale scale = cfg.Scale; // ═══ THE MODULATION FIELDS ═══ // // Three for the curve (bench anchor, plateau anchor, shelf strength), two for detail // (micro-relief, edge warp). Each is decorrelated by SEED OFFSET and sampled at the bare // (x, y) — there are no coordinate offsets in this path to normalize. → TerrainNoise. FastNoiseLite benchNoise = TerrainNoise.CreateModulation(p1.Seed, anchors.BenchSeedOffset, anchors.ElevFreqPerMapWidth, scale); FastNoiseLite plateauNoise = TerrainNoise.CreateModulation(p1.Seed, anchors.PlateauSeedOffset, anchors.ElevFreqPerMapWidth, scale); FastNoiseLite strengthNoise = TerrainNoise.CreateModulation(p1.Seed, anchors.StrengthSeedOffset, anchors.StrengthFreqPerMapWidth, scale); // The curve-off case already returned above, so detail is simply on-or-off from here. bool detailOn = cfg.ShelfDetail; FastNoiseLite reliefNoise = null, edgeNoise = null; float reliefAmpRaw = 0f, edgeAmpRaw = 0f; float maxEdgeShiftRaw = TerrainDetailPass.MaxEdgeShift(knots); if (detailOn) { reliefNoise = TerrainNoise.CreateModulation(p1.Seed, TerrainDetailPass.ReliefSeedOffset, TerrainDetailPass.ReliefFreqPerMapWidth, scale); edgeNoise = TerrainNoise.CreateModulation(p1.Seed, TerrainDetailPass.EdgeSeedOffset, TerrainDetailPass.EdgeFreqPerMapWidth, scale); reliefAmpRaw = WorldScale.RawFromMetres(Mathf.Max(cfg.ShelfReliefAmpM, 0f)); edgeAmpRaw = WorldScale.RawFromMetres(Mathf.Max(cfg.ShelfEdgeVariationM, 0f)); // ⚠ THE CLAMP IS LOUD. The warp is bounded to the largest shift that keeps the knot // set strictly ordered, so monotonicity can never become a tuning question — but a // silently ignored dial is worse than a refused one, because the developer A/Bs a // number that never reached the terrain. if (edgeAmpRaw > maxEdgeShiftRaw) { notes.Add($"[Shaping] ⚠ ShelfEdgeVariation {cfg.ShelfEdgeVariationM:F2} m exceeds this knot set's " + $"safe bound {WorldScale.MetresFromRaw(maxEdgeShiftRaw):F2} m — CLAMPING."); edgeAmpRaw = maxEdgeShiftRaw; } notes.Add($"[Shaping] detail v{TerrainDetailPass.Version}: relief ±{cfg.ShelfReliefAmpM:F1} m @ " + $"{TerrainDetailPass.ReliefFreqPerMapWidth:F0}/map, edge warp " + $"±{WorldScale.MetresFromRaw(edgeAmpRaw):F2} m of INPUT height @ " + $"{TerrainDetailPass.EdgeFreqPerMapWidth:F0}/map (bound " + $"{WorldScale.MetresFromRaw(maxEdgeShiftRaw):F2} m)."); } // ═══ ⭐ THE MONOTONICITY PROOF — between the passes, before the first column ═══ notes.Add(HeightCurve.AssertMonotonic(p1.HMaxSeed, knots, anchors, detailOn ? edgeAmpRaw : 0f)); // ═══ THE CRATER SEAM — INERT THIS PHASE ═══ // // No crater exists yet. CraterRadius defaults to 0, which makes CraterDetailWeight return // 1 everywhere: detail applies unmasked, and the distance is not even computed. Ported // now because the exclusion is part of THIS pass's contract — bolting it on after the // carve arrives is exactly how the reference's 532-px below-sea bug happened. float craterRadius = cfg.CraterRadius; bool craterActive = craterRadius > 0f; var craterCentre = new Vector2(cfg.CraterCenterX, cfg.CraterCenterY); var height = new float[mapSize, mapSize]; var classify = new float[mapSize, mapSize]; float hMin = float.MaxValue, hMax = float.MinValue; for (int x = 0; x < mapSize; x++) { for (int y = 0; y < mapSize; y++) { float raw = p1.Height[x, y]; // ⭐ THE CLASSIFY FIELD IS THE RAW FIELD. Not "approximately", not "before most // things" — bit-for-bit, and asserted as such by oracle (b). classify[x, y] = raw; // ── the per-column shelf modulation (reference v4) ── // Anchors and strength come from three very-low-frequency fields, so the bench // and plateau elevations drift across the island instead of being one global // terrace. Amplitudes are bounded and every extreme is swept by AssertMonotonic, // so ordering safety is by construction rather than by hope. float benchLo = anchors.BenchBase + benchNoise.GetNoise2D(x, y) * anchors.BenchAmp; float plateauLo = anchors.PlateauBase + plateauNoise.GetNoise2D(x, y) * anchors.PlateauAmp; float shelfSpan = HeightCurve.ShelfSpan((strengthNoise.GetNoise2D(x, y) + 1f) * 0.5f, anchors); // ── detail yields to the crater (inert until the carve lands) ── float wCrater = 0f; if (detailOn) { wCrater = craterActive ? TerrainDetailPass.CraterDetailWeight( new Vector2(x, y).DistanceTo(craterCentre), craterRadius) : 1f; } // ── PASS B: the knot-block warp. Slides K3/K4/K5 for THIS column. ── float edgeShift = detailOn ? edgeNoise.GetNoise2D(x, y) * edgeAmpRaw * wCrater : 0f; float curvedH = HeightCurve.Apply(raw, p1.HMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan, knots, anchors, edgeShift); // ── PASS A: the micro-relief skin, on the shelves only ── // ⚠ Fed the SAME edgeShift, so the skin follows the shelf wherever pass B moved // its boundary. Passing 0 here would put the texture on the wrong ground. if (detailOn && wCrater > 0f) { float wShelf = TerrainDetailPass.ShelfWeight(raw, knots, edgeShift); if (wShelf > 0f) curvedH += reliefNoise.GetNoise2D(x, y) * reliefAmpRaw * wShelf * wCrater; } if (curvedH < hMin) hMin = curvedH; if (curvedH > hMax) hMax = curvedH; height[x, y] = curvedH; } } // ⚠ THE REFERENCE'S PASS 2 CONTINUES HERE with erosion (~:737-805, render map only) and // then the crater carve (~:807-833, both maps, last word). Both DEFERRED to later chat2 // tasks. The classify field above is finalized bar the carve — which is exactly the // property that makes it an oracle. return new Pass2Result(mapSize, p1.Seed, height, classify, curveOn: true, detailOn: detailOn, curveModeLabel: "staircase", variantLabel: cfg.VariantLabel, continuous: null, knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed, edgeAmpRaw: edgeAmpRaw, maxEdgeShiftRaw: maxEdgeShiftRaw, hMin: hMin, hMax: hMax, elapsedMs: Time.GetTicksMsec() - t0, notes: notes); } /// /// ⭐ THE CONTINUOUS MODE (chat2/02): the staircase's toe+red lowland preserved bit-for-bit, /// everything above it replaced by one smooth monotone Fritsch–Carlson climb. /// → for the shape and its refusals. /// /// ═══ ⚠ WHY THIS LOOP IS SO MUCH SIMPLER THAN THE STAIRCASE'S ═══ /// /// No bench/plateau/strength modulation fields — the anchors they modulated are dropped. /// No shelf detail — the flat benches it existed to de-slab no longer exist above the /// lowlands, and the preserved lowland never needed it. Micro-relief/roughness on the /// continuous upper grade is DEFERRED: erosion is the real detail source, and painting /// noise on the climb now would pre-judge what erosion should carve. So: one spline per /// seed, one Apply per column, and the classify field untouched as always. /// private static Pass2Result ShapeContinuous(Pass1Result p1, TerrainGenConfig cfg, List notes, ulong t0) { int mapSize = p1.MapSize; CurveKnots knots = cfg.Knots; CurveAnchors anchors = cfg.Anchors; // Same per-seed summit normalization as the staircase — the pass-1/pass-2 boundary // stays hard for the same reason. float spikeMax = HeightCurve.EffectiveSpikeMax(p1.HMaxSeed, knots, anchors); // Build throws (refusing the generation) on any config that cannot hit the target // silhouette; the tool's _Ready catches and Quit(2)s. var curve = ContinuousCurve.Build(knots, anchors, spikeMax, cfg.LowlandCeilingM, cfg.ClimbFeather, cfg.SummitDrama); // Monotone by construction — and proven anyway, per seed, because "cannot fail" is // exactly the claim worth a millisecond of checking. notes.Add(curve.AssertStrictlyIncreasing()); notes.Add($"[Shaping] continuous: {curve.DescribeControlPoints()}"); if (cfg.ShelfDetail) notes.Add("[Shaping] ⚠ shelf detail requested but FORCED OFF in continuous mode — the flat " + "benches it de-slabbed no longer exist, and detail on the climb is deferred to erosion."); var height = new float[mapSize, mapSize]; var classify = new float[mapSize, mapSize]; float hMin = float.MaxValue, hMax = float.MinValue; for (int x = 0; x < mapSize; x++) { for (int y = 0; y < mapSize; y++) { float raw = p1.Height[x, y]; classify[x, y] = raw; // ⭐ the oracle field, as always float h = curve.Apply(raw); // sea identity + lowland delegation // + climb + tail, all inside if (h < hMin) hMin = h; if (h > hMax) hMax = h; height[x, y] = h; } } return new Pass2Result(mapSize, p1.Seed, height, classify, curveOn: true, detailOn: false, curveModeLabel: "continuous", variantLabel: cfg.VariantLabel, continuous: curve, knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed, edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: hMin, hMax: hMax, elapsedMs: Time.GetTicksMsec() - t0, notes: notes); } /// /// ⚠⚠ THE DELIBERATELY-WRONG BOOKEND (chat2/02): an even linear remap of ALL land — /// [SEA, spikeMax] → [SEA, PEAK_CAP]. Every land column above sea is lifted by the /// same ×~1.5 factor, which hoists the entire island off its shoreline and destroys the /// broad low plain the developer likes. /// /// It exists so the preserved-lowland variants can be seen AGAINST the mistake — the batch's /// contrast anchor, per the rev-3 task. It is not a candidate, it takes no knobs, and its /// oracle-(d) FAILURE is expected and reported as confirmation, not as a bug. /// private static Pass2Result ShapeLifted(Pass1Result p1, TerrainGenConfig cfg, List notes, ulong t0) { int mapSize = p1.MapSize; CurveKnots knots = cfg.Knots; CurveAnchors anchors = cfg.Anchors; float spikeMax = HeightCurve.EffectiveSpikeMax(p1.HMaxSeed, knots, anchors); float scale = (anchors.PeakCap - anchors.Sea) / (spikeMax - anchors.Sea); notes.Add($"[Shaping] ⚠⚠ LIFTED_WRONG: even remap [sea, {spikeMax:F4}] → [sea, {anchors.PeakCap:F4}] " + $"(×{scale:F3} on every land height) — the WRONG direction, kept as the contrast bookend."); var height = new float[mapSize, mapSize]; var classify = new float[mapSize, mapSize]; float hMin = float.MaxValue, hMax = float.MinValue; for (int x = 0; x < mapSize; x++) { for (int y = 0; y < mapSize; y++) { float raw = p1.Height[x, y]; classify[x, y] = raw; float h; if (raw <= anchors.Sea) h = raw; // even the wrong direction keeps the // coastline — sea identity is not optional else if (raw >= spikeMax) h = anchors.PeakCap + (raw - spikeMax) * anchors.TailSlope; else h = anchors.Sea + (raw - anchors.Sea) * scale; if (h < hMin) hMin = h; if (h > hMax) hMax = h; height[x, y] = h; } } return new Pass2Result(mapSize, p1.Seed, height, classify, curveOn: true, detailOn: false, curveModeLabel: "lifted_WRONG", variantLabel: cfg.VariantLabel, continuous: null, knots: knots, anchors: anchors, hMaxSeed: p1.HMaxSeed, edgeAmpRaw: 0f, maxEdgeShiftRaw: 0f, hMin: hMin, hMax: hMax, elapsedMs: Time.GetTicksMsec() - t0, notes: notes); } } }