Compare commits

..

10 commits

Author SHA1 Message Date
89e0f85c9f chat2/12: drainage analysis (minimal-first) — the reference river plan ported, analysis only, on the eroded terrain
Core/Scripts/DrainageAnalysis.cs is the reference's DrainageAnalysis ported verbatim: the
Barnes priority-flood routing fill (8-connected, seeded from the four borders, index
tiebreak, pit fills ONE ULP above the parent so every filled cell keeps a strictly
descending path to its spill; the terrain heightmap itself is never written - the fill lives
in its own array), terminal-basin qualification (depth >= 2 m AND area >= 10,000 px; the rest
are pits filled through), D8 flow directions on the routing surface - FOR ANALYSIS ONLY, the
reverted-as-carving landmine stated in the file - Kahn accumulation, the memoised destination
walk crediting a terminal basin with TOTAL inflow, sea-reaching outlets ranked by drainage
area with the outlet separation, main stems by max accumulation, mountain exits from the
along-stem grade, lean tributaries, lean endorheic terminals, and the promoted giants
(provisional routes computed as the reference did, not drawn - routing is a later task).
WorldScale-denominated; Dir / Acc / Filled / FullFilled / BasinId / BasinInflow exposed so the
caller can prove the invariants.

"The sea" is the OCEAN body from the region layer: RegionLabeling.OceanMask - the 4-connected
water component touching the border, on the CLASSIFY field (the water-side complement of the
land contract). Enclosed lagoons, lake beds and island-fringe pockets are ordinary terrain to
the router.

DrainageTool (4 task-11 seeds at 8192, the eroded fields bit-identical to the 11 dumps)
renders the log-scaled accumulation map and the promoted-candidates overlay (trunks cyan,
endorheic giants orange, lean terminals red; nothing carved) and writes the accumulation
.f32. Oracle, all passing: render AND classify fields bit-identical before/after the analysis
(zero terrain cells written), no water added, full fill >= original everywhere with a
non-ascending path to the border from every cell, ocean mask all below sea and on the border,
dir/acc/candidates identical across two runs.

Endorheic basins are the expected first-class output: on every seed the top giants out-drain
the top trunks - the biggest drainages pool inland because erosion delivers the upland network
only and cannot cross the flats.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
2026-08-22 20:51:51 -04:00
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
a59e52fcbc chat2/10: frag_4 seed gallery — render-only, the 09 setting frozen across 2 anchors + 6 fresh seeds at 8192
FragGalleryTool pins every frag_4 value explicitly (amp 0.5, freq 12, window 0.66 +- 0.18,
zero-mean, stretch 2 with band 0.70/0.05 and the sinker stretched, speck revert 2.5e-7, offshore
and shelf off, labeling on) and proves it is the 09 setting: at 4096 the frozen field is
bit-identical to the 09 frag_4 dump on both anchors (a9, 16.8 M cells each), the frag-off field
to the 08 stretch_3 dump (a8), the interior locked against it (r), ids deterministic (o), the
curve untouched (a1). No generation code changed.

Eight seeds at 8192 - the anchors 1063685222 and 999999937 plus 20260822, 31415926, 27182818,
16180339, 14142135, 17320508 (constants chosen before any render) - each with grayscale, .f32,
relief and the labeled-regions overlay, and the hemisphere-split count/size table with a stated
numeric read rule (too solid < 12 big islands across both hemispheres; shredded if big < 25 % of
all; else good spread). Seven read good spread; 14142135 trips the ratio rule (17 big of 69) and
is the most solid mainland of the eight (99.3 % of land). Graduation held.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
2026-08-22 10:24:56 -04:00
bddd3636dd chat2/09: coastal fragmentation — a perimeter-wide, band-limited noise, amplitude laddered at a fixed stretch
A dedicated term, not the edge noise scaled: pass 1's edge noise is positive-only, squircle-
modulated, 2.5x the base frequency - the coastline's jitter; scaling it would roughen the whole
rim and bias the coast inward. CoastalFragment adds, to the PRE-power falloff, amp * window(f) *
noise(x + off, y + off) with its own deterministic field (seed offset 9109, coordinate offset
0.37 map widths - D-059), zero-mean, at 12 periods per map width (the lobe/neck scale, chosen by
probe against 20 and 32: 20 climbs into smalls, 32 is fuzz), weighted by a smooth window on the
falloff value itself, centred 0.66 (the coast's falloff for a median base noise, from the 08
diagnosis), half-width 0.18, EXACTLY ZERO beyond - so the interior, the massif and the deep sea
are bit-identical by construction. Thin necks of barely-land flip first; nothing is detected,
nothing is stamped. FragmentBitesOnly is exposed (probed: it erodes the coast inward - mainland
-3/-8/-14 % at 0.06/0.15/0.30 - rather than detaching pieces; off by default).

Batch: BatchRoot(9, "coastal_fragment") - 4 amplitudes (0.06, 0.15, 0.30, 0.50) x task 08's two
seeds at 4096, stretch fixed at 2 (08's stretch_3 rung, whose dump is the bit-identical baseline,
a8), sinker untouched, speck revert at < 4 cells, offshore/shelf off; lean render (regions overlay
+ relief + .f32), the hemisphere-split count/size table with largest-three and histograms.

Oracle, all passing: a1 (the curve untouched), a8 x2 (frag OFF at stretch 2 == the 08 field,
16.8 M cells each), interior locked (r: every cell clear of the window bit-identical, 8/8),
high-ground report (s, informational), centre is land, tag/coastline, classify == raw, ids and
heights deterministic.

The read: both hemispheres fragment; the north more readily (N 10 -> 28, 13 -> 40 islands across
the ladder; S 4 -> 31, 14 -> 28) and the south's big stretch pieces are the zero-mean noise's
other edge: on 999999937 the 83k-cell southern fragment is BRIDGED back onto the mainland at amp
0.50 (mainland +183k). The working range is amp 0.15-0.30; 0.50 is the bookend. Graduation held.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
2026-08-22 07:09:27 -04:00
537bd3e048 chat2/08: southern stretch, EXPLORATION — the fragmentation knob space, diagnosed then laddered
Diagnostic first (SouthernStretchTool, ISLA_DIAG_ONLY; scratch/southern_diagnosis.md). Along
the central south profile the mask blend climbs 0.018 per 0.01 N, edge noise adds 0.03-0.06,
and the southern sinker is zero until 0.75 N then adds 0.024 per 0.01 N before the 2.5 power;
the coast sits at 0.76-0.82 N, ~0.06 N north of where the blend alone would end. The stretch
compresses the southward distance the mask sees inside a fixed feathered latitude band,
y' = yB + (y - yB)/(1 + s*ramp), texture (base noise, edge noise, latitude) untouched, so the
extended mass keeps the elevation of the rows it came from. With the sinker reading the REAL y
it cancels the stretch outright: median coast saturates at 0.83-0.84 from s = 1 to 16 and the
south stays 1-10 tiny nubs on both seeds. The lever is therefore two things - extend reach AND
hold the sinker back - and this pass couples them 1:1 (StretchSinker = true; the real-y mode is
kept as the config's other setting). Ladder chosen from the sweep, non-linear: 0.5, 1, 2, 3, 5
(peninsula / onset / few big pieces / big pieces consolidating / over-stretched sheet); 8 and 16
are indistinguishable from 5.

Topography: cells above the band take the untouched code path (fy == y), so the classify field
north of the band is bit-identical by construction. SouthernStretch.cs holds the band constants
(start 0.70 N, feather 0.05 N - fixed for the batch) and the ramp. TerrainGenConfig: SouthStretch
(the swept axis, 0 = off), SouthBandStartFrac, SouthBandFeatherFrac, StretchSinker.

Oracle, all passing: a1/a3 stretch OFF bit-identical; a3b stretch 5 north of the band
bit-identical to the task-03 dump (2.93 M cells), a4b the same at 8192 against the
terrain-curve-v1 gallery dump (46.97 M cells north of the band, 20.1 M differ below - the
relaxation); per field north bit-locked classify and render vs stretch 0 (p, p2), northern
island set invariant (q), centre is land, tag/coastline, classify == raw; ids and heights
deterministic. Batch: BatchRoot(8, "southern_stretch_explore") - 5 levels x 2 seeds
(1063685222, 999999937 - the two 07 seeds with the most mainland south of the band), lean render
(regions overlay + relief + .f32), the hemisphere-split count/size table. Region labeling on,
offshore / shelf / speck revert off: the pure fragmentation signal.

The read at 4096: s 0.5 peninsula (coast +0.04 N); s 1 onset (999999937 6 -> 12 southern islands,
11 substantial); s 2 few big pieces (35k / 86k-cell fragments); s 3-5 a solid sheet to the trench
whose southern edge is the trench wall - a ruler-straight coast - and whose island count falls as
pieces re-merge. Gravel never dominates. Graduation held, per the task.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
2026-08-22 05:54:12 -04:00
c32a3b177c chat2/07: the region-labeling layer — label all land, tag by construction, tunable speck revert
Core/Scripts/RegionLabeling.cs is the shared-infra contract, built to the letter: it runs on
the CLASSIFY (raw) field; land is 8-connected, the deliberate complement of water's 4 (a
diagonal isthmus joins; the water either side stays separate); a component is a maximal
8-connected set of land cells; the MAINLAND is the component containing the map centre —
not merely the largest, which a later fragmentation step could flip — with a flagged
fallback to the largest if the centre were ever water (asserted, never needed: oracle m);
every other component is an island; per component id / sizeCells / centroid / hemisphere
(by centroid, one label per island) / isMainland. Ids come from a fixed scan order and are
proven stable across two generations (oracle o, 16.8M cells). It knows nothing about
offshore or stamped. Engine-free, in Core as C++-candidate math; the hemisphere convention
moved there with it, OffshoreAnalysis aliases it.

Tools/Scripts/RegionPass.cs is pass 1c: label, revert, relabel, tag. The island tag
(renamed IsIsland; IslandHemisphere from the component's centroid; Pass1Result.Regions
carries the whole table) is now a CONSEQUENCE of labeling — every non-mainland component.
That is the fix for the chat2/06 overlay, which tagged only what the offshore pass raised:
1063685222 has 11 natural islands including a 94,511-cell detached mass, 20260821 has 19,
all grey in 06's tags.png and all coloured now. The offshore pass itself is untouched; its
internal Tag stays for its own guards and is no longer exported.

The speck revert (TerrainGenConfig.SpeckRevert / MinLandComponentFrac) lowers every
non-mainland component below the threshold to the mean of its ring of adjacent sea cells,
held strictly below sea. Origin-blind: a natural nub goes the same way as an offshore dot
(6 natural components / 273 cells on the bare 1063685222 field at threshold_mid — reported
as a3r, informational). Lower-only and component-only are asserted cell by cell in the
pass and re-proven on the finished fields by oracle n (mainland bit-identical filter OFF
vs ON; every changed cell in a sub-threshold island, lowered below sea); the mainland is
never a candidate and its size is asserted unchanged across the revert. A reverted
offshore island leaves its submerged skirt as a shoal — not this component, by the rule.
Classify/render consistency is by construction (pass 1, curve identity at sea) and
asserted by oracle k. Deliberately OFF in the bare TerrainGenConfig for the reason the
shelf and islets are: the raw field has natural specks, so default-ON would move the
calibration pool and every regression dump; the batch turns it on.

Thresholds swept on 8 seeds at 4096 (1e-5 / 3e-5 / 1e-4 of the map = 168 / 503 / 1,678
cells): low removes 0–6 nubs per seed, mid (the config default, equal to the offshore
guard) 2–11, high 26–36 — most of the offshore islands, the "fewer, bigger" bookend. The
count/size table carries natural / pre / post counts per hemisphere, min/median/mean/max
and a log-spaced size histogram — the instrument for the southern-stretch step.

Oracle, all passing: a1, a3, a4 (8192, 67M cells) with labeling ON + revert OFF; a6 NEW —
labeling ON + revert OFF on the 06 preset bit-identical to the 06 batch's render field
(labeling is pure analysis); j0; m, n, o, i, j, k, l, b per field. Batch:
BatchRoot(7, "region_labeling") — exactly 4 plates (three thresholds on 1063685222,
threshold_mid on 20260821, the table's most-natural-islands seed), each with grayscale /
.f32 / relief / the labeled-regions overlay / the tag overlay, plus count_size_table.md/.csv.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
2026-08-22 03:50:33 -04:00
e8571b2778 chat2/06: offshore islands — revert the forced floor, tune the organic layer for coverage
The seeded floor from chat2/05 — FloorNorth/FloorSouth, the PCG32 placement, StampWeight,
StampRadiusFrac/StampCoreFrac/StampEdgeJitter, the centre separation and land gap, the
floor self-check and oracle (h), the centre rings on the tag overlay — is reverted out
whole, as a forward commit. It looked stamped. It is one checkout away at 3b96e06. What
remains is ONE island mechanism: the organic noise-field layer, with chat2/05 stage 2's
shape untouched (freq 16, crest 24 m pre-curve, core 0.25, sharpness 2.5, corners on,
moat + falloff test + outer bound unmoved), now OffshoreSettings.Organic(). The count per
hemisphere is a statistical outcome of the tuning, read off a table, never guaranteed.

The south was measured before anything moved (OffshoreDiagnosis, over task 01's pool at
2048): the premise that the south generates fewer islands is not what the field says.
The NORTH has 1.75x less island-eligible ocean (56 % of its ocean is zone vs 69 %; 233
zone rows per column vs 407) and under half the island candidates (1,183 over-threshold
peaks in zone vs 2,422). Per cell the south is richer too (484 vs 414 candidates per
Mcell). The gate that loses candidates is the moat in the north and the falloff test in
the south — the bulging south coast pushes the 0.72 contour out — but neither binds
enough to suppress it. So the fix is density, and the binding constraint for "a couple
north, consistently" is the north.

The knobs: Density (the main one, the calibration quantile) and SouthWeight (south density
= Density x weight — applied to the quantile, so a south island looks exactly like a north
one; there are just more). Three levels batched on 12 seeds at 4096, SouthWeight 1.25:
0.016 N 2/4.4/10 S 8/13.8/21 (every seed clears N >= 2, S >= 3, but N's minimum IS 2),
0.022 N 3/6.3/12 S 15/19.9/29 (the preset — the first level whose north minimum sits
comfortably above the target), 0.030 N 3/7.8/15 S 19/25.2/33 (the too-many bookend). The
south weight is the developer's stated preference, not a fix; the SouthWeight 1.0 tables
(S 6/10.9/16 at 0.022) are in the batch's scratch/ for the comparison.

The guards, so more density does not buy slop, each a revert by component membership:
specks (< 3e-5 of the map's area, ~500 cells at 4096 — 70-100 per field at these
densities, the noise caps the reference also surfaced), clusters (the smaller of two
islands whose shores are within 0.8 % of the map width — 0-7 per field), blobs (> 6e-4 —
never bit). And the reference's submerged humps, now attributed by HUMP rather than by
bounding box: the first 4096 plate probe showed hollow ghost rings beside islands — a
guard-dropped island's cap went back to seabed but its rim survived inside its
neighbour's bbox keep-region. A hump (one connected raised region) now stays only if it
holds a kept island, and a dropped island's own cap goes even inside a kept hump.

Oracle, all passing: a1 (Phase-1 dump), a3 (task-03 dump), a4 NEW — offshore OFF at 8192
bit-identical to the terrain-curve-v1 tag's own 04 gallery dump over 67,108,864 cells —
j0 shelf inert on land, and per field moat / mainland unmoved / tag-coastline / HMaxSeed
(unchanged, 1.289169) / classify on all 36 table fields and 4 plates. Batch:
BatchRoot(6, "offshore_organic_tune") — exactly 4 plates (three densities on 1063685222,
density_mid on 424242, the table's sparsest south) + count_table.md/.csv + diagnosis.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
2026-08-21 06:22:54 -04:00
3b96e06c1a chat2/05: offshore islands — rejoin the stub faithfully, then reshape to a guaranteed hybrid
Stage 1 fills the IslandFalloff.cs stub from the reference, verbatim: the coast shelf
(below-sea only, depth-preserving, held strictly below sea by MathF.BitDecrement — the
clamp that makes "cannot move the waterline" exact rather than statistical) and the islet
layer (OffshoreBlob, CalibrateThreshold against the field's ACTUAL distribution, the
OffshoreZoneWeight moat + pre-Trench-falloff test). Both run as pass 1b — OffshorePass, a
second sweep over the finished pass-1 arrays with the same per-pixel arithmetic in the
same order — and HMaxSeed is retaken AFTER them, as the reference did. That closes
chat2/00 Drift §2. The value did not move on any of 15 runs; the order is now right by
construction and oracle (l) prints it every time.

Stage 2 is the reshape, OffshoreSettings.Hybrid(): a seeded floor of ≥2 N / ≥4 S islands
placed by a PCG32 off the world seed — min-separated, clear of ALL existing land by a gap
so each stamp is its own connected component by construction, fully inside the zone so
the moat and falloff protections gate the floor exactly as they gate the organic layer —
plus the noise layer on top, smaller (freq 16), lower (24 m pre-curve, which the
preserved toe squashes to ~5 m and keeps there across curve tweaks), flatter (core 0.25),
crisper (edge sharpness 2.5, stamp rim jittered so it is rigid without being a compass
disc), south-weighted (0.007 N / 0.012 S, blended across the midline), corners allowed
(trench mask 0.90→0.97). Every lifted cell is tagged with its hemisphere — NORTH is rows
[0, N/2), y runs south, read off the spine's southern fade and the southern sinker, not
invented — and carried through Pass1Result → Pass2Result for a consumer that does not
exist yet.

Two things the probes taught, both now in the code: the first organic densities produced
183 blobs of which 174 were noise debris (a six-config sweep fixed that), and the
reference's lerp-to-crest leaves shallow humps across the seabed wherever a blob fails to
surface (a guard reverts them outside any surviving island's skirt; specks by component
membership, bumps by location). The faithful control keeps both, because it is the
reference — its islets measure min 1 cell, median 28.

Oracle, all hard checks passing: floor met on every hybrid seed (N 2–4, S 9–16); zero
land bridges; every offshore-OFF land cell bit-identical with offshore ON; tag ↔ coastline
consistent in both fields; curve-off still bit-identical to Phase 1's dump and
continuous_restored to task 03's. Both gates default OFF, deliberately: flipping them is
the act that retires the Phase-1 regression dumps, and that belongs in a task that
re-baselines the oracles.

The faithful control on seed 8675309 produced one north island. That is the gap the
floor exists to close.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCWNaDZPfTiAy3meGNGgqt
2026-08-21 05:14:53 -04:00
5d160511fd chat2/04: the seed gallery — render-only, plus the terrain-curve-v1 checkpoint
Renders the committed curve (tag terrain-curve-v1 = 639dc5f) across 8 draws to answer
two questions the anchor seed alone could not: does the curve produce good islands
generally, and is the mid-slope abruptness a curve trait or a lucky/unlucky draw.

No curve code is touched — `git diff terrain-curve-v1` over ClimbCalibration,
ContinuousCurve, HeightCurve, CurveAnchors, CurveKnots and Shaping is empty.

SeedGalleryTool pins mountainLift/peakSharpness/lowlandCeiling as CONSTANTS with no
env override, unlike every other tool in this phase. A gallery must not be knob-tunable:
a stray ISLA_* left over from a probe would render eight plates of a curve nobody chose
and they would look exactly like the real thing. It re-measures the calibration on task
01's pool at the iteration size, so the plates are the gated curve rather than a
look-alike.

The eight seeds were fixed before the first render and never screened or replaced. Seven
are fresh; none is in the calibration pool, so none shaped the curve it is testing.

Two things worth recording about the metric:

- The curve's normalized mid-slope CANNOT discriminate between seeds. It is dv/du on a
  fixed control polygon and each seed's denormalization is an affine rescale of both
  axes, so it is the same number on every draw by construction. The first cut of the
  tool measured exactly that and dutifully printed an identical 1.86 eight times.
- What ships instead is the spatial height gradient through the 100-220 m band, in
  metres per pixel — the thing the eye is actually reacting to, and genuinely
  seed-dependent through both spikeMax and the terrain's own gradient.

The character note per seed is an eye call, so the tool refuses to invent it: it reads
scratch/character_notes.tsv and prints "(pending)" when absent. ISLA_INDEX_ONLY=1
rebuilds the contact sheet from scratch/metrics.tsv without re-rendering a pixel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCWNaDZPfTiAy3meGNGgqt
2026-08-21 00:20:42 -04:00
639dc5f5a9 Phase 2a: restore the mountain — calibrate the climb to the staircase, not to a guess
chat2/02 dissolved the terraces and lost two thirds of the mountain with them, then
concluded the loss was structural and needed a Phase-1 noise change. That conclusion
was wrong, and this commit is the refutation.

A monotone curve is a free reparametrization: it may be gentle in one place and steep
in another, and can lift bottom-heavy input into a substantial massif without ever
going flat. The area above a height is set by where the percentile->height mapping
crosses it, and that mapping is entirely ours to choose. The 02 sweep that "proved"
the loss structural varied climbFeather, which shapes the JOIN, not the mass
distribution — the wrong knob, and too strong a conclusion drawn from it.

Core/ClimbCalibration — the climb's control points are now MEASURED off the staircase
instead of invented from shape knobs. For p in {10,30,50,70,85,95} of above-ceiling
land, take that percentile's raw height and its staircase output height; PCHIP through
the pairs. That reproduces the staircase's elevation envelope, so the mountain mass
returns, while MinNormalizedSecant floors every grade so the flat bench and plateau
interiors become slope. The floor bites on exactly one segment — the plateau — which
is precisely where the staircase was flat.

ContinuousCurve.BuildCalibrated joins it to the same pinned lowland handover, the same
C1 join and the same per-seed spikeMax. The 02 analytic path survives unchanged as the
"before" contrast, and deliberately keeps its strictly-increasing-secant rule: a
calibrated curve is WAVY by design, so convexity is the wrong invariant for it and the
secant floor is the right one.

peakSharpness replaces summitDrama and fixes its bad trade. Drama steepened the peak by
pulling the summit ONSET down, dragging the whole massif with it (p99 199 -> 121 m).
Sharpness reshapes only above the last measured percentile, leaving that height fixed,
so peak and massif are independent: raising it leaves p90, >100 m and >220 m untouched
and only moves land within the summit.

Measured, both seeds, 2048:

  variant                >100 m   >220 m    p90
  staircase (target)     16.05%    4.53%  127.4 m
  continuous_02default    4.80%    0.63%   58.4 m
  continuous_restored    14.25%    3.45%  123.2 m
  continuous_bigger      19.70%    5.80%  163.5 m

Oracle all hard checks pass, including (a2) staircase still bit-identical to task 01's
dump and (d) lowlands bit-identical across every calibrated variant. New (g) reports
land above 100/220 m per variant and is deliberately NOT gated — it is a taste target
the developer tunes, and gating it would make mountainLift unusable. What it must never
do is stay silent, which is how 02 lost the mountain unnoticed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCWNaDZPfTiAy3meGNGgqt
2026-08-20 04:14:47 -04:00
70 changed files with 9593 additions and 82 deletions

View file

@ -37,6 +37,9 @@ resolution and the file-safety rails. Constants and contracts.
| `Scripts/CurveKnots.cs` | The six INPUT knots — percentiles of the measured land CDF, plus the reference's for comparison. |
| `Scripts/CurveAnchors.cs` | The OUTPUT anchors — the storm-ladder elevations each band lands at. |
| `Scripts/TerrainDetailPass.cs` | Shelf micro-relief + the shelf-edge **knot warp**. Output-height only. |
| `Scripts/HydraulicErosion.cs` | ⭐⭐ **Droplet (hydraulic) erosion** (chat2/11) — the reference's pass ported VERBATIM: four governors (count, lifetime, carve cap, deposit cap) on a net-displacement ledger proven on exit, the sea clamp (below-sea read-only both ways), the cone brush shared by erode and deposit, the crater exclusion (inert until the carve exists). Engine-free, own PCG32, `WorldScale`-denominated (no literal 251). Render-map only — the caller (`Tools/ErosionPass`) owns the split and the flood guard. |
| `Scripts/DrainageAnalysis.cs` | ⭐⭐ **Drainage analysis** (chat2/12) — the reference's river-PLAN pass ported verbatim: priority-flood routing fill (one ulp above the parent, terrain never written), **D8 flow directions FOR ANALYSIS ONLY** (D8 was reverted as a carving technique), Kahn accumulation, outlets ranked by drainage area, endorheic terminals credited TOTAL inflow, promoted giants. Engine-free, `WorldScale`-denominated. "The sea" = the ocean body from `RegionLabeling.OceanMask`. |
| `Scripts/RegionLabeling.cs` | ⭐⭐ **The region-labeling layer** (chat2/07) — shared infrastructure. 8-connected land components on the CLASSIFY field; mainland = the centre component; per component id / size / centroid / hemisphere (by centroid) / isMainland. Pure, engine-free, C++-candidate; a **contract** downstream phases consume (islands first; biomes, placement, rivers, the crater later). The hemisphere convention lives here. |
| `Scripts/ToolingPaths.cs` | Every tooling path, env-overridable, resolved in one place. |
| `Scripts/FileSafety.cs` | The permanent file-safety rules, as throws rather than sentences. |
@ -56,9 +59,11 @@ resolution and the file-safety rails. Constants and contracts.
- **No water.** Water is an overlay over the columns (levels-not-cells), never a band.
- **No biomes.** Biomes are a later *classification* of finished shape, not an input to it (D-049).
- **No algorithms** *beyond the height curve*. Phase 2 added `HeightCurve` and
- **No algorithms** *beyond the height curve and the region layer*. Phase 2 added `HeightCurve` and
`TerrainDetailPass` here because they are pure, engine-free, C++-candidate math that defines the
world's elevation profile — a contract, not a tool's dial. Stratigraphy, feature passes, meshing
world's elevation profile — a contract, not a tool's dial. chat2/07 added `RegionLabeling` on the
same grounds: region identity is a contract every later phase reads, and the flood fill is a hot
path. (The speck REVERT that uses it is a pass, and lives in `Tools/``RegionPass`.) Stratigraphy, feature passes, meshing
and run-splitting on dig are still later phases.
- **No erosion, rivers, water bodies, crater carve, coast shelf or offshore islets.** Later chat2
tasks; the curve is deliberately the only pass-2 element present.

View file

@ -0,0 +1,300 @@
using System;
using System.Text;
namespace IslaApocalypse.Core
{
/// <summary>
/// ⭐⭐ THE CLIMB'S CONTROL POINTS, MEASURED FROM THE STAIRCASE (chat2/03) — "the staircase's
/// mountain with the terraces melted out".
///
/// ═══ THE MISTAKE THIS TYPE CORRECTS ═══
///
/// chat2/02 built the climb from ANALYTIC control points (a feather and a drama knob) and got a
/// bottom-heavy curve: land above 100 m fell from ~15 % to ~4.8 %. That report concluded the loss
/// was STRUCTURAL — that a no-magnet monotone curve must preserve the raw distribution's
/// bottom-heavy shape, so only a Phase-1 noise change could restore the mountain.
///
/// > ### ⚠ THAT CONCLUSION WAS WRONG, AND THIS TYPE IS THE PROOF.
/// >
/// > A monotone curve is a free reparametrization: it may be gentle in one place and steep in
/// > another, and can LIFT bottom-heavy input into a substantial mid-massif without ever going
/// > flat. **No-flats and lift-the-mass are compatible.** The area of land above a given height
/// > is set by where the percentile→height mapping CROSSES that height, and that mapping is
/// > entirely ours to choose.
/// >
/// > The 02 sweep that "proved" the loss structural varied <c>climbFeather</c>, which shapes the
/// > JOIN, not the mass distribution. It was the wrong knob, and the conclusion generalized from
/// > it was too strong.
///
/// ═══ WHAT THE STAIRCASE'S BENCHES ACTUALLY DID ═══
///
/// They did not CREATE highland. They LIFTED land to 100 m and 220 m. The same ~27 % of land
/// above the ceiling exists in both curves; 02's analytic climb simply placed it low. So the fix
/// is not to make more high land — it is to put the land that is already there back where the
/// staircase had it, as a smooth slope.
///
/// ═══ THE METHOD — the same percentile idea as task 01's knots, one level up ═══
///
/// Task 01 measured percentiles of the raw distribution to place the curve's INPUT knots. This
/// measures percentiles of the staircase's ABOVE-CEILING land to place the climb's OUTPUT
/// heights:
///
/// for each p in {10, 30, 50, 70, 85, 95}:
/// u_p = normalized RAW position of above-ceiling land at percentile p
/// v_p = normalized OUTPUT height of above-ceiling land at percentile p (staircase)
///
/// PCHIP through <c>(0,0), (u_p, v_p)…, (1,1)</c> reproduces the staircase's elevation envelope —
/// the same land ends up at the same heights, so the mountain mass returns — while the flat bench
/// and plateau INTERIORS become smooth grade.
///
/// ═══ ⚠ WHERE THE STAIRCASE WAS FLAT, WE MUST DEVIATE — AND THAT IS THE POINT ═══
///
/// A bench maps a wide input band onto a narrow output band, so two adjacent percentiles land at
/// nearly the same height and their secant is near zero. Reproducing THAT would rebuild the
/// bench. <see cref="MinNormalizedSecant"/> floors every segment's grade and renormalizes, so the
/// curve passes THROUGH the bench height with slope instead of running ALONG it. The floor bites
/// only where the staircase was flat; everywhere else the calibration is reproduced.
/// </summary>
public sealed class ClimbCalibration
{
/// <summary>
/// The above-ceiling land percentiles sampled. Six is a handful — enough to carry the
/// staircase's envelope, few enough that PCHIP interpolates smoothly between them rather
/// than tracing every wobble of the bench.
/// </summary>
public static readonly double[] DefaultPercentiles = { 10.0, 30.0, 50.0, 70.0, 85.0, 95.0 };
/// <summary>
/// The no-bench floor: no segment's grade may fall below this fraction of the climb's average
/// grade (1.0 = average). 0.35 is comfortably above <see cref="ContinuousCurve"/>'s own
/// near-flat tripwire and well below the grades the calibration produces outside the benches,
/// so it is a repair for the flats and a no-op everywhere else.
/// </summary>
public const float MinNormalizedSecant = 0.35f;
/// <summary>Iterations of floor-then-renormalize. It converges in a few; 24 is free insurance.</summary>
private const int RepairIterations = 24;
/// <summary>Normalized control points, strictly increasing in both. Includes (0,0) and (1,1).</summary>
public readonly float[] U, V;
/// <summary>The percentiles sampled, and the raw/output heights measured at each — for the report.</summary>
public readonly double[] Percentiles;
public readonly float[] RawAt, TargetHeightAt;
/// <summary>The knobs this calibration was shaped with.</summary>
public readonly float MountainLift, PeakSharpness;
/// <summary>
/// Normalized u of the summit onset — the LAST measured percentile. Above it,
/// <see cref="PeakSharpness"/> reshapes; below it, nothing does. That is the decoupling.
/// </summary>
public readonly float SummitOnsetU;
/// <summary>How many segments the no-bench floor had to lift. Zero means the staircase had no flats.</summary>
public readonly int SegmentsFloored;
private ClimbCalibration(float[] u, float[] v, double[] pcts, float[] rawAt, float[] targetAt,
float lift, float sharp, float onsetU, int floored)
{
U = u; V = v; Percentiles = pcts; RawAt = rawAt; TargetHeightAt = targetAt;
MountainLift = lift; PeakSharpness = sharp; SummitOnsetU = onsetU; SegmentsFloored = floored;
}
/// <summary>
/// Build the calibration from measured quantiles.
///
/// ⚠ Takes plain arrays, not a histogram: <c>LandHistogram</c> lives in <c>Tools/</c> and Core
/// depends on nothing above it. The caller measures; this shapes.
/// </summary>
/// <param name="percentiles">The percentiles sampled, ascending.</param>
/// <param name="rawQuantiles">Above-ceiling RAW height at each percentile.</param>
/// <param name="outQuantiles">Above-ceiling STAIRCASE OUTPUT height at each percentile.</param>
/// <param name="mountainLift">
/// 1.0 = reproduce the staircase's mountain. &gt;1 lifts the mid-massif higher; &lt;1 lowers it
/// toward chat2/02's bottom-heavy default. Applied as <c>v ← v^(1/lift)</c>, which is monotone
/// and fixes both endpoints, so it can move the massif without touching sea level or the cap.
/// </param>
/// <param name="peakSharpness">
/// ⭐ ACTS ONLY ABOVE THE LAST MEASURED PERCENTILE. 1.0 = a straight run to the cap; higher
/// defers the rise so the final approach steepens and the peak reads pointy.
/// ⚠ Unlike chat2/02's <c>summitDrama</c>, it CANNOT lower the massif — the onset's height is
/// fixed by the calibration before this is applied. That is the §3 fix.
/// </param>
public static ClimbCalibration FromPercentiles(
double[] percentiles, float[] rawQuantiles, float[] outQuantiles,
float ceilingRaw, float spikeMax, float ceilingOut, float peakCap,
float mountainLift, float peakSharpness)
{
int n = percentiles.Length;
if (rawQuantiles.Length != n || outQuantiles.Length != n)
throw new ArgumentException("[ClimbCalibration] percentile/raw/output arrays must be the same length.");
if (mountainLift <= 0f)
throw new ArgumentOutOfRangeException(nameof(mountainLift), mountainLift, "mountainLift must be positive.");
if (peakSharpness < 1f)
throw new ArgumentOutOfRangeException(nameof(peakSharpness), peakSharpness,
"peakSharpness < 1 would make the summit's final approach SHALLOWER than its own average — a ramp, not a peak.");
float spanRaw = spikeMax - ceilingRaw;
float spanOut = peakCap - ceilingOut;
if (spanRaw <= 0f || spanOut <= 0f)
throw new InvalidOperationException("[ClimbCalibration] the climb has no room — ceiling meets the summit.");
// ---- normalize the measured points, plus the two exact endpoints ----
var u = new float[n + 2];
var v = new float[n + 2];
u[0] = 0f; v[0] = 0f;
u[n + 1] = 1f; v[n + 1] = 1f;
for (int i = 0; i < n; i++)
{
u[i + 1] = Math.Clamp((rawQuantiles[i] - ceilingRaw) / spanRaw, 0f, 1f);
v[i + 1] = Math.Clamp((outQuantiles[i] - ceilingOut) / spanOut, 0f, 1f);
}
// ⚠ u must be STRICTLY increasing for PCHIP. Percentiles of a continuous distribution
// give that naturally; a degenerate seed (a plateau in the raw CDF) could not. Nudge
// rather than throw — a hair of u-spacing is not a shape decision.
const float minDu = 1e-4f;
for (int i = 1; i < u.Length; i++)
if (u[i] <= u[i - 1] + minDu) u[i] = u[i - 1] + minDu;
// Renormalize back onto [0,1] if the nudging pushed past the end.
if (u[u.Length - 1] > 1f)
{
float s = 1f / u[u.Length - 1];
for (int i = 1; i < u.Length; i++) u[i] *= s;
u[u.Length - 1] = 1f;
}
// ---- mountainLift: v ← v^(1/lift). Monotone, endpoints fixed. ----
if (Math.Abs(mountainLift - 1f) > 1e-6f)
{
float e = 1f / mountainLift;
for (int i = 1; i <= n; i++) v[i] = MathF.Pow(v[i], e);
}
// ---- the no-bench repair: floor every grade, renormalize to keep v(1) = 1 ----
float onsetU = u[n]; // the last measured percentile
int floored = RepairSecants(u, v, out _);
// ---- peakSharpness: reshape ONLY the segment above the onset ----
// Insert a midpoint whose height defers the rise, so the final approach steepens.
// v_mid = v_onset + (1 - v_onset) * 0.5^sharpness ⇒ sharpness 1 is exactly linear.
if (peakSharpness > 1f + 1e-6f)
{
float uS = u[n], vS = v[n];
float uMid = (uS + 1f) * 0.5f;
float vMid = vS + (1f - vS) * MathF.Pow(0.5f, peakSharpness);
var u2 = new float[u.Length + 1];
var v2 = new float[v.Length + 1];
Array.Copy(u, u2, n + 1); Array.Copy(v, v2, n + 1);
u2[n + 1] = uMid; v2[n + 1] = vMid;
u2[n + 2] = 1f; v2[n + 2] = 1f;
u = u2; v = v2;
// ⚠ The deferred first half must still not be a bench. Re-floor ONLY that segment,
// leaving the calibrated massif below the onset untouched — re-running the global
// repair here would renormalize the massif and undo the decoupling.
float du = uMid - uS;
float minDv = MinNormalizedSecant * du;
if (vMid - vS < minDv) v[n + 1] = vS + minDv;
}
var rawAt = (float[])rawQuantiles.Clone();
var outAt = (float[])outQuantiles.Clone();
var cal = new ClimbCalibration(u, v, (double[])percentiles.Clone(), rawAt, outAt,
mountainLift, peakSharpness, onsetU, floored);
cal.AssertUsable();
return cal;
}
/// <summary>
/// Floor every segment's normalized grade at <see cref="MinNormalizedSecant"/> and renormalize
/// so the last point still lands exactly on 1. Iterated, because renormalizing can push a
/// floored segment back under the floor; it converges as long as the un-floored segments have
/// room to absorb the excess.
/// </summary>
private static int RepairSecants(float[] u, float[] v, out float minSecant)
{
int m = u.Length;
var s = new float[m - 1];
var du = new float[m - 1];
for (int i = 0; i < m - 1; i++)
{
du[i] = u[i + 1] - u[i];
s[i] = (v[i + 1] - v[i]) / du[i];
}
int flooredCount = 0;
for (int it = 0; it < RepairIterations; it++)
{
int hit = 0;
for (int i = 0; i < s.Length; i++)
if (s[i] < MinNormalizedSecant) { s[i] = MinNormalizedSecant; hit++; }
flooredCount = hit;
float total = 0f;
for (int i = 0; i < s.Length; i++) total += s[i] * du[i];
if (Math.Abs(total - 1f) < 1e-6f) break;
for (int i = 0; i < s.Length; i++) s[i] /= total;
}
// Rebuild v from the repaired grades.
minSecant = float.MaxValue;
v[0] = 0f;
for (int i = 0; i < s.Length; i++)
{
if (s[i] < minSecant) minSecant = s[i];
v[i + 1] = v[i] + s[i] * du[i];
}
v[m - 1] = 1f; // exact, against accumulated float drift
return flooredCount;
}
/// <summary>
/// The invariants a calibration must satisfy before it is allowed to shape terrain. Throws
/// and refuses, rather than producing a curve nobody checked.
/// </summary>
private void AssertUsable()
{
for (int i = 1; i < U.Length; i++)
{
if (U[i] <= U[i - 1])
throw new InvalidOperationException(
$"[ClimbCalibration] control point {i} is not strictly right of its predecessor " +
$"(u {U[i - 1]} → {U[i]}). Refusing to generate.");
if (V[i] <= V[i - 1])
throw new InvalidOperationException(
$"[ClimbCalibration] control point {i} does not RISE (v {V[i - 1]} → {V[i]}) — that is a " +
$"bench, which is the artifact this mode exists to remove. Refusing to generate.");
}
if (Math.Abs(U[0]) > 1e-6f || Math.Abs(V[0]) > 1e-6f
|| Math.Abs(U[U.Length - 1] - 1f) > 1e-6f || Math.Abs(V[V.Length - 1] - 1f) > 1e-6f)
throw new InvalidOperationException(
"[ClimbCalibration] the endpoints must be exactly (0,0) and (1,1) — the lowland handover and " +
"the peak cap are not negotiable. Refusing to generate.");
}
/// <summary>The calibration as one line for the INDEX, the log and the report.</summary>
public string Describe()
{
var sb = new StringBuilder();
sb.Append($"lift {MountainLift:F2} sharp {PeakSharpness:F2} onsetU {SummitOnsetU:F3} " +
$"floored {SegmentsFloored} · uv ");
for (int i = 0; i < U.Length; i++) sb.Append($"({U[i]:F3},{V[i]:F3}) ");
return sb.ToString().TrimEnd();
}
/// <summary>The measured percentile table, for the report.</summary>
public string DescribeMeasured(float ceilingOut, float peakCap)
{
var sb = new StringBuilder();
for (int i = 0; i < Percentiles.Length; i++)
sb.Append($"P{Percentiles[i]:F0}→{WorldScale.MetresFromRaw(TargetHeightAt[i] - 0.15f):F0}m ");
return sb.ToString().TrimEnd();
}
}
}

View file

@ -0,0 +1 @@
uid://cl0hijnaw76jq

View file

@ -109,18 +109,106 @@ namespace IslaApocalypse.Core
/// <summary>The knob values this spline was built from, for the INDEX and the report.</summary>
public readonly float LowlandCeilingM, ClimbFeather, SummitDrama;
/// <summary>
/// ⭐ The measured calibration this climb was shaped from (chat2/03), or null when the climb
/// came from chat2/02's ANALYTIC feather/drama points.
///
/// Non-null is the current default: "the staircase's mountain with the terraces melted out".
/// Null survives so the 02 curve stays reproducible as a contrast variant — it is the "before"
/// in the three-way histogram story, not a fallback.
/// </summary>
public readonly ClimbCalibration Calibration;
/// <summary>Where the summit begins, normalized — the calibration's onset when calibrated, else the constant.</summary>
public float EffectiveSummitOnset => Calibration?.SummitOnsetU ?? SummitOnset;
// Control points (raw x, out y) and the FritschCarlson tangents. x strictly increasing.
private readonly float[] _x, _y, _m;
private ContinuousCurve(CurveKnots k, CurveAnchors a, float ceilingRaw, float ceilingOut,
float spikeMax, float joinSlopeRaw, float lowlandCeilingM, float climbFeather,
float summitDrama, float[] x, float[] y, float[] m)
float summitDrama, float[] x, float[] y, float[] m, ClimbCalibration calibration = null)
{
Knots = k; Anchors = a;
CeilingRaw = ceilingRaw; CeilingOut = ceilingOut; SpikeMax = spikeMax;
JoinSlopeRaw = joinSlopeRaw;
LowlandCeilingM = lowlandCeilingM; ClimbFeather = climbFeather; SummitDrama = summitDrama;
_x = x; _y = y; _m = m;
_x = x; _y = y; _m = m; Calibration = calibration;
}
/// <summary>
/// ⭐⭐ THE CALIBRATED CLIMB (chat2/03) — control points MEASURED from the staircase's
/// above-ceiling elevation distribution rather than invented from two shape knobs.
/// → <see cref="ClimbCalibration"/> for the method and for the chat2/02 mistake it corrects.
///
/// Everything outside the climb is identical to <see cref="Build"/>: the same lowland
/// handover pinned to the exact anchors, the same C¹ join to the red band's exit slope, the
/// same per-seed <paramref name="spikeMax"/>, the same tail. Only the interior shape changes.
///
/// ⚠ THIS PATH DOES NOT REQUIRE STRICTLY-INCREASING SECANTS, and that is deliberate. The 02
/// analytic path enforced a convex control polygon as its no-magnet rule. A curve calibrated
/// to real terrain is WAVY — gentler where the staircase had a bench, steeper through its
/// risers — so convexity is the wrong invariant here. The no-magnet guarantee instead comes
/// from <see cref="ClimbCalibration.MinNormalizedSecant"/>, which floors every grade: the
/// curve may slow down, but never to a bench.
/// </summary>
public static ContinuousCurve BuildCalibrated(CurveKnots k, CurveAnchors a, float spikeMax,
float lowlandCeilingM, ClimbCalibration calibration)
{
if (calibration == null) throw new ArgumentNullException(nameof(calibration));
var (ceilingRaw, ceilingOut, redSlope) = ResolveHandover(k, a, lowlandCeilingM);
if (ceilingRaw >= spikeMax - 1e-3f)
throw new InvalidOperationException(
$"[ContinuousCurve] lowland ceiling (raw {ceilingRaw:F4}) reaches this seed's summit " +
$"(spikeMax {spikeMax:F4}) — no room for a climb. Refusing.");
float spanRaw = spikeMax - ceilingRaw;
float spanOut = a.PeakCap - ceilingOut;
int n = calibration.U.Length;
var x = new float[n];
var y = new float[n];
for (int i = 0; i < n; i++)
{
x[i] = ceilingRaw + calibration.U[i] * spanRaw;
y[i] = ceilingOut + calibration.V[i] * spanOut;
}
float[] m = FritschCarlsonTangents(x, y, startTangent: redSlope);
return new ContinuousCurve(k, a, ceilingRaw, ceilingOut, spikeMax, redSlope,
lowlandCeilingM, climbFeather: float.NaN, summitDrama: float.NaN, x, y, m, calibration);
}
/// <summary>
/// Where the preserved lowland hands over to the climb, and the red band's exit slope.
///
/// ⚠ THE FLOOD LINE IS PINNED TO THE EXACT ANCHORS, and "30 m" is NOMINAL: RED_CEIL SEA is
/// 0.12 raw = 30.12 m. Any requested ceiling at or below the red ceiling hands over at
/// EXACTLY <c>(K2, RED_CEIL)</c> — no derived floats — so the linear extension is empty by
/// construction and the preserved toe+red band can never be cut by a rounding. (chat2/02's
/// first run refused its own default over that 0.12 m gap; pinning is the fix, not a wider
/// tolerance.)
/// </summary>
private static (float ceilingRaw, float ceilingOut, float redSlope) ResolveHandover(
CurveKnots k, CurveAnchors a, float lowlandCeilingM)
{
float redSlope = (a.RedCeil - a.OrangeCeil) / (k.K2 - k.K1);
if (lowlandCeilingM > MaxLowlandCeilingM)
throw new InvalidOperationException(
$"[ContinuousCurve] lowlandCeiling {lowlandCeilingM:F1} m is above the {MaxLowlandCeilingM:F0} m " +
"bound — close enough to the old bench (100±12 m) to preserve a flat one, which is the " +
"artifact this mode exists to remove. Refusing.");
float redCeilM = WorldScale.MetresFromRaw(a.RedCeil - a.Sea);
if (lowlandCeilingM <= redCeilM + 0.01f)
return (k.K2, a.RedCeil, redSlope);
float ceilingOut = a.Sea + WorldScale.RawFromMetres(lowlandCeilingM);
return (k.K2 + (ceilingOut - a.RedCeil) / redSlope, ceilingOut, redSlope);
}
/// <summary>
@ -134,35 +222,8 @@ namespace IslaApocalypse.Core
public static ContinuousCurve Build(CurveKnots k, CurveAnchors a, float spikeMax,
float lowlandCeilingM, float climbFeather, float summitDrama)
{
// ---- the preserved lowland's edge ----
float redSlope = (a.RedCeil - a.OrangeCeil) / (k.K2 - k.K1);
if (lowlandCeilingM > MaxLowlandCeilingM)
throw new InvalidOperationException(
$"[ContinuousCurve] lowlandCeiling {lowlandCeilingM:F1} m is above the {MaxLowlandCeilingM:F0} m " +
"bound — close enough to the old bench (100±12 m) to preserve a flat one, which is the " +
"artifact this mode exists to remove. Refusing.");
// ⚠ THE FLOOD LINE IS THE FLOOR, and "30 m" is NOMINAL: RED_CEIL SEA = 0.12 raw is
// actually 30.12 m through the yardstick. Any requested ceiling at or below the red
// ceiling means "hand over exactly where the preserved lowland ends", and that handover
// is pinned to THE EXACT ANCHORS — (K2, RED_CEIL), no derived floats — so the extension
// region is empty by construction and the toe+red band can never be cut. (The first
// probe run refused its own default over this 0.12 m nominal gap; pinning is the fix,
// not widening a tolerance.)
float redCeilM = WorldScale.MetresFromRaw(a.RedCeil - a.Sea);
float ceilingOut, ceilingRaw;
if (lowlandCeilingM <= redCeilM + 0.01f)
{
ceilingOut = a.RedCeil;
ceilingRaw = k.K2;
}
else
{
ceilingOut = a.Sea + WorldScale.RawFromMetres(lowlandCeilingM);
// Where the linear red-slope extension reaches that output.
ceilingRaw = k.K2 + (ceilingOut - a.RedCeil) / redSlope;
}
// ---- the preserved lowland's edge — shared with BuildCalibrated ----
var (ceilingRaw, ceilingOut, redSlope) = ResolveHandover(k, a, lowlandCeilingM);
if (ceilingRaw >= spikeMax - 1e-3f)
throw new InvalidOperationException(
@ -360,7 +421,7 @@ namespace IslaApocalypse.Core
if (v <= prev)
throw new InvalidOperationException(
$"[ContinuousCurve] MONOTONICITY VIOLATION at h={h}: {v} <= {prev} " +
$"(ceiling {LowlandCeilingM:F0} m, feather {ClimbFeather:F2}, drama {SummitDrama:F2}). Refusing to generate.");
$"(ceiling {LowlandCeilingM:F0} m, {KnobSummary()}). Refusing to generate.");
prev = v;
prevH = h;
}
@ -380,7 +441,7 @@ namespace IslaApocalypse.Core
float spanRaw = SpikeMax - CeilingRaw;
float spanOut = Anchors.PeakCap - CeilingOut;
float toN = spanRaw / spanOut; // raw slope → normalized
float onsetRaw = CeilingRaw + SummitOnset * spanRaw;
float onsetRaw = CeilingRaw + EffectiveSummitOnset * spanRaw;
float s0N = JoinSlopeRaw * toN;
float minN = float.MaxValue, maxN = float.MinValue, minAt = 0f, maxAt = 0f;
@ -400,15 +461,24 @@ namespace IslaApocalypse.Core
/// <summary>Raw height where the summit onset sits, and its output — for histogram overlays.</summary>
public (float raw, float outp) SummitOnsetPoint()
{
float r = CeilingRaw + SummitOnset * (SpikeMax - CeilingRaw);
float r = CeilingRaw + EffectiveSummitOnset * (SpikeMax - CeilingRaw);
return (r, Apply(r));
}
/// <summary>
/// The shaping knobs, named for whichever path built this curve — chat2/02's analytic
/// feather/drama or chat2/03's measured lift/sharpness. ⚠ The analytic fields are NaN on a
/// calibrated curve, so nothing may print them unconditionally.
/// </summary>
public string KnobSummary() => Calibration != null
? $"lift {Calibration.MountainLift:F2} sharp {Calibration.PeakSharpness:F2} (calibrated)"
: $"feather {ClimbFeather:F2} drama {SummitDrama:F2} (analytic 02)";
/// <summary>The control points as one line for the INDEX and the report.</summary>
public string DescribeControlPoints()
{
var sb = new StringBuilder();
sb.Append($"ceiling {LowlandCeilingM:F0}m feather {ClimbFeather:F2} drama {SummitDrama:F2} · points ");
sb.Append($"ceiling {LowlandCeilingM:F0}m {KnobSummary()} · points ");
for (int i = 0; i < _x.Length; i++)
sb.Append($"({_x[i]:F4},{_y[i]:F4}{(i == 0 ? " C1" : "")}) ");
sb.Append($"· join slope {JoinSlopeRaw:F4} raw");

View file

@ -0,0 +1,698 @@
using System;
using System.Collections.Generic;
namespace IslaApocalypse.Core
{
/// <summary>
/// ⭐⭐ DRAINAGE ANALYSIS — THE FAITHFUL PORT (chat2/12). From the reference's
/// <c>Tools/Scripts/DrainageAnalysis.cs</c> at tag <c>pre-rewrite-reference</c> (<c>ab78883</c>), verbatim
/// in arithmetic and order (D-050). PURE ANALYSIS: it reads the ERODED render heightmap and produces a
/// river PLAN — it changes zero terrain and adds zero water.
///
/// ═══ WHAT THE PORT CHANGES (and nothing else) ═══
///
/// • Namespace + location: <c>IslaApocalypse.Core</c> — engine-free, a C++ candidate.
/// • The yardstick: metres via <see cref="WorldScale.MetresFromRaw"/> (the same <c>× 251f</c>; no literal).
/// • <see cref="Plan.FullFilled"/> and <see cref="Plan.Filled"/> are EXPOSED (the reference kept the
/// routing surfaces local) so the caller can prove the routing-fill invariants on them.
///
/// ═══ ⚠⚠ THE D8 LANDMINE ═══
///
/// D8 flow direction is CORRECT FOR ANALYSIS and is used here for exactly that. It was REVERTED as a
/// CARVING technique (the prototype's task 10 — straight, grid-aligned grooves; → `Design - Terrain -
/// D8 Incision Revert`). Use D8 to COMPUTE, never to CARVE. Nothing in this file writes terrain.
///
/// ═══ ⚠ ENDORHEIC BASINS ARE EXPECTED, FIRST-CLASS OUTPUT ═══
///
/// This terrain's biggest drainages pool inland: erosion delivers the upland network only and cannot
/// cross the flats. A screen full of endorheic basins is the CORRECT result, not a bug.
///
/// ═══ THE REFERENCE'S CLASS DOC (verbatim) ═══
///
/// Drainage-network promotion — C0b part 1 (terrain-water task 21). PURE ANALYSIS:
/// reads the ERODED render heightmap and produces a river PLAN — it changes zero
/// terrain and adds zero water. Standalone numeric (D-035 family; no Godot types).
///
/// Pipeline, built on the task-03 priority-flood family:
/// 1. Priority-flood the eroded surface from the map border (Barnes heap+pit
/// variant, 8-connected, same as RunPriorityFloodDiagnostics) — but with a
/// one-ulp epsilon on pit fills, so every filled cell keeps a STRICTLY
/// descending path to its spill. This resolves the ~15,000 erosion pits
/// (task-20 finding) for ROUTING ONLY; the terrain itself is never modified.
/// 2. Depressions that are deep AND large enough (the endorheic dials) are NOT
/// filled through: their cells revert to original heights, so flow entering
/// them terminates at the basin minimum. Real closed drainage survives;
/// micro-pits route through.
/// 3. D8 flow directions on that routing surface. D8 was reverted as a CARVING
/// technique (task 10 — grid-aligned scratches in the terrain); using it to
/// COMPUTE where water flows is standard hydrology and leaves no mark.
/// 4. Flow accumulation by topological (Kahn) propagation — no sort needed.
/// 5. Promotion: outlets to the sea ranked by drainage area, top-N (separated)
/// become trunks; main stems traced upstream by max-accumulation; the
/// mountain-exit point found from the along-stem grade; LEAN tributaries and
/// LEAN endorheic terminals marked.
///
/// The plan's lowland courses are provisional: erosion delivered the UPLAND
/// network only (task 18 §3), so below each mountain-exit the traced course is
/// "where the routing surface drains", not a designed river. Part 2 (task 22)
/// routes the lowland reach properly from the mountain-exit points — which is why
/// those points are this analysis's key output.
/// </summary>
public static class DrainageAnalysis
{
// Neighbour order is FIXED (it is the deterministic tiebreak).
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
private static readonly float[] DIST = {
1.41421356f, 1f, 1.41421356f, 1f, 1f, 1.41421356f, 1f, 1.41421356f };
public const sbyte D_NONE = -1, D_SEA = -2;
public class Params
{
// Endorheic qualification: a depression this deep AND this large is a real
// closed basin and terminates flow; anything smaller is a pit, filled through.
public float EndorheicMinDepthM = 2.0f;
public int EndorheicMinAreaPx = 10000;
// Endorheic REPORTING is lean: only terminals with at least this much
// upstream drainage, at most MaxCount of them.
public int EndorheicMinInflowPx = 50000;
public int EndorheicMaxCount = 3;
public int TrunkCount = 3; // ~3 sea-reaching trunks (developer)
public int GiantCount = 3; // 21b: top endorheic giants promoted
public int MinOutletSeparationPx = 400; // don't pick 3 mouths of one delta
public int StemMinAccPx = 1000; // stem tracing stops below this
public int TributaryMinAccPx = 30000; // LEAN: a branch must drain this much
public int TributaryMaxPerTrunk = 4; // ...and only the top few are marked
// Mountain-exit: furthest-downstream stem point where the upstream window
// still sustains this grade (m per px) over ExitWindowPx.
public float ExitGradeMin = 0.05f;
public int ExitWindowPx = 100;
public float SeaLevel = 0.15f; // flat sea scalar (raw units)
}
public class Stream
{
public List<(float x, float y)> Course = new(); // downstream-first
public long DrainageAreaPx;
public (float x, float y) Head; // upstream end
}
public class Trunk : Stream
{
public (float x, float y) Outlet; // last land cell before sea
public (float x, float y) MountainExit;
public float MountainExitElevM;
public bool ExitFound;
public List<Stream> Tributaries = new();
}
/// <summary>
/// A promoted endorheic giant (task 21b): one of the island's biggest drainage
/// systems, which pools inland because erosion could not cross the flats.
/// Kind "routed" carries a PROVISIONAL route across the flats to the ocean —
/// the path part 2 would carve, drawn for the gate, not water. Kind
/// "lake-ender" keeps its lake/lagoon terminal (real geography, developer's
/// call). Terminal is where the MAIN STEM actually pools (its sub-minimum),
/// which on a flat basin floor is more truthful than the basin's deepest cell.
/// ⚠ chat2/12 computes the provisional route as the reference did (it is analysis) but does NOT
/// promote or draw it — lowland routing is a later task.
/// </summary>
public class Giant : Stream
{
public (float x, float y) Terminal;
public (float x, float y) Spill; // where the basin overtops
public float BasinDepthM;
public long BasinAreaPx;
public string Kind = "routed"; // "routed" | "lake-ender"
public bool SouthernCandidate;
public bool TerminalInClassifyWater;
public List<(float x, float y)> ProvisionalRoute; // null for lake-enders
public bool RouteReachedOcean;
public (float x, float y) MountainExit;
public float MountainExitElevM;
public bool ExitFound;
public List<Stream> Tributaries = new();
}
public class EndorheicTerminal
{
public (float x, float y) Terminal; // basin minimum
public long DrainageAreaPx;
public float BasinDepthM;
public long BasinAreaPx;
}
public class Plan
{
public List<Trunk> Trunks = new();
public List<EndorheicTerminal> Endorheics = new();
public List<Giant> Giants = new(); // 21b: the promoted giants
public int TerminalBasinCount; // basins that qualified as sinks
public long PitsFilledCount; // depressions filled through
public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells;
public List<(float x, float y, long acc)> AllOutletsTop = new(); // top 12, pre-separation
public Params P;
// ---- exposed by the port (the reference kept these local) ----
/// <summary>D8 direction per cell (row-major x·n+y): 0..7, <see cref="D_SEA"/>, <see cref="D_NONE"/>. Analysis only.</summary>
public sbyte[] Dir;
/// <summary>Flow accumulation per cell (row-major); 0 on ocean.</summary>
public int[] Acc;
/// <summary>The routing surface after terminal basins reverted (row-major).</summary>
public float[] Filled;
/// <summary>The FULL priority-flood fill, before terminal reversion (row-major) — every cell drains to the border on it.</summary>
public float[] FullFilled;
/// <summary>Terminal-basin id per cell (row-major), 0 = none.</summary>
public int[] BasinId;
/// <summary>Per terminal basin id: total inflow (cells whose flow ends there).</summary>
public long[] BasinInflow;
}
/// <param name="isOcean">Row-major mask of THE OCEAN body — the only water that counts as "the sea" for
/// sea-reaching trunks (in v2: <c>RegionLabeling.OceanMask</c>, the classify field's border-connected water).
/// Below-sea cells that are NOT ocean (enclosed lagoons, below-datum lake beds, island-fringe waters) are
/// ordinary terrain to the router: as depressions they either qualify as terminal basins or fill and spill
/// onward to the true sea.</param>
/// <param name="isClassifyWater">Row-major mask of ANY classify water: a giant whose main stem pools inside
/// classify water is a natural lake-ender; one pooling on dry ground is a route-to-sea candidate.</param>
/// <param name="southX">Southernmost-town position (or -1 for none): the giant whose terminal lies closest is
/// flagged the SOUTHERN CANDIDATE and always routed provisionally, per the 21b design — shown, not forced.</param>
public static Plan Run(float[,] height, int mapSize, bool[] isOcean,
bool[] isClassifyWater, float southX, float southY, Params p)
{
int n = mapSize;
int total = n * n;
var plan = new Plan { P = p };
// 1-D row-major copies (idx = x * n + y), same convention as the task-03 pass.
float[] original = new float[total];
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
original[x * n + y] = height[x, y];
float[] plan_fullFilled = null; // set inside step 2, used by 21b routing
// --- 1. Priority-flood with one-ulp epsilon (routing surface only) ---
float[] filled = (float[])original.Clone();
{
bool[] visited = new bool[total];
var heap = new PriorityQueue<int, (float h, int idx)>();
var pit = new Queue<int>();
void Seed(int idx)
{
if (visited[idx]) return;
visited[idx] = true;
heap.Enqueue(idx, (filled[idx], idx)); // idx tiebreak => deterministic
}
for (int x = 0; x < n; x++) { Seed(x * n); Seed(x * n + (n - 1)); }
for (int y = 0; y < n; y++) { Seed(y); Seed((n - 1) * n + y); }
while (heap.Count > 0 || pit.Count > 0)
{
int c = pit.Count > 0 ? pit.Dequeue() : heap.Dequeue();
float fc = filled[c];
int cx = c / n, cy = c % n;
for (int k = 0; k < 8; k++)
{
int nx = cx + DX[k], ny = cy + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (visited[ni]) continue;
visited[ni] = true;
if (filled[ni] <= fc)
{
// One ulp above the parent: strictly descending back out, so
// D8 never meets an exact flat inside a filled pit.
filled[ni] = MathF.BitIncrement(fc);
pit.Enqueue(ni);
}
else heap.Enqueue(ni, (filled[ni], ni));
}
}
}
// --- 2. Depression components; big+deep ones become terminal sinks ---
// Components of (filled > original), 8-connected — the pools. Qualifying
// pools revert to ORIGINAL height so flow terminates at their minimum.
int[] basinId = new int[total]; // 0 = not in a pool
var basinDepthM = new List<float> { 0f };
var basinAreaPx = new List<long> { 0L };
var basinMinCell = new List<int> { -1 };
{
var stack = new Stack<int>();
int nextId = 1;
for (int i = 0; i < total; i++)
{
if (basinId[i] != 0 || filled[i] <= original[i]) continue;
int id = nextId++;
long area = 0; float depth = 0f; int minCell = i; float minH = original[i];
stack.Push(i); basinId[i] = id;
while (stack.Count > 0)
{
int c = stack.Pop();
area++;
float d = WorldScale.MetresFromRaw(filled[c] - original[c]);
if (d > depth) depth = d;
if (original[c] < minH) { minH = original[c]; minCell = c; }
int cx = c / n, cy = c % n;
for (int k = 0; k < 8; k++)
{
int nx = cx + DX[k], ny = cy + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (basinId[ni] == 0 && filled[ni] > original[ni])
{ basinId[ni] = id; stack.Push(ni); }
}
}
basinDepthM.Add(depth); basinAreaPx.Add(area); basinMinCell.Add(minCell);
}
// 21b: the FULL fill (before terminal reversion) is the provisional-
// routing surface — on it, every basin overtops at its spill and drains
// to the border, which is exactly "where the water would continue".
plan_fullFilled = (float[])filled.Clone();
bool[] terminal = new bool[nextId];
for (int id = 1; id < nextId; id++)
{
if (basinDepthM[id] >= p.EndorheicMinDepthM && basinAreaPx[id] >= p.EndorheicMinAreaPx)
{ terminal[id] = true; plan.TerminalBasinCount++; }
else plan.PitsFilledCount++;
}
// Revert terminal pools to the real surface; re-tag basinId to keep only
// terminal pools (routing needs to know "am I in a terminal basin").
for (int i = 0; i < total; i++)
{
if (basinId[i] == 0) continue;
if (terminal[basinId[i]]) filled[i] = original[i];
else basinId[i] = 0;
}
}
// --- 3. D8 flow directions on the routing surface ---
// dir[i] = 0..7 neighbour, SEA (into a below-sea cell), or NONE (sink).
sbyte[] dir = new sbyte[total];
bool IsSea(int idx) => isOcean[idx];
for (int i = 0; i < total; i++)
{
if (IsSea(i)) { dir[i] = D_NONE; continue; }
int cx = i / n, cy = i % n;
float best = 0f; int bestK = -1; bool bestIsSea = false;
for (int k = 0; k < 8; k++)
{
int nx = cx + DX[k], ny = cy + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
float drop = (filled[i] - filled[ni]) / DIST[k];
if (drop > best) { best = drop; bestK = k; bestIsSea = IsSea(ni); }
}
dir[i] = bestK < 0 ? D_NONE : (bestIsSea ? D_SEA : (sbyte)bestK);
}
// --- 4. Flow accumulation (Kahn topological propagation) ---
int Target(int i)
{
if (dir[i] < 0) return -1;
int cx = i / n, cy = i % n;
return (cx + DX[dir[i]]) * n + (cy + DY[dir[i]]);
}
int[] acc = new int[total];
{
byte[] indeg = new byte[total];
for (int i = 0; i < total; i++)
if (dir[i] >= 0) indeg[Target(i)]++;
var q = new Queue<int>();
for (int i = 0; i < total; i++)
{
if (IsSea(i)) continue;
acc[i] = 1;
if (indeg[i] == 0) q.Enqueue(i);
}
while (q.Count > 0)
{
int c = q.Dequeue();
if (dir[c] < 0) continue;
int t = Target(c);
acc[t] += acc[c];
if (--indeg[t] == 0 && !IsSea(t)) q.Enqueue(t);
}
}
// Bookkeeping: where does each cell's flow END — the sea, WHICH terminal
// basin, or stuck? Memoised downstream walk. The per-basin totals matter:
// crediting a terminal basin only with acc at its deepest cell undercounts
// badly when the basin floor is flat (a lagoon bed scatters inflow across
// many sub-minima — measured: a 500k-px lagoon system reported under 50k).
long[] basinInflow = new long[basinMinCell.Count];
int[] dest = new int[total]; // 0 unknown, -1 sea, -2 stuck, >0 basin id
{
var path = new List<int>(4096);
for (int i = 0; i < total; i++)
{
if (IsSea(i) || dest[i] != 0) continue;
int c = i; path.Clear();
int result;
while (true)
{
if (dest[c] != 0) { result = dest[c]; break; }
path.Add(c);
if (dir[c] == D_SEA) { result = -1; break; }
if (dir[c] == D_NONE) { result = basinId[c] != 0 ? basinId[c] : -2; break; }
c = Target(c);
}
foreach (int pc in path) dest[pc] = result;
}
for (int i = 0; i < total; i++)
{
if (IsSea(i)) continue;
plan.LandCells++;
if (dest[i] == -1) plan.SeaReachingCells++;
else if (dest[i] > 0) { plan.EndorheicCells++; basinInflow[dest[i]]++; }
else plan.UnroutedCells++;
}
}
// --- 5a. Outlets: land cells whose flow enters the sea, ranked by acc ---
var outlets = new List<(int cell, long acc)>();
for (int i = 0; i < total; i++)
if (dir[i] == D_SEA) outlets.Add((i, acc[i]));
outlets.Sort((a, b) => b.acc.CompareTo(a.acc));
foreach (var (cell, a) in outlets.GetRange(0, Math.Min(12, outlets.Count)))
plan.AllOutletsTop.Add((cell / n, cell % n, a));
// Greedy top-N with separation, so three mouths of one delta can't take
// all three trunk slots.
var picked = new List<int>();
foreach (var (cell, _) in outlets)
{
if (picked.Count >= p.TrunkCount) break;
int cx = cell / n, cy = cell % n;
bool far = true;
foreach (int pcell in picked)
{
float ddx = cx - pcell / n, ddy = cy - pcell % n;
if (ddx * ddx + ddy * ddy < (float)p.MinOutletSeparationPx * p.MinOutletSeparationPx)
{ far = false; break; }
}
if (far) picked.Add(cell);
}
// upstream max-acc walk shared by trunks and tributaries
List<int> TraceStem(int fromCell, int minAcc)
{
var stem = new List<int> { fromCell };
int c = fromCell;
while (true)
{
int cx = c / n, cy = c % n;
int bestN = -1; long bestA = minAcc - 1;
for (int k = 0; k < 8; k++)
{
int nx = cx + DX[k], ny = cy + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (dir[ni] >= 0 && Target(ni) == c && acc[ni] > bestA)
{ bestA = acc[ni]; bestN = ni; }
}
if (bestN < 0) break;
stem.Add(bestN);
c = bestN;
}
return stem;
}
List<(float x, float y)> Decimate(List<int> cells, int step = 4)
{
var pts = new List<(float, float)>();
for (int i = 0; i < cells.Count; i += step)
pts.Add((cells[i] / n, cells[i] % n));
if ((cells.Count - 1) % step != 0)
pts.Add((cells[^1] / n, cells[^1] % n));
return pts;
}
// --- 5b. Trunks: stems, mountain exits, LEAN tributaries ---
foreach (int outletCell in picked)
{
var t = new Trunk
{
Outlet = (outletCell / n, outletCell % n),
DrainageAreaPx = acc[outletCell]
};
var stem = TraceStem(outletCell, p.StemMinAccPx);
t.Course = Decimate(stem);
t.Head = (stem[^1] / n, stem[^1] % n);
// Mountain-exit: walk the stem downstream-first; the exit is the
// furthest-DOWNSTREAM point whose upstream window still sustains the
// grade — i.e. where the mountains hand the river to the flats.
// Elevation truth is the ORIGINAL eroded surface, not the fill.
int w = p.ExitWindowPx;
for (int i = 0; i + w < stem.Count; i++)
{
float rise = WorldScale.MetresFromRaw(original[stem[i + w]] - original[stem[i]]);
if (rise / w >= p.ExitGradeMin)
{
t.ExitFound = true;
t.MountainExit = (stem[i] / n, stem[i] % n);
t.MountainExitElevM = WorldScale.MetresFromRaw(original[stem[i]]);
break;
}
}
// LEAN tributaries: junction branches off the stem with enough drainage,
// top few by accumulation.
var stemSet = new HashSet<int>(stem);
var cands = new List<(int cell, long acc)>();
foreach (int sc in stem)
{
int cx = sc / n, cy = sc % n;
for (int k = 0; k < 8; k++)
{
int nx = cx + DX[k], ny = cy + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (stemSet.Contains(ni)) continue;
if (dir[ni] >= 0 && Target(ni) == sc && acc[ni] >= p.TributaryMinAccPx)
cands.Add((ni, acc[ni]));
}
}
cands.Sort((a, b) => b.acc.CompareTo(a.acc));
// Dedup: two inflow neighbours at adjacent stem cells are one confluence,
// not two tributaries — keep only junctions ≥ 30 px apart.
var taken = new List<int>();
foreach (var (cell, a) in cands)
{
if (taken.Count >= p.TributaryMaxPerTrunk) break;
int cx2 = cell / n, cy2 = cell % n;
bool dup = false;
foreach (int tc in taken)
{
float ddx = cx2 - tc / n, ddy = cy2 - tc % n;
if (ddx * ddx + ddy * ddy < 30f * 30f) { dup = true; break; }
}
if (!dup) taken.Add(cell);
}
foreach (int cell in taken)
{
long a = acc[cell];
var trib = new Stream { DrainageAreaPx = a };
var ts = TraceStem(cell, Math.Max(p.StemMinAccPx, (int)(a / 20)));
trib.Course = Decimate(ts);
trib.Head = (ts[^1] / n, ts[^1] % n);
t.Tributaries.Add(trib);
}
plan.Trunks.Add(t);
}
// --- 5c. LEAN endorheic terminals: terminal basins ranked by TOTAL inflow ---
{
var terms = new List<(int id, long inflow)>();
for (int id = 1; id < basinMinCell.Count; id++)
{
int mc = basinMinCell[id];
if (mc < 0 || basinId[mc] != id) continue; // not a terminal basin
if (basinInflow[id] >= p.EndorheicMinInflowPx) terms.Add((id, basinInflow[id]));
}
terms.Sort((a, b) => b.inflow.CompareTo(a.inflow));
foreach (var (id, inflow) in terms.GetRange(0, Math.Min(p.EndorheicMaxCount, terms.Count)))
{
int mc = basinMinCell[id];
plan.Endorheics.Add(new EndorheicTerminal
{
Terminal = (mc / n, mc % n),
DrainageAreaPx = inflow,
BasinDepthM = basinDepthM[id],
BasinAreaPx = basinAreaPx[id]
});
}
}
// --- 5d. The promoted GIANTS (21b): mixed set, provisional routes ---
// Top GiantCount terminal basins by TOTAL inflow. Their upland stems are the
// island's real big rivers; whether each continues to the sea is the gate's
// decision, previewed here.
{
var giantsRanked = new List<(int id, long inflow)>();
for (int id = 1; id < basinMinCell.Count; id++)
{
int mc = basinMinCell[id];
if (mc < 0 || basinId[mc] != id) continue;
if (basinInflow[id] >= p.EndorheicMinInflowPx) giantsRanked.Add((id, basinInflow[id]));
}
giantsRanked.Sort((a, b) => b.inflow.CompareTo(a.inflow));
// Does a terminal basin HOLD classify water? The lake-ender test must look
// at the whole pool, not the stem's single pooling cell — a stem can pool on
// dry ground a few hundred px short of its lagoon and still be a lagoon river.
bool[] basinHasLake = new bool[basinMinCell.Count];
for (int i = 0; i < total; i++)
if (basinId[i] != 0 && isClassifyWater[i] && !isOcean[i])
basinHasLake[basinId[i]] = true;
// The main stem's ENTRY into the basin: the highest-accumulation cell
// whose flow terminates in this basin. On a flat basin floor the deepest
// cell sees only local trickles (the task-21 lesson), so the stem is
// anchored on the strongest feeder instead.
var bestEntry = new Dictionary<int, int>();
for (int i = 0; i < total; i++)
{
if (dest[i] <= 0) continue;
if (!bestEntry.TryGetValue(dest[i], out int cur) || acc[i] > acc[cur])
bestEntry[dest[i]] = i;
}
// The giant whose pooling point sits closest to the southernmost town is
// the SOUTHERN CANDIDATE — always routed provisionally (shown, not forced).
int southernPick = -1;
if (southX >= 0f)
{
float bestD = float.MaxValue;
foreach (var (id, _) in giantsRanked.GetRange(0, Math.Min(p.GiantCount, giantsRanked.Count)))
{
int mc = basinMinCell[id];
float ddx = mc / n - southX, ddy = mc % n - southY;
float d2 = ddx * ddx + ddy * ddy;
if (d2 < bestD) { bestD = d2; southernPick = id; }
}
}
foreach (var (id, inflow) in giantsRanked.GetRange(0, Math.Min(p.GiantCount, giantsRanked.Count)))
{
var g = new Giant { DrainageAreaPx = inflow, BasinDepthM = basinDepthM[id], BasinAreaPx = basinAreaPx[id] };
if (!bestEntry.TryGetValue(id, out int entry)) entry = basinMinCell[id];
// Downstream from the strongest feeder to where it actually pools…
int t2 = entry;
var down = new List<int> { t2 };
while (dir[t2] >= 0) { t2 = Target(t2); down.Add(t2); }
g.Terminal = (t2 / n, t2 % n);
// …then the full main stem, traced upstream from that pooling point.
var stem = TraceStem(t2, p.StemMinAccPx);
g.Course = Decimate(stem);
g.Head = (stem[^1] / n, stem[^1] % n);
g.TerminalInClassifyWater = isClassifyWater[t2];
for (int i = 0; i + p.ExitWindowPx < stem.Count; i++)
{
float rise = WorldScale.MetresFromRaw(original[stem[i + p.ExitWindowPx]] - original[stem[i]]);
if (rise / p.ExitWindowPx >= p.ExitGradeMin)
{
g.ExitFound = true;
g.MountainExit = (stem[i] / n, stem[i] % n);
g.MountainExitElevM = WorldScale.MetresFromRaw(original[stem[i]]);
break;
}
}
// Lean tributaries on the giant's stem, same junction rule as trunks.
var stemSet = new HashSet<int>(stem);
var cands = new List<(int cell, long acc)>();
foreach (int sc in stem)
{
int cx = sc / n, cy = sc % n;
for (int k = 0; k < 8; k++)
{
int nx = cx + DX[k], ny = cy + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (stemSet.Contains(ni)) continue;
if (dir[ni] >= 0 && Target(ni) == sc && acc[ni] >= p.TributaryMinAccPx)
cands.Add((ni, acc[ni]));
}
}
cands.Sort((a, b) => b.acc.CompareTo(a.acc));
var takenT = new List<int>();
foreach (var (cell, _) in cands)
{
if (takenT.Count >= p.TributaryMaxPerTrunk) break;
int cx2 = cell / n, cy2 = cell % n;
bool dup = false;
foreach (int tc in takenT)
{
float ddx = cx2 - tc / n, ddy = cy2 - tc % n;
if (ddx * ddx + ddy * ddy < 30f * 30f) { dup = true; break; }
}
if (!dup) takenT.Add(cell);
}
foreach (int cell in takenT)
{
var trib = new Stream { DrainageAreaPx = acc[cell] };
var ts = TraceStem(cell, Math.Max(p.StemMinAccPx, (int)(acc[cell] / 20)));
trib.Course = Decimate(ts);
trib.Head = (ts[^1] / n, ts[^1] % n);
g.Tributaries.Add(trib);
}
// Kind: the terminal BASIN holds a classify lake → natural lake-ender;
// dry pan → route to sea; the southern candidate is always routed.
g.SouthernCandidate = id == southernPick;
g.TerminalInClassifyWater = g.TerminalInClassifyWater || basinHasLake[id];
g.Kind = (basinHasLake[id] && !g.SouthernCandidate) ? "lake-ender" : "routed";
// PROVISIONAL route (routed giants): walk steepest descent on the FULL
// fill from the pooling point — the basin overtops at its spill and
// the walk continues along the terrain's own drainage to the ocean.
// DRAWN, not carved; part 2 carves along a route like this one.
// (chat2/12: computed as the reference did; not drawn, not promoted — routing is later.)
if (g.Kind == "routed")
{
var route = new List<int>();
int c = t2;
bool spillRecorded = false;
for (int guard = 0; guard < 4 * n; guard++)
{
route.Add(c);
if (isOcean[c]) { g.RouteReachedOcean = true; break; }
if (!spillRecorded && basinId[c] != id)
{ g.Spill = (c / n, c % n); spillRecorded = true; }
int cx = c / n, cy = c % n;
float best = float.MaxValue; int bestN = -1;
for (int k = 0; k < 8; k++)
{
int nx = cx + DX[k], ny = cy + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (plan_fullFilled[ni] < best) { best = plan_fullFilled[ni]; bestN = ni; }
}
if (bestN < 0 || plan_fullFilled[bestN] >= plan_fullFilled[c]) break; // stuck (report via flag)
c = bestN;
}
g.ProvisionalRoute = Decimate(route);
}
plan.Giants.Add(g);
}
}
plan.Dir = dir; plan.Acc = acc; plan.Filled = filled; plan.FullFilled = plan_fullFilled;
plan.BasinId = basinId; plan.BasinInflow = basinInflow;
return plan;
}
}
}

View file

@ -0,0 +1 @@
uid://c6yfvp8p5br07

View file

@ -0,0 +1,385 @@
using System;
namespace IslaApocalypse.Core
{
/// <summary>
/// ⭐⭐ DROPLET (HYDRAULIC) EROSION — THE FAITHFUL PORT (chat2/11). Ported from the reference's
/// <c>Tools/Scripts/HydraulicErosion.cs</c> at tag <c>pre-rewrite-reference</c> (<c>ab78883</c>),
/// VERBATIM in arithmetic and order (D-050): the reference's class doc is kept below because it is
/// the design record; what this port changes is listed first.
///
/// ═══ WHAT THE PORT CHANGES (and nothing else) ═══
///
/// • Namespace + location: <c>IslaApocalypse.Core</c> — engine-free (System.MathF, own PCG32), a
/// named C++ candidate, exactly as the reference said it was.
/// • The yardstick: every metres↔raw conversion goes through <see cref="WorldScale"/>
/// (<c>MetresFromRaw</c> / <c>RawFromMetres</c>) — there is no literal 251 in this file. The
/// arithmetic is the same float multiply / divide by the same constant, so results are
/// bit-identical to the reference's <c>* M_PER_UNIT</c> / <c>/ M_PER_UNIT</c>.
/// • <c>VERSION</c> is a local constant (1): the blueprint format the reference read it from is
/// not ported yet; when it is, this becomes a read of the format's constant, as the reference
/// warned (task 18).
/// • The crater exclusion is ported whole and is INERT in v2 until the crater carve exists: the
/// caller passes radius 0, so <c>CraterWeight</c> is 1 everywhere (see <c>ErosionPass</c>).
///
/// ═══ THE REFERENCE'S CLASS DOC (verbatim) ═══
///
/// Droplet-based hydraulic erosion (terrain-water task 17, Phase C0) — the organic
/// carve-AND-deposit pass, Lague/Beyer lineage. Pure numeric over the height array
/// (D-035; a named future C++ candidate, kept standalone — no Godot types at all,
/// System.MathF only, own deterministic PCG32 RNG).
///
/// Each droplet spawns on land (spawn probability weighted toward high ground),
/// then walks downhill with inertia, carrying water and sediment. Where the ground
/// is steep and it moves fast it ERODES (up to capacity, spread over a small brush
/// so no single-cell spikes — the anti-artifact that killed the D8 predecessor);
/// where it flattens out it DEPOSITS, building valley floors and fans, over the
/// SAME brush (task 18 — bilinear 4-cell deposition built isolated cones at gully
/// mouths; carving and dumping are now symmetric). Water
/// evaporates each step; the droplet dies at its lifetime, at the map edge, or on
/// reaching the sea (its remaining sediment is lost to the ocean).
///
/// OUTPUT-ONLY: this pass is applied to the RENDER height map only; the classify
/// map never sees it (the caller owns that split — see ErosionPass).
///
/// The three hard governors (the pass provably cannot run away):
/// 1. DropletCount — total droplets (the main detail/cost dial).
/// 2. Lifetime — max steps per droplet; no infinite wandering.
/// 3. CarveCapM — max erosion depth per cell, in metres, measured from the
/// height the pass found and enforced against a per-cell NET
/// displacement ledger. The runaway-trench guard, and the
/// dial that decides how deep trunk channels may cut.
/// 4. DepositCapM — max build-up per cell, the same ledger read the other way
/// (task 18). Brush-spreading alone does not bound a spike:
/// droplets on long paths carry far more sediment, and a
/// loaded droplet meeting a rise dumps min(rise, load) at
/// once. This makes "no deposit cones" a governor rather
/// than a hope. &lt;= 0 disables it (the reference model).
///
/// The sea clamp (the "don't over-flood" guard): erosion never lowers any cell
/// below its local sea level + SeaMarginM, and cells already below sea are
/// read-only — never eroded, never deposited on. Land stays land, sea stays sea;
/// the rendered coastline cannot move. Deposition only raises land cells.
///
/// The crater treatment (task 19): no cell within the protected strike CORE is
/// modified (droplets may traverse), and outside it either FULL strength applies
/// immediately or FEATHER ramps in across a band. The carve remains the final
/// authority on the deep bowl; the bay's sea connection is guaranteed by the sea
/// clamp rather than by the exclusion, since below-sea cells are read-only in
/// both directions.
///
/// Heights in the array are raw blueprint units (1 unit = 251 m). All sediment
/// accounting below is done in METRES and converted only when a delta is applied,
/// so untouched cells keep their exact bit pattern — the invariants above are
/// exact, not statistical.
/// </summary>
public static class HydraulicErosion
{
/// <summary>The EROS body version. ⚠ The reference read this from its blueprint format (the version byte IS the payload layout's identity); v2 has no format yet, so it is a local 1 until then.</summary>
public const ushort VERSION = 1;
/// <summary>Deterministic RNG stream: seeded from resolvedSeed + this offset, decorrelated from every noise field.</summary>
public const int SEED_OFFSET = 9271;
// --- Crater treatment (task 19) — ported whole, INERT in v2 until the crater carve lands ---
//
// Task 17 used a hard 1.2 × CraterRadius cutoff. Measured on seed 1280587109
// (task-19 radius dump): the carve writes only inside 0.80 × (640 px) and its
// displacement is EXACTLY 0 beyond that, so the 640960 px annulus was 620,811
// land cells of ordinary terrain held smooth for no geometric reason — a
// visible un-eroded disc against dissected ground, with a hard edge.
//
// The protected core is now the deep strike zone only. The bay itself needs no
// exclusion: below-sea cells are read-only in both directions (the sea clamp),
// so erosion can neither carve the bay's sea connection open nor silt it shut.
// The core exists to stop the BOWL being dissected on seeds where it holds land.
public const float CRATER_CORE_FACTOR_DEFAULT = 0.50f; // ×CraterRadius (the pass's own default; the reference's ConfigManager shipped 0.80 — see ErosionPass)
public const float CRATER_FEATHER_FACTOR_DEFAULT = 1.05f; // ×CraterRadius, FEATHER only
public const byte CRATER_MODE_FULL = 0;
public const byte CRATER_MODE_FEATHER = 1;
// Spawn: droplets source in the mountains, never the ocean. A land point is
// accepted with probability SPAWN_FLOOR + (1-SPAWN_FLOOR) · relative elevation,
// after at most SPAWN_TRIES rejection-sampling attempts (then the droplet is
// skipped and counted — on any real island this is vanishingly rare).
private const int SPAWN_TRIES = 16;
private const float SPAWN_FLOOR = 0.15f;
private const float MIN_WATER = 0.005f; // droplet dies when effectively dry
private const float MIN_DIR = 1e-10f; // below this, direction is re-drawn at random
public struct Params
{
public int DropletCount; // governor 1
public int Lifetime; // governor 2
public float CarveCapM; // governor 3 (metres)
public float DepositCapM; // governor 4 (metres); <= 0 = unbounded
public float SeaMarginM; // sea clamp margin (metres)
public int BrushRadius; // erosion brush radius, px
public float Inertia; // 0 = pure gradient descent, 1 = never turns
public float CapacityFactor; // sediment capacity multiplier
public float MinSlopeM; // capacity slope floor, metres per px
public float ErodeRate; // fraction of remaining capacity eroded per step
public float DepositRate; // fraction of surplus sediment dropped per step
public float Evaporation; // water lost per step (fraction)
public float Gravity; // speed gain per metre of drop
public byte CraterMode; // CRATER_MODE_FULL | CRATER_MODE_FEATHER (task 19)
public int Seed; // resolvedSeed + SEED_OFFSET
}
public class Stats
{
public int Spawned;
public int SkippedNoLand;
public long Steps;
public int DiedLifetime, DiedEdge, DiedSea, DiedDry;
public double ErodedVolumeM3; // 1 px = 1 m², so metres of depth sum to m³
public double DepositedVolumeM3;
public float MaxCellErosionM; // must end ≤ CarveCapM
public float MaxCellDepositM; // the deposit-spike metric (task 18)
public long ModifiedCells; // cells the pass touched at all
}
// PCG32 (O'Neill) — tiny, deterministic, trivially portable to C++.
private struct Pcg32
{
private ulong _state;
public Pcg32(int seed) { _state = 0; NextU(); _state += (ulong)(uint)seed; NextU(); }
public uint NextU()
{
ulong old = _state;
_state = old * 6364136223846793005UL + 1442695040888963407UL;
uint xorshifted = (uint)(((old >> 18) ^ old) >> 27);
int rot = (int)(old >> 59);
return (xorshifted >> rot) | (xorshifted << (-rot & 31));
}
public float NextF() => (NextU() >> 8) * (1f / 16777216f); // [0,1)
}
/// <summary>
/// Runs the pass in place on <paramref name="height"/>. Sea level per cell is
/// <paramref name="seaMap"/>[x,y] when non-null, else the flat scalar
/// <paramref name="seaFlat"/>. Throws (refusing the generation) if a governor
/// bound is violated on exit — the caller treats that as a build failure.
/// </summary>
public static Stats Apply(float[,] height, int mapSize, float[,] seaMap, float seaFlat,
float craterCx, float craterCy, float craterCoreRadius, float craterFeatherRadius, Params p)
{
var stats = new Stats();
var rng = new Pcg32(p.Seed);
float capUnits = WorldScale.RawFromMetres(p.CarveCapM);
if (p.DropletCount <= 0 || capUnits <= 0f) return stats;
// Per-cell NET displacement ledger, metres, positive = carved below where the
// pass found this cell, negative = built up above it. Governor 3's enforcement
// record: the cap bounds `net`, so it bounds erosion depth measured from the
// ORIGINAL height — deposit-then-carve at one cell cannot smuggle in extra
// depth, and carve-then-deposit correctly frees the headroom back up.
float[,] net = new float[mapSize, mapSize];
// Spawn weighting needs the seed's top height.
float hTop = float.MinValue;
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
if (height[x, y] > hTop) hTop = height[x, y];
// Erosion brush: all offsets within BrushRadius, cone-weighted (1 - d/r),
// normalized. Radius 0 degrades to the single cell.
int r = Math.Max(p.BrushRadius, 0);
int brushN = 0;
for (int dx = -r; dx <= r; dx++)
for (int dy = -r; dy <= r; dy++)
if (MathF.Sqrt(dx * dx + dy * dy) <= r + 1e-4f) brushN++;
int[] brushDx = new int[brushN], brushDy = new int[brushN];
float[] brushW = new float[brushN];
{
int i = 0; float wSum = 0f;
for (int dx = -r; dx <= r; dx++)
for (int dy = -r; dy <= r; dy++)
{
float d = MathF.Sqrt(dx * dx + dy * dy);
if (d > r + 1e-4f) continue;
brushDx[i] = dx; brushDy[i] = dy;
brushW[i] = r > 0 ? 1f - d / (r + 1f) : 1f;
wSum += brushW[i]; i++;
}
for (int j = 0; j < brushN; j++) brushW[j] /= wSum;
}
float SeaAt(int cx, int cy) => seaMap != null ? seaMap[cx, cy] : seaFlat;
// Crater weight (task 19): 0 inside the protected strike core, 1 where erosion
// runs at full strength. FULL steps straight to 1 at the core boundary; FEATHER
// ramps linearly out to craterFeatherRadius, mirroring the detail pass's shape,
// so the crater reads as younger/less-weathered with no seam. Amounts are SCALED
// by this rather than skipped, which is what makes FEATHER a one-liner.
// ⚠ v2: with no crater (radius 0) this is 1 everywhere — INERT.
float coreSq = craterCoreRadius * craterCoreRadius;
bool feather = p.CraterMode == CRATER_MODE_FEATHER
&& craterFeatherRadius > craterCoreRadius;
float CraterWeight(int cx, int cy)
{
float ddx = cx - craterCx, ddy = cy - craterCy;
float d2 = ddx * ddx + ddy * ddy;
if (d2 < coreSq) return 0f;
if (!feather) return 1f;
float d = MathF.Sqrt(d2);
if (d >= craterFeatherRadius) return 1f;
return (d - craterCoreRadius) / (craterFeatherRadius - craterCoreRadius);
}
for (int drop = 0; drop < p.DropletCount; drop++)
{
// --- spawn (land only, elevation-weighted) ---
float px = -1f, py = -1f;
for (int attempt = 0; attempt < SPAWN_TRIES; attempt++)
{
float sx = 1f + rng.NextF() * (mapSize - 3);
float sy = 1f + rng.NextF() * (mapSize - 3);
int cx = (int)sx, cy = (int)sy;
float h = height[cx, cy];
float sea = SeaAt(cx, cy);
if (h < sea) { continue; }
float rel = hTop > sea ? Math.Clamp((h - sea) / (hTop - sea), 0f, 1f) : 0f;
if (rng.NextF() < SPAWN_FLOOR + (1f - SPAWN_FLOOR) * rel) { px = sx; py = sy; break; }
}
if (px < 0f) { stats.SkippedNoLand++; continue; }
stats.Spawned++;
float dirX = 0f, dirY = 0f, speed = 1f, water = 1f, sedimentM = 0f;
for (int step = 0; step < p.Lifetime; step++)
{
stats.Steps++;
int xi = (int)px, yi = (int)py;
float fx = px - xi, fy = py - yi;
// Bilinear height + gradient at the current position.
float h00 = height[xi, yi], h10 = height[xi + 1, yi];
float h01 = height[xi, yi + 1], h11 = height[xi + 1, yi + 1];
float gradX = (h10 - h00) * (1f - fy) + (h11 - h01) * fy;
float gradY = (h01 - h00) * (1f - fx) + (h11 - h10) * fx;
float hOld = h00 * (1f - fx) * (1f - fy) + h10 * fx * (1f - fy)
+ h01 * (1f - fx) * fy + h11 * fx * fy;
// Inertia blend, then one unit step.
dirX = dirX * p.Inertia - gradX * (1f - p.Inertia);
dirY = dirY * p.Inertia - gradY * (1f - p.Inertia);
float len = MathF.Sqrt(dirX * dirX + dirY * dirY);
if (len < MIN_DIR)
{
float ang = rng.NextF() * 2f * MathF.PI;
dirX = MathF.Cos(ang); dirY = MathF.Sin(ang); len = 1f;
}
dirX /= len; dirY /= len;
px += dirX; py += dirY;
if (px < 1f || px >= mapSize - 2 || py < 1f || py >= mapSize - 2)
{ stats.DiedEdge++; break; }
int nxi = (int)px, nyi = (int)py;
float nfx = px - nxi, nfy = py - nyi;
float n00 = height[nxi, nyi], n10 = height[nxi + 1, nyi];
float n01 = height[nxi, nyi + 1], n11 = height[nxi + 1, nyi + 1];
float hNew = n00 * (1f - nfx) * (1f - nfy) + n10 * nfx * (1f - nfy)
+ n01 * (1f - nfx) * nfy + n11 * nfx * nfy;
// Reached the sea: die; the sediment is the ocean's now.
if (hNew < SeaAt(nxi, nyi)) { stats.DiedSea++; break; }
float dhM = WorldScale.MetresFromRaw(hNew - hOld);
float capacityM = MathF.Max(-dhM, p.MinSlopeM) * speed * water * p.CapacityFactor;
if (dhM > 0f || sedimentM > capacityM)
{
// Moving uphill (fill the pit behind us, at most the rise) or
// over capacity (drop a fraction of the surplus): DEPOSIT over
// the SAME cone brush erosion uses (task 18). Bilinear 4-cell
// deposition — the reference model's — concentrated a whole
// droplet's load into one cell at gully mouths and built
// isolated cones (measured 15.5 m on seed 1280587109, task 17
// §6.1). Spreading it makes deposition the symmetric mirror of
// carving; total mass is unchanged, only its footprint.
float amountM = dhM > 0f ? MathF.Min(dhM, sedimentM)
: (sedimentM - capacityM) * p.DepositRate;
if (amountM > 0f)
{
for (int b = 0; b < brushN; b++)
{
int cx = xi + brushDx[b], cy = yi + brushDy[b];
if (cx < 0 || cx >= mapSize || cy < 0 || cy >= mapSize) continue;
float wCrater = CraterWeight(cx, cy);
if (wCrater <= 0f) continue;
float hCell = height[cx, cy];
// Below-sea cells are read-only in BOTH directions: no
// submarine deltas, so the rendered coastline cannot move.
if (hCell < SeaAt(cx, cy)) continue;
float give = amountM * brushW[b] * wCrater;
// Governor 4: the ledger read the other way. net is negative
// where the cell has already been built up, so the headroom
// is cap + net.
if (p.DepositCapM > 0f)
give = MathF.Min(give, MathF.Max(0f, p.DepositCapM + net[cx, cy]));
if (give <= 0f) continue;
height[cx, cy] = hCell + WorldScale.RawFromMetres(give);
if (net[cx, cy] == 0f) stats.ModifiedCells++;
net[cx, cy] -= give;
if (-net[cx, cy] > stats.MaxCellDepositM) stats.MaxCellDepositM = -net[cx, cy];
sedimentM -= give;
stats.DepositedVolumeM3 += give;
}
}
}
else
{
// Under capacity on a downhill move: ERODE, spread over the
// brush, never more than the drop itself (no digging pits).
float amountM = MathF.Min((capacityM - sedimentM) * p.ErodeRate, -dhM);
if (amountM > 0f)
{
for (int b = 0; b < brushN; b++)
{
int cx = xi + brushDx[b], cy = yi + brushDy[b];
if (cx < 0 || cx >= mapSize || cy < 0 || cy >= mapSize) continue;
float wCrater = CraterWeight(cx, cy);
if (wCrater <= 0f) continue;
float sea = SeaAt(cx, cy);
float hCell = height[cx, cy];
if (hCell < sea) continue; // below-sea cells are read-only
float want = amountM * brushW[b] * wCrater;
float bySea = MathF.Max(0f, WorldScale.MetresFromRaw(hCell - (sea + WorldScale.RawFromMetres(p.SeaMarginM))));
float byCap = MathF.Max(0f, p.CarveCapM - net[cx, cy]);
float take = MathF.Min(want, MathF.Min(bySea, byCap));
if (take <= 0f) continue;
height[cx, cy] = hCell - WorldScale.RawFromMetres(take);
if (net[cx, cy] == 0f) stats.ModifiedCells++;
net[cx, cy] += take;
if (net[cx, cy] > stats.MaxCellErosionM) stats.MaxCellErosionM = net[cx, cy];
sedimentM += take;
stats.ErodedVolumeM3 += take;
}
}
}
speed = MathF.Sqrt(MathF.Max(0f, speed * speed - dhM * p.Gravity));
water *= 1f - p.Evaporation;
if (water < MIN_WATER) { stats.DiedDry++; break; }
if (step == p.Lifetime - 1) stats.DiedLifetime++;
}
}
// Governor 3, proven on exit rather than assumed: the ledger's maximum must
// respect the cap (float addition of clamped takes cannot exceed it by more
// than rounding; allow one ulp-scale epsilon).
if (stats.MaxCellErosionM > p.CarveCapM * (1f + 1e-5f))
throw new InvalidOperationException(
$"[HydraulicErosion] CARVE-CAP VIOLATION: a cell accumulated {stats.MaxCellErosionM} m against cap {p.CarveCapM} m. Refusing to generate.");
if (p.DepositCapM > 0f && stats.MaxCellDepositM > p.DepositCapM * (1f + 1e-5f))
throw new InvalidOperationException(
$"[HydraulicErosion] DEPOSIT-CAP VIOLATION: a cell built up {stats.MaxCellDepositM} m against cap {p.DepositCapM} m. Refusing to generate.");
return stats;
}
}
}

View file

@ -0,0 +1 @@
uid://bb4v6quey3xwl

View file

@ -0,0 +1,273 @@
using System;
using System.Collections.Generic;
namespace IslaApocalypse.Core
{
/// <summary>One maximal 8-connected component of land, as the region layer exposes it.</summary>
public sealed class LandRegion
{
/// <summary>1-based, assigned in deterministic scan order (x outer, y inner) — stable per seed across runs.</summary>
public int Id;
/// <summary>Cells in the component.</summary>
public long SizeCells;
/// <summary>Centroid in map cells.</summary>
public double CentroidX, CentroidY;
/// <summary>
/// <see cref="RegionLabeling.HemiNorth"/> / <see cref="RegionLabeling.HemiSouth"/>, decided by the
/// CENTROID — one label per component; a straddler is decided by where its mass is, never per cell.
/// </summary>
public byte Hemisphere;
/// <summary>True for exactly one component: the one containing the map centre (or the flagged fallback).</summary>
public bool IsMainland;
/// <summary>Bounding box, inclusive. Convenience for overlays and guards; not part of the contract.</summary>
public int MinX, MinY, MaxX, MaxY;
}
/// <summary>The result of one labeling: the per-cell id map and the per-component table.</summary>
public sealed class RegionLabels
{
public int MapSize;
/// <summary>Per cell, <c>x * MapSize + y</c>: the component id, or 0 for water.</summary>
public int[] Id;
/// <summary>Every component, indexed by <c>Id - 1</c>, in id order.</summary>
public List<LandRegion> Regions;
/// <summary>The mainland's id (0 only if there is no land at all).</summary>
public int MainlandId;
/// <summary>
/// ⚠ Whether the map-centre cell was land. Expected always true (the massif is centred and
/// stable). When false the mainland fell back to the LARGEST component and the caller must
/// report it loudly — the contract's mainland definition did not hold on this field.
/// </summary>
public bool CentreWasLand;
public long LandCells;
public int IslandCount => Regions.Count - (MainlandId > 0 ? 1 : 0);
public LandRegion Mainland => MainlandId > 0 ? Regions[MainlandId - 1] : null;
public LandRegion Of(int id) => Regions[id - 1];
public int IdAt(int x, int y) => Id[x * MapSize + y];
}
/// <summary>
/// ⭐⭐ THE REGION-LABELING LAYER — shared infrastructure (chat2/07). Flood-fills land into distinct
/// components, identifies mainland vs islands, and exposes per-component data. Islands are its first
/// consumer; later phases (biomes, placement, rivers, the crater) CONSUME this layer rather than
/// rebuild it. Engine-free; pure analysis over a <c>float[,]</c>; C++-candidate.
///
/// ═══ THE CONTRACT — build to it exactly (recorded at graduation as the shared-infra contract) ═══
///
/// FIELD It runs on the CLASSIFY (raw, uncurved) height — region identity partitions on the
/// same authoritative field as water bodies and biome regions (D-046), so islands /
/// water / biomes line up by construction. Raw is authoritative for region identity.
/// It does NOT run on the render field.
///
/// CONNECTIVITY Land is 8-CONNECTED. Deliberately the complement of water's 4-connectivity —
/// foreground/background using opposite connectivity is the topologically sound
/// pairing (a diagonal isthmus reads as JOINED; the water on either side of it reads
/// as SEPARATE), not a conflict with the water model.
///
/// COMPONENT A component = a maximal 8-connected set of land cells (land = classify height ≥ sea).
///
/// MAINLAND The component containing the MAP CENTRE (the mountain/massif is always centred and
/// stable) — NOT merely the largest component, because a later fragmentation step
/// could make "largest" flip seed to seed. Every OTHER land component is an island.
/// ⚠ The crater is NOT central — it is a northern-coastline feature, unrelated to the
/// centre or the mountain, and plays no part here.
/// Defensively: if the centre cell is not land, the layer reports it (<see
/// cref="RegionLabels.CentreWasLand"/> = false) and falls back to the largest
/// component, FLAGGED — the caller asserts rather than assumes.
///
/// PER COMPONENT id · sizeCells · centroid (x, y) · hemisphere (north / south, BY THE CENTROID —
/// one label per island; a straddler is decided by its centroid, never per cell) ·
/// isMainland.
///
/// Ids are assigned in deterministic scan order (x outer, y inner, first-seen), so they are stable
/// per seed across runs. Nothing here knows about "offshore" or "stamped" — it labels land.
///
/// ═══ THE HEMISPHERE CONVENTION — read from the code, not invented (chat2/05) ═══
///
/// Pass 1's latitude scalar is <c>y / MapSize</c>; the spine's "southern fade" and the "southern
/// sinker" bite at high y. So y increases SOUTHWARD: NORTH = rows [0, MapSize/2), SOUTH = rows
/// [MapSize/2, MapSize). The clean row midline, never the wobbled latitude field.
/// </summary>
public static class RegionLabeling
{
public const byte HemiNone = 0;
public const byte HemiNorth = 1;
public const byte HemiSouth = 2;
/// <summary>The convention, in one place. Every consumer reads hemisphere through this.</summary>
public static byte HemisphereOfRow(int y, int mapSize) => y < mapSize / 2 ? HemiNorth : HemiSouth;
public static string HemisphereName(byte h) => h switch
{
HemiNorth => "north", HemiSouth => "south", _ => "none",
};
// 8-connectivity, fixed order (determinism: the fill order never changes).
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
/// <summary>
/// Label every 8-connected land component of <paramref name="classify"/> (land = height ≥
/// <paramref name="sea"/>). Pure: the field is read, never written.
/// </summary>
public static RegionLabels Label(float[,] classify, int mapSize, float sea)
{
int n = mapSize;
var id = new int[n * n];
var regions = new List<LandRegion>();
var stack = new Stack<int>();
long landCells = 0;
for (int sx = 0; sx < n; sx++)
{
for (int sy = 0; sy < n; sy++)
{
if (classify[sx, sy] < sea || id[sx * n + sy] != 0) continue;
var r = new LandRegion { Id = regions.Count + 1, MinX = sx, MaxX = sx, MinY = sy, MaxY = sy };
double sumX = 0, sumY = 0;
id[sx * n + sy] = r.Id;
stack.Push(sx * n + sy);
while (stack.Count > 0)
{
int cur = stack.Pop();
int cx = cur / n, cy = cur % n;
r.SizeCells++; sumX += cx; sumY += cy;
if (cx < r.MinX) r.MinX = cx; if (cx > r.MaxX) r.MaxX = cx;
if (cy < r.MinY) r.MinY = cy; if (cy > r.MaxY) r.MaxY = cy;
for (int k = 0; k < 8; k++)
{
int nx = cx + DX[k], ny = cy + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (id[ni] != 0 || classify[nx, ny] < sea) continue;
id[ni] = r.Id;
stack.Push(ni);
}
}
r.CentroidX = sumX / r.SizeCells;
r.CentroidY = sumY / r.SizeCells;
r.Hemisphere = HemisphereOfRow((int)Math.Round(r.CentroidY), n);
landCells += r.SizeCells;
regions.Add(r);
}
}
var labels = new RegionLabels { MapSize = n, Id = id, Regions = regions, LandCells = landCells };
// ⭐ MAINLAND = the component containing the map centre. Asserted by the caller; the
// fallback (largest) exists so a run can finish and REPORT the violation rather than crash.
int centre = (n / 2) * n + (n / 2);
labels.CentreWasLand = id[centre] != 0;
if (labels.CentreWasLand) labels.MainlandId = id[centre];
else
{
long best = -1;
foreach (var r in regions) if (r.SizeCells > best) { best = r.SizeCells; labels.MainlandId = r.Id; }
}
if (labels.MainlandId > 0) regions[labels.MainlandId - 1].IsMainland = true;
return labels;
}
/// <summary>
/// Size statistics over the islands (non-mainland components): count, min / median / mean /
/// max cells, and a log-spaced histogram — the instrument that turns "nice pieces vs shattered
/// gravel" into numbers.
/// </summary>
public static (int count, long min, long median, double mean, long max, int[] histogram)
IslandSizes(RegionLabels labels)
{
var sizes = new List<long>();
foreach (var r in labels.Regions) if (!r.IsMainland) sizes.Add(r.SizeCells);
var hist = new int[HistogramEdges.Length + 1];
if (sizes.Count == 0) return (0, 0, 0, 0.0, 0, hist);
sizes.Sort();
double sum = 0;
foreach (long s in sizes) { sum += s; hist[HistogramBin(s)]++; }
return (sizes.Count, sizes[0], sizes[sizes.Count / 2], sum / sizes.Count, sizes[sizes.Count - 1], hist);
}
/// <summary>Histogram bin edges (cells): [0,64) [64,256) [256,1024) [1024,4096) [4096,16384) [16384,∞).</summary>
public static readonly long[] HistogramEdges = { 64, 256, 1024, 4096, 16384 };
public static int HistogramBin(long cells)
{
for (int i = 0; i < HistogramEdges.Length; i++) if (cells < HistogramEdges[i]) return i;
return HistogramEdges.Length;
}
public static string HistogramLabel(int bin) => bin == 0 ? $"<{HistogramEdges[0]}"
: bin < HistogramEdges.Length ? $"{HistogramEdges[bin - 1]}{HistogramEdges[bin] - 1}"
: $"≥{HistogramEdges[^1]}";
// ═══ chat2/12 — THE OCEAN IDENTITY (the water-side complement of the land contract) ═══
//
// Water is 4-CONNECTED (the deliberate complement of land's 8 — a diagonal isthmus joins land and
// separates the water either side). THE OCEAN = the 4-connected water component that touches the
// map border (the Trench guarantees the border is water, so the corner is a safe seed). Every
// other below-sea cell — enclosed lagoons, lake beds, island-fringe pockets — is NOT ocean: to the
// drainage router it is ordinary terrain (a terminal basin or a fill-and-spill), and to a
// "sea-reaching" test it does not count as the sea. Computed on the CLASSIFY field (authoritative).
/// <summary>
/// The ocean mask, row-major (<c>x·n+y</c>): true for every below-sea cell 4-connected to the map
/// border. Pure: reads <paramref name="classify"/>, writes nothing.
/// </summary>
public static bool[] OceanMask(float[,] classify, int mapSize, float sea, out long oceanCells, out long enclosedWaterCells)
{
int n = mapSize;
var ocean = new bool[n * n];
var q = new Queue<int>();
void Seed(int x, int y) { if (classify[x, y] < sea && !ocean[x * n + y]) { ocean[x * n + y] = true; q.Enqueue(x * n + y); } }
for (int x = 0; x < n; x++) { Seed(x, 0); Seed(x, n - 1); }
for (int y = 0; y < n; y++) { Seed(0, y); Seed(n - 1, y); }
int[] dx4 = { -1, 1, 0, 0 }, dy4 = { 0, 0, -1, 1 };
while (q.Count > 0)
{
int c = q.Dequeue(); int cx = c / n, cy = c % n;
for (int k = 0; k < 4; k++)
{
int nx = cx + dx4[k], ny = cy + dy4[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (ocean[ni] || classify[nx, ny] >= sea) continue;
ocean[ni] = true; q.Enqueue(ni);
}
}
oceanCells = 0; enclosedWaterCells = 0;
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
if (classify[x, y] >= sea) continue;
if (ocean[x * n + y]) oceanCells++; else enclosedWaterCells++;
}
return ocean;
}
/// <summary>Island counts per hemisphere (non-mainland components, by centroid).</summary>
public static (int north, int south) IslandsByHemisphere(RegionLabels labels)
{
int nN = 0, nS = 0;
foreach (var r in labels.Regions)
{
if (r.IsMainland) continue;
if (r.Hemisphere == HemiNorth) nN++; else if (r.Hemisphere == HemiSouth) nS++;
}
return (nN, nS);
}
}
}

View file

@ -0,0 +1 @@
uid://bsb27ghrajnhc

View file

@ -30,7 +30,25 @@ constants, carried over verbatim — not re-derived from a design summary** (→
|---|---|
| `Scripts/TerrainNoise.cs` | ⭐ The FastNoiseLite config, **every fractal property pinned explicitly** |
| `Scripts/Topography.cs` | ⭐⭐ Pass 1 — the six elements, in the reference's execution order |
| `Scripts/IslandFalloff.cs` | `SmoothAbs` + `CREST_EPSILON` (pass-1 half of the reference file) |
| `Scripts/IslandFalloff.cs` | The whole reference file now: `SmoothAbs`, the **coast shelf**, the **offshore islets** + the reshape helpers |
| `Scripts/OffshorePass.cs` | ⭐ **Pass 1b** — shelf + the **organic islet layer** (the one island mechanism) + the slop guards, over the finished pass-1 arrays, then `HMaxSeed` is retaken (chat2/05, retuned chat2/06) |
| `Scripts/OffshoreSettings.cs` | Every islet dial in one object; `Faithful()` (the reference) and `Organic()` (the reshape, tuned: **density** + **south weight** + guards). ⚠ No floor / stamps / count guarantee — chat2/05's `Hybrid()` was reverted out in chat2/06 (git history has it) |
| `Scripts/OffshoreAnalysis.cs` | Island components, N/S counts, the moat check, the separation-guard geometry, **the hemisphere convention** |
| `Scripts/OffshoreDiagnosis.cs` | The per-hemisphere **measurement** — valid-zone area, binding gate, noise peaks over threshold (chat2/06 §2) |
| `Scripts/TagOverlayRenderer.cs` | The island-tag / hemisphere debug overlay |
| `Scripts/RegionPass.cs` | ⭐ **Pass 1c** (chat2/07) — `Core.RegionLabeling` over the classify field, the **origin-blind speck revert** (lower-only + component-only asserted, mainland never), the island tag **by construction** |
| `Scripts/RegionOverlayRenderer.cs` | The labeled-regions overlay: mainland one tint, each island its own colour, reverted specks dark red |
| `Scripts/RegionLabelingTool.cs` + `Scenes/RegionLabelingTool.tscn` | The chat2/07 batch — 3 revert thresholds + a second seed, the count/size instrument |
| `Scripts/SouthernStretch.cs` | ⭐ **The southern stretch** (chat2/08, exploration) — the one deliberate sea-identity relaxation, inside a fixed feathered latitude band: `y' = yB + (y yB)/(1 + stretch·ramp)` for the mask geometry (and the sinker), texture untouched; north of the band bit-locked by construction |
| `Scripts/SouthernStretchTool.cs` + `Scenes/SouthernStretchTool.tscn` | The chat2/08 batch — the diagnostic (`ISLA_DIAG_ONLY`) and the 5-level × 2-seed fragmentation ladder with the hemisphere-split instrument |
| `Scripts/CoastalFragment.cs` | ⭐ **Coastal fragmentation** (chat2/09, exploration) — a perimeter-wide, band-limited, zero-mean noise on the pre-power falloff inside the coastal window (≈ 0.66 ± 0.18); thin necks flip first; interior bit-identical by construction |
| `Scripts/CoastalFragmentTool.cs` + `Scenes/CoastalFragmentTool.tscn` | The chat2/09 batch — the frequency × amplitude probe (`ISLA_PROBE`) and the 4-amplitude × 2-seed ladder at a fixed stretch |
| `Scripts/FragGalleryTool.cs` + `Scenes/FragGalleryTool.tscn` | The chat2/10 gallery — render-only: 09's `frag_4` frozen across 2 anchors + 6 fresh seeds at 8192, with the count/size table |
| `Scripts/ErosionPass.cs` | ⭐ **Pass 2b** (chat2/11) — the erosion caller: render field only (copied if aliased), governors clamped as the reference's ConfigManager did, the crater exclusion passed through INERT, and the **flood guard** (render water pixels before/after; any change throws) |
| `Scripts/ErosionTool.cs` + `Scenes/ErosionTool.tscn` | The chat2/11 batch — 4 gallery seeds × erosion off/on at 8192, the mid-slope crop, the erosion stats table; also `TerrainShapeV1` — the locked shape's values pinned once |
| `Scripts/DrainageRenderer.cs` | The drainage maps (chat2/12): log-scaled accumulation; the promoted-candidates overlay (trunks cyan, endorheic giants orange, lean terminals red) |
| `Scripts/DrainageTool.cs` + `Scenes/DrainageTool.tscn` | The chat2/12 batch — `DrainageAnalysis` on the eroded 8192 fields of 4 task-11 seeds, analysis-only oracle (terrain bit-identical, no water, fill invariants, determinism, ocean from the region layer) |
| `Scripts/OffshoreIslandsTool.cs` + `Scenes/OffshoreIslandsTool.tscn` | The offshore batch — chat2/06: 4 plates + the count table + the diagnosis (the chat2/05 version is at `3b96e06`) |
| `Scripts/Pass1Result.cs` | The height field **and the Phase-2 seams** |
| `Scripts/TerrainGenConfig.cs` | Config + the per-element ablation toggles |
| `Scripts/HeightField.cs` | Raw `.f32` save/load — **the generation/presentation seam** |
@ -197,17 +215,48 @@ Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
> reads**, and nothing else. Climate is **stage 3** and classifies finished shape (D-049 §2, D-056).
> Do not alias, store, or rename this into a climate map.
**Deferred, with the seam already open:** the submarine **coast shelf** and the **offshore islets**
(reference ~:621-664). Both act only below sea level and are judged once water renders.
`Pass1Result.PreTrenchFalloff` is captured at the exact point they consume, and
`Pass1Result.HMaxSeed` is carried for the seed-dependent redistribution curve.
**Pass 1b — the coast shelf and the offshore islets (chat2/05).** The reference continued its pass-1
loop with the submarine **coast shelf** and the **offshore islet** layer (~:621-664); v2 runs them as a
second sweep over the finished arrays — `OffshorePass` — with the same per-pixel arithmetic in the same
order, then **retakes `HMaxSeed` after them**, as the reference did (chat2/00 Drift §2, closed). The
islets exist in two presets: `OffshoreSettings.Faithful()` (the reference, verbatim — the control) and
`OffshoreSettings.Organic()` (the reshape: small / low / flat / crisp, corners allowed — **the organic
noise-field layer is the only island mechanism**, tuned for coverage by **density** and a **south
weight**, with speck / separation / blob guards; chat2/06). ⚠ **No forced count.** chat2/05's seeded
floor (`Hybrid()`: stamps guaranteeing ≥2 N / ≥4 S) was tried and **reverted out on look** in chat2/06;
the per-hemisphere counts are a statistical outcome of the tuning, read off the batch's count table.
> ⚠⚠ **When the coast shelf and islets land, `HMaxSeed` must move with them.** The reference takes
> its map-wide max *after* both layers have already modified the height, inside the same pass-1 loop
> (`MapGenerator.cs:665`). v2 currently takes it before, because the layers do not exist. Since
> `HMaxSeed` normalizes the curve's summit spike, porting those layers without moving the max
> computation below them changes the world for a given seed — silently, with no throw and no failed
> assertion. → chat2/00 report, Drift §2.
> ### ⚠ Both default OFF — deliberately, and that is a decision to revisit.
>
> Every oracle in this phase holds pass 1 against Phase 1's `.f32` dumps. The shelf changes every
> below-sea cell and the islets ADD LAND, so defaulting either ON stales every regression anchor at
> once. Batch tools turn them on explicitly. **Flipping the defaults is the act that retires the
> Phase-1 dumps — do it in a task that re-baselines the oracles.** → `TerrainGenConfig`.
**Pass 1c — region labeling + the speck revert + the island tag (chat2/07).** `RegionPass` runs
`Core.RegionLabeling` over the finished classify field — **land 8-connected, mainland = the component
containing the map centre, every other component an island** — then the config-gated **speck revert**
(`TerrainGenConfig.SpeckRevert` / `MinLandComponentFrac`): every non-mainland component below the
threshold is lowered to its ring's seabed. Origin-blind (a natural nub goes like an offshore dot),
**lower-only and component-only, asserted**, mainland never a candidate. Then the island tag, by
construction. ⚠ `SpeckRevert` defaults OFF in the bare config for the same reason as the shelf/islets
(it would move the calibration pool and every regression dump); the batch turns it on.
> ### ⭐ The island tag — data, set BY THE REGION LAYER, read by nothing yet.
>
> `Pass1Result.IsIsland` / `IslandHemisphere` (carried through `Pass2Result`) mark every cell of every
> non-mainland land component — natural detached masses and offshore-pass islands alike — with the
> COMPONENT's hemisphere (by centroid). **NORTH = rows `[0, MapSize/2)`, SOUTH = `[MapSize/2,
> MapSize)`** — y runs south. `Pass1Result.Regions` carries the full per-component table. A biome /
> fertility / placement pass reads these and never re-derives island-land from geometry. Null when
> region labeling is off. (chat2/0506 tagged only what the offshore pass raised — fixed in 07.)
> ### ⚠ The one honest coupling: islets TURN WATER INTO LAND.
>
> Unlike the curve, which moves heights but not the waterline, the islet layer adds above-sea cells —
> new classification downstream. That is why it runs in the base shape before anything classifies:
> change an island dial, regenerate, and classification re-runs consistently. A known property, not a
> surprise.
**Not here at all:** erosion, rivers, water bodies, the crater carve, biomes, roads, the mesher.

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://cfragment09isla"]
[ext_resource type="Script" path="res://Tools/Scripts/CoastalFragmentTool.cs" id="1_cft"]
[node name="CoastalFragmentTool" type="Node"]
script = ExtResource("1_cft")

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://cdrainage12isla"]
[ext_resource type="Script" path="res://Tools/Scripts/DrainageTool.cs" id="1_drt"]
[node name="DrainageTool" type="Node"]
script = ExtResource("1_drt")

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://cerosion11isla"]
[ext_resource type="Script" path="res://Tools/Scripts/ErosionTool.cs" id="1_ert"]
[node name="ErosionTool" type="Node"]
script = ExtResource("1_ert")

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://cfraggal10isla"]
[ext_resource type="Script" path="res://Tools/Scripts/FragGalleryTool.cs" id="1_fgt"]
[node name="FragGalleryTool" type="Node"]
script = ExtResource("1_fgt")

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://cmtnrestore03isla"]
[ext_resource type="Script" path="res://Tools/Scripts/MountainRestoreTool.cs" id="1_mrt"]
[node name="MountainRestoreTool" type="Node"]
script = ExtResource("1_mrt")

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://coffshore05isla"]
[ext_resource type="Script" path="res://Tools/Scripts/OffshoreIslandsTool.cs" id="1_oit"]
[node name="OffshoreIslandsTool" type="Node"]
script = ExtResource("1_oit")

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://cregions07isla"]
[ext_resource type="Script" path="res://Tools/Scripts/RegionLabelingTool.cs" id="1_rlt"]
[node name="RegionLabelingTool" type="Node"]
script = ExtResource("1_rlt")

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://cseedgallery04isla"]
[ext_resource type="Script" path="res://Tools/Scripts/SeedGalleryTool.cs" id="1_sgt"]
[node name="SeedGalleryTool" type="Node"]
script = ExtResource("1_sgt")

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://cstretch08isla"]
[ext_resource type="Script" path="res://Tools/Scripts/SouthernStretchTool.cs" id="1_sst"]
[node name="SouthernStretchTool" type="Node"]
script = ExtResource("1_sst")

View file

@ -0,0 +1,60 @@
using System;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ COASTAL FRAGMENTATION (chat2/09, EXPLORATION) — a perimeter-wide, band-limited, zero-mean noise
/// on the PRE-power falloff, applied only inside the coastal window.
///
/// ═══ WHY A DEDICATED TERM AND NOT THE EDGE NOISE SCALED ═══
///
/// Pass 1's edge noise is <c>(noise+1)/2 · 0.15 · squircle</c>: positive-only (it only ever PUSHES
/// the falloff up), modulated by the squircle (strongest at the rim, zero at the centre), sampled at
/// 2.5× the base frequency. Scaling it would roughen the whole rim and bias the coast inward; it is
/// the coastline's jitter, not a margin-targeted cutter. This term is separate: its own deterministic
/// field (seed offset + a coordinate offset in map widths), its own frequency (the neck/lobe scale),
/// zero-mean (it bites AND builds, so the coast is redrawn rather than eroded), and weighted by a
/// smooth WINDOW on the falloff value itself:
///
/// w(f) = 1 smoothstep(|f centre| / halfWidth) (exactly 0 beyond ± halfWidth)
/// falloff += amp · w(f) · noise(x + off, y + off) noise ∈ [1, 1]
///
/// The coast sits where rawBase falloff^2.5 crosses sea, i.e. near f ≈ 0.66 for a median rawBase
/// (chat2/08 diagnosis), so a window centred there covers the barely-land / barely-sea margin
/// around the whole perimeter, north and south. Cells whose falloff is clear of the window — the
/// interior, the massif, the deep sea — get w = 0 and are bit-identical by construction (asserted).
///
/// SELF-TARGETING: every lobe hangs off a neck of barely-land, cells whose height sits a hair above
/// sea. A bite of the same Δfalloff flips those first; solid land inside the window (a coastal hill)
/// moves in height but does not flip. No neck is detected; the margin selects itself. Amplitude is
/// the ladder; frequency is fixed and exposed as the secondary dial.
/// </summary>
public static class CoastalFragment
{
/// <summary>Periods per map width. The base noise is ≈ 4/map, the edge noise ≈ 10/map; lobes in the 08 plates are 27 % of the map. Chosen by the chat2/09 probe.</summary>
public const float DefaultFreqPerMapWidth = 12f;
/// <summary>The coastal window's centre in pre-power falloff units (the coast's falloff for a median base noise).</summary>
public const float DefaultBandCentre = 0.66f;
/// <summary>Half-width of the window. 0.18 spans f ∈ [0.48, 0.84] — the whole fringe, nothing of the interior.</summary>
public const float DefaultBandHalfWidth = 0.18f;
/// <summary>Bites only by default? Set by the chat2/09 probe (see the report): zero-mean redraws the margin and can bridge islands back; bites-only only cuts.</summary>
public const bool DefaultBitesOnly = false;
/// <summary>The field's seed offset (a SEED offset, like the islet layer's 7607).</summary>
public const int SeedOffset = 9109;
/// <summary>The field's coordinate offset, IN MAP WIDTHS (D-059) — decorrelates it from the base field's realization at every size.</summary>
public const float OffsetMapWidths = 0.37f;
/// <summary>The window weight for a pre-power falloff value.</summary>
public static float Window(float falloff, float centre, float halfWidth)
{
float t = MathF.Abs(falloff - centre) / halfWidth;
if (t >= 1f) return 0f;
return 1f - t * t * (3f - 2f * t);
}
}
}

View file

@ -0,0 +1 @@
uid://b4skxmovd0xpn

View file

@ -0,0 +1,517 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE COASTAL-FRAGMENTATION BATCH (chat2/09, exploration) — at a FIXED stretch, a perimeter-wide
/// fragmentation-noise amplitude ladder: light → heavy, 4 levels × 2 seeds, options to pick from.
///
/// ═══ TWO MODES ═══
///
/// ISLA_PROBE=1 numbers only at ISLA_CALIB_SIZE: a frequency × amplitude sweep on both seeds
/// (island counts and sizes per hemisphere, mainland size) — used to fix the
/// frequency and place the amplitude ladder. Written to scratch/frag_probe.md.
/// (default) the BATCH: 4 amplitudes × 2 seeds at ISLA_MAPSIZE, lean render per field
/// (labeled-regions overlay + relief + .f32), the hemisphere-split count/size table,
/// the asymmetric oracle (interior locked, coast free).
///
/// Every field: pass 1 + stretch (FIXED) + fragmentation (the axis), region labeling ON, speck revert
/// at a LOW threshold (true 13-cell noise only), offshore OFF, shelf OFF. The curve is the tagged
/// curve, unchanged.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/CoastalFragmentTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
/// ISLA_MAPSIZE / ISLA_CALIB_SIZE (default 4096 / 2048)
/// ISLA_SEEDS the two seeds (default 1063685222, 999999937 — task 08's)
/// ISLA_STRETCH the fixed stretch (default 2)
/// ISLA_FRAG_LEVELS the 4 amplitudes (default: the probe-chosen ladder)
/// ISLA_FRAG_FREQ the fixed frequency, periods per map width
/// ISLA_SPECK_FRAC the speck-revert threshold, fraction of map area (default 2.5e-7 ≈ 4 cells at 4096)
/// ISLA_PROBE=1 · ISLA_PROBE_FREQS · ISLA_PROBE_AMPS the probe sweep
/// ISLA_FRAG_BITES=1 bites-only noise ([0,1]) instead of zero-mean ([-1,1])
/// ISLA_SKIP_8K=1 (no 8192 check this batch — the 08 dump at 4096 is the baseline)
/// </summary>
public partial class CoastalFragmentTool : Node
{
private static readonly int[] DefaultSeeds = { 1063685222, 999999937 };
/// <summary>⚠ Task 01's pool, verbatim — the curve's identity.</summary>
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
/// <summary>⭐ THE LADDER — set from the probe (chat2/09 report §1): light → heavy, amplitude the only axis.</summary>
private static readonly float[] DefaultLadder = { 0.06f, 0.15f, 0.30f, 0.50f };
private static readonly float[] ProbeFreqs = { 12f, 20f, 32f };
private static readonly float[] ProbeAmps = { 0.03f, 0.06f, 0.12f, 0.20f, 0.32f, 0.5f };
private const float DefaultStretch = 2f;
private const float DefaultSpeckFrac = 2.5e-7f; // ≈ 4 cells at 4096 — true noise only
private const int DefaultMapSize = 4096;
private const int DefaultCalibSize = 2048;
public override void _Ready()
{
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr(e.StackTrace);
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private sealed class HemiStats
{
public int All, Big; public long Min, Med, Max; public double Mean; public int[] Hist; public long[] Largest = Array.Empty<long>();
}
private sealed class Row
{
public int Level; public float Amp; public int Seed;
public HemiStats N, S; public long MainlandCells; public int SpecksReverted;
public bool Ok; public ulong Ms;
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
int task = EnvInt("ISLA_TASK", 9);
string descr = EnvStr("ISLA_BATCH", "coastal_fragment");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
float stretch = EnvFloat("ISLA_STRETCH", DefaultStretch);
float[] ladder = EnvFloats("ISLA_FRAG_LEVELS", DefaultLadder);
float freq = EnvFloat("ISLA_FRAG_FREQ", CoastalFragment.DefaultFreqPerMapWidth);
float speckFrac = EnvFloat("ISLA_SPECK_FRAC", DefaultSpeckFrac);
bool probe = EnvStr("ISLA_PROBE", "0") == "1";
bool bitesOnly = EnvStr("ISLA_FRAG_BITES", CoastalFragment.DefaultBitesOnly ? "1" : "0") == "1";
float[] probeFreqs = EnvFloats("ISLA_PROBE_FREQS", ProbeFreqs);
float[] probeAmps = EnvFloats("ISLA_PROBE_AMPS", ProbeAmps);
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
string t08Source = EnvStr("ISLA_T08_SOURCE", "08_southern_stretch_explore");
string t08Level = EnvStr("ISLA_T08_LEVEL", "stretch_3"); // the 08 rung with stretch 2
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
string scratch = ToolingPaths.BatchScratch(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(scratch);
var anchors = CurveAnchors.Default;
float sea = 0.15f;
long big = Cells(RegionPass.ThresholdMidFrac, mapSize);
long speckCells = Cells(speckFrac, mapSize);
GD.Print("==================================================================");
GD.Print(" COASTAL FRAGMENTATION (chat2/09) — break more pieces off the edges, N + S");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} (plates) calibration / probe at {calibSize}");
GD.Print($"seeds : {string.Join(", ", seeds)}");
GD.Print($"fixed : stretch {stretch:G3} (band {SouthernStretch.DefaultBandStartFrac:F2}/{SouthernStretch.DefaultBandFeatherFrac:F2}, sinker stretched) · frag freq {freq:G3}/map · window {CoastalFragment.DefaultBandCentre:F2} ± {CoastalFragment.DefaultBandHalfWidth:F2} · speck revert < {speckCells} cells ({speckFrac:G2})");
GD.Print($"ladder : FragmentAmp {string.Join(", ", ladder)} noise {(bitesOnly ? "BITES ONLY [0,1]" : "zero-mean [-1,1]")} (\"big\" island = ≥ {big:N0} cells at {mapSize})");
GD.Print($"batch : {batchRoot}{(probe ? " ISLA_PROBE numbers only" : "")}");
GD.Print("==================================================================");
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
float highRaw = knots.K2; // the top of the preserved lowland (30 m output) — "above the toe+red band"
TerrainGenConfig Cfg(int size, int seed, string label, float amp, float fq, float st, bool revert)
{
return new TerrainGenConfig
{
MapSize = size, Seed = seed, VariantLabel = label,
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
CoastShelf = false, Offshore = new OffshoreSettings(),
RegionLabeling = true, SpeckRevert = revert, MinLandComponentFrac = speckFrac,
SouthStretch = st,
FragmentAmp = amp, FragmentFreqPerMapWidth = fq, FragmentBitesOnly = bitesOnly,
};
}
// ═══ PROBE ═══
if (probe)
{
GD.Print($"\n--- PROBE at {calibSize}: frequency × amplitude, stretch {stretch:G3} ---");
long bigC = Cells(RegionPass.ThresholdMidFrac, calibSize);
var sb = new StringBuilder();
sb.AppendLine($"# chat2/09 probe — fragmentation frequency × amplitude at {calibSize}, stretch {stretch:G3}, noise {(bitesOnly ? "bites only" : "zero-mean")}");
sb.AppendLine();
sb.AppendLine($"\"big\" = ≥ {bigC} cells at {calibSize}. Speck revert < {Cells(speckFrac, calibSize)} cells. Offshore off.");
sb.AppendLine();
sb.AppendLine("| seed | freq | amp | N islands all / big | N size med / max | S islands all / big | S size med / max | mainland cells | mainland Δ vs amp 0 |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
foreach (int seed in seeds)
{
var base0 = Topography.Generate(Cfg(calibSize, seed, "amp0", 0f, freq, stretch, true));
long main0 = base0.Regions.Mainland.SizeCells;
var (n0, s0) = Stats(base0.Regions, bigC);
sb.AppendLine($"| `{seed}` | — | 0 | {n0.All} / {n0.Big} | {n0.Med} / {n0.Max} | {s0.All} / {s0.Big} | {s0.Med} / {s0.Max} | {main0:N0} | 0 |");
GD.Print($" seed {seed} amp 0: N {n0.All}/{n0.Big} med {n0.Med} max {n0.Max} S {s0.All}/{s0.Big} med {s0.Med} max {s0.Max} mainland {main0:N0}");
foreach (float fq in probeFreqs)
foreach (float amp in probeAmps)
{
var p = Topography.Generate(Cfg(calibSize, seed, "probe", amp, fq, stretch, true));
var (n, s) = Stats(p.Regions, bigC);
long main = p.Regions.Mainland.SizeCells;
sb.AppendLine($"| `{seed}` | {fq:G3} | {amp:G3} | {n.All} / {n.Big} | {n.Med} / {n.Max} | {s.All} / {s.Big} | {s.Med} / {s.Max} | {main:N0} | {main - main0:+#,0;-#,0;0} |");
GD.Print($" seed {seed} freq {fq,4:G3} amp {amp,5:G3}: N {n.All,3}/{n.Big,3} med {n.Med,6} max {n.Max,7} S {s.All,3}/{s.Big,3} med {s.Med,6} max {s.Max,7} mainland {main:N0} ({main - main0:+#,0;-#,0;0})");
}
}
WriteText(Path.Combine(scratch, bitesOnly ? "frag_probe_bites_only.md" : "frag_probe.md"), sb.ToString());
GD.Print($"\n probe written: {Path.Combine(scratch, "frag_probe.md")}");
GetTree().Quit(0);
return;
}
// ═══ 1. REGRESSIONS ═══
GD.Print($"\n--- 1. REGRESSIONS ---");
var hard = new List<ShapingOracle.Check>();
{
var offCfg = Cfg(calibSize, seeds[0], "off", 0f, freq, 0f, false);
Pass1Result p1 = Topography.Generate(offCfg);
var curveOff = offCfg.Clone(); curveOff.Curve = false;
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{seeds[0]}_full", "height.f32");
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, stretch OFF, frag OFF == Phase-1 .f32 dump (the curve is untouched)", Shaping.Shape(p1, curveOff).Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
// ⭐ a8 — frag OFF at the fixed stretch, revert OFF == the task-08 stretch-2 field (its dump at the plate size).
foreach (int seed in seeds)
{
string t08Dump = Path.Combine(ToolingPaths.BatchesRoot, t08Source, $"{seed}_{t08Level}", "height.f32");
if (File.Exists(t08Dump) && mapSize == 4096)
{
var c8 = Cfg(mapSize, seed, "t08", 0f, freq, stretch, false);
Pass2Result q8 = Shaping.Shape(Topography.Generate(c8), c8);
hard.Add(ShapingOracle.DumpRegression("a8", $"frag OFF, stretch {stretch:G3}, revert OFF == task-08 {t08Level} dump (the baseline) [{seed}]", q8.Height, HeightField.Load(t08Dump, mapSize), mapSize, t08Dump));
}
else GD.Print($" a8 [{seed}]: ⚠ skipped — {(mapSize != 4096 ? "map size is not 4096" : $"no 08 dump at {t08Dump}")}");
}
foreach (var c in hard) GD.Print(" " + c);
}
// ═══ 2. THE LADDER — 4 amplitudes × 2 seeds ═══
GD.Print($"\n--- 2. THE LADDER at {mapSize} ---");
var rows = new List<Row>();
var baseline = new Dictionary<int, Row>();
var perField = new List<ShapingOracle.Check>();
foreach (int seed in seeds)
{
var c0 = Cfg(mapSize, seed, "frag_0", 0f, freq, stretch, true);
Pass1Result p0 = Topography.Generate(c0);
baseline[seed] = MakeRow(0, 0f, seed, p0, big, true, p0.ElapsedMs);
var b = baseline[seed];
GD.Print($" seed {seed} baseline (amp 0, stretch {stretch:G3}): N {b.N.All}/{b.N.Big} S {b.S.All}/{b.S.Big} mainland {b.MainlandCells:N0}");
for (int li = 0; li < ladder.Length; li++)
{
float amp = ladder[li];
string label = $"frag_{li + 1}";
var cfg = Cfg(mapSize, seed, label, amp, freq, stretch, true);
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result p2 = Shaping.Shape(p1, cfg);
var checks = new List<ShapingOracle.Check>
{
ShapingOracle.InteriorLocked(p0, p1, cfg.FragmentBandCentre, cfg.FragmentBandHalfWidth),
ShapingOracle.HighGroundReport(p0, p1, highRaw, $"K2 ({highRaw:F3} raw, the top of the preserved lowland)"),
ShapingOracle.CentreIsLand(p1),
ShapingOracle.TagCoastlineConsistent(p2, sea),
ShapingOracle.ClassifyFidelity(p1, p2),
};
foreach (var c in checks) { c.Name += $" [{label} = {amp:G3}, {seed}]"; perField.Add(c); }
bool ok = checks.TrueForAll(c => c.Passed);
var row = MakeRow(li + 1, amp, seed, p1, big, ok, p1.ElapsedMs);
rows.Add(row);
WriteField(batchRoot, p1, p2, sea, anchors, skipRaw, amp);
GD.Print($" {label,-7} amp {amp,5:G3} seed {seed,-11} N {row.N.All,3}/{row.N.Big,3} med {row.N.Med,6} max {row.N.Max,7} S {row.S.All,3}/{row.S.Big,3} med {row.S.Med,6} max {row.S.Max,7} mainland {row.MainlandCells:N0} ({row.MainlandCells - b.MainlandCells:+#,0;-#,0;0}) specks {row.SpecksReverted} {(ok ? "ok" : " CHECK FAILED")} {p1.ElapsedMs} ms");
}
}
// determinism
{
float amp = ladder[1];
var a = Topography.Generate(Cfg(mapSize, seeds[0], "det", amp, freq, stretch, true));
var bb = Topography.Generate(Cfg(mapSize, seeds[0], "det", amp, freq, stretch, true));
var det = ShapingOracle.LabelsDeterministic(a, bb); det.Name += $" [amp {amp:G3}, {seeds[0]}]";
var bits = ShapingOracle.NorthLocked("o2", $"two generations bit-identical everywhere [amp {amp:G3}, {seeds[0]}]", a.Height, bb.Height, mapSize, mapSize);
perField.Add(det); perField.Add(bits); GD.Print(" " + det); GD.Print(" " + bits);
}
bool allOk = hard.TrueForAll(c => c.Passed) && perField.TrueForAll(c => c.Passed);
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
foreach (var c in perField) if (!c.Passed) GD.PrintErr(" " + c);
WriteTable(batchRoot, mapSize, ladder, seeds, rows, baseline, big, stretch, freq, speckCells);
WriteIndex(batchRoot, mapSize, calibSize, ladder, seeds, rows, baseline, big, stretch, freq, speckCells, hard, perField, allOk);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" ORACLE {(allOk ? "HARD CHECKS ALL PASS" : "*** FAILURES see the table ***")}");
GD.Print("==================================================================");
GetTree().Quit(allOk ? 0 : 3);
}
// ---- the instrument --------------------------------------------------
private static long Cells(float frac, int size) => Math.Max(1L, (long)Math.Round(frac * (double)size * size));
private static (HemiStats north, HemiStats south) Stats(RegionLabels l, long big)
{
var n = new List<long>(); var s = new List<long>();
foreach (var r in l.Regions)
{
if (r.IsMainland) continue;
if (r.Hemisphere == RegionLabeling.HemiSouth) s.Add(r.SizeCells); else n.Add(r.SizeCells);
}
return (Make(n, big), Make(s, big));
}
private static HemiStats Make(List<long> sizes, long big)
{
sizes.Sort();
var h = new HemiStats { All = sizes.Count, Hist = new int[RegionLabeling.HistogramEdges.Length + 1] };
if (sizes.Count == 0) return h;
double sum = 0;
foreach (long v in sizes) { h.Hist[RegionLabeling.HistogramBin(v)]++; if (v >= big) h.Big++; sum += v; }
h.Min = sizes[0]; h.Med = sizes[sizes.Count / 2]; h.Max = sizes[^1]; h.Mean = sum / sizes.Count;
int k = Math.Min(3, sizes.Count); h.Largest = new long[k];
for (int i = 0; i < k; i++) h.Largest[i] = sizes[sizes.Count - 1 - i];
return h;
}
private static Row MakeRow(int level, float amp, int seed, Pass1Result p1, long big, bool ok, ulong ms)
{
var (n, s) = Stats(p1.Regions, big);
return new Row
{
Level = level, Amp = amp, Seed = seed, N = n, S = s, MainlandCells = p1.Regions.Mainland.SizeCells,
SpecksReverted = p1.RegionLedger?.RevertedComponents ?? 0, Ok = ok, Ms = ms,
};
}
// ---- the curve --------------------------------------------------------
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
{
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
var knots = new CurveKnots(2, "v2_balanced",
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
};
Pass2Result st = Shaping.Shape(pass1[s], scfg);
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
}
var pcts = ClimbCalibration.DefaultPercentiles;
var rawQ = new float[pcts.Length]; var outQ = new float[pcts.Length];
for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
var cal = ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f);
return (knots, cal);
}
// ---- output -----------------------------------------------------------
private static void WriteField(string batchRoot, Pass1Result p1, Pass2Result p2, float sea, CurveAnchors anchors, bool skipRaw, float amp)
{
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
DirAccess.MakeDirRecursiveAbsolute(dir);
if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
var look = new LookConfig
{
Name = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f, HillshadeStrength = 0.30f, SeaLevel = sea,
};
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, $"{p2.VariantLabel.ToUpperInvariant()} (AMP {amp:G3}) {p2.Seed}")
.SavePng(Path.Combine(dir, "relief.png"));
var led = p1.RegionLedger;
RegionOverlayRenderer.SavePng(p1.Regions, led != null && led.RevertOn ? p1.RegionsPre : null, p1.MapSize,
led?.RevertedComponents ?? 0, led?.ThresholdCells ?? 0, Path.Combine(dir, "regions.png"));
}
private static string HistRow(int[] h)
{
var sb = new StringBuilder();
for (int i = 0; i < h.Length; i++) { if (i > 0) sb.Append(" · "); sb.Append(h[i]); }
return sb.ToString();
}
private static string Largest(long[] l) => l.Length == 0 ? "—" : string.Join(" / ", Array.ConvertAll(l, v => v.ToString("N0")));
private static string TableMarkdown(float[] ladder, int[] seeds, List<Row> rows, Dictionary<int, Row> baseline, long big)
{
var sb = new StringBuilder();
var histHead = new StringBuilder();
for (int i = 0; i <= RegionLabeling.HistogramEdges.Length; i++) { if (i > 0) histHead.Append(" · "); histHead.Append(RegionLabeling.HistogramLabel(i)); }
sb.AppendLine($"| Level | amp | Seed | **N islands all / ≥ {big:N0}** | N size med / mean / max | N largest three | N histogram ({histHead}) | **S islands all / ≥ {big:N0}** | S size med / mean / max | S largest three | S histogram | mainland cells (Δ vs amp 0) | specks reverted | oracle |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|");
foreach (int seed in seeds)
{
var b = baseline[seed];
var all = new List<Row> { b }; all.AddRange(rows.FindAll(r => r.Seed == seed));
foreach (var r in all)
sb.AppendLine($"| {(r.Level == 0 ? "*baseline*" : $"`frag_{r.Level}`")} | {r.Amp:G3} | `{seed}` | **{r.N.All} / {r.N.Big}** | {r.N.Med} / {r.N.Mean:F0} / {r.N.Max} | {Largest(r.N.Largest)} | {HistRow(r.N.Hist)} | " +
$"**{r.S.All} / {r.S.Big}** | {r.S.Med} / {r.S.Mean:F0} / {r.S.Max} | {Largest(r.S.Largest)} | {HistRow(r.S.Hist)} | {r.MainlandCells:N0} ({r.MainlandCells - b.MainlandCells:+#,0;-#,0;0}) | {r.SpecksReverted} | {(r.Level == 0 ? "" : r.Ok ? "pass" : "**FAIL**")} |");
}
return sb.ToString();
}
private static void WriteTable(string batchRoot, int mapSize, float[] ladder, int[] seeds, List<Row> rows, Dictionary<int, Row> baseline, long big,
float stretch, float freq, long speckCells)
{
var sb = new StringBuilder();
sb.AppendLine($"# The hemisphere-split count/size table — {ladder.Length} amplitudes × {seeds.Length} seeds at {mapSize}");
sb.AppendLine();
sb.AppendLine($"Fixed: stretch {stretch:G3}, fragmentation frequency {freq:G3}/map, window {CoastalFragment.DefaultBandCentre:F2} ± {CoastalFragment.DefaultBandHalfWidth:F2}, speck revert < {speckCells} cells. Offshore / shelf OFF.");
sb.AppendLine("Islands = non-mainland 8-connected land components of the classify field; hemisphere by centroid. BOTH hemispheres are signals now.");
sb.AppendLine();
sb.Append(TableMarkdown(ladder, seeds, rows, baseline, big));
WriteText(Path.Combine(batchRoot, "count_size_table.md"), sb.ToString());
var csv = new StringBuilder();
csv.AppendLine("level,amp,seed,n_all,n_big,n_med,n_mean,n_max,n_largest,n_hist,s_all,s_big,s_med,s_mean,s_max,s_largest,s_hist,mainland_cells,specks_reverted,oracle,ms");
var ic = System.Globalization.CultureInfo.InvariantCulture;
foreach (int seed in seeds)
{
var all = new List<Row> { baseline[seed] }; all.AddRange(rows.FindAll(r => r.Seed == seed));
foreach (var r in all)
csv.AppendLine(string.Join(",", r.Level, r.Amp.ToString("G5", ic), r.Seed,
r.N.All, r.N.Big, r.N.Med, r.N.Mean.ToString("F1", ic), r.N.Max, "\"" + Largest(r.N.Largest) + "\"", "\"" + HistRow(r.N.Hist) + "\"",
r.S.All, r.S.Big, r.S.Med, r.S.Mean.ToString("F1", ic), r.S.Max, "\"" + Largest(r.S.Largest) + "\"", "\"" + HistRow(r.S.Hist) + "\"",
r.MainlandCells, r.SpecksReverted, r.Ok ? "pass" : "FAIL", r.Ms));
}
WriteText(Path.Combine(batchRoot, "count_size_table.csv"), csv.ToString());
}
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, float[] ladder, int[] seeds, List<Row> rows, Dictionary<int, Row> baseline, long big,
float stretch, float freq, long speckCells, List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perField, bool allOk)
{
int first = seeds.Length > 1 ? seeds[1] : seeds[0];
var sb = new StringBuilder();
sb.AppendLine("# Batch 09 — coastal fragmentation: break more pieces off the edges, N + S");
sb.AppendLine();
sb.AppendLine("**Options, not a setting.** At a FIXED stretch, a perimeter-wide band-limited fragmentation noise on the pre-power");
sb.AppendLine("falloff — only inside the coastal window, zero-mean — is swept light → heavy. Thin necks of barely-land flip first");
sb.AppendLine("(self-targeting: nothing detected, nothing stamped); the interior is bit-identical by construction (asserted). The");
sb.AppendLine("region layer is the instrument; both hemispheres are fragmentation signals now.");
sb.AppendLine();
sb.AppendLine("## ⭐ Open this first");
sb.AppendLine();
sb.AppendLine($"1. **`{first}_frag_2/regions.png`** — a mid amplitude on the seed whose natural southern islands read fragmentation best; grey mainland, each island its own colour, dark red = a reverted speck (< {speckCells} cells).");
sb.AppendLine($"2. Walk the ladder on that seed: `{first}_frag_1/` … `_frag_{ladder.Length}/` (`regions.png` beside `relief.png`); then the same on `{seeds[0]}`.");
sb.AppendLine("3. Then the table: N and S count + size side by side, down the rows as amplitude climbs — look for counts rising while the largest pieces stay healthy.");
sb.AppendLine();
sb.AppendLine("## The fixed frame and the axis");
sb.AppendLine();
sb.AppendLine($"- **Fixed:** stretch `{stretch:G3}` (task 08's `stretch_3` rung; band {SouthernStretch.DefaultBandStartFrac:F2} / feather {SouthernStretch.DefaultBandFeatherFrac:F2}, sinker stretched — untouched this round) · fragmentation frequency `{freq:G3}` periods/map (the secondary dial, fixed) · window centre {CoastalFragment.DefaultBandCentre:F2} ± {CoastalFragment.DefaultBandHalfWidth:F2} (pre-power falloff) · speck revert < {speckCells} cells (true noise only) · offshore OFF · shelf OFF.");
sb.AppendLine($"- **The axis — `FragmentAmp`:** {string.Join(" · ", Array.ConvertAll(ladder, v => v.ToString("G3")))} (levels 1{ladder.Length}); baseline 0 = the task-08 stretch field, measured for Δ.");
sb.AppendLine($"- \"big\" island = ≥ {big:N0} cells at {mapSize} (the 07 `threshold_mid`).");
sb.AppendLine();
sb.AppendLine($"## ⭐ The hemisphere-split count/size table — {ladder.Length} amplitudes × {seeds.Length} seeds at {mapSize}");
sb.AppendLine();
sb.Append(TableMarkdown(ladder, seeds, rows, baseline, big));
sb.AppendLine();
sb.AppendLine("Also as plain data: `count_size_table.md` / `.csv`. The probe that fixed the frequency and placed the ladder: `scratch/frag_probe.md`.");
sb.AppendLine();
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
sb.AppendLine();
sb.AppendLine("`ProvisionalEven`, flagged. The individually-coloured scheme is only the `regions.png` overlay.");
sb.AppendLine();
sb.AppendLine("## The oracle (asymmetric: interior locked, coast free)");
sb.AppendLine();
sb.AppendLine("Regressions (the curve untouched; frag OFF at the fixed stretch bit-identical to the task-08 field):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(hard));
sb.AppendLine("Per field (interior locked r · high-ground report s (informational) · centre-is-land m · tag/coastline k · classify b · determinism o / o2):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(perField));
sb.AppendLine($"**{(allOk ? "ALL HARD CHECKS PASS" : " FAILURES do not judge this batch")}**");
sb.AppendLine();
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `regions.png`, `relief.png`, `INDEX.md`, `count_size_table.md` / `.csv`, `scratch/frag_probe.md` | **keep** |");
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + code — large, clear freely |");
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
sb.AppendLine();
sb.AppendLine($"Plates at {mapSize}, curve calibrated at {calibSize} with offshore off. {WorldScale.Describe()}.");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
private static void WriteText(string path, string text)
{
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(text);
}
// ---- env helpers --------------------------------------------------------
private static string EnvStr(string k, string fallback)
{
string v = System.Environment.GetEnvironmentVariable(k);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}
private static int EnvInt(string k, int fallback)
=> int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
private static float EnvFloat(string k, float fallback)
=> float.TryParse(EnvStr(k, null) ?? "", System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out float v) ? v : fallback;
private static float[] EnvFloats(string k, float[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<float>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (float.TryParse(part.Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float f)) outp.Add(f);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
private static int[] EnvSeeds(string k, int[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<int>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
}
}

View file

@ -0,0 +1 @@
uid://cuwg87lli67l6

View file

@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// THE DRAINAGE MAPS (chat2/12) — presentation only, for eyeballing that the flow is sane:
///
/// • the LOG-SCALED ACCUMULATION map — drainage spans orders of magnitude, so log(1+acc) over land;
/// the dendritic uplands and the trunks read as bright channels on dark hillslopes; the ocean is a
/// flat dark blue and enclosed (non-ocean) water a dark teal, so the ocean identity is visible too;
/// • the PROMOTED-CANDIDATES overlay — a faint grey terrain, the sea-reaching trunks in cyan (outlet
/// square, mountain-exit white ring, lean tributaries thin), the endorheic giants in orange (pooling
/// terminal disc, lean tributaries thin), the lean endorheic terminals as red rings. Provisional
/// routes are NOT drawn (routing is a later task). Nothing here touches data.
/// </summary>
public static class DrainageRenderer
{
private static readonly Color Ocean = new(0.055f, 0.110f, 0.235f);
private static readonly Color Enclosed = new(0.060f, 0.220f, 0.230f);
private static readonly Color Trunk = new(0.250f, 0.900f, 1.000f);
private static readonly Color Giant = new(1.000f, 0.600f, 0.150f);
private static readonly Color Endo = new(1.000f, 0.250f, 0.250f);
private static readonly Color Exit = new(1.000f, 1.000f, 1.000f);
private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
/// <summary>log(1 + acc) / log(1 + max) over land; ocean / enclosed water flat.</summary>
public static Image Accumulation(int[] acc, bool[] isOcean, float[,] render, int n, float sea)
{
long max = 1;
for (int i = 0; i < acc.Length; i++) if (acc[i] > max) max = acc[i];
double lmax = Math.Log(1.0 + max);
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
int i = x * n + y;
if (isOcean[i]) { img.SetPixel(x, y, Ocean); continue; }
if (render[x, y] < sea) { img.SetPixel(x, y, Enclosed); continue; }
float v = (float)(Math.Log(1.0 + acc[i]) / lmax);
// a dark-to-bright ramp with a cool tint in the channels
float r = 0.06f + 0.94f * v * v, g = 0.08f + 0.92f * v, b = 0.12f + 0.88f * MathF.Sqrt(v);
img.SetPixel(x, y, new Color(MathF.Min(1f, r), MathF.Min(1f, g), MathF.Min(1f, b)));
}
return img;
}
/// <summary>The candidates over a faint terrain.</summary>
public static Image Candidates(DrainageAnalysis.Plan plan, bool[] isOcean, float[,] render, int n, float sea, float hMax, string title)
{
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
float span = MathF.Max(1e-6f, hMax - sea);
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
int i = x * n + y;
if (isOcean[i]) { img.SetPixel(x, y, Ocean); continue; }
if (render[x, y] < sea) { img.SetPixel(x, y, Enclosed); continue; }
float t = MathF.Min(1f, (render[x, y] - sea) / span);
float g = 0.30f + 0.45f * MathF.Sqrt(t);
img.SetPixel(x, y, new Color(g, g, g * 0.96f));
}
int thick = n >= 4096 ? 5 : 3, thin = n >= 4096 ? 3 : 2, mark = n >= 4096 ? 18 : 10;
foreach (var g in plan.Giants)
{
foreach (var tr in g.Tributaries) Polyline(img, tr.Course, n, Giant, thin);
Polyline(img, g.Course, n, Giant, thick);
Disc(img, (int)g.Terminal.x, (int)g.Terminal.y, mark, n, Giant);
Ring(img, (int)g.Terminal.x, (int)g.Terminal.y, mark + 8, n, Ink, 3);
if (g.ExitFound) Ring(img, (int)g.MountainExit.x, (int)g.MountainExit.y, mark, n, Exit, 4);
}
foreach (var t in plan.Trunks)
{
foreach (var tr in t.Tributaries) Polyline(img, tr.Course, n, Trunk, thin);
Polyline(img, t.Course, n, Trunk, thick);
Square(img, (int)t.Outlet.x, (int)t.Outlet.y, mark, n, Trunk);
if (t.ExitFound) Ring(img, (int)t.MountainExit.x, (int)t.MountainExit.y, mark, n, Exit, 4);
}
foreach (var e in plan.Endorheics)
Ring(img, (int)e.Terminal.x, (int)e.Terminal.y, mark + 4, n, Endo, 4);
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
TinyFont.Draw(img, title, 12, 12, s, Ink);
TinyFont.Draw(img, $"CYAN: SEA-REACHING TRUNKS ({plan.Trunks.Count}) - SQUARE = OUTLET WHITE RING = MOUNTAIN EXIT", 12, 12 + lh, s, Ink);
TinyFont.Draw(img, $"ORANGE: ENDORHEIC GIANTS ({plan.Giants.Count}) - DISC = POOLING TERMINAL (EXPECTED, NOT AN ERROR)", 12, 12 + lh * 2, s, Ink);
TinyFont.Draw(img, $"RED RING: LEAN ENDORHEIC TERMINALS ({plan.Endorheics.Count}) THIN LINES: LEAN TRIBUTARIES NOTHING CARVED - ANALYSIS ONLY", 12, 12 + lh * 3, s, Ink);
return img;
}
private static void Polyline(Image img, List<(float x, float y)> pts, int n, Color c, int thick)
{
for (int i = 1; i < pts.Count; i++)
Line(img, (int)pts[i - 1].x, (int)pts[i - 1].y, (int)pts[i].x, (int)pts[i].y, n, c, thick);
}
private static void Line(Image img, int x0, int y0, int x1, int y1, int n, Color c, int thick)
{
int dx = Math.Abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
int dy = -Math.Abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
int err = dx + dy; int r = thick / 2;
int guard = 0;
while (true)
{
for (int ox = -r; ox <= r; ox++)
for (int oy = -r; oy <= r; oy++)
{
int px = x0 + ox, py = y0 + oy;
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
}
if (x0 == x1 && y0 == y1) break;
if (++guard > 4 * n) break;
int e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
private static void Disc(Image img, int cx, int cy, int r, int n, Color c)
{
for (int ox = -r; ox <= r; ox++)
for (int oy = -r; oy <= r; oy++)
{
if (ox * ox + oy * oy > r * r) continue;
int px = cx + ox, py = cy + oy;
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
}
}
private static void Ring(Image img, int cx, int cy, int r, int n, Color c, int w)
{
for (int ox = -r; ox <= r; ox++)
for (int oy = -r; oy <= r; oy++)
{
int d2 = ox * ox + oy * oy;
if (d2 > r * r || d2 < (r - w) * (r - w)) continue;
int px = cx + ox, py = cy + oy;
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
}
}
private static void Square(Image img, int cx, int cy, int r, int n, Color c)
{
for (int ox = -r; ox <= r; ox++)
for (int oy = -r; oy <= r; oy++)
{
int px = cx + ox, py = cy + oy;
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
}
}
}
}

View file

@ -0,0 +1 @@
uid://dp11721l40ixx

View file

@ -0,0 +1,410 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE DRAINAGE-ANALYSIS BATCH (chat2/12) — minimal-first: is the flow sane before rivers are built
/// on it? Runs <see cref="DrainageAnalysis"/> (pure analysis) on the ERODED render field of the locked
/// shape for 4 seeds from the task-11 batch, renders the log-accumulation map + the promoted-candidates
/// overlay, writes the accumulation as .f32, and proves: terrain bit-identical before/after (nothing
/// carved), no water added, the routing-fill invariants, determinism, ocean identity from the region
/// layer. ⚠ D8 is used to COMPUTE where water flows — never to carve.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/DrainageTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
/// ISLA_MAPSIZE / ISLA_CALIB_SIZE (default 8192 / 2048)
/// ISLA_SEEDS (default the 4 task-11 seeds)
/// ISLA_SKIP_T11_CHECK=1 skip the bit-identity against the task-11 erosion_on dumps
/// </summary>
public partial class DrainageTool : Node
{
private static readonly int[] DefaultSeeds = { 1063685222, 999999937, 31415926, 17320508 };
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
private const int DefaultMapSize = 8192;
private const int DefaultCalibSize = 2048;
public override void _Ready()
{
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr(e.StackTrace);
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private sealed class Row
{
public int Seed; public DrainageAnalysis.Plan Plan; public long OceanCells, EnclosedWater, LandCells;
public ulong MsAnalysis; public bool Ok;
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
int task = EnvInt("ISLA_TASK", 12);
string descr = EnvStr("ISLA_BATCH", "drainage_analysis");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
bool skipT11 = EnvStr("ISLA_SKIP_T11_CHECK", "0") == "1";
string t11Source = EnvStr("ISLA_T11_SOURCE", "11_erosion");
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
var dp = new DrainageAnalysis.Params { SeaLevel = sea };
GD.Print("==================================================================");
GD.Print(" DRAINAGE ANALYSIS (chat2/12) — minimal-first: is the flow sane? (analysis only, nothing carved)");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
GD.Print($"seeds : {string.Join(", ", seeds)}");
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON (faithful tune) — the task-11 erosion_on field");
GD.Print($"params : endorheic depth ≥ {dp.EndorheicMinDepthM} m, area ≥ {dp.EndorheicMinAreaPx}, inflow ≥ {dp.EndorheicMinInflowPx}, max {dp.EndorheicMaxCount} · trunks {dp.TrunkCount} sep {dp.MinOutletSeparationPx} px · giants {dp.GiantCount} · stem ≥ {dp.StemMinAccPx} · tributary ≥ {dp.TributaryMinAccPx} (max {dp.TributaryMaxPerTrunk}) · exit grade {dp.ExitGradeMin} m/px over {dp.ExitWindowPx} px");
GD.Print($"batch : {batchRoot}");
GD.Print("==================================================================");
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
TerrainGenConfig Cfg(int size, int seed)
{
var c = new TerrainGenConfig
{
MapSize = size, Seed = seed, VariantLabel = "drainage",
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
};
TerrainShapeV1.Apply(c);
c.Erosion = true; // the faithful tune — the defaults
return c;
}
var hard = new List<ShapingOracle.Check>();
var perSeed = new List<ShapingOracle.Check>();
var rows = new List<Row>();
for (int si = 0; si < seeds.Length; si++)
{
int seed = seeds[si];
GD.Print($"\n--- seed {seed} ---");
var cfg = Cfg(mapSize, seed);
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result shaped = Shaping.Shape(p1, cfg);
var ero = ErosionPass.Apply(shaped, cfg);
Pass2Result p2 = ero.Shaped;
GD.Print($" terrain ready ({p1.ElapsedMs} ms pass 1, erosion {ero.Ms / 1000.0:F1} s)");
if (!skipT11)
{
string dump = Path.Combine(ToolingPaths.BatchesRoot, t11Source, $"{seed}_erosion_on", "height.f32");
if (File.Exists(dump) && mapSize == 8192)
{
var a11 = ShapingOracle.DumpRegression("a11", $"the eroded render field == the task-11 erosion_on dump (the terrain the developer saw) [{seed}]", p2.Height, HeightField.Load(dump, mapSize), mapSize, dump);
hard.Add(a11); GD.Print(" " + a11);
}
else GD.Print($" a11 [{seed}]: ⚠ skipped — {(mapSize != 8192 ? "map size is not the 11 batch's 8192" : $"no dump at {dump}")}");
}
// ⭐ THE OCEAN IDENTITY — from the region layer, on the CLASSIFY field.
bool[] isOcean = RegionLabeling.OceanMask(p2.HeightClassify, mapSize, sea, out long oceanCells, out long enclosed);
var isClassifyWater = new bool[mapSize * mapSize];
long waterPx = 0;
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
if (p2.HeightClassify[x, y] < sea) { isClassifyWater[x * mapSize + y] = true; waterPx++; }
GD.Print($" ocean (region layer, classify): {oceanCells:N0} cells; enclosed non-ocean water: {enclosed:N0} cells; classify water total {waterPx:N0}");
// Snapshot both fields — the analysis must write ZERO terrain cells.
var renderBefore = (float[,])p2.Height.Clone();
var classifyBefore = (float[,])p2.HeightClassify.Clone();
long wetRenderBefore = ErosionPass.CountWaterPixels(p2.Height, mapSize, sea);
ulong tA = Time.GetTicksMsec();
var plan = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp);
ulong msA = Time.GetTicksMsec() - tA;
GD.Print($" analysis {msA / 1000.0:F1} s: land {plan.LandCells:N0} — sea-reaching {plan.SeaReachingCells:N0} ({100.0 * plan.SeaReachingCells / Math.Max(1, plan.LandCells):F1} %), endorheic {plan.EndorheicCells:N0} ({100.0 * plan.EndorheicCells / Math.Max(1, plan.LandCells):F1} %), unrouted {plan.UnroutedCells:N0}; terminal basins {plan.TerminalBasinCount}, pits filled through {plan.PitsFilledCount:N0}");
foreach (var t in plan.Trunks) GD.Print($" trunk: outlet ({t.Outlet.x:F0},{t.Outlet.y:F0}) drainage {t.DrainageAreaPx:N0} px, stem {t.Course.Count * 4} px, exit {(t.ExitFound ? $"({t.MountainExit.x:F0},{t.MountainExit.y:F0}) at {t.MountainExitElevM:F0} m" : "NOT FOUND")}, tributaries {t.Tributaries.Count}");
foreach (var g in plan.Giants) GD.Print($" giant: terminal ({g.Terminal.x:F0},{g.Terminal.y:F0}) inflow {g.DrainageAreaPx:N0} px, basin {g.BasinAreaPx:N0} px / {g.BasinDepthM:F1} m deep, kind {g.Kind}, exit {(g.ExitFound ? $"{g.MountainExitElevM:F0} m" : "NOT FOUND")}, tributaries {g.Tributaries.Count}");
foreach (var e in plan.Endorheics) GD.Print($" lean terminal: ({e.Terminal.x:F0},{e.Terminal.y:F0}) inflow {e.DrainageAreaPx:N0}, basin {e.BasinAreaPx:N0} px / {e.BasinDepthM:F1} m");
// ═══ THE ORACLE ═══
var checks = new List<ShapingOracle.Check>
{
ShapingOracle.NorthLocked("t", "terrain untouched — render field bit-identical before/after the analysis", renderBefore, p2.Height, mapSize, mapSize),
ShapingOracle.NorthLocked("t2", "terrain untouched — classify field bit-identical before/after the analysis", classifyBefore, p2.HeightClassify, mapSize, mapSize),
WaterUnchanged(wetRenderBefore, p2, mapSize, sea, p1),
FillInvariants(plan, p2.Height, mapSize),
OceanFromRegionLayer(isOcean, p2.HeightClassify, mapSize, sea, oceanCells, enclosed),
};
if (si == 0)
{
var plan2 = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp);
checks.Add(Deterministic(plan, plan2));
}
foreach (var c in checks) { c.Name += $" [{seed}]"; perSeed.Add(c); GD.Print(" " + c); }
bool ok = checks.TrueForAll(c => c.Passed);
WriteSeed(batchRoot, seed, plan, isOcean, p2, mapSize, sea, skipRaw);
rows.Add(new Row { Seed = seed, Plan = plan, OceanCells = oceanCells, EnclosedWater = enclosed, LandCells = plan.LandCells, MsAnalysis = msA, Ok = ok });
}
bool allOk = hard.TrueForAll(c => c.Passed) && perSeed.TrueForAll(c => c.Passed);
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
foreach (var c in perSeed) if (!c.Passed) GD.PrintErr(" " + c);
WriteIndex(batchRoot, mapSize, calibSize, seeds, rows, dp, hard, perSeed, allOk);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" ORACLE {(allOk ? "HARD CHECKS ALL PASS" : "*** FAILURES see the table ***")}");
GD.Print("==================================================================");
GetTree().Quit(allOk ? 0 : 3);
}
// ---- the checks -------------------------------------------------------
private static ShapingOracle.Check WaterUnchanged(long wetBefore, Pass2Result p2, int n, float sea, Pass1Result p1)
{
long wetAfter = ErosionPass.CountWaterPixels(p2.Height, n, sea);
var c = new ShapingOracle.Check { Id = "w", Name = "no water added — render water pixels unchanged; region labeling + island tag untouched" };
c.Passed = wetBefore == wetAfter && p1.Regions != null;
c.Detail = $"water pixels {wetBefore:N0} → {wetAfter:N0}; {p1.Regions?.IslandCount ?? 0} islands in the (untouched) region table";
return c;
}
/// <summary>The reference's two routing-fill diagnostics: filled ≥ original everywhere; every cell has a non-ascending path to the border on the full fill.</summary>
private static ShapingOracle.Check FillInvariants(DrainageAnalysis.Plan plan, float[,] height, int n)
{
var c = new ShapingOracle.Check { Id = "r", Name = "routing fill — full fill ≥ original everywhere; every cell has a non-ascending 8-path to the border" };
long below = 0, raised = 0; string first = null;
var ff = plan.FullFilled;
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
int i = x * n + y; float h = height[x, y];
if (ff[i] < h) { below++; first ??= $"[{x},{y}] filled {ff[i]:G9} < original {h:G9}"; }
else if (ff[i] > h) raised++;
}
// Non-ascending path: follow the lowest neighbour; memoised. -1 unknown, 1 reaches border, 2 stuck.
var state = new sbyte[n * n]; long stuck = 0; var path = new List<int>(1 << 12);
int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 }, DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
for (int i = 0; i < n * n && stuck == 0; i++)
{
if (state[i] != 0) continue;
int cur = i; path.Clear(); sbyte result = 0;
while (true)
{
if (state[cur] != 0) { result = state[cur]; break; }
path.Add(cur);
int cx = cur / n, cy = cur % n;
if (cx == 0 || cy == 0 || cx == n - 1 || cy == n - 1) { result = 1; break; }
float best = ff[cur]; int bestN = -1;
for (int k = 0; k < 8; k++)
{
int ni = (cx + DX[k]) * n + (cy + DY[k]);
if (ff[ni] < best) { best = ff[ni]; bestN = ni; } // the strictly lowest neighbour
}
if (bestN < 0)
{
// no strictly lower neighbour: allow an EQUAL neighbour not yet on this path (flat), else stuck
for (int k = 0; k < 8 && bestN < 0; k++)
{
int ni = (cx + DX[k]) * n + (cy + DY[k]);
if (ff[ni] == ff[cur] && state[ni] == 1) bestN = ni;
}
if (bestN < 0) { result = 2; break; }
}
cur = bestN;
if (path.Count > 4 * n) { result = 2; break; }
}
foreach (int pc in path) state[pc] = result;
if (result == 2) { stuck++; first ??= $"cell {path[0] / n},{path[0] % n} has no non-ascending path to the border"; }
}
c.Passed = below == 0 && stuck == 0;
c.Detail = c.Passed ? $"filled ≥ original on all {(long)n * n:N0} cells ({raised:N0} raised); every cell drains to the border on the full fill"
: $"VIOLATION — {below:N0} cells filled below original, {stuck:N0} stuck — {first}";
return c;
}
private static ShapingOracle.Check OceanFromRegionLayer(bool[] isOcean, float[,] classify, int n, float sea, long oceanCells, long enclosed)
{
var c = new ShapingOracle.Check { Id = "s", Name = "\"the sea\" = the ocean body from the region layer (classify, 4-connected to the border) — not any below-sea cell" };
long oceanLand = 0, oceanTouchBorder = 0, count = 0;
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
if (!isOcean[x * n + y]) continue;
count++;
if (classify[x, y] >= sea) oceanLand++;
if (x == 0 || y == 0 || x == n - 1 || y == n - 1) oceanTouchBorder++;
}
c.Passed = oceanLand == 0 && oceanTouchBorder > 0 && count == oceanCells;
c.Detail = $"{count:N0} ocean cells, all below sea, {oceanTouchBorder:N0} on the border; {enclosed:N0} below-sea cells are NOT ocean (enclosed water — ordinary terrain to the router)";
return c;
}
private static ShapingOracle.Check Deterministic(DrainageAnalysis.Plan a, DrainageAnalysis.Plan b)
{
var c = new ShapingOracle.Check { Id = "o", Name = "deterministic — flow field, accumulation and candidate set identical across two runs" };
long dirDiff = 0, accDiff = 0;
for (int i = 0; i < a.Dir.Length; i++) { if (a.Dir[i] != b.Dir[i]) dirDiff++; if (a.Acc[i] != b.Acc[i]) accDiff++; }
bool cand = a.Trunks.Count == b.Trunks.Count && a.Giants.Count == b.Giants.Count && a.Endorheics.Count == b.Endorheics.Count;
if (cand) for (int i = 0; i < a.Trunks.Count; i++) cand &= a.Trunks[i].DrainageAreaPx == b.Trunks[i].DrainageAreaPx && a.Trunks[i].Outlet == b.Trunks[i].Outlet;
if (cand) for (int i = 0; i < a.Giants.Count; i++) cand &= a.Giants[i].DrainageAreaPx == b.Giants[i].DrainageAreaPx && a.Giants[i].Terminal == b.Giants[i].Terminal;
c.Passed = dirDiff == 0 && accDiff == 0 && cand;
c.Detail = c.Passed ? $"dir and acc identical over {a.Dir.Length:N0} cells; {a.Trunks.Count} trunks / {a.Giants.Count} giants / {a.Endorheics.Count} lean terminals identical"
: $"DIFFER — dir {dirDiff:N0} cells, acc {accDiff:N0} cells, candidates {(cand ? "same" : "DIFFER")}";
return c;
}
// ---- the curve --------------------------------------------------------
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
{
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
var knots = new CurveKnots(2, "v2_balanced",
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
};
Pass2Result st = Shaping.Shape(pass1[s], scfg);
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
}
var pcts = ClimbCalibration.DefaultPercentiles;
var rawQ = new float[pcts.Length]; var outQ = new float[pcts.Length];
for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
var cal = ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f);
return (knots, cal);
}
// ---- output -----------------------------------------------------------
private static void WriteSeed(string batchRoot, int seed, DrainageAnalysis.Plan plan, bool[] isOcean, Pass2Result p2, int n, float sea, bool skipRaw)
{
string dir = Path.Combine(batchRoot, $"{seed}");
DirAccess.MakeDirRecursiveAbsolute(dir);
DrainageRenderer.Accumulation(plan.Acc, isOcean, p2.Height, n, sea).SavePng(Path.Combine(dir, "accumulation.png"));
DrainageRenderer.Candidates(plan, isOcean, p2.Height, n, sea, p2.HMax, $"DRAINAGE PLAN SEED {seed} (ERODED TERRAIN, D8 ANALYSIS)").SavePng(Path.Combine(dir, "candidates.png"));
if (!skipRaw)
{
var accF = new float[n, n];
for (int x = 0; x < n; x++) for (int y = 0; y < n; y++) accF[x, y] = plan.Acc[x * n + y];
HeightField.Save(accF, n, Path.Combine(dir, "accumulation.f32"));
}
}
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, int[] seeds, List<Row> rows, DrainageAnalysis.Params dp,
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perSeed, bool allOk)
{
var sb = new StringBuilder();
sb.AppendLine($"# Batch 12 — drainage analysis (minimal-first): is the flow sane? {seeds.Length} seeds at {mapSize}");
sb.AppendLine();
sb.AppendLine("**Analysis only — nothing carved, no water added.** The reference `DrainageAnalysis` (priority-flood routing fill with a");
sb.AppendLine("one-ulp epsilon, D8 flow directions FOR ANALYSIS, Kahn accumulation, drainage-area promotion) on the eroded render field of the");
sb.AppendLine("locked shape. **\"The sea\" is the OCEAN body from the region layer** (classify, 4-connected to the border); enclosed water is");
sb.AppendLine("ordinary terrain to the router. **⚠ Endorheic basins are EXPECTED here, not errors:** erosion delivers the upland network only and");
sb.AppendLine("cannot cross the flats, so the biggest drainages pool inland. A map full of orange terminals is the correct result.");
sb.AppendLine();
sb.AppendLine("## ⭐ Open this first");
sb.AppendLine();
sb.AppendLine($"1. **`{seeds[0]}/accumulation.png`** — log-scaled flow accumulation: dendritic uplands and trunks bright on dark hillslopes.");
sb.AppendLine($"2. **`{seeds[0]}/candidates.png`** — the promoted candidates over a faint terrain: cyan = sea-reaching trunks (square outlet, white ring = mountain exit), orange = endorheic giants (disc = pooling terminal), red rings = lean endorheic terminals.");
sb.AppendLine("3. The other three seeds, then the table.");
sb.AppendLine();
sb.AppendLine("## The summary table — sea-reaching vs endorheic (endorheic dominance is the expected finding)");
sb.AppendLine();
sb.AppendLine("| Seed | land cells | → ocean | → endorheic | unrouted | terminal basins / pits filled | trunks (drainage px; exit) | giants (inflow px; basin px / depth; kind) | largest endorheic giant vs largest trunk | lean terminals | ocean / enclosed water cells | oracle |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|");
foreach (var r in rows)
{
var p = r.Plan;
string trunks = p.Trunks.Count == 0 ? "—" : string.Join("<br>", p.Trunks.ConvertAll(t => $"({t.Outlet.x:F0},{t.Outlet.y:F0}) {t.DrainageAreaPx:N0}; exit {(t.ExitFound ? $"{t.MountainExitElevM:F0} m" : "none")}"));
string giants = p.Giants.Count == 0 ? "—" : string.Join("<br>", p.Giants.ConvertAll(g => $"({g.Terminal.x:F0},{g.Terminal.y:F0}) {g.DrainageAreaPx:N0}; {g.BasinAreaPx:N0} / {g.BasinDepthM:F1} m; {g.Kind}"));
long bigG = p.Giants.Count == 0 ? 0 : p.Giants[0].DrainageAreaPx, bigT = p.Trunks.Count == 0 ? 0 : p.Trunks[0].DrainageAreaPx;
sb.AppendLine($"| `{r.Seed}` | {p.LandCells:N0} | {p.SeaReachingCells:N0} ({100.0 * p.SeaReachingCells / Math.Max(1, p.LandCells):F1} %) | **{p.EndorheicCells:N0} ({100.0 * p.EndorheicCells / Math.Max(1, p.LandCells):F1} %)** | {p.UnroutedCells:N0} | {p.TerminalBasinCount} / {p.PitsFilledCount:N0} | {trunks} | {giants} | **{bigG:N0} vs {bigT:N0}** ({(bigT > 0 ? (double)bigG / bigT : 0):F1}×) | {p.Endorheics.Count} | {r.OceanCells:N0} / {r.EnclosedWater:N0} | {(r.Ok ? "pass" : "**FAIL**")} |");
}
sb.AppendLine();
sb.AppendLine($"Params: endorheic depth ≥ {dp.EndorheicMinDepthM} m, area ≥ {dp.EndorheicMinAreaPx:N0} px, inflow ≥ {dp.EndorheicMinInflowPx:N0} px, max {dp.EndorheicMaxCount} · trunks {dp.TrunkCount}, outlet separation {dp.MinOutletSeparationPx} px · giants {dp.GiantCount} · stem ≥ {dp.StemMinAccPx} · tributary ≥ {dp.TributaryMinAccPx:N0} (max {dp.TributaryMaxPerTrunk}) · exit grade {dp.ExitGradeMin} m/px over {dp.ExitWindowPx} px — the reference's declared defaults. Provisional routes are computed (as the reference did) but NOT drawn or promoted — routing is a later task.");
sb.AppendLine();
sb.AppendLine("## The oracle (analysis-only guarantees)");
sb.AppendLine();
sb.AppendLine(hard.Count == 0 ? "*(the task-11 bit-identity check was skipped)*\n" : ShapingOracle.ToMarkdownTable(hard));
sb.AppendLine("Per seed (terrain untouched t / t2 · no water added w · routing-fill invariants r · ocean from the region layer s · determinism o):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(perSeed));
sb.AppendLine($"**{(allOk ? "ALL HARD CHECKS PASS" : " FAILURES do not judge this batch")}**");
sb.AppendLine();
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `accumulation.png`, `candidates.png`, `INDEX.md` | **keep** |");
sb.AppendLine("| `accumulation.f32` | ♻ regenerable (analysis of a regenerable field) — 256 MB each, clear freely |");
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
sb.AppendLine();
sb.AppendLine($"Analysis at {mapSize}, curve calibrated at {calibSize}. {WorldScale.Describe()}.");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
private static void WriteText(string path, string text)
{
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(text);
}
private static string EnvStr(string k, string fallback)
{
string v = System.Environment.GetEnvironmentVariable(k);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}
private static int EnvInt(string k, int fallback) => int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
private static int[] EnvSeeds(string k, int[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<int>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
}
}

View file

@ -0,0 +1 @@
uid://becalqn1wrw4k

View file

@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ PASS 2b — HYDRAULIC EROSION, THE CALLER (chat2/11). Runs <see cref="HydraulicErosion"/> on
/// the RENDER field of a shaped result — after the curve + detail (pass 2a), before the crater
/// carve (which does not exist yet) — exactly the reference's slot (<c>MapGenerator.cs:737-805</c>).
///
/// ═══ THE THREE THINGS THE CALLER OWNS (ported from the reference caller) ═══
///
/// 1. THE SPLIT. Only the render field is eroded; the classify field is finalized before the pass
/// and never sees it — biomes / water / region labeling classify pre-erosion (the oracle).
/// If the two fields are aliased (curve off), the render field is COPIED first, as the
/// reference allocated a separate classify array when erosion was on.
/// 2. THE FLOOD GUARD. Render-map water pixels are counted BEFORE and AFTER; any change throws
/// "EROSION FLOOD-GUARD VIOLATION". With the pass's sea clamp (below-sea cells read-only in
/// both directions; carve floor at sea + margin) this is the active proof that no coastline
/// moved — mainland and every island alike.
/// 3. THE CRATER EXCLUSION, INERT. The weight is passed whole (core / feather radii, mode) but
/// with no crater in v2 the radius is 0 ⇒ weight 1 everywhere. It activates when the crater
/// carve lands; the reference's "CraterErosionCore &lt; carve factor" warning is DORMANT until
/// then (it concerns the erosion↔carve interaction, which does not exist yet).
///
/// The governors are clamped as the reference's ConfigManager clamped them (count [0, 50 M],
/// lifetime [1, 4096], carve cap [0, 60], deposit cap [0, 60], sea margin [0, 5], inertia
/// [0, 0.99], evaporation [0, 0.5]) and a clamp is reported, not silent.
/// </summary>
public static class ErosionPass
{
public sealed class Result
{
public Pass2Result Shaped; // the result with the eroded RENDER field (classify untouched)
public HydraulicErosion.Stats Stats;
public HydraulicErosion.Params Params;
public long WetBefore, WetAfter;
public ulong Ms;
public List<string> Notes = new();
}
/// <summary>Count render-map water pixels (height below the flat sea) — the flood guard's instrument.</summary>
public static long CountWaterPixels(float[,] height, int mapSize, float sea)
{
long wet = 0;
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
if (height[x, y] < sea) wet++;
return wet;
}
/// <summary>
/// Erode <paramref name="p2"/>'s render field (a copy if aliased to classify) and return the
/// result. Throws on a governor-cap violation (from the pass) or a flood-guard violation.
/// </summary>
public static Result Apply(Pass2Result p2, TerrainGenConfig cfg)
{
ulong t0 = Time.GetTicksMsec();
var r = new Result();
int n = p2.MapSize;
float sea = cfg.SeaLevel;
// 1. THE SPLIT — never erode an array the classify field shares.
float[,] render = p2.Height;
if (p2.FieldsAreAliased)
{
render = (float[,])p2.Height.Clone();
r.Notes.Add("[Erosion] render and classify were aliased (curve off) — the render field was copied before eroding, as the reference allocated a separate classify array when erosion was on.");
}
// The governors, clamped as the reference's ConfigManager clamped them — reported, not silent.
int count = Math.Clamp(cfg.ErosionDropletCount, 0, 50_000_000);
int life = Math.Clamp(cfg.ErosionDropletLifetime, 1, 4096);
float carve = Math.Clamp(cfg.ErosionCarveCapM, 0f, 60f);
float deposit = Math.Clamp(cfg.ErosionDepositCapM, 0f, 60f);
float margin = Math.Clamp(cfg.ErosionSeaMarginM, 0f, 5f);
float inertia = Math.Clamp(cfg.ErosionInertia, 0f, 0.99f);
float evap = Math.Clamp(cfg.ErosionEvaporation, 0f, 0.5f);
if (count != cfg.ErosionDropletCount || life != cfg.ErosionDropletLifetime || carve != cfg.ErosionCarveCapM || deposit != cfg.ErosionDepositCapM
|| margin != cfg.ErosionSeaMarginM || inertia != cfg.ErosionInertia || evap != cfg.ErosionEvaporation)
r.Notes.Add($"[Erosion] ⚠ governor out of bounds — clamped: count {cfg.ErosionDropletCount}→{count}, lifetime {cfg.ErosionDropletLifetime}→{life}, carve {cfg.ErosionCarveCapM}→{carve} m, deposit {cfg.ErosionDepositCapM}→{deposit} m, margin {cfg.ErosionSeaMarginM}→{margin} m, inertia {cfg.ErosionInertia}→{inertia}, evaporation {cfg.ErosionEvaporation}→{evap}.");
// 3. THE CRATER EXCLUSION — inert: no crater ⇒ radius 0 ⇒ weight 1 everywhere.
float craterRadius = cfg.CraterRadius; // 0 in v2 until the crater task
float coreR = craterRadius * cfg.CraterErosionCore;
float featherR = craterRadius * cfg.CraterErosionFeather;
const float CRATER_CARVE_FACTOR = 0.80f; // the reference's carve radius factor
if (craterRadius > 0f && cfg.CraterErosionCore < CRATER_CARVE_FACTOR)
r.Notes.Add($"[Erosion] ⚠ CraterErosionCore {cfg.CraterErosionCore:F2} is inside the carve radius ({CRATER_CARVE_FACTOR:F2} × CraterRadius) — erosion will modify carve-authored terrain and the carve will amplify those deltas across the waterline (the reference's warning; live only once a crater exists).");
// 2. THE FLOOD GUARD — before.
r.WetBefore = CountWaterPixels(render, n, sea);
r.Params = new HydraulicErosion.Params
{
DropletCount = count, Lifetime = life, CarveCapM = carve, DepositCapM = deposit, SeaMarginM = margin,
BrushRadius = Math.Max(0, cfg.ErosionBrushRadius), Inertia = inertia, CapacityFactor = cfg.ErosionCapacity,
MinSlopeM = cfg.ErosionMinSlopeM, ErodeRate = cfg.ErosionErodeRate, DepositRate = cfg.ErosionDepositRate,
Evaporation = evap, Gravity = cfg.ErosionGravity,
CraterMode = cfg.CraterErosionFeatherMode ? HydraulicErosion.CRATER_MODE_FEATHER : HydraulicErosion.CRATER_MODE_FULL,
Seed = cfg.Seed + HydraulicErosion.SEED_OFFSET,
};
r.Stats = HydraulicErosion.Apply(render, n, null, sea, cfg.CraterCenterX, cfg.CraterCenterY, coreR, featherR, r.Params);
// 2. THE FLOOD GUARD — after. Any change refuses the generation.
r.WetAfter = CountWaterPixels(render, n, sea);
if (r.WetAfter != r.WetBefore)
throw new InvalidOperationException(
$"[ErosionPass] EROSION FLOOD-GUARD VIOLATION: render-map water pixels {r.WetBefore} -> {r.WetAfter}. Refusing to generate.");
r.Ms = Time.GetTicksMsec() - t0;
var st = r.Stats;
r.Notes.Add($"[Erosion] crater exclusion: {(craterRadius > 0f ? $"core {coreR:F0} px feather {featherR:F0} px" : "INERT (no crater; weight 1 everywhere)")}.");
r.Notes.Add($"[Erosion] v1: {st.Spawned} droplets ({st.SkippedNoLand} skipped), {st.Steps:N0} steps, {r.Ms / 1000.0:F1}s wall. " +
$"Eroded {st.ErodedVolumeM3:F0} m³ over {st.ModifiedCells:N0} touched cells (max cell carve {st.MaxCellErosionM:F2} m vs cap {carve:F2} m), " +
$"deposited {st.DepositedVolumeM3:F0} m³ (max cell deposit {st.MaxCellDepositM:F2} m vs cap {deposit:F2} m). " +
$"Deaths: {st.DiedSea} sea / {st.DiedEdge} edge / {st.DiedDry} dry / {st.DiedLifetime} lifetime. " +
$"Water pixels {r.WetBefore:N0} -> {r.WetAfter:N0} (flood guard holds).");
r.Shaped = p2.WithHeight(render, r.Notes, r.Ms);
return r;
}
}
}

View file

@ -0,0 +1 @@
uid://dlpkt4kkcqvg8

View file

@ -0,0 +1,431 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE LOCKED SHAPE — `terrain-shape-v1` (chat2/10 gallery-confirmed): the continuous curve + the
/// frag_4 organic islands. Every later pass (erosion, rivers, …) starts from exactly these values,
/// pinned here once so no tool re-types them.
/// </summary>
public static class TerrainShapeV1
{
public const float FragmentAmp = 0.5f, FragmentFreq = 12f, BandCentre = 0.66f, BandHalfWidth = 0.18f;
public const bool BitesOnly = false;
public const float Stretch = 2f, BandStart = 0.70f, BandFeather = 0.05f;
public const bool StretchSinker = true;
public const float SpeckFrac = 2.5e-7f;
/// <summary>Apply the locked shape to a config (curve settings are the caller's — they come from the calibration).</summary>
public static void Apply(TerrainGenConfig c)
{
c.CoastShelf = false; c.Offshore = new OffshoreSettings();
c.RegionLabeling = true; c.SpeckRevert = true; c.MinLandComponentFrac = SpeckFrac;
c.SouthStretch = Stretch; c.SouthBandStartFrac = BandStart; c.SouthBandFeatherFrac = BandFeather; c.StretchSinker = StretchSinker;
c.FragmentAmp = FragmentAmp; c.FragmentFreqPerMapWidth = FragmentFreq;
c.FragmentBandCentre = BandCentre; c.FragmentBandHalfWidth = BandHalfWidth; c.FragmentBitesOnly = BitesOnly;
}
public static string Describe() =>
$"terrain-shape-v1: frag amp {FragmentAmp} freq {FragmentFreq} window {BandCentre}±{BandHalfWidth} · stretch {Stretch} (band {BandStart}/{BandFeather}, sinker stretched) · speck revert {SpeckFrac:G2} · offshore OFF · shelf OFF · labeling ON";
}
/// <summary>
/// ⭐ THE EROSION BATCH (chat2/11) — the faithful droplet erosion on the locked shape, judged across
/// seeds, erosion OFF vs ON. 4 seeds from the task-10 gallery × {off, on} = 8 fields at showpiece
/// size; per field grayscale + .f32 + hillshaded relief; a mid-slope close-up off/on on the first
/// seed (the green→yellow feather gate); the erosion stats table; the asymmetric oracle.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/ErosionTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
/// ISLA_MAPSIZE / ISLA_CALIB_SIZE (default 8192 / 2048)
/// ISLA_SEEDS (default 4 gallery seeds)
/// ISLA_ERO_COUNT / _LIFETIME / _CARVE / _DEPOSIT governor overrides (the faithful tune is the default)
/// ISLA_SKIP_TAG_CHECK=1 skip the bit-identity against the 10 gallery dumps (terrain-shape-v1)
/// </summary>
public partial class ErosionTool : Node
{
private static readonly int[] DefaultSeeds = { 1063685222, 999999937, 31415926, 17320508 };
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
private const int DefaultMapSize = 8192;
private const int DefaultCalibSize = 2048;
/// <summary>The pure-shade plate's vertical exaggeration — stronger than the relief's 18 so half-metre drainage reads. A look dial.</summary>
private const float ShadeZ = 60f;
public override void _Ready()
{
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr(e.StackTrace);
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private sealed class Row
{
public int Seed; public HydraulicErosion.Stats St; public HydraulicErosion.Params P;
public long WetBefore, WetAfter; public ulong MsErosion, MsGen;
public double ErodedMeanM, DepositedMeanM; public long LandCells;
public bool Ok;
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
int task = EnvInt("ISLA_TASK", 11);
string descr = EnvStr("ISLA_BATCH", "erosion");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
bool skipTag = EnvStr("ISLA_SKIP_TAG_CHECK", "0") == "1";
string t10Source = EnvStr("ISLA_T10_SOURCE", "10_frag4_seed_gallery");
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
GD.Print("==================================================================");
GD.Print(" HYDRAULIC EROSION (chat2/11) — the faithful port on the locked shape, off vs on");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
GD.Print($"seeds : {string.Join(", ", seeds)}");
GD.Print($"shape : {TerrainShapeV1.Describe()}");
GD.Print($"batch : {batchRoot}");
GD.Print("==================================================================");
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
TerrainGenConfig Cfg(int size, int seed, string label, bool erosion)
{
var c = new TerrainGenConfig
{
MapSize = size, Seed = seed, VariantLabel = label,
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
};
TerrainShapeV1.Apply(c);
c.Erosion = erosion;
c.ErosionDropletCount = EnvInt("ISLA_ERO_COUNT", c.ErosionDropletCount);
c.ErosionDropletLifetime = EnvInt("ISLA_ERO_LIFETIME", c.ErosionDropletLifetime);
c.ErosionCarveCapM = EnvFloat("ISLA_ERO_CARVE", c.ErosionCarveCapM);
c.ErosionDepositCapM = EnvFloat("ISLA_ERO_DEPOSIT", c.ErosionDepositCapM);
return c;
}
var tuneCfg = Cfg(mapSize, seeds[0], "tune", true);
GD.Print($" tune : droplets {tuneCfg.ErosionDropletCount:N0} · lifetime {tuneCfg.ErosionDropletLifetime} · carve cap {tuneCfg.ErosionCarveCapM} m · deposit cap {tuneCfg.ErosionDepositCapM} m · sea margin {tuneCfg.ErosionSeaMarginM} m · brush {tuneCfg.ErosionBrushRadius} px · " +
$"inertia {tuneCfg.ErosionInertia} · capacity {tuneCfg.ErosionCapacity} · min slope {tuneCfg.ErosionMinSlopeM} m/px · erode {tuneCfg.ErosionErodeRate} · deposit {tuneCfg.ErosionDepositRate} · evaporation {tuneCfg.ErosionEvaporation} · gravity {tuneCfg.ErosionGravity} · crater exclusion INERT");
// ═══ THE FIELDS ═══
var hard = new List<ShapingOracle.Check>();
var perSeed = new List<ShapingOracle.Check>();
var rows = new List<Row>();
var look = new LookConfig
{
Name = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f, HillshadeStrength = 0.30f, SeaLevel = sea,
};
float bandLo = sea + WorldScale.RawFromMetres(30f), bandHi = sea + WorldScale.RawFromMetres(100f); // the green→yellow mid-slope band
bool cropDone = false;
for (int si = 0; si < seeds.Length; si++)
{
int seed = seeds[si];
GD.Print($"\n--- seed {seed} ---");
// OFF — the locked shape, the reference half.
var cOff = Cfg(mapSize, seed, "erosion_off", false);
Pass1Result p1Off = Topography.Generate(cOff);
Pass2Result p2Off = Shaping.Shape(p1Off, cOff);
if (!skipTag)
{
string dump = Path.Combine(ToolingPaths.BatchesRoot, t10Source, $"{seed}", "height.f32");
if (File.Exists(dump) && mapSize == 8192)
hard.Add(ShapingOracle.DumpRegression("a10", $"erosion OFF == terrain-shape-v1 (the task-10 gallery dump) [{seed}]", p2Off.Height, HeightField.Load(dump, mapSize), mapSize, dump));
else GD.Print($" a10 [{seed}]: ⚠ skipped — {(mapSize != 8192 ? "map size is not the gallery's 8192" : $"no gallery dump at {dump}")}");
}
Image reliefOff = ReliefRenderer.Render(p2Off.Height, mapSize, look);
Image shadeOff = ShadeRenderer.Render(p2Off.Height, mapSize, sea, ShadeZ, look.LightAzimuth, look.LightAltitude);
WriteField(batchRoot, p2Off, sea, anchors, skipRaw, reliefOff, shadeOff, "OFF");
// ON — an independent generation, then the pass.
var cOn = Cfg(mapSize, seed, "erosion_on", true);
Pass1Result p1On = Topography.Generate(cOn);
Pass2Result p2Shaped = Shaping.Shape(p1On, cOn);
ulong tE = Time.GetTicksMsec();
var ero = ErosionPass.Apply(p2Shaped, cOn);
Pass2Result p2On = ero.Shaped;
foreach (string nline in ero.Notes) GD.Print(" " + nline);
Image reliefOn = ReliefRenderer.Render(p2On.Height, mapSize, look);
Image shadeOn = ShadeRenderer.Render(p2On.Height, mapSize, sea, ShadeZ, look.LightAzimuth, look.LightAltitude);
WriteField(batchRoot, p2On, sea, anchors, skipRaw, reliefOn, shadeOn, "ON");
// The oracle, per seed.
var checks = new List<ShapingOracle.Check>
{
ShapingOracle.NorthLocked("c", "classify field bit-identical, erosion OFF vs ON (erosion is render-only)", p1Off.Height, p1On.Height, mapSize, mapSize),
ShapingOracle.NorthLocked("c2", "the ON result's classify field IS the pass-1 field (untouched by the pass)", p2On.HeightClassify, p1On.Height, mapSize, mapSize),
ShapingOracle.LabelsDeterministic(p1Off, p1On),
FloodGuard(ero, p2Off.Height, mapSize, sea),
Caps(ero),
ShapingOracle.TagCoastlineConsistent(p2On, sea),
ShapingOracle.ClassifyFidelity(p1On, p2On),
ShapingOracle.CentreIsLand(p1On),
};
checks[2].Name = "region labeling + island tag identical, erosion OFF vs ON";
foreach (var c in checks) { c.Name += $" [{seed}]"; perSeed.Add(c); GD.Print(" " + c); }
bool ok = checks.TrueForAll(c => c.Passed);
// Determinism — the first seed: shape again from the same pass 1, erode again, compare bit for bit.
if (si == 0)
{
var again = ErosionPass.Apply(Shaping.Shape(p1On, cOn), cOn);
var det = ShapingOracle.NorthLocked("o", "eroded render field bit-identical across two runs (determinism)", p2On.Height, again.Shaped.Height, mapSize, mapSize);
det.Name += $" [{seed}]"; perSeed.Add(det); GD.Print(" " + det);
ok &= det.Passed;
}
// The mid-slope crop — the first seed: the 1024² window with the most green→yellow band cells.
if (!cropDone)
{
var (cx, cy, cw) = FindMidSlopeWindow(p2Off.Height, mapSize, bandLo, bandHi, Math.Min(1024, mapSize / 4));
WriteCrop(batchRoot, reliefOff, reliefOn, cx, cy, cw, seed, "midslope");
WriteCrop(batchRoot, shadeOff, shadeOn, cx, cy, cw, seed, "midslope_shade");
GD.Print($" mid-slope crop: window ({cx},{cy}) {cw}² — {Path.Combine(batchRoot, "midslope_pair.png")}");
cropDone = true;
}
long land = 0; for (int x = 0; x < mapSize; x++) for (int y = 0; y < mapSize; y++) if (p1Off.Height[x, y] >= sea) land++;
rows.Add(new Row
{
Seed = seed, St = ero.Stats, P = ero.Params, WetBefore = ero.WetBefore, WetAfter = ero.WetAfter, MsErosion = ero.Ms, MsGen = p1On.ElapsedMs,
LandCells = land, ErodedMeanM = ero.Stats.ModifiedCells == 0 ? 0 : ero.Stats.ErodedVolumeM3 / ero.Stats.ModifiedCells,
DepositedMeanM = ero.Stats.ModifiedCells == 0 ? 0 : ero.Stats.DepositedVolumeM3 / ero.Stats.ModifiedCells, Ok = ok,
});
GD.Print($" seed {seed}: {(ok ? "ok" : " CHECK FAILED")} erosion {ero.Ms / 1000.0:F1}s");
}
bool allOk = hard.TrueForAll(c => c.Passed) && perSeed.TrueForAll(c => c.Passed);
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
foreach (var c in perSeed) if (!c.Passed) GD.PrintErr(" " + c);
WriteIndex(batchRoot, mapSize, calibSize, seeds, rows, tuneCfg, hard, perSeed, allOk);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" ORACLE {(allOk ? "HARD CHECKS ALL PASS" : "*** FAILURES see the table ***")}");
GD.Print("==================================================================");
GetTree().Quit(allOk ? 0 : 3);
}
// ---- the checks only this batch needs -----------------------------------
private static ShapingOracle.Check FloodGuard(ErosionPass.Result ero, float[,] offRender, int n, float sea)
{
long wetOff = ErosionPass.CountWaterPixels(offRender, n, sea);
var c = new ShapingOracle.Check { Id = "f", Name = "flood guard — render water pixels unchanged (OFF field, before, after)" };
c.Passed = wetOff == ero.WetBefore && ero.WetBefore == ero.WetAfter;
c.Detail = $"OFF {wetOff:N0} · before {ero.WetBefore:N0} · after {ero.WetAfter:N0}" + (c.Passed ? " — no coastline moved" : " — MOVED");
return c;
}
private static ShapingOracle.Check Caps(ErosionPass.Result ero)
{
var st = ero.Stats; var p = ero.Params;
var c = new ShapingOracle.Check { Id = "g", Name = "governor caps proven on exit (the pass re-checked here)" };
bool carveOk = st.MaxCellErosionM <= p.CarveCapM * (1f + 1e-5f);
bool depOk = p.DepositCapM <= 0f || st.MaxCellDepositM <= p.DepositCapM * (1f + 1e-5f);
c.Passed = carveOk && depOk;
c.Detail = $"max cell carve {st.MaxCellErosionM:F3} m ≤ {p.CarveCapM} m; max cell deposit {st.MaxCellDepositM:F3} m ≤ {p.DepositCapM} m; {st.ModifiedCells:N0} cells touched";
return c;
}
private static (int x, int y, int w) FindMidSlopeWindow(float[,] h, int n, float lo, float hi, int w)
{
int best = -1, bx = 0, by = 0; int step = Math.Max(64, w / 4);
for (int x0 = 0; x0 + w <= n; x0 += step)
for (int y0 = 0; y0 + w <= n; y0 += step)
{
int cnt = 0;
for (int x = x0; x < x0 + w; x += 4)
for (int y = y0; y < y0 + w; y += 4)
{ float v = h[x, y]; if (v >= lo && v <= hi) cnt++; }
if (cnt > best) { best = cnt; bx = x0; by = y0; }
}
return (bx, by, w);
}
// ---- the curve --------------------------------------------------------
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
{
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
var knots = new CurveKnots(2, "v2_balanced",
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
};
Pass2Result st = Shaping.Shape(pass1[s], scfg);
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
}
var pcts = ClimbCalibration.DefaultPercentiles;
var rawQ = new float[pcts.Length]; var outQ = new float[pcts.Length];
for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
var cal = ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f);
return (knots, cal);
}
// ---- output -----------------------------------------------------------
private static void WriteField(string batchRoot, Pass2Result p2, float sea, CurveAnchors anchors, bool skipRaw, Image relief, Image shade, string tag)
{
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
DirAccess.MakeDirRecursiveAbsolute(dir);
shade.SavePng(Path.Combine(dir, "shade.png"));
GrayscaleRenderer.SavePng(p2.Height, p2.MapSize, Path.Combine(dir, "grayscale.png"));
if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
LegendRenderer.WithLegend((Image)relief.Duplicate(), ReliefPalette.Kind.ProvisionalEven, sea, anchors.PeakCap, $"EROSION {tag} {p2.Seed}")
.SavePng(Path.Combine(dir, "relief.png"));
}
private static void WriteCrop(string batchRoot, Image off, Image on, int x, int y, int w, int seed, string name)
{
var rect = new Rect2I(x, y, w, w);
Image a = off.GetRegion(rect), b = on.GetRegion(rect);
a.SavePng(Path.Combine(batchRoot, $"{name}_off.png"));
b.SavePng(Path.Combine(batchRoot, $"{name}_on.png"));
var pair = Image.CreateEmpty(w * 2 + 16, w, false, Image.Format.Rgb8);
pair.Fill(new Color(0, 0, 0));
pair.BlitRect(a, new Rect2I(0, 0, w, w), new Vector2I(0, 0));
pair.BlitRect(b, new Rect2I(0, 0, w, w), new Vector2I(w + 16, 0));
int s = 3; int lh = TinyFont.Height(s) + 6;
TinyFont.Draw(pair, $"MID-SLOPE (30-100 M BAND) SEED {seed} WINDOW ({x},{y}) {w}PX", 12, 12, s, new Color(0.94f, 0.95f, 0.96f));
TinyFont.Draw(pair, "LEFT: EROSION OFF", 12, 12 + lh, s, new Color(0.94f, 0.95f, 0.96f));
TinyFont.Draw(pair, "RIGHT: EROSION ON", w + 16 + 12, 12 + lh, s, new Color(0.94f, 0.95f, 0.96f));
pair.SavePng(Path.Combine(batchRoot, $"{name}_pair.png"));
}
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, int[] seeds, List<Row> rows, TerrainGenConfig tune,
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perSeed, bool allOk)
{
int best = seeds[0];
var sb = new StringBuilder();
sb.AppendLine($"# Batch 11 — hydraulic erosion on the locked shape: off vs on, {seeds.Length} seeds at {mapSize}");
sb.AppendLine();
sb.AppendLine("**The faithful droplet erosion** (the reference's `HydraulicErosion`, ported verbatim into `Core/`, `WorldScale`-denominated),");
sb.AppendLine("run on the RENDER map only after shaping. OFF is the locked shape `terrain-shape-v1` (bit-identical to the task-10 gallery);");
sb.AppendLine("ON is the star; the pair is the before/after. Hillshade is what makes the dendritic drainage read.");
sb.AppendLine();
sb.AppendLine("## ⭐ Open this first");
sb.AppendLine();
sb.AppendLine($"1. **`{best}_erosion_on/relief.png`** — then `{best}_erosion_off/relief.png` beside it.");
sb.AppendLine("2. **`midslope_pair.png`** — the mid-slope (30100 m, green→yellow) close-up, OFF left / ON right: the feather gate; **`midslope_shade_pair.png`** is the same window in pure hillshade (z×60), where half-metre drainage reads.");
sb.AppendLine(" Every field also has **`shade.png`** — pure hillshade, land only — beside its `relief.png`.");
sb.AppendLine("3. The other three pairs below.");
sb.AppendLine();
sb.AppendLine("## The contact sheet — OFF / ON side by side");
sb.AppendLine();
sb.AppendLine("| Seed | erosion OFF (relief · shade) | erosion ON (relief · shade) | droplets · steps | eroded m³ / deposited m³ | max cell carve / deposit (m) | cells touched | water px before → after | erosion wall | oracle |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|");
foreach (var r in rows)
sb.AppendLine($"| `{r.Seed}` | [`relief`]({r.Seed}_erosion_off/relief.png) · [`shade`]({r.Seed}_erosion_off/shade.png) | [`relief`]({r.Seed}_erosion_on/relief.png) · [`shade`]({r.Seed}_erosion_on/shade.png) | " +
$"{r.St.Spawned:N0} · {r.St.Steps:N0} | {r.St.ErodedVolumeM3:N0} / {r.St.DepositedVolumeM3:N0} | {r.St.MaxCellErosionM:F2} / {r.St.MaxCellDepositM:F2} | {r.St.ModifiedCells:N0} ({100.0 * r.St.ModifiedCells / Math.Max(1, r.LandCells):F1} % of land) | {r.WetBefore:N0} → {r.WetAfter:N0} | {r.MsErosion / 1000.0:F0} s | {(r.Ok ? "pass" : "**FAIL**")} |");
sb.AppendLine();
sb.AppendLine("Deaths per seed (sea / edge / dry / lifetime): " + string.Join(" · ", rows.ConvertAll(r => $"`{r.Seed}` {r.St.DiedSea} / {r.St.DiedEdge} / {r.St.DiedDry} / {r.St.DiedLifetime}")));
sb.AppendLine();
sb.AppendLine("## The tune (faithful — the reference's declared defaults, unchanged unless stated)");
sb.AppendLine();
sb.AppendLine($"droplets `{tune.ErosionDropletCount:N0}` · lifetime `{tune.ErosionDropletLifetime}` · carve cap `{tune.ErosionCarveCapM} m` · deposit cap `{tune.ErosionDepositCapM} m` · sea margin `{tune.ErosionSeaMarginM} m` · brush `{tune.ErosionBrushRadius} px` · " +
$"inertia `{tune.ErosionInertia}` · capacity `{tune.ErosionCapacity}` · min slope `{tune.ErosionMinSlopeM} m/px` · erode `{tune.ErosionErodeRate}` · deposit `{tune.ErosionDepositRate}` · evaporation `{tune.ErosionEvaporation}` · gravity `{tune.ErosionGravity}` · " +
$"seed offset `{HydraulicErosion.SEED_OFFSET}` · crater exclusion **INERT** (no crater; core ×{tune.CraterErosionCore}, feather ×{tune.CraterErosionFeather}, feather mode — activates with the crater task).");
sb.AppendLine();
sb.AppendLine($"Shape: {TerrainShapeV1.Describe()}. Curve calibrated at {calibSize}.");
sb.AppendLine();
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
sb.AppendLine();
sb.AppendLine("`ProvisionalEven` + hillshade (z-exaggeration 18, strength 0.30), flagged. The grayscale is the honest instrument.");
sb.AppendLine();
sb.AppendLine("## The oracle (render-only: classify, labels and every island untouched; no coastline moved)");
sb.AppendLine();
sb.AppendLine(hard.Count == 0 ? "*(the terrain-shape-v1 check was skipped)*\n" : ShapingOracle.ToMarkdownTable(hard));
sb.AppendLine("Per seed (classify bit-identical c / c2 · labels identical · flood guard f · caps g · tag/coastline k · classify b · centre m · determinism o):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(perSeed));
sb.AppendLine($"**{(allOk ? "ALL HARD CHECKS PASS" : " FAILURES do not judge this batch")}**");
sb.AppendLine();
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `relief.png`, `shade.png` (both), `midslope_*.png`, `INDEX.md` | **keep** |");
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + the locked shape (+ the tune) — 256 MB each, clear freely |");
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
sb.AppendLine();
sb.AppendLine($"{WorldScale.Describe()}.");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
private static void WriteText(string path, string text)
{
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(text);
}
private static string EnvStr(string k, string fallback)
{
string v = System.Environment.GetEnvironmentVariable(k);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}
private static int EnvInt(string k, int fallback) => int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
private static float EnvFloat(string k, float fallback)
=> float.TryParse(EnvStr(k, null) ?? "", System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float v) ? v : fallback;
private static int[] EnvSeeds(string k, int[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<int>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
}
}

View file

@ -0,0 +1 @@
uid://df3p1g6ktwij6

View file

@ -0,0 +1,443 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE FRAG-4 SEED GALLERY (chat2/10) — RENDER-ONLY. Does the chat2/09 `frag_4` look generalize?
/// Every setting is FROZEN at frag_4 (pinned explicitly here, not left to a default), 2 anchor seeds +
/// 6 fresh seeds, at showpiece size, each with grayscale + .f32 + relief + the labeled-regions overlay,
/// and the hemisphere-split count/size table as the instrument. No knob, no ladder, no logic change.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/FragGalleryTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
/// ISLA_MAPSIZE gallery size (default 8192)
/// ISLA_CALIB_SIZE curve calibration size (default 2048)
/// ISLA_SEEDS the gallery seeds (default: the 2 anchors + 6 fresh below)
/// ISLA_SKIP_ANCHOR_CHECK=1 skip the 4096 bit-identity check against the 09 frag_4 dumps
/// </summary>
public partial class FragGalleryTool : Node
{
/// <summary>The two seeds frag_4 was judged on (chat2/09).</summary>
private static readonly int[] AnchorSeeds = { 1063685222, 999999937 };
/// <summary>Six fresh seeds, chosen BEFORE any render — constants, not picks: the date and five famous digit strings.</summary>
private static readonly int[] FreshSeeds = { 20260822, 31415926, 27182818, 16180339, 14142135, 17320508 };
/// <summary>⚠ Task 01's pool, verbatim — the curve's identity.</summary>
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
// ═══ THE FROZEN frag_4 SETTING — every value pinned explicitly (chat2/09 batch, level 4) ═══
private const float FrozenFragmentAmp = 0.5f;
private const float FrozenFragmentFreq = 12f;
private const float FrozenBandCentre = 0.66f;
private const float FrozenBandHalfWidth = 0.18f;
private const bool FrozenBitesOnly = false;
private const float FrozenStretch = 2f;
private const float FrozenBandStart = 0.70f;
private const float FrozenBandFeather = 0.05f;
private const bool FrozenStretchSinker = true;
private const float FrozenSpeckFrac = 2.5e-7f; // 09's low speck revert (≈ 4 cells at 4096, ≈ 17 at 8192)
private const int DefaultMapSize = 8192;
private const int DefaultCalibSize = 2048;
private const int AnchorCheckSize = 4096; // the 09 batch's size
public override void _Ready()
{
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr(e.StackTrace);
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private sealed class HemiStats
{
public int All, Big; public long Min, Med, Max; public double Mean; public int[] Hist; public long[] Largest = Array.Empty<long>();
}
private sealed class Row
{
public int Seed; public bool Anchor;
public HemiStats N, S; public long MainlandCells; public double MainlandFrac; public int SpecksReverted;
public string Read; public bool Ok; public ulong Ms;
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
int task = EnvInt("ISLA_TASK", 10);
string descr = EnvStr("ISLA_BATCH", "frag4_seed_gallery");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seedsEnv = EnvSeeds("ISLA_SEEDS", null);
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
bool skipAnchor = EnvStr("ISLA_SKIP_ANCHOR_CHECK", "0") == "1";
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
string t08Source = EnvStr("ISLA_T08_SOURCE", "08_southern_stretch_explore");
string t09Source = EnvStr("ISLA_T09_SOURCE", "09_coastal_fragment");
var seeds = new List<int>(AnchorSeeds); if (seedsEnv == null) seeds.AddRange(FreshSeeds); else { seeds.Clear(); seeds.AddRange(seedsEnv); }
var anchorSet = new HashSet<int>(AnchorSeeds);
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
long big = Cells(RegionPass.ThresholdMidFrac, mapSize);
long speckCells = Cells(FrozenSpeckFrac, mapSize);
GD.Print("==================================================================");
GD.Print(" FRAG-4 SEED GALLERY (chat2/10) — render-only: does frag_4 generalize?");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize} (offshore off)");
GD.Print($"seeds : anchors {string.Join(", ", AnchorSeeds)} · fresh {string.Join(", ", seeds.FindAll(s => !anchorSet.Contains(s)))}");
GD.Print($"FROZEN : FragmentAmp {FrozenFragmentAmp} · freq {FrozenFragmentFreq}/map · window {FrozenBandCentre} ± {FrozenBandHalfWidth} · bitesOnly {FrozenBitesOnly} · " +
$"stretch {FrozenStretch} (band {FrozenBandStart}/{FrozenBandFeather}, sinker stretched {FrozenStretchSinker}) · speck revert < {speckCells} cells ({FrozenSpeckFrac:G2}) · offshore OFF · shelf OFF · labeling ON");
GD.Print($"\"big\" : ≥ {big:N0} cells at {mapSize} (the 07 mid threshold)");
GD.Print($"batch : {batchRoot}");
GD.Print("==================================================================");
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
TerrainGenConfig Frozen(int size, int seed, string label, bool frag = true, bool revert = true, bool stretch = true) => new TerrainGenConfig
{
MapSize = size, Seed = seed, VariantLabel = label,
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
CoastShelf = false, Offshore = new OffshoreSettings(),
RegionLabeling = true, SpeckRevert = revert, MinLandComponentFrac = FrozenSpeckFrac,
SouthStretch = stretch ? FrozenStretch : 0f, SouthBandStartFrac = FrozenBandStart, SouthBandFeatherFrac = FrozenBandFeather, StretchSinker = FrozenStretchSinker,
FragmentAmp = frag ? FrozenFragmentAmp : 0f, FragmentFreqPerMapWidth = FrozenFragmentFreq,
FragmentBandCentre = FrozenBandCentre, FragmentBandHalfWidth = FrozenBandHalfWidth, FragmentBitesOnly = FrozenBitesOnly,
};
// ═══ 1. THE ORACLE — no code change, same setting ═══
GD.Print($"\n--- 1. ORACLE: the setting is the 09 frag_4 setting, and nothing upstream moved ---");
var hard = new List<ShapingOracle.Check>();
{
var offCfg = Frozen(calibSize, AnchorSeeds[0], "off", frag: false, revert: false, stretch: false);
Pass1Result p1 = Topography.Generate(offCfg);
var curveOff = offCfg.Clone(); curveOff.Curve = false;
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{AnchorSeeds[0]}_full", "height.f32");
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, stretch OFF, frag OFF == Phase-1 .f32 dump (the curve is untouched)", Shaping.Shape(p1, curveOff).Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
if (!skipAnchor)
{
foreach (int seed in AnchorSeeds)
{
// ⭐ a9 — the frozen setting at the 09 batch's size reproduces the 09 frag_4 field bit for bit.
string t09Dump = Path.Combine(ToolingPaths.BatchesRoot, t09Source, $"{seed}_frag_4", "height.f32");
var c9 = Frozen(AnchorCheckSize, seed, "frag_4");
Pass1Result q9p = Topography.Generate(c9);
Pass2Result q9 = Shaping.Shape(q9p, c9);
hard.Add(ShapingOracle.DumpRegression("a9", $"frozen frag_4 at {AnchorCheckSize} == task-09 frag_4 dump [{seed}] (no code change, same setting)", q9.Height, HeightField.Load(t09Dump, AnchorCheckSize), AnchorCheckSize, t09Dump));
// a8 — the stretch-2, frag-off baseline still equals the 08 field, and the interior is still locked against it.
string t08Dump = Path.Combine(ToolingPaths.BatchesRoot, t08Source, $"{seed}_stretch_3", "height.f32");
var c8 = Frozen(AnchorCheckSize, seed, "t08", frag: false, revert: false);
Pass1Result q8p = Topography.Generate(c8);
hard.Add(ShapingOracle.DumpRegression("a8", $"frag OFF, stretch 2 at {AnchorCheckSize} == task-08 stretch_3 dump [{seed}]", Shaping.Shape(q8p, c8).Height, HeightField.Load(t08Dump, AnchorCheckSize), AnchorCheckSize, t08Dump));
var r = ShapingOracle.InteriorLocked(q8p, q9p, FrozenBandCentre, FrozenBandHalfWidth); r.Name += $" [{seed}, {AnchorCheckSize}]"; hard.Add(r);
}
// determinism at the check size
var a = Topography.Generate(Frozen(AnchorCheckSize, AnchorSeeds[0], "det"));
var b = Topography.Generate(Frozen(AnchorCheckSize, AnchorSeeds[0], "det"));
var det = ShapingOracle.LabelsDeterministic(a, b); det.Name += $" [{AnchorSeeds[0]}, {AnchorCheckSize}]"; hard.Add(det);
}
foreach (var c in hard) GD.Print(" " + c);
}
// ═══ 2. THE GALLERY ═══
GD.Print($"\n--- 2. THE GALLERY at {mapSize} ---");
var rows = new List<Row>();
var perSeed = new List<ShapingOracle.Check>();
foreach (int seed in seeds)
{
var cfg = Frozen(mapSize, seed, "frag_4");
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result p2 = Shaping.Shape(p1, cfg);
var checks = new List<ShapingOracle.Check>
{
ShapingOracle.CentreIsLand(p1),
ShapingOracle.TagCoastlineConsistent(p2, sea),
ShapingOracle.ClassifyFidelity(p1, p2),
};
foreach (var c in checks) { c.Name += $" [{seed}]"; perSeed.Add(c); }
bool ok = checks.TrueForAll(c => c.Passed);
var (n, s) = Stats(p1.Regions, big);
long landCells = p1.Regions.LandCells;
var row = new Row
{
Seed = seed, Anchor = anchorSet.Contains(seed), N = n, S = s,
MainlandCells = p1.Regions.Mainland.SizeCells, MainlandFrac = landCells == 0 ? 0 : p1.Regions.Mainland.SizeCells / (double)landCells,
SpecksReverted = p1.RegionLedger?.RevertedComponents ?? 0, Ok = ok, Ms = p1.ElapsedMs,
};
row.Read = AutoRead(row);
rows.Add(row);
WriteSeed(batchRoot, p1, p2, sea, anchors, skipRaw);
GD.Print($" seed {seed,-11}{(row.Anchor ? " " : " ")} N {n.All,3}/{n.Big,3} med {n.Med,6} largest {Largest(n.Largest)} S {s.All,3}/{s.Big,3} med {s.Med,6} largest {Largest(s.Largest)} mainland {row.MainlandCells:N0} ({row.MainlandFrac:P1} of land) → {row.Read} {(ok ? "ok" : " CHECK FAILED")} {p1.ElapsedMs} ms");
}
bool allOk = hard.TrueForAll(c => c.Passed) && perSeed.TrueForAll(c => c.Passed);
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
foreach (var c in perSeed) if (!c.Passed) GD.PrintErr(" " + c);
WriteTable(batchRoot, mapSize, rows, big, speckCells);
WriteIndex(batchRoot, mapSize, calibSize, rows, big, speckCells, hard, perSeed, allOk);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" ORACLE {(allOk ? "HARD CHECKS ALL PASS" : "*** FAILURES see the table ***")}");
GD.Print("==================================================================");
GetTree().Quit(allOk ? 0 : 3);
}
// ---- the instrument --------------------------------------------------
private static long Cells(float frac, int size) => Math.Max(1L, (long)Math.Round(frac * (double)size * size));
private static (HemiStats north, HemiStats south) Stats(RegionLabels l, long big)
{
var n = new List<long>(); var s = new List<long>();
foreach (var r in l.Regions)
{
if (r.IsMainland) continue;
if (r.Hemisphere == RegionLabeling.HemiSouth) s.Add(r.SizeCells); else n.Add(r.SizeCells);
}
return (Make(n, big), Make(s, big));
}
private static HemiStats Make(List<long> sizes, long big)
{
sizes.Sort();
var h = new HemiStats { All = sizes.Count, Hist = new int[RegionLabeling.HistogramEdges.Length + 1] };
if (sizes.Count == 0) return h;
double sum = 0;
foreach (long v in sizes) { h.Hist[RegionLabeling.HistogramBin(v)]++; if (v >= big) h.Big++; sum += v; }
h.Min = sizes[0]; h.Med = sizes[sizes.Count / 2]; h.Max = sizes[^1]; h.Mean = sum / sizes.Count;
int k = Math.Min(3, sizes.Count); h.Largest = new long[k];
for (int i = 0; i < k; i++) h.Largest[i] = sizes[sizes.Count - 1 - i];
return h;
}
/// <summary>
/// The one-word NUMERIC read — a stated rule, not a judgement: "too solid" if fewer than 12 big
/// (≥ the 07 mid threshold) islands detached across both hemispheres (the 09 anchors had 2327);
/// "shredded" if fewer than a quarter of the islands are big (all specks, no bigs); else "good
/// spread". The eye's read is in the report.
/// </summary>
private static string AutoRead(Row r)
{
int all = r.N.All + r.S.All, bigs = r.N.Big + r.S.Big;
if (bigs < 12) return "too solid";
if (all > 0 && bigs < all * 0.25) return "shredded";
return "good spread";
}
// ---- the curve --------------------------------------------------------
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
{
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
var knots = new CurveKnots(2, "v2_balanced",
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
};
Pass2Result st = Shaping.Shape(pass1[s], scfg);
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
}
var pcts = ClimbCalibration.DefaultPercentiles;
var rawQ = new float[pcts.Length]; var outQ = new float[pcts.Length];
for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
var cal = ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f);
return (knots, cal);
}
// ---- output -----------------------------------------------------------
private static void WriteSeed(string batchRoot, Pass1Result p1, Pass2Result p2, float sea, CurveAnchors anchors, bool skipRaw)
{
string dir = Path.Combine(batchRoot, $"{p2.Seed}");
DirAccess.MakeDirRecursiveAbsolute(dir);
GrayscaleRenderer.SavePng(p2.Height, p2.MapSize, Path.Combine(dir, "grayscale.png"));
if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
var look = new LookConfig
{
Name = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f, HillshadeStrength = 0.30f, SeaLevel = sea,
};
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, $"FRAG_4 {p2.Seed}").SavePng(Path.Combine(dir, "relief.png"));
var led = p1.RegionLedger;
RegionOverlayRenderer.SavePng(p1.Regions, led != null && led.RevertOn ? p1.RegionsPre : null, p1.MapSize,
led?.RevertedComponents ?? 0, led?.ThresholdCells ?? 0, Path.Combine(dir, "regions.png"));
}
private static string HistRow(int[] h)
{
var sb = new StringBuilder();
for (int i = 0; i < h.Length; i++) { if (i > 0) sb.Append(" · "); sb.Append(h[i]); }
return sb.ToString();
}
private static string Largest(long[] l) => l.Length == 0 ? "—" : string.Join(" / ", Array.ConvertAll(l, v => v.ToString("N0")));
private static string TableMarkdown(List<Row> rows, long big)
{
var sb = new StringBuilder();
var histHead = new StringBuilder();
for (int i = 0; i <= RegionLabeling.HistogramEdges.Length; i++) { if (i > 0) histHead.Append(" · "); histHead.Append(RegionLabeling.HistogramLabel(i)); }
sb.AppendLine($"| Seed | read | **N islands all / ≥ {big:N0}** | N median / largest three | N histogram ({histHead}) | **S islands all / ≥ {big:N0}** | S median / largest three | S histogram | mainland cells (% of land) | specks reverted | oracle |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|");
foreach (var r in rows)
sb.AppendLine($"| `{r.Seed}`{(r.Anchor ? " anchor" : "")} | **{r.Read}** | **{r.N.All} / {r.N.Big}** | {r.N.Med} / {Largest(r.N.Largest)} | {HistRow(r.N.Hist)} | **{r.S.All} / {r.S.Big}** | {r.S.Med} / {Largest(r.S.Largest)} | {HistRow(r.S.Hist)} | {r.MainlandCells:N0} ({r.MainlandFrac:P1}) | {r.SpecksReverted} | {(r.Ok ? "pass" : "**FAIL**")} |");
return sb.ToString();
}
private static void WriteTable(string batchRoot, int mapSize, List<Row> rows, long big, long speckCells)
{
var sb = new StringBuilder();
sb.AppendLine($"# The count/size table — frag_4 frozen, {rows.Count} seeds at {mapSize}");
sb.AppendLine();
sb.AppendLine($"Islands = non-mainland 8-connected land components of the classify field; hemisphere by centroid; \"big\" = ≥ {big:N0} cells; speck revert < {speckCells} cells.");
sb.AppendLine("Read rule (numeric, stated): **too solid** if < 12 big islands across both hemispheres · **shredded** if big < 25 % of all · else **good spread**. The eye's read is in the report.");
sb.AppendLine();
sb.Append(TableMarkdown(rows, big));
WriteText(Path.Combine(batchRoot, "count_size_table.md"), sb.ToString());
var csv = new StringBuilder();
csv.AppendLine("seed,anchor,read,n_all,n_big,n_med,n_mean,n_max,n_largest,n_hist,s_all,s_big,s_med,s_mean,s_max,s_largest,s_hist,mainland_cells,mainland_frac,specks_reverted,oracle,ms");
var ic = System.Globalization.CultureInfo.InvariantCulture;
foreach (var r in rows)
csv.AppendLine(string.Join(",", r.Seed, r.Anchor ? 1 : 0, r.Read,
r.N.All, r.N.Big, r.N.Med, r.N.Mean.ToString("F1", ic), r.N.Max, "\"" + Largest(r.N.Largest) + "\"", "\"" + HistRow(r.N.Hist) + "\"",
r.S.All, r.S.Big, r.S.Med, r.S.Mean.ToString("F1", ic), r.S.Max, "\"" + Largest(r.S.Largest) + "\"", "\"" + HistRow(r.S.Hist) + "\"",
r.MainlandCells, r.MainlandFrac.ToString("F4", ic), r.SpecksReverted, r.Ok ? "pass" : "FAIL", r.Ms));
WriteText(Path.Combine(batchRoot, "count_size_table.csv"), csv.ToString());
}
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, List<Row> rows, long big, long speckCells,
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perSeed, bool allOk)
{
var sb = new StringBuilder();
sb.AppendLine($"# Batch 10 — frag_4 seed gallery: does the look generalize? (render-only, {mapSize})");
sb.AppendLine();
sb.AppendLine("**A contact sheet, not a tune.** Every plate is the SAME setting — chat2/09's `frag_4`, frozen — across the two");
sb.AppendLine("seeds it was judged on (⭐ anchors) and six fresh seeds chosen before any render. The question: does a coherent");
sb.AppendLine("mainland with medium/large lobes detaching all around generalize, or do some seeds come out too solid or shredded?");
sb.AppendLine();
sb.AppendLine("## The frozen setting");
sb.AppendLine();
sb.AppendLine($"`FragmentAmp {FrozenFragmentAmp}` · `FragmentFreqPerMapWidth {FrozenFragmentFreq}` · window `{FrozenBandCentre} ± {FrozenBandHalfWidth}` · bites-only `{FrozenBitesOnly}` · " +
$"`SouthStretch {FrozenStretch}` (band `{FrozenBandStart}` / feather `{FrozenBandFeather}`, sinker stretched `{FrozenStretchSinker}`) · speck revert < {speckCells} cells (`{FrozenSpeckFrac:G2}` of the map) · " +
"offshore OFF · shelf OFF · region labeling ON · the tagged curve (calibrated on task 01's pool at " + calibSize + "). Pinned explicitly in `FragGalleryTool` — nothing is left to a default.");
sb.AppendLine();
sb.AppendLine("## ⭐ The contact sheet");
sb.AppendLine();
sb.AppendLine("Open each seed's `regions.png` (the key view — detached pieces in colour) beside its `relief.png`.");
sb.AppendLine();
sb.AppendLine("| Seed | read | overlay | relief | N islands all / big | S islands all / big | largest N / S piece |");
sb.AppendLine("|---|---|---|---|---|---|---|");
foreach (var r in rows)
sb.AppendLine($"| `{r.Seed}`{(r.Anchor ? " anchor" : "")} | **{r.Read}** | [`{r.Seed}/regions.png`]({r.Seed}/regions.png) | [`{r.Seed}/relief.png`]({r.Seed}/relief.png) | {r.N.All} / {r.N.Big} | {r.S.All} / {r.S.Big} | {r.N.Max:N0} / {r.S.Max:N0} |");
sb.AppendLine();
sb.AppendLine($"## ⭐ The count/size table — hemisphere-split, with the size distribution");
sb.AppendLine();
sb.Append(TableMarkdown(rows, big));
sb.AppendLine();
sb.AppendLine("Read rule (numeric, stated): **too solid** if < 12 big islands across both hemispheres (the anchors had 2327 at 4096) · **shredded** if big < 25 % of all · else **good spread**. The eye's read is in the report. Also as `count_size_table.md` / `.csv`.");
sb.AppendLine();
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
sb.AppendLine();
sb.AppendLine("`ProvisionalEven`, flagged. The grayscale is the honest instrument; the overlay is the region layer's data.");
sb.AppendLine();
sb.AppendLine("## The oracle (render-only: the setting is the 09 setting, nothing upstream moved)");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(hard));
sb.AppendLine("Per seed (centre-is-land m · tag/coastline k · classify b):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(perSeed));
sb.AppendLine($"**{(allOk ? "ALL HARD CHECKS PASS" : " FAILURES do not judge this batch")}**");
sb.AppendLine();
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `regions.png`, `relief.png`, `INDEX.md`, `count_size_table.md` / `.csv` | **keep** |");
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + the frozen setting — large (256 MB each), clear freely |");
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
sb.AppendLine();
sb.AppendLine($"Gallery at {mapSize}, curve calibrated at {calibSize} with offshore off. {WorldScale.Describe()}.");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
private static void WriteText(string path, string text)
{
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(text);
}
// ---- env helpers --------------------------------------------------------
private static string EnvStr(string k, string fallback)
{
string v = System.Environment.GetEnvironmentVariable(k);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}
private static int EnvInt(string k, int fallback)
=> int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
private static int[] EnvSeeds(string k, int[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<int>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
}
}

View file

@ -0,0 +1 @@
uid://b484d8yc32y3c

View file

@ -4,17 +4,27 @@ namespace IslaApocalypse.Tools
{
/// <summary>
/// Shape helpers for the island mask, ported from the reference's
/// <c>Tools/Scripts/IslandFalloff.cs</c>.
/// <c>Tools/Scripts/IslandFalloff.cs</c> — now the WHOLE file, in three parts:
///
/// ⚠ ONLY THE PASS-1 PARTS ARE HERE. The reference file also carries the submarine COAST SHELF
/// (SHELF_STRENGTH / SHELF_SCALE_M / CoastShelf) and the OFFSHORE ISLET layer (OffshoreBlob,
/// OffshoreZoneWeight, CalibrateThreshold, and their constants). Both are DEFERRED to Phase 2 —
/// they act on below-sea height and are judged once water renders. They will port into THIS
/// file, which is why it keeps the reference's name and shape.
/// 1. the spine crest (<see cref="SmoothAbs"/>) — Phase 1, chat1
/// 2. the submarine COAST SHELF (<see cref="CoastShelf"/>) — chat2/05 stage 1
/// 3. the OFFSHORE ISLET layer (<see cref="OffshoreBlob"/>,
/// <see cref="OffshoreZoneWeight"/>, <see cref="CalibrateThreshold"/>) — chat2/05 stage 1
/// + the RESHAPE helper (<see cref="RigidBlob"/>) — stage 2
///
/// The faithful functions keep the reference's names, constants and arithmetic verbatim (D-050);
/// the parameterized overloads beside them exist so the reshape can move a dial without touching
/// the faithful path — the faithful overload CALLS the parameterized one with the reference's
/// constants, so the two cannot drift apart.
///
/// The reference's own separability argument (verbatim): "Every one of these is monotone in the
/// sign of (sea height): none of them can turn water into land or land into water ON ITS OWN."
/// That holds for the shelf, which is why it is invisible until water renders. ⚠ It does NOT hold
/// for the islets, which exist precisely to turn water into land — see the note on
/// <see cref="OffshoreBlob"/>.
///
/// This type is pure math and engine-free. It sits in Tools/ rather than Core/ so the pass-1
/// port stays auditable as one unit against one reference file, and so the deferred Phase-2
/// halves land beside their siblings rather than in a second location.
/// port stays auditable as one unit against one reference file.
/// </summary>
public static class IslandFalloff
{
@ -47,5 +57,187 @@ namespace IslaApocalypse.Tools
float a = Math.Abs(d);
return a * a / MathF.Sqrt(a * a + epsilon * epsilon);
}
// ═══════════════════════════════════════════════════════════════════════
// 2. THE COAST SHELF — chat2/05 stage 1, faithful (reference ~:48-62)
// ═══════════════════════════════════════════════════════════════════════
//
// depth' = depth · (1 STRENGTH · exp(depth / SCALE_M))
//
// The height curve is identity at and below sea, so it never reached the seabed. Measured
// on the reference: land rises from the shoreline at 0.038 m/px while the seabed drops at
// 0.258 m/px — a shelf on the land side and a ramp on the sea side. This compresses shallow
// depth so the shallows extend much further out, leaving deep water and the Trench alone.
// At the shoreline the seabed starts at (1 STRENGTH) = 22.5 % of its former gradient.
//
// C^∞ everywhere and STRICTLY POSITIVE for positive depth — in exact arithmetic it cannot
// move the waterline by one pixel. ⚠ In float32 it can (see the call site's BitDecrement
// clamp), which is why "cannot" is enforced at the call site and not assumed here.
//
// ⚠ INVISIBLE UNTIL WATER RENDERS. Nothing in Phase 2's hypsometric plates shows it; it is
// ported faithfully now, wired in now, and judged when the water pass lands.
/// <summary>Reference: 0 = off, →1 = a flat lagoon.</summary>
public const float SHELF_STRENGTH = 0.775f;
/// <summary>Reference: metres of depth over which the shelf relaxes back to the raw seabed.</summary>
public const float SHELF_SCALE_M = 100f;
/// <summary>Remaps a positive depth in metres. Returns the new depth in metres. THE FAITHFUL FORM.</summary>
public static float CoastShelf(float depthMetres)
=> CoastShelf(depthMetres, SHELF_STRENGTH, SHELF_SCALE_M);
/// <summary>The parameterized form. With the reference constants it IS the reference — same floats, same order.</summary>
public static float CoastShelf(float depthMetres, float strength, float scaleM)
{
if (depthMetres <= 0f) return depthMetres;
return depthMetres * (1f - strength * MathF.Exp(-depthMetres / scaleM));
}
// ═══════════════════════════════════════════════════════════════════════
// 3. THE OFFSHORE ISLETS — chat2/05 stage 1, faithful (reference ~:64-142)
// ═══════════════════════════════════════════════════════════════════════
//
// Islets are placed by LERPING the seabed TOWARD a target height, not by adding to it, so
// they surface at any ambient depth instead of only where the seafloor happens to be shallow.
//
// ⚠⚠ THIS IS THE ONE LAYER IN PASS 1 THAT TURNS WATER INTO LAND. Every other shaping element
// is monotone in (sea height). Islets add above-sea land, which means they CHANGE
// CLASSIFICATION — new land is new biome/water pixels downstream. That is exactly why they
// belong in the base shape before classification runs: tweaking an island dial later and
// regenerating re-runs classification consistently. It is a known property, not a surprise.
// → chat2/05 report, "modularity".
/// <summary>Reference: ~585 px blobs at 8K — few and sizeable, not a scatter of 50 px debris.</summary>
public const float OFFSHORE_FREQ_ISLANDS = 14f;
/// <summary>Reference: the islet noise field's seed offset. A SEED offset, not a coordinate offset.</summary>
public const int OFFSHORE_SEED_OFFSET = 7607;
/// <summary>Reference: target crest, metres above sea, PRE-CURVE. The curve's toe squashes it lower.</summary>
public const float OFFSHORE_ISLAND_H_M = 34f;
/// <summary>Reference: fraction of a blob's excess over threshold that saturates to full weight.</summary>
public const float OFFSHORE_CORE = 0.45f;
/// <summary>
/// ⭐ THE MOAT. Reference: the raise is EXACTLY zero wherever the ambient water is shallower
/// than this, so the ring of water between the mainland shore and any islet cannot be
/// bridged — a continuous path from shore to islet must cross this depth contour, and every
/// pixel on it is untouched water.
/// </summary>
public const float OFFSHORE_MIN_DEPTH_M = 14f;
/// <summary>Reference: the moat's feather width, metres.</summary>
public const float OFFSHORE_DEPTH_FEATHER_M = 10f;
/// <summary>Reference: the Trench mask — zone fades from INNER to zero at OUTER (the Trench ramp starts at 0.90).</summary>
public const float OFFSHORE_TRENCH_INNER = 0.78f;
public const float OFFSHORE_TRENCH_OUTER = 0.86f;
/// <summary>
/// ⭐ THE "ACTUALLY OFFSHORE" TEST. Reference: depth alone is not enough — a deep LAKE or the
/// carved crater bay is also below sea level, and islets have no business in either. The
/// pre-Trench falloff is the honest discriminator: the mainland coast sits near f = 0.66, and
/// inland water is far below that whatever the axis ratios are, because elongation moves
/// WHERE a given f occurs, not the f at which land ends.
/// → this is precisely why <c>Pass1Result.PreTrenchFalloff</c> exists.
/// </summary>
public const float OFFSHORE_MIN_FALLOFF = 0.72f;
/// <summary>Reference: the falloff test's feather width.</summary>
public const float OFFSHORE_FALLOFF_FEATHER = 0.06f;
/// <summary>
/// Blob weight in [0,1] for one ocean column. THE FAITHFUL FORM.
///
/// ⚠ <paramref name="threshold"/> comes from <see cref="CalibrateThreshold"/>, NOT from the
/// density directly. Reference: "Simplex output is concentrated well inside [1,1] (in
/// practice it rarely passes ±0.87), so treating density as a fraction of the theoretical
/// range produces a threshold almost nothing clears. That bug shipped in the first task-11
/// build and raised 171 pixels on the whole map, none of them above sea."
/// </summary>
public static float OffshoreBlob(float noise01, float threshold)
=> OffshoreBlob(noise01, threshold, OFFSHORE_CORE);
/// <summary>
/// The parameterized form. <paramref name="coreFraction"/> is the fraction of the excess
/// over threshold that saturates: SMALLER ⇒ more of the blob at full weight ⇒ FLATTER top
/// and a sharper base. (The reshape's "flatter" lever lowers this, not raises it.)
/// </summary>
public static float OffshoreBlob(float noise01, float threshold, float coreFraction)
{
if (noise01 <= threshold) return 0f;
float core = MathF.Max((1f - threshold) * coreFraction, 1e-4f);
float k = Math.Clamp((noise01 - threshold) / core, 0f, 1f);
return k * k * (3f - 2f * k);
}
/// <summary>
/// The noise value that <paramref name="density"/> of <paramref name="samples"/> exceed.
/// Sorts a COPY, so the caller's array is left alone. Verbatim.
/// </summary>
public static float CalibrateThreshold(float[] samples, float density)
{
if (samples.Length == 0 || density <= 0f) return 1f;
float[] s = (float[])samples.Clone();
Array.Sort(s);
int idx = (int)((1f - Math.Clamp(density, 0f, 1f)) * (s.Length - 1));
return s[Math.Clamp(idx, 0, s.Length - 1)];
}
/// <summary>
/// How much of the blob is allowed here: zero in shallow water near the mainland (the moat),
/// zero anywhere not genuinely outside the island body, zero in and near the Trench ramp,
/// full in the open ocean between. THE FAITHFUL FORM.
/// </summary>
public static float OffshoreZoneWeight(float ambientDepthMetres, float preTrenchFalloff,
float distX01, float distY01)
=> OffshoreZoneWeight(ambientDepthMetres, preTrenchFalloff, distX01, distY01,
OFFSHORE_MIN_DEPTH_M, OFFSHORE_DEPTH_FEATHER_M,
OFFSHORE_MIN_FALLOFF, OFFSHORE_FALLOFF_FEATHER,
OFFSHORE_TRENCH_INNER, OFFSHORE_TRENCH_OUTER);
/// <summary>
/// The parameterized form. ⚠ <paramref name="distX01"/>/<paramref name="distY01"/> are
/// MAP-anchored (|x cx| / halfSpan, no axis ratio) — the same normalization the Trench
/// itself uses, because the mask's job is to stay off the Trench, not off the island ellipse.
/// </summary>
public static float OffshoreZoneWeight(float ambientDepthMetres, float preTrenchFalloff,
float distX01, float distY01,
float minDepthM, float depthFeatherM, float minFalloff, float falloffFeather,
float trenchInner, float trenchOuter)
{
if (ambientDepthMetres < minDepthM) return 0f;
if (preTrenchFalloff < minFalloff) return 0f;
float w = Math.Clamp((ambientDepthMetres - minDepthM) / depthFeatherM, 0f, 1f);
w *= Math.Clamp((preTrenchFalloff - minFalloff) / falloffFeather, 0f, 1f);
float d = MathF.Max(distX01, distY01);
if (d >= trenchOuter) return 0f;
if (d > trenchInner)
w *= 1f - (d - trenchInner) / (trenchOuter - trenchInner);
return w;
}
// ═══════════════════════════════════════════════════════════════════════
// 3b. THE RESHAPE HELPER — chat2/05 stage 2. Not in the reference. (The seeded-floor
// stamp that sat beside it was reverted out in chat2/06 — git history has it.)
// ═══════════════════════════════════════════════════════════════════════
/// <summary>
/// The reshaped organic blob: the faithful smoothstep, then its weight pushed toward
/// saturation by <paramref name="edgeSharpness"/> — <c>1 (1 w)^sharpness</c>. At
/// sharpness 1 this IS <see cref="OffshoreBlob"/>. Higher values keep the same footprint but
/// make the top flatter and the crest-to-sea transition narrower: a distinct flat-topped
/// landmass instead of a gentle noise bump. C¹ at both ends, so it cannot alias.
/// </summary>
public static float RigidBlob(float noise01, float threshold, float coreFraction, float edgeSharpness)
{
float w = OffshoreBlob(noise01, threshold, coreFraction);
if (w <= 0f || edgeSharpness <= 1f) return w;
return 1f - MathF.Pow(1f - w, edgeSharpness);
}
}
}

View file

@ -134,6 +134,42 @@ namespace IslaApocalypse.Tools
FieldsPooled++;
}
/// <summary>
/// Pool one field's samples in, but only where a SECOND field clears a threshold — "the
/// output heights of the cells whose raw height is above the climb's ceiling".
///
/// ⚠ The gate is a different field from the values. That is the whole point: chat2/03
/// calibrates the climb against the staircase's OUTPUT distribution restricted to
/// ABOVE-CEILING land, and "above the ceiling" is a fact about the RAW height. Gating on the
/// values themselves would select a different population — output above the ceiling includes
/// nothing extra here, but only because the curve is monotone, and relying on that silently
/// would break the moment a caller gated a non-monotone pair.
///
/// The sea test still applies to the VALUES, so this stays a land histogram.
/// </summary>
public void AccumulateWhere(float[,] field, float[,] gate, int mapSize, float gateAbove)
{
for (int x = 0; x < mapSize; x++)
{
for (int y = 0; y < mapSize; y++)
{
if (gate[x, y] <= gateAbove) continue;
float h = field[x, y];
if (h <= SeaLevel) continue;
TotalLand++;
if (h < MinLand) MinLand = h;
if (h > MaxLand) MaxLand = h;
int bin = (int)((h - SeaLevel) / BinWidth);
if (bin >= _counts.Length) OverflowCount++;
else _counts[bin]++;
}
}
FieldsPooled++;
}
/// <summary>
/// The quantile at <paramref name="percent"/> (0..100) — a raw height, interpolated inside
/// its bin so the answer is not quantized to <see cref="BinWidth"/>.

View file

@ -0,0 +1,524 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE MOUNTAIN-RESTORE BATCH (chat2/03) — put the mountain back, as a smooth slope.
///
/// ═══ THE STORY THIS BATCH TELLS, IN THREE HISTOGRAMS ═══
///
/// staircase the mass is there, but parked in two spikes (bench 100 m, plateau 220 m)
/// continuous_02 the spikes are gone — and so is the mass. It fell to 3090 m.
/// continuous_restored ⭐ the same mass as the staircase, spread as one smooth grade.
///
/// That contrast is the point, so the three are named to sort adjacent.
///
/// ═══ HOW THE RESTORATION IS MEASURED ═══
///
/// The climb's control points are no longer invented from shape knobs. They are MEASURED off the
/// staircase itself, on the same 6-seed pool tasks 01/02 use:
///
/// for p in {10,30,50,70,85,95} of ABOVE-CEILING land:
/// u_p ← that percentile of the RAW height (normalized into the climb's span)
/// v_p ← that percentile of the OUTPUT height (staircase, normalized)
///
/// PCHIP through those points reproduces the staircase's elevation envelope; the flat bench and
/// plateau interiors become grade because <c>ClimbCalibration.MinNormalizedSecant</c> floors
/// every segment. → <see cref="ClimbCalibration"/>.
///
/// ⚠ The two quantile sets are paired by percentile across the SAME cell population, which is
/// exact only if the staircase were strictly monotone per column. It is monotone in raw, but the
/// per-column bench/plateau modulation (±12 / ±20 m) blurs the pairing by about that much. That
/// is well inside the envelope being targeted, and calibrating on the MEASURED output (rather
/// than a nominal unmodulated curve) is what makes oracle (g)'s land-above-100 m figure the thing
/// actually being aimed at.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/MountainRestoreTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_MAPSIZE / ISLA_SEEDS / ISLA_SHOWPIECE_SIZE / ISLA_SHOWPIECE
/// ISLA_PHASE1_SOURCE (default "02_pass1_port") · ISLA_T01_SOURCE (default "01_curve_baseline")
/// ISLA_SKIP_RAW
/// ISLA_LIFT_BIG probe: the `continuous_bigger` lift (default 1.35)
/// ISLA_SHARP probe: the `continuous_sharper_peak` knob (default 2.5)
/// </summary>
public partial class MountainRestoreTool : Node
{
private static readonly int[] DefaultSeeds = { 1063685222, 777001 };
/// <summary>⚠ Task 01's pool, verbatim — the knots' identity, and with it the staircase control's.</summary>
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
private const int DefaultMapSize = 2048;
private const int DefaultShowpieceSize = 8192;
/// <summary>Oracle (g)'s PASS/NOTE threshold, percentage points of land above 100 m. Reported either way.</summary>
private const double MountainTolerancePp = 2.0;
public override void _Ready()
{
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
int task = EnvInt("ISLA_TASK", 3);
string descr = EnvStr("ISLA_BATCH", "mountain_restore");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
int showSize = EnvInt("ISLA_SHOWPIECE_SIZE", DefaultShowpieceSize);
bool showpiece = EnvStr("ISLA_SHOWPIECE", "1") == "1";
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
string t01Source = EnvStr("ISLA_T01_SOURCE", "01_curve_baseline");
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
float liftBig = EnvFloat("ISLA_LIFT_BIG", 1.35f);
float sharpKnob = EnvFloat("ISLA_SHARP", 2.5f);
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
int primary = seeds[0];
GD.Print("==================================================================");
GD.Print(" MOUNTAIN RESTORE (chat2/03) — the staircase's mountain,");
GD.Print(" de-terraced. Calibrated, not invented.");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} showpiece {(showpiece ? showSize.ToString() : "off")}");
GD.Print($"yardstick : {WorldScale.Describe()}");
GD.Print($"seeds : {string.Join(", ", seeds)} (calibration pool: {string.Join(", ", CalibrationSeeds)})");
GD.Print($"batch : {batchRoot}");
GD.Print("==================================================================");
// ═══ 0. KNOTS — task 01's pool, re-measured for bit-identity ═══
GD.Print("\n--- 0. KNOTS ---");
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int seed in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = mapSize, Seed = seed });
pass1[seed] = p1;
rawPool.Accumulate(p1.Height, mapSize);
}
var knots = new CurveKnots(2, "v2_balanced",
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
GD.Print($" {rawPool}");
GD.Print($" {knots}");
// ═══ 1. CALIBRATE — measure the staircase's above-ceiling elevation distribution ═══
//
// ⚠ The ceiling is the DEFAULT 30 m handover, which is exactly (K2, RED_CEIL). So
// "above-ceiling" is simply "raw > K2" — no derived float, and the same population the
// climb will later be responsible for.
GD.Print("\n--- 1. CALIBRATION (staircase above-ceiling distribution) ---");
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
var stairPool = new Dictionary<int, Pass2Result>();
foreach (int seed in CalibrationSeeds)
{
var scfg = MakeConfig(mapSize, seed, knots, anchors, "staircase");
scfg.CurveMode = CurveModeKind.Staircase;
scfg.ShelfDetail = true;
Pass2Result st = Shaping.Shape(pass1[seed], scfg);
stairPool[seed] = st;
rawAbove.AccumulateWhere(pass1[seed].Height, pass1[seed].Height, mapSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, pass1[seed].Height, mapSize, ceilingRaw);
}
double shareAbove = 100.0 * rawAbove.TotalLand / rawPool.TotalLand;
GD.Print($" above-ceiling land: {rawAbove.TotalLand:N0} cells = {shareAbove:F1}% of all land");
var pcts = ClimbCalibration.DefaultPercentiles;
var rawQ = new float[pcts.Length];
var outQ = new float[pcts.Length];
GD.Print(" percentile → raw → staircase output");
for (int i = 0; i < pcts.Length; i++)
{
rawQ[i] = rawAbove.Quantile(pcts[i]);
outQ[i] = outAbove.Quantile(pcts[i]);
GD.Print($" P{pcts[i],-4:F0} raw {rawQ[i]:F4} → {WorldScale.MetresFromRaw(outQ[i] - sea),6:F1} m");
}
ClimbCalibration Calib(float lift, float sharp) => ClimbCalibration.FromPercentiles(
pcts, rawQ, outQ, ceilingRaw, HeightCurve.EffectiveSpikeMax(pass1[primary].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, lift, sharp);
// ⚠ ONE calibration object per knob pair, shared across seeds. spikeMax differs slightly
// per seed, but the calibration is NORMALIZED (u, v in [0,1]) — BuildCalibrated
// denormalizes against each seed's own spikeMax. So the shape is shared; the extent is
// per-seed, exactly as the per-seed peak normalization requires.
var calRestored = Calib(1.0f, 1.0f);
var calBigger = Calib(liftBig, 1.0f);
var calSharper = Calib(1.0f, sharpKnob);
GD.Print($" restored: {calRestored.Describe()}");
GD.Print($" bigger : {calBigger.Describe()}");
GD.Print($" sharper : {calSharper.Describe()}");
// ═══ 2. VARIANTS ═══
var variants = new List<(string label, Action<TerrainGenConfig> mutate)>
{
("staircase", c => { c.CurveMode = CurveModeKind.Staircase; c.ShelfDetail = true; }),
("continuous_02default", c => { c.CurveMode = CurveModeKind.Continuous;
c.ClimbCalibration = null; // the analytic 02 curve
c.ClimbFeather = 0.4f; c.SummitDrama = 2.5f; }),
("continuous_restored", c => { c.CurveMode = CurveModeKind.Continuous; c.ClimbCalibration = calRestored; }),
("continuous_bigger", c => { c.CurveMode = CurveModeKind.Continuous; c.ClimbCalibration = calBigger; }),
("continuous_sharper_peak", c => { c.CurveMode = CurveModeKind.Continuous; c.ClimbCalibration = calSharper; }),
};
GD.Print("\n--- 2. VARIANTS ---");
var results = new Dictionary<(int, string), Pass2Result>();
var offs = new Dictionary<int, Pass2Result>();
var rows = new List<string>();
bool notesPrinted = false;
foreach (int seed in seeds)
{
var offCfg = MakeConfig(mapSize, seed, knots, anchors, "curve_off");
offCfg.Curve = false;
offs[seed] = Shaping.Shape(pass1[seed], offCfg);
foreach (var (label, mutate) in variants)
{
var cfg = MakeConfig(mapSize, seed, knots, anchors, label);
mutate(cfg);
Pass2Result p2 = Shaping.Shape(pass1[seed], cfg);
results[(seed, label)] = p2;
if (!notesPrinted) foreach (string nt in p2.Notes) GD.Print(" " + nt);
rows.Add(WriteVariant(batchRoot, p2, sea, anchors, skipRaw));
}
notesPrinted = true;
}
// ═══ 3. ORACLE ═══
GD.Print("\n--- 3. ORACLE ---");
var hard = new List<ShapingOracle.Check>();
var soft = new List<ShapingOracle.Check>();
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{primary}_full", "height.f32");
string t01Dump = Path.Combine(ToolingPaths.BatchesRoot, t01Source, $"{primary}_curve_on", "height.f32");
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF == Phase-1 .f32 dump",
offs[primary].Height, HeightField.Load(p1Dump, mapSize), mapSize, p1Dump));
hard.Add(ShapingOracle.DumpRegression("a2", "staircase == task-01 curve_on .f32 dump",
results[(primary, "staircase")].Height, HeightField.Load(t01Dump, mapSize), mapSize, t01Dump));
long bFail = 0;
foreach (int seed in seeds)
foreach (var (label, _) in variants)
if (!ShapingOracle.ClassifyFidelity(pass1[seed], results[(seed, label)]).Passed) bFail++;
hard.Add(new ShapingOracle.Check
{
Id = "b", Name = "classify == raw, all seeds × all variants",
Passed = bFail == 0,
Detail = bFail == 0 ? $"bit-identical on {seeds.Length} seeds × {variants.Count} variants"
: $"{bFail} (seed, variant) pairs drifted",
});
bool cOk = results[(primary, "continuous_restored")].Notes
.Exists(n => n.Contains("strict-increase sample passed"));
hard.Add(new ShapingOracle.Check
{
Id = "c", Name = "monotone — FritschCarlson + per-seed sampled",
Passed = cOk,
Detail = cOk ? "confirmed on the calibrated climb (throws and refuses on violation)"
: "no strict-increase confirmation recorded",
});
string[] continuous = { "continuous_02default", "continuous_restored", "continuous_bigger", "continuous_sharper_peak" };
foreach (int seed in seeds)
foreach (string label in continuous)
{
var d = ShapingOracle.LowlandsPreserved(pass1[seed], results[(seed, "staircase")], results[(seed, label)]);
d.Name += $" [seed {seed}]";
hard.Add(d);
var e = ShapingOracle.UpperClimbProfile(results[(seed, label)]);
e.Name += $" [seed {seed}]";
soft.Add(e);
}
foreach (int seed in seeds)
foreach (var (label, _) in variants)
{
var f = ShapingOracle.SeaIdentity(offs[seed], results[(seed, label)], sea);
f.Name += $" [seed {seed}]";
hard.Add(f);
}
// (g) the restoration, measured — reported for every variant, gated for none.
var mountain = new List<ShapingOracle.Check>();
foreach (int seed in seeds)
foreach (string label in continuous)
{
var g = ShapingOracle.MountainRestored(results[(seed, label)], results[(seed, "staircase")],
sea, MountainTolerancePp);
g.Name += $" [seed {seed}]";
mountain.Add(g);
}
foreach (var c in hard) GD.Print(" " + c);
foreach (var c in soft) { if (c.Passed) GD.Print(" " + c); else GD.PrintErr(" ⚠ SLOPE: " + c); }
GD.Print(" --- (g) mountain, reported not gated ---");
foreach (var c in mountain) GD.Print(" " + c);
bool hardOk = hard.TrueForAll(c => c.Passed);
GD.Print($" ORACLE: {(hardOk ? "ALL HARD CHECKS PASS" : "*** HARD FAILURES ***")}");
// ═══ 4. HISTOGRAMS — the three-way contrast, adjacent by filename ═══
GD.Print("\n--- 4. HISTOGRAMS ---");
foreach (int seed in seeds)
{
var rawSeed = new LandHistogram(sea);
rawSeed.Accumulate(pass1[seed].Height, mapSize);
int order = 1;
foreach (var (label, _) in variants)
DrawShaped(results[(seed, label)], anchors, seed, mapSize, batchRoot, order++, sea);
}
// ═══ 5. SHOWPIECE ═══
string showNote = "skipped (ISLA_SHOWPIECE=0)";
if (showpiece)
{
GD.Print($"\n--- 5. SHOWPIECE at {showSize} (continuous_restored, seed {primary}) ---");
var cfg = MakeConfig(showSize, primary, knots, anchors, "continuous_restored_showpiece");
cfg.CurveMode = CurveModeKind.Continuous;
cfg.ClimbCalibration = calRestored;
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result big = Shaping.Shape(p1, cfg);
foreach (string nt in big.Notes) GD.Print(" " + nt);
var cb = ShapingOracle.ClassifyFidelity(p1, big);
var offBigCfg = MakeConfig(showSize, primary, knots, anchors, "off"); offBigCfg.Curve = false;
var cf = ShapingOracle.SeaIdentity(Shaping.Shape(p1, offBigCfg), big, sea);
GD.Print($" {cb}");
GD.Print($" {cf}");
if (!cb.Passed || !cf.Passed) hardOk = false;
var (b100, b220) = ShapingOracle.LandAbove(big, sea);
GD.Print($" land >100 m {b100:F2}% >220 m {b220:F2}% (at {showSize})");
rows.Add(WriteVariant(batchRoot, big, sea, anchors, skipRaw));
showNote = $"seed {primary} at {showSize}; >100 m {b100:F2}%, >220 m {b220:F2}%";
}
WriteIndex(batchRoot, mapSize, showSize, seeds, primary, anchors, results, calRestored,
calBigger, calSharper, pcts, rawQ, outQ, hard, soft, mountain, rows, hardOk, showNote, sea);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" ORACLE {(hardOk ? "HARD CHECKS ALL PASS" : "*** HARD FAILURES ***")}");
GD.Print("==================================================================");
GetTree().Quit(hardOk ? 0 : 3);
}
private static TerrainGenConfig MakeConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a, string label)
=> new TerrainGenConfig
{
MapSize = mapSize, Seed = seed, VariantLabel = label,
Curve = true, ShelfDetail = false, Knots = k, Anchors = a,
LowlandCeilingM = 30f,
};
// ---- output ---------------------------------------------------------
private static string WriteVariant(string batchRoot, Pass2Result p2, float sea,
CurveAnchors anchors, bool skipRaw)
{
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
DirAccess.MakeDirRecursiveAbsolute(dir);
var (gMin, gMax) = GrayscaleRenderer.SavePng(p2.Height, p2.MapSize, Path.Combine(dir, "grayscale.png"));
if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
var look = new LookConfig
{
Name = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f,
HillshadeStrength = 0.30f, SeaLevel = sea,
};
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, p2.VariantLabel.ToUpperInvariant())
.SavePng(Path.Combine(dir, "relief.png"));
var (a100, a220) = ShapingOracle.LandAbove(p2, sea);
GD.Print($" {p2.VariantLabel,-30} seed {p2.Seed,-11} h[{p2.HMin,7:F3} .. {p2.HMax,6:F3}] " +
$" >100m {a100,5:F2}% >220m {a220,5:F2}% {p2.ElapsedMs,5} ms");
return $"| `{p2.Seed}_{p2.VariantLabel}` | {p2.Seed} | {p2.VariantLabel} | {p2.HMin:F3} | {p2.HMax:F3} | " +
$"{a100:F2}% | {a220:F2}% | {gMin:F3}..{gMax:F3} | {p2.ElapsedMs} ms |";
}
private static void DrawShaped(Pass2Result p2, CurveAnchors a, int seed, int mapSize,
string batchRoot, int order, float sea)
{
var shaped = new LandHistogram(sea);
shaped.Accumulate(p2.Height, mapSize);
float top = MathF.Ceiling(shaped.MaxLand * 20f) / 20f;
var display = shaped.Rebin((top - shaped.SeaLevel) / 360f);
var (a100, a220) = ShapingOracle.LandAbove(p2, sea);
var o = new HistogramRenderer.Options
{
Title = $"{p2.VariantLabel.ToUpperInvariant()} - SEED {seed}",
Subtitle = $"LAND ABOVE 100M {a100:F2} PCT - ABOVE 220M {a220:F2} PCT",
XAxisLabel = "RAW HEIGHT (POST-CURVE)",
XTop = top,
Footer = $"{shaped.TotalLand} LAND COLUMNS AT MAPSIZE {mapSize}",
};
// The two heights the restoration is measured at, on every plate, so the three-way
// contrast can be read off the same reference lines.
o.Markers.Add(new HistogramRenderer.Marker { Value = a.Sea + WorldScale.RawFromMetres(100f), Label = "100M" });
o.Markers.Add(new HistogramRenderer.Marker { Value = a.Sea + WorldScale.RawFromMetres(220f), Label = "220M" });
o.Markers.Add(new HistogramRenderer.Marker { Value = a.PeakCap, Label = "CAP 420M", Strong = false });
if (p2.Continuous != null)
o.Markers.Add(new HistogramRenderer.Marker { Value = p2.Continuous.CeilingOut, Label = "LOWLAND", Strong = false });
string file = $"hist_{seed}_{order}_{p2.VariantLabel}.png";
HistogramRenderer.SavePng(display, o, Path.Combine(batchRoot, file));
GD.Print($" {file}");
}
private static void WriteIndex(string batchRoot, int mapSize, int showSize, int[] seeds, int primary,
CurveAnchors a, Dictionary<(int, string), Pass2Result> results,
ClimbCalibration calRestored, ClimbCalibration calBigger, ClimbCalibration calSharper,
double[] pcts, float[] rawQ, float[] outQ,
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> soft, List<ShapingOracle.Check> mountain,
List<string> rows, bool hardOk, string showNote, float sea)
{
var sb = new StringBuilder();
sb.AppendLine("# Batch 03 — restore the mountain, as a smooth slope");
sb.AppendLine();
sb.AppendLine("The continuous climb's control points are now **measured off the staircase** instead of");
sb.AppendLine("invented from shape knobs. Same mountain mass, zero terraces. The lowlands are still");
sb.AppendLine("preserved bit-for-bit (oracle d).");
sb.AppendLine();
sb.AppendLine("## ⭐ Open this first");
sb.AppendLine();
sb.AppendLine($"1. **`{primary}_continuous_restored_showpiece/relief.png`** — the centerpiece ({showNote}).");
sb.AppendLine("2. **The three-way histogram contrast**, adjacent by filename:");
sb.AppendLine($" - `hist_{primary}_1_staircase.png` — the mass, parked in two spikes");
sb.AppendLine($" - `hist_{primary}_2_continuous_02default.png` — spikes gone, **and so is the mass**");
sb.AppendLine($" - `hist_{primary}_3_continuous_restored.png` — ⭐ **the mass back, spread smooth**");
sb.AppendLine(" Every plate carries the same 100 m / 220 m reference lines.");
sb.AppendLine();
sb.AppendLine("## The restoration, measured");
sb.AppendLine();
sb.AppendLine("| Variant | land >100 m | land >220 m |");
sb.AppendLine("|---|---|---|");
foreach (string label in new[] { "staircase", "continuous_02default", "continuous_restored", "continuous_bigger", "continuous_sharper_peak" })
{
var (x100, x220) = ShapingOracle.LandAbove(results[(primary, label)], sea);
string star = label == "continuous_restored" ? " ⭐" : label == "staircase" ? " *(target)*" : "";
sb.AppendLine($"| `{label}`{star} | {x100:F2} % | {x220:F2} % |");
}
sb.AppendLine();
sb.AppendLine($"*(seed {primary} at {mapSize}; per-seed rows in Results below.)*");
sb.AppendLine();
sb.AppendLine("## The calibration");
sb.AppendLine();
sb.AppendLine("Measured on the 6-seed pool, above-ceiling land only:");
sb.AppendLine();
sb.AppendLine("| percentile | raw | staircase output |");
sb.AppendLine("|---|---|---|");
for (int i = 0; i < pcts.Length; i++)
sb.AppendLine($"| P{pcts[i]:F0} | {rawQ[i]:F4} | **{WorldScale.MetresFromRaw(outQ[i] - sea):F0} m** |");
sb.AppendLine();
sb.AppendLine("| Variant | knobs | control points (u,v) |");
sb.AppendLine("|---|---|---|");
sb.AppendLine($"| `continuous_restored` | {calRestored.Describe().Split('·')[0].Trim()} | `{calRestored.Describe().Split('·')[1].Trim()}` |");
sb.AppendLine($"| `continuous_bigger` | {calBigger.Describe().Split('·')[0].Trim()} | `{calBigger.Describe().Split('·')[1].Trim()}` |");
sb.AppendLine($"| `continuous_sharper_peak` | {calSharper.Describe().Split('·')[0].Trim()} | `{calSharper.Describe().Split('·')[1].Trim()}` |");
sb.AppendLine();
sb.AppendLine($"`floored` counts segments the no-bench floor had to lift — i.e. where the staircase was flat.");
sb.AppendLine();
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
sb.AppendLine();
sb.AppendLine("`ProvisionalEven` — the CostaRica colours re-spaced evenly SEA → 420 m. Final calibration");
sb.AppendLine("waits for the chosen profile. **Grayscale + the histograms are the honest instruments.**");
sb.AppendLine();
sb.AppendLine("## The oracle");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(hard));
sb.AppendLine($"**{(hardOk ? "ALL HARD CHECKS PASS" : " HARD FAILURES do not judge this batch")}**");
sb.AppendLine();
sb.AppendLine("Soft — upper climb slope profile (e):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(soft));
sb.AppendLine("(g) mountain restored — **reported, not gated** (it is a taste target the developer tunes):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(mountain));
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `relief.png`, `hist_*.png`, `INDEX.md` | **keep** |");
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + code (the byte-level oracle) |");
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
sb.AppendLine();
sb.AppendLine("## Results");
sb.AppendLine();
sb.AppendLine("| Folder | Seed | Variant | h min | h max | >100 m | >220 m | grayscale range | time |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
foreach (string row in rows) sb.AppendLine(row);
sb.AppendLine();
sb.AppendLine($"MapSize {mapSize}, showpiece {showSize}, seeds {string.Join(", ", seeds)}. {WorldScale.Describe()}.");
string index = Path.Combine(batchRoot, "INDEX.md");
using var f = Godot.FileAccess.Open(index, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {index}"); return; }
f.StoreString(sb.ToString());
}
// ---- env helpers ----------------------------------------------------
private static string EnvStr(string k, string fallback)
{
string v = System.Environment.GetEnvironmentVariable(k);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}
private static int EnvInt(string k, int fallback)
=> int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
private static float EnvFloat(string k, float fallback)
=> float.TryParse(EnvStr(k, null) ?? "", System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out float v) ? v : fallback;
private static int[] EnvSeeds(string k, int[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<int>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
}
}

View file

@ -0,0 +1 @@
uid://dsg3y4a75vwmu

View file

@ -0,0 +1,235 @@
using System;
using System.Collections.Generic;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>One connected island of tagged offshore land, as the analysis sees it.</summary>
public sealed class IslandComponent
{
public int Id;
public long Cells;
public double CentroidX, CentroidY;
public int MinX, MinY, MaxX, MaxY;
/// <summary>Hemisphere by CENTROID (an island straddling the midline is counted once, where its mass is).</summary>
public byte Hemisphere;
/// <summary>
/// ⚠ True if any cell of this island is 8-adjacent to land that is NOT tagged offshore —
/// i.e. the island touches the mainland. The moat exists to make this impossible; this is
/// the check that it did.
/// </summary>
public bool BridgedToMainland;
}
/// <summary>
/// ⭐ THE OFFSHORE ANALYSIS — counts islands, reads their hemisphere, and catches a land bridge.
/// Engine-free; used by the pass (to prove its own floor) and by the oracle (to prove it again,
/// independently, on the finished field).
///
/// ═══ THE HEMISPHERE CONVENTION — read from the code, not invented ═══
///
/// Pass 1's latitude scalar is <c>y / MapSize</c> (+ a ±0.1 wobble). The spine fades out where
/// that scalar exceeds 0.65 — "the southern fade" — and the "southern sinker" bites in the
/// BOTTOM 25 % of rows. So in this codebase, and in the lore it encodes (snow-town north,
/// shipwreck south): <b>y increases SOUTHWARD. North is the top half of the image.</b>
///
/// NORTH y ∈ [0, MapSize/2)
/// SOUTH y ∈ [MapSize/2, MapSize)
///
/// ⚠ The tag uses the clean row midline, NOT the wobbled latitude field. A hemisphere tag keyed
/// to a field that wanders ±10 % of the map would put the same island in different hemispheres
/// on different seeds for no geographic reason. The field's ORIENTATION is what is borrowed; its
/// wobble is not.
/// </summary>
public static class OffshoreAnalysis
{
// The convention now lives in Core (RegionLabeling, chat2/07) — one definition; these are aliases.
public const byte HemiNone = RegionLabeling.HemiNone;
public const byte HemiNorth = RegionLabeling.HemiNorth;
public const byte HemiSouth = RegionLabeling.HemiSouth;
/// <summary>→ <see cref="RegionLabeling.HemisphereOfRow"/>.</summary>
public static byte HemisphereOfRow(int y, int mapSize) => RegionLabeling.HemisphereOfRow(y, mapSize);
public static string HemisphereName(byte h) => RegionLabeling.HemisphereName(h);
// 8-connectivity, fixed order.
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
/// <summary>
/// Label the 8-connected components of tagged offshore land, and for each, whether it
/// touches untagged land (a bridge). <paramref name="height"/> + <paramref name="sea"/>
/// define "land"; <paramref name="tag"/> defines "offshore". Both are needed: the bridge test
/// is "tagged cell next to a land cell that is not tagged".
/// </summary>
public static List<IslandComponent> Components(bool[,] tag, float[,] height, float sea, int mapSize)
=> Components(tag, height, sea, mapSize, out _);
/// <summary>
/// As above, also returning the per-cell component id map (<c>x * mapSize + y</c>; 0 = not
/// tagged) — the debris guard needs membership, not just the list.
/// </summary>
public static List<IslandComponent> Components(bool[,] tag, float[,] height, float sea, int mapSize,
out int[] idMap)
{
var comps = new List<IslandComponent>();
int n = mapSize;
var id = new int[n * n]; // 0 = unvisited / not tagged
idMap = id;
if (tag == null) return comps;
var stack = new Stack<int>();
int next = 0;
for (int sx = 0; sx < n; sx++)
{
for (int sy = 0; sy < n; sy++)
{
if (!tag[sx, sy] || id[sx * n + sy] != 0) continue;
var c = new IslandComponent
{
Id = ++next, MinX = sx, MaxX = sx, MinY = sy, MaxY = sy,
};
double sumX = 0, sumY = 0;
id[sx * n + sy] = c.Id;
stack.Push(sx * n + sy);
while (stack.Count > 0)
{
int cur = stack.Pop();
int cx = cur / n, cy = cur % n;
c.Cells++; sumX += cx; sumY += cy;
if (cx < c.MinX) c.MinX = cx; if (cx > c.MaxX) c.MaxX = cx;
if (cy < c.MinY) c.MinY = cy; if (cy > c.MaxY) c.MaxY = cy;
for (int k = 0; k < 8; k++)
{
int nx = cx + DX[k], ny = cy + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
if (tag[nx, ny])
{
int ni = nx * n + ny;
if (id[ni] != 0) continue;
id[ni] = c.Id;
stack.Push(ni);
}
else if (height[nx, ny] >= sea)
{
// Land, not tagged offshore ⇒ mainland (or a lake-shore) touching
// this island. The moat should have made this impossible.
c.BridgedToMainland = true;
}
}
}
c.CentroidX = sumX / c.Cells;
c.CentroidY = sumY / c.Cells;
c.Hemisphere = HemisphereOfRow((int)Math.Round(c.CentroidY), mapSize);
comps.Add(c);
}
}
return comps;
}
/// <summary>Island counts per hemisphere, by component centroid.</summary>
public static (int north, int south) CountByHemisphere(List<IslandComponent> comps)
{
int nN = 0, nS = 0;
foreach (var c in comps)
{
if (c.Hemisphere == HemiNorth) nN++;
else if (c.Hemisphere == HemiSouth) nS++;
}
return (nN, nS);
}
/// <summary>
/// Island SIZE statistics — the thing a count alone hides. 189 islands averaging 66 cells is
/// noise debris, not an archipelago; 12 islands averaging 900 cells is what the developer
/// asked for. Cells are map cells (1 column = 1 m at the target scale).
/// </summary>
public static (long min, long median, double mean, long max, int belowThreshold)
SizeSummary(List<IslandComponent> comps, long threshold)
{
if (comps.Count == 0) return (0, 0, 0.0, 0, 0);
var sizes = new List<long>(comps.Count);
double sum = 0; int below = 0;
foreach (var c in comps) { sizes.Add(c.Cells); sum += c.Cells; if (c.Cells < threshold) below++; }
sizes.Sort();
return (sizes[0], sizes[sizes.Count / 2], sum / sizes.Count, sizes[sizes.Count - 1], below);
}
/// <summary>How many components touch the mainland. Zero is the only acceptable answer.</summary>
public static int BridgedCount(List<IslandComponent> comps)
{
int b = 0;
foreach (var c in comps) if (c.BridgedToMainland) b++;
return b;
}
// ═══ chat2/06 — the separation guard's geometry ═══
/// <summary>
/// Per component id, its BOUNDARY cells — tagged cells with at least one 8-neighbour that is
/// not tagged (or the map edge). The nearest approach between two islands is between
/// boundary cells, so the separation guard compares boundaries, not bodies: a few hundred
/// cells per island instead of thousands. <paramref name="surfaced"/> is the pass's list of
/// every surfaced cell (the bodies), walked once.
/// </summary>
public static Dictionary<int, List<(int x, int y)>> BoundaryCells(bool[,] tag, int[] compId, int mapSize,
IEnumerable<(int x, int y, float h0)> surfaced)
{
var result = new Dictionary<int, List<(int x, int y)>>();
foreach (var (x, y, _) in surfaced)
{
if (!tag[x, y]) continue;
int id = compId[x * mapSize + y];
if (id == 0) continue;
bool edge = false;
for (int k = 0; k < 8 && !edge; k++)
{
int nx = x + DX[k], ny = y + DY[k];
if (nx < 0 || nx >= mapSize || ny < 0 || ny >= mapSize || !tag[nx, ny]) edge = true;
}
if (!edge) continue;
if (!result.TryGetValue(id, out var list)) result[id] = list = new List<(int, int)>();
list.Add((x, y));
}
return result;
}
/// <summary>Chebyshev gap between two components' bounding boxes (0 if they overlap). A lower bound on their true distance.</summary>
public static int BoxGap(IslandComponent a, IslandComponent b)
{
int gx = Math.Max(0, Math.Max(a.MinX - b.MaxX, b.MinX - a.MaxX));
int gy = Math.Max(0, Math.Max(a.MinY - b.MaxY, b.MinY - a.MaxY));
return Math.Max(gx, gy);
}
/// <summary>
/// The minimum Chebyshev distance between two boundary sets, early-exiting once it is
/// known to be below <paramref name="below"/> (the caller only needs "closer than the
/// minimum or not").
/// </summary>
public static int MinChebyshev(List<(int x, int y)> a, List<(int x, int y)> b, int below)
{
int best = int.MaxValue;
if (a == null || b == null) return best;
foreach (var (ax, ay) in a)
{
foreach (var (bx, by) in b)
{
int d = Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by));
if (d < best) { best = d; if (best < below) return best; }
}
}
return best;
}
}
}

View file

@ -0,0 +1 @@
uid://bw21tp5wa5vxt

View file

@ -0,0 +1,231 @@
using System;
using System.Collections.Generic;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>One hemisphere's share of the offshore zone, the gates, and the noise peaks.</summary>
public sealed class HemisphereDiagnosis
{
public string Name;
// ---- the valid offshore zone ----
public long Sea; // below-sea cells in this hemisphere (after the shelf)
public long Zone; // cells with zone weight > 0 (island-eligible)
public long ZoneFull; // cells with zone weight == 1 (clear of every feather)
// ---- which gate blocks (non-exclusive: a cell may fail several) ----
public long BlockDepth; // ambient depth < moat
public long BlockFalloff; // pre-Trench falloff < min (not "actually offshore")
public long BlockTrench; // at/past the outer bound
// ---- the SOLE blocker (a cell that fails exactly one gate — loosen that gate and it joins the zone) ----
public long SoleDepth, SoleFalloff, SoleTrench;
// ---- band geometry: per column, how many rows are ocean / zone in this hemisphere ----
public double OceanRowsPerColumn, ZoneRowsPerColumn;
public int ColumnsWithNoZone;
// ---- the noise peaks (strict 8-neighbour local maxima of the islet field, on sea cells) ----
public int PeaksSea; // all maxima over sea
public int PeaksInZone; // maxima inside the zone — the CAPACITY for islands at this frequency
public int PeaksInZoneOverThr; // inside the zone AND clearing the threshold — the island candidates
public int PeaksOverThrLost; // clearing the threshold but OUTSIDE the zone — killed by a gate:
public int LostDepth, LostFalloff, LostTrench; // …which one(s) (non-exclusive)
public double ZoneShareOfSea => Sea == 0 ? 0 : (double)Zone / Sea;
public double CandidatesPerMegacell => Zone == 0 ? 0 : PeaksInZoneOverThr * 1e6 / Zone;
}
/// <summary>
/// ⭐ THE SOUTH-SUPPRESSION DIAGNOSIS (chat2/06 §2) — MEASURE, DON'T GUESS. Before a knob moves,
/// answer per hemisphere: how much island-eligible ocean is there, how many noise peaks clear the
/// threshold in it, and which gate is the binding one. Read-only: it evaluates the islet noise
/// field and the zone mask exactly as <see cref="OffshorePass"/> does (same noise factory, same
/// calibration samples, same blended threshold, same gate arithmetic) over the post-shelf,
/// pre-islet field, and counts. It raises nothing.
///
/// Hemisphere is the row midline (<see cref="OffshoreAnalysis.HemisphereOfRow"/>), as the tag.
/// </summary>
public static class OffshoreDiagnosis
{
public sealed class Report
{
public int Seed, MapSize;
public float ThresholdNorth, ThresholdSouth;
public HemisphereDiagnosis North = new() { Name = "north" };
public HemisphereDiagnosis South = new() { Name = "south" };
public HemisphereDiagnosis Of(byte hemi) => hemi == OffshoreAnalysis.HemiNorth ? North : South;
}
/// <param name="height">The post-shelf, pre-islet field (offshore OFF, shelf as the batch runs it).</param>
public static Report Run(float[,] height, float[,] preTrench, int mapSize, int seed, float sea,
OffshoreSettings s, GenerationScale scale)
{
var rep = new Report { Seed = seed, MapSize = mapSize };
FastNoiseLite noise = TerrainNoise.CreateModulation(seed, s.SeedOffset, s.FreqPerMapWidth, scale);
float[] samples = OffshorePass.CalibrationSamples(noise, mapSize);
float thrN = IslandFalloff.CalibrateThreshold(samples, s.Density);
float thrS = s.Mode == OffshoreMode.Faithful ? thrN : IslandFalloff.CalibrateThreshold(samples, s.DensitySouth);
rep.ThresholdNorth = thrN; rep.ThresholdSouth = thrS;
float centerX = mapSize / 2.0f, centerY = mapSize / 2.0f, halfSpan = mapSize / 2.0f;
float mid = mapSize * 0.5f;
float band = MathF.Max(1f, s.HemisphereBlendHalfWidth * mapSize);
// The islet field over the whole map (a peak's neighbours may be land or out of zone).
var v = new float[mapSize, mapSize];
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
v[x, y] = (noise.GetNoise2D(x, y) + 1f) * 0.5f;
// Zone weight per cell, and the gate ledger. -1 = land.
var zone = new float[mapSize, mapSize];
int half = mapSize / 2;
long[] oceanRows = new long[2], zoneRows = new long[2];
int[] colsNoZone = new int[2];
for (int x = 0; x < mapSize; x++)
{
long[] colZone = new long[2];
for (int y = 0; y < mapSize; y++)
{
float h = height[x, y];
if (h >= sea) { zone[x, y] = -1f; continue; }
int hi = y < half ? 0 : 1;
var d = hi == 0 ? rep.North : rep.South;
d.Sea++; oceanRows[hi]++;
float depthM = WorldScale.MetresFromRaw(sea - h);
float dist = MathF.Max(MathF.Abs(x - centerX) / halfSpan, MathF.Abs(y - centerY) / halfSpan);
bool bDepth = depthM < s.MinDepthM;
bool bFall = preTrench[x, y] < s.MinFalloff;
bool bTr = dist >= s.TrenchOuter;
if (bDepth) d.BlockDepth++;
if (bFall) d.BlockFalloff++;
if (bTr) d.BlockTrench++;
int fails = (bDepth ? 1 : 0) + (bFall ? 1 : 0) + (bTr ? 1 : 0);
if (fails == 1)
{
if (bDepth) d.SoleDepth++; else if (bFall) d.SoleFalloff++; else d.SoleTrench++;
}
float z = IslandFalloff.OffshoreZoneWeight(depthM, preTrench[x, y],
MathF.Abs(x - centerX) / halfSpan, MathF.Abs(y - centerY) / halfSpan,
s.MinDepthM, s.DepthFeatherM, s.MinFalloff, s.FalloffFeather, s.TrenchInner, s.TrenchOuter);
zone[x, y] = z;
if (z > 0f) { d.Zone++; zoneRows[hi]++; colZone[hi]++; }
if (z >= 1f) d.ZoneFull++;
}
for (int hi = 0; hi < 2; hi++) if (colZone[hi] == 0) colsNoZone[hi]++;
}
rep.North.OceanRowsPerColumn = oceanRows[0] / (double)mapSize;
rep.South.OceanRowsPerColumn = oceanRows[1] / (double)mapSize;
rep.North.ZoneRowsPerColumn = zoneRows[0] / (double)mapSize;
rep.South.ZoneRowsPerColumn = zoneRows[1] / (double)mapSize;
rep.North.ColumnsWithNoZone = colsNoZone[0];
rep.South.ColumnsWithNoZone = colsNoZone[1];
// Peaks: strict local maxima of v over the 8-neighbourhood, on sea cells.
for (int x = 0; x < mapSize; x++)
{
for (int y = 0; y < mapSize; y++)
{
if (zone[x, y] < 0f) continue; // land
float c = v[x, y];
bool isMax = true;
for (int dx = -1; dx <= 1 && isMax; dx++)
{
int nx = x + dx; if (nx < 0 || nx >= mapSize) continue;
for (int dy = -1; dy <= 1; dy++)
{
if (dx == 0 && dy == 0) continue;
int ny = y + dy; if (ny < 0 || ny >= mapSize) continue;
if (v[nx, ny] >= c) { isMax = false; break; }
}
}
if (!isMax) continue;
var d = y < half ? rep.North : rep.South;
d.PeaksSea++;
float thr = s.Mode == OffshoreMode.Faithful ? thrN : OffshorePass.BlendedThreshold(y, mid, band, thrN, thrS);
bool over = c > thr;
if (zone[x, y] > 0f)
{
d.PeaksInZone++;
if (over) d.PeaksInZoneOverThr++;
}
else if (over)
{
d.PeaksOverThrLost++;
float h = height[x, y];
float depthM = WorldScale.MetresFromRaw(sea - h);
float dist = MathF.Max(MathF.Abs(x - centerX) / halfSpan, MathF.Abs(y - centerY) / halfSpan);
if (depthM < s.MinDepthM) d.LostDepth++;
if (preTrench[x, y] < s.MinFalloff) d.LostFalloff++;
if (dist >= s.TrenchOuter) d.LostTrench++;
}
}
}
return rep;
}
/// <summary>One markdown row per hemisphere for a report table (see <see cref="TableHeader"/>).</summary>
public static string TableHeader() =>
"| seed | hemi | sea cells | zone cells | zone / sea | zone == 1 | ocean rows/col | zone rows/col | cols w/o zone | " +
"blocked: depth / falloff / trench | sole blocker: depth / falloff / trench | peaks: sea / in zone / in zone > thr | lost > thr (depth / falloff / trench) | candidates per Mcell |\n" +
"|---|---|---|---|---|---|---|---|---|---|---|---|---|---|";
public static string TableRow(Report r, HemisphereDiagnosis d) =>
$"| `{r.Seed}` | **{d.Name}** | {d.Sea:N0} | {d.Zone:N0} | {d.ZoneShareOfSea:P1} | {d.ZoneFull:N0} | {d.OceanRowsPerColumn:F0} | {d.ZoneRowsPerColumn:F0} | {d.ColumnsWithNoZone} | " +
$"{d.BlockDepth:N0} / {d.BlockFalloff:N0} / {d.BlockTrench:N0} | {d.SoleDepth:N0} / {d.SoleFalloff:N0} / {d.SoleTrench:N0} | " +
$"{d.PeaksSea} / {d.PeaksInZone} / **{d.PeaksInZoneOverThr}** | {d.PeaksOverThrLost} ({d.LostDepth} / {d.LostFalloff} / {d.LostTrench}) | {d.CandidatesPerMegacell:F1} |";
/// <summary>Sum a pool of reports per hemisphere (means for the per-column numbers).</summary>
public static (HemisphereDiagnosis north, HemisphereDiagnosis south) Pool(IReadOnlyList<Report> reports)
{
var n = new HemisphereDiagnosis { Name = "north (pool)" };
var s = new HemisphereDiagnosis { Name = "south (pool)" };
foreach (var r in reports) { Add(n, r.North); Add(s, r.South); }
int k = Math.Max(1, reports.Count);
n.OceanRowsPerColumn /= k; s.OceanRowsPerColumn /= k;
n.ZoneRowsPerColumn /= k; s.ZoneRowsPerColumn /= k;
n.ColumnsWithNoZone /= k; s.ColumnsWithNoZone /= k;
return (n, s);
}
private static void Add(HemisphereDiagnosis a, HemisphereDiagnosis b)
{
a.Sea += b.Sea; a.Zone += b.Zone; a.ZoneFull += b.ZoneFull;
a.BlockDepth += b.BlockDepth; a.BlockFalloff += b.BlockFalloff; a.BlockTrench += b.BlockTrench;
a.SoleDepth += b.SoleDepth; a.SoleFalloff += b.SoleFalloff; a.SoleTrench += b.SoleTrench;
a.OceanRowsPerColumn += b.OceanRowsPerColumn; a.ZoneRowsPerColumn += b.ZoneRowsPerColumn; a.ColumnsWithNoZone += b.ColumnsWithNoZone;
a.PeaksSea += b.PeaksSea; a.PeaksInZone += b.PeaksInZone; a.PeaksInZoneOverThr += b.PeaksInZoneOverThr;
a.PeaksOverThrLost += b.PeaksOverThrLost; a.LostDepth += b.LostDepth; a.LostFalloff += b.LostFalloff; a.LostTrench += b.LostTrench;
}
/// <summary>A one-paragraph reading of the pooled numbers: which hemisphere has less eligible ocean, and which gate binds it.</summary>
public static string Interpret(HemisphereDiagnosis n, HemisphereDiagnosis s)
{
var sb = new StringBuilder();
string smaller = n.Zone < s.Zone ? "NORTH" : "SOUTH";
double ratio = n.Zone == 0 || s.Zone == 0 ? 0 : (double)Math.Max(n.Zone, s.Zone) / Math.Min(n.Zone, s.Zone);
sb.Append($"Valid offshore zone: north {n.Zone:N0} cells ({n.ZoneShareOfSea:P1} of its ocean, {n.ZoneRowsPerColumn:F0} rows/col), " +
$"south {s.Zone:N0} cells ({s.ZoneShareOfSea:P1} of its ocean, {s.ZoneRowsPerColumn:F0} rows/col) — the {smaller} has " +
$"{ratio:F2}× less island-eligible ocean. ");
sb.Append($"Island candidates (peaks in zone clearing the threshold): north {n.PeaksInZoneOverThr}, south {s.PeaksInZoneOverThr}; " +
$"capacity (all peaks in zone): north {n.PeaksInZone}, south {s.PeaksInZone}. ");
string Bind(HemisphereDiagnosis d)
{
long max = Math.Max(d.SoleDepth, Math.Max(d.SoleFalloff, d.SoleTrench));
string g = max == d.SoleFalloff ? "the falloff test" : max == d.SoleDepth ? "the moat (ambient depth)" : "the outer/trench bound";
long lostMax = Math.Max(d.LostDepth, Math.Max(d.LostFalloff, d.LostTrench));
string lg = d.PeaksOverThrLost == 0 ? "none" : lostMax == d.LostFalloff ? "falloff" : lostMax == d.LostDepth ? "moat" : "outer bound";
return $"{d.Name}: binding gate by sole-blocked cells = {g} (depth {d.SoleDepth:N0} / falloff {d.SoleFalloff:N0} / trench {d.SoleTrench:N0}); " +
$"over-threshold peaks lost to gates = {d.PeaksOverThrLost} (mostly {lg})";
}
sb.Append(Bind(n)).Append(". ").Append(Bind(s)).Append('.');
return sb.ToString();
}
}
}

View file

@ -0,0 +1 @@
uid://mriontjn1ihq

View file

@ -0,0 +1,679 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE OFFSHORE-ISLANDS BATCH — chat2/06: ORGANIC-ONLY, TUNED FOR COVERAGE, SOUTH-WEIGHTED,
/// NO FORCED COUNT. (chat2/05's version of this tool — faithful / floor_only / hybrid / dense —
/// is in git history at 3b96e06; the forced floor it batched was reverted out on look.)
///
/// ⚠ Since chat2/07 the island tag is set BY THE REGION LAYER (every non-mainland land component,
/// natural islets included), so this tool's counts now include natural islands; the chat2/06 batch
/// of record (offshore-pass islands only) was produced at e8571b2.
///
/// ═══ WHAT IT PRODUCES — a fixed budget: 4 plates + a count table + a diagnosis ═══
///
/// PLATES (4, at ISLA_MAPSIZE, grayscale + .f32 + relief + tags overlay):
/// {plate}_density_low / _mid / _high three densities on ONE seed — the developer picks the
/// look by eye (more islands vs slop), apples to apples.
/// {bulge}_density_mid the preset on the southern-bulge seed — the table seed
/// whose south was SPARSEST at density_mid (auto-picked,
/// or ISLA_BULGE_SEED) — the south fix is not seed-specific.
///
/// THE COUNT TABLE (data, not plates): per seed, north / south island counts across ~12 seeds for
/// each of the 3 density levels, with min / mean / max per hemisphere per level, the guard
/// ledger and the island sizes. This is how "a few south / a couple north, consistently" is
/// READ — as statistics of the tuning, never as a floor.
///
/// THE DIAGNOSIS (data): per hemisphere over the calibration seed pool — valid-zone area, which
/// gate binds, noise peaks clearing the threshold — measured BEFORE any knob moved (§2 of the
/// task). → <see cref="OffshoreDiagnosis"/>.
///
/// ═══ THE CURVE IS THE TAGGED CURVE, UNCHANGED ═══
///
/// `continuous_restored` (tag terrain-curve-v1), calibrated on task 01's pool at the iteration
/// size WITH OFFSHORE OFF — islands are additive land on top of a curve that does not know they
/// exist. Oracle a1 / a3 / a4 prove offshore-off is bit-identical to Phase 1, task 03 and the
/// tag's own 04 gallery dump.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/OffshoreIslandsTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
/// ISLA_MAPSIZE plate + table size (default 4096 — islands need pixels to read)
/// ISLA_TABLE_SIZE count-table size (default = ISLA_MAPSIZE; a probe may drop it)
/// ISLA_CALIB_SIZE curve calibration size (default 2048, task 01's) — also the diagnosis size
/// ISLA_TABLE_SEEDS the count-table seeds (default 12 below)
/// ISLA_PLATE_SEED the three-density seed (default 1063685222)
/// ISLA_BULGE_SEED the south-bulge seed (default 0 = auto: sparsest south at density_mid)
/// ISLA_DENS_LOW / ISLA_DENS_MID / ISLA_DENS_HIGH / ISLA_SOUTH_WEIGHT the tuning (probe overrides)
/// ISLA_OFF_MINAREA / ISLA_OFF_MINSEP / ISLA_OFF_MAXAREA the guards (probe overrides)
/// ISLA_OFF_FREQ / ISLA_OFF_CORE / ISLA_OFF_SHARP / ISLA_OFF_CREST the shape (probe overrides)
/// ISLA_TABLE_ONLY=1 probe: diagnosis + count table only (no regressions, no plates)
/// ISLA_SKIP_8K=1 skip the 8192 regression against the 04 gallery dump (a4)
/// ISLA_PHASE1_SOURCE / ISLA_T03_SOURCE / ISLA_T04_SOURCE the regression dumps' batches
/// </summary>
public partial class OffshoreIslandsTool : Node
{
/// <summary>
/// The count-table pool: chat2/05's six hybrid seeds + task 01's calibration pool (minus the
/// shared anchor) + one more. Twelve draws; the anchor first.
/// </summary>
private static readonly int[] DefaultTableSeeds =
{
1063685222, 20260821, 8675309, 123456789, 271828182, 999999937,
20260819, 777001, 424242, 90210, 31337, 55555,
};
/// <summary>⚠ Task 01's pool, verbatim — the curve's identity. Also the diagnosis pool.</summary>
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
private const int DefaultMapSize = 4096;
private const int DefaultCalibSize = 2048;
private const int GallerySize = 8192; // the 04 gallery's render size
/// <summary>The consistency targets the table is read against: "a couple north, a few south".</summary>
private const int TargetNorth = 2, TargetSouth = 3;
public override void _Ready()
{
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr(e.StackTrace);
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private sealed class Row
{
public string Level; public int Seed;
public int CountN, CountS, PreN, PreS, SpecksN, SpecksS, ClustersN, ClustersS, BlobsN, BlobsS;
public long Lifted, SizeMin, SizeMed, SizeMax; public double SizeMean;
public float HMaxBefore, HMaxAfter;
public bool Ok; public ulong Ms;
}
private sealed class Level
{
public string Label; public OffshoreSettings Settings;
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
int task = EnvInt("ISLA_TASK", 6);
string descr = EnvStr("ISLA_BATCH", "offshore_organic_tune");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int tableSize = EnvInt("ISLA_TABLE_SIZE", mapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] tableSeeds = EnvSeeds("ISLA_TABLE_SEEDS", DefaultTableSeeds);
int plateSeed = EnvInt("ISLA_PLATE_SEED", 1063685222);
int bulgeSeedEnv = EnvInt("ISLA_BULGE_SEED", 0);
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
string t03Source = EnvStr("ISLA_T03_SOURCE", "03_mountain_restore");
string t04Source = EnvStr("ISLA_T04_SOURCE", "04_seed_gallery");
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
bool tableOnly = EnvStr("ISLA_TABLE_ONLY", "0") == "1";
bool skip8k = EnvStr("ISLA_SKIP_8K", "0") == "1";
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
// ═══ THE THREE DENSITY LEVELS — the preset of record is `mid` ═══
OffshoreSettings LevelSettings(float density)
{
var o = OffshoreSettings.Organic();
o.Density = density;
o.SouthWeight = EnvFloat("ISLA_SOUTH_WEIGHT", o.SouthWeight);
o.MinIslandAreaFrac = EnvFloat("ISLA_OFF_MINAREA", o.MinIslandAreaFrac);
o.MinSeparationFrac = EnvFloat("ISLA_OFF_MINSEP", o.MinSeparationFrac);
o.MaxIslandAreaFrac = EnvFloat("ISLA_OFF_MAXAREA", o.MaxIslandAreaFrac);
o.FreqPerMapWidth = EnvFloat("ISLA_OFF_FREQ", o.FreqPerMapWidth);
o.CoreFraction = EnvFloat("ISLA_OFF_CORE", o.CoreFraction);
o.EdgeSharpness = EnvFloat("ISLA_OFF_SHARP", o.EdgeSharpness);
o.CrestM = EnvFloat("ISLA_OFF_CREST", o.CrestM);
return o;
}
var levels = new List<Level>
{
new() { Label = "density_low", Settings = LevelSettings(EnvFloat("ISLA_DENS_LOW", OffshoreSettings.OrganicDensityLow)) },
new() { Label = "density_mid", Settings = LevelSettings(EnvFloat("ISLA_DENS_MID", OffshoreSettings.OrganicDensityMid)) },
new() { Label = "density_high", Settings = LevelSettings(EnvFloat("ISLA_DENS_HIGH", OffshoreSettings.OrganicDensityHigh)) },
};
Level mid = levels[1];
GD.Print("==================================================================");
GD.Print(" OFFSHORE ISLANDS (chat2/06) — organic-only, tuned for coverage, south-weighted, no forced count");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} (plates) table at {tableSize} curve calibrated at {calibSize} (offshore OFF) diagnosis at {calibSize}");
GD.Print($"table : {string.Join(", ", tableSeeds)}");
GD.Print($"plate seed: {plateSeed} bulge seed: {(bulgeSeedEnv > 0 ? bulgeSeedEnv.ToString() : "auto (sparsest south at density_mid)")}");
foreach (var l in levels) GD.Print($" {l.Label,-13} {l.Settings.Describe()}");
GD.Print($"hemisphere: NORTH = rows [0, {mapSize / 2}) SOUTH = rows [{mapSize / 2}, {mapSize}) (y runs south)");
GD.Print($"batch : {batchRoot}{(tableOnly ? " ISLA_TABLE_ONLY a probe, not the batch of record" : "")}");
GD.Print("==================================================================");
// ═══ 0. THE CURVE — continuous_restored, calibrated with offshore off ═══
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
var (knots, calibration, _) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
GD.Print($" {calibration.Describe()}");
// ═══ 1. REGRESSIONS — the things that must not have moved ═══
var hard = new List<ShapingOracle.Check>();
if (!tableOnly)
{
GD.Print($"\n--- 1. REGRESSIONS at {calibSize}, seed {plateSeed} ---");
var offCfg = BaseConfig(calibSize, plateSeed, knots, anchors, calibration, "off");
Pass1Result p1 = Topography.Generate(offCfg);
var curveOff = offCfg.Clone(); curveOff.Curve = false;
Pass2Result pOff = Shaping.Shape(p1, curveOff);
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{plateSeed}_full", "height.f32");
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, offshore OFF == Phase-1 .f32 dump",
pOff.Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
Pass2Result pRest = Shaping.Shape(p1, offCfg);
string t03Dump = Path.Combine(ToolingPaths.BatchesRoot, t03Source, $"{plateSeed}_continuous_restored", "height.f32");
hard.Add(ShapingOracle.DumpRegression("a3", "continuous_restored, offshore OFF == task-03 .f32 dump (lowlands + curve untouched)",
pRest.Height, HeightField.Load(t03Dump, calibSize), calibSize, t03Dump));
// Shelf ON, islets OFF: land must be bit-identical (the shelf touches only sea).
var shelfCfg = offCfg.Clone(); shelfCfg.CoastShelf = true; shelfCfg.VariantLabel = "shelf_only";
Pass1Result p1Shelf = Topography.Generate(shelfCfg);
var j0 = ShapingOracle.MainlandUnmoved(p1, p1Shelf, sea);
j0.Name = "shelf alone: every land cell bit-identical (shelf is below-sea only)";
hard.Add(j0);
hard.Add(ShapingOracle.HMaxAfterOffshore(p1Shelf));
foreach (var c in hard) GD.Print(" " + c);
// ⭐ a4 — offshore OFF at the 04 gallery's size == the terrain-curve-v1 tag's OWN output.
// The literal "offshore-off is bit-identical to terrain-curve-v1", at full size.
if (!skip8k)
{
string t04Dump = Path.Combine(ToolingPaths.BatchesRoot, t04Source, $"{plateSeed}", "height.f32");
if (File.Exists(t04Dump))
{
GD.Print($" a4: generating {plateSeed} at {GallerySize}, offshore OFF, against {t04Dump} …");
var gCfg = BaseConfig(GallerySize, plateSeed, knots, anchors, calibration, "off");
Pass2Result pG = Shaping.Shape(Topography.Generate(gCfg), gCfg);
var a4 = ShapingOracle.DumpRegression("a4", $"offshore OFF at {GallerySize} == terrain-curve-v1's 04 gallery .f32 dump",
pG.Height, HeightField.Load(t04Dump, GallerySize), GallerySize, t04Dump);
hard.Add(a4);
GD.Print(" " + a4);
}
else GD.Print($" a4: ⚠ skipped — no 04 gallery dump at {t04Dump}");
}
else GD.Print(" a4: skipped (ISLA_SKIP_8K)");
}
// ═══ 2. THE DIAGNOSIS — measure the south before touching a knob ═══
GD.Print($"\n--- 2. DIAGNOSIS (calibration pool at {calibSize}, shelf on, {mid.Label} thresholds) ---");
var diag = new List<OffshoreDiagnosis.Report>();
foreach (int s in CalibrationSeeds)
{
var dCfg = BaseConfig(calibSize, s, knots, anchors, calibration, "shelf_only");
dCfg.CoastShelf = true;
Pass1Result pShelf = Topography.Generate(dCfg);
var rep = OffshoreDiagnosis.Run(pShelf.Height, pShelf.PreTrenchFalloff, calibSize, s, sea, mid.Settings, dCfg.Scale);
diag.Add(rep);
GD.Print($" seed {s,-11} N: zone {rep.North.Zone,9:N0} ({rep.North.ZoneShareOfSea,6:P1}) peaks in zone {rep.North.PeaksInZone,3} > thr {rep.North.PeaksInZoneOverThr,3} " +
$"S: zone {rep.South.Zone,9:N0} ({rep.South.ZoneShareOfSea,6:P1}) peaks in zone {rep.South.PeaksInZone,3} > thr {rep.South.PeaksInZoneOverThr,3} " +
$"sole-blocked N d/f/t {rep.North.SoleDepth:N0}/{rep.North.SoleFalloff:N0}/{rep.North.SoleTrench:N0} S {rep.South.SoleDepth:N0}/{rep.South.SoleFalloff:N0}/{rep.South.SoleTrench:N0}");
}
var (poolN, poolS) = OffshoreDiagnosis.Pool(diag);
string interpretation = OffshoreDiagnosis.Interpret(poolN, poolS);
GD.Print(" " + interpretation);
// ═══ 3. THE COUNT TABLE — ~12 seeds × 3 levels ═══
GD.Print($"\n--- 3. COUNT TABLE at {tableSize} ---");
var rows = new List<Row>();
var perFieldChecks = new List<ShapingOracle.Check>();
bool notesShown = false;
foreach (int seed in tableSeeds)
{
Pass1Result p1Off = Topography.Generate(BaseConfig(tableSize, seed, knots, anchors, calibration, "off"));
foreach (var lv in levels)
{
var cfg = BaseConfig(tableSize, seed, knots, anchors, calibration, lv.Label);
cfg.CoastShelf = true; cfg.Offshore = lv.Settings.Clone();
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result p2 = Shaping.Shape(p1, cfg);
if (!notesShown) { foreach (string n in p1.Notes) GD.Print(" " + n); notesShown = true; }
var comps = OffshoreAnalysis.Components(p1.IsIsland, p1.Height, sea, tableSize);
var (cn, cs) = OffshoreAnalysis.CountByHemisphere(comps);
var (szMin, szMed, szMean, szMax, _) = OffshoreAnalysis.SizeSummary(comps, 0);
var checks = new List<ShapingOracle.Check>
{
ShapingOracle.MoatIntact(p1, comps),
ShapingOracle.MainlandUnmoved(p1Off, p1, sea),
ShapingOracle.TagCoastlineConsistent(p2, sea),
ShapingOracle.HMaxAfterOffshore(p1),
ShapingOracle.ClassifyFidelity(p1, p2),
};
foreach (var c in checks) { c.Name += $" [{lv.Label} {seed}]"; perFieldChecks.Add(c); }
bool ok = checks.TrueForAll(c => c.Passed);
var row = new Row
{
Level = lv.Label, Seed = seed, CountN = cn, CountS = cs,
Lifted = p1.OffshoreLiftedCells, HMaxBefore = p1.HMaxSeedBeforeOffshore, HMaxAfter = p1.HMaxSeed,
SizeMin = szMin, SizeMed = szMed, SizeMean = szMean, SizeMax = szMax, Ok = ok, Ms = p1.ElapsedMs,
};
ReadGuardLedger(p1, row);
rows.Add(row);
GD.Print($" {lv.Label,-13} seed {seed,-11} N {cn,2} S {cs,2} (pre-guard N {row.PreN,2} S {row.PreS,2}; specks {row.SpecksN + row.SpecksS,2} clusters {row.ClustersN + row.ClustersS,2} blobs {row.BlobsN + row.BlobsS,2}) " +
$"size med {szMed,5} max {szMax,6} {(ok ? "ok" : " CHECK FAILED")} {p1.ElapsedMs} ms");
}
}
var stats = LevelStats(levels, rows);
GD.Print("\n per level (min / mean / max):");
foreach (var st in stats)
GD.Print($" {st.Label,-13} N {st.MinN} / {st.MeanN:F1} / {st.MaxN} S {st.MinS} / {st.MeanS:F1} / {st.MaxS} " +
$"seeds with N≥{TargetNorth} & S≥{TargetSouth}: {st.MeetBoth}/{st.Seeds} S≥N: {st.SouthAtLeastNorth}/{st.Seeds} " +
$"guards: specks {st.Specks} clusters {st.Clusters} blobs {st.Blobs}");
// ═══ 4. THE PLATES — exactly four ═══
int bulgeSeed = bulgeSeedEnv > 0 ? bulgeSeedEnv : PickBulgeSeed(rows, mid.Label, plateSeed);
GD.Print($"\n southern-bulge seed: {bulgeSeed}{(bulgeSeedEnv > 0 ? " (ISLA_BULGE_SEED)" : " (auto: sparsest south at density_mid among the table seeds)")}");
var plates = new List<(int seed, Level level)> { (plateSeed, levels[0]), (plateSeed, levels[1]), (plateSeed, levels[2]), (bulgeSeed, mid) };
var plateRows = new List<Row>();
if (!tableOnly)
{
GD.Print($"\n--- 4. PLATES at {mapSize} ---");
foreach (var (seed, lv) in plates)
{
var cfg = BaseConfig(mapSize, seed, knots, anchors, calibration, lv.Label);
cfg.CoastShelf = true; cfg.Offshore = lv.Settings.Clone();
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result p2 = Shaping.Shape(p1, cfg);
var comps = OffshoreAnalysis.Components(p1.IsIsland, p1.Height, sea, mapSize);
var (cn, cs) = OffshoreAnalysis.CountByHemisphere(comps);
var moat = ShapingOracle.MoatIntact(p1, comps); moat.Name += $" [plate {lv.Label} {seed}]";
var tag = ShapingOracle.TagCoastlineConsistent(p2, sea); tag.Name += $" [plate {lv.Label} {seed}]";
perFieldChecks.Add(moat); perFieldChecks.Add(tag);
WritePlate(batchRoot, p1, p2, sea, anchors, skipRaw, cn, cs);
var row = new Row { Level = lv.Label, Seed = seed, CountN = cn, CountS = cs, Lifted = p1.OffshoreLiftedCells, Ok = moat.Passed && tag.Passed };
ReadGuardLedger(p1, row);
plateRows.Add(row);
GD.Print($" plate {seed}_{lv.Label}: N {cn} S {cs} lifted {p1.OffshoreLiftedCells:N0} {(row.Ok ? "ok" : " CHECK FAILED")} {p1.ElapsedMs} ms");
foreach (string n in p1.Notes) if (n.Contains("guards") || n.Contains("islands:")) GD.Print(" " + n);
}
}
bool allOk = hard.TrueForAll(c => c.Passed) && perFieldChecks.TrueForAll(c => c.Passed);
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
foreach (var c in perFieldChecks) if (!c.Passed) GD.PrintErr(" " + c);
// ═══ 5. THE DATA FILES + INDEX ═══
WriteCountTable(batchRoot, tableSize, levels, rows, stats);
WriteDiagnosis(batchRoot, calibSize, mid, diag, poolN, poolS, interpretation);
WriteIndex(batchRoot, mapSize, tableSize, calibSize, plateSeed, bulgeSeed, tableSeeds, levels, rows, stats, plateRows,
diag, poolN, poolS, interpretation, hard, perFieldChecks, allOk, tableOnly);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" ORACLE {(allOk ? "HARD CHECKS ALL PASS" : "*** FAILURES see the table ***")}");
GD.Print("==================================================================");
GetTree().Quit(allOk ? 0 : 3);
}
// ---- the table's statistics -------------------------------------------
private sealed class LevelStat
{
public string Label; public int Seeds;
public int MinN, MaxN, MinS, MaxS; public double MeanN, MeanS;
public int MeetBoth, SouthAtLeastNorth, Specks, Clusters, Blobs;
public long SizeMed, SizeMax;
}
private static List<LevelStat> LevelStats(List<Level> levels, List<Row> rows)
{
var outp = new List<LevelStat>();
foreach (var lv in levels)
{
var st = new LevelStat { Label = lv.Label, MinN = int.MaxValue, MinS = int.MaxValue };
double sumN = 0, sumS = 0; var meds = new List<long>();
foreach (var r in rows)
{
if (r.Level != lv.Label) continue;
st.Seeds++;
st.MinN = Math.Min(st.MinN, r.CountN); st.MaxN = Math.Max(st.MaxN, r.CountN); sumN += r.CountN;
st.MinS = Math.Min(st.MinS, r.CountS); st.MaxS = Math.Max(st.MaxS, r.CountS); sumS += r.CountS;
if (r.CountN >= TargetNorth && r.CountS >= TargetSouth) st.MeetBoth++;
if (r.CountS >= r.CountN) st.SouthAtLeastNorth++;
st.Specks += r.SpecksN + r.SpecksS; st.Clusters += r.ClustersN + r.ClustersS; st.Blobs += r.BlobsN + r.BlobsS;
meds.Add(r.SizeMed); st.SizeMax = Math.Max(st.SizeMax, r.SizeMax);
}
if (st.Seeds == 0) { st.MinN = st.MinS = 0; }
else { st.MeanN = sumN / st.Seeds; st.MeanS = sumS / st.Seeds; meds.Sort(); st.SizeMed = meds[meds.Count / 2]; }
outp.Add(st);
}
return outp;
}
/// <summary>The table seed whose SOUTH count at the preset level is lowest (ties → lower total, then first in the pool), excluding the plate seed.</summary>
private static int PickBulgeSeed(List<Row> rows, string midLabel, int plateSeed)
{
int best = 0, bestS = int.MaxValue, bestTotal = int.MaxValue;
foreach (var r in rows)
{
if (r.Level != midLabel || r.Seed == plateSeed) continue;
int total = r.CountN + r.CountS;
if (r.CountS < bestS || (r.CountS == bestS && total < bestTotal)) { best = r.Seed; bestS = r.CountS; bestTotal = total; }
}
return best == 0 ? plateSeed : best;
}
/// <summary>Copy the pass's guard ledger into a table row (the pass reports it; the tool does not recompute it).</summary>
private static void ReadGuardLedger(Pass1Result p1, Row row)
{
var l = p1.OffshoreLedger;
if (l == null) return;
row.PreN = l.PreGuardNorth; row.PreS = l.PreGuardSouth;
row.SpecksN = l.SpecksNorth; row.SpecksS = l.SpecksSouth;
row.ClustersN = l.ClustersNorth; row.ClustersS = l.ClustersSouth;
row.BlobsN = l.BlobsNorth; row.BlobsS = l.BlobsSouth;
}
// ---- the curve, measured exactly as tasks 03/04/05 did ----------------
private static (CurveKnots, ClimbCalibration, Dictionary<int, Pass1Result>) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
{
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s }); // offshore OFF by default
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
var knots = new CurveKnots(2, "v2_balanced",
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
};
Pass2Result st = Shaping.Shape(pass1[s], scfg);
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
}
var pcts = ClimbCalibration.DefaultPercentiles;
var rawQ = new float[pcts.Length];
var outQ = new float[pcts.Length];
for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
var cal = ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f);
return (knots, cal, pass1);
}
private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a,
ClimbCalibration cal, string label) => new TerrainGenConfig
{
MapSize = mapSize, Seed = seed, VariantLabel = label,
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = k, Anchors = a, ClimbCalibration = cal, LowlandCeilingM = 30f,
CoastShelf = false, Offshore = new OffshoreSettings(), // OFF unless the variant turns it on
};
// ---- output -----------------------------------------------------------
private static void WritePlate(string batchRoot, Pass1Result p1, Pass2Result p2, float sea,
CurveAnchors anchors, bool skipRaw, int countN, int countS)
{
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
DirAccess.MakeDirRecursiveAbsolute(dir);
GrayscaleRenderer.SavePng(p2.Height, p2.MapSize, Path.Combine(dir, "grayscale.png"));
if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
var look = new LookConfig
{
Name = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f,
HillshadeStrength = 0.30f, SeaLevel = sea,
};
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, $"{p2.VariantLabel.ToUpperInvariant()} {p2.Seed}")
.SavePng(Path.Combine(dir, "relief.png"));
// ⭐ The tag overlay — the one artifact that shows the DATA this pass set.
TagOverlayRenderer.SavePng(p2.Height, p2.IsIsland, p2.IslandHemisphere, p2.MapSize, sea,
countN, countS, Path.Combine(dir, "tags.png"));
}
private static string CountTableMarkdown(List<Level> levels, List<Row> rows, List<LevelStat> stats)
{
var sb = new StringBuilder();
sb.AppendLine("| Level | Seed | **N** | **S** | total | pre-guard N / S | specks | clusters | blobs | size cells min / median / mean / max | lifted cells | HMaxSeed before → after | oracle | ms |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|");
foreach (var lv in levels)
foreach (var r in rows)
{
if (r.Level != lv.Label) continue;
sb.AppendLine($"| `{r.Level}` | `{r.Seed}` | **{r.CountN}** | **{r.CountS}** | {r.CountN + r.CountS} | {r.PreN} / {r.PreS} | " +
$"{r.SpecksN + r.SpecksS} | {r.ClustersN + r.ClustersS} | {r.BlobsN + r.BlobsS} | " +
$"{r.SizeMin} / {r.SizeMed} / {r.SizeMean:F0} / {r.SizeMax} | {r.Lifted:N0} | " +
$"{r.HMaxBefore:F4} → {r.HMaxAfter:F4}{(r.HMaxBefore != r.HMaxAfter ? " " : "")} | {(r.Ok ? "pass" : "**FAIL**")} | {r.Ms} |");
}
sb.AppendLine();
sb.AppendLine("**Per level — the consistency read (min / mean / max over the seeds):**");
sb.AppendLine();
sb.AppendLine($"| Level | density N / S | seeds | **N min / mean / max** | **S min / mean / max** | seeds with N ≥ {TargetNorth} & S ≥ {TargetSouth} | seeds with S ≥ N | specks / clusters / blobs reverted | median island (cells) | largest island (cells) |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|");
foreach (var st in stats)
{
var lv = levels.Find(l => l.Label == st.Label);
sb.AppendLine($"| `{st.Label}` | {lv.Settings.Density:F4} / {lv.Settings.DensitySouth:F4} | {st.Seeds} | **{st.MinN} / {st.MeanN:F1} / {st.MaxN}** | **{st.MinS} / {st.MeanS:F1} / {st.MaxS}** | " +
$"**{st.MeetBoth} / {st.Seeds}** | {st.SouthAtLeastNorth} / {st.Seeds} | {st.Specks} / {st.Clusters} / {st.Blobs} | {st.SizeMed} | {st.SizeMax} |");
}
return sb.ToString();
}
private static void WriteCountTable(string batchRoot, int tableSize, List<Level> levels, List<Row> rows, List<LevelStat> stats)
{
var sb = new StringBuilder();
sb.AppendLine($"# The count table — {rows.Count / Math.Max(1, levels.Count)} seeds × {levels.Count} density levels at {tableSize}");
sb.AppendLine();
sb.AppendLine("Tagged 8-connected island components per hemisphere (centroid), after the guards. **No count is forced**;");
sb.AppendLine("this is the statistical outcome of the tuning. NORTH = rows [0, N/2), SOUTH = rows [N/2, N); y runs south.");
sb.AppendLine();
sb.Append(CountTableMarkdown(levels, rows, stats));
WriteText(Path.Combine(batchRoot, "count_table.md"), sb.ToString());
var csv = new StringBuilder();
csv.AppendLine("level,seed,density_n,density_s,north,south,total,preguard_n,preguard_s,specks,clusters,blobs,size_min,size_median,size_mean,size_max,lifted_cells,hmax_before,hmax_after,oracle,ms");
foreach (var r in rows)
{
var lv = levels.Find(l => l.Label == r.Level);
csv.AppendLine(string.Join(",", r.Level, r.Seed, lv.Settings.Density.ToString("F5", System.Globalization.CultureInfo.InvariantCulture),
lv.Settings.DensitySouth.ToString("F5", System.Globalization.CultureInfo.InvariantCulture),
r.CountN, r.CountS, r.CountN + r.CountS, r.PreN, r.PreS, r.SpecksN + r.SpecksS, r.ClustersN + r.ClustersS, r.BlobsN + r.BlobsS,
r.SizeMin, r.SizeMed, r.SizeMean.ToString("F1", System.Globalization.CultureInfo.InvariantCulture), r.SizeMax, r.Lifted,
r.HMaxBefore.ToString("G9", System.Globalization.CultureInfo.InvariantCulture), r.HMaxAfter.ToString("G9", System.Globalization.CultureInfo.InvariantCulture),
r.Ok ? "pass" : "FAIL", r.Ms));
}
WriteText(Path.Combine(batchRoot, "count_table.csv"), csv.ToString());
}
private static string DiagnosisMarkdown(int calibSize, Level mid, List<OffshoreDiagnosis.Report> diag,
HemisphereDiagnosis poolN, HemisphereDiagnosis poolS, string interpretation)
{
var sb = new StringBuilder();
sb.AppendLine($"Measured on the calibration pool at {calibSize} (shelf on, offshore off — the field the islet layer sees), with the");
sb.AppendLine($"`{mid.Label}` thresholds (density N {mid.Settings.Density:F4} / S {mid.Settings.DensitySouth:F4}). *Zone* = cells with zone weight > 0");
sb.AppendLine("(pass the falloff test + moat + outer bound); *peaks* = strict 8-neighbour local maxima of the islet noise field on sea cells;");
sb.AppendLine("*sole blocker* = sea cells that fail exactly one gate (loosen that gate and they join the zone); *lost > thr* = over-threshold");
sb.AppendLine("peaks outside the zone, with the gate(s) that excluded them.");
sb.AppendLine();
sb.AppendLine(OffshoreDiagnosis.TableHeader());
foreach (var r in diag)
{
sb.AppendLine(OffshoreDiagnosis.TableRow(r, r.North));
sb.AppendLine(OffshoreDiagnosis.TableRow(r, r.South));
}
var poolRep = new OffshoreDiagnosis.Report { Seed = 0 };
sb.AppendLine(OffshoreDiagnosis.TableRow(poolRep, poolN).Replace("| `0` |", "| **pool** |"));
sb.AppendLine(OffshoreDiagnosis.TableRow(poolRep, poolS).Replace("| `0` |", "| **pool** |"));
sb.AppendLine();
sb.AppendLine($"**Reading:** {interpretation}");
return sb.ToString();
}
private static void WriteDiagnosis(string batchRoot, int calibSize, Level mid, List<OffshoreDiagnosis.Report> diag,
HemisphereDiagnosis poolN, HemisphereDiagnosis poolS, string interpretation)
{
var sb = new StringBuilder();
sb.AppendLine("# The south diagnosis — zone area, gates and peaks per hemisphere");
sb.AppendLine();
sb.Append(DiagnosisMarkdown(calibSize, mid, diag, poolN, poolS, interpretation));
WriteText(Path.Combine(batchRoot, "diagnosis.md"), sb.ToString());
}
private static void WriteIndex(string batchRoot, int mapSize, int tableSize, int calibSize, int plateSeed, int bulgeSeed,
int[] tableSeeds, List<Level> levels, List<Row> rows, List<LevelStat> stats, List<Row> plateRows,
List<OffshoreDiagnosis.Report> diag, HemisphereDiagnosis poolN, HemisphereDiagnosis poolS, string interpretation,
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perField, bool allOk, bool tableOnly)
{
var mid = levels[1];
var sb = new StringBuilder();
sb.AppendLine("# Batch 06 — offshore islands: organic-only, tuned for coverage, south-weighted, no forced count");
sb.AppendLine();
sb.AppendLine("The chat2/05 forced floor (seeded stamps, guaranteed ≥2 N / ≥4 S) is **reverted out** — it looked stamped.");
sb.AppendLine("The **organic noise-field layer is the only island mechanism**; this batch tunes its **density** (the main");
sb.AppendLine("knob) and a **south weight** so it yields *a few south / a couple north* **consistently across seeds, as a");
sb.AppendLine("statistical outcome** — never a hard-coded count. Guards against slop: specks, clusters and blobs are");
sb.AppendLine("reverted whole; every surviving island is a noise outline, small, low, crisp.");
sb.AppendLine();
if (tableOnly) sb.AppendLine("> ⚠ **ISLA_TABLE_ONLY** — a probe run: diagnosis + count table only, no regressions, no plates. Not the batch of record.\n");
sb.AppendLine("## ⭐ Open this first");
sb.AppendLine();
sb.AppendLine($"1. **`{plateSeed}_density_mid/tags.png`** — the preset of record: grey mainland, cyan = island N, orange = island S.");
sb.AppendLine(" No rings any more — nothing is seeded. Then its `relief.png` for the shape.");
sb.AppendLine($"2. **`{plateSeed}_density_low/`** and **`{plateSeed}_density_high/`** beside it — the same seed, less and more density;");
sb.AppendLine(" pick the look by eye (more islands vs slop).");
sb.AppendLine($"3. **`{bulgeSeed}_density_mid/tags.png`** — the preset on the southern-bulge seed (the table seed whose south was");
sb.AppendLine(" sparsest at `density_mid`): the south tuning is not seed-specific.");
sb.AppendLine("4. Then the count table below — *does it consistently give a few south / a couple north?*");
sb.AppendLine();
sb.AppendLine($"**Hemisphere convention (from the code, not invented):** y runs SOUTH. NORTH = rows `[0, {mapSize / 2})`,");
sb.AppendLine($"SOUTH = rows `[{mapSize / 2}, {mapSize})`. Component hemisphere is by centroid; the tag per cell is by row.");
sb.AppendLine();
sb.AppendLine("## The four plates");
sb.AppendLine();
sb.AppendLine("| Plate | N | S | total | pre-guard N / S | specks / clusters / blobs | lifted cells | oracle |");
sb.AppendLine("|---|---|---|---|---|---|---|---|");
foreach (var r in plateRows)
sb.AppendLine($"| `{r.Seed}_{r.Level}/` | **{r.CountN}** | **{r.CountS}** | {r.CountN + r.CountS} | {r.PreN} / {r.PreS} | {r.SpecksN + r.SpecksS} / {r.ClustersN + r.ClustersS} / {r.BlobsN + r.BlobsS} | {r.Lifted:N0} | {(r.Ok ? "pass" : "**FAIL**")} |");
if (plateRows.Count == 0) sb.AppendLine("| *(no plates — probe run)* | | | | | | | |");
sb.AppendLine();
sb.AppendLine($"## ⭐ The count table — {tableSeeds.Length} seeds × 3 levels at {tableSize} (the consistency evidence)");
sb.AppendLine();
sb.Append(CountTableMarkdown(levels, rows, stats));
sb.AppendLine();
sb.AppendLine("Also as plain data: `count_table.md` / `count_table.csv`.");
sb.AppendLine();
sb.AppendLine("## The levels — density and south weight");
sb.AppendLine();
sb.AppendLine("| Level | settings |");
sb.AppendLine("|---|---|");
foreach (var lv in levels) sb.AppendLine($"| `{lv.Label}`{(lv == mid ? " preset of record" : "")} | {lv.Settings.Describe()} |");
sb.AppendLine();
sb.AppendLine("The coast shelf is ON for every field (strength 0.775, scale 100 m, BitDecrement-clamped); invisible on these");
sb.AppendLine("hypsometric plates — ported faithfully, judged when water renders. Moat / falloff test / outer bound unchanged from chat2/05.");
sb.AppendLine();
sb.AppendLine("## The south diagnosis — measured before tuning");
sb.AppendLine();
sb.Append(DiagnosisMarkdown(calibSize, mid, diag, poolN, poolS, interpretation));
sb.AppendLine();
sb.AppendLine("Also as `diagnosis.md`.");
sb.AppendLine();
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
sb.AppendLine();
sb.AppendLine("`ProvisionalEven`, flagged. Grayscale + `tags.png` are the honest instruments here.");
sb.AppendLine();
sb.AppendLine("## The oracle");
sb.AppendLine();
sb.AppendLine("Regressions (offshore OFF must be bit-identical to Phase 1, task 03 and the `terrain-curve-v1` gallery dump):");
sb.AppendLine();
sb.AppendLine(hard.Count == 0 ? "*(skipped — probe run)*\n" : ShapingOracle.ToMarkdownTable(hard));
sb.AppendLine("Per field (moat i · mainland unmoved j · tag/coastline k · HMaxSeed l · classify b):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(perField));
sb.AppendLine($"**{(allOk ? "ALL HARD CHECKS PASS" : " FAILURES do not judge this batch")}**");
sb.AppendLine();
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `tags.png`, `relief.png`, `INDEX.md`, `count_table.md` / `.csv`, `diagnosis.md` | **keep** |");
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + code — large, clear freely |");
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
sb.AppendLine();
sb.AppendLine($"Plates at {mapSize}, table at {tableSize}, curve calibrated at {calibSize} with offshore off. {WorldScale.Describe()}.");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
private static void WriteText(string path, string text)
{
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(text);
}
// ---- env helpers --------------------------------------------------------
private static string EnvStr(string k, string fallback)
{
string v = System.Environment.GetEnvironmentVariable(k);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}
private static int EnvInt(string k, int fallback)
=> int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
private static float EnvFloat(string k, float fallback)
=> float.TryParse(EnvStr(k, null) ?? "", System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out float v) ? v : fallback;
private static int[] EnvSeeds(string k, int[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<int>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
}
}

View file

@ -0,0 +1 @@
uid://bvrfybmk7pvyn

View file

@ -0,0 +1,404 @@
using System;
using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ PASS 1b — THE COAST SHELF AND THE OFFSHORE ISLETS (chat2/05, retuned chat2/06). Runs over
/// the finished pass-1 arrays, IN PLACE, before <c>HMaxSeed</c> is taken and before anything
/// classifies.
///
/// ═══ WHERE THIS SITS, AND WHY IT IS A SECOND SWEEP ═══
///
/// The reference did all of this INSIDE the pass-1 pixel loop (<c>MapGenerator.cs:621-664</c>):
/// combine → shelf → islets → <c>_hMaxSeed</c> → write. v2 runs the same arithmetic as a second
/// sweep over the arrays the first loop produced. Per pixel the inputs are identical — the raw
/// height, the sea level, the pre-Trench falloff, (x, y) — and the operations are applied in the
/// same order on the same floats, so the FAITHFUL mode reproduces the reference cell for cell.
/// The second sweep is also what lets the slop guards see whole islands: a component rule needs
/// the finished field.
///
/// ⚠⚠ THE ORDERING THAT CLOSES chat2/00 DRIFT §2: the caller recomputes <c>HMaxSeed</c> AFTER this
/// pass, as the reference did, so the curve's per-seed peak normalization sees the same maximum
/// the reference saw. Expected to be unchanged (a ~34 m crest is far below any peak) — reported,
/// not assumed.
///
/// ═══ THE SUB-PASSES ═══
///
/// 1. SHELF every below-sea cell; depth-preserving; held strictly below sea by BitDecrement.
/// 2. ORGANIC ⭐ THE ONE ISLAND MECHANISM — the reference's noise layer, faithful or reshaped
/// (smaller / lower / flatter / crisper, density + south weight), every below-sea
/// cell inside the zone mask. Nothing places an island from a centre; nothing
/// guarantees a count. (chat2/05's seeded floor was reverted out in chat2/06 — it
/// looked stamped. It lives in git history.)
/// 3. GUARDS (Organic only) specks, clusters and blobs reverted BY COMPONENT, then the
/// reference's submerged humps outside any surviving island's skirt.
///
/// ═══ THE TWO PROTECTIONS ═══
///
/// The moat (min depth) and the "actually offshore" test (pre-Trench falloff) gate the organic
/// layer per pixel — that is the reference. Nothing in this pass can raise a cell outside the
/// zone, so no island can bridge to shore or appear in a lake / the crater bay.
///
/// ═══ ⚠ THE ONE HONEST COUPLING ═══
///
/// Unlike the curve, this pass TURNS WATER INTO LAND. New land is new classification downstream
/// (biome/water pixels that did not exist before). That is precisely why it runs here, in the base
/// shape, before anything classifies: change an island dial, regenerate, and classification
/// re-runs consistently. Known property, stated in code, not a surprise.
///
/// Every cell lifted from below sea to at/above sea is TAGGED (<c>Result.Tag</c>) with its
/// hemisphere (<c>Result.Hemi</c>). That tag is data the shape pass sets and carries; nothing in
/// this phase reads it. → <see cref="OffshoreAnalysis"/> for the hemisphere convention.
/// </summary>
public static class OffshorePass
{
public sealed class Result
{
public bool[,] Tag; // null when the islet layer is off (shelf only)
public byte[,] Hemi;
public long ShelfCells, LiftedOrganic;
public float ThresholdNorth = float.NaN, ThresholdSouth = float.NaN;
public List<string> Notes = new();
public int CountNorth, CountSouth, Bridged;
// ---- the guards' ledger (Organic only) ----
public int PreGuardNorth, PreGuardSouth; // islands before any guard
public int SpecksNorth, SpecksSouth; // reverted as specks
public int ClustersNorth, ClustersSouth; // reverted as too-close-to-a-larger-island
public int BlobsNorth, BlobsSouth; // reverted as oversize
public long LiftedReverted; // surfaced cells the guards put back
public long RaisedReverted; // submerged bump cells the guard put back
internal List<(int x, int y, float h0)> LiftedOrigin = new(); // every SURFACED lift
internal List<(int x, int y, float h0)> RaisedOrigin = new(); // every organic raise, surfaced or not (guards on)
public OffshoreLedger ToLedger() => Tag == null ? null : new OffshoreLedger
{
ThresholdNorth = ThresholdNorth, ThresholdSouth = ThresholdSouth,
PreGuardNorth = PreGuardNorth, PreGuardSouth = PreGuardSouth,
SpecksNorth = SpecksNorth, SpecksSouth = SpecksSouth,
ClustersNorth = ClustersNorth, ClustersSouth = ClustersSouth,
BlobsNorth = BlobsNorth, BlobsSouth = BlobsSouth,
LiftedOrganic = LiftedOrganic, LiftedReverted = LiftedReverted, RaisedReverted = RaisedReverted,
CountNorth = CountNorth, CountSouth = CountSouth,
};
}
/// <summary>
/// Apply the shelf and/or islets to <paramref name="height"/> IN PLACE. Returns null when
/// both are off (nothing touched, nothing allocated).
/// </summary>
public static Result Apply(float[,] height, float[,] preTrench, int mapSize, int seed,
float sea, TerrainGenConfig cfg)
{
OffshoreSettings s = cfg.Offshore;
bool shelfOn = cfg.CoastShelf;
bool isletsOn = s != null && s.Mode != OffshoreMode.Off;
if (!shelfOn && !isletsOn) return null;
var r = new Result();
GenerationScale scale = cfg.Scale;
// ⚠ MAP-anchored, exactly as the reference: `center = MapSize / 2.0f`, `distX = |x cx| / (MapSize / 2.0f)`.
float centerX = mapSize / 2.0f, centerY = mapSize / 2.0f;
float halfSpan = mapSize / 2.0f;
// ═══ 1. THE COAST SHELF ═══
if (shelfOn)
{
// ⭐ THE CLAMP THAT MAKES "CANNOT MOVE THE WATERLINE" EXACT. Reference: the remap is
// strictly positive on positive depth, so in exact arithmetic the waterline cannot
// move; in float32 it can — a pixel a few microns under water rounds back up to
// exactly sea and `h < sea` then calls it land. That cost 5 px of 67 M on the
// reference's first batch. Hold the result strictly below sea and the invariant is
// exact. Do not port this without the clamp.
float strictlyBelowSea = MathF.BitDecrement(sea);
for (int x = 0; x < mapSize; x++)
{
for (int y = 0; y < mapSize; y++)
{
float h = height[x, y];
if (h >= sea) continue; // below-sea ONLY
float depthM = WorldScale.MetresFromRaw(sea - h); // (seaHere finalH) * 251f
height[x, y] = MathF.Min(
sea - WorldScale.RawFromMetres(IslandFalloff.CoastShelf(depthM, cfg.ShelfStrength, cfg.ShelfScaleM)),
strictlyBelowSea);
r.ShelfCells++;
}
}
r.Notes.Add($"[Offshore] coast shelf: {r.ShelfCells:N0} below-sea cells remapped " +
$"(strength {cfg.ShelfStrength:F3}, scale {cfg.ShelfScaleM:F0} m) — held strictly below sea.");
}
if (!isletsOn) return r;
r.Tag = new bool[mapSize, mapSize];
r.Hemi = new byte[mapSize, mapSize];
bool faithful = s.Mode == OffshoreMode.Faithful;
bool guardsOn = !faithful && (s.MinIslandAreaFrac > 0f || s.MinSeparationFrac > 0f || s.MaxIslandAreaFrac > 0f);
float crest = sea + WorldScale.RawFromMetres(s.CrestM); // seaHere + OFFSHORE_ISLAND_H_M / 251f
// ═══ 2. THE ORGANIC LAYER — the one island mechanism ═══
{
FastNoiseLite noise = TerrainNoise.CreateModulation(seed, s.SeedOffset, s.FreqPerMapWidth, scale);
// ⭐ Calibrate against the field's ACTUAL distribution, not the theoretical [1,1]:
// sample on a stride grid and take the quantile. Deterministic from the seed, and it
// makes the density dial mean what it says whatever FastNoiseLite's range turns out
// to be. Verbatim: stride 8, (side)² samples, (noise + 1) * 0.5.
float[] samples = CalibrationSamples(noise, mapSize);
float thrN = IslandFalloff.CalibrateThreshold(samples, s.Density);
float thrS = faithful ? thrN : IslandFalloff.CalibrateThreshold(samples, s.DensitySouth);
r.ThresholdNorth = thrN; r.ThresholdSouth = thrS;
float mid = mapSize * 0.5f;
float band = MathF.Max(1f, s.HemisphereBlendHalfWidth * mapSize);
for (int x = 0; x < mapSize; x++)
{
for (int y = 0; y < mapSize; y++)
{
float h = height[x, y];
if (h >= sea) continue; // below-sea ONLY — never touches land
float ambientDepthM = WorldScale.MetresFromRaw(sea - h);
float zone = IslandFalloff.OffshoreZoneWeight(
ambientDepthM, preTrench[x, y],
MathF.Abs(x - centerX) / halfSpan, MathF.Abs(y - centerY) / halfSpan,
s.MinDepthM, s.DepthFeatherM, s.MinFalloff, s.FalloffFeather,
s.TrenchInner, s.TrenchOuter);
if (zone <= 0f) continue;
float v = (noise.GetNoise2D(x, y) + 1.0f) * 0.5f;
// The threshold: one number in faithful mode; north/south blended smoothly
// across the midline in the reshape, so a straddling island is not sliced.
float thr = faithful ? thrN : BlendedThreshold(y, mid, band, thrN, thrS);
float blob = (faithful
? IslandFalloff.OffshoreBlob(v, thr)
: IslandFalloff.RigidBlob(v, thr, s.CoreFraction, s.EdgeSharpness)) * zone;
if (blob <= 0f) continue;
// ⭐ LERP TOWARD THE CREST, never add — surfaces at any ambient depth.
float before = h;
h = h + (crest - h) * blob; // Mathf.Lerp, written out
height[x, y] = h;
if (guardsOn) r.RaisedOrigin.Add((x, y, before)); // for the submerged-bump guard
if (before < sea && h >= sea)
{
r.LiftedOrganic++;
r.Tag[x, y] = true;
r.Hemi[x, y] = OffshoreAnalysis.HemisphereOfRow(y, mapSize);
r.LiftedOrigin.Add((x, y, before));
}
}
}
r.Notes.Add($"[Offshore] organic ({(faithful ? "faithful" : "reshaped")}): threshold N {thrN:F4}" +
$"{(faithful ? "" : $" S {thrS:F4}")} from {samples.Length:N0} samples " +
$"(range {Min(samples):F3}..{Max(samples):F3}); lifted {r.LiftedOrganic:N0} cells above sea.");
}
// ═══ 3. THE SLOP GUARDS — reshape only; over everything the pass raised ═══
//
// Four kinds of slop, three component rules and one location rule:
// (a) SPECKS a local maximum that barely clears the threshold surfaces a cap of a few
// cells — not an island. Reverted by MEMBERSHIP (a speck inside a real
// island's skirt is still a speck).
// (b) BLOBS a superlevel region that merged several maxima into one sprawling
// landmass. Reverted by membership. The cap sits well above the natural
// size so it is a net, not a sculptor; how often it bites is reported.
// (c) CLUSTERS two islands whose shores are closer than the minimum separation read as
// one; the SMALLER goes (greedy by size, so the largest of a cluster stays).
// (d) SUBMERGED BUMPS — every blob with any weight lerps the seabed toward the crest
// whether or not it surfaces, so the ocean fills with shallow humps (the
// reference's character; the water pass would draw a reef field nobody
// asked for). Attributed by HUMP: a hump (connected raised region) that
// holds a kept island is that island's own skirt and stays; one that holds
// none goes back to seabed; a dropped island's own cap goes too.
// None of these places, shapes or counts anything. (The faithful control keeps all four;
// that is the reference.)
if (guardsOn && (r.RaisedOrigin.Count > 0 || r.LiftedOrigin.Count > 0))
{
double area = (double)mapSize * mapSize;
long minCells = s.MinIslandAreaFrac > 0f ? Math.Max(1L, (long)Math.Round(s.MinIslandAreaFrac * area)) : 0;
long maxCells = s.MaxIslandAreaFrac > 0f ? Math.Max(1L, (long)Math.Round(s.MaxIslandAreaFrac * area)) : long.MaxValue;
int minSep = s.MinSeparationFrac > 0f ? Math.Max(1, (int)Math.Round(s.MinSeparationFrac * mapSize)) : 0;
var pre = OffshoreAnalysis.Components(r.Tag, height, sea, mapSize, out int[] compId);
(r.PreGuardNorth, r.PreGuardSouth) = OffshoreAnalysis.CountByHemisphere(pre);
var dropped = new HashSet<int>();
var byId = new Dictionary<int, IslandComponent>();
foreach (var c in pre) byId[c.Id] = c;
// (a) specks and (b) blobs — by size.
foreach (var c in pre)
{
if (c.Cells < minCells) { dropped.Add(c.Id); Bump(r, c.Hemisphere, ref r.SpecksNorth, ref r.SpecksSouth); }
else if (c.Cells > maxCells) { dropped.Add(c.Id); Bump(r, c.Hemisphere, ref r.BlobsNorth, ref r.BlobsSouth); }
}
// (c) clusters — greedy by size among the survivors of (a)/(b).
if (minSep > 0)
{
var survivors = new List<IslandComponent>();
foreach (var c in pre) if (!dropped.Contains(c.Id)) survivors.Add(c);
survivors.Sort((a, b) => b.Cells.CompareTo(a.Cells)); // largest first
var boundary = OffshoreAnalysis.BoundaryCells(r.Tag, compId, mapSize, r.LiftedOrigin);
var kept = new List<IslandComponent>();
foreach (var c in survivors)
{
bool tooClose = false;
foreach (var k in kept)
{
if (OffshoreAnalysis.BoxGap(c, k) >= minSep) continue; // cannot be closer than the bbox gap
if (OffshoreAnalysis.MinChebyshev(boundary[c.Id], boundary[k.Id], minSep) < minSep) { tooClose = true; break; }
}
if (tooClose) { dropped.Add(c.Id); Bump(r, c.Hemisphere, ref r.ClustersNorth, ref r.ClustersSouth); }
else kept.Add(c);
}
}
// (d) THE SUBMERGED BUMPS — by HUMP, not by box. A hump is one 8-connected region of
// raised cells (the blob's footprint, surfaced or not). A hump that holds a kept island
// is that island's own skirt and stays whole; a hump that holds none is a reef nobody
// asked for and goes back to seabed whole. A DROPPED island's own cap — its bbox plus a
// margin — is reverted even inside a kept hump, or its rim would stay as a hollow ring
// beside its neighbour (the first probe plate showed exactly those ghost outlines).
var raised = new bool[mapSize, mapSize];
foreach (var (x, y, _) in r.RaisedOrigin) raised[x, y] = true;
var humpId = new int[mapSize * mapSize];
var humpKept = new List<bool> { false }; // index 0 unused
{
var stack = new Stack<int>();
foreach (var (sx, sy, _) in r.RaisedOrigin)
{
if (humpId[sx * mapSize + sy] != 0) continue;
int id = humpKept.Count; humpKept.Add(false);
humpId[sx * mapSize + sy] = id; stack.Push(sx * mapSize + sy);
bool kept = false;
while (stack.Count > 0)
{
int cur = stack.Pop(); int cx = cur / mapSize, cy = cur % mapSize;
if (r.Tag[cx, cy] && !dropped.Contains(compId[cur])) kept = true;
for (int dx = -1; dx <= 1; dx++)
{
int nx = cx + dx; if (nx < 0 || nx >= mapSize) continue;
for (int dy = -1; dy <= 1; dy++)
{
int ny = cy + dy; if (ny < 0 || ny >= mapSize || (dx == 0 && dy == 0)) continue;
if (!raised[nx, ny]) continue;
int ni = nx * mapSize + ny;
if (humpId[ni] != 0) continue;
humpId[ni] = id; stack.Push(ni);
}
}
}
humpKept[id] = kept;
}
}
var dropBoxes = new List<(int x0, int y0, int x1, int y1)>();
foreach (var c in pre)
{
if (!dropped.Contains(c.Id)) continue;
int w = c.MaxX - c.MinX + 1, hgt = c.MaxY - c.MinY + 1;
int margin = Math.Max(4, Math.Max(w, hgt) / 2);
dropBoxes.Add((c.MinX - margin, c.MinY - margin, c.MaxX + margin, c.MaxY + margin));
}
bool InDropBox(int x, int y)
{
foreach (var (x0, y0, x1, y1) in dropBoxes)
if (x >= x0 && x <= x1 && y >= y0 && y <= y1) return true;
return false;
}
long revertedLifted = 0, revertedRaised = 0;
foreach (var (x, y, h0) in r.LiftedOrigin)
{
if (!dropped.Contains(compId[x * mapSize + y])) continue;
height[x, y] = h0; r.Tag[x, y] = false; r.Hemi[x, y] = OffshoreAnalysis.HemiNone;
revertedLifted++;
}
foreach (var (x, y, h0) in r.RaisedOrigin)
{
if (r.Tag[x, y]) continue; // a kept island's own surfaced cell
bool keepIt = humpKept[humpId[x * mapSize + y]] && !InDropBox(x, y);
if (keepIt) continue;
if (height[x, y] > h0) { height[x, y] = h0; revertedRaised++; }
}
r.LiftedReverted = revertedLifted;
r.RaisedReverted = revertedRaised;
r.Notes.Add($"[Offshore] guards: of {pre.Count} islands (N {r.PreGuardNorth} / S {r.PreGuardSouth}) reverted " +
$"{r.SpecksNorth + r.SpecksSouth} specks (< {minCells:N0} cells), " +
$"{r.ClustersNorth + r.ClustersSouth} clustered (< {minSep} px from a larger island), " +
$"{r.BlobsNorth + r.BlobsSouth} blobs (> {(maxCells == long.MaxValue ? "" : maxCells.ToString("N0"))} cells) " +
$"— {revertedLifted:N0} surfaced cells, plus {revertedRaised:N0} submerged bump cells outside any kept island's hump.");
}
// ═══ PROVE THE MOAT ON THE FINISHED FIELD — in the pass, before anyone looks ═══
var comps = OffshoreAnalysis.Components(r.Tag, height, sea, mapSize);
(r.CountNorth, r.CountSouth) = OffshoreAnalysis.CountByHemisphere(comps);
r.Bridged = OffshoreAnalysis.BridgedCount(comps);
var (szMin, szMed, szMean, szMax, _) = OffshoreAnalysis.SizeSummary(comps, 0);
r.Notes.Add($"[Offshore] islands: {r.CountNorth} north, {r.CountSouth} south ({comps.Count} components, " +
$"{r.Bridged} bridged to mainland); lifted {r.LiftedOrganic - r.LiftedReverted:N0} cells net" +
$"{(r.RaisedReverted > 0 ? $", {r.RaisedReverted:N0} submerged debris cells reverted" : "")}; " +
$"size cells min {szMin} median {szMed} mean {szMean:F0} max {szMax}.");
if (r.Bridged > 0)
throw new InvalidOperationException(
$"[OffshorePass] MOAT VIOLATION: {r.Bridged} island(s) touch mainland land. Refusing.");
return r;
}
/// <summary>The reference's calibration sample: stride 8, (side)² samples, (noise + 1) · 0.5. Shared with the diagnosis.</summary>
public static float[] CalibrationSamples(FastNoiseLite noise, int mapSize)
{
const int stride = 8;
int side = mapSize / stride;
var samples = new float[side * side];
for (int i = 0; i < side; i++)
for (int j = 0; j < side; j++)
samples[i * side + j] = (noise.GetNoise2D(i * stride, j * stride) + 1f) * 0.5f;
return samples;
}
/// <summary>The north/south threshold, smoothstep-blended across the midline. Shared with the diagnosis.</summary>
public static float BlendedThreshold(int y, float mid, float band, float thrN, float thrS)
{
float t = Math.Clamp((y - mid) / band * 0.5f + 0.5f, 0f, 1f);
t = t * t * (3f - 2f * t);
return thrN + (thrS - thrN) * t;
}
private static void Bump(Result r, byte hemi, ref int north, ref int south)
{
if (hemi == OffshoreAnalysis.HemiNorth) north++; else south++;
}
private static float Min(float[] a) { float m = float.MaxValue; foreach (float v in a) if (v < m) m = v; return m; }
private static float Max(float[] a) { float m = float.MinValue; foreach (float v in a) if (v > m) m = v; return m; }
}
/// <summary>
/// The islet layer's ledger, carried on <c>Pass1Result</c> for the report: thresholds, the
/// pre-guard island count, what each guard reverted, the final count. Numbers only — the tag
/// arrays are carried separately.
/// </summary>
public sealed class OffshoreLedger
{
public float ThresholdNorth, ThresholdSouth;
public int PreGuardNorth, PreGuardSouth;
public int SpecksNorth, SpecksSouth, ClustersNorth, ClustersSouth, BlobsNorth, BlobsSouth;
public long LiftedOrganic, LiftedReverted, RaisedReverted;
public int CountNorth, CountSouth;
}
}

View file

@ -0,0 +1 @@
uid://cseifxt2bd820

View file

@ -0,0 +1,220 @@
using System.Text;
namespace IslaApocalypse.Tools
{
/// <summary>Which offshore-islet system pass 1 runs. → <see cref="OffshoreSettings"/>.</summary>
public enum OffshoreMode
{
/// <summary>No islets. The shelf is gated separately (<c>TerrainGenConfig.CoastShelf</c>).</summary>
Off,
/// <summary>
/// ⭐ chat2/05 stage 1 — the reference's probabilistic layer, verbatim: one noise field, one
/// calibrated threshold, the faithful blob, the faithful zone mask. Sparse, no corners, no
/// guards. THE CONTROL.
/// </summary>
Faithful,
/// <summary>
/// ⭐ chat2/06 — THE ONE ISLAND MECHANISM: the organic noise-field layer, reshaped small /
/// low / flat / crisp (chat2/05 stage 2's shape, unchanged), tuned for coverage by DENSITY
/// and a SOUTH WEIGHT, with the speck / separation / blob guards. No seeded floor, no
/// stamps, no count guarantee — every island is a noise outline, and the per-hemisphere
/// counts are a statistical outcome of the tuning (the chat2/05 forced floor was tried and
/// reverted on look; it is in git history, one checkout away).
/// </summary>
Organic,
}
/// <summary>
/// ⭐ EVERY OFFSHORE-ISLET DIAL, IN ONE OBJECT — config-gated and isolated, per the developer's
/// standing modularity concern. Nothing above <see cref="OffshorePass"/> hardcodes an island
/// specific; the whole system is this object + <c>IslandFalloff</c> + <c>OffshorePass</c>.
///
/// ═══ TWO PRESETS, AND WHY BOTH EXIST ═══
///
/// <see cref="Faithful"/> the reference's constants, verbatim. The control in every batch.
/// <see cref="Organic"/> the reshape + the chat2/06 coverage tuning — the deliverable.
///
/// The reshape does not EDIT the faithful constants; it sets different values on the same
/// dials. So <c>Faithful()</c> stays bit-reproducible however far the organic layer is tuned.
///
/// ═══ THE TWO PROTECTIONS THAT ARE NOT DIALS ═══
///
/// <see cref="MinDepthM"/> (the moat) and <see cref="MinFalloff"/> (the "actually offshore" test)
/// are exposed here because every knob is, but they are the MAIN-ISLAND AND LAKE PROTECTIONS and
/// the presets do not move them. They are what makes "no island can bridge to shore" and "no
/// island in a lake or the crater bay" true by construction.
///
/// ═══ ⚠ WHAT IS DELIBERATELY NOT HERE (chat2/06) ═══
///
/// No floor, no stamp radius/core/jitter, no separation-of-seeded-centres, no placement RNG —
/// nothing that guarantees a count or places an island from a centre. The forced mechanism
/// (chat2/05 <c>Hybrid()</c>) was reverted out whole; if a hard count ever comes back it is a
/// design decision, not a knob that was left lying around.
/// </summary>
public sealed class OffshoreSettings
{
public OffshoreMode Mode = OffshoreMode.Off;
// ---- the organic (noise) layer ----------------------------------------
/// <summary>Islet noise frequency, periods per map width. Higher ⇒ SMALLER blobs. Reference 14.</summary>
public float FreqPerMapWidth = IslandFalloff.OFFSHORE_FREQ_ISLANDS;
/// <summary>Islet noise seed offset. Reference 7607.</summary>
public int SeedOffset = IslandFalloff.OFFSHORE_SEED_OFFSET;
/// <summary>
/// ⭐ THE MAIN KNOB — organic density: the fraction of the noise field's ACTUAL sampled
/// distribution that clears the calibrated threshold. More ⇒ more of the field becomes
/// island. This is the NORTH density; the south is this × <see cref="SouthWeight"/>.
/// Reference 0.02.
/// </summary>
public float Density = 0.02f;
/// <summary>
/// ⭐ THE SOUTH WEIGHT — a per-hemisphere density bias. South density =
/// <see cref="Density"/> × this. 1 ⇒ one dial (the reference); &gt; 1 ⇒ "weighted south",
/// the developer's preference. It is applied to the DENSITY (i.e. the calibration quantile),
/// not to the blob shape, so a south island looks exactly like a north island — there are
/// simply more of them.
/// </summary>
public float SouthWeight = 1f;
/// <summary>The effective south density.</summary>
public float DensitySouth => Density * SouthWeight;
/// <summary>
/// Half-width of the smooth threshold blend across the hemisphere midline, as a fraction of
/// the map. ⚠ Without it a north/south density difference would cut any organic island that
/// straddles the midline along a dead-straight line. 0 ⇒ hard step (never wanted).
/// </summary>
public float HemisphereBlendHalfWidth = 0.05f;
/// <summary>Islet crest, metres above sea, PRE-CURVE. Reference 34. The curve's toe squashes it lower.</summary>
public float CrestM = IslandFalloff.OFFSHORE_ISLAND_H_M;
/// <summary>Fraction of a blob's excess over threshold that saturates. LOWER ⇒ flatter. Reference 0.45.</summary>
public float CoreFraction = IslandFalloff.OFFSHORE_CORE;
/// <summary>Crest-to-sea edge sharpening exponent. 1 ⇒ the faithful smoothstep. Higher ⇒ crisper shore.</summary>
public float EdgeSharpness = 1f;
// ---- the slop guards (Organic only; all scale-free fractions of the map) --------------
//
// More density must not buy speck-debris or merged blobs. Three rules, each a revert BY
// COMPONENT MEMBERSHIP (the island goes back to seabed whole, and is untagged) — none of
// them places, shapes or counts anything. 0 ⇒ that rule is off. The faithful preset has none
// (the reference had no such rules).
/// <summary>
/// ⚠ THE SPECK GUARD. An organic island smaller than this fraction of the map's AREA is
/// noise debris — a local maximum that barely cleared the threshold and surfaced a cap of a
/// few cells — and is reverted to seabed and untagged. 3e-5 is ~126 cells at 2048, ~500 at
/// 4096, ~2,000 cells (a ~50 m islet) at 8192.
/// </summary>
public float MinIslandAreaFrac = 0f;
/// <summary>
/// ⚠ THE SEPARATION GUARD — "enough separation that they read as distinct islands". Two
/// surviving islands whose nearest shores are closer than this fraction of the map WIDTH
/// read as one cluster; the SMALLER is reverted. Greedy by size, so the largest island in a
/// cluster always stays. Chebyshev distance between boundary cells.
/// </summary>
public float MinSeparationFrac = 0f;
/// <summary>
/// ⚠ THE BLOB GUARD. An organic island LARGER than this fraction of the map's area is a
/// superlevel region that merged several maxima into one sprawling landmass — the "blob"
/// the developer does not want — and is reverted whole. Set well above the natural size at
/// the preset density so it is a safety net, not a sculptor; how often it bites is reported.
/// </summary>
public float MaxIslandAreaFrac = 0f;
// ---- the zone mask: protections + the Trench bound --------------------
/// <summary>⭐ THE MOAT. Reference 14 m. The presets do not move it.</summary>
public float MinDepthM = IslandFalloff.OFFSHORE_MIN_DEPTH_M;
public float DepthFeatherM = IslandFalloff.OFFSHORE_DEPTH_FEATHER_M;
/// <summary>⭐ THE "ACTUALLY OFFSHORE" TEST on the pre-Trench falloff. Reference 0.72. The presets do not move it.</summary>
public float MinFalloff = IslandFalloff.OFFSHORE_MIN_FALLOFF;
public float FalloffFeather = IslandFalloff.OFFSHORE_FALLOFF_FEATHER;
/// <summary>
/// The outer bound, as a fraction of the half-span (map-anchored, like the Trench). Zone
/// fades from INNER to zero at OUTER. Reference 0.78 / 0.86 keeps islets well off the
/// Trench ramp (which starts at 0.90) and out of the corners. The reshape pushes both OUT so
/// islands populate the corners and may sit over the outer edge — the developer accepts
/// islands clipped by the map edge. OUTER &lt; 1 keeps every island's centre on the playable
/// map.
/// </summary>
public float TrenchInner = IslandFalloff.OFFSHORE_TRENCH_INNER;
public float TrenchOuter = IslandFalloff.OFFSHORE_TRENCH_OUTER;
// ---- presets ----------------------------------------------------------
/// <summary>The reference, verbatim. Single density, no guards, original footprint/crest/mask.</summary>
public static OffshoreSettings Faithful() => new OffshoreSettings
{
Mode = OffshoreMode.Faithful,
FreqPerMapWidth = IslandFalloff.OFFSHORE_FREQ_ISLANDS,
SeedOffset = IslandFalloff.OFFSHORE_SEED_OFFSET,
Density = 0.02f, SouthWeight = 1f, // ConfigManager.OffshoreIslandDensity
CrestM = IslandFalloff.OFFSHORE_ISLAND_H_M,
CoreFraction = IslandFalloff.OFFSHORE_CORE,
EdgeSharpness = 1f,
TrenchInner = IslandFalloff.OFFSHORE_TRENCH_INNER,
TrenchOuter = IslandFalloff.OFFSHORE_TRENCH_OUTER,
};
/// <summary>
/// ⭐ THE DELIVERABLE (chat2/06) — the organic layer, reshaped (chat2/05 stage 2's shape:
/// small / low / flat / crisp, corners on) and tuned for coverage: density up from 05's
/// 0.007, south-weighted, guarded against specks / clusters / blobs.
///
/// ⚠ TUNED BY MEASUREMENT, not by feel (chat2/06 batch — the count table over 12 seeds ×
/// 3 levels and the per-hemisphere diagnosis). This preset is the batch's `density_mid`.
/// </summary>
public static OffshoreSettings Organic() => new OffshoreSettings
{
Mode = OffshoreMode.Organic,
FreqPerMapWidth = 16f, // a little smaller than the reference's 14 (chat2/05)
Density = OrganicDensityMid,
SouthWeight = OrganicSouthWeight,
CrestM = 24f, // lower than the reference's 34 (pre-curve) — lands in the curve's preserved toe
CoreFraction = 0.25f, // flatter than 0.45
EdgeSharpness = 2.5f, // crisper shore than the faithful smoothstep
MinIslandAreaFrac = OrganicMinIslandAreaFrac,
MinSeparationFrac = OrganicMinSeparationFrac,
MaxIslandAreaFrac = OrganicMaxIslandAreaFrac,
TrenchInner = 0.90f, TrenchOuter = 0.97f, // corners + outer edge allowed; centre stays on-map
};
/// <summary>
/// The three density levels of the chat2/06 batch, in one place so the tool and the preset
/// cannot disagree. <c>Mid</c> is the preset of record.
/// </summary>
public const float OrganicDensityLow = 0.016f; // the edge: every seed of the 12 still clears N ≥ 2 / S ≥ 3, but N's minimum IS 2
public const float OrganicDensityMid = 0.022f; // ⭐ the preset: N min 3 / mean 6.3, S min ~15 / mean ~20 over 12 seeds at 4096
public const float OrganicDensityHigh = 0.030f; // the "how many is too many" bookend
public const float OrganicSouthWeight = 1.25f; // preference, not a fix — the diagnosis found the south UN-suppressed (see the report)
public const float OrganicMinIslandAreaFrac = 3e-5f;
public const float OrganicMinSeparationFrac = 0.008f;
public const float OrganicMaxIslandAreaFrac = 6e-4f;
public OffshoreSettings Clone() => (OffshoreSettings)MemberwiseClone();
public string Describe()
{
if (Mode == OffshoreMode.Off) return "offshore OFF";
var sb = new StringBuilder();
sb.Append($"{Mode}: freq {FreqPerMapWidth:F0}/map density {Density:F4} × south {SouthWeight:F2} (= S {DensitySouth:F4}) · ");
sb.Append($"crest {CrestM:F0} m core {CoreFraction:F2} sharp {EdgeSharpness:F1} · ");
sb.Append($"guards minArea {MinIslandAreaFrac:G2} minSep {MinSeparationFrac:G2} maxArea {MaxIslandAreaFrac:G2} · ");
sb.Append($"moat {MinDepthM:F0}+{DepthFeatherM:F0} m falloff {MinFalloff:F2}+{FalloffFeather:F2} trench {TrenchInner:F2}→{TrenchOuter:F2}");
return sb.ToString();
}
}
}

View file

@ -0,0 +1 @@
uid://cv2f5uho1c3km

View file

@ -1,3 +1,6 @@
using System.Collections.Generic;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
@ -59,11 +62,61 @@ namespace IslaApocalypse.Tools
/// <summary>The minimum height. Not a reference field — carried for the renderer's ramp and the report.</summary>
public readonly float HMinSeed;
/// <summary>
/// ⚠ The map-wide max BEFORE the coast shelf and offshore islets ran (chat2/05). The
/// reference takes <c>_hMaxSeed</c> AFTER both, inside the same loop; v2 now does too —
/// <see cref="HMaxSeed"/> is the post-shelf/offshore value the curve normalizes against.
/// This one is carried so the report can state whether the two differed (expected: no, an
/// islet crest of ~34 m is far below any peak — but "expected" is measured, not assumed).
/// </summary>
public readonly float HMaxSeedBeforeOffshore;
// ═══ ⭐ THE ISLAND TAG — BY CONSTRUCTION from the region layer (chat2/07; chat2/05's tag, fixed) ═══
//
// DATA, set here, carried downstream, READ BY NOTHING IN THIS PHASE. It exists so a later
// pass (biome, fertility, placement) can find island land without re-deriving it from
// geometry. Since chat2/07 it is a CONSEQUENCE OF LABELING: every cell of every non-mainland
// land component is tagged — the big organic detached masses the same as an offshore-pass
// dot (chat2/0506 tagged only what the offshore pass raised; that was the bug). Both arrays
// are NULL when region labeling is off — a consumer checks for null, not for all-false.
/// <summary>Per column: is this land an island (any non-mainland land component)? Null when labeling is off.</summary>
public readonly bool[,] IsIsland;
/// <summary>
/// Per column: <see cref="RegionLabeling.HemiNorth"/> / <see cref="RegionLabeling.HemiSouth"/>
/// for tagged cells (the COMPONENT's hemisphere, by centroid), <see cref="RegionLabeling.HemiNone"/>
/// otherwise. Null when labeling is off.
/// </summary>
public readonly byte[,] IslandHemisphere;
/// <summary>⭐ The region layer's output for this field: id map + per-component table (post-revert). Null when labeling is off.</summary>
public readonly RegionLabels Regions;
/// <summary>The labeling BEFORE the speck revert (== <see cref="Regions"/> when the revert is off or removed nothing). For the overlay's "where a speck was". Null when labeling is off.</summary>
public readonly RegionLabels RegionsPre;
/// <summary>The region pass's numbers — pre/post-revert counts and sizes, what was reverted. Null when labeling is off.</summary>
public readonly RegionLedger RegionLedger;
/// <summary>Cells the islet layer lifted above sea. The reference printed this too.</summary>
public readonly long OffshoreLiftedCells;
/// <summary>The islet layer's numbers — thresholds, pre-guard count, guard ledger, final count. Null when offshore is off.</summary>
public readonly OffshoreLedger OffshoreLedger;
/// <summary>Lines worth printing from the shelf/offshore pass: thresholds, guard ledger, lifted counts.</summary>
public readonly IReadOnlyList<string> Notes;
/// <summary>Wall-clock milliseconds the pass took.</summary>
public readonly ulong ElapsedMs;
public Pass1Result(int mapSize, int seed, float[,] height, float[,] preTrenchFalloff,
float[,] latitudeField, float hMaxSeed, float hMinSeed, ulong elapsedMs)
float[,] latitudeField, float hMaxSeed, float hMinSeed, ulong elapsedMs,
float hMaxSeedBeforeOffshore = float.NaN,
bool[,] isIsland = null, byte[,] islandHemisphere = null,
long offshoreLiftedCells = 0, IReadOnlyList<string> notes = null, OffshoreLedger offshoreLedger = null,
RegionLabels regions = null, RegionLedger regionLedger = null, RegionLabels regionsPre = null)
{
MapSize = mapSize;
Seed = seed;
@ -73,8 +126,20 @@ namespace IslaApocalypse.Tools
HMaxSeed = hMaxSeed;
HMinSeed = hMinSeed;
ElapsedMs = elapsedMs;
HMaxSeedBeforeOffshore = float.IsNaN(hMaxSeedBeforeOffshore) ? hMaxSeed : hMaxSeedBeforeOffshore;
IsIsland = isIsland;
IslandHemisphere = islandHemisphere;
Regions = regions;
RegionsPre = regionsPre;
RegionLedger = regionLedger;
OffshoreLiftedCells = offshoreLiftedCells;
OffshoreLedger = offshoreLedger;
Notes = notes ?? System.Array.Empty<string>();
}
/// <summary>Whether the region layer ran on this field (the tag arrays are present).</summary>
public bool HasIslandTag => IsIsland != null;
/// <summary>Fraction of the map at or above the sea threshold. A cheap shape sanity number.</summary>
public float LandFraction(float seaLevel)
{

View file

@ -83,6 +83,24 @@ namespace IslaApocalypse.Tools
/// </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;
@ -118,8 +136,10 @@ namespace IslaApocalypse.Tools
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)
List<string> notes, bool[,] isIsland = null, byte[,] islandHemisphere = null)
{
IsIsland = isIsland;
IslandHemisphere = islandHemisphere;
MapSize = mapSize;
Seed = seed;
Height = height;
@ -146,6 +166,21 @@ namespace IslaApocalypse.Tools
/// </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)
{

View file

@ -0,0 +1,611 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE REGION-LABELING BATCH (chat2/07) — the general region layer on the current terrain, the
/// island tag fixed by construction, and the tunable speck revert swept.
///
/// ═══ WHAT IT PRODUCES — a fixed budget: 4 plates + the count/size table ═══
///
/// PLATES (4 fields, each grayscale + .f32 + relief + the LABELED-REGIONS overlay + the tag overlay):
/// {plate}_threshold_low / _mid / _high three revert thresholds on ONE seed — the developer
/// dials "where too-small-to-keep sits" by eye.
/// {second}_threshold_mid the preset on a second seed — the table seed with the
/// most NATURAL islands (offshore off), auto-picked or
/// ISLA_SECOND_SEED — labeling + threshold are not
/// seed-specific; the big organic masses tag correctly.
///
/// THE COUNT/SIZE TABLE (data): per seed, natural islands (offshore off), pre-revert islands, and
/// post-revert islands at each threshold, with size min / median / mean / max and a log-spaced
/// size histogram — the instrument the later southern-stretch step tunes against.
///
/// Every "current terrain" field = shelf ON + the chat2/06 organic preset (`density_mid`) + region
/// labeling ON; the revert is the variable. The curve is the tagged curve, unchanged.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/RegionLabelingTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
/// ISLA_MAPSIZE plate + table size (default 4096)
/// ISLA_TABLE_SIZE count-table size (default = ISLA_MAPSIZE; a probe may drop it)
/// ISLA_CALIB_SIZE curve calibration size (default 2048, task 01's)
/// ISLA_TABLE_SEEDS the table seeds (default 8 below)
/// ISLA_PLATE_SEED the three-threshold seed (default 1063685222)
/// ISLA_SECOND_SEED the second plate seed (default 0 = auto: most natural islands)
/// ISLA_THR_LOW / ISLA_THR_MID / ISLA_THR_HIGH thresholds, fraction of map area (probe overrides)
/// ISLA_TABLE_ONLY=1 probe: table only (no regressions, no plates)
/// ISLA_SKIP_8K=1 skip the 8192 regression (a4)
/// ISLA_PHASE1_SOURCE / ISLA_T03_SOURCE / ISLA_T04_SOURCE / ISLA_T06_SOURCE the regression dumps' batches
/// </summary>
public partial class RegionLabelingTool : Node
{
private static readonly int[] DefaultTableSeeds =
{
1063685222, 20260821, 8675309, 123456789, 271828182, 999999937, 90210, 424242,
};
/// <summary>⚠ Task 01's pool, verbatim — the curve's identity.</summary>
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
private const int DefaultMapSize = 4096;
private const int DefaultCalibSize = 2048;
private const int GallerySize = 8192;
public override void _Ready()
{
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr(e.StackTrace);
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private sealed class Level { public string Label; public float Frac; }
private sealed class Row
{
public string Level; public int Seed; public long ThresholdCells;
public int Natural, NaturalN, NaturalS; // offshore OFF, revert OFF
public int Pre, PreN, PreS; // offshore ON, revert OFF
public int Post, PostN, PostS; // offshore ON, revert at this level
public int RevertedComps; public long RevertedCells;
public long PreMin, PreMed, PreMax, PostMin, PostMed, PostMax; public double PreMean, PostMean;
public int[] PreHist, PostHist;
public long MainlandCells; public bool Ok; public ulong Ms;
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
int task = EnvInt("ISLA_TASK", 7);
string descr = EnvStr("ISLA_BATCH", "region_labeling");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int tableSize = EnvInt("ISLA_TABLE_SIZE", mapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] tableSeeds = EnvSeeds("ISLA_TABLE_SEEDS", DefaultTableSeeds);
int plateSeed = EnvInt("ISLA_PLATE_SEED", 1063685222);
int secondEnv = EnvInt("ISLA_SECOND_SEED", 0);
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
string t03Source = EnvStr("ISLA_T03_SOURCE", "03_mountain_restore");
string t04Source = EnvStr("ISLA_T04_SOURCE", "04_seed_gallery");
string t06Source = EnvStr("ISLA_T06_SOURCE", "06_offshore_organic_tune");
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
bool tableOnly = EnvStr("ISLA_TABLE_ONLY", "0") == "1";
bool skip8k = EnvStr("ISLA_SKIP_8K", "0") == "1";
var levels = new List<Level>
{
new() { Label = "threshold_low", Frac = EnvFloat("ISLA_THR_LOW", RegionPass.ThresholdLowFrac) },
new() { Label = "threshold_mid", Frac = EnvFloat("ISLA_THR_MID", RegionPass.ThresholdMidFrac) },
new() { Label = "threshold_high", Frac = EnvFloat("ISLA_THR_HIGH", RegionPass.ThresholdHighFrac) },
};
Level mid = levels[1];
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
GD.Print("==================================================================");
GD.Print(" REGION LABELING (chat2/07) — label all land, fix the tag, tunable speck revert");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} (plates) table at {tableSize} curve calibrated at {calibSize} (offshore OFF)");
GD.Print($"table : {string.Join(", ", tableSeeds)}");
GD.Print($"plate seed: {plateSeed} second seed: {(secondEnv > 0 ? secondEnv.ToString() : "auto (most natural islands)")}");
foreach (var l in levels) GD.Print($" {l.Label,-15} {l.Frac:G3} of map area = {Cells(l.Frac, mapSize):N0} cells at {mapSize} ({Cells(l.Frac, tableSize):N0} at {tableSize})");
GD.Print($"terrain : shelf ON + offshore {OffshoreSettings.Organic().Describe()}");
GD.Print($"contract : classify field · land 8-connected · mainland = centre component · id/size/centroid/hemisphere(centroid)/isMainland");
GD.Print($"batch : {batchRoot}{(tableOnly ? " ISLA_TABLE_ONLY a probe, not the batch of record" : "")}");
GD.Print("==================================================================");
// ═══ 0. THE CURVE ═══
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
GD.Print($" {calibration.Describe()}");
TerrainGenConfig Cfg(int size, int seed, string label, bool offshoreOn, bool revertOn, float frac)
{
var c = BaseConfig(size, seed, knots, anchors, calibration, label);
if (offshoreOn) { c.CoastShelf = true; c.Offshore = OffshoreSettings.Organic(); }
c.RegionLabeling = true;
c.SpeckRevert = revertOn;
c.MinLandComponentFrac = frac;
return c;
}
// ═══ 1. REGRESSIONS ═══
var hard = new List<ShapingOracle.Check>();
if (!tableOnly)
{
GD.Print($"\n--- 1. REGRESSIONS at {calibSize}, seed {plateSeed} ---");
var offCfg = Cfg(calibSize, plateSeed, "off", offshoreOn: false, revertOn: false, mid.Frac);
Pass1Result p1 = Topography.Generate(offCfg);
var curveOff = offCfg.Clone(); curveOff.Curve = false;
Pass2Result pOff = Shaping.Shape(p1, curveOff);
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{plateSeed}_full", "height.f32");
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, offshore OFF, revert OFF (labeling on) == Phase-1 .f32 dump",
pOff.Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
Pass2Result pRest = Shaping.Shape(p1, offCfg);
string t03Dump = Path.Combine(ToolingPaths.BatchesRoot, t03Source, $"{plateSeed}_continuous_restored", "height.f32");
float[,] t03 = HeightField.Load(t03Dump, calibSize);
hard.Add(ShapingOracle.DumpRegression("a3", "continuous_restored, offshore OFF, revert OFF (labeling on) == task-03 .f32 dump",
pRest.Height, t03, calibSize, t03Dump));
// Informational: offshore OFF, revert ON — how many NATURAL speck cells the revert removes
// from the bare field. Allowed to differ (the revert may change terrain); reported, not asserted.
var revCfg = Cfg(calibSize, plateSeed, "off_revert", offshoreOn: false, revertOn: true, mid.Frac);
Pass1Result p1Rev = Topography.Generate(revCfg);
Pass2Result pRev = Shaping.Shape(p1Rev, revCfg);
var info = ShapingOracle.DumpRegression("a3r", "(informational) offshore OFF, revert ON at threshold_mid vs task-03 dump — the natural specks removed", pRev.Height, t03, calibSize, t03Dump);
info.Detail = (info.Passed ? "no natural speck below the threshold on this seed — " : "") + info.Detail +
$" · reverted {p1Rev.RegionLedger.RevertedComponents} natural components / {p1Rev.RegionLedger.RevertedCells:N0} cells";
info.Passed = true;
hard.Add(info);
var shelfCfg = offCfg.Clone(); shelfCfg.CoastShelf = true; shelfCfg.VariantLabel = "shelf_only";
Pass1Result p1Shelf = Topography.Generate(shelfCfg);
var j0 = ShapingOracle.MainlandUnmoved(p1, p1Shelf, sea);
j0.Name = "shelf alone: every land cell bit-identical (shelf is below-sea only)";
hard.Add(j0);
hard.Add(ShapingOracle.CentreIsLand(p1));
foreach (var c in hard) GD.Print(" " + c);
if (!skip8k)
{
string t04Dump = Path.Combine(ToolingPaths.BatchesRoot, t04Source, $"{plateSeed}", "height.f32");
if (File.Exists(t04Dump))
{
GD.Print($" a4: generating {plateSeed} at {GallerySize}, offshore OFF, revert OFF …");
var gCfg = Cfg(GallerySize, plateSeed, "off", offshoreOn: false, revertOn: false, mid.Frac);
Pass2Result pG = Shaping.Shape(Topography.Generate(gCfg), gCfg);
var a4 = ShapingOracle.DumpRegression("a4", $"offshore OFF, revert OFF at {GallerySize} == terrain-curve-v1's 04 gallery .f32 dump",
pG.Height, HeightField.Load(t04Dump, GallerySize), GallerySize, t04Dump);
hard.Add(a4); GD.Print(" " + a4);
}
else GD.Print($" a4: ⚠ skipped — no 04 gallery dump at {t04Dump}");
}
else GD.Print(" a4: skipped (ISLA_SKIP_8K)");
// ⭐ a6 — labeling ON, revert OFF, on the chat2/06 preset: bit-identical to the 06 batch's
// render field. Labeling is pure analysis; only the revert may change terrain.
string t06Dump = Path.Combine(ToolingPaths.BatchesRoot, t06Source, $"{plateSeed}_density_mid", "height.f32");
if (File.Exists(t06Dump) && mapSize == 4096)
{
var c6 = Cfg(mapSize, plateSeed, "density_mid", offshoreOn: true, revertOn: false, mid.Frac);
Pass2Result p6 = Shaping.Shape(Topography.Generate(c6), c6);
var a6 = ShapingOracle.DumpRegression("a6", "offshore density_mid ON, labeling ON, revert OFF == task-06 .f32 dump (labeling is pure analysis)",
p6.Height, HeightField.Load(t06Dump, mapSize), mapSize, t06Dump);
hard.Add(a6); GD.Print(" " + a6);
}
else GD.Print($" a6: ⚠ skipped — {(mapSize != 4096 ? "map size is not the 06 batch's 4096" : $"no 06 dump at {t06Dump}")}");
}
// ═══ 2. DETERMINISM ═══
GD.Print($"\n--- 2. DETERMINISM at {tableSize}, seed {plateSeed}, {mid.Label} ---");
var perFieldChecks = new List<ShapingOracle.Check>();
{
var cA = Cfg(tableSize, plateSeed, mid.Label, true, true, mid.Frac);
var cB = Cfg(tableSize, plateSeed, mid.Label, true, true, mid.Frac);
var det = ShapingOracle.LabelsDeterministic(Topography.Generate(cA), Topography.Generate(cB));
det.Name += $" [{plateSeed}]";
perFieldChecks.Add(det); GD.Print(" " + det);
}
// ═══ 3. THE COUNT/SIZE TABLE ═══
GD.Print($"\n--- 3. COUNT/SIZE TABLE at {tableSize} ---");
var rows = new List<Row>();
var naturalCount = new Dictionary<int, int>();
bool notesShown = false;
foreach (int seed in tableSeeds)
{
// natural: offshore OFF, revert OFF
Pass1Result pNat = Topography.Generate(Cfg(tableSize, seed, "natural", false, false, mid.Frac));
var (natN, natS) = RegionLabeling.IslandsByHemisphere(pNat.Regions);
naturalCount[seed] = pNat.Regions.IslandCount;
// pre: offshore ON, revert OFF
var cPre = Cfg(tableSize, seed, "pre", true, false, mid.Frac);
Pass1Result pPre = Topography.Generate(cPre);
{
var cj = ShapingOracle.MainlandUnmoved(pNat, pPre, sea); cj.Name += $" [offshore on vs off, {seed}]"; perFieldChecks.Add(cj);
var cm = ShapingOracle.CentreIsLand(pPre); cm.Name += $" [pre {seed}]"; perFieldChecks.Add(cm);
}
if (!notesShown) { foreach (string n in pPre.Notes) GD.Print(" " + n); }
GD.Print($" seed {seed,-11} natural islands {pNat.Regions.IslandCount,3} (N {natN} / S {natS}) pre-revert {pPre.Regions.IslandCount,3} (N {pPre.RegionLedger.PostNorth} / S {pPre.RegionLedger.PostSouth}) mainland {pPre.Regions.Mainland.SizeCells:N0} cells");
foreach (var lv in levels)
{
var cfg = Cfg(tableSize, seed, lv.Label, true, true, lv.Frac);
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result p2 = Shaping.Shape(p1, cfg);
if (!notesShown) { foreach (string n in p1.Notes) if (n.StartsWith("[Regions]")) GD.Print(" " + n); notesShown = true; }
var led = p1.RegionLedger;
long thr = led.ThresholdCells;
var comps = OffshoreAnalysis.Components(p1.IsIsland, p1.Height, sea, tableSize);
var checks = new List<ShapingOracle.Check>
{
ShapingOracle.CentreIsLand(p1),
ShapingOracle.RevertGuards(pPre, p1, sea, thr),
ShapingOracle.MoatIntact(p1, comps),
ShapingOracle.TagCoastlineConsistent(p2, sea),
ShapingOracle.HMaxAfterOffshore(p1),
ShapingOracle.ClassifyFidelity(p1, p2),
};
foreach (var c in checks) { c.Name += $" [{lv.Label} {seed}]"; perFieldChecks.Add(c); }
bool ok = checks.TrueForAll(c => c.Passed);
var row = new Row
{
Level = lv.Label, Seed = seed, ThresholdCells = thr,
Natural = pNat.Regions.IslandCount, NaturalN = natN, NaturalS = natS,
Pre = led.PreIslands, PreN = led.PreNorth, PreS = led.PreSouth,
Post = led.PostIslands, PostN = led.PostNorth, PostS = led.PostSouth,
RevertedComps = led.RevertedComponents, RevertedCells = led.RevertedCells,
PreMin = led.PreMin, PreMed = led.PreMedian, PreMean = led.PreMean, PreMax = led.PreMax,
PostMin = led.PostMin, PostMed = led.PostMedian, PostMean = led.PostMean, PostMax = led.PostMax,
PreHist = led.PreHistogram, PostHist = led.PostHistogram,
MainlandCells = p1.Regions.Mainland.SizeCells, Ok = ok, Ms = p1.ElapsedMs,
};
rows.Add(row);
GD.Print($" {lv.Label,-15} seed {seed,-11} thr {thr,5} pre {row.Pre,3} → post {row.Post,3} (N {row.PostN,2} / S {row.PostS,2}) reverted {row.RevertedComps,3} comps / {row.RevertedCells,7:N0} cells " +
$"post size min {row.PostMin,5} med {row.PostMed,5} max {row.PostMax,6} {(ok ? "ok" : " CHECK FAILED")} {p1.ElapsedMs} ms");
}
}
// ═══ 4. THE PLATES ═══
int secondSeed = secondEnv > 0 ? secondEnv : PickSecondSeed(naturalCount, tableSeeds, plateSeed);
GD.Print($"\n second seed: {secondSeed}{(secondEnv > 0 ? " (ISLA_SECOND_SEED)" : $" (auto: most natural islands among the table seeds {naturalCount.GetValueOrDefault(secondSeed)})")}");
var plateRows = new List<Row>();
if (!tableOnly)
{
GD.Print($"\n--- 4. PLATES at {mapSize} ---");
var plates = new List<(int seed, Level lv)> { (plateSeed, levels[0]), (plateSeed, levels[1]), (plateSeed, levels[2]), (secondSeed, mid) };
foreach (var (seed, lv) in plates)
{
var cfg = Cfg(mapSize, seed, lv.Label, true, true, lv.Frac);
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result p2 = Shaping.Shape(p1, cfg);
var led = p1.RegionLedger;
var cm = ShapingOracle.CentreIsLand(p1); cm.Name += $" [plate {lv.Label} {seed}]";
var ck = ShapingOracle.TagCoastlineConsistent(p2, sea); ck.Name += $" [plate {lv.Label} {seed}]";
perFieldChecks.Add(cm); perFieldChecks.Add(ck);
WritePlate(batchRoot, p1, p2, sea, anchors, skipRaw);
plateRows.Add(new Row
{
Level = lv.Label, Seed = seed, ThresholdCells = led.ThresholdCells,
Pre = led.PreIslands, PreN = led.PreNorth, PreS = led.PreSouth, Post = led.PostIslands, PostN = led.PostNorth, PostS = led.PostSouth,
RevertedComps = led.RevertedComponents, RevertedCells = led.RevertedCells,
PostMin = led.PostMin, PostMed = led.PostMedian, PostMean = led.PostMean, PostMax = led.PostMax,
MainlandCells = p1.Regions.Mainland.SizeCells, Ok = cm.Passed && ck.Passed, Ms = p1.ElapsedMs,
});
GD.Print($" plate {seed}_{lv.Label}: pre {led.PreIslands} → post {led.PostIslands} (N {led.PostNorth} / S {led.PostSouth}), reverted {led.RevertedComponents} comps {(cm.Passed && ck.Passed ? "ok" : " CHECK FAILED")} {p1.ElapsedMs} ms");
}
}
bool allOk = hard.TrueForAll(c => c.Passed) && perFieldChecks.TrueForAll(c => c.Passed);
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
foreach (var c in perFieldChecks) if (!c.Passed) GD.PrintErr(" " + c);
WriteTable(batchRoot, tableSize, levels, rows);
WriteIndex(batchRoot, mapSize, tableSize, calibSize, plateSeed, secondSeed, tableSeeds, levels, rows, plateRows, hard, perFieldChecks, allOk, tableOnly);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" ORACLE {(allOk ? "HARD CHECKS ALL PASS" : "*** FAILURES see the table ***")}");
GD.Print("==================================================================");
GetTree().Quit(allOk ? 0 : 3);
}
private static long Cells(float frac, int size) => Math.Max(1L, (long)Math.Round(frac * (double)size * size));
private static int PickSecondSeed(Dictionary<int, int> natural, int[] seeds, int plateSeed)
{
int best = 0, bestN = -1;
foreach (int s in seeds)
{
if (s == plateSeed) continue;
int n = natural.GetValueOrDefault(s);
if (n > bestN) { best = s; bestN = n; }
}
return best == 0 ? plateSeed : best;
}
// ---- the curve, measured exactly as tasks 0306 did --------------------
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
{
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s }); // offshore OFF, revert OFF by default
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
var knots = new CurveKnots(2, "v2_balanced",
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
};
Pass2Result st = Shaping.Shape(pass1[s], scfg);
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
}
var pcts = ClimbCalibration.DefaultPercentiles;
var rawQ = new float[pcts.Length];
var outQ = new float[pcts.Length];
for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
var cal = ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f);
return (knots, cal);
}
private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a,
ClimbCalibration cal, string label) => new TerrainGenConfig
{
MapSize = mapSize, Seed = seed, VariantLabel = label,
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = k, Anchors = a, ClimbCalibration = cal, LowlandCeilingM = 30f,
CoastShelf = false, Offshore = new OffshoreSettings(), // OFF unless the variant turns it on
};
// ---- output -----------------------------------------------------------
private static void WritePlate(string batchRoot, Pass1Result p1, Pass2Result p2, float sea, CurveAnchors anchors, bool skipRaw)
{
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
DirAccess.MakeDirRecursiveAbsolute(dir);
GrayscaleRenderer.SavePng(p2.Height, p2.MapSize, Path.Combine(dir, "grayscale.png"));
if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
var look = new LookConfig
{
Name = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f,
HillshadeStrength = 0.30f, SeaLevel = sea,
};
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, $"{p2.VariantLabel.ToUpperInvariant()} {p2.Seed}")
.SavePng(Path.Combine(dir, "relief.png"));
// ⭐ The labeled-regions overlay — the point of this task.
var led = p1.RegionLedger;
RegionOverlayRenderer.SavePng(p1.Regions, led.RevertOn ? p1.RegionsPre : null, p1.MapSize,
led.RevertedComponents, led.ThresholdCells, Path.Combine(dir, "regions.png"));
// The hemisphere tag overlay (chat2/05's), now showing the tag by construction.
var (n, s) = RegionLabeling.IslandsByHemisphere(p1.Regions);
TagOverlayRenderer.SavePng(p2.Height, p2.IsIsland, p2.IslandHemisphere, p2.MapSize, sea, n, s, Path.Combine(dir, "tags.png"));
}
private static string HistRow(int[] h)
{
if (h == null) return "—";
var sb = new StringBuilder();
for (int i = 0; i < h.Length; i++) { if (i > 0) sb.Append(" · "); sb.Append(h[i]); }
return sb.ToString();
}
private static string TableMarkdown(List<Level> levels, List<Row> rows, int tableSize)
{
var sb = new StringBuilder();
var histHead = new StringBuilder();
for (int i = 0; i <= RegionLabeling.HistogramEdges.Length; i++) { if (i > 0) histHead.Append(" · "); histHead.Append(RegionLabeling.HistogramLabel(i)); }
sb.AppendLine($"| Level | Seed | threshold (cells) | natural islands (offshore off) N / S | pre-revert islands N / S | **post-revert islands N / S** | reverted comps / cells | pre size min / med / mean / max | **post size min / med / mean / max** | post histogram ({histHead}) | mainland cells | oracle |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|");
foreach (var lv in levels)
foreach (var r in rows)
{
if (r.Level != lv.Label) continue;
sb.AppendLine($"| `{r.Level}` | `{r.Seed}` | {r.ThresholdCells:N0} | {r.Natural} ({r.NaturalN} / {r.NaturalS}) | {r.Pre} ({r.PreN} / {r.PreS}) | **{r.Post} ({r.PostN} / {r.PostS})** | {r.RevertedComps} / {r.RevertedCells:N0} | " +
$"{r.PreMin} / {r.PreMed} / {r.PreMean:F0} / {r.PreMax} | **{r.PostMin} / {r.PostMed} / {r.PostMean:F0} / {r.PostMax}** | {HistRow(r.PostHist)} | {r.MainlandCells:N0} | {(r.Ok ? "pass" : "**FAIL**")} |");
}
sb.AppendLine();
sb.AppendLine("**Per level (over the seeds):**");
sb.AppendLine();
sb.AppendLine("| Level | threshold | post islands min / mean / max | post N min / mean / max | post S min / mean / max | reverted comps (total) | reverted cells (total) | post median island (median over seeds) | smallest surviving island |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
foreach (var lv in levels)
{
int cnt = 0, minP = int.MaxValue, maxP = 0, minN = int.MaxValue, maxN = 0, minS = int.MaxValue, maxS = 0; double sumP = 0, sumN = 0, sumS = 0;
long revC = 0, revCells = 0, smallest = long.MaxValue; var meds = new List<long>(); long thr = 0;
foreach (var r in rows)
{
if (r.Level != lv.Label) continue;
cnt++; thr = r.ThresholdCells;
minP = Math.Min(minP, r.Post); maxP = Math.Max(maxP, r.Post); sumP += r.Post;
minN = Math.Min(minN, r.PostN); maxN = Math.Max(maxN, r.PostN); sumN += r.PostN;
minS = Math.Min(minS, r.PostS); maxS = Math.Max(maxS, r.PostS); sumS += r.PostS;
revC += r.RevertedComps; revCells += r.RevertedCells; meds.Add(r.PostMed);
if (r.Post > 0) smallest = Math.Min(smallest, r.PostMin);
}
if (cnt == 0) continue;
meds.Sort();
sb.AppendLine($"| `{lv.Label}` | {lv.Frac:G3} = {thr:N0} cells | {minP} / {sumP / cnt:F1} / {maxP} | {minN} / {sumN / cnt:F1} / {maxN} | {minS} / {sumS / cnt:F1} / {maxS} | {revC} | {revCells:N0} | {meds[meds.Count / 2]} | {(smallest == long.MaxValue ? 0 : smallest)} |");
}
return sb.ToString();
}
private static void WriteTable(string batchRoot, int tableSize, List<Level> levels, List<Row> rows)
{
var sb = new StringBuilder();
sb.AppendLine($"# The count/size table — {rows.Count / Math.Max(1, levels.Count)} seeds × {levels.Count} revert thresholds at {tableSize}");
sb.AppendLine();
sb.AppendLine("Islands = non-mainland 8-connected land components of the CLASSIFY field (mainland = the centre component).");
sb.AppendLine("*natural* = offshore off, revert off; *pre-revert* = offshore `density_mid` on, revert off; *post-revert* = the same with");
sb.AppendLine("the speck revert on at the level's threshold. Sizes in cells. Histogram bins are cells, log-spaced.");
sb.AppendLine();
sb.Append(TableMarkdown(levels, rows, tableSize));
WriteText(Path.Combine(batchRoot, "count_size_table.md"), sb.ToString());
var csv = new StringBuilder();
csv.AppendLine("level,seed,threshold_cells,natural,natural_n,natural_s,pre,pre_n,pre_s,post,post_n,post_s,reverted_comps,reverted_cells,pre_min,pre_median,pre_mean,pre_max,post_min,post_median,post_mean,post_max,post_hist,mainland_cells,oracle,ms");
var ic = System.Globalization.CultureInfo.InvariantCulture;
foreach (var r in rows)
csv.AppendLine(string.Join(",", r.Level, r.Seed, r.ThresholdCells, r.Natural, r.NaturalN, r.NaturalS, r.Pre, r.PreN, r.PreS, r.Post, r.PostN, r.PostS,
r.RevertedComps, r.RevertedCells, r.PreMin, r.PreMed, r.PreMean.ToString("F1", ic), r.PreMax, r.PostMin, r.PostMed, r.PostMean.ToString("F1", ic), r.PostMax,
"\"" + HistRow(r.PostHist) + "\"", r.MainlandCells, r.Ok ? "pass" : "FAIL", r.Ms));
WriteText(Path.Combine(batchRoot, "count_size_table.csv"), csv.ToString());
}
private static void WriteIndex(string batchRoot, int mapSize, int tableSize, int calibSize, int plateSeed, int secondSeed,
int[] tableSeeds, List<Level> levels, List<Row> rows, List<Row> plateRows,
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perField, bool allOk, bool tableOnly)
{
var sb = new StringBuilder();
sb.AppendLine("# Batch 07 — region labeling: label all land, fix the tag, tunable speck revert");
sb.AppendLine();
sb.AppendLine("The **region-labeling layer** (`Core.RegionLabeling`) flood-fills the CLASSIFY field's land into 8-connected");
sb.AppendLine("components, names the **centre component** the mainland, and exposes id / size / centroid / hemisphere (by");
sb.AppendLine("centroid) / isMainland. The **island tag is now a consequence of labeling** — every non-mainland component,");
sb.AppendLine("natural detached masses included. The **speck revert** (origin-blind, lower-only, component-only, mainland never)");
sb.AppendLine("lowers sub-threshold islands to their ring's seabed; the threshold is the dial swept here.");
sb.AppendLine();
if (tableOnly) sb.AppendLine("> ⚠ **ISLA_TABLE_ONLY** — a probe run: table only, no regressions, no plates. Not the batch of record.\n");
sb.AppendLine("## ⭐ Open this first");
sb.AppendLine();
sb.AppendLine($"1. **`{plateSeed}_threshold_mid/regions.png`** — the labeled-regions overlay: grey = mainland (the centre component),");
sb.AppendLine(" every island its own colour, dark red = where a reverted speck was. Then `relief.png` for the clean ocean.");
sb.AppendLine($"2. **`{plateSeed}_threshold_low/`** and **`{plateSeed}_threshold_high/`** beside it — same seed, lower / higher cutoff.");
sb.AppendLine($"3. **`{secondSeed}_threshold_mid/regions.png`** + `tags.png` — the second seed (most natural islands): the big organic");
sb.AppendLine(" detached masses are labeled and TAGGED (cyan / orange), which task 06's overlay left grey.");
sb.AppendLine("4. Then the count/size table — the instrument for the later southern-stretch step.");
sb.AppendLine();
sb.AppendLine("**The contract (verbatim):** field = classify (raw, uncurved) · land 8-connected (the complement of water's 4) ·");
sb.AppendLine("component = maximal 8-connected set of land cells (classify ≥ sea) · mainland = the component containing the map");
sb.AppendLine("centre (not merely the largest; the crater is NOT central) · per component: id, sizeCells, centroid, hemisphere");
sb.AppendLine($"(by centroid), isMainland. NORTH = rows `[0, {mapSize / 2})`, SOUTH = rows `[{mapSize / 2}, {mapSize})`; y runs south.");
sb.AppendLine();
sb.AppendLine("## The four plates");
sb.AppendLine();
sb.AppendLine("| Plate | threshold (cells) | pre-revert islands N / S | **post-revert islands N / S** | reverted comps / cells | post size min / med / mean / max | mainland cells | oracle |");
sb.AppendLine("|---|---|---|---|---|---|---|---|");
foreach (var r in plateRows)
sb.AppendLine($"| `{r.Seed}_{r.Level}/` | {r.ThresholdCells:N0} | {r.Pre} ({r.PreN} / {r.PreS}) | **{r.Post} ({r.PostN} / {r.PostS})** | {r.RevertedComps} / {r.RevertedCells:N0} | {r.PostMin} / {r.PostMed} / {r.PostMean:F0} / {r.PostMax} | {r.MainlandCells:N0} | {(r.Ok ? "pass" : "**FAIL**")} |");
if (plateRows.Count == 0) sb.AppendLine("| *(no plates — probe run)* | | | | | | | |");
sb.AppendLine();
sb.AppendLine($"## ⭐ The count/size table — {tableSeeds.Length} seeds × 3 thresholds at {tableSize}");
sb.AppendLine();
sb.Append(TableMarkdown(levels, rows, tableSize));
sb.AppendLine();
sb.AppendLine("Also as plain data: `count_size_table.md` / `.csv`.");
sb.AppendLine();
sb.AppendLine("## The levels");
sb.AppendLine();
sb.AppendLine("| Level | `MinLandComponentFrac` | cells at the plate size | cells at 8192 |");
sb.AppendLine("|---|---|---|---|");
foreach (var lv in levels) sb.AppendLine($"| `{lv.Label}`{(lv.Label == "threshold_mid" ? " config default" : "")} | {lv.Frac:G3} | {Cells(lv.Frac, mapSize):N0} | {Cells(lv.Frac, 8192):N0} |");
sb.AppendLine();
sb.AppendLine($"Every field: coast shelf ON + offshore `{OffshoreSettings.Organic().Describe()}` + region labeling ON. The revert is the variable.");
sb.AppendLine("The revert is **origin-blind**: it removes small natural nubs as well as offshore-pass dots (fewer / bigger, intended). An offshore");
sb.AppendLine("island it removes leaves its submerged skirt (not this component — component-only) as a shoal.");
sb.AppendLine();
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
sb.AppendLine();
sb.AppendLine("`ProvisionalEven`, flagged. The individually-coloured scheme is ONLY the `regions.png` overlay.");
sb.AppendLine();
sb.AppendLine("## The oracle");
sb.AppendLine();
sb.AppendLine("Regressions (offshore OFF + revert OFF must be bit-identical to Phase 1, task 03 and the `terrain-curve-v1` gallery dump; labeling ON + revert OFF bit-identical to the task-06 dump):");
sb.AppendLine();
sb.AppendLine(hard.Count == 0 ? "*(skipped — probe run)*\n" : ShapingOracle.ToMarkdownTable(hard));
sb.AppendLine("Per field (centre-is-land m · revert guards n · determinism o · moat i · mainland unmoved j · tag/coastline k · HMaxSeed l · classify b):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(perField));
sb.AppendLine($"**{(allOk ? "ALL HARD CHECKS PASS" : " FAILURES do not judge this batch")}**");
sb.AppendLine();
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `regions.png`, `tags.png`, `relief.png`, `INDEX.md`, `count_size_table.md` / `.csv` | **keep** |");
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + code — large, clear freely |");
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
sb.AppendLine();
sb.AppendLine($"Plates at {mapSize}, table at {tableSize}, curve calibrated at {calibSize} with offshore off. {WorldScale.Describe()}.");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
private static void WriteText(string path, string text)
{
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(text);
}
// ---- env helpers --------------------------------------------------------
private static string EnvStr(string k, string fallback)
{
string v = System.Environment.GetEnvironmentVariable(k);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}
private static int EnvInt(string k, int fallback)
=> int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
private static float EnvFloat(string k, float fallback)
=> float.TryParse(EnvStr(k, null) ?? "", System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out float v) ? v : fallback;
private static int[] EnvSeeds(string k, int[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<int>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
}
}

View file

@ -0,0 +1 @@
uid://ceeyncymgtjpm

View file

@ -0,0 +1,77 @@
using System;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// THE LABELED-REGIONS OVERLAY (chat2/07): the mainland one tint, EACH island component an
/// individually distinct colour, the components the speck revert removed in a dim red, the midline
/// drawn — so the developer can SEE that components are identified correctly and catch an
/// 8-connectivity mislabel (two touching blobs coloured as one, one mass coloured as two).
///
/// ⚠ A DIAGNOSTIC, NOT A MAP. It draws the region layer's id map, which is DATA. No hypsometry, no
/// hillshade — flat tints on purpose. The relief / grayscale plates stay on the provisional palette.
/// Presentation only: it is handed arrays and returns a PNG.
/// </summary>
public static class RegionOverlayRenderer
{
private static readonly Color Sea = new(0.055f, 0.110f, 0.235f);
private static readonly Color Mainland = new(0.340f, 0.380f, 0.330f);
private static readonly Color Reverted = new(0.420f, 0.080f, 0.080f); // where a reverted speck WAS (now sea)
private static readonly Color Midline = new(0.700f, 0.720f, 0.760f);
private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
/// <summary>A distinct, saturated colour per island id — golden-angle hue walk, three value steps.</summary>
public static Color IslandColor(int id)
{
float hue = (id * 137.508f) % 360f / 360f;
float val = 0.70f + 0.15f * (id % 3);
float sat = 0.85f - 0.15f * ((id / 3) % 2);
return Color.FromHsv(hue, sat, val);
}
/// <param name="labels">The finished field's labeling (post-revert).</param>
/// <param name="labelsPre">The pre-revert labeling, or null — its reverted components are painted <see cref="Reverted"/>.</param>
public static void SavePng(RegionLabels labels, RegionLabels labelsPre, int mapSize, int revertedCount, long thresholdCells,
string absolutePath)
{
var img = Image.CreateEmpty(mapSize, mapSize, false, Image.Format.Rgb8);
int n = mapSize;
for (int x = 0; x < n; x++)
{
for (int y = 0; y < n; y++)
{
int id = labels.Id[x * n + y];
Color c;
if (id == 0)
{
c = Sea;
if (labelsPre != null)
{
int pid = labelsPre.Id[x * n + y];
if (pid != 0 && pid != labelsPre.MainlandId) c = Reverted; // was land, was not mainland, is sea now
}
}
else if (id == labels.MainlandId) c = Mainland;
else c = IslandColor(id);
img.SetPixel(x, y, c);
}
}
int mid = n / 2;
for (int x = 0; x < n; x += 3) img.SetPixel(x, mid, Midline);
var (north, south) = RegionLabeling.IslandsByHemisphere(labels);
int s = n >= 4096 ? 4 : 3;
int lh = TinyFont.Height(s) + 6;
TinyFont.Draw(img, "LABELED REGIONS - CLASSIFY FIELD, LAND 8-CONNECTED", 12, 12, s, Ink);
TinyFont.Draw(img, $"GREY: MAINLAND (CENTRE COMPONENT{(labels.CentreWasLand ? "" : " - FALLBACK, CENTRE NOT LAND")}) EACH ISLAND: ITS OWN COLOUR", 12, 12 + lh, s, Ink);
TinyFont.Draw(img, $"ISLANDS: {labels.IslandCount} ({north} N / {south} S BY CENTROID) DARK RED: {revertedCount} REVERTED < {thresholdCells} CELLS", 12, 12 + lh * 2, s, Ink);
TinyFont.Draw(img, "N ABOVE THE LINE - S BELOW - Y RUNS SOUTH", 12, 12 + lh * 3, s, Ink);
Error err = img.SavePng(absolutePath);
if (err != Error.Ok) GD.PrintErr($"[RegionOverlayRenderer] SavePng failed ({err}) for {absolutePath}");
}
}
}

View file

@ -0,0 +1 @@
uid://c05blbudak0as

203
Tools/Scripts/RegionPass.cs Normal file
View file

@ -0,0 +1,203 @@
using System;
using System.Collections.Generic;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>The region pass's numbers, carried on <c>Pass1Result</c> for the report.</summary>
public sealed class RegionLedger
{
public bool RevertOn;
public long ThresholdCells;
public int PreIslands, PreNorth, PreSouth; // before the speck revert
public int PostIslands, PostNorth, PostSouth; // after
public int RevertedComponents; public long RevertedCells;
public bool CentreWasLand = true, CentreWasLandPre = true;
public long PreMin, PreMedian, PreMax, PostMin, PostMedian, PostMax; public double PreMean, PostMean;
public int[] PreHistogram, PostHistogram;
public List<(int id, long cells, byte hemi, float newHeight)> Reverted = new();
}
/// <summary>
/// ⭐ PASS 1c — REGION LABELING + THE SPECK REVERT + THE ISLAND TAG (chat2/07). Runs over the
/// finished pass-1/1b classify field, IN PLACE, after the shelf/islets and before <c>HMaxSeed</c>
/// is retaken and anything classifies.
///
/// ═══ WHAT IT DOES, IN ORDER ═══
///
/// 1. LABEL <see cref="RegionLabeling.Label"/> over the classify field — the general layer.
/// Pure analysis: no height changes. (The pre-revert table is kept for the instrument.)
/// 2. REVERT (config-gated: <c>TerrainGenConfig.SpeckRevert</c>; threshold
/// <c>MinLandComponentFrac</c> × map area) — every NON-MAINLAND component below the
/// threshold is lowered to seabed. ORIGIN-BLIND: it judges components by size, not by
/// who made them — a small natural nub goes the same way as an offshore-pass dot.
/// Fewer, bigger. Two guards, ASSERTED per component, hard failure on violation:
/// LOWER-ONLY — every touched cell goes DOWN (land → below sea), never up;
/// COMPONENT-ONLY — only cells of the sub-threshold component are touched, never a
/// neighbour (the submerged skirt an offshore island leaves behind
/// is NOT this component and stays — a shoal, by the rule).
/// Together they make it impossible for "revert" to move the mainland coast.
/// MAINLAND IS NEVER A CANDIDATE (asserted), however small a pathological seed made it.
/// The seabed a cell is lowered to is the MEAN height of the component's adjacent sea
/// cells (its ring), held strictly below sea by BitDecrement — a flat shoal at the
/// local depth, not a pit and not a reef.
/// 3. RELABEL after a revert the layer is run again, so the exposed table and ids are those of
/// the finished field.
/// 4. TAG BY CONSTRUCTION: every cell of every non-mainland component is an island cell,
/// hemisphere from its component's centroid. The big organic detached masses are
/// tagged the same as an offshore-pass dot. Nothing here knows which pass made a cell.
///
/// The classify field IS the pass-1 array; pass 2 derives the render field from it and the curve is
/// identity at and below sea, so a reverted cell is seabed in both — asserted downstream by oracle (k).
/// </summary>
public static class RegionPass
{
public sealed class Result
{
public RegionLabels LabelsPre; // before the revert (== Labels when the revert is off or reverted nothing)
public RegionLabels Labels; // the finished field's labeling
public bool[,] IsIsland; // the tag, by construction
public byte[,] IslandHemisphere;
public RegionLedger Ledger = new();
public List<string> Notes = new();
}
/// <summary>The three thresholds of the chat2/07 batch, fractions of the map's area; Mid is the config default.</summary>
public const float ThresholdLowFrac = 1e-5f; // 168 cells at 4096 — only the smallest natural specks
public const float ThresholdMidFrac = 3e-5f; // 503 cells at 4096 — the offshore pass's own speck guard, applied to all land
public const float ThresholdHighFrac = 1e-4f; // 1,678 cells at 4096 — "fewer, bigger": takes small offshore islands too
public static Result Apply(float[,] height, int mapSize, float sea, TerrainGenConfig cfg)
{
var r = new Result();
var pre = RegionLabeling.Label(height, mapSize, sea);
r.LabelsPre = pre;
var led = r.Ledger;
led.CentreWasLandPre = pre.CentreWasLand;
FillPre(led, pre);
r.Notes.Add($"[Regions] labeled {pre.Regions.Count} land components ({pre.LandCells:N0} land cells): mainland id {pre.MainlandId} " +
$"({(pre.Mainland == null ? 0 : pre.Mainland.SizeCells):N0} cells, centre {(pre.CentreWasLand ? "is land" : " NOT LAND fell back to the largest component")}), " +
$"{pre.IslandCount} islands (N {led.PreNorth} / S {led.PreSouth}); island cells min {led.PreMin} median {led.PreMedian} mean {led.PreMean:F0} max {led.PreMax}.");
RegionLabels final = pre;
led.RevertOn = cfg.SpeckRevert;
if (cfg.SpeckRevert)
{
long threshold = Math.Max(1L, (long)Math.Round(cfg.MinLandComponentFrac * (double)mapSize * mapSize));
led.ThresholdCells = threshold;
float strictlyBelowSea = MathF.BitDecrement(sea);
int n = mapSize;
// Which components go: non-mainland, below the threshold. Mainland is never a candidate.
var revert = new Dictionary<int, LandRegion>();
foreach (var c in pre.Regions)
if (!c.IsMainland && c.SizeCells < threshold) revert[c.Id] = c;
if (pre.Mainland != null && revert.ContainsKey(pre.MainlandId))
throw new InvalidOperationException("[RegionPass] the mainland was selected for revert. Refusing.");
if (revert.Count > 0)
{
// The ring: the mean height of each doomed component's adjacent SEA cells.
var ringSum = new Dictionary<int, double>();
var ringCnt = new Dictionary<int, long>();
foreach (int id in revert.Keys) { ringSum[id] = 0; ringCnt[id] = 0; }
for (int x = 0; x < n; x++)
{
for (int y = 0; y < n; y++)
{
int id = pre.Id[x * n + y];
if (id == 0 || !revert.ContainsKey(id)) continue;
for (int dx = -1; dx <= 1; dx++)
{
int nx = x + dx; if (nx < 0 || nx >= n) continue;
for (int dy = -1; dy <= 1; dy++)
{
int ny = y + dy; if (ny < 0 || ny >= n || (dx == 0 && dy == 0)) continue;
if (pre.Id[nx * n + ny] != 0) continue; // land (this or another component)
ringSum[id] += height[nx, ny]; ringCnt[id]++;
}
}
}
}
var target = new Dictionary<int, float>();
foreach (var (id, c) in revert)
{
float t = ringCnt[id] > 0 ? (float)(ringSum[id] / ringCnt[id]) : strictlyBelowSea;
target[id] = MathF.Min(t, strictlyBelowSea); // strictly below sea, whatever the ring says
}
// The revert, with both guards asserted cell by cell.
var touched = new Dictionary<int, long>();
foreach (int id in revert.Keys) touched[id] = 0;
long cells = 0;
for (int x = 0; x < n; x++)
{
for (int y = 0; y < n; y++)
{
int id = pre.Id[x * n + y];
if (id == 0 || !target.TryGetValue(id, out float t)) continue; // COMPONENT-ONLY: nothing else is ever touched
float h = height[x, y];
if (h < sea)
throw new InvalidOperationException($"[RegionPass] component {id} cell ({x},{y}) is not land (h {h:G9} < sea {sea:G9}) — the labeling and the field disagree. Refusing.");
if (t >= h)
throw new InvalidOperationException($"[RegionPass] LOWER-ONLY violated at ({x},{y}): {h:G9} → {t:G9}. Refusing.");
height[x, y] = t;
touched[id]++; cells++;
}
}
foreach (var (id, c) in revert)
{
if (touched[id] != c.SizeCells)
throw new InvalidOperationException($"[RegionPass] COMPONENT-ONLY violated: component {id} has {c.SizeCells} cells, {touched[id]} touched. Refusing.");
led.Reverted.Add((id, c.SizeCells, c.Hemisphere, target[id]));
}
led.RevertedComponents = revert.Count;
led.RevertedCells = cells;
final = RegionLabeling.Label(height, mapSize, sea);
if (final.Mainland == null || pre.Mainland == null || final.Mainland.SizeCells != pre.Mainland.SizeCells)
throw new InvalidOperationException("[RegionPass] the mainland's size changed across the revert. Refusing.");
}
r.Notes.Add($"[Regions] speck revert ON (threshold {threshold:N0} cells = {cfg.MinLandComponentFrac:G2} of the map): " +
$"{led.RevertedComponents} sub-threshold non-mainland components ({led.RevertedCells:N0} cells) lowered to their ring's mean seabed; " +
$"lower-only and component-only asserted; mainland untouched by definition.");
}
else r.Notes.Add("[Regions] speck revert OFF.");
r.Labels = final;
led.CentreWasLand = final.CentreWasLand;
FillPost(led, final);
// ═══ THE TAG, BY CONSTRUCTION ═══
var tag = new bool[mapSize, mapSize];
var hemi = new byte[mapSize, mapSize];
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
{
int id = final.Id[x * mapSize + y];
if (id == 0 || id == final.MainlandId) continue;
tag[x, y] = true;
hemi[x, y] = final.Regions[id - 1].Hemisphere;
}
r.IsIsland = tag; r.IslandHemisphere = hemi;
r.Notes.Add($"[Regions] tag by construction: {final.IslandCount} islands (N {led.PostNorth} / S {led.PostSouth}), " +
$"{final.LandCells - (final.Mainland?.SizeCells ?? 0):N0} island cells tagged; island cells min {led.PostMin} median {led.PostMedian} mean {led.PostMean:F0} max {led.PostMax}.");
return r;
}
private static void FillPre(RegionLedger l, RegionLabels lab)
{
(l.PreNorth, l.PreSouth) = RegionLabeling.IslandsByHemisphere(lab);
var (c, mn, med, mean, mx, h) = RegionLabeling.IslandSizes(lab);
l.PreIslands = c; l.PreMin = mn; l.PreMedian = med; l.PreMean = mean; l.PreMax = mx; l.PreHistogram = h;
}
private static void FillPost(RegionLedger l, RegionLabels lab)
{
(l.PostNorth, l.PostSouth) = RegionLabeling.IslandsByHemisphere(lab);
var (c, mn, med, mean, mx, h) = RegionLabeling.IslandSizes(lab);
l.PostIslands = c; l.PostMin = mn; l.PostMedian = med; l.PostMean = mean; l.PostMax = mx; l.PostHistogram = h;
}
}
}

View file

@ -0,0 +1 @@
uid://btgwrycrjllr4

View file

@ -0,0 +1,580 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE SEED GALLERY (chat2/04) — is <c>continuous_restored</c> good across seeds, or is
/// 1063685222 a lucky draw?
///
/// ═══ ⚠⚠ THIS TOOL RENDERS. IT DOES NOT TUNE. ═══
///
/// The curve is the one committed at tag <c>terrain-curve-v1</c> and it is PINNED HERE IN CODE —
/// <see cref="MountainLift"/>, <see cref="PeakSharpness"/> and <see cref="LowlandCeilingM"/> are
/// constants with no environment override, deliberately. Every other tool in this phase exposes
/// its knobs to <c>ISLA_*</c> so they can be probed; a GALLERY must not, because a stray
/// environment variable left over from a probe would silently render eight plates of a curve
/// nobody chose and they would look exactly like the real thing. The knobs are printed in the
/// run header and written into the INDEX so the plates can always be traced to a curve.
///
/// ═══ THE CALIBRATION IS RE-MEASURED, NOT RE-INVENTED ═══
///
/// <c>continuous_restored</c> is defined by a calibration measured on task 01's six-seed pool at
/// the ITERATION size, then applied at any size. This tool reproduces that measurement exactly —
/// same pool, same size, same primary seed for the normalization anchor — so the gallery renders
/// the same curve task 03 gated, not a look-alike. → <see cref="ClimbCalibration"/>.
///
/// ⚠ The gallery seeds are NOT the calibration pool. The pool stays fixed at task 01's six; the
/// gallery is eight separate draws, seven of them never previously rendered.
///
/// ═══ THE CHARACTER NOTE IS A HUMAN JUDGEMENT, AND IS TREATED AS ONE ═══
///
/// The INDEX carries a one-word note per seed ("clean" / "sharp mid-slope" / "flat draw"). That is
/// an EYE call, so this tool will not invent it. It reads the notes from
/// <c>scratch/character_notes.tsv</c> if that file exists and prints "(pending)" if it does not.
/// Write the notes after looking at the plates, then re-run with <c>ISLA_INDEX_ONLY=1</c> to
/// rebuild the INDEX from <c>scratch/metrics.tsv</c> without re-rendering a single pixel.
///
/// What the tool DOES contribute is an objective companion, and getting it right took one correction
/// worth recording:
///
/// ⚠ THE CURVE'S NORMALIZED MID-SLOPE CANNOT DISCRIMINATE BETWEEN SEEDS. It is
/// <c>dv/du</c> on a fixed set of control points, and each seed's denormalization is an affine
/// rescale of both axes — which leaves <c>dv/du</c> untouched. It is the SAME NUMBER on every
/// seed by construction, so measuring it per seed answers nothing. (The first cut of this tool
/// reported exactly that and printed an identical 1.86 for every draw.)
///
/// So the metric that ships is the one the eye is actually reacting to: the **spatial height
/// gradient through the 100220 m band**, in metres per pixel — how fast the ground climbs
/// through the heights where the yellow→orange transition sits. That is
/// <c>(dOutput/dRaw) × (dRaw/dPixel)</c>: the first factor varies by seed because a taller
/// <c>spikeMax</c> spreads the same curve over more raw range, and the second is pure terrain.
/// Both are seed-dependent, and their product is what a render shows.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/SeedGalleryTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_SEEDS / ISLA_SKIP_RAW
/// ISLA_MAPSIZE gallery render size (default 8192 — judging size, not calibration size)
/// ISLA_CALIB_SIZE calibration pool size (default 2048 — task 01's, do not change casually)
/// ISLA_INDEX_ONLY "1" to rebuild INDEX.md from scratch/metrics.tsv and skip all rendering
/// </summary>
public partial class SeedGalleryTool : Node
{
// ═══ THE CURVE, PINNED. No environment override, by design — see the type header. ═══
/// <summary>task-03 `continuous_restored`: reproduce the staircase's mountain.</summary>
private const float MountainLift = 1.0f;
/// <summary>task-03 `continuous_restored`: a straight run to the cap, no extra summit steepening.</summary>
private const float PeakSharpness = 1.0f;
/// <summary>The flood line — hands over at exactly (K2, RED_CEIL).</summary>
private const float LowlandCeilingM = 30f;
/// <summary>
/// ⚠ TASK 01'S CALIBRATION POOL, VERBATIM. Not the gallery seeds. Changing this changes the
/// curve, which is the one thing this task must not do.
/// </summary>
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
/// <summary>
/// ⭐ THE GALLERY. `1063685222` is the ANCHOR — the plate every previous task was judged on —
/// and the other seven are fresh draws never rendered before.
///
/// ⚠⚠ THESE WERE FIXED BEFORE THE FIRST RENDER AND WERE NEVER SCREENED OR REPLACED. That is
/// the whole point of a spread: hand-picking flattering draws would answer the question
/// "can this curve ever look good?" when the question asked is "does it look good generally?"
/// If a seed in this list turns out ugly, it stays in the gallery and goes in the report.
///
/// None of the seven appear in the calibration pool, so none of them shaped the curve they
/// are being used to test.
/// </summary>
private static readonly int[] GallerySeeds =
{
1063685222, // ⭐ the anchor — Phase 1's primary, the plate 01/02/03 were judged on
20260821, // the date this gallery was drawn
8675309,
123456789,
271828182,
42424242,
555000111,
999999937, // a large prime, for no reason beyond being an unconsidered draw
};
private const int DefaultGallerySize = 8192;
private const int DefaultCalibSize = 2048;
public override void _Ready()
{
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
int task = EnvInt("ISLA_TASK", 4);
string descr = EnvStr("ISLA_BATCH", "seed_gallery");
int gallerySize = EnvInt("ISLA_MAPSIZE", DefaultGallerySize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", GallerySeeds);
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
bool indexOnly = EnvStr("ISLA_INDEX_ONLY", "0") == "1";
string batchRoot = ToolingPaths.BatchRoot(task, descr);
string scratch = ToolingPaths.BatchScratch(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(scratch);
string metricsPath = Path.Combine(scratch, "metrics.tsv");
string notesPath = Path.Combine(scratch, "character_notes.tsv");
var anchors = CurveAnchors.Default;
float sea = 0.15f;
int anchor = seeds[0];
GD.Print("==================================================================");
GD.Print(" SEED GALLERY (chat2/04) — the committed curve, across 8 draws");
GD.Print("==================================================================");
GD.Print($"curve : continuous_restored, PINNED — lift {MountainLift:F2}, " +
$"sharpness {PeakSharpness:F2}, ceiling {LowlandCeilingM:F0} m (no env override)");
GD.Print($"gallery : {gallerySize} calibration pool at {calibSize}");
GD.Print($"seeds : {string.Join(", ", seeds)} (anchor: {anchor})");
GD.Print($"batch : {batchRoot}");
GD.Print($"yardstick : {WorldScale.Describe()}");
GD.Print("==================================================================");
// ═══ INDEX-ONLY: rebuild the contact sheet from the recorded metrics ═══
if (indexOnly)
{
var recorded = ReadMetrics(metricsPath);
if (recorded.Count == 0)
throw new InvalidOperationException(
$"ISLA_INDEX_ONLY=1 but no metrics at {metricsPath}. Run the gallery first — " +
"the index is rebuilt FROM a render, never instead of one.");
GD.Print($"\n--- INDEX ONLY — {recorded.Count} seeds from {metricsPath} ---");
WriteIndex(batchRoot, recorded, ReadNotes(notesPath), anchor, gallerySize, calibSize, notesPath);
GD.Print($" INDEX.md rebuilt. No pixels were rendered.");
GetTree().Quit(0);
return;
}
// ═══ 1. THE CURVE — re-measured exactly as task 03 did ═══
GD.Print($"\n--- 1. CALIBRATION (task-01 pool at {calibSize}, as task 03) ---");
var rawPool = new LandHistogram(sea);
var poolPass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
poolPass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
var knots = new CurveKnots(2, "v2_balanced",
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
// Above-ceiling land, gated on RAW height, output measured off the staircase.
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
var scfg = BaseConfig(calibSize, s, knots, anchors, "staircase");
scfg.CurveMode = CurveModeKind.Staircase;
scfg.ShelfDetail = true;
Pass2Result st = Shaping.Shape(poolPass1[s], scfg);
rawAbove.AccumulateWhere(poolPass1[s].Height, poolPass1[s].Height, calibSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, poolPass1[s].Height, calibSize, ceilingRaw);
}
var pcts = ClimbCalibration.DefaultPercentiles;
var rawQ = new float[pcts.Length];
var outQ = new float[pcts.Length];
for (int i = 0; i < pcts.Length; i++)
{
rawQ[i] = rawAbove.Quantile(pcts[i]);
outQ[i] = outAbove.Quantile(pcts[i]);
}
// ⚠ Anchored on the POOL PRIMARY's spikeMax, exactly as task 03 did — reproducing the
// gated curve matters more here than improving it. (Task 03 §7.4 flags the anchor choice
// as worth revisiting; a gallery is not the place to revisit it.)
var calibration = ClimbCalibration.FromPercentiles(
pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(poolPass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, MountainLift, PeakSharpness);
GD.Print($" {knots}");
GD.Print($" {calibration.Describe()}");
// ═══ 2. THE GALLERY ═══
GD.Print($"\n--- 2. GALLERY at {gallerySize} ---");
var metrics = new List<SeedMetrics>();
foreach (int seed in seeds)
{
ulong t0 = Time.GetTicksMsec();
var cfg = BaseConfig(gallerySize, seed, knots, anchors, "restored");
cfg.CurveMode = CurveModeKind.Continuous;
cfg.ClimbCalibration = calibration;
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result p2 = Shaping.Shape(p1, cfg);
// ⚠ The two invariants that must hold on EVERY plate, checked per seed rather than
// assumed from task 03's two. A gallery that quietly rendered a broken seed would be
// the worst possible artifact: eight plates, one of them lying.
var classify = ShapingOracle.ClassifyFidelity(p1, p2);
var offCfg = BaseConfig(gallerySize, seed, knots, anchors, "off"); offCfg.Curve = false;
var seaId = ShapingOracle.SeaIdentity(Shaping.Shape(p1, offCfg), p2, sea);
if (!classify.Passed) GD.PrintErr($" ⚠⚠ seed {seed}: {classify}");
if (!seaId.Passed) GD.PrintErr($" ⚠⚠ seed {seed}: {seaId}");
var m = WriteSeed(batchRoot, p1, p2, sea, anchors, skipRaw);
m.ClassifyOk = classify.Passed;
m.SeaOk = seaId.Passed;
m.ElapsedMs = Time.GetTicksMsec() - t0;
metrics.Add(m);
GD.Print($" {seed,-11} land {m.LandPct,5:F1}% >100m {m.Above100,5:F2}% >220m {m.Above220,5:F2}% " +
$"p90 {WorldScale.MetresFromRaw(m.P90 - sea),6:F1}m " +
$"grad100-220 p50 {m.BandGradP50:F2} p95 {m.BandGradP95:F2} m/px hMax {p1.HMaxSeed:F3} " +
$"{(m.ClassifyOk && m.SeaOk ? "ok" : " CHECK FAILED")} {m.ElapsedMs / 1000.0:F0}s");
}
WriteMetrics(metricsPath, metrics);
WriteIndex(batchRoot, metrics, ReadNotes(notesPath), anchor, gallerySize, calibSize, notesPath);
bool allOk = metrics.TrueForAll(m => m.ClassifyOk && m.SeaOk);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" per-seed invariants: {(allOk ? "ALL PASS" : "*** A SEED FAILED see above ***")}");
GD.Print($" ⚠ character notes are a HUMAN call — write {notesPath}");
GD.Print($" then re-run with ISLA_INDEX_ONLY=1 to fill them in.");
GD.Print("==================================================================");
GetTree().Quit(allOk ? 0 : 3);
}
// ---- per-seed record -------------------------------------------------
private sealed class SeedMetrics
{
public int Seed;
public float HMin, HMax, HMaxSeed;
public double LandPct, Above100, Above220;
public float P50, P75, P90, P99;
public float MaxMidSlope, MinSlope; // curve-space; identical every seed by construction
public float BandGradP50, BandGradP95; // ⭐ metres per pixel through the 100-220 m band
public long BandCells;
public float GrayMin, GrayMax;
public bool ClassifyOk = true, SeaOk = true;
public ulong ElapsedMs;
}
private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a, string label)
=> new TerrainGenConfig
{
MapSize = mapSize, Seed = seed, VariantLabel = label,
Curve = true, ShelfDetail = false, Knots = k, Anchors = a,
LowlandCeilingM = LowlandCeilingM,
};
private static SeedMetrics WriteSeed(string batchRoot, Pass1Result p1, Pass2Result p2,
float sea, CurveAnchors anchors, bool skipRaw)
{
string dir = Path.Combine(batchRoot, $"{p2.Seed}");
DirAccess.MakeDirRecursiveAbsolute(dir);
// ⭐ Plain data beside the pretty render. The grayscale is the honest instrument; the
// relief is for eye-appeal and hillshade.
var (gMin, gMax) = GrayscaleRenderer.SavePng(p2.Height, p2.MapSize, Path.Combine(dir, "grayscale.png"));
if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
var look = new LookConfig
{
Name = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f,
HillshadeStrength = 0.30f, SeaLevel = sea,
};
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, $"SEED {p2.Seed}")
.SavePng(Path.Combine(dir, "relief.png"));
var (a100, a220) = ShapingOracle.LandAbove(p2, sea);
var land = new LandHistogram(sea);
land.Accumulate(p2.Height, p2.MapSize);
// ⚠ Curve-space slope: recorded for completeness, but it is the SAME on every seed by
// construction (see the type header). It is NOT the discriminator.
var (minN, _, maxMid, _, _, _) = p2.Continuous.SampleClimbSlopes();
// ⭐ THE ACTUAL DISCRIMINATOR: how fast the ground climbs through 100-220 m, in metres
// per pixel. This is what "the yellow-to-orange step looks abrupt" is a reaction to.
var (bandP50, bandP95, bandCells) = MidBandGradient(p2.Height, p2.MapSize, sea);
return new SeedMetrics
{
Seed = p2.Seed, HMin = p2.HMin, HMax = p2.HMax, HMaxSeed = p1.HMaxSeed,
LandPct = p2.LandFraction(sea) * 100.0, Above100 = a100, Above220 = a220,
P50 = land.Quantile(50), P75 = land.Quantile(75),
P90 = land.Quantile(90), P99 = land.Quantile(99),
MaxMidSlope = maxMid, MinSlope = minN, GrayMin = gMin, GrayMax = gMax,
BandGradP50 = bandP50, BandGradP95 = bandP95, BandCells = bandCells,
};
}
/// <summary>
/// Median and p95 of the spatial height gradient, in METRES PER PIXEL, over land cells whose
/// height falls in the 100-220 m band — the stretch the developer flagged as reading abrupt.
///
/// Central differences on the interior; edge cells are skipped rather than one-sided, because
/// the map border is the Trench's synthetic wall and its gradient is not terrain.
///
/// ⚠ Gradients are collected into a coarse histogram rather than a list: at 8192 the band can
/// hold millions of cells and sorting them all to take two quantiles would cost more than the
/// render. 0.01 m/px bins are far finer than any difference worth reading.
/// </summary>
private static (float p50, float p95, long cells) MidBandGradient(float[,] h, int n, float sea)
{
float lo = sea + WorldScale.RawFromMetres(100f);
float hi = sea + WorldScale.RawFromMetres(220f);
const float binW = 0.01f; // metres per pixel
const int bins = 4000; // up to 40 m/px — far beyond anything real
var hist = new long[bins + 1];
long cells = 0;
for (int x = 1; x < n - 1; x++)
{
for (int y = 1; y < n - 1; y++)
{
float v = h[x, y];
if (v < lo || v > hi) continue;
float gx = (h[x + 1, y] - h[x - 1, y]) * 0.5f;
float gy = (h[x, y + 1] - h[x, y - 1]) * 0.5f;
float g = WorldScale.MetresFromRaw(MathF.Sqrt(gx * gx + gy * gy));
int b = (int)(g / binW);
hist[b >= bins ? bins : b]++;
cells++;
}
}
if (cells == 0) return (0f, 0f, 0);
float Q(double q)
{
long target = (long)(q * cells), cum = 0;
for (int i = 0; i <= bins; i++)
{
cum += hist[i];
if (cum >= target) return (i + 0.5f) * binW;
}
return bins * binW;
}
return (Q(0.50), Q(0.95), cells);
}
// ---- metrics + notes round-trip -------------------------------------
private static void WriteMetrics(string path, List<SeedMetrics> ms)
{
var sb = new StringBuilder();
sb.AppendLine("seed\tland\ta100\ta220\tp50\tp75\tp90\tp99\tmidslope\tminslope\thmaxseed\tgmin\tgmax\tok\tms\tgradp50\tgradp95\tbandcells");
foreach (var m in ms)
sb.AppendLine($"{m.Seed}\t{m.LandPct:F2}\t{m.Above100:F3}\t{m.Above220:F3}\t{m.P50:F4}\t{m.P75:F4}\t" +
$"{m.P90:F4}\t{m.P99:F4}\t{m.MaxMidSlope:F4}\t{m.MinSlope:F4}\t{m.HMaxSeed:F4}\t" +
$"{m.GrayMin:F4}\t{m.GrayMax:F4}\t{(m.ClassifyOk && m.SeaOk ? 1 : 0)}\t{m.ElapsedMs}\t" +
$"{m.BandGradP50:F4}\t{m.BandGradP95:F4}\t{m.BandCells}");
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(sb.ToString());
}
private static List<SeedMetrics> ReadMetrics(string path)
{
var outp = new List<SeedMetrics>();
if (!Godot.FileAccess.FileExists(path)) return outp;
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read);
if (f == null) return outp;
string all = f.GetAsText();
bool header = true;
foreach (string line in all.Split('\n'))
{
if (header) { header = false; continue; }
if (string.IsNullOrWhiteSpace(line)) continue;
string[] c = line.Split('\t');
if (c.Length < 15) continue; // older rows without the gradient columns still load
outp.Add(new SeedMetrics
{
Seed = int.Parse(c[0]), LandPct = double.Parse(c[1]),
Above100 = double.Parse(c[2]), Above220 = double.Parse(c[3]),
P50 = float.Parse(c[4]), P75 = float.Parse(c[5]),
P90 = float.Parse(c[6]), P99 = float.Parse(c[7]),
MaxMidSlope = float.Parse(c[8]), MinSlope = float.Parse(c[9]),
HMaxSeed = float.Parse(c[10]), GrayMin = float.Parse(c[11]), GrayMax = float.Parse(c[12]),
ClassifyOk = c[13].Trim() == "1", SeaOk = c[13].Trim() == "1",
ElapsedMs = ulong.Parse(c[14].Trim()),
BandGradP50 = c.Length > 15 ? float.Parse(c[15]) : 0f,
BandGradP95 = c.Length > 16 ? float.Parse(c[16]) : 0f,
BandCells = c.Length > 17 ? long.Parse(c[17].Trim()) : 0L,
});
}
return outp;
}
/// <summary>
/// The human character notes, if they have been written yet. Format: <c>seed\tnote</c>.
/// Absent is a normal state, not an error — the first run cannot have them.
/// </summary>
private static Dictionary<int, string> ReadNotes(string path)
{
var notes = new Dictionary<int, string>();
if (!Godot.FileAccess.FileExists(path)) return notes;
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read);
if (f == null) return notes;
foreach (string line in f.GetAsText().Split('\n'))
{
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#")) continue;
string[] c = line.Split('\t');
if (c.Length < 2) continue;
if (int.TryParse(c[0].Trim(), out int s)) notes[s] = c[1].Trim();
}
return notes;
}
// ---- the contact sheet ----------------------------------------------
private static void WriteIndex(string batchRoot, List<SeedMetrics> ms, Dictionary<int, string> notes,
int anchor, int gallerySize, int calibSize, string notesPath)
{
var sb = new StringBuilder();
sb.AppendLine("# Batch 04 — seed gallery: the committed curve across 8 draws");
sb.AppendLine();
sb.AppendLine("**A contact sheet, not a tuning batch.** Every plate is the SAME curve — the one committed");
sb.AppendLine("at tag `terrain-curve-v1` (chat2/03 `continuous_restored`). The question is whether it holds");
sb.AppendLine("up across draws, or whether the anchor seed was lucky.");
sb.AppendLine();
sb.AppendLine($"- **Curve (pinned, no env override):** `mountainLift {MountainLift:F2}` · " +
$"`peakSharpness {PeakSharpness:F2}` · `lowlandCeiling {LowlandCeilingM:F0} m`");
sb.AppendLine($"- **Rendered at:** {gallerySize} · **calibration pool measured at:** {calibSize} (task 01's six seeds)");
sb.AppendLine($"- **Anchor:** `{anchor}` — the plate tasks 0103 were judged on. **Compare the others to it.**");
sb.AppendLine($"- **Palette:** ⚠ `ProvisionalEven` — evenly spaced SEA → 420 m, **provisional**. The");
sb.AppendLine(" grayscale is the honest instrument; the relief is for shape and hillshade.");
sb.AppendLine();
sb.AppendLine("## ⭐ The contact sheet");
sb.AppendLine();
bool anyNotes = notes.Count > 0;
sb.AppendLine("| Seed | Character | Relief | Grayscale | land | >100 m | >220 m | p90 | 100220 m grade (p50 / p95) |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
foreach (var m in ms)
{
string tag = m.Seed == anchor ? " ⭐" : "";
string note = notes.TryGetValue(m.Seed, out string n) ? n : "*(pending)*";
string warn = m.ClassifyOk && m.SeaOk ? "" : " ⚠⚠ INVARIANT FAILED";
sb.AppendLine($"| `{m.Seed}`{tag} | {note}{warn} | [`{m.Seed}/relief.png`]({m.Seed}/relief.png) | " +
$"[`grayscale.png`]({m.Seed}/grayscale.png) | {m.LandPct:F1}% | {m.Above100:F2}% | " +
$"{m.Above220:F2}% | {WorldScale.MetresFromRaw(m.P90 - 0.15f):F0} m | " +
$"{m.BandGradP50:F2} / {m.BandGradP95:F2} m per px |");
}
sb.AppendLine();
if (!anyNotes)
{
sb.AppendLine("> ⚠ **Character notes are pending.** They are an EYE call and this tool will not invent");
sb.AppendLine($"> them. Write `{Path.GetFileName(notesPath)}` in `scratch/` as `seed<TAB>note` lines, then");
sb.AppendLine("> re-run with `ISLA_INDEX_ONLY=1` to rebuild this table without re-rendering.");
sb.AppendLine();
}
sb.AppendLine("**100220 m grade** is the spatial height gradient through the band where the yellow→orange");
sb.AppendLine("transition sits — metres of climb per pixel, median and p95 over the land in that band. It is");
sb.AppendLine("the objective companion to the eye's *\"is that step abrupt?\"*: near-constant across seeds");
sb.AppendLine("means abruptness is a **curve trait** worth a tuning pass; a wide spread means it is a **draw**.");
sb.AppendLine();
sb.AppendLine("> ⚠ The curve's own normalized mid-slope is deliberately NOT tabulated: it is `dv/du` on a");
sb.AppendLine("> fixed control polygon, and each seed's denormalization rescales both axes, so it is the same");
sb.AppendLine("> number on every seed **by construction** and can discriminate nothing.");
sb.AppendLine();
sb.AppendLine("## Spread");
sb.AppendLine();
if (ms.Count > 0)
{
double lo100 = double.MaxValue, hi100 = double.MinValue, sum100 = 0;
float loMid = float.MaxValue, hiMid = float.MinValue;
double sumG = 0;
foreach (var m in ms)
{
lo100 = Math.Min(lo100, m.Above100); hi100 = Math.Max(hi100, m.Above100); sum100 += m.Above100;
loMid = MathF.Min(loMid, m.BandGradP50); hiMid = MathF.Max(hiMid, m.BandGradP50);
sumG += m.BandGradP50;
}
sb.AppendLine($"- **land >100 m:** {lo100:F2} % … {hi100:F2} % (mean {sum100 / ms.Count:F2} %)");
sb.AppendLine($"- **100220 m grade (p50):** {loMid:F2} … {hiMid:F2} m per px " +
$"(mean {sumG / ms.Count:F2}) — spread {(hiMid - loMid) / (sumG / ms.Count) * 100:F0}% of the mean");
sb.AppendLine();
}
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `relief.png`, `INDEX.md` | **keep** — the gallery |");
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + the tagged curve — **large, clear freely** |");
sb.AppendLine("| `scratch/metrics.tsv`, `scratch/character_notes.tsv` | **keep** — the INDEX is rebuilt from them |");
sb.AppendLine();
sb.AppendLine("Every `.f32` here is reproducible from `git checkout terrain-curve-v1` plus the seed, so the");
sb.AppendLine("bulk of this batch is safe to delete once the gallery has been judged.");
string index = Path.Combine(batchRoot, "INDEX.md");
using var f = Godot.FileAccess.Open(index, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {index}"); return; }
f.StoreString(sb.ToString());
}
// ---- env helpers ----------------------------------------------------
private static string EnvStr(string k, string fallback)
{
string v = System.Environment.GetEnvironmentVariable(k);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}
private static int EnvInt(string k, int fallback)
=> int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
private static int[] EnvSeeds(string k, int[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<int>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
}
}

View file

@ -0,0 +1 @@
uid://bkh7iu47is60q

View file

@ -0,0 +1,34 @@
using Godot;
namespace IslaApocalypse.Tools
{
/// <summary>
/// PURE HILLSHADE (chat2/11) — a grayscale slope/aspect plate with no hypsometric tint, for
/// reading low-amplitude surface detail (erosion's drainage is half a metre on average) that the
/// palette-blended relief hides. Land shaded; sea a flat dark. ⚠ PRESENTATION ONLY — a look dial,
/// never a claim about the world (→ Hillshade).
/// </summary>
public static class ShadeRenderer
{
public static Image Render(float[,] height, int mapSize, float sea, float zExaggeration, float azimuth, float altitude)
{
var (lx, ly, lz) = Hillshade.LightVector(azimuth, altitude);
var img = Image.CreateEmpty(mapSize, mapSize, false, Image.Format.Rgb8);
var seaColor = new Color(0.10f, 0.14f, 0.22f);
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
{
if (height[x, y] < sea) { img.SetPixel(x, y, seaColor); continue; }
float s = Hillshade.At(height, mapSize, x, y, zExaggeration, lx, ly, lz);
img.SetPixel(x, y, new Color(s, s, s));
}
return img;
}
public static void SavePng(float[,] height, int mapSize, float sea, float zExaggeration, float azimuth, float altitude, string absolutePath)
{
Error err = Render(height, mapSize, sea, zExaggeration, azimuth, altitude).SavePng(absolutePath);
if (err != Error.Ok) GD.PrintErr($"[ShadeRenderer] SavePng failed ({err}) for {absolutePath}");
}
}
}

View file

@ -0,0 +1 @@
uid://4k3liidno15g

View file

@ -74,7 +74,8 @@ namespace IslaApocalypse.Tools
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);
elapsedMs: Time.GetTicksMsec() - t0, notes: notes,
isIsland: p1.IsIsland, islandHemisphere: p1.IslandHemisphere);
}
// ═══ WHICH CURVE (chat2/02) ═══
@ -210,7 +211,8 @@ namespace IslaApocalypse.Tools
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);
elapsedMs: Time.GetTicksMsec() - t0, notes: notes,
isIsland: p1.IsIsland, islandHemisphere: p1.IslandHemisphere);
}
/// <summary>
@ -238,10 +240,15 @@ namespace IslaApocalypse.Tools
// 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);
// ⭐ CALIBRATED when a measurement is supplied (chat2/03 — the staircase's mountain with
// the terraces melted out), ANALYTIC otherwise (chat2/02's feather/drama curve, kept as
// the "before" contrast). Both throw and refuse rather than degrade; the tool's _Ready
// catches and Quit(2)s.
var curve = cfg.ClimbCalibration != null
? ContinuousCurve.BuildCalibrated(knots, anchors, spikeMax,
cfg.LowlandCeilingM, cfg.ClimbCalibration)
: 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.
@ -275,7 +282,8 @@ namespace IslaApocalypse.Tools
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);
elapsedMs: Time.GetTicksMsec() - t0, notes: notes,
isIsland: p1.IsIsland, islandHemisphere: p1.IslandHemisphere);
}
/// <summary>
@ -328,7 +336,8 @@ namespace IslaApocalypse.Tools
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);
elapsedMs: Time.GetTicksMsec() - t0, notes: notes,
isIsland: p1.IsIsland, islandHemisphere: p1.IslandHemisphere);
}
}
}

View file

@ -349,6 +349,332 @@ namespace IslaApocalypse.Tools
return c;
}
/// <summary>
/// (g) ⭐ MOUNTAIN RESTORED (chat2/03) — how much land ends up above 100 m and 220 m,
/// against the staircase's own figures.
///
/// ═══ ⚠ REPORTED, NOT GATED ═══
///
/// This is a taste target the developer tunes, so a miss is a FINDING, not a build failure —
/// gating it would make `mountainLift` unusable as a knob, since every value but one would
/// fail the run. What it must never do is stay silent: chat2/02 lost two thirds of the
/// mountain and only found out because someone went looking at the dumps afterwards. This
/// check is that look, made automatic.
///
/// <paramref name="tolerancePp"/> only decides whether the row reads PASS or NOTE; the
/// numbers are always printed.
/// </summary>
public static Check MountainRestored(Pass2Result variant, Pass2Result staircase,
float seaLevel, double tolerancePp)
{
var c = new Check { Id = "g", Name = $"mountain restored vs staircase [{variant.VariantLabel}]" };
var (v100, v220) = LandAbove(variant, seaLevel);
var (s100, s220) = LandAbove(staircase, seaLevel);
double d100 = v100 - s100;
c.Passed = Math.Abs(d100) <= tolerancePp;
c.Detail = $">100 m: {v100:F2} % vs staircase {s100:F2} % ({d100:+0.00;-0.00} pp) · " +
$">220 m: {v220:F2} % vs {s220:F2} % ({v220 - s220:+0.00;-0.00} pp)";
return c;
}
/// <summary>Percentage of LAND above 100 m and 220 m of world height. Land = at/above sea.</summary>
public static (double above100, double above220) LandAbove(Pass2Result p2, float seaLevel)
{
float t100 = seaLevel + WorldScale.RawFromMetres(100f);
float t220 = seaLevel + WorldScale.RawFromMetres(220f);
long land = 0, a100 = 0, a220 = 0;
for (int x = 0; x < p2.MapSize; x++)
{
for (int y = 0; y < p2.MapSize; y++)
{
float h = p2.Height[x, y];
if (h < seaLevel) continue;
land++;
if (h > t100) a100++;
if (h > t220) a220++;
}
}
return land == 0 ? (0.0, 0.0) : (100.0 * a100 / land, 100.0 * a220 / land);
}
// ═══ chat2/05 — the offshore checks ═══
// (h) was the chat2/05 seeded-floor check — reverted out with the floor in chat2/06. No
// count is guaranteed any more, so there is nothing for an oracle to assert; the count
// table is the evidence, and it is statistics, not a check.
/// <summary>
/// (i) ⭐ MOAT INTACT — no offshore island is 8-connected to mainland land. The moat exists
/// to make a land bridge impossible; this is the proof that it did.
/// </summary>
public static Check MoatIntact(Pass1Result p1, List<IslandComponent> comps)
{
var c = new Check { Id = "i", Name = "moat intact — no island touches the mainland" };
int bridged = OffshoreAnalysis.BridgedCount(comps);
c.Passed = p1.HasIslandTag && bridged == 0;
c.Detail = !p1.HasIslandTag ? "no island tag — nothing to check"
: bridged == 0 ? $"all {comps.Count} islands are separated from mainland by water"
: $"{bridged} island(s) BRIDGE to mainland land";
return c;
}
/// <summary>
/// (j) ⭐ MAINLAND UNMOVED — with offshore on vs off, every cell that was LAND with it off is
/// BIT-IDENTICAL with it on. The shelf touches only below-sea cells, the islets only lift
/// below-sea cells; neither may touch existing land. (The falloff test and the moat did their
/// job if this holds.) Reports how many sea cells the shelf moved and how many were lifted.
/// </summary>
public static Check MainlandUnmoved(Pass1Result off, Pass1Result on, float sea)
{
var c = new Check { Id = "j", Name = "mainland unmoved — every offshore-OFF land cell bit-identical with offshore ON" };
long land = 0, landDiff = 0, seaChanged = 0, lifted = 0;
string first = null;
for (int x = 0; x < off.MapSize; x++)
{
for (int y = 0; y < off.MapSize; y++)
{
float a = off.Height[x, y], b = on.Height[x, y];
if (a >= sea)
{
land++;
if (BitConverter.SingleToInt32Bits(a) != BitConverter.SingleToInt32Bits(b))
{
landDiff++;
first ??= $"first at [{x},{y}]: {a:G9} → {b:G9}";
}
}
else
{
if (a != b) seaChanged++;
if (b >= sea) lifted++;
}
}
}
c.Passed = landDiff == 0;
c.Detail = landDiff == 0
? $"all {land:N0} land cells bit-identical; {seaChanged:N0} sea cells remapped by the shelf, {lifted:N0} lifted to land"
: $"{landDiff:N0} of {land:N0} land cells CHANGED — {first}";
return c;
}
/// <summary>
/// (k) TAG ↔ COASTLINE CONSISTENT — per cell, classify-land ⇔ render-land (the curve is
/// identity at sea and monotone above, so it must be — D-046), and every TAGGED cell is land
/// in both fields. This is what lets the tag be carried through pass 2 without recomputation.
/// </summary>
public static Check TagCoastlineConsistent(Pass2Result p2, float sea)
{
var c = new Check { Id = "k", Name = "offshore tag: classify/render coastline consistent, every tagged cell is land" };
long mismatch = 0, tagNotLand = 0, tagged = 0;
for (int x = 0; x < p2.MapSize; x++)
{
for (int y = 0; y < p2.MapSize; y++)
{
bool cl = p2.HeightClassify[x, y] >= sea;
bool rl = p2.Height[x, y] >= sea;
if (cl != rl) mismatch++;
if (p2.IsIsland != null && p2.IsIsland[x, y])
{
tagged++;
if (!cl || !rl) tagNotLand++;
}
}
}
c.Passed = mismatch == 0 && tagNotLand == 0;
c.Detail = $"{mismatch:N0} classify/render landness mismatches; {tagNotLand:N0} of {tagged:N0} tagged cells not land";
return c;
}
/// <summary>
/// (l) HMaxSeed RECOMPUTED AFTER SHELF + OFFSHORE — reported. Expected unchanged (a ~34 m
/// crest vs a ~290 m peak), but the ORDER is the fix (chat2/00 Drift §2), and the value is
/// measured rather than assumed. Always passes; the detail is the point.
/// </summary>
public static Check HMaxAfterOffshore(Pass1Result p1)
{
bool moved = p1.HMaxSeed != p1.HMaxSeedBeforeOffshore;
return new Check
{
Id = "l", Name = "HMaxSeed recomputed after shelf + offshore",
Passed = true,
Detail = $"before {p1.HMaxSeedBeforeOffshore:F6} → after {p1.HMaxSeed:F6} " +
(moved ? "— ⚠ MOVED (an islet outran the peak?)" : "— unchanged, as expected; the ORDER is now right by construction"),
};
}
// ═══ chat2/07 — the region checks ═══
/// <summary>(m) CENTRE IS LAND — the mainland definition held (the massif is centred); the fallback was not needed.</summary>
public static Check CentreIsLand(Pass1Result p1)
{
var c = new Check { Id = "m", Name = "mainland = centre component (centre cell is land, no fallback)" };
if (p1.Regions == null) { c.Passed = false; c.Detail = "no region labeling on this field"; return c; }
var m = p1.Regions.Mainland;
c.Passed = p1.Regions.CentreWasLand && m != null && (p1.RegionLedger == null || p1.RegionLedger.CentreWasLandPre);
c.Detail = c.Passed
? $"centre is land; mainland id {p1.Regions.MainlandId}, {m.SizeCells:N0} cells, centroid ({m.CentroidX:F0},{m.CentroidY:F0}); {p1.Regions.IslandCount} islands"
: "⚠ CENTRE CELL IS NOT LAND — fell back to the largest component";
return c;
}
/// <summary>
/// (n) ⭐ THE REVERT GUARDS, RE-PROVEN ON THE FIELDS — filter OFF vs ON: every cell of the OFF
/// field's MAINLAND component is bit-identical; every cell that changed was land in a
/// sub-threshold NON-MAINLAND component of the OFF labeling (component-only) and went DOWN, to
/// below sea (lower-only); nothing else moved. The pass asserted this as it ran; this is the
/// independent proof on the finished fields.
/// </summary>
public static Check RevertGuards(Pass1Result off, Pass1Result on, float sea, long thresholdCells)
{
var c = new Check { Id = "n", Name = "speck revert: mainland bit-identical, every change is in a sub-threshold island and lower-only" };
if (off.Regions == null) { c.Passed = false; c.Detail = "the OFF field has no region labeling"; return c; }
int n = off.MapSize;
var lab = off.Regions;
long mainland = 0, mainlandDiff = 0, changed = 0, notIsland = 0, notSmall = 0, raised = 0, notSea = 0;
string first = null;
for (int x = 0; x < n; x++)
{
for (int y = 0; y < n; y++)
{
float a = off.Height[x, y], b = on.Height[x, y];
int id = lab.Id[x * n + y];
bool isMain = id != 0 && id == lab.MainlandId;
if (isMain) mainland++;
if (BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b)) continue;
changed++;
if (isMain) { mainlandDiff++; first ??= $"mainland cell [{x},{y}] {a:G9} → {b:G9}"; continue; }
if (id == 0) { notIsland++; first ??= $"sea cell [{x},{y}] changed {a:G9} → {b:G9}"; continue; }
if (lab.Regions[id - 1].SizeCells >= thresholdCells) { notSmall++; first ??= $"cell [{x},{y}] of component {id} ({lab.Regions[id - 1].SizeCells} cells ≥ {thresholdCells}) changed"; }
if (b >= a) { raised++; first ??= $"cell [{x},{y}] RAISED {a:G9} → {b:G9}"; }
if (b >= sea) { notSea++; first ??= $"cell [{x},{y}] still land after revert ({b:G9})"; }
}
}
c.Passed = mainlandDiff == 0 && notIsland == 0 && notSmall == 0 && raised == 0 && notSea == 0;
c.Detail = c.Passed
? $"all {mainland:N0} mainland cells bit-identical; {changed:N0} cells changed, every one in a sub-threshold island, lowered below sea"
: $"VIOLATION — mainland {mainlandDiff:N0} / non-island {notIsland:N0} / over-threshold {notSmall:N0} / raised {raised:N0} / still land {notSea:N0} — {first}";
return c;
}
/// <summary>(o) LABELS DETERMINISTIC — two generations of the same seed: id maps and component tables identical.</summary>
public static Check LabelsDeterministic(Pass1Result a, Pass1Result b)
{
var c = new Check { Id = "o", Name = "region ids deterministic per seed (two runs, id map + table identical)" };
if (a.Regions == null || b.Regions == null) { c.Passed = false; c.Detail = "no region labeling"; return c; }
long diff = 0; int n = a.MapSize;
for (int i = 0; i < n * n; i++) if (a.Regions.Id[i] != b.Regions.Id[i]) diff++;
bool table = a.Regions.Regions.Count == b.Regions.Regions.Count && a.Regions.MainlandId == b.Regions.MainlandId;
if (table)
for (int i = 0; i < a.Regions.Regions.Count; i++)
{
var ra = a.Regions.Regions[i]; var rb = b.Regions.Regions[i];
if (ra.SizeCells != rb.SizeCells || ra.CentroidX != rb.CentroidX || ra.CentroidY != rb.CentroidY || ra.Hemisphere != rb.Hemisphere || ra.IsMainland != rb.IsMainland) { table = false; break; }
}
c.Passed = diff == 0 && table;
c.Detail = c.Passed ? $"{a.Regions.Regions.Count} components, id map identical over {(long)n * n:N0} cells, tables identical"
: $"{diff:N0} id cells differ; tables {(table ? "identical" : "DIFFER")}";
return c;
}
// ═══ chat2/08 — the southern-stretch band checks ═══
/// <summary>
/// (p) ⭐ NORTH BIT-LOCKED, BOTH DIRECTIONS — every cell with y &lt; <paramref name="bandRow"/> is
/// bit-identical between the two fields (no land lowered, no sea raised); reports how many cells
/// differ at/below the band (allowed — that is the relaxation). The surgical guarantee, per cell.
/// </summary>
public static Check NorthLocked(string id, string name, float[,] a, float[,] b, int mapSize, int bandRow)
{
var c = new Check { Id = id, Name = name };
long northDiff = 0, southDiff = 0, north = 0; string first = null;
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
{
bool same = BitConverter.SingleToInt32Bits(a[x, y]) == BitConverter.SingleToInt32Bits(b[x, y]);
if (y < bandRow) { north++; if (!same) { northDiff++; first ??= $"[{x},{y}] {a[x, y]:G9} vs {b[x, y]:G9}"; } }
else if (!same) southDiff++;
}
c.Passed = northDiff == 0;
c.Detail = c.Passed
? $"all {north:N0} cells north of row {bandRow} bit-identical; {southDiff:N0} cells differ in the band or below (the relaxation)"
: $"{northDiff:N0} cells north of row {bandRow} DIFFER — {first}";
return c;
}
/// <summary>(q) ⭐ NORTHERN ISLANDS INVARIANT — the multiset of north-hemisphere island components (size, centroid) is identical between two labelings.</summary>
public static Check NorthIslandsInvariant(string name, RegionLabels a, RegionLabels b)
{
var c = new Check { Id = "q", Name = name };
if (a == null || b == null) { c.Passed = false; c.Detail = "no region labeling"; return c; }
var sa = NorthSet(a); var sb = NorthSet(b);
bool same = sa.Count == sb.Count;
if (same) for (int i = 0; i < sa.Count; i++) if (sa[i] != sb[i]) { same = false; break; }
c.Passed = same;
c.Detail = same ? $"{sa.Count} northern islands, identical (size + centroid)" : $"DIFFER — {sa.Count} vs {sb.Count} northern islands, or a size/centroid moved";
return c;
}
private static List<string> NorthSet(RegionLabels l)
{
var list = new List<string>();
foreach (var r in l.Regions)
if (!r.IsMainland && r.Hemisphere == RegionLabeling.HemiNorth)
list.Add($"{r.SizeCells}:{r.CentroidX:F3}:{r.CentroidY:F3}");
list.Sort(StringComparer.Ordinal);
return list;
}
// ═══ chat2/09 — the coastal-fragmentation checks ═══
/// <summary>
/// (r) ⭐ INTERIOR LOCKED — every cell whose BASELINE pre-trench falloff is clear of the coastal
/// window (weight exactly 0: the interior, the massif, the deep sea) is bit-identical between the
/// baseline and the fragmented field. Reports how many cells changed inside the window (the
/// coast, allowed). The proof that fragmentation cannot reach inland.
/// </summary>
public static Check InteriorLocked(Pass1Result baseline, Pass1Result frag, float centre, float halfWidth)
{
var c = new Check { Id = "r", Name = "interior locked — every cell clear of the coastal window bit-identical (classify)" };
int n = baseline.MapSize; long outside = 0, outsideDiff = 0, inside = 0, insideDiff = 0; string first = null;
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
bool inWindow = MathF.Abs(baseline.PreTrenchFalloff[x, y] - centre) < halfWidth;
bool same = BitConverter.SingleToInt32Bits(baseline.Height[x, y]) == BitConverter.SingleToInt32Bits(frag.Height[x, y]);
if (inWindow) { inside++; if (!same) insideDiff++; }
else { outside++; if (!same) { outsideDiff++; first ??= $"[{x},{y}] f {baseline.PreTrenchFalloff[x, y]:F3}: {baseline.Height[x, y]:G9} → {frag.Height[x, y]:G9}"; } }
}
c.Passed = outsideDiff == 0;
c.Detail = c.Passed
? $"all {outside:N0} cells outside the window bit-identical; {insideDiff:N0} of {inside:N0} window cells changed (the coast)"
: $"{outsideDiff:N0} cells OUTSIDE the window changed — {first}";
return c;
}
/// <summary>
/// (s) informational — HIGH GROUND: of the cells whose BASELINE raw height is at or above
/// <paramref name="rawThreshold"/>, how many changed, and the largest change. A coastal hill
/// inside the window may legitimately move in height without flipping; this reports it.
/// </summary>
public static Check HighGroundReport(Pass1Result baseline, Pass1Result frag, float rawThreshold, string thresholdLabel)
{
var c = new Check { Id = "s", Name = $"(informational) high ground ≥ {thresholdLabel}: cells changed / largest |Δ|", Passed = true };
int n = baseline.MapSize; long high = 0, changed = 0; float maxAbs = 0f;
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
float a = baseline.Height[x, y]; if (a < rawThreshold) continue;
high++;
float b = frag.Height[x, y];
if (a != b) { changed++; float d = MathF.Abs(b - a); if (d > maxAbs) maxAbs = d; }
}
c.Detail = $"{changed:N0} of {high:N0} high cells changed; largest |Δ| {maxAbs:G4} raw ({Core.WorldScale.MetresFromRaw(maxAbs):F1} m)";
return c;
}
/// <summary>Render the whole oracle as a markdown table for the INDEX and the report.</summary>
public static string ToMarkdownTable(IEnumerable<Check> checks)
{

View file

@ -0,0 +1,57 @@
using System;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE SOUTHERN STRETCH (chat2/08, EXPLORATION) — the one deliberate relaxation of sea identity,
/// confined to a FIXED feathered latitude band.
///
/// ═══ THE MECHANISM (read from pass 1, chat2/08 diagnostic) ═══
///
/// Pass 1's mask is falloff = ½·ellipse + ½·squircle (+ edge noise · squircle), then the SOUTHERN
/// SINKER adds 0.6 · (y 0.75N)/(0.25N) for y &gt; 0.75N, BEFORE the 2.5 power; the coast sits
/// where rawBase falloff^2.5 crosses sea, i.e. near falloff ≈ 0.66. Every term that grows with y
/// is a function of the SOUTHWARD DISTANCE. The stretch compresses that distance inside the band:
///
/// y' = yB + (y yB) / (1 + stretch · ramp(y)) ramp = smoothstep over the feather
///
/// so a cell at y takes the mask geometry of the row y' north of it — the mass reaches further
/// south AND keeps the elevation profile of the rows it came from (the base noise, edge noise and
/// latitude field keep the real y — texture stays, only the mask's geometry stretches). Where the
/// stretched thin edge drops below sea it fragments organically. Nothing is stamped.
///
/// ⚠ North of the band (y ≤ yB) the caller takes the untouched code path: bit-identical by
/// construction, asserted by the oracle. The band line and feather are constants for a batch;
/// <c>TerrainGenConfig.SouthStretch</c> is the only swept axis.
///
/// The SINKER: <c>TerrainGenConfig.StretchSinker</c> decides whether it rides y' (pushed out with
/// the geometry — held back inside the band) or the real y (keeps pulling the extended mass down
/// where it always did). The chat2/08 diagnostic measured both — see the report.
/// </summary>
public static class SouthernStretch
{
/// <summary>The band's fixed latitude line, fraction of the map. Chosen by the chat2/08 diagnostic (see the report).</summary>
public const float DefaultBandStartFrac = 0.70f;
/// <summary>The feather width, fraction of the map.</summary>
public const float DefaultBandFeatherFrac = 0.05f;
/// <summary>Whether the sinker rides the stretched distance by default. Set by the chat2/08 diagnostic.</summary>
public const bool DefaultStretchSinker = true;
/// <summary>The smoothstep ramp across the feather: 0 at the band line, 1 a feather-width below it.</summary>
public static float Ramp(float y, float bandStart, float bandFeather)
{
float t = Math.Clamp((y - bandStart) / bandFeather, 0f, 1f);
return t * t * (3f - 2f * t);
}
/// <summary>The y the mask geometry sees. For y ≤ bandStart returns y unchanged.</summary>
public static float StretchedY(float y, float bandStart, float bandFeather, float stretch)
{
if (y <= bandStart || stretch <= 0f) return y;
float r = Ramp(y, bandStart, bandFeather);
return bandStart + (y - bandStart) / (1f + stretch * r);
}
}
}

View file

@ -0,0 +1 @@
uid://dttggsatk4hh0

View file

@ -0,0 +1,631 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE SOUTHERN-STRETCH EXPLORATION BATCH (chat2/08) — map the fragmentation knob space: a
/// peninsula → few-big-pieces → gravel ladder, 5 stretch levels × 2 seeds, with the region layer
/// as the instrument. NOT a converged setting.
///
/// ═══ TWO MODES ═══
///
/// ISLA_DIAG_ONLY=1 the DIAGNOSTIC (numbers, no plates): the three southern forces along a
/// south-running profile (falloff blend / edge noise / sinker, each alone),
/// the reach table for the candidate seeds, and a stretch sweep under both
/// sinker modes — written to scratch/southern_diagnosis.md. Run first; it
/// sets the ladder.
/// (default) the BATCH: 5 levels × 2 seeds at ISLA_MAPSIZE, lean render per field
/// (labeled-regions overlay + relief + .f32), the hemisphere-split count/size
/// table, the asymmetric oracle.
///
/// Every field: pass 1 + the stretch, region labeling ON, offshore OFF, shelf OFF, speck revert OFF
/// — the pure fragmentation signal (the instrument counts "all islands" and "islands ≥ the 07
/// mid threshold" side by side). The curve is the tagged curve, unchanged.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/SouthernStretchTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
/// ISLA_MAPSIZE plate size (default 4096)
/// ISLA_CALIB_SIZE curve calibration + diagnostic size (default 2048)
/// ISLA_SEEDS the two batch seeds (default: auto — the two 07 seeds with the most southern mass)
/// ISLA_CANDIDATE_SEEDS the pool the auto-pick reads (default: the 07 table seeds)
/// ISLA_STRETCH_LEVELS the 5 stretch values (default: the diagnostic-chosen ladder below)
/// ISLA_BAND_START / ISLA_BAND_FEATHER the fixed band (fractions of the map; constants for the batch)
/// ISLA_STRETCH_SINKER 1 = the sinker rides the stretched distance (default), 0 = real y
/// ISLA_DIAG_ONLY=1 diagnostic only
/// ISLA_SKIP_8K=1 skip the 8192 band regression (a4b)
/// </summary>
public partial class SouthernStretchTool : Node
{
private static readonly int[] DefaultCandidateSeeds =
{
1063685222, 20260821, 8675309, 123456789, 271828182, 999999937, 90210, 424242,
};
/// <summary>⚠ Task 01's pool, verbatim — the curve's identity.</summary>
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
/// <summary>
/// ⭐ THE LADDER — chosen by the diagnostic (chat2/08 report §1), not linear: the stretch
/// bites unevenly, so the steps are spaced where the southern count/size actually moves.
/// </summary>
private static readonly float[] DefaultLadder = { 0.5f, 1.0f, 2.0f, 3.0f, 5.0f };
/// <summary>The diagnostic's sweep (both sinker modes).</summary>
private static readonly float[] DiagSweep = { 0.25f, 0.5f, 1f, 1.5f, 2f, 3f, 5f, 8f, 16f };
private const int DefaultMapSize = 4096;
private const int DefaultCalibSize = 2048;
private const int GallerySize = 8192;
public override void _Ready()
{
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr(e.StackTrace);
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private sealed class Row
{
public int Level; public float Stretch; public int Seed;
public double ReachFrac, MedianCoastFrac; public long MainlandCells, MainlandSouthOfBand;
public int SouthAll, SouthBig, NorthAll, NorthBig;
public long SMin, SMed, SMax, NMax; public double SMean; public int[] SHist;
public bool Ok; public ulong Ms;
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
int task = EnvInt("ISLA_TASK", 8);
string descr = EnvStr("ISLA_BATCH", "southern_stretch_explore");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] candidates = EnvSeeds("ISLA_CANDIDATE_SEEDS", DefaultCandidateSeeds);
int[] seedsEnv = EnvSeeds("ISLA_SEEDS", null);
float[] ladder = EnvFloats("ISLA_STRETCH_LEVELS", DefaultLadder);
float bandStart = EnvFloat("ISLA_BAND_START", SouthernStretch.DefaultBandStartFrac);
float bandFeather = EnvFloat("ISLA_BAND_FEATHER", SouthernStretch.DefaultBandFeatherFrac);
bool stretchSinker = EnvStr("ISLA_STRETCH_SINKER", SouthernStretch.DefaultStretchSinker ? "1" : "0") == "1";
bool diagOnly = EnvStr("ISLA_DIAG_ONLY", "0") == "1";
bool skip8k = EnvStr("ISLA_SKIP_8K", "0") == "1";
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
string t03Source = EnvStr("ISLA_T03_SOURCE", "03_mountain_restore");
string t04Source = EnvStr("ISLA_T04_SOURCE", "04_seed_gallery");
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
string scratch = ToolingPaths.BatchScratch(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(scratch);
var anchors = CurveAnchors.Default;
float sea = 0.15f;
long bigCells4k = Cells(RegionPass.ThresholdMidFrac, mapSize);
GD.Print("==================================================================");
GD.Print(" SOUTHERN STRETCH (chat2/08) — EXPLORATION: map the fragmentation knob space");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} (plates) calibration + diagnostic at {calibSize}");
GD.Print($"band : start {bandStart:F3} of the map (row {(int)(bandStart * mapSize)} at {mapSize}), feather {bandFeather:F3} — FIXED for the batch");
GD.Print($"sinker : {(stretchSinker ? "rides the stretched distance (held back inside the band)" : "real y (keeps pulling the extended mass down)")}");
GD.Print($"ladder : {string.Join(", ", ladder)}");
GD.Print($"fields : pass 1 + stretch · labeling ON · offshore OFF · shelf OFF · speck revert OFF (\"big\" island = ≥ {bigCells4k:N0} cells at {mapSize}, the 07 mid threshold)");
GD.Print($"batch : {batchRoot}{(diagOnly ? " ISLA_DIAG_ONLY the diagnostic, no plates" : "")}");
GD.Print("==================================================================");
// ═══ 0. THE CURVE ═══
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
TerrainGenConfig Cfg(int size, int seed, string label, float stretch, bool sinkerStretched, bool sinkerOn = true, bool edgeOn = true)
{
var c = new TerrainGenConfig
{
MapSize = size, Seed = seed, VariantLabel = label,
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
CoastShelf = false, Offshore = new OffshoreSettings(),
RegionLabeling = true, SpeckRevert = false,
SouthStretch = stretch, SouthBandStartFrac = bandStart, SouthBandFeatherFrac = bandFeather, StretchSinker = sinkerStretched,
SouthernSinker = sinkerOn, EdgeNoise = edgeOn,
};
return c;
}
// ═══ 1. THE SEEDS — southern mass, measured ═══
GD.Print($"\n--- 1. SOUTHERN REACH of the candidate seeds at {calibSize} (offshore off, stretch off) ---");
var reachRows = new List<(int seed, double reach, double median, long southCells, long mainland, int southIslands)>();
int bandRowC = (int)(bandStart * calibSize);
foreach (int s in candidates)
{
var p = Topography.Generate(Cfg(calibSize, s, "reach", 0f, stretchSinker));
var (reach, median, southCells) = Reach(p.Regions, calibSize, bandRowC);
var (n, so) = RegionLabeling.IslandsByHemisphere(p.Regions);
reachRows.Add((s, reach, median, southCells, p.Regions.Mainland.SizeCells, so));
GD.Print($" seed {s,-11} southernmost mainland row {reach:F3} of map, median coast {median:F3}, mainland cells south of band {southCells,9:N0} ({100.0 * southCells / p.Regions.Mainland.SizeCells:F1} % of mainland), natural S islands {so}");
}
int[] seeds = seedsEnv;
if (seeds == null)
{
reachRows.Sort((a, b) => b.southCells.CompareTo(a.southCells));
seeds = new[] { reachRows[0].seed, reachRows[1].seed };
}
GD.Print($" → batch seeds: {seeds[0]}, {seeds[1]}{(seedsEnv == null ? " (auto: the two with the most mainland south of the band)" : " (ISLA_SEEDS)")}");
// ═══ 2. THE DIAGNOSTIC ═══
var diag = new StringBuilder();
diag.AppendLine("# The southern diagnosis — chat2/08 (measured by SouthernStretchTool, ISLA_DIAG_ONLY)");
diag.AppendLine();
diag.AppendLine($"Size {calibSize}. Band start {bandStart:F3} (row {bandRowC}), feather {bandFeather:F3}. y runs south; fractions are y / MapSize.");
diag.AppendLine();
diag.AppendLine("## 1. Southern reach of the candidate seeds (offshore off, stretch off)");
diag.AppendLine();
diag.AppendLine("| seed | southernmost mainland row | median coast row (per column, central 60 %) | mainland cells south of band | % of mainland | natural S islands |");
diag.AppendLine("|---|---|---|---|---|---|");
foreach (var r in reachRows) diag.AppendLine($"| `{r.seed}` | {r.reach:F3} | {r.median:F3} | {r.southCells:N0} | {100.0 * r.southCells / r.mainland:F1} % | {r.southIslands} |");
diag.AppendLine();
diag.AppendLine($"**Batch seeds:** `{seeds[0]}`, `{seeds[1]}`.");
diag.AppendLine();
if (diagOnly)
{
GD.Print($"\n--- 2. THE THREE FORCES along a south-running profile (central 20 % of x, averaged) ---");
foreach (int s in seeds)
{
var full = Topography.Generate(Cfg(calibSize, s, "full", 0f, stretchSinker));
var noSink = Topography.Generate(Cfg(calibSize, s, "nosink", 0f, stretchSinker, sinkerOn: false));
var bare = Topography.Generate(Cfg(calibSize, s, "bare", 0f, stretchSinker, sinkerOn: false, edgeOn: false));
diag.AppendLine($"## 2. The three forces — seed `{s}` (central 20 % of x averaged; falloff terms are PRE-power)");
diag.AppendLine();
diag.AppendLine("| y / N | blend (ellipse+squircle) | edge noise | sinker | total pre-trench | total^2.5 | mean height | land fraction of row |");
diag.AppendLine("|---|---|---|---|---|---|---|---|");
int x0 = (int)(calibSize * 0.40), x1 = (int)(calibSize * 0.60);
for (int yi = 50; yi <= 100; yi += 2)
{
int y = Math.Min(calibSize - 1, yi * calibSize / 100);
double sb = 0, sn = 0, sf = 0, sh = 0; long land = 0; int cnt = 0;
for (int x = x0; x < x1; x++)
{
sb += bare.PreTrenchFalloff[x, y]; sn += noSink.PreTrenchFalloff[x, y]; sf += full.PreTrenchFalloff[x, y];
sh += full.Height[x, y]; if (full.Height[x, y] >= sea) land++; cnt++;
}
double blend = sb / cnt, edge = sn / cnt - blend, sink = sf / cnt - sn / cnt, total = sf / cnt;
diag.AppendLine($"| {y / (double)calibSize:F2} | {blend:F3} | {edge:F3} | {sink:F3} | {total:F3} | {Math.Pow(Math.Max(0, total), 2.5):F3} | {sh / cnt:F3} | {land / (double)cnt:P0} |");
GD.Print($" seed {s} y {y / (double)calibSize:F2}: blend {blend:F3} edge {edge:F3} sinker {sink:F3} total {total:F3} pow {Math.Pow(Math.Max(0, total), 2.5):F3} height {sh / cnt:F3} land {land / (double)cnt:P0}");
}
diag.AppendLine();
}
GD.Print($"\n--- 3. THE STRETCH SWEEP (both sinker modes) ---");
diag.AppendLine("## 3. The stretch sweep — reach, southern islands (all / ≥ mid threshold), sizes, and the northern control");
diag.AppendLine();
long bigC = Cells(RegionPass.ThresholdMidFrac, calibSize);
diag.AppendLine($"\"big\" = ≥ {bigC} cells at {calibSize} (the 07 `threshold_mid`). Reach = southernmost mainland row / N; median coast over the central 60 % of columns.");
diag.AppendLine();
foreach (bool mode in new[] { false, true })
{
diag.AppendLine($"### Sinker on {(mode ? "the STRETCHED distance (held back with the geometry)" : "the REAL y (unchanged)")}");
diag.AppendLine();
diag.AppendLine("| seed | stretch | reach | median coast | mainland cells south of band | S islands all / big | S size med / max | S largest island | N islands (control) |");
diag.AppendLine("|---|---|---|---|---|---|---|---|---|");
foreach (int s in seeds)
{
var sweep = new List<float> { 0f }; sweep.AddRange(DiagSweep);
foreach (float st in sweep)
{
var p = Topography.Generate(Cfg(calibSize, s, $"sweep_{st}", st, mode));
var (reach, median, southCells) = Reach(p.Regions, calibSize, bandRowC);
var stats = Hemi(p.Regions, bigC);
diag.AppendLine($"| `{s}` | {st:G3} | {reach:F3} | {median:F3} | {southCells:N0} | {stats.southAll} / {stats.southBig} | {stats.sMed} / {stats.sMax} | {stats.sMax} | {stats.northAll} |");
GD.Print($" sinker {(mode ? "stretched" : "real ")} seed {s,-11} stretch {st,5:G3} reach {reach:F3} median {median:F3} southCells {southCells,8:N0} S {stats.southAll,3}/{stats.southBig,3} med {stats.sMed,6} max {stats.sMax,7} N {stats.northAll}");
}
}
diag.AppendLine();
}
WriteText(Path.Combine(scratch, "southern_diagnosis.md"), diag.ToString());
GD.Print($"\n diagnosis written: {Path.Combine(scratch, "southern_diagnosis.md")}");
GD.Print(" ISLA_DIAG_ONLY — done; no plates.");
GetTree().Quit(0);
return;
}
// ═══ 3. REGRESSIONS — north bit-identical to terrain-curve-v1; stretch-off bit-identical everywhere ═══
GD.Print($"\n--- 3. REGRESSIONS at {calibSize}, seed {seeds[0]} ---");
var hard = new List<ShapingOracle.Check>();
int plate = seeds[0];
float maxStretch = ladder[ladder.Length - 1];
{
var offCfg = Cfg(calibSize, plate, "off", 0f, stretchSinker);
Pass1Result p1 = Topography.Generate(offCfg);
var curveOff = offCfg.Clone(); curveOff.Curve = false;
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{plate}_full", "height.f32");
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, stretch OFF == Phase-1 .f32 dump", Shaping.Shape(p1, curveOff).Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
string t03Dump = Path.Combine(ToolingPaths.BatchesRoot, t03Source, $"{plate}_continuous_restored", "height.f32");
float[,] t03 = HeightField.Load(t03Dump, calibSize);
hard.Add(ShapingOracle.DumpRegression("a3", "continuous_restored, stretch OFF == task-03 .f32 dump", Shaping.Shape(p1, offCfg).Height, t03, calibSize, t03Dump));
// ⭐ a3b — stretch ON at the ladder's TOP: north of the band bit-identical to the tag's own dump.
var topCfg = Cfg(calibSize, plate, "top", maxStretch, stretchSinker);
Pass2Result pTop = Shaping.Shape(Topography.Generate(topCfg), topCfg);
if (t03 != null)
hard.Add(ShapingOracle.NorthLocked("a3b", $"stretch {maxStretch:G3} ON: north of the band bit-identical to task-03 dump (terrain-curve-v1); changes only in/below the band", pTop.Height, t03, calibSize, bandRowC));
foreach (var c in hard) GD.Print(" " + c);
if (!skip8k)
{
string t04Dump = Path.Combine(ToolingPaths.BatchesRoot, t04Source, $"{plate}", "height.f32");
if (File.Exists(t04Dump))
{
GD.Print($" a4b: generating {plate} at {GallerySize}, stretch {maxStretch:G3} …");
var g = Cfg(GallerySize, plate, "top", maxStretch, stretchSinker);
Pass2Result pG = Shaping.Shape(Topography.Generate(g), g);
var a4b = ShapingOracle.NorthLocked("a4b", $"stretch {maxStretch:G3} ON at {GallerySize}: north of the band bit-identical to terrain-curve-v1's 04 gallery dump",
pG.Height, HeightField.Load(t04Dump, GallerySize), GallerySize, (int)(bandStart * GallerySize));
hard.Add(a4b); GD.Print(" " + a4b);
}
else GD.Print($" a4b: ⚠ skipped — no 04 gallery dump at {t04Dump}");
}
else GD.Print(" a4b: skipped (ISLA_SKIP_8K)");
}
// ═══ 4. THE LADDER — 5 levels × 2 seeds ═══
GD.Print($"\n--- 4. THE LADDER at {mapSize} ---");
int bandRow = (int)(bandStart * mapSize);
var rows = new List<Row>();
var perField = new List<ShapingOracle.Check>();
var baselineRows = new Dictionary<int, Row>();
foreach (int seed in seeds)
{
var c0 = Cfg(mapSize, seed, "stretch_0", 0f, stretchSinker);
Pass1Result p0 = Topography.Generate(c0);
Pass2Result q0 = Shaping.Shape(p0, c0);
baselineRows[seed] = MakeRow(0, 0f, seed, p0, mapSize, bandRow, bigCells4k, true, p0.ElapsedMs);
var r0 = baselineRows[seed];
GD.Print($" seed {seed} baseline (stretch 0): reach {r0.ReachFrac:F3}, S islands {r0.SouthAll} / big {r0.SouthBig}, N islands {r0.NorthAll}");
for (int li = 0; li < ladder.Length; li++)
{
float st = ladder[li];
string label = $"stretch_{li + 1}";
var cfg = Cfg(mapSize, seed, label, st, stretchSinker);
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result p2 = Shaping.Shape(p1, cfg);
var checks = new List<ShapingOracle.Check>
{
ShapingOracle.NorthLocked("p", "north of the band bit-locked (classify) vs stretch 0", p1.Height, p0.Height, mapSize, bandRow),
ShapingOracle.NorthLocked("p2", "north of the band bit-locked (render) vs stretch 0", p2.Height, q0.Height, mapSize, bandRow),
ShapingOracle.NorthIslandsInvariant("northern islands invariant vs stretch 0", p0.Regions, p1.Regions),
ShapingOracle.CentreIsLand(p1),
ShapingOracle.TagCoastlineConsistent(p2, sea),
ShapingOracle.ClassifyFidelity(p1, p2),
};
foreach (var c in checks) { c.Name += $" [{label} = {st:G3}, {seed}]"; perField.Add(c); }
bool ok = checks.TrueForAll(c => c.Passed);
var row = MakeRow(li + 1, st, seed, p1, mapSize, bandRow, bigCells4k, ok, p1.ElapsedMs);
rows.Add(row);
WriteField(batchRoot, p1, p2, sea, anchors, skipRaw, st);
GD.Print($" {label,-10} {st,5:G3} seed {seed,-11} reach {row.ReachFrac:F3} (median coast {row.MedianCoastFrac:F3}) mainland S-of-band {row.MainlandSouthOfBand,9:N0} " +
$"S islands {row.SouthAll,3} / big {row.SouthBig,3} S size med {row.SMed,6} max {row.SMax,7} N islands {row.NorthAll,3} {(ok ? "ok" : " CHECK FAILED")} {p1.ElapsedMs} ms");
}
}
// determinism: the middle level on the first seed, twice
{
float st = ladder[ladder.Length / 2];
var cA = Cfg(mapSize, plate, "det", st, stretchSinker); var cB = Cfg(mapSize, plate, "det", st, stretchSinker);
var a = Topography.Generate(cA); var b = Topography.Generate(cB);
var det = ShapingOracle.LabelsDeterministic(a, b); det.Name += $" [stretch {st:G3}, {plate}]";
var bits = ShapingOracle.NorthLocked("o2", $"two generations bit-identical everywhere [stretch {st:G3}, {plate}]", a.Height, b.Height, mapSize, mapSize);
perField.Add(det); perField.Add(bits);
GD.Print(" " + det); GD.Print(" " + bits);
}
bool allOk = hard.TrueForAll(c => c.Passed) && perField.TrueForAll(c => c.Passed);
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
foreach (var c in perField) if (!c.Passed) GD.PrintErr(" " + c);
WriteTable(batchRoot, mapSize, ladder, seeds, rows, baselineRows, bigCells4k, bandStart, bandFeather, stretchSinker);
WriteIndex(batchRoot, mapSize, calibSize, ladder, seeds, rows, baselineRows, bigCells4k, bandStart, bandFeather, stretchSinker, hard, perField, allOk, diag.ToString());
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" ORACLE {(allOk ? "HARD CHECKS ALL PASS" : "*** FAILURES see the table ***")}");
GD.Print("==================================================================");
GetTree().Quit(allOk ? 0 : 3);
}
// ---- the instrument --------------------------------------------------
private static long Cells(float frac, int size) => Math.Max(1L, (long)Math.Round(frac * (double)size * size));
/// <summary>Southernmost mainland row / N, the median per-column coast row (central 60 % of columns) / N, and mainland cells at/below the band row.</summary>
private static (double reach, double median, long southCells) Reach(RegionLabels l, int n, int bandRow)
{
int main = l.MainlandId; int maxY = -1; long south = 0;
var coast = new List<int>();
int x0 = (int)(n * 0.2), x1 = (int)(n * 0.8);
for (int x = 0; x < n; x++)
{
int colMax = -1;
for (int y = 0; y < n; y++)
{
if (l.Id[x * n + y] != main) continue;
if (y > colMax) colMax = y;
if (y >= bandRow) south++;
}
if (colMax > maxY) maxY = colMax;
if (x >= x0 && x < x1 && colMax >= 0) coast.Add(colMax);
}
coast.Sort();
double median = coast.Count == 0 ? 0 : coast[coast.Count / 2] / (double)n;
return (maxY / (double)n, median, south);
}
private static (int southAll, int southBig, int northAll, int northBig, long sMin, long sMed, double sMean, long sMax, long nMax, int[] sHist)
Hemi(RegionLabels l, long big)
{
var south = new List<long>(); int northAll = 0, northBig = 0; long nMax = 0;
foreach (var r in l.Regions)
{
if (r.IsMainland) continue;
if (r.Hemisphere == RegionLabeling.HemiSouth) south.Add(r.SizeCells);
else { northAll++; if (r.SizeCells >= big) northBig++; nMax = Math.Max(nMax, r.SizeCells); }
}
south.Sort();
var hist = new int[RegionLabeling.HistogramEdges.Length + 1];
int sBig = 0; double sum = 0;
foreach (long s in south) { hist[RegionLabeling.HistogramBin(s)]++; if (s >= big) sBig++; sum += s; }
return (south.Count, sBig, northAll, northBig,
south.Count == 0 ? 0 : south[0], south.Count == 0 ? 0 : south[south.Count / 2], south.Count == 0 ? 0 : sum / south.Count,
south.Count == 0 ? 0 : south[south.Count - 1], nMax, hist);
}
private static Row MakeRow(int level, float st, int seed, Pass1Result p1, int mapSize, int bandRow, long big, bool ok, ulong ms)
{
var (reach, median, southCells) = Reach(p1.Regions, mapSize, bandRow);
var h = Hemi(p1.Regions, big);
return new Row
{
Level = level, Stretch = st, Seed = seed, ReachFrac = reach, MedianCoastFrac = median,
MainlandCells = p1.Regions.Mainland.SizeCells, MainlandSouthOfBand = southCells,
SouthAll = h.southAll, SouthBig = h.southBig, NorthAll = h.northAll, NorthBig = h.northBig,
SMin = h.sMin, SMed = h.sMed, SMean = h.sMean, SMax = h.sMax, NMax = h.nMax, SHist = h.sHist, Ok = ok, Ms = ms,
};
}
// ---- the curve --------------------------------------------------------
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
{
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s }); // bare default: offshore / revert / stretch OFF
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
var knots = new CurveKnots(2, "v2_balanced",
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
};
Pass2Result st = Shaping.Shape(pass1[s], scfg);
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
}
var pcts = ClimbCalibration.DefaultPercentiles;
var rawQ = new float[pcts.Length]; var outQ = new float[pcts.Length];
for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
var cal = ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f);
return (knots, cal);
}
// ---- output -----------------------------------------------------------
private static void WriteField(string batchRoot, Pass1Result p1, Pass2Result p2, float sea, CurveAnchors anchors, bool skipRaw, float stretch)
{
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
DirAccess.MakeDirRecursiveAbsolute(dir);
if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
var look = new LookConfig
{
Name = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f, HillshadeStrength = 0.30f, SeaLevel = sea,
};
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, $"{p2.VariantLabel.ToUpperInvariant()} ({stretch:G3}) {p2.Seed}")
.SavePng(Path.Combine(dir, "relief.png"));
RegionOverlayRenderer.SavePng(p1.Regions, null, p1.MapSize, 0, 0, Path.Combine(dir, "regions.png"));
}
private static string HistRow(int[] h)
{
if (h == null) return "—";
var sb = new StringBuilder();
for (int i = 0; i < h.Length; i++) { if (i > 0) sb.Append(" · "); sb.Append(h[i]); }
return sb.ToString();
}
private static string TableMarkdown(float[] ladder, int[] seeds, List<Row> rows, Dictionary<int, Row> baseline, long big)
{
var sb = new StringBuilder();
var histHead = new StringBuilder();
for (int i = 0; i <= RegionLabeling.HistogramEdges.Length; i++) { if (i > 0) histHead.Append(" · "); histHead.Append(RegionLabeling.HistogramLabel(i)); }
sb.AppendLine($"| Level | stretch | Seed | reach (southernmost mainland row / N) | median coast / N | mainland cells south of band | **SOUTH islands: all / ≥ {big:N0} cells** | **S size min / med / mean / max** | S histogram ({histHead}) | **NORTH islands (control)** | N largest | oracle |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|");
foreach (int seed in seeds)
{
var b = baseline[seed];
sb.AppendLine($"| *baseline* | 0 | `{seed}` | {b.ReachFrac:F3} | {b.MedianCoastFrac:F3} | {b.MainlandSouthOfBand:N0} | **{b.SouthAll} / {b.SouthBig}** | **{b.SMin} / {b.SMed} / {b.SMean:F0} / {b.SMax}** | {HistRow(b.SHist)} | **{b.NorthAll}** | {b.NMax} | — |");
foreach (var r in rows)
{
if (r.Seed != seed) continue;
sb.AppendLine($"| `stretch_{r.Level}` | {r.Stretch:G3} | `{seed}` | {r.ReachFrac:F3} | {r.MedianCoastFrac:F3} | {r.MainlandSouthOfBand:N0} | **{r.SouthAll} / {r.SouthBig}** | **{r.SMin} / {r.SMed} / {r.SMean:F0} / {r.SMax}** | {HistRow(r.SHist)} | **{r.NorthAll}**{(r.NorthAll != b.NorthAll ? " MOVED" : "")} | {r.NMax} | {(r.Ok ? "pass" : "**FAIL**")} |");
}
}
return sb.ToString();
}
private static void WriteTable(string batchRoot, int mapSize, float[] ladder, int[] seeds, List<Row> rows, Dictionary<int, Row> baseline, long big,
float bandStart, float bandFeather, bool stretchSinker)
{
var sb = new StringBuilder();
sb.AppendLine($"# The hemisphere-split count/size table — {ladder.Length} stretch levels × {seeds.Length} seeds at {mapSize}");
sb.AppendLine();
sb.AppendLine($"Band start {bandStart:F3} (row {(int)(bandStart * mapSize)}), feather {bandFeather:F3} — FIXED. Sinker {(stretchSinker ? "rides the stretched distance" : "on the real y")}.");
sb.AppendLine("Islands = non-mainland 8-connected land components of the classify field (region layer); hemisphere by centroid. Offshore OFF, speck revert OFF.");
sb.AppendLine("SOUTH = the fragmentation signal; NORTH = the should-stay-flat control (flagged if it moves).");
sb.AppendLine();
sb.Append(TableMarkdown(ladder, seeds, rows, baseline, big));
WriteText(Path.Combine(batchRoot, "count_size_table.md"), sb.ToString());
var csv = new StringBuilder();
csv.AppendLine("level,stretch,seed,reach,median_coast,mainland_cells,mainland_south_of_band,south_all,south_big,s_min,s_median,s_mean,s_max,s_hist,north_all,north_big,n_max,oracle,ms");
var ic = System.Globalization.CultureInfo.InvariantCulture;
foreach (int seed in seeds)
{
var all = new List<Row> { baseline[seed] }; all.AddRange(rows.FindAll(r => r.Seed == seed));
foreach (var r in all)
csv.AppendLine(string.Join(",", r.Level, r.Stretch.ToString("G5", ic), r.Seed, r.ReachFrac.ToString("F4", ic), r.MedianCoastFrac.ToString("F4", ic), r.MainlandCells, r.MainlandSouthOfBand,
r.SouthAll, r.SouthBig, r.SMin, r.SMed, r.SMean.ToString("F1", ic), r.SMax, "\"" + HistRow(r.SHist) + "\"", r.NorthAll, r.NorthBig, r.NMax, r.Ok ? "pass" : "FAIL", r.Ms));
}
WriteText(Path.Combine(batchRoot, "count_size_table.csv"), csv.ToString());
}
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, float[] ladder, int[] seeds, List<Row> rows, Dictionary<int, Row> baseline, long big,
float bandStart, float bandFeather, bool stretchSinker, List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perField, bool allOk, string diagSummary)
{
int midLevel = ladder.Length / 2 + 1;
var sb = new StringBuilder();
sb.AppendLine("# Batch 08 — southern stretch, EXPLORATION: the fragmentation knob space");
sb.AppendLine();
sb.AppendLine("**A ladder, not a setting.** Inside a FIXED feathered latitude band the falloff's southward distance is compressed");
sb.AppendLine("(`y' = yB + (y yB) / (1 + stretch · ramp)`), so the mainland reaches further south with the elevation of the rows it came");
sb.AppendLine("from, and where the stretched thin edge thins below sea it fragments organically. North of the band the classify field is");
sb.AppendLine("bit-locked in both directions (asserted). Nothing is stamped. The region layer is the instrument: SOUTH island count/size");
sb.AppendLine("is the fragmentation signal, NORTH is the should-stay-flat control.");
sb.AppendLine();
sb.AppendLine("## ⭐ Open this first");
sb.AppendLine();
sb.AppendLine($"1. **`{seeds[0]}_stretch_{midLevel}/regions.png`** — the middle of the ladder on the first seed: grey mainland, each island its own colour.");
sb.AppendLine($"2. Walk the ladder on that seed: `{seeds[0]}_stretch_1/` … `_stretch_{ladder.Length}/` (`regions.png` beside `relief.png`).");
sb.AppendLine($"3. Then the same five on `{seeds[1]}` — what repeats is the knob; what does not is the seed.");
sb.AppendLine("4. Then the table: southern count/size down the rows, the northern control beside it.");
sb.AppendLine();
sb.AppendLine("## The fixed frame and the axis");
sb.AppendLine();
sb.AppendLine($"- **Band (constant for the batch):** start `{bandStart:F3}` of the map (row {(int)(bandStart * mapSize)} at {mapSize}), feather `{bandFeather:F3}` (smoothstep). Sea identity is hard above it; ramped across; extended below.");
sb.AppendLine($"- **Sinker:** {(stretchSinker ? "rides the stretched distance (pushed out with the geometry held back inside the band)" : "on the real y (keeps pulling the extended mass down where it always did)")}.");
sb.AppendLine($"- **The axis — stretch strength:** {string.Join(" · ", Array.ConvertAll(ladder, v => v.ToString("G3")))} (levels 1{ladder.Length}); baseline 0 measured for the control.");
sb.AppendLine($"- Every field: pass 1 + stretch, region labeling ON, offshore OFF, shelf OFF, speck revert OFF. \"big\" island = ≥ {big:N0} cells at {mapSize} (the 07 `threshold_mid`).");
sb.AppendLine();
sb.AppendLine($"## ⭐ The hemisphere-split count/size table — {ladder.Length} levels × {seeds.Length} seeds at {mapSize}");
sb.AppendLine();
sb.Append(TableMarkdown(ladder, seeds, rows, baseline, big));
sb.AppendLine();
sb.AppendLine("Also as plain data: `count_size_table.md` / `.csv`.");
sb.AppendLine();
sb.AppendLine("## The diagnosis (summary — full tables in `scratch/southern_diagnosis.md`)");
sb.AppendLine();
sb.Append(diagSummary);
sb.AppendLine();
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
sb.AppendLine();
sb.AppendLine("`ProvisionalEven`, flagged. The individually-coloured scheme is only the `regions.png` overlay.");
sb.AppendLine();
sb.AppendLine("## The oracle (asymmetric)");
sb.AppendLine();
sb.AppendLine("Regressions (stretch OFF bit-identical everywhere; stretch ON at the ladder's top bit-identical NORTH OF THE BAND to the `terrain-curve-v1` dumps):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(hard));
sb.AppendLine("Per field (north bit-locked classify p / render p2 · northern islands invariant q · centre-is-land m · tag/coastline k · classify b · determinism o):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(perField));
sb.AppendLine($"**{(allOk ? "ALL HARD CHECKS PASS" : " FAILURES do not judge this batch")}**");
sb.AppendLine();
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `regions.png`, `relief.png`, `INDEX.md`, `count_size_table.md` / `.csv`, `scratch/southern_diagnosis.md` | **keep** |");
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + code — large, clear freely |");
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
sb.AppendLine();
sb.AppendLine($"Plates at {mapSize}, curve calibrated at {calibSize} with offshore off. {WorldScale.Describe()}.");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
private static void WriteText(string path, string text)
{
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(text);
}
// ---- env helpers --------------------------------------------------------
private static string EnvStr(string k, string fallback)
{
string v = System.Environment.GetEnvironmentVariable(k);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}
private static int EnvInt(string k, int fallback)
=> int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
private static float EnvFloat(string k, float fallback)
=> float.TryParse(EnvStr(k, null) ?? "", System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out float v) ? v : fallback;
private static float[] EnvFloats(string k, float[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<float>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (float.TryParse(part.Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float f)) outp.Add(f);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
private static int[] EnvSeeds(string k, int[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<int>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
}
}

View file

@ -0,0 +1 @@
uid://cbo4e7he3x7o3

View file

@ -0,0 +1,72 @@
using Godot;
namespace IslaApocalypse.Tools
{
/// <summary>
/// The offshore TAG / HEMISPHERE debug overlay (chat2/05): mainland one tint, offshore-island
/// land tinted by hemisphere, the midline drawn — so the island count,
/// the N/S split and the tag's correctness are all visible at one glance.
///
/// ⚠ A DIAGNOSTIC, NOT A MAP. It draws the tag layer, which is DATA the shape pass set; it is
/// the one artifact in the batch that shows what a downstream consumer of the tag would see.
/// No hypsometry, no hillshade — three flat tints and some rings, on purpose.
///
/// Presentation only: it is handed arrays and returns a PNG. It cannot change them.
/// </summary>
public static class TagOverlayRenderer
{
private static readonly Color Sea = new(0.055f, 0.110f, 0.235f);
private static readonly Color Mainland = new(0.310f, 0.360f, 0.300f);
private static readonly Color IslandN = new(0.250f, 0.850f, 0.950f); // cool — north
private static readonly Color IslandS = new(0.980f, 0.600f, 0.200f); // warm — south
private static readonly Color Untagged = new(0.950f, 0.150f, 0.800f); // ⚠ land that is neither — must never appear
private static readonly Color Midline = new(0.700f, 0.720f, 0.760f);
private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
/// <param name="isMainlandLand">
/// Per cell, land that is NOT offshore (from the offshore-OFF field, so a tag bug cannot hide
/// by mis-tagging mainland). Null ⇒ derived as "land and not tagged", which is weaker.
/// </param>
public static void SavePng(float[,] height, bool[,] tag, byte[,] hemi, int mapSize, float sea,
int countN, int countS, string absolutePath)
{
var img = Image.CreateEmpty(mapSize, mapSize, false, Image.Format.Rgb8);
for (int x = 0; x < mapSize; x++)
{
for (int y = 0; y < mapSize; y++)
{
Color c;
bool land = height[x, y] >= sea;
bool tagged = tag != null && tag[x, y];
if (!land) c = Sea;
else if (!tagged) c = Mainland;
else if (hemi == null) c = Untagged;
else c = hemi[x, y] switch
{
OffshoreAnalysis.HemiNorth => IslandN,
OffshoreAnalysis.HemiSouth => IslandS,
_ => Untagged,
};
img.SetPixel(x, y, c);
}
}
// The hemisphere midline — the tag's convention, drawn where it bites.
int mid = mapSize / 2;
for (int x = 0; x < mapSize; x += 3) img.SetPixel(x, mid, Midline);
// A legend that cannot be separated from the picture.
int s = mapSize >= 4096 ? 4 : 3;
int lh = TinyFont.Height(s) + 6;
TinyFont.Draw(img, "OFFSHORE TAG OVERLAY", 12, 12, s, Ink);
TinyFont.Draw(img, "GREY: MAINLAND CYAN: ISLAND N ORANGE: ISLAND S", 12, 12 + lh, s, Ink);
TinyFont.Draw(img, $"ISLANDS: {countN} NORTH {countS} SOUTH - ALL ORGANIC, NONE FORCED", 12, 12 + lh * 2, s, Ink);
TinyFont.Draw(img, "N ABOVE THE LINE - S BELOW - Y RUNS SOUTH", 12, 12 + lh * 3, s, Ink);
Error err = img.SavePng(absolutePath);
if (err != Error.Ok) GD.PrintErr($"[TagOverlayRenderer] SavePng failed ({err}) for {absolutePath}");
}
}
}

View file

@ -0,0 +1 @@
uid://dxsgyj0fg77rr

View file

@ -145,13 +145,51 @@ namespace IslaApocalypse.Tools
public float ClimbFeather = 0.4f;
/// <summary>
/// The summit's steepening, ≥ 1: the secant slope of the top 15 % of the climb, in units of
/// the climb's average grade. 1 = a ramp (refused); 2.5 = the default pointed peak; higher =
/// more dramatic. The peak reads pointy, never a needle-on-a-hump — there is no plateau
/// under it any more.
/// ⚠ chat2/02's ANALYTIC summit knob — SUPERSEDED by <see cref="PeakSharpness"/>.
///
/// It steepened the peak by pulling the summit ONSET DOWN, which lowered the whole mid-massif
/// with it: at 4.5 the p99 land height collapsed from 199 m to 121 m. A bad trade, and the
/// bug chat2/03 §3 exists to fix. It survives ONLY so the 02 curve stays reproducible as a
/// contrast variant; it is read only when <see cref="ClimbCalibration"/> is null.
/// </summary>
public float SummitDrama = 2.5f;
// ---- chat2/03: the CALIBRATED climb ---------------------------------
/// <summary>
/// ⭐ The measured climb calibration. Non-null ⇒ the climb reproduces the staircase's
/// above-ceiling elevation distribution as a smooth slope. Null ⇒ chat2/02's analytic
/// feather/drama curve (kept only as the "before" contrast).
///
/// ⚠ Not a value knob — it is MEASURED, per calibration pool, by the batch tool. Two configs
/// may share one instance safely: it is immutable.
/// </summary>
public ClimbCalibration ClimbCalibration = null;
/// <summary>
/// ⭐ How big the mountain is, relative to the staircase's.
///
/// 1.0 reproduce the staircase's mountain (the default — the least-surprising baseline)
/// &gt;1 lift the mid-massif higher: more land at 150300 m
/// &lt;1 a smaller mountain, toward chat2/02's bottom-heavy climb
///
/// Applied as <c>v ← v^(1/lift)</c> on the calibrated control points: monotone, and it fixes
/// both endpoints, so it moves the massif without touching the lowland handover or the cap.
/// ⚠ It scales the CLIMB only. It cannot move a lowland cell — oracle (d) proves that.
/// </summary>
public float MountainLift = 1.0f;
/// <summary>
/// ⭐ How pointy the summit is — and, unlike <see cref="SummitDrama"/>, <b>nothing else</b>.
///
/// It reshapes only the span above the last measured percentile, leaving that percentile's
/// height fixed. Raising it therefore cannot reduce the land below the onset: peak sharpness
/// and mountain mass are independent knobs. → chat2/03 §3.
///
/// 1.0 = a straight run to the cap; higher defers the rise so the final approach steepens.
/// </summary>
public float PeakSharpness = 1.0f;
/// <summary>
/// ⭐ Pass 2a rung 2: the shelf detail passes — micro-relief skin + shelf-edge knot warp.
/// ⚠ REQUIRES <see cref="Curve"/>: the edge warp slides the CURVE's knots, so with no curve
@ -202,6 +240,161 @@ namespace IslaApocalypse.Tools
/// <summary>Crater centre Y, columns. Unused while <see cref="CraterRadius"/> is 0.</summary>
public float CraterCenterY = 0f;
// ---- PASS 1b — the coast shelf + offshore islets (chat2/05) ----------
//
// ⚠⚠ BOTH DEFAULT OFF, DELIBERATELY — and that is a decision to revisit, not an oversight.
//
// Every oracle in this phase holds pass 1 against Phase 1's `.f32` dumps (curve-off ==
// `02_pass1_port`), and the curve tools hold it against task 01/03's. The shelf changes every
// below-sea cell and the islets ADD LAND, so the moment either defaults ON, every one of
// those regression anchors goes stale at once. The batch tools that want them turn them on
// explicitly. FLIPPING THESE DEFAULTS IS THE ACT THAT RETIRES THE PHASE-1 REGRESSION DUMPS —
// do it deliberately, in a task that re-baselines the oracles, not as a side effect here.
/// <summary>
/// The submarine coast shelf (<c>IslandFalloff.CoastShelf</c>). Below-sea only,
/// depth-preserving, held strictly below sea by <c>MathF.BitDecrement</c>. Invisible until
/// water renders; ported faithfully now, judged then.
/// </summary>
public bool CoastShelf = false;
// ⚠ Fully qualified: this class's own `IslandFalloff` ablation toggle shadows the static
// type of the same name inside field initializers.
/// <summary>Shelf strength, 0 = off → 1 = a flat lagoon. Reference 0.775.</summary>
public float ShelfStrength = IslaApocalypse.Tools.IslandFalloff.SHELF_STRENGTH;
/// <summary>Metres of depth over which the shelf relaxes. Reference 100.</summary>
public float ShelfScaleM = IslaApocalypse.Tools.IslandFalloff.SHELF_SCALE_M;
/// <summary>
/// ⭐ The offshore islet system — every dial in one object. <c>Mode = Off</c> by default
/// (see the note above). <see cref="OffshoreSettings.Faithful"/> is the reference verbatim;
/// <see cref="OffshoreSettings.Organic"/> is the reshape, tuned (chat2/06).
/// </summary>
public OffshoreSettings Offshore = new OffshoreSettings();
// ---- PASS 1c — region labeling + the speck revert (chat2/07) ----------
/// <summary>
/// ⭐ THE REGION-LABELING LAYER (<c>Core.RegionLabeling</c>, via <c>RegionPass</c>): 8-connected
/// land components on the classify field, mainland = the centre component, the island tag BY
/// CONSTRUCTION. Pure analysis — it changes no height — so it is ON by default without touching
/// any regression anchor. Off ⇒ no tag, no region table (the tag arrays are null).
/// </summary>
public bool RegionLabeling = true;
/// <summary>
/// ⭐ THE SPECK REVERT — lower every non-mainland land component smaller than
/// <see cref="MinLandComponentFrac"/> of the map to seabed. Origin-blind; lower-only and
/// component-only, asserted; mainland never a candidate.
///
/// ⚠ DEFAULT OFF IN THE BARE CONFIG, for exactly the reason the shelf and the islets are: the
/// raw field has small natural nubs, so with this ON the calibration pool's land histogram, the
/// curve knots and every Phase-1 / task-03 / task-04 regression dump would move at once.
/// The region batch turns it on explicitly (its preset is ON); flipping the bare default is
/// the act that re-baselines the oracles — own task, not a side effect.
/// </summary>
public bool SpeckRevert = false;
/// <summary>The revert threshold, as a fraction of the map's AREA (scale-free). → <see cref="RegionPass.ThresholdMidFrac"/>.</summary>
public float MinLandComponentFrac = RegionPass.ThresholdMidFrac;
// ---- PASS 1 — THE SOUTHERN STRETCH (chat2/08, exploration) ----------------
//
// ⚠ THE ONE DELIBERATE RELAXATION OF SEA IDENTITY — and only below a FIXED latitude band.
// Inside the band (feathered, keyed off a fixed y, never distance-from-coast) the falloff's
// southward distance is compressed: y' = yB + (y yB) / (1 + stretch · ramp). The mask
// geometry is stretched south; the base noise, edge noise and latitude field are NOT — so
// the extended mass keeps the elevation/relief of the rows it came from (preserve height
// as the mass extends), and where the stretched thin edge thins below sea it fragments
// organically. Cells north of the band take the UNTOUCHED code path, so the classify field
// there is bit-identical by construction (asserted). Nothing is stamped.
/// <summary>⭐ THE SWEPT AXIS. 0 = off (bit-identical to the unstretched field everywhere). Stretch factor inside the band: 1 ⇒ the southward distance is halved, 3 ⇒ quartered.</summary>
public float SouthStretch = 0f;
/// <summary>The band's FIXED latitude line, fraction of the map (y runs south). Sea identity is hard above it. A constant for a whole batch.</summary>
public float SouthBandStartFrac = SouthernStretch.DefaultBandStartFrac;
/// <summary>The feather width across which the stretch ramps 0 → 1 (smoothstep), fraction of the map. A constant for a whole batch.</summary>
public float SouthBandFeatherFrac = SouthernStretch.DefaultBandFeatherFrac;
/// <summary>
/// Does the SOUTHERN SINKER ride the stretched distance (true — it is part of the southern
/// geometry and is pushed out with it, i.e. held back inside the band) or the real y (false —
/// it keeps pulling the extended mass down where it always did)? The chat2/08 diagnostic
/// measured both; → <see cref="SouthernStretch"/>.
/// </summary>
public bool StretchSinker = SouthernStretch.DefaultStretchSinker;
// ---- PASS 1 — COASTAL FRAGMENTATION (chat2/09, exploration) ------------------
//
// A band-limited, zero-mean noise added to the PRE-power falloff only where the falloff sits in
// the coastal window (≈ the barely-land / barely-sea margin, around the whole perimeter). It
// self-targets thin necks: the cells closest to the sea threshold flip first, so lobes pinch off
// into islands while the interior — window weight exactly zero — is bit-identical by
// construction. Nothing is detected, nothing is stamped. → CoastalFragment.
/// <summary>⭐ THE SWEPT AXIS. 0 = off (bit-identical everywhere). Peak |Δfalloff| (pre-power) at the window's centre.</summary>
public float FragmentAmp = 0f;
/// <summary>The fragmentation noise's frequency, periods per map width — the neck/lobe scale. The secondary dial (fixed this round). → <see cref="CoastalFragment.DefaultFreqPerMapWidth"/>.</summary>
public float FragmentFreqPerMapWidth = CoastalFragment.DefaultFreqPerMapWidth;
/// <summary>The coastal window's centre and half-width in PRE-power falloff units. Weight 1 at the centre, smooth to 0 at ± half-width; exactly 0 beyond.</summary>
public float FragmentBandCentre = CoastalFragment.DefaultBandCentre;
public float FragmentBandHalfWidth = CoastalFragment.DefaultBandHalfWidth;
/// <summary>
/// false (default) ⇒ zero-mean noise: the margin is redrawn — bites AND builds (which can also
/// bridge an island back onto the mainland). true ⇒ bites only ((noise+1)/2 ≥ 0): land can only
/// recede, necks are cut, nothing is bridged, the coast net-recedes. → <see cref="CoastalFragment"/>.
/// </summary>
public bool FragmentBitesOnly = CoastalFragment.DefaultBitesOnly;
// ---- PASS 2b — HYDRAULIC EROSION (chat2/11) — RENDER MAP ONLY ------------------
//
// The reference's droplet erosion, ported verbatim (Core.HydraulicErosion), run on the render
// field AFTER shaping (after detail, before the crater carve — which does not exist yet). The
// classify field never sees it (D-046); the caller's flood guard proves no waterline moved.
// ⚠ DEFAULT OFF in the bare config for the usual reason (regression anchors); the batch turns
// it on. The governors + physics are the reference ConfigManager's declared defaults, clamped
// as it clamped them (→ ErosionPass).
/// <summary>⭐ Erosion on/off. Render only. Default OFF (see above).</summary>
public bool Erosion = false;
/// <summary>Governor 1 — droplet count. Reference 250000, clamp [0, 50,000,000].</summary>
public int ErosionDropletCount = 250000;
/// <summary>Governor 2 — max steps per droplet. Reference 384, clamp [1, 4096].</summary>
public int ErosionDropletLifetime = 384;
/// <summary>Governor 3 — max carve per cell, metres (net ledger). Reference 15, clamp [0, 60].</summary>
public float ErosionCarveCapM = 15.0f;
/// <summary>Governor 4 — max build-up per cell, metres (the ledger read the other way). Reference 6, clamp [0, 60]; ≤ 0 = unbounded.</summary>
public float ErosionDepositCapM = 6.0f;
/// <summary>The sea clamp's carve floor above sea, metres. Reference 0.5, clamp [0, 5].</summary>
public float ErosionSeaMarginM = 0.5f;
/// <summary>Brush radius, px (the cone brush shared by erode and deposit). Reference 2.</summary>
public int ErosionBrushRadius = 2;
public float ErosionInertia = 0.35f; // clamp [0, 0.99]
public float ErosionCapacity = 4.0f;
public float ErosionMinSlopeM = 0.02f; // metres per px
public float ErosionErodeRate = 0.12f;
public float ErosionDepositRate = 0.15f;
public float ErosionEvaporation = 0.004f; // clamp [0, 0.5]
public float ErosionGravity = 4.0f;
/// <summary>
/// The crater exclusion (task 19), PORTED BUT INERT: with no crater (<see cref="CraterRadius"/> 0)
/// the weight is 1 everywhere. Core ×radius — the reference's ConfigManager shipped 0.80 (the
/// pass's own default constant is 0.50); feather ×radius 1.05; mode feather. Activates when
/// the crater carve lands; the reference's "core &lt; carve factor" warning is dormant until then.
/// </summary>
public float CraterErosionCore = 0.80f;
public float CraterErosionFeather = 1.05f;
public bool CraterErosionFeatherMode = true;
/// <summary>A short label for this variant, used in output filenames. E.g. "full", "base_only".</summary>
public string VariantLabel = "full";
@ -218,6 +411,7 @@ namespace IslaApocalypse.Tools
{
var c = (TerrainGenConfig)MemberwiseClone();
c.Anchors = Anchors?.Clone();
c.Offshore = Offshore?.Clone(); // same reason: a mutable dial object, deep-copied
return c;
}

View file

@ -1,3 +1,4 @@
using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
@ -27,12 +28,13 @@ namespace IslaApocalypse.Tools
/// so it is a raw additive wall the exponent never softens. preTrenchFalloff is captured
/// between them. Reordering any of it changes the island.
///
/// ═══ ⚠ WHAT IS DELIBERATELY NOT PORTED HERE ═══
/// ═══ PASS 1b — THE SHELF AND THE ISLETS (chat2/05) ═══
///
/// The reference's pass-1 loop continues past the height write with two more task-11 passes:
/// the submarine COAST SHELF (~:621-640) and the OFFSHORE ISLET layer (~:641-664). Both are
/// DEFERRED to Phase 2 by the developer's ruling — they act only on below-sea height and are
/// judged once water renders. <see cref="Pass1Result.PreTrenchFalloff"/> is exposed for them.
/// the submarine COAST SHELF (~:621-640) and the OFFSHORE ISLET layer (~:641-664). v2 runs them
/// as a second sweep over the finished arrays — <see cref="OffshorePass"/> — with the same
/// per-pixel arithmetic in the same order, and then recomputes <c>HMaxSeed</c> AFTER them, as
/// the reference did. Config-gated; both default off (see <c>TerrainGenConfig</c> for why).
///
/// Nothing from pass 2 is here at all: no redistribution curve, no shelf detail, no erosion,
/// no rivers, no water bodies, no crater carve, no biomes.
@ -145,6 +147,18 @@ namespace IslaApocalypse.Tools
float hMax = float.MinValue;
float hMin = float.MaxValue;
// ═══ COASTAL FRAGMENTATION (chat2/09) — its own deterministic field, precomputed ═══
bool fragOn = cfg.FragmentAmp > 0f && cfg.IslandFalloff;
FastNoiseLite fragNoise = fragOn
? TerrainNoise.CreateModulation(cfg.Seed, CoastalFragment.SeedOffset, cfg.FragmentFreqPerMapWidth, scale)
: null;
float fragOffset = scale.OffsetInMapWidths(CoastalFragment.OffsetMapWidths); // D-059: an offset in MAP WIDTHS, never raw pixels
// ═══ THE SOUTHERN STRETCH (chat2/08) — band constants, precomputed ═══
bool stretchOn = cfg.SouthStretch > 0f;
float bandStart = cfg.SouthBandStartFrac * mapSize;
float bandFeather = Mathf.Max(1f, cfg.SouthBandFeatherFrac * mapSize);
// ⚠ x IS THE OUTER LOOP, as in the reference. Numerically irrelevant here, but a
// PARALLEL port must reduce hMax/hMin rather than share them — noted before someone
// reaches for Parallel.For and quietly races on the running max.
@ -173,6 +187,21 @@ namespace IslaApocalypse.Tools
lat += (latNoise * LatitudeWobbleSpan) - (LatitudeWobbleSpan / 2.0f);
latitudeField[x, y] = lat;
// ═══ THE SOUTHERN STRETCH (chat2/08) — the y the MASK GEOMETRY sees ═══
//
// North of the band fy == y exactly and every expression below is the untouched
// original, so the classify field there is bit-identical by construction. Inside
// the band the southward distance is compressed by (1 + stretch · ramp); the base
// noise, edge noise and latitude field keep the real y. → SouthernStretch.
float fy = y; // the mask's y (squircle + ellipse)
float sy = y; // the sinker's y
if (stretchOn && y > bandStart)
{
float stretched = SouthernStretch.StretchedY(y, bandStart, bandFeather, cfg.SouthStretch);
fy = stretched;
if (cfg.StretchSinker) sy = stretched;
}
// ═══ 2. THE ISLAND FALLOFF / MASK (ref ~:567-574) ═══
float finalFalloff = 0f;
float squircleFalloff = 0f;
@ -182,11 +211,11 @@ namespace IslaApocalypse.Tools
// Squircle — Max(nx, ny), giving squared-off corners. ISLAND-anchored:
// the axis ratios are applied here.
float nx = Mathf.Abs(x - centerX) / (halfSpan * axisX);
float ny = Mathf.Abs(y - centerY) / (halfSpan * axisY);
float ny = Mathf.Abs(fy - centerY) / (halfSpan * axisY);
squircleFalloff = Mathf.Max(nx, ny);
// Ellipse — vector length, giving a rounded shape.
var ellipticalPos = new Vector2((x - centerX) / axisX, (y - centerY) / axisY);
var ellipticalPos = new Vector2((x - centerX) / axisX, (fy - centerY) / axisY);
float ellipticalFalloff = ellipticalPos.Length() / (mapSize / EllipseScaleDivisor);
// 50/50 blend.
@ -215,12 +244,29 @@ namespace IslaApocalypse.Tools
// Sinks the stretched land bridges in the bottom 25%. ⚠ BEFORE the power, so its
// effect is superlinear — +0.6 on a falloff already near 1 costs far more height
// than +0.6 near 0. Moving it after the power would change the southern coast.
if (cfg.IslandFalloff && cfg.SouthernSinker && y > southThreshold)
if (cfg.IslandFalloff && cfg.SouthernSinker && sy > southThreshold)
{
float southDepth = (y - southThreshold) / (mapSize - southThreshold);
float southDepth = (sy - southThreshold) / (mapSize - southThreshold);
finalFalloff += southDepth * SouthSinkAmount;
}
// ═══ COASTAL FRAGMENTATION (chat2/09) — in the coastal window only, pre-power ═══
//
// window(falloff) is exactly zero where the falloff is clear of the coastal margin,
// so the interior never sees this term (bit-identical by construction); inside the
// window a zero-mean noise bites or builds the margin, and the thinnest necks — the
// cells nearest the sea threshold — flip first. → CoastalFragment.
if (fragOn)
{
float w = CoastalFragment.Window(finalFalloff, cfg.FragmentBandCentre, cfg.FragmentBandHalfWidth);
if (w > 0f)
{
float nz = fragNoise.GetNoise2D(x + fragOffset, y + fragOffset); // [-1, 1]
if (cfg.FragmentBitesOnly) nz = (nz + 1f) * 0.5f; // [0, 1] — bites only
finalFalloff += cfg.FragmentAmp * w * nz;
}
}
// ═══ ⭐ THE PHASE-2 SEAM — captured BEFORE the power and BEFORE the Trench ═══
// (ref ~:591). See Pass1Result.PreTrenchFalloff for why this exact point.
preTrenchFalloff[x, y] = finalFalloff;
@ -279,14 +325,64 @@ namespace IslaApocalypse.Tools
if (finalH < hMin) hMin = finalH;
height[x, y] = finalH;
// ⚠ THE REFERENCE'S PASS 1 CONTINUES HERE with the coast shelf (~:621-640) and
// the offshore islets (~:641-664). Both DEFERRED to Phase 2 — below-sea only,
// judged once water renders. preTrenchFalloff above is their inlet.
// The reference's pass 1 CONTINUED HERE with the coast shelf (~:621-640) and the
// offshore islets (~:641-664). v2 runs them as PASS 1b, a second sweep over these
// arrays, immediately below — same per-pixel arithmetic, same order, and the
// slop guards need the whole field to see whole islands. → OffshorePass.
}
}
// ═══ PASS 1b — THE COAST SHELF + OFFSHORE ISLETS (chat2/05) ═══
//
// In place, on `height`. Config-gated; returns null when both are off, in which case
// nothing above is touched and this pass-1 output is bit-identical to Phase 1's.
//
// ⚠⚠ HMaxSeed IS RECOMPUTED AFTER THIS — closing chat2/00 Drift §2. The reference took
// `_hMaxSeed` after the shelf and islets inside the same loop; v2 used to take it before
// they existed. The curve normalizes its summit spike against this value, so the order is
// load-bearing even when the number does not move (an islet crest is ~34 m; a peak is
// ~290 m). Both values are carried so the report states whether it moved, not guesses.
float hMaxBeforeOffshore = hMax;
OffshorePass.Result offshore = OffshorePass.Apply(height, preTrenchFalloff, mapSize, cfg.Seed, cfg.SeaLevel, cfg);
// ═══ PASS 1c — REGION LABELING + THE SPECK REVERT + THE ISLAND TAG (chat2/07) ═══
//
// The general region layer over the classify field (this array), then the origin-blind
// speck revert (config-gated, lower-only, component-only), then the island tag BY
// CONSTRUCTION from the finished labeling. Labeling alone changes nothing; only the revert
// may, and only downward, and only inside a sub-threshold non-mainland component. → RegionPass.
RegionPass.Result regions = cfg.RegionLabeling
? RegionPass.Apply(height, mapSize, cfg.SeaLevel, cfg)
: null;
if (offshore != null || regions != null)
{
hMax = float.MinValue;
hMin = float.MaxValue;
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
{
float h = height[x, y];
if (h > hMax) hMax = h;
if (h < hMin) hMin = h;
}
}
var notes = new List<string>();
if (offshore != null) notes.AddRange(offshore.Notes);
if (regions != null) notes.AddRange(regions.Notes);
return new Pass1Result(mapSize, cfg.Seed, height, preTrenchFalloff, latitudeField,
hMax, hMin, Time.GetTicksMsec() - t0);
hMax, hMin, Time.GetTicksMsec() - t0,
hMaxSeedBeforeOffshore: hMaxBeforeOffshore,
isIsland: regions?.IsIsland,
islandHemisphere: regions?.IslandHemisphere,
offshoreLiftedCells: offshore == null ? 0 : offshore.LiftedOrganic - offshore.LiftedReverted,
notes: notes,
offshoreLedger: offshore?.ToLedger(),
regions: regions?.Labels,
regionLedger: regions?.Ledger,
regionsPre: regions?.LabelsPre);
}
}
}