Compare commits

..

8 commits

Author SHA1 Message Date
ccdd6a1996 rivers/05: flow-through routing — river → lake → over the spill → … → sea; the hydrology map and the flow-direction field
Replaces terminate-at-first with chaining through the basin graph (rivers/04): one D8 field on
FullFilled per seed; each promoted river follows it from its terminal, checked at every basin
entered against the floor→spill climb (SpillClimbM) vs ISLA_FLOW_CAP_M (30) — overflow or wall.
Lake basins are entered on the real terrain (Plan.Dir, rivers/03c fix B fallback), crossed as
water to the entered body's lowest-FullFilled outlet, left over the spill. Keep on OceanMask /
IsLake, drop on dry or puddle-only, read through the rivers/03b confluence root (reused verbatim).
The field at the cap (walled basins re-pointed onto the real terrain), its accumulation and every
cell's destination; hero-lake candidates ranked as data. HydrologyRenderer: the showpiece map on
the atlas relief and the flow-direction data map. Heights digested and asserted; nothing filled,
nothing carved. RiverRouting.Confluence and DrainageRenderer.LabelPlacer private→internal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EppUMXNhSeuA5Mu51UnTyP
2026-08-25 22:50:25 -04:00
df286ffb3c rivers/04: lake-basin layer — the basin graph (spill + lake-identity + downstream edge), data only
Per terminal basin of DrainageAnalysis (reused, untouched): the spill cell/height on the
render flow surface (boundary minimum of FullFilled, cross-checked bit-for-bit against
BitDecrement(min inside)), lake-identity on classify (in-basin non-ocean classify water
>= ISLA_LAKE_MIN_PX, 20000), and the downstream edge (the reference's FullFilled descent
started at the spill, cross-checked against Plan.Dir). Seabed pits — terminal basins entirely
under the classify sea — tagged and excluded from statistics. Per-lake ownership table.
BasinGraphTool: chain → analysis → layer → CSVs → plate, height digests asserted around it.
DrainageRenderer: five primitives private→internal. No height mutated, no water filled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EppUMXNhSeuA5Mu51UnTyP
2026-08-25 00:45:23 -04:00
09d23127e7 rivers/03c: routing polish — lake-termination as a preference, lake-enders come home
Two surgical refinements of rivers/03b's fix 3. Everything else (confluence,
the rim cap, the faithful default) unchanged. Courses only: no height mutated,
no water filled or created — asserted per seed by a raw-bit digest.

- FIX A (ISLA_LAKE_PREFER_RATIO, default 0.4): a dry-basin router goes to the SEA
  unless cost_lake < ratio * cost_ocean, i.e. unless a lake is materially cheaper.
  rivers/03b used "nearest of {ocean, lake}", which is exactly ratio < 1.0 and
  captured 12 rivers, halving the sea mouths. Both leg costs are now recorded for
  every router so the knob is readable off the table without a re-run.
- FIX B (OwnBasinLakeEnder): a natural lake-ender terminates at the water inside
  its OWN terminal basin at any size. The 20,000 px significance threshold had
  exiled 999999937 #3 from its sub-threshold home lake, sending it ~5,800 px along
  the shoreline. The threshold still applies to routers choosing a distant lake.

As predicted, the rim cap re-arms: 31415926 #2 (66.7 m) now goes to the sea and
is refused, rather than being hidden in a lake.

Both fixes are off by default, so faithful mode still reproduces rivers/03 and
refined mode still reproduces rivers/03b.

Taste gate: nothing locked, nothing graduated.
2026-08-24 23:28:02 -04:00
b5ceca0419 rivers/03b: routing refinement — three deliberate divergences from the faithful port
NOT a port. Each fix corrects a faithful reference behaviour that produced a
physically-wrong result, on the developer's explicit call. basinHasLake is KEPT
as the sort. Courses only: no height mutated, no water filled or created —
asserted per seed by a raw-bit digest of both height fields.

- FIX 1 rim cap (ISLA_RIM_CAP_M, default 30 m): a route to the sea that must
  climb higher than this above its terminal is refused; the river ends at its
  own terminal. Reference routes at any cost (rivers/03 found a 66.7 m one).
- FIX 3 lake targets: a router stops at the nearer of {ocean, significant lake},
  so it cannot skirt a lake to reach a distant coast. Reference targets ocean only.
- FIX 2 confluence: courses laid biggest-first join on TRUE cell intersection
  (never proximity); the smaller becomes a tributary and adopts the bigger one's
  downstream and terminus. Reference lays routes independently — rivers/03 found
  two rivers at the identical ocean cell on every seed.

All three are off by default (RiverRouting.Options.Faithful), so rivers/03 still
reproduces bit-for-bit from the same tool.

Taste gate: nothing locked, nothing graduated.
2026-08-24 22:00:22 -04:00
4e4be6a83e rivers/03: lowland routing — the routed MIX on the pure N=12, courses only
Ports the ROUTING PORTION of the reference's RiverCarvePass (RouteToOcean, the
routed/lake-ender sort, SmoothCourse). NOT CarveRiver (bed stamp) and NOT
AddSteppedWater (water bodies) — those are later tasks.

RED LINE: no height mutated, no water filled, nothing carved. Asserted per seed
by an FNV digest of both height fields before/after routing.

- RiverRouting: deterministic LOWGROUND Dijkstra, uphill penalised so a route may
  cross the basin rim, empty-list-on-no-path. Effective == declared constants
  (verified: private const, no ConfigManager key, no [Export] in the reference).
- The sort is the REFERENCE's — Kind = basinHasLake ? lake-ender : routed. The
  task's stated "a path exists -> routed" cannot discriminate: on an 8-connected
  grid a path to the ocean always exists, confirmed empirically (43/43 probes
  reached). The ocean route is probed for every giant anyway, so the missing
  affordability threshold is reported as a number rather than guessed.
- RegionLabeling.SignificantWaterMask: interim substitute for v2's missing
  water-bodies table — 8-connected classify-water components >= 20,000 px.
- RiverCandidates: the candidate enumeration extracted out of RiverPromotionTool
  so routing ranks the identical set the count gate was judged on. Behaviour
  neutral — rivers/02b's twelve plates are byte-identical across the extraction.
- DrainageRenderer.RoutedMix: three classes, with each routed river's added
  lowland reach and the rim it crossed drawn distinctly from its natural stem.

Taste gate: no count, no K, no style, no default set.
2026-08-24 04:54:30 -04:00
559306ca73 rivers/02b: composition gate — N=12 pure ranking vs a gameplay sea-river floor of K=3
A re-selection over rivers/02's candidates, not a new analysis: DrainageAnalysis is
reused untouched and the 8-seed distribution sweep is not re-run.

- RiverPromotionTool: ISLA_PROMOTE_MODE=composition builds both compositions at one
  fixed total — PURE (top N by drainage, terminus irrelevant) and QUOTA (the K largest
  sea-reaching forced in + the N-K largest endorheic). Deterministic; shortfall is
  handled and flagged rather than back-filled. BindCourses now takes an explicit
  candidate set, because a forced sea river can sit far below rank N (measured: #29).
- DrainageRenderer.RiverComposition: the composition plate, at ONE ABSOLUTE
  width->drainage constant (1/180 px per sqrt(drainage px)) shared across both plates
  and all seeds — per-plate normalisation cannot answer "is this river thin?" — plus
  per-river drainage/rank labels with collision-avoided placement.
- ToolingPaths.BatchRoot: additive overload for a lettered sub-task, so 02b writes to
  02b_composition instead of claiming task 03's number.

Taste gate: no N, no K, no default set anywhere. Nothing carved, no routing, and
Giant.ProvisionalRoute is still never drawn.
2026-08-24 01:58:39 -04:00
ea301f7a8c rivers/02: river promotion — unified ranking over both termini, and the count gate
Choose the river count against the terrain that actually exists. The M3 count of
3 was tuned on topography the southern stretch (D-065) and coastal fragmentation
(D-063) have since replaced, and TrunkCount/GiantCount/EndorheicMaxCount = 3 are
LEAN REPORTING CAPS, not a statement about this island.

UNIFIED RANKING. The reference promoted from two lists with two quotas — N sea
trunks, N endorheic giants — which encodes the assumption that reaching the sea
is what makes a drainage a river. This terrain does not satisfy it: 54-78% of
land drains inland across the gallery, 106-121 terminal basins per seed. So
selection is unified — every major drainage ranked by contributing-cell count in
ONE list, top N promoted, and the sea/endorheic split FALLS OUT. A quota would
have promoted small coastal drainages over far larger inland ones purely because
of where they end. Deliberate departure from the reference's structure (D-050
noted, approved in chat). Only SELECTION is unified: the per-terminus tag is
retained per river because rivers/03's routing branches on it, and Trunk/Giant
are untouched.

  The metric is the same unit on both sides, and that is now ASSERTED rather
  than argued: sea Acc and endorheic BasinInflow are both counts of contributing
  land cells on the same D8 field, and every land cell has exactly one
  destination, so Σ sea Acc + Σ BasinInflow + unrouted == LandCells exactly. The
  tool refuses to rank unless it holds, per seed. It held on all 8.

DrainageAnalysis.cs is NOT modified. The complete candidate set is re-derived
from state it already exposes (Dir/Acc/BasinId/BasinInflow/FullFilled); the
three internal per-basin values that are not exposed are exactly reconstructible
because terminal basins are reverted to the real surface while FullFilled keeps
the fill. Only REPORTING caps were raised, and only so the analysis's own
TraceStem produces a real upland course per promotable candidate.
EndorheicMinDepthM/MinAreaPx were left alone — they decide which depressions
BECOME terminal basins, i.e. they define the routing surface itself, and a count
chosen on a moved surface would be a count for terrain that does not exist.

TWO FINDINGS.

  1. THE TERRAIN HAS NO NATURAL COUNT. The knee (largest ratio between
     consecutive ranks) lands at rank 19/7/15/5/4/12/3/8 across the eight
     gallery seeds at ratios of 1.33-1.57x, with one uncorroborated 2.37x. A
     break that wanders across the whole plausible range at that strength is not
     a break — on a log axis the candidates fall on a near-straight line.
     N is a DESIGN choice, and the diagnostic says so instead of inventing one.

  2. THE ISLAND'S MAJOR RIVERS ARE INLAND RIVERS. Six of eight seeds have ZERO
     sea-reaching drainages in their top 8; the median at N=16 is one; one seed
     has none at 16. On the primary seed the largest sea drainage ranks 11th at
     736,088 px against 2,329,573 px for the largest endorheic — 3.2x. (That
     outlet is exactly batch 12's recorded top trunk, to the cell.)

A concern I raised and then CLOSED by measuring: MinOutletSeparationPx is a
plain Euclidean test with no notion of which landmass a coastline belongs to, so
on a fragmented archipelago it could suppress an island's only river. Measured:
0-6 cross-landmass suppressions per seed, and — decisively — the largest
suppressed above-floor outlet anywhere is 209,611 px against a smallest ladder
cutoff of 439,592 px, so ZERO suppressed outlets clear any rung on any seed. The
rule provably cannot have altered the ladder. Real but harmless; flagged for
rivers/03, not a blocker. The rule was NOT changed — it belongs to the analysis,
and moving it would move the candidate set the developer is being asked to judge.

Also found: the params are ABSOLUTE pixel counts, so this analysis is only valid
at the size they were tuned for. At 1024 nothing qualifies as endorheic (the
whole endorheic half cannot be exercised) and separation suppresses 15,043 of
15,048 outlets. The tool now refuses loudly to have a non-8192 run read as a
count decision. Flagged for rivers/03: these want to become scale-free fractions
as MinLandComponentFrac already is.

Giant.ProvisionalRoute is never rendered — the "comb" is rivers/03's to replace,
and drawing it would make a count judgment look like a finished network. The
plates draw the real erosion-carved upland stems. Endorheic rivers are marked at
where their stem POOLS, not at the basin's deepest cell: the analysis
distinguishes these deliberately and they sit up to 395 px apart, which drew
every stem detached from its own endpoint until it was fixed.

TASTE GATE: 8/12/16 are presented and nothing is decided. No count is chosen, no
default is set, and neither TerrainGenConfig nor DrainageAnalysis.Params changed.

-> XX_Human/output/rivers/02_promotion.report.md
-> batches/rivers/02_promotion/INDEX.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhkXBQh2tDmcWKpXYcj8vj
2026-08-23 06:13:03 -04:00
03fe75b378 rivers/01: re-baseline — bare defaults reproduce terrain-shape-v1 (calibration pinned family-off); retire stale oracles; namespace batch root by chat
The bare TerrainGenConfig defaults did NOT reproduce the terrain the developer
locked, so "run the default generator" was not "the terrain in the gallery" —
the single most expensive fact in the codebase, and the reason a fresh chat
would spend an afternoon chasing differences that were CONFIGURATION, not
regression. This is the deliberate task that ends that, before any river work.

A1 — the defaults ARE the locked shape now. Five fields actually move:
SpeckRevert false->true, MinLandComponentFrac 3e-5->2.5e-7 (120x smaller; the
config default would have eaten real islands, not specks), SouthStretch 0->2,
FragmentAmp 0->0.5, Erosion false->true. Seven more were already correct via
SouthernStretch.Default* / CoastalFragment.Default* and are now pinned as
literals, because TerrainShapeV1 used to do that pinning and this default set
inherits the job. CoastShelf stays OFF — the locked shape has no shelf, and
evaluating it (D-041) is its own later task once water renders. Offshore stays
Off permanently (D-063): islands are organic-only, made by the stretch +
fragmentation and identified by the region layer, never placed.

A2 — the preserve mechanism. The curve knots are percentiles of the FAMILY-OFF
land distribution; flipping the defaults would have moved the pool, the knots,
and with them the render field of every batch including terrain-shape-v1
itself. So the pool is pinned family-off (TerrainGenConfig.WithFamilyOff /
CalibrationPool) rather than the knots being baked: calibration stays live, its
INPUT distribution is held still. The pin was a no-op by construction — it sets
the values the defaults carried the instant before the flip — and re-measuring
after confirms it: pool, all six knots, per-seed spread, shaped max,
monotonicity spikeMax and all seven band shares identical.

  Applied wider than "in CalibrateCurve": OffshoreIslandsTool,
  RegionLabelingTool and SouthernStretchTool generate their own family-off
  field for the Phase-1 anchor, so the pool pin alone would NOT have covered
  them and their a1 would have failed for a configuration reason. TerrainGenTool
  too — it AUTHORED 02_pass1_port and must stay able to regenerate its own
  anchor.

  Recorded as a judged-and-parked property: knots measured family-off, applied
  family-on. Deliberate, not an oversight. Same disposition as the mid-slope
  feather.

A4 — no oracle may pass against a superseded baseline. Six anchors retired
(01/03/04/06/08/09) with their checks and ISLA_T0x_SOURCE defaults; three kept
(chat1/02_pass1_port as the family-off pass-1 guard, chat2/10 and chat2/11 as
the shape and erosion acceptance anchors). Two invariants were RE-POINTED
rather than lost — the southern stretch's north-lock and the coastal-fragment
interior-lock now compare against SAME-RUN fields, which is scale-free and
cannot be invalidated by a moved dump. The retired dumps are kept, not deleted,
and marked superseded in their INDEX.md.

  A missing anchor is now LOUD. The old pattern skipped silently, so a moved
  anchor did not make its oracle fail — it made it not RUN, and a batch with a
  skipped check prints an all-PASS table that reads like a clean one. That is
  the INVERSE of the hazard the re-baseline guards against, and the migration
  below is exactly the event that would have triggered it, on nine anchors at
  once. ShapingOracle.LoadAnchor now separates the two cases: absent -> throw;
  present at another size -> loud INCONCLUSIVE, which is a fail, never a pass.

  TerrainShapeV1 inverted from PRESET to GUARD and moved to its own file.
  Apply() is gone — stamping the values on top of the defaults would MASK a
  drift instead of catching it. Its constants are now the assertion target, and
  Assert() refuses a run whose defaults have drifted off the locked shape.

B/C — batches are namespaced by chat: batches/<chat>/NN_slug/. Task numbers
restart at 00 per chat, so a flat root collided the moment a second chat
existed — four colliding prefixes across 25 batches, separable only by slug.
ToolingPaths.ChatSlug is REQUIRED (throws if unset) and defaults per tool to
its authoring chat, so re-running reproduces a batch in place while ISLA_CHAT
redirects — which is also what stops an acceptance run from overwriting the
very anchor it checks against. Writes go through BatchRoot; historical READS
compose against BatchesRoot and so carry the prefix in their own source string
("chat1/02_pass1_port"). The 25 existing batches were migrated moves-only.

ACCEPTANCE — 16 of 16 byte-identical, 0 failed. All 8 gallery seeds at 8192
from the bare defaults are byte-identical to chat2/10_frag4_seed_gallery
(= terrain-shape-v1, a59e52f); all 8 erosion fields byte-identical to
chat2/11_erosion (= ea291ea). Every gallery table row and every erosion
statistic reproduces its recorded value exactly. DrainageTool's a11 passes
bit-identical over 67,108,864 cells, and its analysis reproduces batch 12
exactly — so the whole chain rivers depends on (shape -> erosion -> drainage)
is unchanged. All 12 edited tools re-run clean; both new guards negative-tested.

The baseline moved in DEFAULTS, not in TERRAIN.

-> XX_Human/output/rivers/01_rebaseline_and_batch_namespace.report.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhkXBQh2tDmcWKpXYcj8vj
2026-08-23 04:45:10 -04:00
37 changed files with 7658 additions and 322 deletions

478
Core/Scripts/BasinGraph.cs Normal file
View file

@ -0,0 +1,478 @@
using System;
using System.Collections.Generic;
namespace IslaApocalypse.Core
{
/// <summary>Where a terminal basin's spill drains next.</summary>
public enum DownstreamKind : byte
{
/// <summary>The spill walk found no strictly-lower neighbour before reaching anything — a genuinely closed sink (or an exact float flat).</summary>
None = 0,
/// <summary>The spill drains into <c>RegionLabeling.OceanMask</c> — the sea, on the CLASSIFY field.</summary>
Ocean = 1,
/// <summary>The spill drains into another terminal basin's cells (<see cref="BasinNode.DownstreamId"/>).</summary>
Basin = 2,
}
/// <summary>
/// ⭐⭐ ONE NODE OF THE BASIN GRAPH (rivers/04) — a terminal basin of <see cref="DrainageAnalysis"/>,
/// enriched with the three things the analysis left latent: its SPILL, its LAKE-IDENTITY, and its
/// DOWNSTREAM EDGE. Pure data. Nothing here is a height write or a water fill.
///
/// ═══ ⚠⚠ D-046 — WHICH SURFACE EACH FIELD IS READ ON ═══
///
/// RENDER / FLOW surface (<c>Plan.FullFilled</c>, the priority-flood of the eroded render height)
/// <see cref="SpillCell"/>, <see cref="SpillHeightRaw"/>, <see cref="FloorHeightRaw"/>,
/// <see cref="SpillClimbM"/>, <see cref="DownstreamKind"/> walk — everything about WHERE WATER GOES.
/// This is the surface <c>RiverRouting.RouteTo</c> routes on, so a spill height and a rim climb are
/// the same kind of number the router already measures.
///
/// CLASSIFY / WATER surface (<c>isClassifyWater</c> = classify &lt; sea; <c>OceanMask</c>)
/// <see cref="LakeCells"/>, <see cref="IsLake"/>, <see cref="OceanCells"/>, and the OCEAN terminus
/// of the downstream walk — everything about WHAT IS VISIBLY WATER. This is the surface the
/// router's terminus tests already use.
///
/// ⛔ No field compares a classify height to a render height. The two surfaces meet only as
/// MEMBERSHIP (is this basin cell classify-water? is this walk cell ocean?), which is exactly the
/// split the routing already lives by (route on render, `OceanMask` on classify). No new seam.
/// </summary>
public sealed class BasinNode
{
/// <summary>The terminal-basin id, as <c>Plan.BasinId</c> carries it (sparse: pits that filled through gave up their ids).</summary>
public int Id;
/// <summary>Cells with <c>BasinId == Id</c>.</summary>
public long AreaPx;
/// <summary><c>Plan.BasinInflow[Id]</c> — cells whose flow terminates here (the promotion metric).</summary>
public long InflowPx;
/// <summary>The basin's deepest cell on the RENDER height (first in scan order on ties), and its height.</summary>
public int FloorCell;
public float FloorHeightRaw;
/// <summary>
/// The basin's ENTRY cell: its minimum on <c>FullFilled</c>. The priority-flood raises the first cell
/// it steps into from the spill to exactly one ulp above the spill, so this is spill + 1 ulp.
/// </summary>
public int EntryCell;
public float EntryFullFilledRaw;
/// <summary>
/// ⭐⭐ THE SPILL — the lowest cell on the basin's 8-neighbour boundary, read on <c>FullFilled</c>
/// (== the render height there — asserted, see <see cref="SpillOnTerrain"/>). This is the rim cell
/// water would overtop. Ties (same height) resolve to the lowest cell index; <see cref="SpillTies"/>
/// says how many boundary cells sit at exactly this height.
/// </summary>
public int SpillCell;
public float SpillHeightRaw;
public int SpillTies;
/// <summary>⭐ Cross-check (a) vs (b): <c>BitDecrement(EntryFullFilledRaw) == SpillHeightRaw</c>. The uniform-fill-level reading and the rim-walk reading must agree exactly.</summary>
public bool SpillCrossCheckOk;
/// <summary>⭐ The spill cell is real terrain: <c>FullFilled[spill] == render[spill]</c> (it was never raised by the flood).</summary>
public bool SpillOnTerrain;
/// <summary>Spill height above the sea scalar, metres (render surface; may be negative for a rim below the datum).</summary>
public float SpillAboveSeaM;
/// <summary>Floor → spill, metres, unclamped — the basin's depth to its overflow (≈ <c>DrainageAnalysis</c>'s <c>basinDepthM</c>).</summary>
public float DepthToSpillM;
/// <summary>
/// ⭐ THE CLIMB THE CAP IS JUDGED AGAINST: <c>ElevM(spill) ElevM(floor)</c> with elevation clamped at
/// sea exactly as <c>RiverRouting.RouteTo</c> clamps it — so a below-datum lagoon bed climbs from sea
/// level, not from its bed. Same number the router's <c>RimClimbM</c> is. ⚠ The clamp is an ELEVATION
/// rule on the render surface, not a water test — nothing here reads "render &lt; sea" as water.
/// </summary>
public float SpillClimbM;
/// <summary>Cells with <c>BasinId == Id</c> that are classify-water and NOT ocean. Read on CLASSIFY.</summary>
public long LakeCells;
/// <summary>Of those, cells belonging to a SIGNIFICANT body (the router's ≥ floor mask) — for cross-reference with the routing's lake mask.</summary>
public long LakeCellsSignificant;
/// <summary>⚠ Cells with <c>BasinId == Id</c> that are OCEAN on classify — a render depression under the sea. The D-046 seam, made visible rather than hidden.</summary>
public long OceanCells;
/// <summary>⭐ <c>LakeCells &gt;= floor</c> — a significant heightmap lake sits in this basin. This is the graph's lake/dry label.</summary>
public bool IsLake;
/// <summary>
/// ⚠⚠ EVERY cell of this basin is OCEAN on classify — a render depression on the SEABED. The priority-flood runs on
/// the whole render surface, so a deep-enough, large-enough pit under the sea qualifies as a "terminal basin" exactly
/// like a land one; hydrologically it is inert (its cells are D_NONE, its inflow is 0). Kept in the layer, EXCLUDED
/// from every lake/dry, spill and cap statistic, and counted loudly — this is the D-046 seam, not a lake.
/// </summary>
public bool IsSeabed;
/// <summary>Some but not all cells are ocean on classify — a basin straddling the shoreline seam. Treated as land (it has land cells and inflow) and counted.</summary>
public bool IsCoastal;
/// <summary>A basin with at least one land cell — the ones the graph is about.</summary>
public bool IsLand => !IsSeabed;
/// <summary><c>LakeCells &gt; 0</c> — exactly <c>DrainageAnalysis</c>'s <c>basinHasLake</c> (any size), the routing sort. Kept so the two labels can be compared.</summary>
public bool HasAnyLake;
/// <summary>⭐⭐ THE EDGE — where the spill drains next.</summary>
public DownstreamKind Downstream;
/// <summary>The downstream basin id when <see cref="Downstream"/> is <see cref="DownstreamKind.Basin"/>; 0 otherwise.</summary>
public int DownstreamId;
/// <summary>The cell the spill walk ended on: the first ocean cell, the first cell of the next basin, or where it stuck.</summary>
public int DownstreamEntryCell;
/// <summary>The spill walk itself, spill → entry, 1-px cells — the reference's provisional-route descent on <c>FullFilled</c>.</summary>
public List<int> SpillPath = new();
/// <summary>⭐ Cross-check: following <c>Plan.Dir</c> (the analysis's own D8 field) from the first cell past the spill reaches the same node.</summary>
public bool DirWalkAgrees = true;
public DownstreamKind DirWalkKind;
public int DirWalkId;
}
/// <summary>
/// ⭐⭐ THE BASIN GRAPH — the water-bodies layer the flow-through routing model traverses (rivers/04).
///
/// ═══ WHAT IT IS ═══
///
/// <see cref="DrainageAnalysis"/> already found the sinks (<c>BasinId</c>) and already computed the
/// overflow surface (<c>FullFilled</c>). This layer reads those outputs and records, per terminal basin,
/// its spill, whether a significant heightmap lake sits in it, and where its spill drains to. The
/// result is a DAG: an edge always leads to a strictly lower spill, so no chain can cycle.
///
/// ═══ ⛔ THE RED LINE ═══
///
/// **Reads heights, writes none. Creates no water. <c>DrainageAnalysis</c> is consumed, not edited.**
/// The caller asserts both height digests unchanged around <see cref="Build"/>.
///
/// ═══ ⭐ WHY THE SPILL IS EXACT (the Part-0 argument, kept where the code is) ═══
///
/// The routing fill is a Barnes priority-flood with a one-ulp pit epsilon. A depression is entered
/// from the lowest rim cell S popped off the heap (height L, never raised); the first cells inside
/// are raised to <c>BitIncrement(L)</c> and every deeper cell to one ulp above ITS parent. So:
/// • a terminal basin's cells (<c>FullFilled &gt; original</c>, 8-connected) are ONE flood chain from
/// ONE spill, and their minimum on <c>FullFilled</c> is exactly L + 1 ulp;
/// • every boundary cell (8-adjacent, not in the basin) was NOT raised, so <c>FullFilled == original</c>
/// there, and its height is ≥ L (a lower one would have been a lower way in);
/// • the boundary minimum IS S, at exactly L.
/// Both readings are computed and compared per basin (<see cref="BasinNode.SpillCrossCheckOk"/>), and
/// the "spill sits on real terrain" fact is asserted too (<see cref="BasinNode.SpillOnTerrain"/>).
///
/// ═══ ⭐ WHY THE DOWNSTREAM WALK IS ON FullFilled, NOT Plan.Dir ═══
///
/// <c>Plan.Dir</c> is D8 on <c>Filled</c> — the surface with terminal basins REVERTED to real heights.
/// The spill cell is the saddle; on <c>Filled</c> its steepest neighbour may be back INTO its own basin
/// (the reverted floor is lower than the rim), which would name the basin its own downstream. On
/// <c>FullFilled</c> the basin stands at L + ulps above its spill, so the descent from S cannot re-enter
/// it — that is exactly why the reference walked its provisional route on the full fill. <c>Dir</c> is
/// used as the CROSS-CHECK from the first cell past the spill, where re-entry is impossible.
/// </summary>
public sealed class BasinGraph
{
// Neighbour order FIXED, identical to DrainageAnalysis — 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 };
public int MapSize;
public float SeaLevel;
/// <summary>The significance floor a basin's in-basin classify water must reach to make it a LAKE basin. A knob (<c>ISLA_LAKE_MIN_PX</c>).</summary>
public int LakeMinPx;
// ---- provenance, recorded on the layer ----
public const string SpillDatum =
"spill = minimum of Plan.FullFilled over the basin's 8-neighbour boundary (== eroded RENDER height there); " +
"cross-checked against BitDecrement(min FullFilled inside the basin); ties → lowest cell index";
public const string LakeDatum =
"lake = cells with BasinId == id AND classify < sea AND NOT OceanMask (CLASSIFY surface), total >= LakeMinPx";
public const string DownstreamMethod =
"edge = steepest descent on Plan.FullFilled from the spill cell (the reference's provisional-route walk), " +
"until OceanMask (classify) or another BasinId; cross-checked by following Plan.Dir from the first cell past the spill";
public List<BasinNode> Nodes = new();
private Dictionary<int, BasinNode> _byId = new();
public BasinNode Of(int id) => _byId.TryGetValue(id, out var b) ? b : null;
// ---- invariant tallies ----
public int SpillCrossCheckFailures, SpillNotOnTerrain, DirWalkDisagreements;
/// <summary>Tallies over LAND basins only (seabed basins excluded — see <see cref="BasinNode.IsSeabed"/>).</summary>
public int ToOcean, ToBasin, Closed, LakeBasins, DryBasins;
/// <summary>⚠ The seam counts: basins entirely under the classify sea, and basins straddling the shoreline.</summary>
public int Seabed, Coastal;
/// <summary>The land basins, in id order — what every statistic and the plate's graph are over.</summary>
public List<BasinNode> LandNodes = new();
/// <summary>
/// ⭐ ONE SIGNIFICANT CLASSIFY-WATER BODY, and which basin (if any) owns it. The reconciliation the whole
/// layer exists for, measured per lake rather than assumed: heightmap lakes and terminal basins coincide
/// only by terrain coincidence, so this says, per significant body, how much of it sits inside a terminal
/// basin and which one — or that it floats free of the hydrology entirely.
/// </summary>
public sealed class LakeBody
{
public int Index; // 1-based, scan order
public long SizePx;
public long CellsInBasins; // cells with BasinId != 0
public int DominantBasinId; // the basin holding most of its cells (0 = none)
public long DominantCells;
public int BasinsTouched; // distinct basins it overlaps
public bool Owned => DominantBasinId != 0 && DominantCells * 2 >= SizePx; // ≥ half inside one basin
public bool Free => CellsInBasins == 0;
}
/// <summary>Every significant body (8-connected, ≥ floor), scan order.</summary>
public List<LakeBody> LakeBodies = new();
public int LakeBodiesOwned, LakeBodiesFree, LakeBodiesSplit;
/// <summary>Non-ocean classify-water cells outside every terminal basin — heightmap water the hydrology never pooled into.</summary>
public long ClassifyWaterCellsOutsideBasins, ClassifyWaterCellsTotal;
public static BasinGraph Build(DrainageAnalysis.Plan plan, float[,] render, int n,
bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, int lakeMinPx)
{
int total = n * n;
var g = new BasinGraph { MapSize = n, SeaLevel = sea, LakeMinPx = lakeMinPx };
int[] basinId = plan.BasinId;
float[] ff = plan.FullFilled;
int maxId = 0;
for (int i = 0; i < total; i++) if (basinId[i] > maxId) maxId = basinId[i];
// ---- pass 1: per-basin scalars, scan order ----
var area = new long[maxId + 1];
var floorCell = new int[maxId + 1]; var floorH = new float[maxId + 1];
var entryCell = new int[maxId + 1]; var entryFF = new float[maxId + 1];
var lake = new long[maxId + 1]; var lakeSig = new long[maxId + 1]; var ocean = new long[maxId + 1];
for (int id = 0; id <= maxId; id++) { floorCell[id] = -1; floorH[id] = float.MaxValue; entryCell[id] = -1; entryFF[id] = float.MaxValue; }
for (int i = 0; i < total; i++)
{
int id = basinId[i];
if (id == 0) continue;
area[id]++;
float h = render[i / n, i % n];
if (h < floorH[id]) { floorH[id] = h; floorCell[id] = i; }
if (ff[i] < entryFF[id]) { entryFF[id] = ff[i]; entryCell[id] = i; }
if (isOcean[i]) ocean[id]++;
else if (isClassifyWater[i])
{
lake[id]++;
if (isSignificantWater != null && isSignificantWater[i]) lakeSig[id]++;
}
}
// ---- pass 2: boundary minimum on FullFilled (the rim walk) ----
var bMin = new float[maxId + 1]; var bCell = new int[maxId + 1];
for (int id = 0; id <= maxId; id++) { bMin[id] = float.MaxValue; bCell[id] = -1; }
for (int i = 0; i < total; i++)
{
int id = basinId[i];
if (id == 0) continue;
int cx = i / n, cy = i % 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] == id) continue;
float v = ff[ni];
if (v < bMin[id] || (v == bMin[id] && ni < bCell[id])) { bMin[id] = v; bCell[id] = ni; }
}
}
// ---- pass 3: how many DISTINCT boundary cells tie at the spill height ----
var ties = new HashSet<int>[maxId + 1];
for (int i = 0; i < total; i++)
{
int id = basinId[i];
if (id == 0) continue;
int cx = i / n, cy = i % 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] == id || ff[ni] != bMin[id]) continue;
(ties[id] ??= new HashSet<int>()).Add(ni);
}
}
float ElevM(float h) => MathF.Max(0f, WorldScale.MetresFromRaw(h - sea));
// ---- per basin: the node ----
for (int id = 1; id <= maxId; id++)
{
if (area[id] == 0) continue;
var b = new BasinNode
{
Id = id, AreaPx = area[id],
InflowPx = id < plan.BasinInflow.Length ? plan.BasinInflow[id] : 0,
FloorCell = floorCell[id], FloorHeightRaw = floorH[id],
EntryCell = entryCell[id], EntryFullFilledRaw = entryFF[id],
SpillCell = bCell[id], SpillHeightRaw = bMin[id],
SpillTies = ties[id]?.Count ?? 0,
LakeCells = lake[id], LakeCellsSignificant = lakeSig[id], OceanCells = ocean[id],
};
b.SpillCrossCheckOk = bCell[id] >= 0 && MathF.BitDecrement(entryFF[id]) == bMin[id];
b.SpillOnTerrain = bCell[id] >= 0 && render[bCell[id] / n, bCell[id] % n] == bMin[id];
b.SpillAboveSeaM = WorldScale.MetresFromRaw(b.SpillHeightRaw - sea);
b.DepthToSpillM = WorldScale.MetresFromRaw(b.SpillHeightRaw - b.FloorHeightRaw);
b.SpillClimbM = ElevM(b.SpillHeightRaw) - ElevM(b.FloorHeightRaw);
b.IsLake = b.LakeCells >= lakeMinPx;
b.HasAnyLake = b.LakeCells > 0;
b.IsSeabed = b.OceanCells == b.AreaPx;
b.IsCoastal = b.OceanCells > 0 && !b.IsSeabed;
if (!b.SpillCrossCheckOk) g.SpillCrossCheckFailures++;
if (!b.SpillOnTerrain) g.SpillNotOnTerrain++;
if (b.IsSeabed) g.Seabed++;
if (b.IsCoastal) g.Coastal++;
// ⭐⭐ THE DOWNSTREAM WALK — the reference's provisional-route descent, started at the spill.
if (bCell[id] >= 0)
{
int c = bCell[id];
b.Downstream = DownstreamKind.None;
for (int guard = 0; guard < 4 * n; guard++)
{
b.SpillPath.Add(c);
if (isOcean[c]) { b.Downstream = DownstreamKind.Ocean; break; }
int bid = basinId[c];
if (bid != 0 && bid != id) { b.Downstream = DownstreamKind.Basin; b.DownstreamId = bid; break; }
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 (ff[ni] < best) { best = ff[ni]; bestN = ni; }
}
if (bestN < 0 || ff[bestN] >= ff[c]) break; // stuck — a closed sink (or an exact flat)
c = bestN;
}
b.DownstreamEntryCell = b.SpillPath[^1];
// ⭐ Cross-check on the analysis's own D8 field, from the first cell PAST the spill.
if (b.SpillPath.Count >= 2)
{
int c2 = b.SpillPath[1];
var (dk, did) = WalkDir(plan, n, isOcean, c2);
b.DirWalkKind = dk; b.DirWalkId = did;
b.DirWalkAgrees = dk == b.Downstream && did == b.DownstreamId;
}
else { b.DirWalkKind = b.Downstream; b.DirWalkId = b.DownstreamId; b.DirWalkAgrees = true; }
if (!b.DirWalkAgrees) g.DirWalkDisagreements++;
}
if (b.IsLand)
{
switch (b.Downstream)
{
case DownstreamKind.Ocean: g.ToOcean++; break;
case DownstreamKind.Basin: g.ToBasin++; break;
default: g.Closed++; break;
}
if (b.IsLake) g.LakeBasins++; else g.DryBasins++;
g.LandNodes.Add(b);
}
g.Nodes.Add(b);
g._byId[id] = b;
}
// ---- the reconciliation, per significant body: which basin owns it? ----
for (int i = 0; i < total; i++)
if (isClassifyWater[i] && !isOcean[i]) { g.ClassifyWaterCellsTotal++; if (basinId[i] == 0) g.ClassifyWaterCellsOutsideBasins++; }
if (isSignificantWater != null)
{
var seen = new bool[total];
var stack = new Stack<int>();
var perBasin = new Dictionary<int, long>();
for (int s = 0; s < total; s++)
{
if (seen[s] || !isSignificantWater[s]) continue;
var body = new LakeBody { Index = g.LakeBodies.Count + 1 };
perBasin.Clear();
seen[s] = true; stack.Push(s);
while (stack.Count > 0)
{
int c = stack.Pop();
body.SizePx++;
int bid = basinId[c];
if (bid != 0) { body.CellsInBasins++; perBasin.TryGetValue(bid, out long cur); perBasin[bid] = cur + 1; }
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 (seen[ni] || !isSignificantWater[ni]) continue;
seen[ni] = true; stack.Push(ni);
}
}
body.BasinsTouched = perBasin.Count;
foreach (var kv in perBasin)
if (kv.Value > body.DominantCells || (kv.Value == body.DominantCells && kv.Key < body.DominantBasinId))
{ body.DominantCells = kv.Value; body.DominantBasinId = kv.Key; }
if (body.Free) g.LakeBodiesFree++; else if (body.Owned) g.LakeBodiesOwned++; else g.LakeBodiesSplit++;
g.LakeBodies.Add(body);
}
}
return g;
}
/// <summary>Follow <c>Plan.Dir</c> from a cell to where its flow ends: the sea, a terminal basin, or nowhere.</summary>
private static (DownstreamKind kind, int id) WalkDir(DrainageAnalysis.Plan plan, int n, bool[] isOcean, int start)
{
int c = start;
for (int guard = 0; guard < 8 * n; guard++)
{
if (isOcean[c]) return (DownstreamKind.Ocean, 0);
sbyte d = plan.Dir[c];
if (d == DrainageAnalysis.D_SEA) return (DownstreamKind.Ocean, 0);
if (d == DrainageAnalysis.D_NONE) return plan.BasinId[c] != 0 ? (DownstreamKind.Basin, plan.BasinId[c]) : (DownstreamKind.None, 0);
int cx = c / n, cy = c % n;
c = (cx + DX[d]) * n + (cy + DY[d]);
}
return (DownstreamKind.None, 0);
}
/// <summary>
/// ⭐ THE CAP PREVIEW — which basins have an UNBROKEN spill-chain to the ocean when every link's
/// <see cref="BasinNode.SpillClimbM"/> must be ≤ <paramref name="capM"/>. A preview of what the
/// flow-through model will trade at a given cap; it decides nothing.
/// </summary>
public bool[] ConnectedAtCap(float capM, out int connected)
{
var state = new Dictionary<int, byte>(); // 1 = yes, 2 = no, 3 = visiting
bool Reach(int id)
{
if (state.TryGetValue(id, out byte s)) return s == 1;
var b = Of(id);
if (b == null) { state[id] = 2; return false; }
state[id] = 3;
bool ok = false;
if (b.SpillClimbM <= capM)
{
if (b.Downstream == DownstreamKind.Ocean) ok = true;
else if (b.Downstream == DownstreamKind.Basin)
{
// The graph is a DAG (an edge always lands on a strictly lower spill); the
// visiting guard is belt-and-braces, never expected to fire.
bool visiting = state.TryGetValue(b.DownstreamId, out byte ds) && ds == 3;
ok = !visiting && Reach(b.DownstreamId);
}
}
state[id] = ok ? (byte)1 : (byte)2;
return ok;
}
var outp = new bool[Nodes.Count];
connected = 0;
for (int i = 0; i < Nodes.Count; i++)
{
outp[i] = Reach(Nodes[i].Id);
if (outp[i]) connected++;
}
return outp;
}
/// <summary>Chain length (edges) from a basin to the ocean, or -1 if the chain ends in a closed sink.</summary>
public int HopsToOcean(int id)
{
int hops = 0; var seen = new HashSet<int>();
var b = Of(id);
while (b != null && seen.Add(b.Id))
{
if (b.Downstream == DownstreamKind.Ocean) return hops + 1;
if (b.Downstream != DownstreamKind.Basin) return -1;
b = Of(b.DownstreamId); hops++;
}
return -1;
}
}
}

View file

@ -183,6 +183,72 @@ namespace IslaApocalypse.Core
return labels;
}
/// <summary>
/// ⭐⭐ SIGNIFICANT WATER (rivers/03) — the interim substitute for the reference's water-bodies
/// table, built with this layer's own connected-component machinery.
///
/// ═══ WHY THIS EXISTS ═══
///
/// The reference builds `isSignificantWater` from `_waterBodies` — cells of any body with
/// `PixelCount >= RiverLakeMinTargetPx` — and a lake-ender routes to THAT rather than to any wet
/// pixel. **v2 has no water-bodies table yet** (a known port gap, `00_ground` §D3 /
/// carry-forward §5), so this labels 8-connected components of classify water directly and keeps
/// the ones at least <paramref name="minPx"/> cells. Same semantics, same threshold, no table.
///
/// ⚠ The size filter is the whole point and it is not a detail: routing a lake-ender to the
/// NEAREST wet pixel put one into a three-cell puddle a few hundred px short of the obvious
/// lagoon — the reference's own task-23 gate finding. "Nearest water" is satisfied by a puddle.
///
/// ⚠ OCEAN IS EXCLUDED. A lake-ender that could reach the ocean is not a lake-ender; including
/// ocean here would let one "terminate" at the coast and quietly become a sea river without ever
/// passing the routed test.
///
/// Pure: reads the mask, writes nothing, creates no water. Same 8-connectivity and same fixed
/// neighbour order as <see cref="Label"/>, so component identity is deterministic.
/// </summary>
public static bool[] SignificantWaterMask(bool[] isClassifyWater, bool[] isOcean, int mapSize,
int minPx, out int bodiesKept, out int bodiesTotal, out long cellsKept, out long largestPx)
{
int n = mapSize;
var seen = new bool[n * n];
var mask = new bool[n * n];
var stack = new Stack<int>();
var component = new List<int>();
bodiesKept = 0; bodiesTotal = 0; cellsKept = 0; largestPx = 0;
for (int s = 0; s < n * n; s++)
{
if (seen[s] || !isClassifyWater[s] || isOcean[s]) continue;
component.Clear();
seen[s] = true;
stack.Push(s);
while (stack.Count > 0)
{
int cur = stack.Pop();
component.Add(cur);
int cx = cur / n, cy = cur % 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 (seen[ni] || !isClassifyWater[ni] || isOcean[ni]) continue;
seen[ni] = true;
stack.Push(ni);
}
}
bodiesTotal++;
if (component.Count > largestPx) largestPx = component.Count;
if (component.Count >= minPx)
{
bodiesKept++;
cellsKept += component.Count;
foreach (int c in component) mask[c] = true;
}
}
return mask;
}
/// <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

View file

@ -26,6 +26,7 @@ namespace IslaApocalypse.Core
/// ISLA_CONFIG_PATH the generation config file default: user://config.json
/// ISLA_BLUEPRINT_PATH the blueprint read/written default: user://blueprints
/// ISLA_OUTPUT_DIR generation output (maps, batches) default: user://output
/// ISLA_CHAT the batch chat namespace default: the tool's authoring chat
///
/// ⚠ user:// RESOLUTION. Defaults sit under the project's own user data directory, which this
/// project pins away from the old prototype's — see project.godot's user:// isolation block.
@ -60,6 +61,61 @@ namespace IslaApocalypse.Core
/// <summary>Whether <see cref="Configure"/> has been called. Tooling should assert this before a run.</summary>
public static bool IsConfigured => _userDataDir != null;
// ═══ ⭐⭐ THE CHAT NAMESPACE (rivers/01) ═══════════════════════════════════════════════════
//
// ═══ WHY BATCHES ARE NAMESPACED BY CHAT ═══
//
// The batch prefix is the AUTHORING TASK NUMBER (see <see cref="BatchRoot"/>), and task
// numbers restart at 00 in every new build chat. So a flat batches/ directory COLLIDES the
// moment a second chat exists: chat 1's `02_pass1_port` and chat 2's `02_curve_continuous`
// are both "batch 02", and nothing in either name says which chat made it. Measured on the
// real pile at rivers/01: 25 batches, FOUR colliding prefixes (02, 03, 04, 06), 13 folders
// belonging to chat 1 and 12 to chat 2 — separable only by SLUG, never by number.
//
// > ### ⚠ The slug is WHO IS RUNNING, not who authored.
// > A tool carries its authoring chat as its default so that re-running it reproduces its own
// > batch in place. A different chat re-running it for its own purposes sets ISLA_CHAT and
// > writes under its own namespace — which is also what stops an acceptance run from
// > OVERWRITING THE VERY ANCHOR IT IS CHECKING AGAINST.
//
// ⚠ REQUIRED, exactly like <see cref="Configure"/>: with no slug set, <see cref="BatchRoot"/>
// throws rather than quietly writing to the un-namespaced root and re-creating the collision
// this exists to end.
public const string ChatVar = "ISLA_CHAT";
private static string _chatSlug;
/// <summary>
/// Set the chat namespace batches are written under. Called once at startup by every batch
/// tool, with its authoring chat as the fallback: <c>ConfigureChat(EnvStr(ChatVar, "chat2"))</c>.
/// </summary>
/// <param name="slug">
/// A short domain slug — `chat1`, `chat2`, `rivers`. ⚠ It becomes a single path SEGMENT, so
/// separators are refused rather than silently creating a nested tree nobody asked for.
/// </param>
public static void ConfigureChat(string slug)
{
if (string.IsNullOrWhiteSpace(slug))
throw new ArgumentException("A chat slug is required — batches are namespaced by chat.", nameof(slug));
string t = slug.Trim();
if (t.IndexOf('/') >= 0 || t.IndexOf('\\') >= 0 || t.IndexOf(Path.DirectorySeparatorChar) >= 0
|| t == "." || t == "..")
throw new ArgumentException(
$"Chat slug '{t}' is not a single path segment. The slug is ONE folder under batches/ — " +
"pass \"rivers\", not \"a/b\" or \"..\".", nameof(slug));
_chatSlug = t;
}
/// <summary>The chat namespace. ⚠ Throws if <see cref="ConfigureChat"/> has not been called.</summary>
public static string ChatSlug => _chatSlug ?? throw new InvalidOperationException(
"CHAT SLUG NOT SET. Batches are namespaced by chat (batches/<chat>/NN_slug/); a tool must call " +
"ToolingPaths.ConfigureChat(...) before composing a batch path. Writing to the un-namespaced root " +
$"is what collided task numbers across chats in the first place. (Override with {ChatVar}.) — rivers/01.");
/// <summary>Whether <see cref="ConfigureChat"/> has been called.</summary>
public static bool IsChatConfigured => _chatSlug != null;
/// <summary>The generation config file. Override: ISLA_CONFIG_PATH.</summary>
public static string ConfigPath =>
Override(ConfigPathVar) ?? Path.Combine(UserDataDir, "config.json");
@ -78,6 +134,13 @@ namespace IslaApocalypse.Core
/// INDEX.md and a persistent scratch/. A/B comparisons are browsed by a human, and a flat
/// directory of same-named PNGs is not browsable.
///
/// ⚠⚠ THIS IS THE ROOT, NOT A BATCH, AND THE DISTINCTION IS LOAD-BEARING. Batch WRITES go
/// through <see cref="BatchRoot"/>, which inserts the <see cref="ChatSlug"/> segment. Anchor
/// READS compose against THIS, so an anchor's source string must carry its own explicit
/// `chatN/` prefix (e.g. `"chat1/02_pass1_port"`). Changing only `BatchRoot` would namespace
/// every write and silently orphan every historical read — the exact trap rivers/01 had to
/// walk through, and why <c>ShapingOracle.LoadAnchor</c> now throws on a missing anchor.
///
/// ⚠ PROTECTED FROM DELETION. → <see cref="FileSafety"/>.
/// </summary>
public static string BatchesRoot => Path.Combine(OutputDir, "batches");
@ -90,7 +153,7 @@ namespace IslaApocalypse.Core
public static string BatchScratch(string batchDir) => Path.Combine(batchDir, "scratch");
/// <summary>
/// ⭐ A BATCH ROOT: <c>batches/&lt;task&gt;_&lt;descriptor&gt;/</c>.
/// ⭐ A BATCH ROOT: <c>batches/&lt;chat&gt;/&lt;task&gt;_&lt;descriptor&gt;/</c>.
///
/// ═══ ⚠⚠ THE PREFIX IS THE AUTHORING TASK NUMBER. IT IS NOT A COUNTER. ═══
///
@ -107,9 +170,40 @@ namespace IslaApocalypse.Core
///
/// The descriptor must NOT carry its own numeric prefix; that is the mistake this method
/// exists to prevent, so it is refused rather than silently accepted.
///
/// ═══ ⭐ THE &lt;chat&gt; SEGMENT (rivers/01) ═══
///
/// Prepended from <see cref="ChatSlug"/>, because the task-number prefix restarts at 00 in
/// every chat — see the note on <see cref="ConfigureChat"/>. It is a SEPARATE segment and is
/// never folded into the descriptor: the prefix guard below fires on a descriptor starting
/// with digits, so passing `"chat2/12_drainage"` as a descriptor would be a different kind
/// of wrong.
/// </summary>
public static string BatchRoot(int taskNumber, string descriptor)
public static string BatchRoot(int taskNumber, string descriptor) => BatchRoot(taskNumber, "", descriptor);
/// <summary>
/// ⭐ The same, for a LETTERED SUB-TASK: <c>batches/&lt;chat&gt;/&lt;task&gt;&lt;suffix&gt;_&lt;descriptor&gt;/</c>,
/// e.g. <c>02b_composition</c> (rivers/02b).
///
/// ═══ WHY A SUFFIX RATHER THAN A NEW TASK NUMBER ═══
///
/// The prefix is the AUTHORING TASK's identity, and a task numbered "02b" — a follow-up that
/// re-renders 02's material under one changed choice — has exactly that identity. Giving it a
/// fresh number (03) would claim it is the next task in the sequence and collide with the one
/// that actually is; folding the letter into the descriptor (<c>"02b_composition"</c>) would
/// smuggle a prefix past the guard below, which is the drift that guard exists to stop.
///
/// ⚠ Letters only, and lowercase — a suffix that could be read as part of a number would
/// reintroduce the ambiguity. Refused rather than sanitised.
/// </summary>
public static string BatchRoot(int taskNumber, string suffix, string descriptor)
{
string sfx = (suffix ?? "").Trim();
foreach (char c in sfx)
if (c < 'a' || c > 'z')
throw new ArgumentException(
$"Task suffix '{sfx}' must be lowercase letters only (e.g. \"b\" for task 02b). A suffix that " +
"could be read as part of the task number is exactly the ambiguity the prefix rule removes.", nameof(suffix));
if (taskNumber < 0)
throw new ArgumentOutOfRangeException(nameof(taskNumber), taskNumber,
"A batch is named for the task that authored it; there is no negative task.");
@ -127,7 +221,7 @@ namespace IslaApocalypse.Core
$"taskNumber and the descriptor WITHOUT one (e.g. \"review\", not \"04_review\") — " +
"the prefix is composed here so it cannot drift.", nameof(descriptor));
return Path.Combine(BatchesRoot, $"{taskNumber:D2}_{d}");
return Path.Combine(BatchesRoot, ChatSlug, $"{taskNumber:D2}{sfx}_{d}");
}
/// <summary>
@ -137,6 +231,13 @@ namespace IslaApocalypse.Core
public static string BatchDir(int taskNumber, string descriptor, long seed, string variant)
=> Path.Combine(BatchRoot(taskNumber, descriptor), $"{seed}_{variant}");
/// <summary>
/// Resolve a HISTORICAL batch by its namespaced name, e.g. <c>"chat1/02_pass1_port"</c> — the
/// form every `ISLA_*_SOURCE` anchor default takes since rivers/01. Kept beside
/// <see cref="BatchRoot"/> so a READ and a WRITE are visibly two different operations.
/// </summary>
public static string BatchSource(string namespacedName) => Path.Combine(BatchesRoot, namespacedName);
private static string Override(string variable)
{
string v = Environment.GetEnvironmentVariable(variable);
@ -149,7 +250,8 @@ namespace IslaApocalypse.Core
$"config : {ConfigPath}{Marker(ConfigPathVar)}\n" +
$"blueprints: {BlueprintPath}{Marker(BlueprintPathVar)}\n" +
$"output : {OutputDir}{Marker(OutputDirVar)}\n" +
$"batches : {BatchesRoot}";
$"batches : {BatchesRoot}\n" +
$"chat : {(IsChatConfigured ? ChatSlug : " NOT SET")} → writes land under batches/{(IsChatConfigured ? ChatSlug : "<chat>")}/NN_slug/";
private static string Marker(string variable) => Override(variable) != null ? $" [{variable}]" : "";
}

View file

@ -343,9 +343,15 @@ Enforced by `Core/Scripts/FileSafety.cs`, which throws rather than advises.
### 4. Batch layout
```
batches/<task>_<descriptor>/<seed>_<variant>/
batches/<task>_<descriptor>/INDEX.md
batches/<task>_<descriptor>/scratch/ ← persistent; never cleaned
batches/<chat>/<task>_<descriptor>/<seed>_<variant>/
batches/<chat>/<task>_<descriptor>/INDEX.md
batches/<chat>/<task>_<descriptor>/scratch/ ← persistent; never cleaned
<chat> is required (rivers/01): task numbers restart per chat, so a flat batches/
collided across chat1 and chat2. Set by ToolingPaths.ConfigureChat(), overridable
with ISLA_CHAT. Historical anchor READS carry the prefix in their own source string
(e.g. "chat1/02_pass1_port"), because they compose against BatchesRoot, not BatchRoot.
→ Tools/batches/README.md
```
> ### ⚠⚠ THE PREFIX IS THE AUTHORING TASK NUMBER. IT IS NOT A COUNTER.

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE BASIN-GRAPH PLATE (rivers/04) — the taste gate on the foundation. Presentation only; reads
/// the graph and the height, writes pixels. Nothing here touches data.
///
/// What the eye is meant to check, per the task:
/// • is each SPILL (yellow ring, labelled) at the true low rim where water would actually overtop?
/// • which basins hold REAL LAKES (blue tint, the lake cells brighter) vs DRY sinks (amber tint)?
/// • does "who drains to whom" (the arrows: cyan → ocean, white → another basin, red = closed)
/// look like a physically sane network?
///
/// Label at each spill: <c>"23M/7"</c> = spill 23 m above the sea datum, 7 m climb from the basin
/// floor (the number the cap is judged against). Both on the RENDER surface.
/// </summary>
public static class BasinGraphRenderer
{
private static readonly Color LakeTint = new(0.250f, 0.520f, 1.000f);
private static readonly Color LakeWater = new(0.180f, 0.420f, 0.980f);
private static readonly Color DryTint = new(0.980f, 0.660f, 0.250f);
private static readonly Color OutlineL = new(0.100f, 0.250f, 0.650f);
private static readonly Color OutlineD = new(0.600f, 0.330f, 0.060f);
private static readonly Color Spill = new(1.000f, 0.930f, 0.350f);
private static readonly Color EdgeOcean = new(0.250f, 0.900f, 1.000f);
private static readonly Color EdgeBasin = new(1.000f, 1.000f, 1.000f);
private static readonly Color EdgeNone = new(1.000f, 0.250f, 0.250f);
private static readonly Color Floor = new(0.050f, 0.050f, 0.050f);
private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
private static readonly Color Shadow = new(0.000f, 0.000f, 0.000f);
private static readonly Color SeabedOutline = new(0.180f, 0.300f, 0.520f);
public static Image Plate(BasinGraph g, DrainageAnalysis.Plan plan, Image img, int n,
bool[] isOcean, bool[] isClassifyWater, string title, string subtitle, string third)
{
int[] basinId = plan.BasinId;
int total = n * n;
// 1. tint every basin cell by lake/dry; lake cells inside a basin drawn as water.
var lakeOf = new Dictionary<int, bool>();
var seabed = new HashSet<int>();
foreach (var b in g.Nodes) { lakeOf[b.Id] = b.IsLake; if (b.IsSeabed) seabed.Add(b.Id); }
for (int i = 0; i < total; i++)
{
int id = basinId[i];
if (id == 0 || seabed.Contains(id)) continue;
int x = i / n, y = i % n;
bool isLakeBasin = lakeOf.TryGetValue(id, out bool l) && l;
if (isClassifyWater[i] && !isOcean[i])
{
img.SetPixel(x, y, isLakeBasin ? LakeWater : img.GetPixel(x, y).Lerp(LakeWater, 0.55f));
continue;
}
img.SetPixel(x, y, img.GetPixel(x, y).Lerp(isLakeBasin ? LakeTint : DryTint, 0.38f));
}
// 2. outline: a basin cell with a 4-neighbour of a different id.
for (int i = 0; i < total; i++)
{
int id = basinId[i];
if (id == 0) continue;
int x = i / n, y = i % n;
bool edge = (x > 0 && basinId[i - n] != id) || (x < n - 1 && basinId[i + n] != id)
|| (y > 0 && basinId[i - 1] != id) || (y < n - 1 && basinId[i + 1] != id);
if (!edge) continue;
if (seabed.Contains(id)) { img.SetPixel(x, y, SeabedOutline); continue; } // the seam: outline only
bool isLakeBasin = lakeOf.TryGetValue(id, out bool l) && l;
img.SetPixel(x, y, isLakeBasin ? OutlineL : OutlineD);
}
int thin = n >= 4096 ? 3 : 2, ring = n >= 4096 ? 16 : 9, ringW = n >= 4096 ? 4 : 3;
int floorR = n >= 4096 ? 6 : 3, head = n >= 4096 ? 22 : 12;
int scale = n >= 4096 ? 3 : 2;
// 3. the edges — the spill walk, arrowhead at the downstream end.
foreach (var b in g.LandNodes)
{
Color c = b.Downstream switch
{
DownstreamKind.Ocean => EdgeOcean,
DownstreamKind.Basin => EdgeBasin,
_ => EdgeNone,
};
var path = b.SpillPath;
if (path.Count >= 2)
{
for (int i = 1; i < path.Count; i++)
DrainageRenderer.Line(img, path[i - 1] / n, path[i - 1] % n, path[i] / n, path[i] % n, n, c, thin);
Arrowhead(img, path, n, c, head, thin);
}
else
{
// A spill that is itself the terminus (the rim cell is ocean) — or stuck on the spot.
DrainageRenderer.Disc(img, b.SpillCell / n, b.SpillCell % n, ring / 2, n, c);
}
}
// 4. spills, floors, labels.
foreach (var b in g.LandNodes)
{
int sx = b.SpillCell / n, sy = b.SpillCell % n;
DrainageRenderer.Ring(img, sx, sy, ring, n, Spill, ringW);
DrainageRenderer.Disc(img, b.FloorCell / n, b.FloorCell % n, floorR, n, Floor);
string lbl = $"{b.SpillAboveSeaM:F0}M/{b.SpillClimbM:F0}"; // TinyFont has no '+' or '^': "spill m above sea / climb m from floor"
Label(img, lbl, sx + ring + 4, sy - TinyFont.Height(scale) / 2, scale, n);
Label(img, $"#{b.Id}", b.FloorCell / n + floorR + 3, b.FloorCell % n - TinyFont.Height(scale) / 2, scale, n);
}
// 5. the legend.
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
TinyFont.Draw(img, title, 12, 12, s, Ink);
TinyFont.Draw(img, subtitle, 12, 12 + lh, s, Ink);
TinyFont.Draw(img, third, 12, 12 + lh * 2, s, Ink);
TinyFont.Draw(img, "BLUE TINT = LAKE BASIN (SIGNIFICANT CLASSIFY WATER INSIDE) AMBER TINT = DRY SINK BLACK DOT = BASIN FLOOR (#ID)", 12, 12 + lh * 3, s, Ink);
TinyFont.Draw(img, "YELLOW RING = SPILL CELL. LABEL 12M/4 = SPILL 12 M ABOVE SEA / 4 M CLIMB FROM THE BASIN FLOOR TO OVERTOP (RENDER SURFACE)", 12, 12 + lh * 4, s, Ink);
TinyFont.Draw(img, "ARROW = WHERE THE SPILL DRAINS: CYAN TO OCEAN, WHITE INTO ANOTHER BASIN, RED = CLOSED. DATA LAYER ONLY - NOTHING FILLED, NOTHING CARVED", 12, 12 + lh * 5, s, Ink);
TinyFont.Draw(img, "FAINT BLUE OUTLINE, NO MARKS = SEABED PIT (A RENDER DEPRESSION UNDER THE CLASSIFY SEA - THE D-046 SEAM, INERT, EXCLUDED FROM THE GRAPH)", 12, 12 + lh * 6, s, Ink);
return img;
}
private static void Arrowhead(Image img, List<int> path, int n, Color c, int len, int thick)
{
int end = path[^1];
int from = path[Math.Max(0, path.Count - 1 - 24)];
float ex = end / n, ey = end % n, fx = from / n, fy = from % n;
float dx = ex - fx, dy = ey - fy;
float L = MathF.Sqrt(dx * dx + dy * dy);
if (L < 1f) return;
dx /= L; dy /= L;
// two barbs, 30° either side of the reversed direction
const float a = 0.5236f;
float cs = MathF.Cos(a), sn = MathF.Sin(a);
float bx1 = -dx * cs - (-dy) * sn, by1 = -dx * sn + (-dy) * cs;
float bx2 = -dx * cs + (-dy) * sn, by2 = -(-dx) * sn + (-dy) * cs;
DrainageRenderer.Line(img, (int)ex, (int)ey, (int)(ex + bx1 * len), (int)(ey + by1 * len), n, c, thick);
DrainageRenderer.Line(img, (int)ex, (int)ey, (int)(ex + bx2 * len), (int)(ey + by2 * len), n, c, thick);
}
/// <summary>Ink over a one-px black shadow, clamped inside the image so a rim label near the edge is still readable.</summary>
private static void Label(Image img, string text, int x, int y, int scale, int n)
{
int w = TinyFont.Width(text, scale), h = TinyFont.Height(scale);
x = Math.Clamp(x, 0, Math.Max(0, n - w - 1));
y = Math.Clamp(y, 0, Math.Max(0, n - h - 1));
TinyFont.Draw(img, text, x + 1, y + 1, scale, Shadow);
TinyFont.Draw(img, text, x, y, scale, Ink);
}
}
}

View file

@ -0,0 +1,599 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ THE LAKE-BASIN LAYER (rivers/04) — build the basin graph and show it, so the developer can
/// trust the spills before anything is routed on them.
///
/// ═══ WHAT THIS TASK IS FOR ═══
///
/// The flow-through routing model (the next task) needs lakes to be NODES in the drainage graph,
/// not free-floating heightmap classifications the router bumps into. This builds that layer from
/// what `DrainageAnalysis` already computed — the sinks (`BasinId`) and the overflow surface
/// (`FullFilled`) — enriching each terminal basin with its SPILL, its LAKE-IDENTITY and its
/// DOWNSTREAM EDGE. → <see cref="BasinGraph"/>.
///
/// ═══ ⛔ THE RED LINE — A DATA LAYER, NOT A TERRAIN WRITE ═══
///
/// **No height is written. No water is filled or created. `DrainageAnalysis` is reused, not
/// rewritten.** Spills and lake-identity are computed and recorded, never stamped. Both height
/// fields are digested before the layer is built and after the plate is drawn, and the tool REFUSES
/// to continue if either changed — the flood-guard discipline every task since erosion has kept.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/BasinGraphTool.tscn
///
/// ISLA_TASK / ISLA_TASK_SUFFIX / ISLA_BATCH / ISLA_CHAT / ISLA_MAPSIZE / ISLA_CALIB_SIZE / ISLA_SEEDS / ISLA_SKIP_RAW
/// ISLA_LAKE_MIN_PX the significance floor for a basin's in-basin classify water (default 20000 — a KNOB)
/// ISLA_CAP_PREVIEW_M comma list of rim caps to preview connectivity at (default "15,30,60")
/// </summary>
public partial class BasinGraphTool : Node
{
private static readonly int[] DefaultSeeds = { 1063685222, 999999937, 31415926, 14142135 };
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 SeedResult
{
public int Seed;
public BasinGraph Graph;
public int TerminalBasinCount;
public long LandCells, EndorheicCells;
public ulong RenderDigest, ClassifyDigest;
public int WaterBodiesKept, WaterBodiesTotal; public long WaterCellsKept, LargestWaterPx;
public float SpillMinM, SpillP25M, SpillMedM, SpillP75M, SpillMaxM;
public float ClimbMinM, ClimbP25M, ClimbMedM, ClimbP75M, ClimbMaxM;
public int[] ClimbBands; // <=5, 5-15, 15-30, 30-60, >60
public Dictionary<float, (int all, int lake, int dry, int direct)> Cap = new();
public int LakeAnyOnly; // HasAnyLake && !IsLake — where this layer's label differs from the routing sort
public int MaxHops;
public float GraphSeconds, RenderSeconds; public ulong Ms;
public float GMin, GMax;
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers"));
int task = EnvInt("ISLA_TASK", 4);
string taskSfx = EnvStr("ISLA_TASK_SUFFIX", "");
string descr = EnvStr("ISLA_BATCH", "lake_basin_layer");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
int lakeMinPx = EnvInt("ISLA_LAKE_MIN_PX", RiverRouting.LakeMinTargetPx);
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "1") == "1";
float[] caps = EnvFloats("ISLA_CAP_PREVIEW_M", new[] { 15f, 30f, 60f });
TerrainShapeV1.Assert("BasinGraph");
TerrainShapeV1.AssertErosionDefaultOn("BasinGraph");
string batchRoot = ToolingPaths.BatchRoot(task, taskSfx, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
// ⚠ The ENUMERATION gates (EndorheicMinDepthM / EndorheicMinAreaPx) are the defaults — they
// define which depressions ARE terminal basins, i.e. the nodes of this graph. Reporting caps
// are irrelevant here: the layer reads BasinId / FullFilled / Dir, not the promoted lists.
var dp = new DrainageAnalysis.Params { SeaLevel = sea };
GD.Print("==================================================================");
GD.Print(" THE LAKE-BASIN LAYER (rivers/04) — the basin graph: spill + lake-identity + downstream edge, per terminal basin");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default");
GD.Print($"seeds : {seeds.Length} — {string.Join(", ", seeds)}");
GD.Print($"spill : {BasinGraph.SpillDatum}");
GD.Print($"lake : {BasinGraph.LakeDatum} floor = {lakeMinPx:N0} px (ISLA_LAKE_MIN_PX, a knob)");
GD.Print($"edge : {BasinGraph.DownstreamMethod}");
GD.Print($"D-046 : spill geometry/height on RENDER (the flow surface, as RouteTo routes); lake presence + OCEAN on CLASSIFY (the water surface, as the terminus tests). No cross-surface comparison anywhere.");
GD.Print($"cap view : unbroken spill-chain to the ocean previewed at {string.Join(" / ", Array.ConvertAll(caps, c => c.ToString("F0")))} m (every link's floor→spill climb ≤ cap; clamped at sea as RouteTo clamps)");
GD.Print($"⛔ RED LINE : DATA LAYER ONLY — no height mutated, no water filled, DrainageAnalysis untouched. ASSERTED per seed around build + render.");
GD.Print($"batch : {batchRoot}");
GD.Print("==================================================================");
if (mapSize != 8192)
GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the terminal-basin gates are ABSOLUTE PIXEL COUNTS tuned at 8192; a smaller " +
"map under-produces terminal basins. This run checks the PLUMBING (spill extraction, graph, render), not the character.");
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, family-off pinned) ---");
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
TerrainGenConfig Cfg(int size, int seed) => new TerrainGenConfig
{
MapSize = size, Seed = seed, VariantLabel = "basins",
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
};
var results = new List<SeedResult>();
foreach (int seed in seeds)
{
ulong t0 = Time.GetTicksMsec();
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;
// ⭐ THE OCEAN IDENTITY and the water surface — CLASSIFY (→ D-066 / D-046).
bool[] isOcean = RegionLabeling.OceanMask(p2.HeightClassify, mapSize, sea, out long oceanCells, out long enclosed);
var isClassifyWater = new bool[mapSize * mapSize];
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;
// ⭐ THE FLOW SURFACE — the analysis on the eroded RENDER height, exactly as every task since chat2/12.
var plan = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp);
GD.Print($" 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} %); terminal basins {plan.TerminalBasinCount}; pits filled through {plan.PitsFilledCount:N0}");
bool[] significant = RegionLabeling.SignificantWaterMask(isClassifyWater, isOcean, mapSize,
lakeMinPx, out int keptBodies, out int totalBodies, out long keptCells, out long largestPx);
GD.Print($" significant water: {keptBodies} of {totalBodies} classify-water bodies >= {lakeMinPx:N0} px ({keptCells:N0} cells; largest {largestPx:N0} px)");
// ═══ ⛔ THE RED-LINE GUARD — both fields digested BEFORE the layer ═══
ulong hRenderBefore = Digest(p2.Height, mapSize);
ulong hClassifyBefore = Digest(p2.HeightClassify, mapSize);
ulong tg0 = Time.GetTicksMsec();
var g = BasinGraph.Build(plan, p2.Height, mapSize, isOcean, isClassifyWater, significant, sea, lakeMinPx);
float graphSec = (Time.GetTicksMsec() - tg0) / 1000f;
if (g.Nodes.Count != plan.TerminalBasinCount)
throw new InvalidOperationException($"[BasinGraph] node count {g.Nodes.Count} != Plan.TerminalBasinCount {plan.TerminalBasinCount} — the layer did not enumerate the analysis's basins exactly.");
var r = new SeedResult
{
Seed = seed, Graph = g, TerminalBasinCount = plan.TerminalBasinCount,
LandCells = plan.LandCells, EndorheicCells = plan.EndorheicCells,
WaterBodiesKept = keptBodies, WaterBodiesTotal = totalBodies, WaterCellsKept = keptCells, LargestWaterPx = largestPx,
GraphSeconds = graphSec,
};
Summarise(r, caps);
GD.Print($" ⚠ SEAM: {g.Seabed} of {g.Nodes.Count} terminal basins are SEABED pits (every cell ocean on classify, inflow 0) — excluded from the graph statistics below; {g.Coastal} straddle the shoreline (kept as land).");
GD.Print($" ⭐ GRAPH (land): {g.LandNodes.Count} basins — {g.LakeBasins} LAKE / {g.DryBasins} DRY (floor {lakeMinPx:N0} px; {r.LakeAnyOnly} hold only a sub-floor puddle)");
GD.Print($" spills → ocean {g.ToOcean} / → another basin {g.ToBasin} / closed {g.Closed}; longest chain {r.MaxHops} hops");
GD.Print($" spill height above sea (m): min {r.SpillMinM:F1} p25 {r.SpillP25M:F1} median {r.SpillMedM:F1} p75 {r.SpillP75M:F1} max {r.SpillMaxM:F1}");
GD.Print($" floor→spill climb (m): min {r.ClimbMinM:F1} p25 {r.ClimbP25M:F1} median {r.ClimbMedM:F1} p75 {r.ClimbP75M:F1} max {r.ClimbMaxM:F1} bands ≤5/515/1530/3060/>60: {string.Join("/", r.ClimbBands)}");
foreach (float cap in caps)
{
var c = r.Cap[cap];
GD.Print($" cap {cap,3:F0} m: {c.all,3} of {g.LandNodes.Count} land basins chain to the ocean ({c.lake} lake / {c.dry} dry; {c.direct} of them directly)");
}
GD.Print($" ✅ invariants: spill cross-check (fill-level vs rim-walk) failures {g.SpillCrossCheckFailures}; spill-not-on-terrain {g.SpillNotOnTerrain}; " +
$"Dir-walk disagreements {g.DirWalkDisagreements}; seabed {g.Seabed} / coastal {g.Coastal}");
if (g.SpillCrossCheckFailures > 0 || g.SpillNotOnTerrain > 0)
GD.PrintErr(" ⚠⚠ A SPILL INVARIANT FAILED — the spill datum is not exact on this seed. Reported, not hidden; see the CSV.");
if (g.DirWalkDisagreements > 0)
GD.PrintErr(" ⚠ The FullFilled walk and the Dir walk disagree on some basin's downstream — listed in the CSV (dir_walk_agrees).");
GD.Print($" ⭐ RECONCILIATION: {g.LakeBodies.Count} significant classify bodies — {g.LakeBodiesOwned} owned by a basin (≥ half inside one), " +
$"{g.LakeBodiesSplit} split across basins, {g.LakeBodiesFree} FREE (in no terminal basin at all); " +
$"{g.ClassifyWaterCellsOutsideBasins:N0} of {g.ClassifyWaterCellsTotal:N0} non-ocean classify-water cells lie outside every basin");
GD.Print($" graph built in {graphSec:F2}s");
WriteBasinCsv(batchRoot, r, caps);
WriteLakeCsv(batchRoot, r);
ulong tr0 = Time.GetTicksMsec();
RenderSeed(batchRoot, r, plan, isOcean, isClassifyWater, p2, mapSize, sea, skipRaw);
r.RenderSeconds = (Time.GetTicksMsec() - tr0) / 1000f;
// ═══ ⛔ …and asserted byte-identical AFTER build + render ═══
ulong hRenderAfter = Digest(p2.Height, mapSize);
ulong hClassifyAfter = Digest(p2.HeightClassify, mapSize);
if (hRenderAfter != hRenderBefore || hClassifyAfter != hClassifyBefore)
throw new InvalidOperationException(
"[BasinGraph] RED-LINE VIOLATION: a height field CHANGED across the layer build / render.\n" +
$" render {hRenderBefore:X16} -> {hRenderAfter:X16}\n" +
$" classify {hClassifyBefore:X16} -> {hClassifyAfter:X16}\n" +
"This task builds a DATA layer — it must never mutate a height or fill water. Refusing to continue.");
r.RenderDigest = hRenderBefore; r.ClassifyDigest = hClassifyBefore;
GD.Print($" ✅ RED LINE HELD: render {hRenderBefore:X16} and classify {hClassifyBefore:X16} byte-identical across build + render — no height mutated, no water filled.");
r.Ms = Time.GetTicksMsec() - t0;
results.Add(r);
}
WriteIndex(batchRoot, mapSize, seeds, results, lakeMinPx, caps, dp, skipRaw);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print(" ⛔ TASTE GATE: the graph is PRESENTED, not routed on. Nothing locked, nothing routed, nothing graduated.");
GD.Print(" ⛔ DATA LAYER ONLY: no height mutated, no water filled — asserted per seed.");
GD.Print("==================================================================");
GetTree().Quit(0);
}
private static void Summarise(SeedResult r, float[] caps)
{
var g = r.Graph;
var spill = new List<float>(); var climb = new List<float>();
r.ClimbBands = new int[5];
foreach (var b in g.LandNodes)
{
spill.Add(b.SpillAboveSeaM); climb.Add(b.SpillClimbM);
r.ClimbBands[b.SpillClimbM <= 5f ? 0 : b.SpillClimbM <= 15f ? 1 : b.SpillClimbM <= 30f ? 2 : b.SpillClimbM <= 60f ? 3 : 4]++;
if (b.HasAnyLake && !b.IsLake) r.LakeAnyOnly++;
int hops = g.HopsToOcean(b.Id);
if (hops > r.MaxHops) r.MaxHops = hops;
}
spill.Sort(); climb.Sort();
(r.SpillMinM, r.SpillP25M, r.SpillMedM, r.SpillP75M, r.SpillMaxM) = Quantiles(spill);
(r.ClimbMinM, r.ClimbP25M, r.ClimbMedM, r.ClimbP75M, r.ClimbMaxM) = Quantiles(climb);
foreach (float cap in caps)
{
bool[] ok = g.ConnectedAtCap(cap, out int connected);
int lake = 0, dry = 0, direct = 0;
connected = 0;
for (int i = 0; i < g.Nodes.Count; i++)
{
if (!ok[i] || !g.Nodes[i].IsLand) continue; // land basins only — a seabed pit "chains" trivially
connected++;
if (g.Nodes[i].IsLake) lake++; else dry++;
if (g.Nodes[i].Downstream == DownstreamKind.Ocean) direct++;
}
r.Cap[cap] = (connected, lake, dry, direct);
}
}
private static (float, float, float, float, float) Quantiles(List<float> sorted)
{
if (sorted.Count == 0) return (0, 0, 0, 0, 0);
float Q(double q) => sorted[Math.Clamp((int)Math.Round(q * (sorted.Count - 1)), 0, sorted.Count - 1)];
return (sorted[0], Q(0.25), Q(0.5), Q(0.75), sorted[^1]);
}
/// <summary>FNV-1a over the raw float bits — "byte-identical", not "numerically close".</summary>
private static ulong Digest(float[,] f, int n)
{
ulong h = 14695981039346656037UL;
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
uint bits = (uint)BitConverter.SingleToInt32Bits(f[x, y]);
for (int b = 0; b < 4; b++)
{
h ^= (byte)(bits >> (b * 8));
h *= 1099511628211UL;
}
}
return h;
}
private static string Kind(DownstreamKind k) => k switch
{
DownstreamKind.Ocean => "OCEAN",
DownstreamKind.Basin => "BASIN",
_ => "NONE",
};
private static void WriteBasinCsv(string batchRoot, SeedResult r, float[] caps)
{
var g = r.Graph; int n = g.MapSize;
var capOk = new Dictionary<float, bool[]>();
foreach (float cap in caps) capOk[cap] = g.ConnectedAtCap(cap, out _);
var sb = new StringBuilder();
sb.Append("id,class,area_px,inflow_px,is_lake,has_any_lake,lake_cells,lake_cells_significant,ocean_cells," +
"floor_x,floor_y,floor_raw,spill_x,spill_y,spill_raw,spill_above_sea_m,depth_to_spill_m,spill_climb_m,spill_ties," +
"spill_crosscheck_ok,spill_on_terrain,downstream,downstream_id,downstream_x,downstream_y,spill_path_cells,dir_walk_agrees,dir_walk_kind,dir_walk_id,hops_to_ocean");
foreach (float cap in caps) sb.Append($",chain_ok_cap{cap:F0}");
sb.AppendLine();
for (int i = 0; i < g.Nodes.Count; i++)
{
var b = g.Nodes[i];
sb.Append($"{b.Id},{(b.IsSeabed ? "seabed" : b.IsCoastal ? "coastal" : "inland")},{b.AreaPx},{b.InflowPx},{(b.IsLake ? "yes" : "no")},{(b.HasAnyLake ? "yes" : "no")},{b.LakeCells},{b.LakeCellsSignificant},{b.OceanCells}," +
$"{b.FloorCell / n},{b.FloorCell % n},{b.FloorHeightRaw:R},{b.SpillCell / n},{b.SpillCell % n},{b.SpillHeightRaw:R}," +
$"{b.SpillAboveSeaM:F2},{b.DepthToSpillM:F2},{b.SpillClimbM:F2},{b.SpillTies}," +
$"{(b.SpillCrossCheckOk ? "yes" : "NO")},{(b.SpillOnTerrain ? "yes" : "NO")},{Kind(b.Downstream)},{b.DownstreamId}," +
$"{b.DownstreamEntryCell / n},{b.DownstreamEntryCell % n},{b.SpillPath.Count},{(b.DirWalkAgrees ? "yes" : "NO")},{Kind(b.DirWalkKind)},{b.DirWalkId},{g.HopsToOcean(b.Id)}");
foreach (float cap in caps) sb.Append($",{(capOk[cap][i] ? "yes" : "no")}");
sb.AppendLine();
}
WriteText(Path.Combine(batchRoot, $"basins_{r.Seed}.csv"), sb.ToString());
}
private static void WriteLakeCsv(string batchRoot, SeedResult r)
{
var g = r.Graph;
var sb = new StringBuilder();
sb.AppendLine("body,size_px,cells_in_basins,basins_touched,dominant_basin_id,dominant_cells,dominant_basin_is_lake,status");
foreach (var l in g.LakeBodies)
{
var b = l.DominantBasinId != 0 ? g.Of(l.DominantBasinId) : null;
sb.AppendLine($"{l.Index},{l.SizePx},{l.CellsInBasins},{l.BasinsTouched},{l.DominantBasinId},{l.DominantCells}," +
$"{(b == null ? "" : b.IsLake ? "yes" : "no")},{(l.Free ? "FREE" : l.Owned ? "owned" : "split")}");
}
WriteText(Path.Combine(batchRoot, $"lakes_{r.Seed}.csv"), sb.ToString());
}
private static void RenderSeed(string batchRoot, SeedResult r, DrainageAnalysis.Plan plan, bool[] isOcean,
bool[] isClassifyWater, Pass2Result p2, int n, float sea, bool skipRaw)
{
string dir = Path.Combine(batchRoot, $"{r.Seed}");
DirAccess.MakeDirRecursiveAbsolute(dir);
var g = r.Graph;
Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
string caps = "";
foreach (var kv in r.Cap) caps += $"{kv.Key:F0}M:{kv.Value.all} ";
BasinGraphRenderer.Plate(g, plan, baseImg, n, isOcean, isClassifyWater,
$"SEED {r.Seed} - THE BASIN GRAPH: {g.LandNodes.Count} LAND BASINS, {g.LakeBasins} LAKE / {g.DryBasins} DRY (FLOOR {g.LakeMinPx} PX) [+{g.Seabed} SEABED PITS, OUTLINED ONLY]",
$"SPILLS: {g.ToOcean} TO OCEAN / {g.ToBasin} INTO ANOTHER BASIN / {g.Closed} CLOSED. LONGEST CHAIN {r.MaxHops} HOPS. BASINS CHAINING TO THE OCEAN AT CAP {caps.Trim()}",
$"SPILL HEIGHT ABOVE SEA: MEDIAN {r.SpillMedM:F0} M (RANGE {r.SpillMinM:F0}..{r.SpillMaxM:F0}). FLOOR-TO-SPILL CLIMB: MEDIAN {r.ClimbMedM:F0} M (RANGE {r.ClimbMinM:F0}..{r.ClimbMaxM:F0}). TASTE GATE - NOTHING ROUTED, NOTHING LOCKED")
.SavePng(Path.Combine(dir, $"basin_graph_{r.Seed}.png"));
var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
r.GMin = gmin; r.GMax = gmax;
GD.Print($" grayscale: render field range {gmin:F4} .. {gmax:F4} raw = {WorldScale.MetresFromRaw(gmin):F1} .. {WorldScale.MetresFromRaw(gmax):F1} m");
if (!skipRaw) HeightField.Save(p2.Height, n, Path.Combine(dir, "height.f32"));
}
private static void WriteIndex(string batchRoot, int mapSize, int[] seeds, List<SeedResult> rows,
int lakeMinPx, float[] caps, DrainageAnalysis.Params def, bool skipRaw)
{
var sb = new StringBuilder();
int primary = seeds.Length > 0 ? seeds[0] : 0;
string capHdr = string.Join(" | ", Array.ConvertAll(caps, c => $"chain→ocean @ {c:F0} m"));
sb.AppendLine("# Batch 04 — the lake-basin layer: the basin graph (spill + lake-identity + downstream edge)");
sb.AppendLine();
sb.AppendLine("**⛔ TASTE GATE ON THE FOUNDATION. Nothing is routed, nothing is locked, nothing is graduated.** This is the");
sb.AppendLine("data layer the flow-through routing model (piece 2) will traverse; piece 2 is authored only once the spills");
sb.AppendLine("and the graph read right.");
sb.AppendLine();
sb.AppendLine("**⛔ DATA LAYER ONLY. No height was mutated, no water was filled or created, `DrainageAnalysis` was not");
sb.AppendLine("edited** — asserted per seed by an FNV digest of both height fields taken before the layer was built and");
sb.AppendLine("after the plate was drawn.");
sb.AppendLine();
sb.AppendLine("## 👉 The pick");
sb.AppendLine();
sb.AppendLine($"Open **`{primary}/basin_graph_{primary}.png`**. Then the other three: " +
string.Join(", ", Array.ConvertAll(Array.FindAll(seeds, x => x != primary), x => $"`{x}`")) + ".");
sb.AppendLine();
sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED");
sb.AppendLine("> **Do the spills (yellow rings) sit where water would actually overflow — the true low rim? Are the");
sb.AppendLine("> lake/dry labels right (blue = a significant classify lake sits in the basin, amber = dry sink)? And does");
sb.AppendLine("> \"who drains to whom\" (cyan arrow → ocean, white arrow → another basin, red = closed) look like a real");
sb.AppendLine("> drainage network?** If the spills are wrong, piece 2 must not be built on this.");
sb.AppendLine(">");
sb.AppendLine("> Each spill is labelled `23M/7`: **23 m above the sea datum**, and **7 m of climb from the basin floor**");
sb.AppendLine("> to overtop — the second number is what a rim cap is judged against. Both read on the RENDER surface.");
sb.AppendLine("> The black dot is the basin floor, with its `#id` (the id `BasinId` carries — sparse, as the analysis leaves it).");
sb.AppendLine();
sb.AppendLine("## ⭐ The graph, per seed");
sb.AppendLine();
sb.AppendLine($"| Seed | terminal basins | ⚠ seabed (excluded) | coastal | **land basins** | **lake** | dry | (sub-floor puddle only) | spill → ocean | → basin | closed | longest chain | {capHdr} |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|" + string.Concat(Array.ConvertAll(caps, _ => "---|")));
foreach (var r in rows)
{
var g = r.Graph;
sb.Append($"| `{r.Seed}` | {g.Nodes.Count} | {g.Seabed} | {g.Coastal} | **{g.LandNodes.Count}** | **{g.LakeBasins}** | {g.DryBasins} | {r.LakeAnyOnly} | {g.ToOcean} | {g.ToBasin} | {g.Closed} | {r.MaxHops} hops |");
foreach (float cap in caps) { var c = r.Cap[cap]; sb.Append($" **{c.all}** ({c.lake} lake / {c.dry} dry) |"); }
sb.AppendLine();
}
sb.AppendLine();
sb.AppendLine("> ### ⚠⚠ SEABED PITS — half the analysis's \"terminal basins\" are not on land");
sb.AppendLine("> The priority-flood runs on the whole RENDER surface, ocean floor included, so a deep-enough, large-enough");
sb.AppendLine("> depression UNDER the classify sea qualifies as a terminal basin exactly like a land one. Every cell of such");
sb.AppendLine("> a basin is `OceanMask`, its cells are `D_NONE`, its inflow is 0 — it is hydrologically inert, and it is the");
sb.AppendLine("> D-046 seam (render vs classify) made visible. They are kept in the layer and the CSV (`class = seabed`),");
sb.AppendLine("> drawn as a faint outline only, and **excluded from every statistic on this page.** The `land basins` column is");
sb.AppendLine("> the graph; `coastal` basins (some ocean cells, some land) are counted as land and flagged.");
sb.AppendLine();
sb.AppendLine("> **Reading the cap columns.** A basin \"chains to the ocean at cap C\" when every link from it to the sea — its");
sb.AppendLine("> own spill and every downstream basin's spill — climbs ≤ C m from that basin's floor (elevation clamped at sea,");
sb.AppendLine("> exactly as `RouteTo` clamps). This previews what the flow-through model will trade at a given cap; it decides");
sb.AppendLine("> nothing. Piece 2 routes; this only says how many basins *could* connect.");
sb.AppendLine();
sb.AppendLine("## ⭐ Spill-height distributions (metres, render surface)");
sb.AppendLine();
sb.AppendLine("| Seed | spill above sea: min / p25 / median / p75 / max | floor→spill climb: min / p25 / median / p75 / max | climb bands ≤5 / 515 / 1530 / 3060 / >60 |");
sb.AppendLine("|---|---|---|---|");
foreach (var r in rows)
sb.AppendLine($"| `{r.Seed}` | {r.SpillMinM:F1} / {r.SpillP25M:F1} / {r.SpillMedM:F1} / {r.SpillP75M:F1} / {r.SpillMaxM:F1} | " +
$"{r.ClimbMinM:F1} / {r.ClimbP25M:F1} / {r.ClimbMedM:F1} / {r.ClimbP75M:F1} / {r.ClimbMaxM:F1} | {string.Join(" / ", r.ClimbBands)} |");
sb.AppendLine();
sb.AppendLine("## ✅ The invariants, per seed — the spill datum is exact, or it says so");
sb.AppendLine();
sb.AppendLine("| Seed | spill cross-check failures (fill-level vs rim-walk) | spill not on terrain | Dir-walk disagreements | seabed / coastal | render digest | classify digest |");
sb.AppendLine("|---|---|---|---|---|---|---|");
foreach (var r in rows)
{
var g = r.Graph;
sb.AppendLine($"| `{r.Seed}` | {(g.SpillCrossCheckFailures == 0 ? "**0** " : $"**{g.SpillCrossCheckFailures}** ")} | " +
$"{(g.SpillNotOnTerrain == 0 ? "**0** " : $"**{g.SpillNotOnTerrain}** ")} | " +
$"{(g.DirWalkDisagreements == 0 ? "**0** " : $"**{g.DirWalkDisagreements}** ")} | {g.Seabed} / {g.Coastal} | `{r.RenderDigest:X16}` | `{r.ClassifyDigest:X16}` |");
}
sb.AppendLine();
sb.AppendLine("*Cross-check: the basin's minimum on `FullFilled` is the spill + one ulp (the flood's own epsilon), so");
sb.AppendLine("`BitDecrement(min inside) == min over the rim` must hold exactly. \"On terrain\": `FullFilled == render` at the spill");
sb.AppendLine("cell — the rim was never raised by the flood. \"Dir-walk\": following `Plan.Dir` from the first cell past the spill");
sb.AppendLine("reaches the same node as the `FullFilled` descent. A basin holding OCEAN cells is a render depression under");
sb.AppendLine("classify-sea — the D-046 seam, counted rather than hidden.*");
sb.AppendLine();
sb.AppendLine("## ⭐ The reconciliation — does each significant heightmap lake sit in a terminal basin?");
sb.AppendLine();
sb.AppendLine("| Seed | significant bodies (≥ floor) | **owned** (≥ half inside one basin) | split across basins | **FREE** (in no basin) | non-ocean classify-water cells outside every basin |");
sb.AppendLine("|---|---|---|---|---|---|");
foreach (var r in rows)
{
var g = r.Graph;
sb.AppendLine($"| `{r.Seed}` | {g.LakeBodies.Count} | **{g.LakeBodiesOwned}** | {g.LakeBodiesSplit} | **{g.LakeBodiesFree}** | {g.ClassifyWaterCellsOutsideBasins:N0} of {g.ClassifyWaterCellsTotal:N0} ({100.0 * g.ClassifyWaterCellsOutsideBasins / Math.Max(1, g.ClassifyWaterCellsTotal):F1} %) |");
}
sb.AppendLine();
sb.AppendLine("*A FREE body is a classify lake that is not a depression ≥ 2 m / 10,000 px on the RENDER surface (or filled through as a");
sb.AppendLine("pit) — heightmap water the hydrology never pooled into. On the plate these are the dark-teal patches with no tint. They are");
sb.AppendLine("exactly the \"free-floating heightmap lakes\" this layer exists to reconcile; per body detail in `lakes_<seed>.csv`.*");
sb.AppendLine();
foreach (var r in rows)
{
var g = r.Graph; int n = g.MapSize;
sb.AppendLine($"### `{r.Seed}` — every LAND basin, largest first ({g.Seabed} seabed pits omitted; see the CSV)");
sb.AppendLine();
sb.Append("| id | area px | inflow px | lake? | lake cells | spill (x,y) | spill +m | climb m | → | hops |");
foreach (float cap in caps) sb.Append($" @{cap:F0} |");
sb.AppendLine();
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|" + string.Concat(Array.ConvertAll(caps, _ => "---|")));
var order = new List<BasinNode>(g.LandNodes);
order.Sort((a, b) => b.AreaPx.CompareTo(a.AreaPx));
var capOk = new Dictionary<float, bool[]>();
foreach (float cap in caps) capOk[cap] = g.ConnectedAtCap(cap, out _);
foreach (var b in order)
{
int idx = g.Nodes.IndexOf(b);
string to = b.Downstream switch
{
DownstreamKind.Ocean => "**OCEAN**",
DownstreamKind.Basin => $"#{b.DownstreamId}",
_ => "⚠ closed",
};
int hops = g.HopsToOcean(b.Id);
sb.Append($"| #{b.Id}{(b.IsCoastal ? " coastal" : "")} | {b.AreaPx:N0} | {b.InflowPx:N0} | {(b.IsLake ? "**lake**" : b.HasAnyLake ? "puddle" : "dry")} | {b.LakeCells:N0} | " +
$"({b.SpillCell / n},{b.SpillCell % n}) | {b.SpillAboveSeaM:F1} | {b.SpillClimbM:F1} | {to} | {(hops < 0 ? "" : hops.ToString())} |");
foreach (float cap in caps) sb.Append(capOk[cap][idx] ? " ✅ |" : " — |");
sb.AppendLine();
}
sb.AppendLine();
sb.AppendLine($"*Land {r.LandCells:N0}, endorheic {r.EndorheicCells:N0} ({100.0 * r.EndorheicCells / Math.Max(1, r.LandCells):F1} %) · " +
$"significant water {r.WaterBodiesKept} of {r.WaterBodiesTotal} bodies ≥ {lakeMinPx:N0} px ({r.WaterCellsKept:N0} cells, largest {r.LargestWaterPx:N0} px) · " +
$"graph {r.GraphSeconds:F2}s, plate {r.RenderSeconds:F1}s, seed total {r.Ms / 1000.0:F0}s · grayscale range {r.GMin:F4}..{r.GMax:F4} raw = {WorldScale.MetresFromRaw(r.GMin):F1}..{WorldScale.MetresFromRaw(r.GMax):F1} m.*");
sb.AppendLine();
}
sb.AppendLine("## The two \"lakes\" this layer reconciles — and the datum each is read on (D-046)");
sb.AppendLine();
sb.AppendLine("| Quantity | Surface | Why |");
sb.AppendLine("|---|---|---|");
sb.AppendLine("| spill cell, spill height, floor, climb | **RENDER** (`Plan.FullFilled`, the flood of the eroded render height) | where water GOES — the surface `RouteTo` routes on, so a climb here is the same number as the router's `RimClimbM` |");
sb.AppendLine("| downstream walk | **RENDER** (`FullFilled` descent) | the reference's provisional-route machinery, started at the spill |");
sb.AppendLine("| lake presence (`IsLake`, lake cells) | **CLASSIFY** (`classify < sea` and not `OceanMask`) | what is VISIBLY water — the surface the terminus tests use |");
sb.AppendLine("| the OCEAN terminus of a walk | **CLASSIFY** (`OceanMask`) | as routing: route on render, ocean on classify |");
sb.AppendLine();
sb.AppendLine("**No field compares a classify height to a render height.** The surfaces meet only as membership tests");
sb.AppendLine("(is this basin cell classify-water? is this walk cell ocean?) — the split the routing already lives by. No new seam.");
sb.AppendLine();
sb.AppendLine("## What was run");
sb.AppendLine();
sb.AppendLine($"Chain + drainage analysis + the layer at **{mapSize}** on **{seeds.Length} seeds** (`{string.Join(", ", seeds)}`), all rendered.");
sb.AppendLine($"`ISLA_LAKE_MIN_PX={lakeMinPx:N0}` (the significance floor — a knob); cap preview at {string.Join(" / ", Array.ConvertAll(caps, c => c.ToString("F0")))} m.");
sb.AppendLine();
sb.AppendLine($"**⚠ NOT touched:** `DrainageAnalysis` (reused — the layer reads `BasinId`, `FullFilled`, `Dir`, `BasinInflow`); " +
$"`EndorheicMinDepthM` {def.EndorheicMinDepthM} m / `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} (they define which depressions ARE the nodes).");
sb.AppendLine();
sb.AppendLine("## Files");
sb.AppendLine();
sb.AppendLine("| File | What it is |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `<seed>/basin_graph_<seed>.png` | the graph over the faint terrain: basins tinted lake/dry, spill rings labelled, arrows to the downstream node |");
sb.AppendLine("| `<seed>/grayscale.png` | the eroded render field, no palette |");
sb.AppendLine("| `basins_<seed>.csv` | every `BasinNode`: class (inland/coastal/seabed), area, inflow, lake cells, floor, spill (cell + raw + metres), climb, downstream kind/id/entry, invariants, hops, chain-ok per cap |");
sb.AppendLine("| `lakes_<seed>.csv` | every significant classify body: size, cells inside basins, dominant basin, owned / split / FREE |");
if (skipRaw)
sb.AppendLine("| ~~`<seed>/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`. |");
sb.AppendLine();
sb.AppendLine($"Ranges: sea level `{def.SeaLevel}` raw = `{WorldScale.MetresFromRaw(def.SeaLevel):F2} m`; {WorldScale.Describe()}.");
sb.AppendLine();
sb.AppendLine("→ `XX_Human/output/rivers/04_lake_basin_layer.report.md`");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
// ---- the curve (the house pattern; pool pinned family-off per rivers/01) -------------------
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(TerrainGenConfig.CalibrationPool(calibSize, 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",
}.WithFamilyOff();
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]); }
return (knots, ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f));
}
// ---- env / io -----------------------------------------------------------------------------
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;
}
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;
}
}
}

View file

@ -38,7 +38,6 @@ namespace IslaApocalypse.Tools
/// 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
{
@ -86,6 +85,10 @@ namespace IslaApocalypse.Tools
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
int task = EnvInt("ISLA_TASK", 9);
string descr = EnvStr("ISLA_BATCH", "coastal_fragment");
@ -101,9 +104,7 @@ namespace IslaApocalypse.Tools
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 p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
@ -132,16 +133,26 @@ namespace IslaApocalypse.Tools
TerrainGenConfig Cfg(int size, int seed, string label, float amp, float fq, float st, bool revert)
{
return new TerrainGenConfig
// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. This tool is chat-2 shaping DEVELOPMENT:
// it was authored and judged before the shape family existed, and its regression checks
// hold pass 1 against the FAMILY-OFF `02_pass1_port` dump. The re-baseline flipped the
// bare defaults family-ON, so without this pin every config here would silently acquire
// stretch + fragmentation and every anchor check would fail for a configuration reason.
// → TerrainGenConfig.WithFamilyOff().
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 = revert, MinLandComponentFrac = speckFrac,
SouthStretch = st,
FragmentAmp = amp, FragmentFreqPerMapWidth = fq, FragmentBitesOnly = bitesOnly,
};
RegionLabeling = true,
}.WithFamilyOff();
// …then this tool's swept axes, AFTER the pin. (These were already explicit before
// rivers/01; the pin makes the tool's independence from the defaults total rather than
// field-by-field, so a future default can never leak in through a field nobody listed.)
c.SpeckRevert = revert; c.MinLandComponentFrac = speckFrac;
c.SouthStretch = st;
c.FragmentAmp = amp; c.FragmentFreqPerMapWidth = fq; c.FragmentBitesOnly = bitesOnly;
return c;
}
// ═══ PROBE ═══
@ -186,21 +197,17 @@ namespace IslaApocalypse.Tools
var offCfg = Cfg(calibSize, seeds[0], "off", 0f, freq, 0f, false);
Pass1Result p1 = Topography.Generate(offCfg);
var curveOff = offCfg.Clone(); curveOff.Curve = false;
// ⭐ a1 KEPT at rivers/01 — the family-off pass-1 guard (config pinned family-off). ⚠ loud.
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));
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, stretch OFF, frag OFF == Phase-1 .f32 dump (the curve is untouched)",
Shaping.Shape(p1, curveOff).Height, ShapingOracle.LoadAnchor("a1", "ISLA_PHASE1_SOURCE", 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}")}");
}
// ⚑ RETIRED at rivers/01 — a8, the frag-OFF baseline == `08_southern_stretch_explore`.
// An EXPLORATION ladder, and off-shape: the 08 batch's dump is at stretch 3, the locked
// shape is stretch 2. Nothing should be pinned to a rung of a ladder that was climbed to
// find a value, then superseded by the value it found.
// The dump is NOT deleted (file-safety; regenerable, and the record of what was judged);
// its `INDEX.md` is marked superseded. → XX_Human/output/rivers/01_*.report.md §A4.
foreach (var c in hard) GD.Print(" " + c);
}
@ -311,7 +318,7 @@ namespace IslaApocalypse.Tools
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
@ -324,11 +331,14 @@ namespace IslaApocalypse.Tools
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
// ⭐ rivers/01: family-off PINNED, like the pool it shapes. (The family acts in pass 1 and
// `Shaping.Shape` never reads it, so this is inert today — pinned anyway so "the whole
// calibration is family-off" is a total claim rather than a field-by-field one.)
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
};
}.WithFamilyOff();
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);

View file

@ -47,7 +47,7 @@ namespace IslaApocalypse.Tools
/// ISLA_SHOWPIECE_SIZE larger confirmation profile (default 8192)
/// ISLA_SHOWPIECE "0" to skip the big render
/// ISLA_VARIANTS "0" to skip the per-seed variants (calibration-only probe)
/// ISLA_PHASE1_SOURCE batch holding Phase-1 .f32 (default "02_pass1_port")
/// ISLA_PHASE1_SOURCE batch holding Phase-1 .f32 (default "chat1/02_pass1_port")
/// ISLA_SKIP_RAW "1" to skip the .f32 dumps
/// </summary>
public partial class CurveBaselineTool : Node
@ -86,6 +86,10 @@ namespace IslaApocalypse.Tools
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
int task = EnvInt("ISLA_TASK", 1);
string descr = EnvStr("ISLA_BATCH", "curve_baseline");
@ -94,7 +98,7 @@ namespace IslaApocalypse.Tools
int showSize = EnvInt("ISLA_SHOWPIECE_SIZE", DefaultShowpieceSize);
bool showpiece = EnvStr("ISLA_SHOWPIECE", "1") == "1";
bool variants = EnvStr("ISLA_VARIANTS", "1") == "1";
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
// ⚠ Composed by BatchRoot, never free-form — it refuses a descriptor carrying its own
@ -131,7 +135,7 @@ namespace IslaApocalypse.Tools
foreach (int seed in seeds)
{
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed };
var cfg = TerrainGenConfig.CalibrationPool(mapSize, seed);
Pass1Result p1 = Topography.Generate(cfg);
pass1[seed] = p1;
rawPool.Accumulate(p1.Height, mapSize);
@ -289,8 +293,16 @@ namespace IslaApocalypse.Tools
// ---- configs --------------------------------------------------------
/// <remarks>
/// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. This is chat-2 CURVE development: authored
/// and judged before the shape family existed, on the family-off distribution the knots are
/// percentiles of. The re-baseline flipped the bare defaults family-ON, so the pin is what
/// keeps this tool measuring the thing it was written to measure.
/// → <see cref="TerrainGenConfig.WithFamilyOff"/>.
/// </remarks>
private static TerrainGenConfig OffConfig(int mapSize, int seed) =>
new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "curve_off", Curve = false, ShelfDetail = false };
new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "curve_off", Curve = false, ShelfDetail = false }
.WithFamilyOff();
private static TerrainGenConfig OnConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a) =>
new TerrainGenConfig
@ -301,7 +313,7 @@ namespace IslaApocalypse.Tools
// bit-for-bit. chat2/02 moved the config DEFAULT to Continuous for its exploration;
// a control batch must not move with a default. (chat2/02.)
CurveMode = CurveModeKind.Staircase,
};
}.WithFamilyOff(); // ⭐ rivers/01 — see OffConfig: the staircase control is pre-family too
/// <summary>
/// Fraction of land below the CostaRica palette's third stop (0.310 raw). A blunt

View file

@ -48,8 +48,7 @@ namespace IslaApocalypse.Tools
/// ISLA_SEEDS variant seeds, comma-separated (default: the 2 pinned below)
/// ISLA_SHOWPIECE_SIZE the big confirmation render (default 8192)
/// ISLA_SHOWPIECE "0" to skip it
/// ISLA_PHASE1_SOURCE batch holding Phase-1 .f32 (default "02_pass1_port")
/// ISLA_T01_SOURCE batch holding task-01 .f32 (default "01_curve_baseline")
/// ISLA_PHASE1_SOURCE batch holding Phase-1 .f32 (default "chat1/02_pass1_port")
/// ISLA_SKIP_RAW "1" to skip the .f32 dumps
/// ISLA_CEILING_M probe override: lowland ceiling, metres (default 30)
/// ISLA_FEATHER probe override: climb feather, 0..1 (default 0.4)
@ -91,6 +90,10 @@ namespace IslaApocalypse.Tools
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
int task = EnvInt("ISLA_TASK", 2);
string descr = EnvStr("ISLA_BATCH", "curve_continuous");
@ -98,8 +101,7 @@ namespace IslaApocalypse.Tools
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");
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
string batchRoot = ToolingPaths.BatchRoot(task, descr); // composed, never free-form
@ -128,7 +130,7 @@ namespace IslaApocalypse.Tools
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int seed in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = mapSize, Seed = seed });
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(mapSize, seed));
pass1[seed] = p1;
rawPool.Accumulate(p1.Height, mapSize);
GD.Print($" pooled seed {seed,-11} h[{p1.HMinSeed,7:F3} .. {p1.HMaxSeed,6:F3}] {p1.ElapsedMs,5} ms");
@ -187,15 +189,20 @@ namespace IslaApocalypse.Tools
{
Pass1Result pp1 = pass1[primary];
// (a1) curve off == Phase 1's own dump.
// (a1) curve off == Phase 1's own dump. ⭐ KEPT at rivers/01: the family-off pass-1 guard,
// the last link between today's generator and the Phase-1 port. The config is pinned
// family-off so it still means what it says. ⚠ A missing dump now THROWS.
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{primary}_full", "height.f32");
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF == Phase-1 .f32 dump",
offs[primary].Height, HeightField.Load(p1Dump, mapSize), mapSize, p1Dump));
offs[primary].Height, ShapingOracle.LoadAnchor("a1", "ISLA_PHASE1_SOURCE", p1Dump, mapSize), mapSize, p1Dump));
// (a2) staircase == task 01's own dump — the control is the control.
string t01Dump = Path.Combine(ToolingPaths.BatchesRoot, t01Source, $"{primary}_curve_on", "height.f32");
hard.Add(ShapingOracle.DumpRegression("a2", "staircase mode == task-01 curve_on .f32 dump",
results[(primary, "staircase")].Height, HeightField.Load(t01Dump, mapSize), mapSize, t01Dump));
// ⚑ RETIRED at rivers/01 — a2, the staircase == `01_curve_baseline` control.
// The staircase curve is SUPERSEDED by the continuous grade (→ D-062). A control that
// reproduces a curve nothing ships is scaffolding, and holding it green cost a
// 4-variant batch run to prove a mode no design doc describes any more.
// The dump is NOT deleted (file-safety; it is regenerable and it is the record of what
// was judged); its `INDEX.md` is marked superseded. The check is gone so nothing can
// pass against a superseded baseline. → XX_Human/output/rivers/01_*.report.md §A4.
// (b) classify == raw, every seed × every variant.
long bFail = 0;
@ -340,7 +347,11 @@ namespace IslaApocalypse.Tools
LowlandCeilingM = EnvFloat("ISLA_CEILING_M", 30f),
ClimbFeather = EnvFloat("ISLA_FEATHER", 0.4f),
SummitDrama = EnvFloat("ISLA_DRAMA", 2.5f),
};
}
// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. chat2/02 is CURVE development, measured on
// the family-off distribution the knots are percentiles of; the re-baseline flipped the bare
// defaults family-ON. → TerrainGenConfig.WithFamilyOff().
.WithFamilyOff();
// ---- output -----------------------------------------------------------

View file

@ -47,8 +47,13 @@ namespace IslaApocalypse.Tools
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)
/// <summary>
/// The faint grey terrain every overlay map is drawn on — ocean flat dark blue, enclosed
/// (non-ocean) water dark teal, land a shallow sqrt ramp. Factored out at rivers/02 so the
/// promotion maps sit on the SAME base as the chat2/12 candidates map and can be compared
/// without the eye correcting for two different backgrounds.
/// </summary>
public static Image TerrainBase(bool[] isOcean, float[,] render, int n, float sea, float hMax)
{
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
float span = MathF.Max(1e-6f, hMax - sea);
@ -62,6 +67,13 @@ namespace IslaApocalypse.Tools
float g = 0.30f + 0.45f * MathF.Sqrt(t);
img.SetPixel(x, y, new Color(g, g, g * 0.96f));
}
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 = TerrainBase(isOcean, render, n, sea, hMax);
int thick = n >= 4096 ? 5 : 3, thin = n >= 4096 ? 3 : 2, mark = n >= 4096 ? 18 : 10;
foreach (var g in plan.Giants)
@ -90,13 +102,694 @@ namespace IslaApocalypse.Tools
return img;
}
private static void Polyline(Image img, List<(float x, float y)> pts, int n, Color c, int thick)
// ═══ ⭐ THE PROMOTION MAPS (rivers/02) — the count decision, on the map ═══════════════════
//
// Two views, same base, same colour law:
// SEA-REACHING cyan (as chat2/12's trunks)
// ENDORHEIC orange (as chat2/12's giants)
// so a reader carrying chat2/12 in their eye reads these without relearning anything.
//
// ⚠⚠ NEITHER MAP DRAWS `Giant.ProvisionalRoute`. That steepest-descent placeholder — the
// visible "comb" of parallel threads on the flats — is rivers/03's job to replace, and drawing
// it here would make a count look like a river network it is not. What IS drawn is the REAL
// upland stem: the max-accumulation course traced through erosion-carved valleys.
/// <summary>
/// ⭐ THE DIAGNOSTIC MAP — every candidate above the floor, marker AREA ∝ drainage area,
/// colour by terminus. Answers "where are the big drainages, and is the spread north/south?"
/// before any count is chosen.
///
/// ⚠ Marker radius scales as √area so the MARKER'S AREA is proportional to the drainage area —
/// scaling the radius linearly would exaggerate the big ones quadratically and make a knee look
/// like a cliff.
/// </summary>
public static Image PromotionCandidates(List<RiverCandidate> ranked, Image img, int n, string title, int[] ladder)
{
if (ranked.Count == 0) return img;
long maxArea = 1;
foreach (var c in ranked) if (c.DrainagePx > maxArea) maxArea = c.DrainagePx;
float rMax = n >= 4096 ? 46f : 22f, rMin = n >= 4096 ? 6f : 3f;
int ringW = n >= 4096 ? 4 : 2;
// Draw smallest-first so a big marker never hides behind a small one.
for (int i = ranked.Count - 1; i >= 0; i--)
{
var c = ranked[i];
float f = MathF.Sqrt((float)c.DrainagePx / maxArea); // area ∝ drainage
int r = (int)MathF.Round(rMin + (rMax - rMin) * f);
Color col = c.IsSea ? Trunk : Giant;
Disc(img, c.X, c.Y, r, n, col);
Ring(img, c.X, c.Y, r + ringW + 1, n, Ink, ringW); // ink halo: legible on any ground
}
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
int nSea = 0; foreach (var c in ranked) if (c.IsSea) nSea++;
TinyFont.Draw(img, title, 12, 12, s, Ink);
TinyFont.Draw(img, $"ALL {ranked.Count} CANDIDATES ABOVE THE FLOOR - MARKER AREA IS PROPORTIONAL TO DRAINAGE AREA", 12, 12 + lh, s, Ink);
TinyFont.Draw(img, $"CYAN: SEA-REACHING ({nSea}) ORANGE: ENDORHEIC ({ranked.Count - nSea}) - AN INLAND TERMINUS IS A PASS, NOT A FALLBACK", 12, 12 + lh * 2, s, Ink);
TinyFont.Draw(img, $"NOTHING IS PROMOTED HERE - THIS IS THE DISTRIBUTION THE COUNT ({Join(ladder)}) IS CHOSEN FROM", 12, 12 + lh * 3, s, Ink);
return img;
}
/// <summary>
/// ⭐ THE A/B PLATE — the unified top-N promoted, real upland stems, width ∝ drainage area,
/// terminus markers coloured by type. One plate per N; the developer picks by comparing them.
/// </summary>
public static Image PromotedRivers(List<RiverCandidate> promoted, Image img, int n,
int nPromoted, long floorPx, string title)
{
if (promoted.Count == 0) return img;
long maxArea = 1;
foreach (var c in promoted) if (c.DrainagePx > maxArea) maxArea = c.DrainagePx;
float wMax = n >= 4096 ? 11f : 6f, wMin = n >= 4096 ? 3f : 2f;
int mark = n >= 4096 ? 18 : 10;
// Smallest first, so the biggest rivers finish on top.
for (int i = promoted.Count - 1; i >= 0; i--)
{
var c = promoted[i];
if (c.Course == null || c.Course.Count < 2) continue;
float f = MathF.Sqrt((float)c.DrainagePx / maxArea);
int w = (int)MathF.Round(wMin + (wMax - wMin) * f);
Polyline(img, c.Course, n, c.IsSea ? Trunk : Giant, w);
}
// ⚠ Marked at the RIVER's terminus (where its stem pools), NOT at the basin's deepest cell —
// on a flat basin floor those differ, and marking the deepest cell draws the stem visibly
// detached from its own endpoint. → RiverCandidate.TermX.
foreach (var c in promoted)
{
if (c.IsSea) Square(img, c.TermX, c.TermY, mark, n, Trunk);
else { Disc(img, c.TermX, c.TermY, mark, n, Giant); Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3); }
}
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
int nSea = 0; foreach (var c in promoted) if (c.IsSea) nSea++;
TinyFont.Draw(img, title, 12, 12, s, Ink);
TinyFont.Draw(img, $"UNIFIED TOP {nPromoted} BY DRAINAGE AREA - THE SEA/ENDORHEIC SPLIT FELL OUT, IT WAS NOT QUOTA'D", 12, 12 + lh, s, Ink);
TinyFont.Draw(img, $"CYAN SQUARE: SEA OUTLET ({nSea}) ORANGE DISC: ENDORHEIC TERMINUS ({promoted.Count - nSea}) STEM WIDTH IS PROPORTIONAL TO DRAINAGE", 12, 12 + lh * 2, s, Ink);
TinyFont.Draw(img, $"REAL UPLAND STEMS ONLY - NO LOWLAND ROUTING, NO WATER, NOTHING CARVED (FLOOR {floorPx:N0} PX)", 12, 12 + lh * 3, s, Ink);
return img;
}
// ═══ ⭐⭐ THE COMPOSITION PLATE (rivers/02b) — pure ranking vs a gameplay sea-river floor ═══
//
// rivers/02 established that the count is a DESIGN choice (the distribution is a power law) and
// that this terrain's honest top-of-distribution is INLAND-DOMINANT. rivers/02b keeps the total
// fixed and asks one question the developer stated: **do 3 FORCED sea rivers read as real
// rivers, just smaller — or as sad thin threads beside the big inland ones?**
//
// ⚠⚠ THAT QUESTION CANNOT BE ASKED ON A PER-PLATE-NORMALISED PLATE, and `PromotedRivers` above
// normalises to the widest river ON ITS OWN PLATE. Under that law the quota plate would rescale
// itself around whatever it happens to contain, so a forced sea river drawn "thin" would be
// reporting the plate's contents, not the river's size — and drawn beside a plate that rescaled
// differently, the comparison is meaningless. THE ONE THING THIS PLATE MUST NOT DO.
//
// So the composition plates use ONE ABSOLUTE width→drainage constant, below, shared by both
// compositions and all four seeds. A thin river is thin because it IS smaller. The constant is
// printed on every plate and reported in the INDEX, so a reader can check the claim.
/// <summary>
/// ⭐ THE FIXED WIDTH→DRAINAGE CONSTANT — stem width in px per √(drainage px).
///
/// <c>1/180</c>. Chosen once, from the measured population rather than per plate: the largest
/// candidate on ANY of the eight gallery seeds is 4,474,342 px (seed `17320508`), whose √ is
/// 2,115 — so <c>2115/180 ≈ 11.8</c> lands the biggest drainage the terrain produces just under
/// the 16 px ceiling, with no clipping anywhere in the population and headroom left over.
///
/// ⭐ THE LAW IS SCALE-FREE WITH NO MAP-SIZE TERM IN IT, and that is not an oversight. Drainage
/// area scales as n², so √(drainage) scales as n — meaning <c>k·√area</c> already draws a stem
/// at the same FRACTION of the map at any size. Multiplying by n/8192 on top would make width
/// scale as n² and collapse every river onto the floor on a smaller smoke.
/// *(rivers/02 established this analysis is only valid at 8192 regardless — the params are
/// absolute pixel counts — so a smaller render is a pipeline check, never a comparison.)*
/// </summary>
public const float StemWidthPerSqrtPx = 1f / 180f;
/// <summary>
/// Legibility clamp on the fixed law. ⚠ The MINIMUM is a deliberate, reported distortion: a
/// 1 px line at 8192 is invisible at any zoom a person actually looks at a plate with, so the
/// smallest rivers are drawn at 2 px rather than truthfully thinner. Any river AT the floor is
/// therefore "at least this thin, possibly thinner" — which matters here, because the floor is
/// exactly where the "thread" verdict lives. <see cref="StemWidthAtFloor"/> reports whether any
/// drawn river hit it, so the plate never quietly flatters a thread.
/// </summary>
public const int StemWidthMinPx = 2;
public const int StemWidthMaxPx = 16;
/// <summary>The fixed law, evaluated. NEVER normalised against the plate's own contents.</summary>
public static int StemWidthFixed(long drainagePx)
{
int w = (int)MathF.Round(MathF.Sqrt(MathF.Max(0f, drainagePx)) * StemWidthPerSqrtPx);
return Math.Clamp(w, StemWidthMinPx, StemWidthMaxPx);
}
/// <summary>True when this river is drawn at the legibility floor, i.e. no thinner than shown.</summary>
public static bool StemWidthAtFloor(long drainagePx) => StemWidthFixed(drainagePx) <= StemWidthMinPx;
/// <summary>The law as printed on the plate and in the INDEX — the constant is auditable, not implied.</summary>
public static string StemWidthLaw() =>
$"W PX = CLAMP(ROUND(SQRT(DRAINAGE PX) X {StemWidthPerSqrtPx:F6}), {StemWidthMinPx}, {StemWidthMaxPx})";
/// <summary>Compact drainage label: 2.33M / 736K / 4210 — the font has no lowercase.</summary>
public static string DrainageLabel(long px) =>
px >= 1_000_000 ? $"{px / 1e6:F2}M" : px >= 1_000 ? $"{(long)Math.Round(px / 1000.0)}K" : px.ToString();
/// <summary>
/// ⭐⭐ ONE COMPOSITION OF N RIVERS, on the shared faint base, at the FIXED width scale, with
/// per-river size labels.
///
/// Identical in style to <see cref="PromotedRivers"/> — same colours (cyan sea / orange
/// endorheic), same real upland stems, same terminus markers, `Giant.ProvisionalRoute` still
/// never drawn — and differs in exactly the two ways rivers/02b needs:
///
/// 1. THE FIXED WIDTH SCALE above, instead of per-plate normalisation.
/// 2. PER-RIVER LABELS: drainage area and rank in the FULL candidate distribution, so
/// "real river vs thin thread" has numbers behind the eyeball. A forced sea river reading
/// `272K R29` beside an inland `2.33M R1` tells the story before the eye does.
///
/// ⚠ Labels are placed with greedy collision avoidance against already-placed labels, on a dark
/// backing box so they are legible over both bright terrain and dark ocean. A label that cannot
/// be placed clear of the others is DROPPED rather than drawn illegibly on top of one — and the
/// legend says how many were dropped, so a missing number is never silent.
/// </summary>
public static Image RiverComposition(List<RiverCandidate> promoted, Image img, int n,
string title, string compositionLine, int candidateCount, long floorPx, bool labelAll)
{
if (promoted.Count == 0) return img;
int mark = n >= 4096 ? 18 : 10;
// Smallest first, so the biggest rivers finish on top.
var byArea = new List<RiverCandidate>(promoted);
byArea.Sort((a, b) => b.DrainagePx.CompareTo(a.DrainagePx));
for (int i = byArea.Count - 1; i >= 0; i--)
{
var c = byArea[i];
if (c.Course == null || c.Course.Count < 2) continue;
Polyline(img, c.Course, n, c.IsSea ? Trunk : Giant, StemWidthFixed(c.DrainagePx));
}
// ⚠ Marked at the RIVER's terminus (where its stem pools), NOT the basin's deepest cell —
// they differ on a flat basin floor. → RiverCandidate.TermX.
foreach (var c in byArea)
{
if (c.IsSea) Square(img, c.TermX, c.TermY, mark, n, Trunk);
else { Disc(img, c.TermX, c.TermY, mark, n, Giant); Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3); }
}
// ---- the labels ----
// Which rivers get one: all of them when the plate can carry it, otherwise the 3 the
// judgment turns on — every sea river — plus the largest inland, for scale.
var toLabel = new List<RiverCandidate>();
if (labelAll) toLabel.AddRange(byArea);
else
{
foreach (var c in byArea) if (c.IsSea) toLabel.Add(c);
foreach (var c in byArea) if (!c.IsSea) { toLabel.Add(c); break; }
}
int ls = n >= 4096 ? 4 : 3;
var placer = new LabelPlacer(n, ls, headerLines: 7);
int dropped = 0;
foreach (var c in toLabel)
if (!placer.Place(img, $"{DrainageLabel(c.DrainagePx)} R{c.Rank}", c.TermX, c.TermY, mark,
c.IsSea ? Trunk : Giant)) dropped++;
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
int nSea = 0; long seaPx = 0, endoPx = 0;
int atFloor = 0;
foreach (var c in byArea)
{
if (c.IsSea) { nSea++; seaPx += c.DrainagePx; } else endoPx += c.DrainagePx;
if (StemWidthAtFloor(c.DrainagePx)) atFloor++;
}
TinyFont.Draw(img, title, 12, 12, s, Ink);
TinyFont.Draw(img, compositionLine, 12, 12 + lh, s, Ink);
TinyFont.Draw(img, $"CYAN SQUARE: SEA OUTLET ({nSea}, {seaPx:N0} PX) ORANGE DISC: ENDORHEIC TERMINUS ({byArea.Count - nSea}, {endoPx:N0} PX)", 12, 12 + lh * 2, s, Ink);
TinyFont.Draw(img, $"FIXED SHARED WIDTH SCALE: {StemWidthLaw()} - THE SAME CONSTANT ON EVERY PLATE AND EVERY SEED, NEVER PER-PLATE", 12, 12 + lh * 3, s, Ink);
TinyFont.Draw(img, $"SO A THIN RIVER IS THIN BECAUSE IT IS SMALLER" + (atFloor > 0 ? $" - {atFloor} RIVER(S) AT THE {StemWidthMinPx} PX LEGIBILITY FLOOR: NO THINNER THAN DRAWN" : ""), 12, 12 + lh * 4, s, Ink);
TinyFont.Draw(img, $"LABEL: DRAINAGE AREA THEN R = RANK AMONG ALL {candidateCount} CANDIDATES ABOVE THE {floorPx:N0} PX FLOOR" + (dropped > 0 ? $" ({dropped} LABEL(S) DROPPED, NO CLEAR SPACE)" : ""), 12, 12 + lh * 5, s, Ink);
TinyFont.Draw(img, "REAL UPLAND STEMS ONLY - GIANT.PROVISIONALROUTE (THE COMB) NOT DRAWN - NO ROUTING, NO WATER, NOTHING CARVED", 12, 12 + lh * 6, s, Ink);
return img;
}
private static void FillRect(Image img, Rect2I r, Color c, int n)
{
for (int x = r.Position.X; x < r.Position.X + r.Size.X; x++)
for (int y = r.Position.Y; y < r.Position.Y + r.Size.Y; y++)
if (x >= 0 && y >= 0 && x < n && y < n) img.SetPixel(x, y, c);
}
/// <summary>
/// Greedy non-overlapping label placement on a dark backing box, so a number is legible over
/// both bright terrain and dark ocean. A label that cannot be placed clear of the others is
/// DROPPED rather than drawn illegibly on top of one — and every caller reports how many, so a
/// missing number is never silent. Shared by the composition and routed-mix plates.
/// </summary>
internal sealed class LabelPlacer
{
private readonly List<Rect2I> _placed = new();
private readonly int _n, _scale, _pad;
public LabelPlacer(int n, int scale, int headerLines)
{
_n = n; _scale = scale; _pad = 4 * (scale >= 4 ? 2 : 1);
// Reserve the legend block so a river label never lands under the header text.
_placed.Add(new Rect2I(0, 0, n, 12 + (TinyFont.Height(scale) + 6) * headerLines));
}
public bool Place(Image img, string txt, int atX, int atY, int mark, Color ink)
{
int w = TinyFont.Width(txt, _scale), h = TinyFont.Height(_scale);
int gap = mark + 10;
// right, left, below, above, then pushed further out — first clear slot wins.
var tries = new (int x, int y)[]
{
(atX + gap, atY - h / 2),
(atX - gap - w, atY - h / 2),
(atX - w / 2, atY + gap),
(atX - w / 2, atY - gap - h),
(atX + gap * 2 + w / 2, atY - h / 2),
(atX - gap * 2 - w - w / 2, atY - h / 2),
(atX - w / 2, atY + gap * 2 + h),
(atX - w / 2, atY - gap * 2 - h * 2),
};
foreach (var (tx, ty) in tries)
{
int bx = Math.Clamp(tx - _pad, 0, Math.Max(0, _n - (w + _pad * 2)));
int by = Math.Clamp(ty - _pad, 0, Math.Max(0, _n - (h + _pad * 2)));
var box = new Rect2I(bx, by, w + _pad * 2, h + _pad * 2);
bool hit = false;
foreach (var q in _placed) if (box.Intersects(q)) { hit = true; break; }
if (hit) continue;
FillRect(img, box, new Color(0.04f, 0.05f, 0.07f), _n);
TinyFont.Draw(img, txt, bx + _pad, by + _pad, _scale, ink);
_placed.Add(box);
return true;
}
return false;
}
}
// ═══ ⭐⭐ THE ROUTED MIX (rivers/03) — three classes, and where routing added the channel ═══
/// <summary>Routed giants: the natural upland stem, muted.</summary>
private static readonly Color RoutedStem = new(0.250f, 0.620f, 0.330f);
/// <summary>⭐ The LOWLAND REACH routing added — bright, so the added channel is unmistakable.</summary>
private static readonly Color RoutedReach = new(0.380f, 1.000f, 0.420f);
/// <summary>The rim the route climbed over — the point the developer is asked to judge.</summary>
private static readonly Color RimMark = new(1.000f, 0.930f, 0.350f);
/// <summary>
/// ⭐⭐ THE MIX PLATE — natural ocean trunks, routed-through giants, and inland lake-enders, on
/// the shared faint base at rivers/02b's FIXED width scale.
///
/// The one thing this plate exists to show: **which part of a routed river is terrain and which
/// part is routing.** So a routed giant is drawn in two tones of one colour — its erosion-carved
/// upland stem muted, the lowland reach the Dijkstra added bright — and the point where that
/// reach crosses its rim is ringed. A reader can then see, without reading a table, how far the
/// river was carried and how high it had to climb to get there.
///
/// ⚠⚠ `Giant.ProvisionalRoute` is NOT drawn — the real route is what replaces it.
/// </summary>
public static Image RoutedMix(List<RiverRouting.RoutedRiver> rivers, Image img, int n,
string title, string subtitle, long floorPx)
{
if (rivers.Count == 0) return img;
int mark = n >= 4096 ? 18 : 10;
var byArea = new List<RiverRouting.RoutedRiver>(rivers);
byArea.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx));
// Smallest first, so the biggest rivers finish on top.
for (int i = byArea.Count - 1; i >= 0; i--)
{
var r = byArea[i];
int w = StemWidthFixed(r.Candidate.DrainagePx);
Color stemCol = r.Class switch
{
RiverRouting.RiverClass.OceanTrunk => Trunk,
RiverRouting.RiverClass.RoutedGiant => RoutedStem,
_ => Giant,
};
// The upland stem, as erosion made it (head → terminal), reversed out of the analysis.
var stem = new List<(float x, float y)>(r.Candidate.Course);
stem.Reverse();
Polyline(img, stem, n, stemCol, w);
// The lowland reach routing added, drawn distinctly on top of its own stem.
if (r.Lowland != null && r.Lowland.Smoothed != null && r.Lowland.Smoothed.Count > 1)
{
Color reachCol = r.Class == RiverRouting.RiverClass.RoutedGiant ? RoutedReach : Giant;
Polyline(img, r.Lowland.Smoothed, n, reachCol, w);
}
}
// Terminus markers, and the rim a routed river crossed.
foreach (var r in byArea)
{
var c = r.Candidate;
switch (r.Class)
{
case RiverRouting.RiverClass.OceanTrunk:
Square(img, c.TermX, c.TermY, mark, n, Trunk);
break;
case RiverRouting.RiverClass.RoutedGiant:
if (r.Lowland != null && r.Lowland.Reached)
{
var t = r.Lowland.Target;
Square(img, (int)t.x, (int)t.y, mark, n, RoutedReach);
Ring(img, (int)t.x, (int)t.y, mark + 8, n, Ink, 3);
MarkRim(img, r.Lowland, n, mark);
}
// The basin it came FROM stays marked, so the reader sees what was connected.
Ring(img, c.TermX, c.TermY, mark, n, RoutedStem, 4);
break;
default:
Disc(img, c.TermX, c.TermY, mark, n, Giant);
Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3);
if (r.Lowland != null && r.Lowland.Reached)
{
var t = r.Lowland.Target;
Ring(img, (int)t.x, (int)t.y, mark, n, Giant, 4);
}
break;
}
}
// ---- labels ----
int ls = n >= 4096 ? 4 : 3;
var placer = new LabelPlacer(n, ls, headerLines: 8);
int dropped = 0;
foreach (var r in byArea)
{
var c = r.Candidate;
string txt = r.Class switch
{
RiverRouting.RiverClass.OceanTrunk => $"{DrainageLabel(c.DrainagePx)} R{c.Rank} TRUNK",
RiverRouting.RiverClass.RoutedGiant => $"{DrainageLabel(c.DrainagePx)} R{c.Rank} RIM {(r.Lowland != null ? r.Lowland.RimClimbM : 0f):F0}M",
_ => $"{DrainageLabel(c.DrainagePx)} R{c.Rank} LAKE",
};
Color ink = r.Class switch
{
RiverRouting.RiverClass.OceanTrunk => Trunk,
RiverRouting.RiverClass.RoutedGiant => RoutedReach,
_ => Giant,
};
if (!placer.Place(img, txt, c.TermX, c.TermY, mark, ink)) dropped++;
}
int trunks = 0, routed = 0, lakes = 0;
foreach (var r in byArea)
{
if (r.Class == RiverRouting.RiverClass.OceanTrunk) trunks++;
else if (r.Class == RiverRouting.RiverClass.RoutedGiant) routed++;
else lakes++;
}
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
TinyFont.Draw(img, title, 12, 12, s, Ink);
TinyFont.Draw(img, subtitle, 12, 12 + lh, s, Ink);
TinyFont.Draw(img, $"CYAN: NATURAL OCEAN TRUNK ({trunks}) - EROSION ALREADY REACHES THE COAST, NO LOWLAND ROUTE ADDED", 12, 12 + lh * 2, s, Trunk);
TinyFont.Draw(img, $"GREEN: ROUTED-THROUGH GIANT ({routed}) - DARK = ITS NATURAL UPLAND STEM, BRIGHT = THE LOWLAND REACH ROUTING ADDED", 12, 12 + lh * 3, s, RoutedReach);
TinyFont.Draw(img, $"YELLOW RING ON A GREEN REACH = THE RIM IT CLIMBED OVER (ROUTE HIGH POINT). LABEL RIM = METRES CLIMBED FROM THE BASIN", 12, 12 + lh * 4, s, RimMark);
TinyFont.Draw(img, $"ORANGE: INLAND LAKE-ENDER ({lakes}) - DISC = ITS TERMINAL, RING = THE SIGNIFICANT WATER BODY IT JOINS", 12, 12 + lh * 5, s, Giant);
TinyFont.Draw(img, $"WIDTH: {StemWidthLaw()} - THE SAME FIXED CONSTANT AS RIVERS/02B, EVERY PLATE AND SEED", 12, 12 + lh * 6, s, Ink);
TinyFont.Draw(img, $"COURSES ONLY - NO HEIGHT MUTATED, NO WATER FILLED, NOTHING CARVED. PROVISIONALROUTE (THE COMB) NOT DRAWN." +
(dropped > 0 ? $" ({dropped} LABEL(S) DROPPED)" : ""), 12, 12 + lh * 7, s, Ink);
return img;
}
// ═══ ⭐⭐ THE REFINED MIX (rivers/03b) — five classes, and the dendritic tree ═══════════════
//
// Same base, same colours where they carry over, and the SAME fixed width scale as rivers/02b
// and rivers/03, so this plate can be laid beside `03_lowland_routing/<seed>/routed_mix.png` and
// read as a before/after rather than as two different pictures.
//
// `RoutedMix` above is left exactly as rivers/03 produced it — that batch stays reproducible.
/// <summary>⭐ rivers/03b: a router that stopped at a significant lake instead of skirting it.</summary>
private static readonly Color LakeFedStem = new(0.520f, 0.380f, 0.780f);
private static readonly Color LakeFedReach = new(0.720f, 0.560f, 1.000f);
/// <summary>⚠ rivers/03b: refused by the rim cap — it would have been an uphill river.</summary>
private static readonly Color Walled = new(0.950f, 0.330f, 0.330f);
/// <summary>Where two courses actually meet.</summary>
private static readonly Color Junction = new(1.000f, 1.000f, 1.000f);
private static (Color stem, Color reach) ClassColours(RiverRouting.RiverClass c) => c switch
{
RiverRouting.RiverClass.OceanTrunk => (Trunk, Trunk),
RiverRouting.RiverClass.RoutedGiant => (RoutedStem, RoutedReach),
RiverRouting.RiverClass.LakeFed => (LakeFedStem, LakeFedReach),
RiverRouting.RiverClass.WalledOff => (Walled, Walled),
_ => (Giant, Giant),
};
/// <summary>
/// ⭐⭐ THE RESHAPED MIX — natural trunks, routed-through, lake-fed, natural lake-enders and
/// walled-off lake-enders, drawn as a dendritic TREE rather than as independent courses.
///
/// Each river draws only its OWN reach — truncated at its confluence junction if it joined one —
/// so tributaries merge into a single downstream line instead of running as parallel duplicates.
/// A white dot marks every junction. Within a river, the natural upland stem is drawn in the
/// muted tone and the lowland reach routing added in the bright one, exactly as rivers/03.
/// </summary>
public static Image RefinedMix(List<RiverRouting.RoutedRiver> rivers, Image img, int n,
string title, string subtitle, string capLine)
{
if (rivers.Count == 0) return img;
int mark = n >= 4096 ? 18 : 10;
var byArea = new List<RiverRouting.RoutedRiver>(rivers);
byArea.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx));
// Smallest first, so the biggest rivers finish on top.
for (int i = byArea.Count - 1; i >= 0; i--)
{
var r = byArea[i];
int w = StemWidthFixed(r.Candidate.DrainagePx);
var (stemCol, reachCol) = ClassColours(r.Class);
var cells = r.CellPath;
if (cells == null || cells.Count == 0) continue;
// Its OWN reach: everything up to the junction, or the whole course if it kept its mouth.
int own = r.Joined ? OwnLength(r) : cells.Count;
int stemEnd = Math.Min(own, Math.Max(1, r.StemCells));
Polyline(img, Slice(cells, 0, stemEnd), n, stemCol, w);
if (own > stemEnd) Polyline(img, Slice(cells, stemEnd - 1, own), n, reachCol, w);
}
// Terminus markers — read through the CONFLUENCE ROOT, because a tributary's mouth is its
// trunk's mouth and marking its own truncated end would invent a terminus it does not have.
foreach (var r in byArea)
{
var c = r.Candidate;
if (r.Joined)
{
Disc(img, r.JunctionCell.x, r.JunctionCell.y, Math.Max(4, mark / 2), n, Junction);
continue;
}
var (stemCol, reachCol) = ClassColours(r.Class);
switch (r.Class)
{
case RiverRouting.RiverClass.OceanTrunk:
Square(img, c.TermX, c.TermY, mark, n, Trunk);
break;
case RiverRouting.RiverClass.RoutedGiant:
if (r.Lowland != null && r.Lowland.Reached)
{
var t = r.Lowland.Target;
Square(img, (int)t.x, (int)t.y, mark, n, reachCol);
Ring(img, (int)t.x, (int)t.y, mark + 8, n, Ink, 3);
MarkRim(img, r.Lowland, n, mark);
}
Ring(img, c.TermX, c.TermY, mark, n, stemCol, 4);
break;
case RiverRouting.RiverClass.LakeFed:
if (r.Lowland != null && r.Lowland.Reached)
{
var t = r.Lowland.Target;
Disc(img, (int)t.x, (int)t.y, mark, n, reachCol);
Ring(img, (int)t.x, (int)t.y, mark + 8, n, Ink, 3);
}
Ring(img, c.TermX, c.TermY, mark, n, stemCol, 4);
break;
case RiverRouting.RiverClass.WalledOff:
// ⚠ It ends at its own terminal. A cross-less ring plus the rim it could not clear.
Disc(img, c.TermX, c.TermY, mark, n, Walled);
Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3);
break;
default:
Disc(img, c.TermX, c.TermY, mark, n, Giant);
Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3);
if (r.Lowland != null && r.Lowland.Reached)
Ring(img, (int)r.Lowland.Target.x, (int)r.Lowland.Target.y, mark, n, Giant, 4);
break;
}
}
// ---- labels ----
int ls = n >= 4096 ? 4 : 3;
var placer = new LabelPlacer(n, ls, headerLines: 9);
int dropped = 0;
foreach (var r in byArea)
{
var c = r.Candidate;
var (stemCol, reachCol) = ClassColours(r.Class);
string tag = r.Class switch
{
RiverRouting.RiverClass.OceanTrunk => "TRUNK",
RiverRouting.RiverClass.RoutedGiant => $"SEA RIM {(r.Lowland != null ? r.Lowland.RimClimbM : 0f):F0}M",
RiverRouting.RiverClass.LakeFed => "LAKE-FED",
RiverRouting.RiverClass.WalledOff => $"WALLED {r.CappedRimM:F0}M",
_ => "LAKE",
};
if (r.Joined) tag += $" INTO R{r.ConfluenceParentRank}";
int lx = r.Joined ? r.JunctionCell.x : c.TermX;
int ly = r.Joined ? r.JunctionCell.y : c.TermY;
if (!placer.Place(img, $"{DrainageLabel(c.DrainagePx)} R{c.Rank} {tag}", lx, ly, mark,
r.Joined ? Junction : reachCol)) dropped++;
}
int trunks = 0, routed = 0, lakeFed = 0, natural = 0, walled = 0, joined = 0;
foreach (var r in byArea)
{
switch (r.Class)
{
case RiverRouting.RiverClass.OceanTrunk: trunks++; break;
case RiverRouting.RiverClass.RoutedGiant: routed++; break;
case RiverRouting.RiverClass.LakeFed: lakeFed++; break;
case RiverRouting.RiverClass.WalledOff: walled++; break;
default: natural++; break;
}
if (r.Joined) joined++;
}
int s2 = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s2) + 6;
TinyFont.Draw(img, title, 12, 12, s2, Ink);
TinyFont.Draw(img, subtitle, 12, 12 + lh, s2, Ink);
TinyFont.Draw(img, $"CYAN: NATURAL OCEAN TRUNK ({trunks}) GREEN: ROUTED THROUGH TO THE SEA ({routed}) - DARK = NATURAL STEM, BRIGHT = THE REACH ROUTING ADDED", 12, 12 + lh * 2, s2, Trunk);
TinyFont.Draw(img, $"VIOLET: LAKE-FED ({lakeFed}) - A DRY BASIN THAT MET A SIGNIFICANT LAKE BEFORE THE SEA AND STOPS THERE (FIX 3)", 12, 12 + lh * 3, s2, LakeFedReach);
TinyFont.Draw(img, $"RED: WALLED OFF ({walled}) - {capLine} (FIX 1)", 12, 12 + lh * 4, s2, Walled);
TinyFont.Draw(img, $"ORANGE: NATURAL LAKE-ENDER ({natural}) - ITS BASIN ALREADY HOLDS A LAKE, SO ITS RIVER FEEDS IT", 12, 12 + lh * 5, s2, Giant);
TinyFont.Draw(img, $"WHITE DOT: CONFLUENCE ({joined} JOINED) - A TRIBUTARY MERGING INTO A BIGGER RIVER, NOT A PARALLEL DUPLICATE (FIX 2)", 12, 12 + lh * 6, s2, Junction);
TinyFont.Draw(img, $"YELLOW RING = THE RIM A ROUTED RIVER CLIMBED OVER. WIDTH: {StemWidthLaw()} - AS RIVERS/02B AND 03", 12, 12 + lh * 7, s2, RimMark);
TinyFont.Draw(img, "COURSES ONLY - NO HEIGHT MUTATED, NO WATER FILLED OR CREATED, NOTHING CARVED. PROVISIONALROUTE NOT DRAWN." +
(dropped > 0 ? $" ({dropped} LABEL(S) DROPPED)" : ""), 12, 12 + lh * 8, s2, Ink);
return img;
}
/// <summary>How many leading cells of a joined river's path are its own, up to the junction.</summary>
private static int OwnLength(RiverRouting.RoutedRiver r)
{
for (int i = 0; i < r.CellPath.Count; i++)
if (r.CellPath[i].x == r.JunctionCell.x && r.CellPath[i].y == r.JunctionCell.y) return i + 1;
return r.CellPath.Count;
}
private static List<(float x, float y)> Slice(List<(int x, int y)> cells, int from, int to)
{
var outp = new List<(float x, float y)>();
for (int i = Math.Max(0, from); i < Math.Min(to, cells.Count); i++) outp.Add((cells[i].x, cells[i].y));
return outp;
}
/// <summary>Ring the route's high point — the rim the channel crosses.</summary>
private static void MarkRim(Image img, RiverRouting.Route route, int n, int mark)
{
if (route.Path == null || route.Path.Count < 2 || route.RimClimbM <= 0.01f) return;
var p = route.RimPoint;
Ring(img, (int)p.x, (int)p.y, mark - 4, n, RimMark, 4);
}
/// <summary>
/// ⭐ THE DISTRIBUTION PLOT — drainage area (log y) against rank (linear x), with the ladder
/// counts marked vertically and the analysis's own thresholds marked horizontally.
///
/// **Log y is not a presentation choice, it is the only honest one:** drainage areas span three
/// or more orders of magnitude, so on a linear axis every candidate but the top two or three
/// collapses onto the floor and the knee — the thing this plot exists to show — is invisible.
/// </summary>
public static Image Distribution(List<RiverCandidate> ranked, int[] ladder,
long endorheicMinInflowPx, long stemMinAccPx, long floorPx, string title)
{
const int W = 1600, H = 1000, L = 150, R = 40, T = 120, B = 90;
var img = Image.CreateEmpty(W, H, false, Image.Format.Rgb8);
var bg = new Color(0.07f, 0.08f, 0.10f);
for (int x = 0; x < W; x++) for (int y = 0; y < H; y++) img.SetPixel(x, y, bg);
if (ranked.Count == 0) return img;
double loMin = Math.Log10(Math.Max(1.0, Math.Min(floorPx, ranked[ranked.Count - 1].DrainagePx)));
double hiMax = Math.Log10(Math.Max(10.0, ranked[0].DrainagePx));
loMin = Math.Floor(loMin); hiMax = Math.Ceiling(hiMax);
int plotW = W - L - R, plotH = H - T - B;
int XOf(int rank) => L + (int)((rank - 1) / (double)Math.Max(1, ranked.Count - 1) * plotW);
int YOf(double area) => T + plotH - (int)((Math.Log10(Math.Max(1.0, area)) - loMin) / Math.Max(1e-9, hiMax - loMin) * plotH);
var grid = new Color(0.16f, 0.18f, 0.22f);
for (int d = (int)loMin; d <= (int)hiMax; d++) // decade gridlines
{
int y = YOf(Math.Pow(10, d));
for (int x = L; x < L + plotW; x++) if (y >= 0 && y < H) img.SetPixel(x, y, grid);
TinyFont.Draw(img, $"1E{d}", 12, Math.Max(0, y - 6), 2, new Color(0.60f, 0.64f, 0.70f));
}
// the analysis's own thresholds — so the ladder is read RELATIVE to them, not in a vacuum
DashH(img, YOf(endorheicMinInflowPx), L, L + plotW, new Color(1f, 0.45f, 0.45f));
TinyFont.Draw(img, $"ENDORHEIC MIN INFLOW {endorheicMinInflowPx:N0}", L + 8, YOf(endorheicMinInflowPx) - 22, 2, new Color(1f, 0.45f, 0.45f));
DashH(img, YOf(stemMinAccPx), L, L + plotW, new Color(0.55f, 0.85f, 0.55f));
TinyFont.Draw(img, $"STEM MIN ACC {stemMinAccPx:N0}", L + 8, YOf(stemMinAccPx) - 22, 2, new Color(0.55f, 0.85f, 0.55f));
foreach (int nn in ladder) // the ladder counts
{
if (nn < 1 || nn > ranked.Count) continue;
int x = XOf(nn);
for (int y = T; y < T + plotH; y += 6)
for (int k = 0; k < 3 && y + k < T + plotH; k++) img.SetPixel(x, y + k, new Color(0.95f, 0.90f, 0.35f));
TinyFont.Draw(img, $"N={nn}", x + 6, T + 6, 3, new Color(0.95f, 0.90f, 0.35f));
TinyFont.Draw(img, $"{ranked[nn - 1].DrainagePx:N0}", x + 6, T + 6 + TinyFont.Height(3) + 4, 2, new Color(0.95f, 0.90f, 0.35f));
}
for (int i = 0; i < ranked.Count; i++) // the candidates
{
var c = ranked[i];
int x = XOf(i + 1), y = YOf(c.DrainagePx);
Color col = c.IsSea ? Trunk : Giant;
for (int ox = -3; ox <= 3; ox++)
for (int oy = -3; oy <= 3; oy++)
{
if (ox * ox + oy * oy > 9) continue;
int px = x + ox, py = y + oy;
if (px >= 0 && py >= 0 && px < W && py < H) img.SetPixel(px, py, col);
}
}
TinyFont.Draw(img, title, 12, 12, 3, Ink);
TinyFont.Draw(img, "DRAINAGE AREA (PX, LOG) VS UNIFIED RANK - CYAN SEA-REACHING, ORANGE ENDORHEIC", 12, 12 + TinyFont.Height(3) + 8, 2, Ink);
TinyFont.Draw(img, $"{ranked.Count} CANDIDATES ABOVE THE {floorPx:N0} PX FLOOR - A KNEE IS A SHARP DROP; A SMOOTH CURVE MEANS THE TERRAIN HAS NO NATURAL COUNT", 12, H - 34, 2, new Color(0.70f, 0.74f, 0.80f));
return img;
}
private static string Join(int[] v)
{
var sb = new System.Text.StringBuilder();
for (int i = 0; i < v.Length; i++) { if (i > 0) sb.Append('/'); sb.Append(v[i]); }
return sb.ToString();
}
private static void DashH(Image img, int y, int x0, int x1, Color c)
{
if (y < 0 || y >= img.GetHeight()) return;
for (int x = x0; x < x1; x += 14)
for (int k = 0; k < 8 && x + k < x1; k++) img.SetPixel(x + k, y, c);
}
internal 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)
internal 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;
@ -118,7 +811,7 @@ namespace IslaApocalypse.Tools
}
}
private static void Disc(Image img, int cx, int cy, int r, int n, Color c)
internal 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++)
@ -129,7 +822,7 @@ namespace IslaApocalypse.Tools
}
}
private static void Ring(Image img, int cx, int cy, int r, int n, Color c, int w)
internal 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++)
@ -141,7 +834,7 @@ namespace IslaApocalypse.Tools
}
}
private static void Square(Image img, int cx, int cy, int r, int n, Color c)
internal 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++)

View file

@ -54,6 +54,10 @@ namespace IslaApocalypse.Tools
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
int task = EnvInt("ISLA_TASK", 12);
string descr = EnvStr("ISLA_BATCH", "drainage_analysis");
@ -62,7 +66,7 @@ namespace IslaApocalypse.Tools
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 t11Source = EnvStr("ISLA_T11_SOURCE", "chat2/11_erosion");
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
@ -77,7 +81,11 @@ namespace IslaApocalypse.Tools
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");
// ⚠ rivers/01: the shape AND erosion now come from the defaults, so both are asserted before
// anything generates. A drift here would silently re-baseline every river measurement.
TerrainShapeV1.Assert("DrainageTool");
TerrainShapeV1.AssertErosionDefaultOn("DrainageTool");
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default (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("==================================================================");
@ -88,14 +96,15 @@ namespace IslaApocalypse.Tools
TerrainGenConfig Cfg(int size, int seed)
{
// ⭐ rivers/01: THE SHAPE AND EROSION COME FROM THE BARE DEFAULTS. `TerrainShapeV1.Apply(c)`
// and `c.Erosion = true` used to sit here; both are now what `new TerrainGenConfig()`
// carries. Only the CURVE (measured this run) is set.
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;
}
@ -114,15 +123,16 @@ namespace IslaApocalypse.Tools
Pass2Result p2 = ero.Shaped;
GD.Print($" terrain ready ({p1.ElapsedMs} ms pass 1, erosion {ero.Ms / 1000.0:F1} s)");
// ⭐ a11 — THE EROSION ACCEPTANCE ANCHOR (rivers/01 keeps this one). Since the re-baseline
// the whole chain — shape AND erosion — comes from the bare defaults, so this is the
// standing proof that the terrain every river measurement rests on has not moved.
// ⚠ A missing dump now THROWS (ShapingOracle.LoadAnchor) instead of skipping silently.
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}")}");
var a11 = ShapingOracle.DumpRegression("a11", $"the eroded render field from the BARE DEFAULTS == the task-11 erosion_on dump (the terrain the developer saw) [{seed}]",
p2.Height, ShapingOracle.LoadAnchor("a11", "ISLA_T11_SOURCE", dump, mapSize), mapSize, dump);
hard.Add(a11); GD.Print(" " + a11);
}
// ⭐ THE OCEAN IDENTITY — from the region layer, on the CLASSIFY field.
@ -284,7 +294,7 @@ namespace IslaApocalypse.Tools
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
@ -297,11 +307,14 @@ namespace IslaApocalypse.Tools
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
// ⭐ rivers/01: family-off PINNED, like the pool it shapes. (The family acts in pass 1 and
// `Shaping.Shape` never reads it, so this is inert today — pinned anyway so "the whole
// calibration is family-off" is a total claim rather than a field-by-field one.)
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
};
}.WithFamilyOff();
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);

View file

@ -7,33 +7,6 @@ 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
@ -85,6 +58,10 @@ namespace IslaApocalypse.Tools
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
int task = EnvInt("ISLA_TASK", 11);
string descr = EnvStr("ISLA_BATCH", "erosion");
@ -93,7 +70,7 @@ namespace IslaApocalypse.Tools
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 t10Source = EnvStr("ISLA_T10_SOURCE", "chat2/10_frag4_seed_gallery");
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
@ -107,6 +84,7 @@ namespace IslaApocalypse.Tools
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
GD.Print($"seeds : {string.Join(", ", seeds)}");
TerrainShapeV1.Assert("ErosionTool"); // ⚠ rivers/01: refuse to render if the defaults drifted off the locked shape
GD.Print($"shape : {TerrainShapeV1.Describe()}");
GD.Print($"batch : {batchRoot}");
GD.Print("==================================================================");
@ -117,13 +95,17 @@ namespace IslaApocalypse.Tools
TerrainGenConfig Cfg(int size, int seed, string label, bool erosion)
{
// ⭐ rivers/01: THE SHAPE COMES FROM THE BARE DEFAULTS. `TerrainShapeV1.Apply(c)` used to
// sit here; the locked shape is now what `new TerrainGenConfig()` produces, so stamping
// a preset on top would MASK a default drift instead of catching it. Only the CURVE
// (measured this run) and the per-variant erosion flag are set.
// → TerrainShapeV1.Assert(), called before any generation below.
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);
@ -156,12 +138,15 @@ namespace IslaApocalypse.Tools
var cOff = Cfg(mapSize, seed, "erosion_off", false);
Pass1Result p1Off = Topography.Generate(cOff);
Pass2Result p2Off = Shaping.Shape(p1Off, cOff);
// ⭐ a10 — THE SHAPE ACCEPTANCE ANCHOR (rivers/01 keeps this one). Since the re-baseline
// `p2Off` is generated from the BARE DEFAULTS, so this check is now the standing proof
// that the defaults still reproduce `terrain-shape-v1`.
// ⚠ A missing dump now THROWS (ShapingOracle.LoadAnchor) instead of skipping silently.
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}")}");
hard.Add(ShapingOracle.DumpRegression("a10", $"erosion OFF from the BARE DEFAULTS == terrain-shape-v1 (the task-10 gallery dump) [{seed}]",
p2Off.Height, ShapingOracle.LoadAnchor("a10", "ISLA_T10_SOURCE", dump, mapSize), mapSize, dump));
}
Image reliefOff = ReliefRenderer.Render(p2Off.Height, mapSize, look);
Image shadeOff = ShadeRenderer.Render(p2Off.Height, mapSize, sea, ShadeZ, look.LightAzimuth, look.LightAltitude);
@ -281,7 +266,7 @@ namespace IslaApocalypse.Tools
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
@ -294,11 +279,14 @@ namespace IslaApocalypse.Tools
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
// ⭐ rivers/01: family-off PINNED, like the pool it shapes. (The family acts in pass 1 and
// `Shaping.Shape` never reads it, so this is inert today — pinned anyway so "the whole
// calibration is family-off" is a total claim rather than a field-by-field one.)
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
};
}.WithFamilyOff();
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);

View file

@ -0,0 +1,642 @@
using System;
using System.Collections.Generic;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ FLOW-THROUGH ROUTING (rivers/05) — river → lake → over the spill → river → … → sea.
///
/// ═══ THE MODEL ═══
///
/// The reference routes a river to the FIRST terminus it can afford and stops. This replaces that with
/// CHAINING THROUGH THE BASIN GRAPH (rivers/04): a promoted river runs down its erosion-carved stem to
/// its terminal basin and from there follows the terrain's own overflow structure — into the lake or
/// across the dry pan, over the basin's spill, into the next basin, over its spill — until it reaches
/// the coast, walls at a real lake, or walls at a dry sink.
///
/// THE FIELD one island-wide D8 direction per land cell on <c>Plan.FullFilled</c> (the overflow
/// surface, rivers/04 §0.2: on it every basin's minimum is its spill, so descent leaves
/// each basin over its spill into the next). Cap-independent; computed once per seed.
/// THE WALK a river FOLLOWS the field from its terminal. Each basin it enters is checked once:
/// floor→spill climb (<c>BasinNode.SpillClimbM</c>, the fill-to-overtop metric, applied
/// uniformly to lake and dry basins) ≤ cap → overflow, continue; > cap → walled, stop.
/// DISPOSITION reaches <c>OceanMask</c> → KEEP (flow-through to the sea); walls at an <c>IsLake</c>
/// basin → KEEP (lake-terminal, feeds visible water); walls at a dry or puddle-only basin
/// → DROP the river entirely (a river dead-ending in dry nowhere is worse than no river,
/// and nothing is filled). Read through the CONFLUENCE ROOT: a river that joins a kept
/// river is kept as its tributary, whatever its own chain would have done.
/// CONFLUENCE rivers/03b's, reused verbatim: biggest-first, true cell intersection, never proximity.
///
/// ═══ ⛔ THE RED LINE ═══
///
/// **Courses, a direction field, and data. No height written, no bed carved, no water created or
/// filled.** The caller digests both height fields around this and refuses on any change.
///
/// ═══ ⚠ D-046 — which surface each step reads ═══
///
/// RENDER the field (<c>FullFilled</c>), the climbs (<c>SpillClimbM</c>), the real-terrain descent into
/// a lake (<c>Plan.Dir</c> on <c>Filled</c>), the lowground fallback (<c>RouteTo</c> on <c>p2.Height</c>).
/// CLASSIFY every terminus test: <c>OceanMask</c> for the sea, <c>IsLake</c> (≥ floor) for a lake, and
/// "is this cell the basin's own water" for where a river enters a lake. No bare <c>h &lt; sea</c>.
///
/// ═══ ⭐ HOW A LAKE BASIN IS CROSSED (the one place the field is not simply followed) ═══
///
/// The field inside a basin is the flood's ulp-staircase — it points from anywhere in the basin straight
/// at the spill, IGNORING the lake, because the flood never asked where the low water is. Water entering
/// a lake basin does not skirt the lake to the spill; it runs down to the lake, fills it, and leaves at
/// the spill. So inside an <c>IsLake</c> basin the course is: the REAL-TERRAIN descent from the entry
/// point into the basin's own classify water (<c>Plan.Dir</c>; rivers/03c fix B's lowground route as the
/// fallback when the descent pools short of the water), then the LAKE SPAN (water — recorded, not drawn),
/// then the OUTLET: from the lake's lowest cell on <c>FullFilled</c> (its point nearest the spill in flood
/// terms) along the field over the spill. A dry basin is crossed on the field as a visible line.
/// </summary>
public static class FlowThroughRouting
{
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;
public enum Terminus : byte
{
/// <summary>The chain reached <c>OceanMask</c>.</summary>
Ocean,
/// <summary>Walled at an <c>IsLake</c> basin — a significant lake.</summary>
Lake,
/// <summary>Walled at a dry (or puddle-only) basin.</summary>
DrySink,
/// <summary>The walk stuck with no lower neighbour outside any basin — an exact flat. Not expected.</summary>
Closed,
}
/// <summary>One basin the chain entered.</summary>
public sealed class Hop
{
public int BasinId;
public bool IsLake;
/// <summary>Floor→spill climb, metres, clamped at sea as <c>RouteTo</c> clamps — the cap metric.</summary>
public float ClimbM;
public bool Walled;
public int EntryCell;
/// <summary>The first cell outside the basin on the way out (-1 if walled).</summary>
public int SpillCell = -1;
/// <summary>⭐ The cell the river actually ENTERED the lake at (lake basins only; -1 otherwise).</summary>
public int LakeEntryCell = -1;
/// <summary>Cross-check: the field's exit from this basin lands where <c>BasinGraph</c>'s edge says.</summary>
public bool EdgeAgreesWithGraph = true;
/// <summary>The lake-entry descent had to fall back to the lowground route (the terminal pooled short of the water).</summary>
public bool UsedLowgroundFallback;
}
/// <summary>One promoted river, walked, disposed, assembled.</summary>
public sealed class FlowRiver
{
public RiverCandidate Candidate;
public List<Hop> Chain = new();
/// <summary>This river's OWN terminus, before confluence.</summary>
public Terminus Terminus;
public int TerminusBasinId;
/// <summary>The ocean cell entered, the lake cell entered, or where a dry/closed chain ended.</summary>
public int MouthCell = -1;
/// <summary>Head → terminus: the upland stem then the lowland chain. Lake spans are straight jumps across water.</summary>
public List<(float x, float y)> Course;
/// <summary>Lake spans: (entry water cell, outlet water cell) — the parts of the course that are water, not channel.</summary>
public List<(int from, int to)> WaterSpans = new();
public float StemLenPx, LowlandLenPx;
public float TotalLenPx => StemLenPx + LowlandLenPx;
public float MaxHopClimbM, TotalClimbM;
public int LakesPassed;
/// <summary>The rivers/03b confluence wrapper — <c>Joined</c>, <c>ConfluenceParentRank</c>, <c>CellPath</c>, <c>OwnPath</c>, <c>StemCells</c>.</summary>
public RiverRouting.RoutedRiver Routed;
/// <summary>⭐ The disposition of record — read through the confluence root.</summary>
public Terminus RootTerminus;
public bool Dropped;
public bool Trunk => Candidate.IsSea;
public bool ReachesSea => !Dropped && RootTerminus == Terminus.Ocean;
public string Why = "";
}
/// <summary>The cap-independent field: D8 on <c>FullFilled</c>, 0..7 or <see cref="D_NONE"/> (ocean, or no lower neighbour).</summary>
public sealed class Field
{
public sbyte[] Dir;
public int N;
public int Target(int i)
{
sbyte d = Dir[i];
if (d < 0) return -1;
int cx = i / N, cy = i % N;
return (cx + DX[d]) * N + (cy + DY[d]);
}
}
/// <summary>Per-seed precomputation shared by every cap: each lake basin's water cells and outlet cell; every basin's fill volume.</summary>
public sealed class Prep
{
public Dictionary<int, List<int>> LakeCells = new();
/// <summary>
/// ⭐ Per WATER BODY (an 8-connected component of a lake basin's own classify water): its cell with the lowest
/// <c>FullFilled</c> — the body's point nearest the spill in flood terms, where its overflow leaves. Per body, not
/// per basin: one basin can own several separate lakes (rivers/04 found `1063685222 #699` owning two), and a
/// river that enters one must leave from THAT one, not jump across land to another.
/// </summary>
public Dictionary<int, int> BodyOut = new();
/// <summary>Water cell → its body id (lake basins' own water only).</summary>
public Dictionary<int, int> BodyOf = new();
/// <summary>Per basin id: Σ (FullFilled render) × metres, over its cells — the volume to fill it to its spill, in metre·cells.</summary>
public Dictionary<int, double> FillVolumeMPx = new();
public bool[] Scratch; // one reusable target mask for the lowground fallback
}
public sealed class HeroLake
{
public int BasinId; public long LakeCells; public double FillVolumeMPx;
public float RiverLenPx; public int RiverRank; public int RiversThrough;
public double Score;
}
public sealed class Result
{
public float CapM;
public List<FlowRiver> Rivers = new();
public HashSet<int> WalledIds = new();
public int Trunks, FlowThrough, LakeTerminal, DroppedDry, DroppedClosed, Joined, RescuedByConfluence, DroppedByConfluence;
public int EdgeAgree, EdgeDisagree, LowgroundFallbacks;
/// <summary>The field AT THIS CAP: the ∞ field with every walled basin's cells replaced by D8 on the real terrain, so flow into a walled basin ends there.</summary>
public sbyte[] CappedDir;
/// <summary>Flow accumulation on the capped field (Kahn), cells; 0 on ocean — the field's own drainage tree, for the data map.</summary>
public int[] CappedAcc;
public long LandCells, CellsToSea, CellsToWalledLake, CellsToWalledDry, CellsStuck;
public List<HeroLake> HeroLakes = new();
}
// ═══ THE FIELD ══════════════════════════════════════════════════════════════════════════
/// <summary>D8 on <c>FullFilled</c> for every non-ocean cell, the analysis's exact neighbour order and drop/DIST rule. Ocean cells are <see cref="D_NONE"/>.</summary>
public static Field BuildField(DrainageAnalysis.Plan plan, int n, bool[] isOcean)
{
int total = n * n;
var dir = new sbyte[total];
float[] ff = plan.FullFilled;
for (int i = 0; i < total; i++)
{
if (isOcean[i]) { dir[i] = D_NONE; continue; }
int cx = i / n, cy = i % n;
float best = 0f; int bestK = -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;
float drop = (ff[i] - ff[nx * n + ny]) / DIST[k];
if (drop > best) { best = drop; bestK = k; }
}
dir[i] = bestK < 0 ? D_NONE : (sbyte)bestK;
}
return new Field { Dir = dir, N = n };
}
public static Prep Prepare(DrainageAnalysis.Plan plan, BasinGraph graph, float[,] height, int n, bool[] isOcean, bool[] isClassifyWater)
{
int total = n * n;
var p = new Prep { Scratch = new bool[total] };
var isLake = new HashSet<int>();
foreach (var b in graph.Nodes) if (b.IsLake) isLake.Add(b.Id);
var outFF = new Dictionary<int, float>();
for (int i = 0; i < total; i++)
{
int id = plan.BasinId[i];
if (id == 0) continue;
double d = WorldScale.MetresFromRaw(plan.FullFilled[i] - height[i / n, i % n]);
p.FillVolumeMPx.TryGetValue(id, out double v); p.FillVolumeMPx[id] = v + d;
if (!isLake.Contains(id) || !isClassifyWater[i] || isOcean[i]) continue;
if (!p.LakeCells.TryGetValue(id, out var cells)) { cells = new List<int>(); p.LakeCells[id] = cells; }
cells.Add(i);
}
// Label each lake basin's water into bodies (8-connected, fixed order) and find each body's outlet cell.
int nextBody = 1;
var stack = new Stack<int>();
foreach (var kv in p.LakeCells)
{
var set = new HashSet<int>(kv.Value);
foreach (int seed in kv.Value)
{
if (p.BodyOf.ContainsKey(seed)) continue;
int body = nextBody++;
p.BodyOf[seed] = body; stack.Push(seed);
int outCell = seed; float outFFv = plan.FullFilled[seed];
while (stack.Count > 0)
{
int c = stack.Pop();
if (plan.FullFilled[c] < outFFv || (plan.FullFilled[c] == outFFv && c < outCell)) { outFFv = plan.FullFilled[c]; outCell = 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 (!set.Contains(ni) || p.BodyOf.ContainsKey(ni)) continue;
p.BodyOf[ni] = body; stack.Push(ni);
}
}
p.BodyOut[body] = outCell;
}
}
return p;
}
/// <summary>The shortest 8-connected path THROUGH a body's water from one of its cells to another (BFS, fixed order). Water, not channel — recorded, never drawn.</summary>
private static List<int> WaterPath(Prep prep, int from, int to, int n)
{
int body = prep.BodyOf[from];
var parent = new Dictionary<int, int> { [from] = -1 };
var q = new Queue<int>(); q.Enqueue(from);
while (q.Count > 0)
{
int c = q.Dequeue();
if (c == to) break;
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 (parent.ContainsKey(ni) || !prep.BodyOf.TryGetValue(ni, out int b) || b != body) continue;
parent[ni] = c; q.Enqueue(ni);
}
}
var path = new List<int>();
if (!parent.ContainsKey(to)) { path.Add(from); path.Add(to); return path; }
for (int c = to; c >= 0; c = parent[c]) path.Add(c);
path.Reverse();
return path;
}
// ═══ THE WALKS ══════════════════════════════════════════════════════════════════════════
public static Result Run(List<RiverCandidate> promoted, DrainageAnalysis.Plan plan, BasinGraph graph, Field field, Prep prep,
float[,] height, int n, bool[] isOcean, bool[] isClassifyWater, float sea, float capM, Action<string> log, bool confluence = true)
{
var r = new Result { CapM = capM };
foreach (var b in graph.LandNodes) if (b.SpillClimbM > capM) r.WalledIds.Add(b.Id);
foreach (var c in promoted)
{
var fr = Walk(c, plan, graph, field, prep, height, n, isOcean, isClassifyWater, sea, capM, r);
r.Rivers.Add(fr);
}
// ---- confluence (rivers/03b, reused) over EVERY river, kept or not — a river that meets a kept river's
// channel before its own dead-end is that river's tributary, and its water reaches the sea through it.
var wrappers = new List<RiverRouting.RoutedRiver>();
foreach (var fr in r.Rivers)
{
fr.Routed = new RiverRouting.RoutedRiver
{
Candidate = fr.Candidate, Course = fr.Course,
Class = fr.Trunk ? RiverRouting.RiverClass.OceanTrunk
: fr.Terminus == Terminus.Ocean ? RiverRouting.RiverClass.RoutedGiant
: fr.Terminus == Terminus.Lake ? RiverRouting.RiverClass.LakeEnder
: RiverRouting.RiverClass.WalledOff,
};
wrappers.Add(fr.Routed);
}
if (confluence) RiverRouting.Confluence(wrappers, log);
else foreach (var w in wrappers) w.OwnPath = w.Course;
var byRank = new Dictionary<int, FlowRiver>();
foreach (var fr in r.Rivers) byRank[fr.Candidate.Rank] = fr;
foreach (var fr in r.Rivers)
{
var root = RiverRouting.Root(fr.Routed, wrappers);
var rootFr = byRank[root.Candidate.Rank];
fr.RootTerminus = rootFr.Terminus;
fr.Dropped = fr.RootTerminus == Terminus.DrySink || fr.RootTerminus == Terminus.Closed;
bool ownKept = fr.Terminus == Terminus.Ocean || fr.Terminus == Terminus.Lake;
if (fr.Routed.Joined)
{
r.Joined++;
if (!ownKept && !fr.Dropped) r.RescuedByConfluence++;
if (ownKept && fr.Dropped) r.DroppedByConfluence++;
}
if (fr.Dropped) { if (fr.RootTerminus == Terminus.Closed) r.DroppedClosed++; else r.DroppedDry++; }
else if (fr.Trunk) r.Trunks++;
else if (fr.RootTerminus == Terminus.Ocean) r.FlowThrough++;
else r.LakeTerminal++;
foreach (var h in fr.Chain) { if (h.UsedLowgroundFallback) r.LowgroundFallbacks++; }
}
foreach (var fr in r.Rivers)
for (int i = 0; i < fr.Chain.Count; i++)
if (fr.Chain[i].SpillCell >= 0) { if (fr.Chain[i].EdgeAgreesWithGraph) r.EdgeAgree++; else r.EdgeDisagree++; }
return r;
}
private static FlowRiver Walk(RiverCandidate c, DrainageAnalysis.Plan plan, BasinGraph graph, Field field, Prep prep,
float[,] height, int n, bool[] isOcean, bool[] isClassifyWater, float sea, float capM, Result res)
{
var fr = new FlowRiver { Candidate = c };
// The upland stem, head → terminal (the analysis's course is downstream-first, decimated ×4).
fr.Course = new List<(float x, float y)>(c.Course);
fr.Course.Reverse();
fr.StemLenPx = PolyLen(fr.Course);
if (c.IsSea)
{
fr.Terminus = Terminus.Ocean;
fr.MouthCell = c.Cell;
fr.Why = "natural ocean trunk — erosion already reaches the coast";
return fr;
}
int cur = c.TermX * n + c.TermY;
int basin = plan.BasinId[cur] != 0 ? plan.BasinId[cur] : c.BasinId;
var visited = new HashSet<int>();
var why = new System.Text.StringBuilder();
for (int guard = 0; guard < 256; guard++)
{
var node = graph.Of(basin);
if (node == null || !visited.Add(basin))
{
fr.Terminus = Terminus.Closed; fr.TerminusBasinId = basin; fr.MouthCell = cur;
why.Append(node == null ? $" → basin #{basin} not in the graph (closed)" : $" → basin #{basin} revisited (closed)");
break;
}
var hop = new Hop { BasinId = basin, IsLake = node.IsLake, ClimbM = node.SpillClimbM, Walled = node.SpillClimbM > capM, EntryCell = cur };
fr.Chain.Add(hop);
if (hop.ClimbM > fr.MaxHopClimbM) fr.MaxHopClimbM = hop.ClimbM;
if (hop.Walled)
{
fr.TerminusBasinId = basin;
if (node.IsLake)
{
var reach = DescendToWater(cur, basin, plan, prep, height, n, isOcean, isClassifyWater, sea, out bool fb);
hop.UsedLowgroundFallback = fb;
if (reach.Count > 1) AppendReach(fr, reach, n);
hop.LakeEntryCell = reach[^1];
fr.Terminus = Terminus.Lake; fr.MouthCell = reach[^1];
why.Append($" → #{basin} LAKE, rim {hop.ClimbM:F1} m > cap {capM:F0} m: walls at the lake — LAKE-TERMINAL, kept");
}
else
{
fr.Terminus = Terminus.DrySink; fr.MouthCell = cur;
why.Append($" → #{basin} DRY{(node.HasAnyLake ? " (puddle only)" : "")}, rim {hop.ClimbM:F1} m > cap {capM:F0} m: walls at a dry sink — DROPPED");
}
break;
}
fr.TotalClimbM += hop.ClimbM;
int from = cur;
if (node.IsLake)
{
var reach = DescendToWater(cur, basin, plan, prep, height, n, isOcean, isClassifyWater, sea, out bool fb);
hop.UsedLowgroundFallback = fb;
if (reach.Count > 1) AppendReach(fr, reach, n);
int w = reach[^1];
hop.LakeEntryCell = w;
int lakeOut = prep.BodyOf.TryGetValue(w, out int body) && prep.BodyOut.TryGetValue(body, out int bo) ? bo : w;
if (lakeOut != w)
{
// The lake span — through the water of the body the river entered, to that body's outlet.
fr.WaterSpans.Add((w, lakeOut));
var span = WaterPath(prep, w, lakeOut, n);
for (int i = 1; i < span.Count; i++) fr.Course.Add((span[i] / n, span[i] % n));
}
fr.LakesPassed++;
from = lakeOut;
why.Append($" → #{basin} LAKE, rim {hop.ClimbM:F1} m ≤ cap: through the lake and over its spill");
}
else why.Append($" → #{basin} dry, rim {hop.ClimbM:F1} m ≤ cap: across the low ground and over its spill");
var path = Follow(from, basin, field, plan, isOcean, n, out int status, out int spill);
hop.SpillCell = spill;
if (path.Count > 1) AppendReach(fr, path, n);
int end = path[^1];
if (status == 1)
{
fr.Terminus = Terminus.Ocean; fr.MouthCell = end;
hop.EdgeAgreesWithGraph = node.Downstream == DownstreamKind.Ocean;
why.Append(" → the SEA — flow-through, kept");
break;
}
if (status == 0)
{
fr.Terminus = Terminus.Closed; fr.TerminusBasinId = 0; fr.MouthCell = end;
hop.EdgeAgreesWithGraph = node.Downstream == DownstreamKind.None;
why.Append(" → stuck on an exact flat outside any basin — CLOSED, dropped");
break;
}
int next = plan.BasinId[end];
hop.EdgeAgreesWithGraph = node.Downstream == DownstreamKind.Basin && node.DownstreamId == next;
cur = end; basin = next;
}
fr.Why = $"terminal basin #{fr.Chain[0].BasinId}" + why;
return fr;
}
/// <summary>
/// Follow the ∞ field from a cell inside <paramref name="basin"/> until it reaches the ocean (status 1),
/// enters another terminal basin (status 2), or sticks (status 0). <paramref name="spill"/> is the first
/// cell outside the basin on the way.
/// </summary>
private static List<int> Follow(int start, int basin, Field field, DrainageAnalysis.Plan plan, bool[] isOcean, int n,
out int status, out int spill)
{
var path = new List<int> { start };
int c = start; spill = -1; status = 0;
for (int guard = 0; guard < 8 * n; guard++)
{
int b = plan.BasinId[c];
if (b != basin && spill < 0) spill = c;
if (isOcean[c]) { status = 1; return path; }
if (b != basin && b != 0) { status = 2; return path; }
int t = field.Target(c);
if (t < 0) { status = 0; return path; }
c = t; path.Add(c);
}
return path;
}
/// <summary>
/// The real-terrain descent from a point inside a lake basin to the basin's own classify water:
/// <c>Plan.Dir</c> (D8 on <c>Filled</c>) until a water cell; if it pools short (the terminal is a D8
/// sink by definition), rivers/03c fix B's lowground route to the basin's own water — same rule, same cost model.
/// </summary>
private static List<int> DescendToWater(int start, int basin, DrainageAnalysis.Plan plan, Prep prep, float[,] height, int n,
bool[] isOcean, bool[] isClassifyWater, float sea, out bool usedFallback)
{
usedFallback = false;
var path = new List<int> { start };
int c = start;
bool IsOwnWater(int i) => isClassifyWater[i] && !isOcean[i] && plan.BasinId[i] == basin;
for (int guard = 0; guard < 8 * n; guard++)
{
if (IsOwnWater(c)) return path;
sbyte d = plan.Dir[c];
if (d < 0) break;
int cx = c / n, cy = c % n;
int t = (cx + DX[d]) * n + (cy + DY[d]);
if (plan.BasinId[t] != basin) break;
c = t; path.Add(c);
}
// Pooled short of the water — route the rest as rivers/03c does for a lake-ender.
if (!prep.LakeCells.TryGetValue(basin, out var cells) || cells.Count == 0) return path;
usedFallback = true;
foreach (int i in cells) prep.Scratch[i] = true;
var route = RiverRouting.RouteTo(height, n, prep.Scratch, start / n, start % n, RiverRouting.StyleLowground, sea);
foreach (int i in cells) prep.Scratch[i] = false;
if (!route.Reached) return path;
var outp = new List<int>(route.Path.Count);
foreach (var p in route.Path) outp.Add((int)p.x * n + (int)p.y);
return outp;
}
/// <summary>Append a 1-px cell reach to the course, RDP+Chaikin-smoothed as rivers/03 smooths every lowland reach (endpoints pinned).</summary>
private static void AppendReach(FlowRiver fr, List<int> cells, int n)
{
var pts = new List<(float x, float y)>(cells.Count);
foreach (int i in cells) pts.Add((i / n, i % n));
fr.LowlandLenPx += PolyLen(pts);
var sm = RiverRouting.SmoothCourse(pts);
int start = fr.Course.Count > 0 && fr.Course[^1].x == sm[0].x && fr.Course[^1].y == sm[0].y ? 1 : 0;
for (int i = start; i < sm.Count; i++) fr.Course.Add(sm[i]);
}
private static float PolyLen(List<(float x, float y)> pts)
{
float L = 0f;
for (int i = 1; i < pts.Count; i++)
{
float dx = pts[i].x - pts[i - 1].x, dy = pts[i].y - pts[i - 1].y;
L += MathF.Sqrt(dx * dx + dy * dy);
}
return L;
}
// ═══ THE FIELD AT THE CAP — the artifact ════════════════════════════════════════════════
/// <summary>
/// The ∞ field with every WALLED basin's cells replaced by D8 on the real terrain (<c>Filled</c>), so
/// flow that reaches a walled basin descends to its floor and ends there — "a cell whose downstream
/// chain walls off drains to that wall, not past it". Then every land cell's destination, memoised.
/// </summary>
public static void BuildCappedField(Result r, DrainageAnalysis.Plan plan, BasinGraph graph, Field field, int n, bool[] isOcean)
{
int total = n * n;
var dir = (sbyte[])field.Dir.Clone();
float[] filled = plan.Filled;
for (int i = 0; i < total; i++)
{
int id = plan.BasinId[i];
if (id == 0 || !r.WalledIds.Contains(id) || isOcean[i]) continue;
int cx = i / n, cy = i % n;
float best = 0f; int bestK = -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;
float drop = (filled[i] - filled[nx * n + ny]) / DIST[k];
if (drop > best) { best = drop; bestK = k; }
}
dir[i] = bestK < 0 ? D_NONE : (sbyte)bestK;
}
r.CappedDir = dir;
// Accumulation on the capped field — Kahn propagation, as the analysis does on its own field.
{
var acc = new int[total];
var indeg = new byte[total];
int Tgt(int i) { sbyte d = dir[i]; if (d < 0) return -1; int cx = i / n, cy = i % n; return (cx + DX[d]) * n + (cy + DY[d]); }
for (int i = 0; i < total; i++) if (!isOcean[i] && dir[i] >= 0) { int t = Tgt(i); if (!isOcean[t]) indeg[t]++; }
var q = new Queue<int>();
for (int i = 0; i < total; i++) { if (isOcean[i]) continue; acc[i] = 1; if (indeg[i] == 0) q.Enqueue(i); }
while (q.Count > 0)
{
int c = q.Dequeue();
int t = Tgt(c);
if (t < 0 || isOcean[t]) continue;
acc[t] += acc[c];
if (--indeg[t] == 0) q.Enqueue(t);
}
r.CappedAcc = acc;
}
// Destinations: -1 sea, >0 basin id (a sink), -2 stuck.
var lakeIds = new HashSet<int>();
foreach (var b in graph.Nodes) if (b.IsLake) lakeIds.Add(b.Id);
var dest = new int[total];
var path = new List<int>(4096);
for (int i = 0; i < total; i++)
{
if (isOcean[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);
sbyte d = dir[c];
if (d < 0) { result = plan.BasinId[c] != 0 ? plan.BasinId[c] : -2; break; }
int cx = c / n, cy = c % n;
int t = (cx + DX[d]) * n + (cy + DY[d]);
if (isOcean[t]) { result = -1; break; }
c = t;
}
foreach (int pc in path) dest[pc] = result;
}
for (int i = 0; i < total; i++)
{
if (isOcean[i]) continue;
r.LandCells++;
int d = dest[i];
if (d == -1) r.CellsToSea++;
else if (d > 0) { if (lakeIds.Contains(d)) r.CellsToWalledLake++; else r.CellsToWalledDry++; }
else r.CellsStuck++;
}
}
// ═══ THE HERO-LAKE CANDIDATE — data, not a fill ═════════════════════════════════════════
/// <summary>
/// Among lakes that a sea-reaching, un-joined river flows THROUGH, rank by the geometric mean of
/// normalised fill volume and normalised attached river length. Recorded intent (procedural, executed
/// post-water-render); nothing is filled here.
/// </summary>
public static void RankHeroLakes(Result r, BasinGraph graph, Prep prep)
{
var cand = new Dictionary<int, HeroLake>();
foreach (var fr in r.Rivers)
{
if (fr.Dropped || fr.Trunk || fr.Routed.Joined || fr.Terminus != Terminus.Ocean) continue;
foreach (var h in fr.Chain)
{
if (!h.IsLake || h.Walled) continue;
if (!cand.TryGetValue(h.BasinId, out var hl))
{
var node = graph.Of(h.BasinId);
hl = new HeroLake { BasinId = h.BasinId, LakeCells = node.LakeCells, FillVolumeMPx = prep.FillVolumeMPx.TryGetValue(h.BasinId, out double v) ? v : 0 };
cand[h.BasinId] = hl;
}
hl.RiversThrough++;
if (fr.TotalLenPx > hl.RiverLenPx) { hl.RiverLenPx = fr.TotalLenPx; hl.RiverRank = fr.Candidate.Rank; }
}
}
double maxV = 1, maxL = 1;
foreach (var hl in cand.Values) { if (hl.FillVolumeMPx > maxV) maxV = hl.FillVolumeMPx; if (hl.RiverLenPx > maxL) maxL = hl.RiverLenPx; }
foreach (var hl in cand.Values) hl.Score = Math.Sqrt((hl.FillVolumeMPx / maxV) * (hl.RiverLenPx / maxL));
r.HeroLakes = new List<HeroLake>(cand.Values);
r.HeroLakes.Sort((a, b) => b.Score != a.Score ? b.Score.CompareTo(a.Score) : a.BasinId.CompareTo(b.BasinId));
}
public static string TerminusName(Terminus t) => t switch
{
Terminus.Ocean => "sea", Terminus.Lake => "lake", Terminus.DrySink => "dry-sink", _ => "closed",
};
public static string ClassName(FlowRiver fr) =>
fr.Dropped ? "dropped" : fr.Trunk ? "trunk" : fr.RootTerminus == Terminus.Ocean ? "flow-through" : "lake-terminal";
}
}

View file

@ -0,0 +1,582 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ FLOW-THROUGH ROUTING (rivers/05) — the model rework, the hydrology map, and the taste gate.
///
/// river → lake → over the spill → river → … → sea. The promoted set chains through the basin graph
/// (rivers/04) on the terrain's own overflow structure, cap-gated at every rim, kept if it reaches the
/// sea or a real lake, dropped if it dead-ends dry. Output: the courses, a per-cell flow-direction
/// field, and the hydrology map — a first-class reference artifact (→ D-056).
///
/// ═══ ⛔ THE RED LINE ═══
///
/// **Routing and data only. No render or classify height written, no bed carved, no water body created
/// or filled.** Both height fields are digested before the graph is built and after the last plate is
/// drawn; any change refuses the run (exit 2).
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/FlowThroughTool.tscn
///
/// ISLA_TASK / ISLA_TASK_SUFFIX / ISLA_BATCH / ISLA_CHAT / ISLA_MAPSIZE / ISLA_CALIB_SIZE / ISLA_SEEDS / ISLA_SKIP_RAW
/// ISLA_FLOW_CAP_M the rim cap on floor→spill climb (default 30) — the connected-vs-inland knob
/// ISLA_CAP_PREVIEW_M caps to re-run the walks at for the sensitivity table (default "15,30,60")
/// ISLA_LAKE_MIN_PX the lake significance floor (default 20000)
/// ISLA_PROMOTE_N / ISLA_PROMOTE_FLOOR_PX / ISLA_PROMOTE_MAX as rivers/03 (12 / 5000 / 24)
/// </summary>
public partial class FlowThroughTool : Node
{
private static readonly int[] DefaultSeeds = { 1063685222, 999999937, 31415926, 14142135 };
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 SeedResult
{
public int Seed;
public FlowThroughRouting.Result Main;
public Dictionary<float, FlowThroughRouting.Result> ByCap = new();
public BasinGraph Graph;
public int DistinctMouths; public List<string> SharedMouths = new(); public string Spread = "";
public ulong RenderDigest, ClassifyDigest;
public int CandidateCount, SeaCandidates;
public double WalkSeconds, FieldSeconds, RenderSeconds; public ulong Ms;
public float GMin, GMax;
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers"));
int task = EnvInt("ISLA_TASK", 5);
string taskSfx = EnvStr("ISLA_TASK_SUFFIX", "");
string descr = EnvStr("ISLA_BATCH", "flow_through_routing");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
long floorPx = EnvInt("ISLA_PROMOTE_FLOOR_PX", 5000);
int promoteN = EnvInt("ISLA_PROMOTE_N", 12);
int promoteMax = EnvInt("ISLA_PROMOTE_MAX", 24);
int lakeMinPx = EnvInt("ISLA_LAKE_MIN_PX", RiverRouting.LakeMinTargetPx);
float capM = EnvFloat("ISLA_FLOW_CAP_M", 30f);
float[] caps = EnvFloats("ISLA_CAP_PREVIEW_M", new[] { 15f, 30f, 60f });
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "1") == "1";
if (Array.IndexOf(caps, capM) < 0) { var l = new List<float>(caps) { capM }; l.Sort(); caps = l.ToArray(); }
if (promoteMax < promoteN)
throw new InvalidOperationException($"ISLA_PROMOTE_MAX ({promoteMax}) is below the promoted count ({promoteN}).");
TerrainShapeV1.Assert("FlowThrough");
TerrainShapeV1.AssertErosionDefaultOn("FlowThrough");
string batchRoot = ToolingPaths.BatchRoot(task, taskSfx, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
var dp = new DrainageAnalysis.Params
{
SeaLevel = sea, TrunkCount = promoteMax, GiantCount = promoteMax,
EndorheicMaxCount = promoteMax, EndorheicMinInflowPx = (int)floorPx,
};
var dpDefaults = new DrainageAnalysis.Params();
GD.Print("==================================================================");
GD.Print(" FLOW-THROUGH ROUTING (rivers/05) — river → lake → over the spill → river → … → sea. THE HYDROLOGY MAP.");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default");
GD.Print($"seeds : {seeds.Length} — {string.Join(", ", seeds)}");
GD.Print($"promoted : PURE top {promoteN} by drainage (unified ranking, rivers/02). No quota.");
GD.Print($"model : follow the D8 field on FullFilled from each terminal; at every basin entered, floor→spill climb (SpillClimbM) ≤ cap → overflow, > cap → walled.");
GD.Print($" reaches OceanMask → KEEP; walls at IsLake (≥ {lakeMinPx:N0} px, classify) → KEEP (lake-terminal); walls dry or puddle-only → DROP. Read through the confluence root.");
GD.Print($"cap : ISLA_FLOW_CAP_M = {capM:F0} m (floor→spill, clamped at sea as RouteTo) — sensitivity at {string.Join(" / ", Array.ConvertAll(caps, c => c.ToString("F0")))} m");
GD.Print($"lakes : entered on the real terrain (Plan.Dir; rivers/03c fix B lowground fallback), left at the lake's lowest FullFilled cell over the spill. The in-lake span is water, not channel.");
GD.Print($"confluence: rivers/03b's, unchanged — biggest-first, true cell intersection.");
GD.Print($"field : per land cell, the FullFilled D8 heading; walled basins' cells re-pointed onto the real terrain so flow entering one ends there. Emitted as a map; serialization DEFERRED.");
GD.Print($"⛔ RED LINE : routing + data only — no height mutated, no water filled, no bed carved. ASSERTED per seed.");
GD.Print($"batch : {batchRoot}");
GD.Print("==================================================================");
if (mapSize != 8192)
GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the basin gates are absolute pixel counts tuned at 8192; a smaller map under-produces basins. PLUMBING only.");
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, family-off pinned) ---");
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
TerrainGenConfig Cfg(int size, int seed) => new TerrainGenConfig
{
MapSize = size, Seed = seed, VariantLabel = "flow",
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
};
var results = new List<SeedResult>();
foreach (int seed in seeds)
{
ulong t0 = Time.GetTicksMsec();
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;
bool[] isOcean = RegionLabeling.OceanMask(p2.HeightClassify, mapSize, sea, out long oceanCells, out long enclosed);
var isClassifyWater = new bool[mapSize * mapSize];
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;
var plan = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp);
GD.Print($" land {plan.LandCells:N0} — sea-reaching {100.0 * plan.SeaReachingCells / Math.Max(1, plan.LandCells):F1} %, endorheic {100.0 * plan.EndorheicCells / Math.Max(1, plan.LandCells):F1} %; terminal basins {plan.TerminalBasinCount}");
var e = RiverCandidates.Enumerate(plan, p2.Height, mapSize, floorPx, dpDefaults.MinOutletSeparationPx, p1.Regions);
var promoted = e.Ranked.GetRange(0, Math.Min(promoteN, e.Ranked.Count));
RiverCandidates.BindCourses(plan, mapSize, promoted, $"the pure top {promoteN}");
int pSea = 0; foreach (var c in promoted) if (c.IsSea) pSea++;
GD.Print($" promoted: pure top {promoted.Count} — {pSea} sea / {promoted.Count - pSea} endorheic (from {e.Ranked.Count} candidates)");
bool[] significant = RegionLabeling.SignificantWaterMask(isClassifyWater, isOcean, mapSize, lakeMinPx,
out int keptBodies, out int totalBodies, out long keptCells, out long largestPx);
// ═══ ⛔ RED-LINE GUARD — digest both fields BEFORE the graph, the field, the walks and the plates ═══
ulong hRenderBefore = Digest(p2.Height, mapSize);
ulong hClassifyBefore = Digest(p2.HeightClassify, mapSize);
var graph = BasinGraph.Build(plan, p2.Height, mapSize, isOcean, isClassifyWater, significant, sea, lakeMinPx);
GD.Print($" basin graph: {graph.LandNodes.Count} land basins ({graph.LakeBasins} lake / {graph.DryBasins} dry), {graph.Seabed} seabed pits excluded; spill invariants {graph.SpillCrossCheckFailures}/{graph.SpillNotOnTerrain}/{graph.DirWalkDisagreements}");
ulong tf0 = Time.GetTicksMsec();
var field = FlowThroughRouting.BuildField(plan, mapSize, isOcean);
var prep = FlowThroughRouting.Prepare(plan, graph, p2.Height, mapSize, isOcean, isClassifyWater);
double fieldSec = (Time.GetTicksMsec() - tf0) / 1000.0;
var r = new SeedResult { Seed = seed, Graph = graph, CandidateCount = e.Ranked.Count, SeaCandidates = e.SeaCount, FieldSeconds = fieldSec };
ulong tw0 = Time.GetTicksMsec();
foreach (float cap in caps)
{
bool main = cap == capM;
GD.Print($" walks at cap {cap:F0} m{(main ? " (THE CAP)" : " (sensitivity)")}:");
var res = FlowThroughRouting.Run(promoted, plan, graph, field, prep, p2.Height, mapSize, isOcean, isClassifyWater, sea, cap,
m => { if (main) GD.Print(m); }, confluence: true);
r.ByCap[cap] = res;
if (main) r.Main = res;
GD.Print($" → {res.Trunks} trunk + {res.FlowThrough} flow-through + {res.LakeTerminal} lake-terminal = {res.Trunks + res.FlowThrough + res.LakeTerminal} kept; " +
$"{res.DroppedDry + res.DroppedClosed} dropped ({res.DroppedDry} dry, {res.DroppedClosed} closed); {res.Joined} joined; walled basins {res.WalledIds.Count}");
}
r.WalkSeconds = (Time.GetTicksMsec() - tw0) / 1000.0;
var mainRes = r.Main;
foreach (var fr in mainRes.Rivers)
GD.Print($" #{fr.Candidate.Rank,-3} {fr.Candidate.DrainagePx,10:N0} px {FlowThroughRouting.ClassName(fr).ToUpperInvariant(),-13} " +
$"hops {fr.Chain.Count} lakes {fr.LakesPassed} max rim {fr.MaxHopClimbM,5:F1} m lowland {fr.LowlandLenPx,6:F0} px" +
(fr.Routed.Joined ? $" → into #{fr.Routed.ConfluenceParentRank}" : "") + $" {fr.Why}");
FlowThroughRouting.BuildCappedField(mainRes, plan, graph, field, mapSize, isOcean);
FlowThroughRouting.RankHeroLakes(mainRes, graph, prep);
MeasureMouths(r, mainRes);
r.Spread = Spread(mainRes, mapSize);
GD.Print($" ⭐ HYDROLOGY at cap {capM:F0} m: {mainRes.Trunks} trunk + {mainRes.FlowThrough} flow-through + {mainRes.LakeTerminal} lake-terminal kept, {mainRes.DroppedDry + mainRes.DroppedClosed} dropped, {mainRes.Joined} joined" +
$" — {r.DistinctMouths} distinct sea mouths{(r.SharedMouths.Count > 0 ? $" shared: {string.Join(", ", r.SharedMouths)}" : "")}; spread {r.Spread}");
GD.Print($" confluence: {mainRes.RescuedByConfluence} would-be-dropped river(s) rescued by joining a kept river; {mainRes.DroppedByConfluence} kept-on-its-own river(s) dropped by joining a dropped one");
GD.Print($" field: {100.0 * mainRes.CellsToSea / Math.Max(1, mainRes.LandCells):F1} % of land drains to the sea, {100.0 * mainRes.CellsToWalledLake / Math.Max(1, mainRes.LandCells):F1} % to a walled lake, " +
$"{100.0 * mainRes.CellsToWalledDry / Math.Max(1, mainRes.LandCells):F1} % to a walled dry sink, {100.0 * mainRes.CellsStuck / Math.Max(1, mainRes.LandCells):F2} % stuck; {mainRes.WalledIds.Count} walled basins");
GD.Print($" edge cross-check vs BasinGraph: {mainRes.EdgeAgree} agree / {mainRes.EdgeDisagree} disagree; lowground fallbacks into lakes {mainRes.LowgroundFallbacks}");
if (mainRes.HeroLakes.Count > 0)
{
var h = mainRes.HeroLakes[0];
GD.Print($" ⭐ HERO-LAKE CANDIDATE (data, NOT filled): basin #{h.BasinId} — lake {h.LakeCells:N0} px, fill volume {h.FillVolumeMPx / 1e6:F2} M m·px, river R{h.RiverRank} {h.RiverLenPx:F0} px, {h.RiversThrough} river(s) through; score {h.Score:F3}");
}
else GD.Print(" hero-lake candidate: none — no sea-reaching river passes through a lake on this seed");
WriteRiverCsv(batchRoot, r, caps);
ulong tr0 = Time.GetTicksMsec();
RenderSeed(batchRoot, r, plan, isOcean, isClassifyWater, p2, mapSize, sea, capM, skipRaw);
r.RenderSeconds = (Time.GetTicksMsec() - tr0) / 1000.0;
// ═══ ⛔ …and asserted byte-identical AFTER everything ═══
ulong hRenderAfter = Digest(p2.Height, mapSize);
ulong hClassifyAfter = Digest(p2.HeightClassify, mapSize);
if (hRenderAfter != hRenderBefore || hClassifyAfter != hClassifyBefore)
throw new InvalidOperationException(
"[FlowThrough] RED-LINE VIOLATION: a height field CHANGED across routing / rendering.\n" +
$" render {hRenderBefore:X16} -> {hRenderAfter:X16}\n" +
$" classify {hClassifyBefore:X16} -> {hClassifyAfter:X16}\n" +
"This task routes and draws — it must never mutate a height, fill water, or carve. Refusing to continue.");
r.RenderDigest = hRenderBefore; r.ClassifyDigest = hClassifyBefore;
GD.Print($" ✅ RED LINE HELD: render {hRenderBefore:X16} and classify {hClassifyBefore:X16} byte-identical — no height mutated, no water filled, no bed carved.");
r.Ms = Time.GetTicksMsec() - t0;
results.Add(r);
}
WriteIndex(batchRoot, mapSize, seeds, results, promoteN, lakeMinPx, capM, caps, dpDefaults, skipRaw);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print(" ⛔ TASTE GATE: the hydrology is PRESENTED, not locked. The cap is a knob; nothing graduated.");
GD.Print(" ⛔ ROUTING + DATA ONLY: no height mutated, no water filled, no bed carved — asserted per seed. Flow field emitted as a map; serialization deferred.");
GD.Print("==================================================================");
GetTree().Quit(0);
}
private static ulong Digest(float[,] f, int n)
{
ulong h = 14695981039346656037UL;
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
uint bits = (uint)BitConverter.SingleToInt32Bits(f[x, y]);
for (int b = 0; b < 4; b++) { h ^= (byte)(bits >> (b * 8)); h *= 1099511628211UL; }
}
return h;
}
private static void MeasureMouths(SeedResult r, FlowThroughRouting.Result res)
{
var at = new Dictionary<int, List<int>>();
foreach (var fr in res.Rivers)
{
if (fr.Dropped || fr.Routed.Joined || fr.Terminus != FlowThroughRouting.Terminus.Ocean || fr.MouthCell < 0) continue;
if (!at.TryGetValue(fr.MouthCell, out var l)) { l = new List<int>(); at[fr.MouthCell] = l; }
l.Add(fr.Candidate.Rank);
}
r.DistinctMouths = at.Count;
foreach (var kv in at) if (kv.Value.Count > 1) r.SharedMouths.Add("#" + string.Join("+#", kv.Value));
}
private static string Spread(FlowThroughRouting.Result res, int n)
{
var counts = new Dictionary<string, int>(); int total = 0;
foreach (var fr in res.Rivers)
{
if (fr.Dropped || fr.Routed.Joined || fr.Terminus != FlowThroughRouting.Terminus.Ocean || fr.MouthCell < 0) continue;
string c = Compass(fr.MouthCell / n, fr.MouthCell % n, n);
counts.TryGetValue(c, out int cur); counts[c] = cur + 1; total++;
}
if (total == 0) return "none";
var order = new[] { "N", "NE", "E", "SE", "S", "SW", "W", "NW", "centre" };
var parts = new List<string>();
foreach (string k in order) if (counts.TryGetValue(k, out int v)) parts.Add($"{k}x{v}");
return $"{string.Join(" ", parts)} ({counts.Count} of 8 compass sectors)";
}
private static string Compass(float x, float y, int n)
{
float half = n / 2f, dx = (x - half) / half, dy = (y - half) / half;
const float band = 0.35f;
string ns = dy < -band ? "N" : dy > band ? "S" : "";
string ew = dx < -band ? "W" : dx > band ? "E" : "";
return ns + ew == "" ? "centre" : ns + ew;
}
private static string ChainText(FlowThroughRouting.FlowRiver fr)
{
var parts = new List<string>();
foreach (var h in fr.Chain)
parts.Add($"#{h.BasinId}{(h.IsLake ? "L" : "d")}:{h.ClimbM:F1}{(h.Walled ? "!" : "")}");
return string.Join(" > ", parts) + (fr.Terminus == FlowThroughRouting.Terminus.Ocean ? " > SEA" : fr.Terminus == FlowThroughRouting.Terminus.Closed ? " > closed" : "");
}
private static void WriteRiverCsv(string batchRoot, SeedResult r, float[] caps)
{
var res = r.Main; int n = r.Graph.MapSize;
var sb = new StringBuilder();
sb.Append("rank,class,own_terminus,root_terminus,drainage_px,is_sea_candidate,terminal_basin,hops,lakes_passed,chain,max_hop_climb_m,total_climb_m," +
"terminus_basin,mouth_x,mouth_y,joined,confluence_parent_rank,junction_x,junction_y,stem_len_px,lowland_len_px,total_len_px,lowground_fallbacks,edge_disagreements,width_px,why");
foreach (float cap in caps) sb.Append($",class_at_cap{cap:F0}");
sb.AppendLine();
foreach (var fr in res.Rivers)
{
var c = fr.Candidate; var rr = fr.Routed;
int fb = 0, dis = 0; foreach (var h in fr.Chain) { if (h.UsedLowgroundFallback) fb++; if (h.SpillCell >= 0 && !h.EdgeAgreesWithGraph) dis++; }
sb.Append($"{c.Rank},{FlowThroughRouting.ClassName(fr)},{FlowThroughRouting.TerminusName(fr.Terminus)},{FlowThroughRouting.TerminusName(fr.RootTerminus)},{c.DrainagePx},{(c.IsSea ? "yes" : "no")}," +
$"{(c.IsSea ? 0 : (fr.Chain.Count > 0 ? fr.Chain[0].BasinId : c.BasinId))},{fr.Chain.Count},{fr.LakesPassed},\"{ChainText(fr)}\",{fr.MaxHopClimbM:F2},{fr.TotalClimbM:F2}," +
$"{fr.TerminusBasinId},{(fr.MouthCell >= 0 ? (fr.MouthCell / n).ToString() : "")},{(fr.MouthCell >= 0 ? (fr.MouthCell % n).ToString() : "")}," +
$"{(rr.Joined ? "yes" : "no")},{(rr.Joined ? rr.ConfluenceParentRank.ToString() : "")},{(rr.Joined ? rr.JunctionCell.x.ToString() : "")},{(rr.Joined ? rr.JunctionCell.y.ToString() : "")}," +
$"{fr.StemLenPx:F1},{fr.LowlandLenPx:F1},{fr.TotalLenPx:F1},{fb},{dis},{DrainageRenderer.StemWidthFixed(c.DrainagePx)},\"{fr.Why}\"");
foreach (float cap in caps)
{
var other = r.ByCap[cap];
FlowThroughRouting.FlowRiver o = null;
foreach (var x in other.Rivers) if (x.Candidate.Rank == c.Rank) { o = x; break; }
sb.Append($",{(o == null ? "" : FlowThroughRouting.ClassName(o))}");
}
sb.AppendLine();
}
WriteText(Path.Combine(batchRoot, $"rivers_{r.Seed}.csv"), sb.ToString());
// The hero-lake ranking, as data.
var hb = new StringBuilder();
hb.AppendLine("rank,basin_id,lake_cells,fill_volume_m_px,river_rank,river_len_px,rivers_through,score");
for (int i = 0; i < res.HeroLakes.Count; i++)
{
var h = res.HeroLakes[i];
hb.AppendLine($"{i + 1},{h.BasinId},{h.LakeCells},{h.FillVolumeMPx:F0},{h.RiverRank},{h.RiverLenPx:F0},{h.RiversThrough},{h.Score:F4}");
}
WriteText(Path.Combine(batchRoot, $"hero_lakes_{r.Seed}.csv"), hb.ToString());
}
private static void RenderSeed(string batchRoot, SeedResult r, DrainageAnalysis.Plan plan, bool[] isOcean, bool[] isClassifyWater,
Pass2Result p2, int n, float sea, float capM, bool skipRaw)
{
string dir = Path.Combine(batchRoot, $"{r.Seed}");
DirAccess.MakeDirRecursiveAbsolute(dir);
var res = r.Main;
int kept = res.Trunks + res.FlowThrough + res.LakeTerminal;
Image baseImg = HydrologyRenderer.Base(p2.Height, n, sea);
HydrologyRenderer.Hydrology(res, res.CappedDir, plan, r.Graph, baseImg, n, isOcean, isClassifyWater,
$"SEED {r.Seed} - HYDROLOGY: {kept} RIVERS OF THE PURE TOP {res.Rivers.Count}, CHAINING THROUGH LAKES AND LOW GROUND TO THE SEA. CAP {capM:F0} M.",
$"{res.Trunks} NATURAL TRUNK + {res.FlowThrough} FLOW-THROUGH TO THE SEA + {res.LakeTerminal} LAKE-TERMINAL; {res.DroppedDry + res.DroppedClosed} DROPPED; {res.Joined} JOINED. {r.DistinctMouths} DISTINCT SEA MOUTHS. SPREAD: {r.Spread.ToUpperInvariant()}",
$"THE FIELD: {100.0 * res.CellsToSea / Math.Max(1, res.LandCells):F0}% OF LAND DRAINS TO THE SEA, {100.0 * res.CellsToWalledLake / Math.Max(1, res.LandCells):F0}% TO A WALLED LAKE, {100.0 * res.CellsToWalledDry / Math.Max(1, res.LandCells):F0}% TO A WALLED DRY SINK ({res.WalledIds.Count} WALLED BASINS). TASTE GATE - NOTHING LOCKED")
.SavePng(Path.Combine(dir, $"hydrology_{r.Seed}.png"));
HydrologyRenderer.FlowDirection(res.CappedDir, res.CappedAcc, plan, r.Graph, res.WalledIds, n, isOcean, isClassifyWater,
$"SEED {r.Seed} - FLOW DIRECTION FIELD AT CAP {capM:F0} M: PER LAND CELL, THE D8 HEADING ON THE OVERFLOW SURFACE (FULLFILLED), CHAINED OVER SPILLS TOWARD THE SEA",
$"{100.0 * res.CellsToSea / Math.Max(1, res.LandCells):F1}% OF LAND DRAINS TO THE SEA; {100.0 * res.CellsToWalledLake / Math.Max(1, res.LandCells):F1}% ENDS IN A WALLED LAKE; {100.0 * res.CellsToWalledDry / Math.Max(1, res.LandCells):F1}% IN A WALLED DRY SINK; {100.0 * res.CellsStuck / Math.Max(1, res.LandCells):F2}% STUCK. {res.WalledIds.Count} WALLED BASINS (RIM > CAP).",
"DATA MAP - THE REFERENCE FOR PLACEMENT, FLOODING (C3) AND IRRIGATION (C4). HELD IN MEMORY; SERIALIZATION DEFERRED TO THE COLUMN WATER-DATA PHASE.")
.SavePng(Path.Combine(dir, $"flow_direction_{r.Seed}.png"));
var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
r.GMin = gmin; r.GMax = gmax;
GD.Print($" grayscale: render field range {gmin:F4} .. {gmax:F4} raw = {WorldScale.MetresFromRaw(gmin):F1} .. {WorldScale.MetresFromRaw(gmax):F1} m");
if (!skipRaw) HeightField.Save(p2.Height, n, Path.Combine(dir, "height.f32"));
}
private static void WriteIndex(string batchRoot, int mapSize, int[] seeds, List<SeedResult> rows, int promoteN, int lakeMinPx,
float capM, float[] caps, DrainageAnalysis.Params def, bool skipRaw)
{
var sb = new StringBuilder();
int primary = seeds.Length > 0 ? seeds[0] : 0;
sb.AppendLine($"# Batch 05 — flow-through routing: the hydrology map (cap {capM:F0} m)");
sb.AppendLine();
sb.AppendLine("**⛔ TASTE GATE. Nothing is locked** — the cap is a knob, the count falls out, nothing is graduated. This is the");
sb.AppendLine("routing finale: if the hydrology map reads right, routing is done and the next step is the bed carve.");
sb.AppendLine();
sb.AppendLine("**⛔ ROUTING AND DATA ONLY. No height mutated, no water filled or created, no bed carved** — asserted per seed by an");
sb.AppendLine("FNV digest of both height fields taken before the basin graph was built and after the last plate was drawn.");
sb.AppendLine("`DrainageAnalysis` and `BasinGraph` reused. The flow-direction field is emitted as a map and held in memory; its");
sb.AppendLine("serialization is deferred to the blueprint / column water-data phase.");
sb.AppendLine();
sb.AppendLine("## 👉 The pick");
sb.AppendLine();
sb.AppendLine($"Open **`{primary}/hydrology_{primary}.png`**. Then the other three: " +
string.Join(", ", Array.ConvertAll(Array.FindAll(seeds, x => x != primary), x => $"`{x}`")) + ".");
sb.AppendLine();
sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED");
sb.AppendLine("> **Does the island's water now read as one connected, natural, gorgeous system — rivers chaining through lakes");
sb.AppendLine("> and low ground to the sea, dead-ends dropped, the whole network legible?** Then the count, the spread, and");
sb.AppendLine($"> whether the cap (`ISLA_FLOW_CAP_M`, {capM:F0} m) wants moving — the sensitivity table below says how the counts move.");
sb.AppendLine(">");
sb.AppendLine("> **Reading the map.** Pale-blue rivers reach the sea (natural trunks and flow-through chains alike; square = mouth).");
sb.AppendLine("> Amber rivers are lake-terminal (disc = where the river enters its lake). A river's span across a lake is water,");
sb.AppendLine("> not a drawn channel. White dot = confluence. A faint red ghost is a river's upland stem that was considered and");
sb.AppendLine("> dropped — its chain walled at a dry sink. The streamline texture is the flow-direction field.");
sb.AppendLine();
sb.AppendLine("## ⭐⭐ The hydrology, per seed (at the cap)");
sb.AppendLine();
sb.AppendLine("| Seed | trunk | **flow-through → sea** | **lake-terminal** | **kept** | dropped (dry / closed) | joined | rescued by confluence | **distinct sea mouths** | spread | land → sea / walled lake / walled dry |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|");
foreach (var r in rows)
{
var m = r.Main;
sb.AppendLine($"| `{r.Seed}` | {m.Trunks} | **{m.FlowThrough}** | **{m.LakeTerminal}** | **{m.Trunks + m.FlowThrough + m.LakeTerminal}** of {m.Rivers.Count} | {m.DroppedDry} / {m.DroppedClosed} | {m.Joined} | {m.RescuedByConfluence} | **{r.DistinctMouths}**{(r.SharedMouths.Count > 0 ? $" {string.Join(", ", r.SharedMouths)}" : "")} | {r.Spread} | " +
$"{100.0 * m.CellsToSea / Math.Max(1, m.LandCells):F0} % / {100.0 * m.CellsToWalledLake / Math.Max(1, m.LandCells):F0} % / {100.0 * m.CellsToWalledDry / Math.Max(1, m.LandCells):F0} % |");
}
sb.AppendLine();
sb.AppendLine("## ⭐ The cap — how the counts move (the connected-vs-inland knob, off one run)");
sb.AppendLine();
sb.Append("| Seed |");
foreach (float cap in caps) sb.Append($" @ {cap:F0} m: kept (sea + lake) / dropped / joined |");
sb.AppendLine();
sb.AppendLine("|---|" + string.Concat(Array.ConvertAll(caps, _ => "---|")));
foreach (var r in rows)
{
sb.Append($"| `{r.Seed}` |");
foreach (float cap in caps)
{
var m = r.ByCap[cap];
sb.Append($" {(cap == capM ? "**" : "")}{m.Trunks + m.FlowThrough + m.LakeTerminal} ({m.Trunks + m.FlowThrough} + {m.LakeTerminal}) / {m.DroppedDry + m.DroppedClosed} / {m.Joined}{(cap == capM ? "**" : "")} |");
}
sb.AppendLine();
}
sb.AppendLine();
sb.AppendLine("*A higher cap lets rivers overflow deeper basins: more reach the sea, fewer end at lakes or drop. The per-river");
sb.AppendLine("class at every cap is in `rivers_<seed>.csv` (`class_at_capNN` columns), so the flip points are readable per river.*");
sb.AppendLine();
sb.AppendLine("## ⭐ The hero-lake candidate — DATA, not filled");
sb.AppendLine();
sb.AppendLine("Among lakes a sea-reaching, un-joined river flows *through*, ranked by √(fill volume × attached river length), each");
sb.AppendLine("normalised to the seed's maximum. Recorded intent: procedural, executed post-water-render. **Nothing is filled.**");
sb.AppendLine();
sb.AppendLine("| Seed | basin | lake px | fill volume (M m·px) | river | river length px | rivers through | score | runner-up |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
foreach (var r in rows)
{
var hl = r.Main.HeroLakes;
if (hl.Count == 0) { sb.AppendLine($"| `{r.Seed}` | — | | | | | | | no sea-reaching river passes through a lake |"); continue; }
var h = hl[0];
string ru = hl.Count > 1 ? $"#{hl[1].BasinId} ({hl[1].Score:F2})" : "—";
sb.AppendLine($"| `{r.Seed}` | **#{h.BasinId}** | {h.LakeCells:N0} | {h.FillVolumeMPx / 1e6:F2} | R{h.RiverRank} | {h.RiverLenPx:F0} | {h.RiversThrough} | {h.Score:F3} | {ru} |");
}
sb.AppendLine();
sb.AppendLine("## ⭐ Per river — the chain each one walked");
sb.AppendLine();
sb.AppendLine("Chain notation: `#id L|d : climb` per basin entered (L = lake basin, d = dry), `!` = walled there. The climb is the");
sb.AppendLine("basin's floor→spill (`SpillClimbM`, render surface, clamped at sea as `RouteTo`).");
sb.AppendLine();
foreach (var r in rows)
{
sb.AppendLine($"### `{r.Seed}`");
sb.AppendLine();
sb.AppendLine("| rank | class | drainage px | hops | lakes | max rim m | chain | terminus | joins | lowland px |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|");
foreach (var fr in r.Main.Rivers)
{
var c = fr.Candidate; int n = r.Graph.MapSize;
string cls = fr.Dropped ? "~~dropped~~" : fr.Trunk ? "trunk" : fr.ReachesSea ? "**flow-through**" : "**lake-terminal**";
string term = fr.Routed.Joined ? $"→ tributary of #{fr.Routed.ConfluenceParentRank}"
: fr.Terminus == FlowThroughRouting.Terminus.Ocean ? $"sea ({fr.MouthCell / n},{fr.MouthCell % n})"
: fr.Terminus == FlowThroughRouting.Terminus.Lake ? $"lake #{fr.TerminusBasinId}"
: fr.Terminus == FlowThroughRouting.Terminus.DrySink ? $"dry sink #{fr.TerminusBasinId} → dropped" : "closed → dropped";
sb.AppendLine($"| #{c.Rank} | {cls} | {c.DrainagePx:N0} | {fr.Chain.Count} | {fr.LakesPassed} | {(fr.Chain.Count > 0 ? fr.MaxHopClimbM.ToString("F1") : "")} | `{(c.IsSea ? "trunk" : ChainText(fr))}` | {term} | {(fr.Routed.Joined ? $"#{fr.Routed.ConfluenceParentRank}" : "")} | {fr.LowlandLenPx:F0} |");
}
sb.AppendLine();
sb.AppendLine($"*Candidates {r.CandidateCount} ({r.SeaCandidates} sea) · walled basins at the cap {r.Main.WalledIds.Count} · edge cross-check vs the basin graph {r.Main.EdgeAgree} agree / {r.Main.EdgeDisagree} disagree · " +
$"lowground fallbacks into lakes {r.Main.LowgroundFallbacks} · field {r.FieldSeconds:F1}s, walks ×{caps.Length} {r.WalkSeconds:F1}s, plates {r.RenderSeconds:F0}s, seed {r.Ms / 1000.0:F0}s · " +
$"digests render `{r.RenderDigest:X16}` classify `{r.ClassifyDigest:X16}` · grayscale {r.GMin:F4}..{r.GMax:F4} raw = {WorldScale.MetresFromRaw(r.GMin):F1}..{WorldScale.MetresFromRaw(r.GMax):F1} m.*");
sb.AppendLine();
}
sb.AppendLine("## The model, as run");
sb.AppendLine();
sb.AppendLine("1. **The field** — per land cell, the D8 heading on `Plan.FullFilled` (the overflow surface), cap-independent; on it a");
sb.AppendLine(" basin's minimum is its spill, so descent leaves every basin over its spill into the next (rivers/04 §0.2).");
sb.AppendLine("2. **The walk** — each promoted river follows the field from its terminal. Every basin entered is checked once:");
sb.AppendLine($" floor→spill climb ≤ {capM:F0} m → overflow; > {capM:F0} m → walled. Uniform for lake and dry basins.");
sb.AppendLine("3. **Lakes** — inside an `IsLake` basin the river runs down the REAL terrain (`Plan.Dir`) into the basin's own classify");
sb.AppendLine(" water (rivers/03c fix B's lowground route as the fallback when the descent pools short), crosses the lake as water,");
sb.AppendLine(" and leaves from the lake's lowest `FullFilled` cell over the spill. A dry basin is crossed on the field as a visible line.");
sb.AppendLine($"4. **Disposition** — reaches `OceanMask` → keep; walls at `IsLake` (≥ {lakeMinPx:N0} px, classify) → keep (lake-terminal);");
sb.AppendLine(" walls dry or puddle-only → drop. Read through the confluence root, so a river that joins a kept river is kept.");
sb.AppendLine("5. **Confluence** — rivers/03b's, unchanged: biggest-first, true cell intersection, never proximity.");
sb.AppendLine("6. **The field at the cap** — every walled basin's cells re-pointed onto the real terrain, so flow entering one ends at");
sb.AppendLine(" its floor. That is the `flow_direction_<seed>.png` plate and the streamline texture on the hydrology map.");
sb.AppendLine();
sb.AppendLine($"**⚠ NOT touched:** `DrainageAnalysis`, `BasinGraph`; `EndorheicMinDepthM` {def.EndorheicMinDepthM} m / `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0}; `MinOutletSeparationPx` {def.MinOutletSeparationPx}.");
sb.AppendLine("Termini by `OceanMask` and `IsLake` only — no bare `h < sea`.");
sb.AppendLine();
sb.AppendLine("## Files");
sb.AppendLine();
sb.AppendLine("| File | What it is |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `<seed>/hydrology_<seed>.png` | **the showpiece** — relief, lakes as water, the field as streamlines, the kept rivers outlined at the fixed width law, confluences, mouths, dropped ghosts |");
sb.AppendLine("| `<seed>/flow_direction_<seed>.png` | **the data map** — the field at the cap: hue by heading, sinks black, walled basins darkened |");
sb.AppendLine("| `<seed>/grayscale.png` | the eroded render field, no palette |");
sb.AppendLine("| `rivers_<seed>.csv` | per river: class, own vs root terminus, the chain with per-hop climbs, terminus, confluence, lengths, and the class at every preview cap |");
sb.AppendLine("| `hero_lakes_<seed>.csv` | the hero-lake ranking, as data |");
if (skipRaw) sb.AppendLine("| ~~`<seed>/height.f32`~~ | **deliberately not written** — byte-identical to `chat2/11_erosion` (rivers/01). |");
sb.AppendLine();
sb.AppendLine($"Ranges: sea level `{def.SeaLevel}` raw = `{WorldScale.MetresFromRaw(def.SeaLevel):F2} m`; {WorldScale.Describe()}.");
sb.AppendLine();
sb.AppendLine("→ `XX_Human/output/rivers/05_flow_through_routing.report.md`");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
// ---- the curve (the house pattern; pool pinned family-off per rivers/01) -------------------
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(TerrainGenConfig.CalibrationPool(calibSize, 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",
}.WithFamilyOff();
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]); }
return (knots, ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f));
}
// ---- env / io -----------------------------------------------------------------------------
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;
}
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;
}
}
}

View file

@ -22,7 +22,7 @@ namespace IslaApocalypse.Tools
/// 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
/// ISLA_SKIP_ANCHOR_CHECK=1 skip the 4096 interior-locked invariant check
/// </summary>
public partial class FragGalleryTool : Node
{
@ -35,17 +35,22 @@ namespace IslaApocalypse.Tools
/// <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)
// ═══ THE FROZEN frag_4 SETTING (chat2/09 batch, level 4) ═══
//
// ⭐ rivers/01: these were the ONLY home of the locked values. They now ALIAS
// `TerrainShapeV1`, which is itself the assertion target for `TerrainGenConfig`'s defaults —
// so the chain is: bare defaults → asserted against TerrainShapeV1 → printed here. One value,
// one place, and a throw if the generator ever stops agreeing with it.
private const float FrozenFragmentAmp = TerrainShapeV1.FragmentAmp;
private const float FrozenFragmentFreq = TerrainShapeV1.FragmentFreq;
private const float FrozenBandCentre = TerrainShapeV1.BandCentre;
private const float FrozenBandHalfWidth = TerrainShapeV1.BandHalfWidth;
private const bool FrozenBitesOnly = TerrainShapeV1.BitesOnly;
private const float FrozenStretch = TerrainShapeV1.Stretch;
private const float FrozenBandStart = TerrainShapeV1.BandStart;
private const float FrozenBandFeather = TerrainShapeV1.BandFeather;
private const bool FrozenStretchSinker = TerrainShapeV1.StretchSinker;
private const float FrozenSpeckFrac = TerrainShapeV1.SpeckFrac; // ≈ 4 cells at 4096, ≈ 17 at 8192
private const int DefaultMapSize = 8192;
private const int DefaultCalibSize = 2048;
@ -79,6 +84,10 @@ namespace IslaApocalypse.Tools
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
int task = EnvInt("ISLA_TASK", 10);
string descr = EnvStr("ISLA_BATCH", "frag4_seed_gallery");
@ -87,9 +96,7 @@ namespace IslaApocalypse.Tools
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");
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
var seeds = new List<int>(AnchorSeeds); if (seedsEnv == null) seeds.AddRange(FreshSeeds); else { seeds.Clear(); seeds.AddRange(seedsEnv); }
var anchorSet = new HashSet<int>(AnchorSeeds);
@ -118,17 +125,40 @@ namespace IslaApocalypse.Tools
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
// ⭐⭐ rivers/01: THE FROZEN SETTING IS NOW THE BARE DEFAULT.
//
// Every `Frozen*` constant above was re-homed into `TerrainGenConfig`'s defaults by the
// re-baseline, so this helper no longer SETS the shape — it only ABLATES it, for the
// family-off halves of the regression checks. That is the whole point: this batch is the
// `terrain-shape-v1` acceptance, and it can only prove the defaults reproduce the locked
// shape if it reads them instead of re-stating them.
//
// ⚠ The `frag` / `revert` / `stretch` flags are ABLATIONS ONLY. All three true = the bare
// default = the locked shape; `TerrainShapeV1.Assert` below is what keeps that claim
// honest if a default ever drifts.
TerrainGenConfig Frozen(int size, int seed, string label, bool frag = true, bool revert = true, bool stretch = true)
{
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,
};
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,
};
if (!stretch) c.SouthStretch = 0f;
if (!frag) c.FragmentAmp = 0f;
if (!revert) c.SpeckRevert = false;
// This batch is render-only shape: erosion is a later pass and never ran here.
c.Erosion = false;
return c;
}
// ⚠⚠ THE DEFAULT-DRIFT GUARD (rivers/01). The gallery above stopped STATING the locked shape
// and started READING it. If a default ever moves, every render silently moves with it and
// the batch still "passes" — so the claim is asserted, loudly, before a pixel is drawn.
// The `Frozen*` constants below are unchanged in value; their ROLE flipped from source to
// assertion target. → Tools/Scripts/TerrainShapeV1.cs
TerrainShapeV1.Assert("FragGallery");
GD.Print($" defaults : ✅ {TerrainShapeV1.Describe()}");
// ═══ 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 ---");
@ -137,25 +167,32 @@ namespace IslaApocalypse.Tools
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;
// ⭐ a1 KEPT at rivers/01 — the family-off pass-1 guard (config pinned family-off). ⚠ loud.
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));
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, stretch OFF, frag OFF == Phase-1 .f32 dump (the curve is untouched)",
Shaping.Shape(p1, curveOff).Height, ShapingOracle.LoadAnchor("a1", "ISLA_PHASE1_SOURCE", p1Dump, calibSize), calibSize, p1Dump));
// ⚑ RETIRED at rivers/01 — a8 (`08_southern_stretch_explore`) and a9 (`09_coastal_fragment`).
// Both are EXPLORATION ladders this gallery was built to CONCLUDE: chat2/09 climbed the
// fragmentation ladder, chat2/10 froze rung 4 across 8 seeds, and the developer tagged the
// result `terrain-shape-v1`. Since rivers/01 that frozen setting IS the bare default, and
// `TerrainShapeV1.Assert` + a10 assert it directly — asserting it a third time through the
// rungs it was chosen from is circular, and 08's dump is at stretch 3 (off-shape) besides.
// The dump is NOT deleted (file-safety; regenerable, and the record of what was judged);
// its `INDEX.md` is marked superseded. → XX_Human/output/rivers/01_*.report.md §A4.
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");
// ⭐ r KEPT and SELF-ANCHORED — the interior-locked invariant is coastal fragmentation's
// load-bearing claim (it must touch the coastal window and NOTHING else), and it needs
// no external dump: both fields are generated here, from the bare defaults and from the
// same defaults with frag ablated off. That is what let 08 and 09 retire intact.
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
@ -203,7 +240,7 @@ namespace IslaApocalypse.Tools
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);
WriteIndex(batchRoot, task, mapSize, calibSize, rows, big, speckCells, hard, perSeed, allOk);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
@ -262,7 +299,7 @@ namespace IslaApocalypse.Tools
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
@ -275,11 +312,14 @@ namespace IslaApocalypse.Tools
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
// ⭐ rivers/01: family-off PINNED, like the pool it shapes. (The family acts in pass 1 and
// `Shaping.Shape` never reads it, so this is inert today — pinned anyway so "the whole
// calibration is family-off" is a total claim rather than a field-by-field one.)
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
};
}.WithFamilyOff();
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);
@ -356,11 +396,11 @@ namespace IslaApocalypse.Tools
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,
private static void WriteIndex(string batchRoot, int task, 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($"# Batch {task:D2} — 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");
@ -370,7 +410,13 @@ namespace IslaApocalypse.Tools
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.");
"offshore OFF · shelf OFF · region labeling ON · the tagged curve (calibrated on task 01's pool at " + calibSize + ").");
sb.AppendLine();
sb.AppendLine("> ### ⭐ Since rivers/01, this setting IS the bare `TerrainGenConfig` default — it is not stated here, it is READ.");
sb.AppendLine("> That is what makes this batch the acceptance for the re-baseline rather than a restatement of it: if a default");
sb.AppendLine("> ever drifts, `TerrainShapeV1.Assert` refuses the run instead of rendering a gallery that would look right and");
sb.AppendLine("> mean nothing. The curve calibration pool is pinned FAMILY-OFF (`TerrainGenConfig.WithFamilyOff`), which is what");
sb.AppendLine("> keeps the knots — and therefore these renders — bit-identical across the flip.");
sb.AppendLine();
sb.AppendLine("## ⭐ The contact sheet");
sb.AppendLine();

View file

@ -0,0 +1,279 @@
using System;
using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ THE HYDROLOGY MAP (rivers/05) — a first-class reference map, not a diagnostic dump (→ D-056).
///
/// "Here is where the water lives and where it flows": the island's relief, its lakes as water, the
/// flow-direction field as a quiet streamline texture, and the flow-through rivers prominent —
/// chaining visibly through lakes and low ground to the sea, width ∝ √drainage on the fixed law,
/// sea-reaching and lake-terminal distinguished, confluences as merges, dropped rivers as a ghost of
/// their stem only. Presentation only — reads data, writes pixels.
///
/// The companion <see cref="FlowDirection"/> plate is the DATA map of the field on its own: hue by
/// heading, sinks black, walled basins darkened — the one placement / flooding / irrigation reference.
/// </summary>
public static class HydrologyRenderer
{
private static readonly Color RiverSea = new(0.860f, 0.960f, 1.000f);
private static readonly Color RiverLake = new(1.000f, 0.840f, 0.520f);
private static readonly Color RiverEdge = new(0.050f, 0.110f, 0.240f);
private static readonly Color LakeWater = new(0.300f, 0.560f, 0.940f);
private static readonly Color PondWater = new(0.330f, 0.540f, 0.860f);
private static readonly Color Ghost = new(0.620f, 0.220f, 0.200f);
private static readonly Color Junction = new(1.000f, 1.000f, 1.000f);
private static readonly Color Stream = new(0.980f, 0.990f, 1.000f);
private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
private static readonly Color Ocean = new(0.055f, 0.110f, 0.235f);
private static readonly Color Sink = new(0.020f, 0.020f, 0.020f);
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>TinyFont carries only <c>. - : / ( ) 0-9 A-Z</c>; everything else would print as a gap. Map the punctuation the plate text uses.</summary>
public static string Txt(string s) => s
.Replace("%", " PCT").Replace("+", " AND ").Replace(";", " -").Replace(",", " -").Replace("'", "")
.Replace("→", "-").Replace("≤", "UNDER").Replace(">", "OVER").Replace("<", "UNDER").Replace("!", ".").Replace(" ", " ");
/// <summary>The shaded-relief base (the atlas look), quietened — desaturated and darkened a little so the water reads on top of it.</summary>
public static Image Base(float[,] height, int n, float sea)
{
var look = new LookConfig { SeaLevel = sea };
var img = ReliefRenderer.Render(height, n, look);
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
Color c = img.GetPixel(x, y);
float l = 0.299f * c.R + 0.587f * c.G + 0.114f * c.B;
Color q = c.Lerp(new Color(l, l, l), 0.38f);
img.SetPixel(x, y, new Color(q.R * 0.88f, q.G * 0.88f, q.B * 0.88f));
}
return img;
}
private static bool[] LakeIds(BasinGraph graph)
{
int max = 0;
foreach (var b in graph.Nodes) if (b.Id > max) max = b.Id;
var lake = new bool[max + 1];
foreach (var b in graph.Nodes) if (b.IsLake) lake[b.Id] = true;
return lake;
}
public static Image Hydrology(FlowThroughRouting.Result r, sbyte[] dir, DrainageAnalysis.Plan plan, BasinGraph graph,
Image img, int n, bool[] isOcean, bool[] isClassifyWater, string title, string subtitle, string third)
{
int total = n * n;
bool[] lakeId = LakeIds(graph);
bool IsLakeWater(int i) => isClassifyWater[i] && !isOcean[i] && plan.BasinId[i] != 0 && plan.BasinId[i] < lakeId.Length && lakeId[plan.BasinId[i]];
// 1. the lakes — every non-ocean classify body drawn as water; the significant ones (lake basins) a touch brighter.
for (int i = 0; i < total; i++)
{
if (!isClassifyWater[i] || isOcean[i]) continue;
img.SetPixel(i / n, i % n, IsLakeWater(i) ? LakeWater : PondWater);
}
// 2. the field as a streamline texture — quiet, present, not shouting.
Streamlines(img, dir, n, isOcean, isClassifyWater, n >= 4096 ? 56 : 28, 0.30f);
// 3. dropped rivers — a ghost of the upland stem only: "considered, dropped".
foreach (var fr in r.Rivers)
{
if (!fr.Dropped || fr.Routed.CellPath == null) continue;
int stem = Math.Min(fr.Routed.StemCells, fr.Routed.CellPath.Count);
for (int i = 0; i < stem; i++)
{
var (x, y) = fr.Routed.CellPath[i];
img.SetPixel(x, y, img.GetPixel(x, y).Lerp(Ghost, 0.55f));
}
}
// 4. the kept rivers — outlined, smallest first so the big ones finish on top; lake spans not drawn.
var kept = new List<FlowThroughRouting.FlowRiver>();
foreach (var fr in r.Rivers) if (!fr.Dropped && fr.Routed.CellPath != null && fr.Routed.CellPath.Count > 0) kept.Add(fr);
kept.Sort((a, b) => a.Candidate.DrainagePx.CompareTo(b.Candidate.DrainagePx));
foreach (var fr in kept) DrawRiver(img, fr, n, RiverEdge, +1, IsLakeWater);
foreach (var fr in kept) DrawRiver(img, fr, n, fr.ReachesSea ? RiverSea : RiverLake, 0, IsLakeWater);
// 5. markers — through the confluence root: a tributary's mouth is its trunk's mouth.
int mark = n >= 4096 ? 14 : 8;
foreach (var fr in kept)
{
if (fr.Routed.Joined)
{
DrainageRenderer.Disc(img, fr.Routed.JunctionCell.x, fr.Routed.JunctionCell.y, Math.Max(3, mark / 2), n, Junction);
continue;
}
if (fr.MouthCell < 0) continue;
int mx = fr.MouthCell / n, my = fr.MouthCell % n;
if (fr.Terminus == FlowThroughRouting.Terminus.Ocean)
{
DrainageRenderer.Square(img, mx, my, mark / 2, n, RiverSea);
DrainageRenderer.Ring(img, mx, my, mark, n, RiverEdge, 3);
}
else
{
DrainageRenderer.Disc(img, mx, my, mark / 2, n, RiverLake);
DrainageRenderer.Ring(img, mx, my, mark, n, RiverEdge, 3);
}
}
// 6. labels.
int ls = n >= 4096 ? 4 : 3;
var placer = new DrainageRenderer.LabelPlacer(n, ls, headerLines: 8);
int droppedLabels = 0;
var byArea = new List<FlowThroughRouting.FlowRiver>(kept);
byArea.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx));
foreach (var fr in byArea)
{
var c = fr.Candidate;
string tag = fr.Routed.Joined ? $"INTO R{fr.Routed.ConfluenceParentRank}"
: fr.Trunk ? "TRUNK" : fr.Terminus == FlowThroughRouting.Terminus.Ocean ? $"SEA VIA {fr.Chain.Count}" : $"LAKE {fr.TerminusBasinId}";
int lx = fr.Routed.Joined ? fr.Routed.JunctionCell.x : fr.MouthCell / n;
int ly = fr.Routed.Joined ? fr.Routed.JunctionCell.y : fr.MouthCell % n;
if (!placer.Place(img, Txt($"R{c.Rank} {DrainageRenderer.DrainageLabel(c.DrainagePx)} {tag}"), lx, ly, mark,
fr.Routed.Joined ? Junction : fr.ReachesSea ? RiverSea : RiverLake)) droppedLabels++;
}
// 7. the legend, on a dark bar so it reads on the relief.
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
Bar(img, n, 12 + lh * 8 + 8);
TinyFont.Draw(img, Txt(title), 12, 12, s, Ink);
TinyFont.Draw(img, Txt(subtitle), 12, 12 + lh, s, Ink);
TinyFont.Draw(img, Txt(third), 12, 12 + lh * 2, s, Ink);
TinyFont.Draw(img, Txt($"PALE BLUE RIVER = REACHES THE SEA ({r.Trunks} NATURAL TRUNK + {r.FlowThrough} FLOW-THROUGH). SQUARE = MOUTH. AMBER RIVER = LAKE-TERMINAL ({r.LakeTerminal}), DISC = WHERE IT ENTERS ITS LAKE"), 12, 12 + lh * 3, s, RiverSea);
TinyFont.Draw(img, Txt($"WHITE DOT = CONFLUENCE ({r.Joined} JOINED). FAINT RED GHOST = A RIVER CONSIDERED AND DROPPED ({r.DroppedDry + r.DroppedClosed}) - ITS CHAIN WALLED AT A DRY SINK, SO IT IS NOT DRAWN"), 12, 12 + lh * 4, s, Junction);
TinyFont.Draw(img, Txt($"BLUE = EXISTING LAKES (CLASSIFY WATER). A RIVER'S SPAN ACROSS A LAKE IS WATER, NOT A DRAWN CHANNEL. STREAMLINES = THE FLOW-DIRECTION FIELD AT CAP {r.CapM:F0} M"), 12, 12 + lh * 5, s, LakeWater);
TinyFont.Draw(img, Txt($"WIDTH: {DrainageRenderer.StemWidthLaw()} - AS RIVERS/02B, 03, 03B, 03C"), 12, 12 + lh * 6, s, Ink);
TinyFont.Draw(img, "ROUTING AND DATA ONLY - NO HEIGHT MUTATED, NO WATER FILLED OR CREATED, NO BED CARVED." + (droppedLabels > 0 ? $" ({droppedLabels} LABEL(S) DROPPED)" : ""), 12, 12 + lh * 7, s, Ink);
return img;
}
/// <summary>Draw a river's OWN reach (up to its junction) as discs along its rasterised cells, skipping cells that are lake water.</summary>
private static void DrawRiver(Image img, FlowThroughRouting.FlowRiver fr, int n, Color c, int grow, Func<int, bool> isLakeWater)
{
int w = DrainageRenderer.StemWidthFixed(fr.Candidate.DrainagePx);
int rad = Math.Max(1, w / 2) + grow;
var cells = fr.Routed.CellPath;
int own = fr.Routed.Joined ? OwnLength(fr.Routed) : cells.Count;
for (int i = 0; i < own; i++)
{
var (x, y) = cells[i];
if (x < 0 || y < 0 || x >= n || y >= n) continue;
if (isLakeWater(x * n + y)) continue;
DrainageRenderer.Disc(img, x, y, rad, n, c);
}
}
private static int OwnLength(RiverRouting.RoutedRiver r)
{
for (int i = 0; i < r.CellPath.Count; i++)
if (r.CellPath[i].x == r.JunctionCell.x && r.CellPath[i].y == r.JunctionCell.y) return i + 1;
return r.CellPath.Count;
}
/// <summary>
/// The field as a texture: from a grid of seed cells on land, follow the field for a short run and
/// draw it faint-to-stronger along the flow, with a dot at the downstream end. A quiet island-wide
/// "which way does water go here" that never competes with the rivers.
/// </summary>
private static void Streamlines(Image img, sbyte[] dir, int n, bool[] isOcean, bool[] isClassifyWater, int step, float alpha)
{
int len = (int)(step * 0.7f);
for (int gx = step / 2; gx < n; gx += step)
for (int gy = step / 2; gy < n; gy += step)
{
int c = gx * n + gy;
if (isOcean[c] || isClassifyWater[c] || dir[c] < 0) continue;
for (int k = 0; k < len; k++)
{
sbyte d = dir[c];
if (d < 0) break;
int cx = c / n, cy = c % n;
int t = (cx + DX[d]) * n + (cy + DY[d]);
if (isOcean[t]) break;
float a = alpha * (0.35f + 0.65f * k / len);
img.SetPixel(t / n, t % n, img.GetPixel(t / n, t % n).Lerp(Stream, a));
c = t;
}
img.SetPixel(c / n, c % n, img.GetPixel(c / n, c % n).Lerp(Stream, alpha * 1.4f));
}
}
private static void Bar(Image img, int n, int height)
{
for (int y = 0; y < Math.Min(height, n); y++)
for (int x = 0; x < n; x++)
img.SetPixel(x, y, img.GetPixel(x, y).Lerp(new Color(0.04f, 0.05f, 0.07f), 0.72f));
}
/// <summary>
/// ⭐ THE FLOW-DIRECTION DATA MAP — hue by heading (the wheel: N red, E yellow-green, S cyan, W violet),
/// sinks black, ocean dark, walled basins darkened, lakes as water at half strength so the field still
/// shows through, streamlines on top. Not pretty by design — legible.
/// </summary>
public static Image FlowDirection(sbyte[] dir, int[] acc, DrainageAnalysis.Plan plan, BasinGraph graph, HashSet<int> walled,
int n, bool[] isOcean, bool[] isClassifyWater, string title, string subtitle, string third)
{
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
long maxAcc = 1;
for (int i = 0; i < acc.Length; i++) if (acc[i] > maxAcc) maxAcc = acc[i];
double lmax = Math.Log(1.0 + maxAcc);
var hue = new Color[8];
for (int k = 0; k < 8; k++)
{
// screen +y is SOUTH, so flip y to get a compass angle; hue 0 at north, clockwise.
float ang = MathF.Atan2(DX[k], -DY[k]); // 0 = north, +π/2 = east
float h = (ang / (2f * MathF.PI) + 1f) % 1f;
hue[k] = Color.FromHsv(h, 0.62f, 0.86f);
}
bool[] lakeId = LakeIds(graph);
for (int i = 0; i < n * n; i++)
{
int x = i / n, y = i % n;
if (isOcean[i]) { img.SetPixel(x, y, Ocean); continue; }
sbyte d = dir[i];
Color c;
if (d < 0) c = Sink;
else
{
// Hue = heading; brightness = log accumulation on the capped field, so the field's own drainage
// tree reads as bright channels on dark slopes and the per-cell heading noise stays quiet.
float v = (float)(Math.Log(1.0 + acc[i]) / lmax);
float b = 0.22f + 0.78f * v;
c = new Color(hue[d].R * b, hue[d].G * b, hue[d].B * b);
}
int id = plan.BasinId[i];
if (id != 0 && walled.Contains(id)) c = c.Lerp(new Color(0.55f, 0.08f, 0.08f), 0.35f);
if (isClassifyWater[i])
c = c.Lerp(id != 0 && id < lakeId.Length && lakeId[id] ? LakeWater : PondWater, 0.45f);
img.SetPixel(x, y, c);
}
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
Bar(img, n, 12 + lh * 5 + 8);
TinyFont.Draw(img, Txt(title), 12, 12, s, Ink);
TinyFont.Draw(img, Txt(subtitle), 12, 12 + lh, s, Ink);
TinyFont.Draw(img, Txt(third), 12, 12 + lh * 2, s, Ink);
// the wheel, as swatches
string[] names = { "NW", "W", "SW", "N", "S", "NE", "E", "SE" };
int cx0 = 12, cy0 = 12 + lh * 3;
TinyFont.Draw(img, Txt("HUE = HEADING:"), cx0, cy0, s, Ink);
int cursor = cx0 + TinyFont.Width("HUE = HEADING: ", s);
int[] order = { 3, 5, 6, 7, 4, 2, 1, 0 }; // N NE E SE S SW W NW
foreach (int k in order)
{
DrainageRenderer.Square(img, cursor + 8, cy0 + TinyFont.Height(s) / 2, 7, n, hue[k]);
TinyFont.Draw(img, names[k], cursor + 20, cy0, s, Ink);
cursor += 20 + TinyFont.Width(names[k] + " ", s);
}
TinyFont.Draw(img, Txt("BRIGHTNESS = LOG FLOW ACCUMULATION ON THIS FIELD (CHANNELS BRIGHT). BLACK = SINK (A WALLED BASIN FLOOR). RED-TINTED = INSIDE A WALLED BASIN - FLOW ENTERING IT ENDS THERE. BLUE HAZE = CLASSIFY WATER."), 12, 12 + lh * 4, s, Ink);
return img;
}
}
}

View file

@ -44,7 +44,7 @@ namespace IslaApocalypse.Tools
/// --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_PHASE1_SOURCE (default "chat1/02_pass1_port")
/// 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)
@ -77,6 +77,10 @@ namespace IslaApocalypse.Tools
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
int task = EnvInt("ISLA_TASK", 3);
string descr = EnvStr("ISLA_BATCH", "mountain_restore");
@ -84,8 +88,7 @@ namespace IslaApocalypse.Tools
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");
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
float liftBig = EnvFloat("ISLA_LIFT_BIG", 1.35f);
float sharpKnob = EnvFloat("ISLA_SHARP", 2.5f);
@ -114,7 +117,7 @@ namespace IslaApocalypse.Tools
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int seed in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = mapSize, Seed = seed });
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(mapSize, seed));
pass1[seed] = p1;
rawPool.Accumulate(p1.Height, mapSize);
}
@ -219,12 +222,17 @@ namespace IslaApocalypse.Tools
var hard = new List<ShapingOracle.Check>();
var soft = new List<ShapingOracle.Check>();
// ⭐ a1 KEPT at rivers/01 — the family-off pass-1 guard (config pinned family-off).
// ⚠ A missing dump now THROWS instead of skipping silently.
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));
offs[primary].Height, ShapingOracle.LoadAnchor("a1", "ISLA_PHASE1_SOURCE", p1Dump, mapSize), mapSize, p1Dump));
// ⚑ RETIRED at rivers/01 — a2, the staircase == `01_curve_baseline` control.
// Superseded by the continuous grade (→ D-062) — see CurveContinuousTool for the full note.
// The dump is NOT deleted (file-safety; it is regenerable and it is the record of what
// was judged); its `INDEX.md` is marked superseded. The check is gone so nothing can
// pass against a superseded baseline. → XX_Human/output/rivers/01_*.report.md §A4.
long bFail = 0;
foreach (int seed in seeds)
@ -335,13 +343,20 @@ namespace IslaApocalypse.Tools
GetTree().Quit(hardOk ? 0 : 3);
}
/// <remarks>
/// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. This is chat-2 CURVE development: authored
/// and judged before the shape family existed, on the family-off distribution the knots are
/// percentiles of. The re-baseline flipped the bare defaults family-ON, so the pin is what
/// keeps this tool measuring the thing it was written to measure.
/// → <see cref="TerrainGenConfig.WithFamilyOff"/>.
/// </remarks>
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,
};
}.WithFamilyOff();
// ---- output ---------------------------------------------------------

View file

@ -57,8 +57,7 @@ namespace IslaApocalypse.Tools
/// 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
/// ISLA_PHASE1_SOURCE the Phase-1 regression dump's batch (default "chat1/02_pass1_port")
/// </summary>
public partial class OffshoreIslandsTool : Node
{
@ -77,7 +76,6 @@ namespace IslaApocalypse.Tools
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;
@ -112,6 +110,10 @@ namespace IslaApocalypse.Tools
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
int task = EnvInt("ISLA_TASK", 6);
string descr = EnvStr("ISLA_BATCH", "offshore_organic_tune");
@ -121,12 +123,9 @@ namespace IslaApocalypse.Tools
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");
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
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);
@ -185,14 +184,17 @@ namespace IslaApocalypse.Tools
var curveOff = offCfg.Clone(); curveOff.Curve = false;
Pass2Result pOff = Shaping.Shape(p1, curveOff);
// ⭐ a1 KEPT at rivers/01 — the family-off pass-1 guard (config pinned family-off). ⚠ loud.
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));
pOff.Height, ShapingOracle.LoadAnchor("a1", "ISLA_PHASE1_SOURCE", 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));
// ⚑ RETIRED at rivers/01 — a3, `continuous_restored` == `03_mountain_restore`.
// A curve-development intermediate: it proved the chat2/03 climb restoration against
// chat2/02. Both are upstream of the locked shape, and `terrain-shape-v1` (a10) now
// asserts the whole chain end-to-end — subsuming it.
// The dump is NOT deleted (file-safety; regenerable, and the record of what was judged);
// its `INDEX.md` is marked superseded. → XX_Human/output/rivers/01_*.report.md §A4.
// 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";
@ -205,22 +207,12 @@ namespace IslaApocalypse.Tools
// ⭐ 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)");
// ⚑ RETIRED at rivers/01 — a4, offshore-OFF at 8192 == `04_seed_gallery` (`terrain-curve-v1`).
// The PRE-FAMILY committed curve. The locked shape is family-ON, so this dump is a
// baseline the generator is deliberately no longer on; `a10` replaced it as the 8192
// acceptance. (It also cost an 8192 generation on every run of this tool.)
// The dump is NOT deleted (file-safety; regenerable, and the record of what was judged);
// its `INDEX.md` is marked superseded. → XX_Human/output/rivers/01_*.report.md §A4.
}
// ═══ 2. THE DIAGNOSIS — measure the south before touching a knob ═══
@ -402,7 +394,7 @@ namespace IslaApocalypse.Tools
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
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s)); // family-off PINNED (rivers/01), not defaulted
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
@ -416,11 +408,14 @@ namespace IslaApocalypse.Tools
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
// ⭐ rivers/01: family-off PINNED, like the pool it shapes. (The family acts in pass 1 and
// `Shaping.Shape` never reads it, so this is inert today — pinned anyway so "the whole
// calibration is family-off" is a total claim rather than a field-by-field one.)
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
};
}.WithFamilyOff();
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);
@ -436,14 +431,21 @@ namespace IslaApocalypse.Tools
return (knots, cal, pass1);
}
/// <remarks>
/// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. This tool is chat-2 shaping DEVELOPMENT
/// (chat2/0506): authored and judged before the shape family existed, and its regressions hold
/// pass 1 against the FAMILY-OFF `02_pass1_port` dump. The re-baseline flipped the bare defaults
/// family-ON, so without <see cref="TerrainGenConfig.WithFamilyOff"/> every config here would
/// silently acquire stretch + fragmentation and the anchor checks would fail for a configuration
/// reason, not a regression. The variants re-enable offshore explicitly, after the pin.
/// </remarks>
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
};
}.WithFamilyOff(); // ⭐ shelf / islets / speck / stretch / frag / erosion all OFF — pinned
// ---- output -----------------------------------------------------------

View file

@ -42,8 +42,7 @@ namespace IslaApocalypse.Tools
/// 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
/// ISLA_PHASE1_SOURCE the Phase-1 regression dump's batch (default "chat1/02_pass1_port")
/// </summary>
public partial class RegionLabelingTool : Node
{
@ -57,7 +56,6 @@ namespace IslaApocalypse.Tools
private const int DefaultMapSize = 4096;
private const int DefaultCalibSize = 2048;
private const int GallerySize = 8192;
public override void _Ready()
{
@ -89,6 +87,10 @@ namespace IslaApocalypse.Tools
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
int task = EnvInt("ISLA_TASK", 7);
string descr = EnvStr("ISLA_BATCH", "region_labeling");
@ -98,13 +100,9 @@ namespace IslaApocalypse.Tools
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");
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
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>
{
@ -141,7 +139,14 @@ namespace IslaApocalypse.Tools
TerrainGenConfig Cfg(int size, int seed, string label, bool offshoreOn, bool revertOn, float frac)
{
var c = BaseConfig(size, seed, knots, anchors, calibration, label);
// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. This tool is chat-2 shaping DEVELOPMENT:
// it was authored and judged before the shape family existed, and its regression checks
// hold pass 1 against the FAMILY-OFF `02_pass1_port` dump. The re-baseline flipped the
// bare defaults family-ON, so without this pin every config here would silently acquire
// stretch + fragmentation and every anchor check would fail for a configuration reason.
// → TerrainGenConfig.WithFamilyOff().
var c = BaseConfig(size, seed, knots, anchors, calibration, label).WithFamilyOff();
// …then this tool's own axes, AFTER the pin (the pin would otherwise clear them).
if (offshoreOn) { c.CoastShelf = true; c.Offshore = OffshoreSettings.Organic(); }
c.RegionLabeling = true;
c.SpeckRevert = revertOn;
@ -159,26 +164,17 @@ namespace IslaApocalypse.Tools
var curveOff = offCfg.Clone(); curveOff.Curve = false;
Pass2Result pOff = Shaping.Shape(p1, curveOff);
// ⭐ a1 KEPT at rivers/01 — the family-off pass-1 guard (config pinned family-off). ⚠ loud.
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));
pOff.Height, ShapingOracle.LoadAnchor("a1", "ISLA_PHASE1_SOURCE", 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);
// ⚑ RETIRED at rivers/01 — a3 and a3r, both against `03_mountain_restore`.
// A curve-development intermediate, subsumed by the `terrain-shape-v1` acceptance (a10).
// a3r was informational only, and its subject — how many natural specks the revert takes —
// is now reported by the region ledger every run, with the revert ON by default.
// The dump is NOT deleted (file-safety; regenerable, and the record of what was judged);
// its `INDEX.md` is marked superseded. → XX_Human/output/rivers/01_*.report.md §A4.
var shelfCfg = offCfg.Clone(); shelfCfg.CoastShelf = true; shelfCfg.VariantLabel = "shelf_only";
Pass1Result p1Shelf = Topography.Generate(shelfCfg);
@ -188,34 +184,13 @@ namespace IslaApocalypse.Tools
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}")}");
// ⚑ RETIRED at rivers/01 — a4 (`04_seed_gallery`) and a6 (`06_offshore_organic_tune`).
// a4 held the PRE-FAMILY committed curve; the locked shape is family-ON, and a10 is the
// 8192 acceptance now. a6 held the ORGANIC ISLET layer — the DROPPED mechanism (→ D-063):
// islands are organic-only, made by the stretch + fragmentation and identified here, never
// placed. An oracle pinning islet output is an oracle defending a design that was reversed.
// The dump is NOT deleted (file-safety; regenerable, and the record of what was judged);
// its `INDEX.md` is marked superseded. → XX_Human/output/rivers/01_*.report.md §A4.
}
// ═══ 2. DETERMINISM ═══
@ -354,7 +329,7 @@ namespace IslaApocalypse.Tools
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
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s)); // family-off PINNED (rivers/01), not defaulted
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
@ -368,11 +343,14 @@ namespace IslaApocalypse.Tools
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
// ⭐ rivers/01: family-off PINNED, like the pool it shapes. (The family acts in pass 1 and
// `Shaping.Shape` never reads it, so this is inert today — pinned anyway so "the whole
// calibration is family-off" is a total claim rather than a field-by-field one.)
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
};
}.WithFamilyOff();
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);

View file

@ -26,7 +26,7 @@ namespace IslaApocalypse.Tools
/// ISLA_LOOKS comma-separated look names (default: atlas,relief,dusk)
/// ISLA_TASK authoring task number (default 3)
/// ISLA_BATCH descriptor, NO prefix (default "relief_taste")
/// ISLA_SOURCE batch to read .f32 from (default 02_pass1_port)
/// ISLA_SOURCE batch to read .f32 from (default "chat1/02_pass1_port")
/// ISLA_DUMP_RAW "1" to also dump .f32 when a field had to be generated
/// </summary>
public partial class ReliefRenderTool : Node
@ -36,6 +36,10 @@ namespace IslaApocalypse.Tools
public override void _Ready()
{
ToolingPaths.Configure(OS.GetUserDataDir());
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat1"));
int mapSize = EnvInt("ISLA_MAPSIZE", 2048);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
@ -43,8 +47,8 @@ namespace IslaApocalypse.Tools
string batch = EnvStr("ISLA_BATCH", "relief_taste");
// ⚠ A FULL batch folder name, prefix included — it names an EXISTING folder rather than
// composing a new one, so it is not run through BatchRoot. Tracks TerrainGenTool's
// default output: BatchRoot(task 2, "pass1_port") = 02_pass1_port.
string source = EnvStr("ISLA_SOURCE", "02_pass1_port");
// default output: BatchRoot(task 2, "pass1_port") = <chat>/02_pass1_port (rivers/01: chat-namespaced).
string source = EnvStr("ISLA_SOURCE", "chat1/02_pass1_port");
bool dumpRaw = EnvStr("ISLA_DUMP_RAW", "0") == "1";
LookConfig[] looks = SelectLooks(EnvStr("ISLA_LOOKS", null));
@ -83,7 +87,9 @@ namespace IslaApocalypse.Tools
}
else
{
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "full" };
// ⭐ rivers/01: family-off PINNED — this regenerates a PHASE-1 field to stand in for a
// missing `02_pass1_port` dump, so it must reproduce that dump, not the new default.
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "full" }.WithFamilyOff();
Pass1Result r = Topography.Generate(cfg);
height = r.Height;
origin = "generated";

View file

@ -25,7 +25,7 @@ namespace IslaApocalypse.Tools
///
/// ISLA_TASK authoring task number (default 4)
/// ISLA_BATCH descriptor, NO prefix (default "review")
/// ISLA_SOURCE batch holding the .f32 (default 02_pass1_port)
/// ISLA_SOURCE batch holding the .f32 (default "chat1/02_pass1_port")
/// ISLA_MAPSIZE side in columns (default 2048)
/// ISLA_SEEDS comma-separated positive (default: the 4 pinned seeds)
/// </summary>
@ -59,16 +59,21 @@ namespace IslaApocalypse.Tools
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat1"));
int task = EnvInt("ISLA_TASK", 4);
string descr = EnvStr("ISLA_BATCH", "review");
// ⚠ A FULL batch folder name, prefix included — this NAMES AN EXISTING FOLDER rather than
// composing a new one, so it is not run through BatchRoot. The default tracks where
// TerrainGenTool writes by default: BatchRoot(task 2, "pass1_port") = 02_pass1_port.
// TerrainGenTool writes by default: BatchRoot(task 2, "pass1_port") = chat1/02_pass1_port
// (rivers/01 namespaced batches by chat; the READ side carries the chatN/ prefix explicitly).
// It was "01_pass1_port" until chat1/05 renamed the folder to its authoring-task number;
// a stale default here does not fail loudly, it just silently regenerates instead of
// loading — which is exactly the kind of quiet cost worth pinning to the real name.
string source = EnvStr("ISLA_SOURCE", "02_pass1_port");
string source = EnvStr("ISLA_SOURCE", "chat1/02_pass1_port");
int mapSize = EnvInt("ISLA_MAPSIZE", 2048);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
@ -103,7 +108,7 @@ namespace IslaApocalypse.Tools
if (h == null)
{
GD.PrintErr($" ⚠ no full dump for {seed} at {mapSize} — generating deterministically.");
h = Topography.Generate(new TerrainGenConfig { MapSize = mapSize, Seed = seed }).Height;
h = Topography.Generate(TerrainGenConfig.CalibrationPool(mapSize, seed)).Height;
}
notes.Add(Gray(h, mapSize, batchRoot, "2_height_grayscale", seed,

View file

@ -0,0 +1,120 @@
using System.Collections.Generic;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ ONE CANDIDATE MAJOR DRAINAGE — the unit the river count is chosen over (rivers/02).
///
/// ═══ WHY THIS TYPE EXISTS: ONE LIST, NOT TWO ═══
///
/// The reference promoted rivers from TWO separate lists with TWO separate quotas — N sea-reaching
/// trunks and N endorheic giants (<c>DrainageAnalysis.Params.TrunkCount</c> / <c>GiantCount</c>,
/// both 3). That structure encodes an assumption this terrain does not satisfy: that reaching the
/// sea is what makes a drainage a river, and inland ones are a second category to be quota'd
/// separately.
///
/// **On the reshaped terrain ~68 % of land drains INLAND** (measured: 67.6 % on the primary seed,
/// 116 terminal basins). A separate quota would fight that — it would promote small coastal
/// drainages over far larger inland ones purely because of where they end.
///
/// > ### So selection is UNIFIED: rank every major drainage by contributing area, promote the top N,
/// > and let the sea-vs-endorheic split FALL OUT of which promoted rivers happen to reach the ocean.
/// > **An endorheic terminus is a PASS, not a fallback** — a river ending in a significant lake is
/// > as real as one reaching the coast, and is never forced to the coast.
///
/// ⚠ This is a DELIBERATE DEPARTURE from the reference's two-list structure (approved in chat;
/// D-050 port-discipline noted). Only the SELECTION is unified — <see cref="IsSea"/> is retained
/// per river because downstream routing branches on it, and <c>Trunk</c> / <c>Giant</c> are left
/// exactly as ported.
///
/// ═══ ⚠ THE METRIC IS THE SAME UNIT ON BOTH SIDES, AND THAT IS LOAD-BEARING ═══
///
/// <see cref="DrainagePx"/> is a COUNT OF CONTRIBUTING LAND CELLS in both cases, computed on the
/// same D8 field in the same pass:
///
/// SEA <c>Plan.Acc</c> at the outlet — every non-ocean cell is seeded 1 and accumulated
/// along <c>Plan.Dir</c>, so the outlet's value is the count of cells whose flow path
/// passes through it.
/// ENDORHEIC <c>Plan.BasinInflow[BasinId]</c> — the memoised downstream walk over the SAME
/// <c>Dir</c>, counting cells whose flow TERMINATES in that basin.
///
/// Every land cell has exactly one destination, so the two populations are disjoint and exhaustive:
/// <c>Σ sea-outlet Acc + Σ BasinInflow + UnroutedCells == LandCells</c>. `RiverPromotionTool`
/// ASSERTS that identity per seed — it is the mechanical proof that one ranking over both is sound.
/// </summary>
public sealed class RiverCandidate
{
/// <summary>⭐ The terminus. True = the outlet touches <c>RegionLabeling.OceanMask</c>; false = it pools in a terminal basin. Never a bare <c>h &lt; sea</c> test.</summary>
public bool IsSea;
/// <summary>Row-major cell: the sea outlet, or the terminal basin's MINIMUM (its deepest cell).</summary>
public int Cell;
public int X, Y;
/// <summary>
/// ⭐ Where the RIVER actually ends — the point its main stem pools at, i.e. the first point of
/// <see cref="Course"/>. Defaults to <see cref="X"/>/<see cref="Y"/> until bound.
///
/// ⚠⚠ FOR AN ENDORHEIC RIVER THIS IS NOT THE BASIN'S DEEPEST CELL, and the difference is
/// visible on a map. `DrainageAnalysis` is explicit about why: *"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."* On a wide flat lagoon bed those two points can sit far apart.
///
/// Both are real and both are kept: the basin minimum is the BASIN's identity (and is what the
/// CSV records), this is the RIVER's terminus (and is what the plates mark). Marking a river at
/// its basin's deepest cell draws the stem visibly detached from its own endpoint — which reads
/// as a broken river and would corrupt a count judgment.
/// </summary>
public int TermX, TermY;
/// <summary>⭐ THE RANKING METRIC — contributing land cells. Same unit for both termini (see the class note).</summary>
public long DrainagePx;
/// <summary>Terminal-basin id (endorheic only; 0 for sea). The stable key for binding a candidate to its <c>Giant</c>.</summary>
public int BasinId;
/// <summary>Endorheic only: the basin's max fill depth, metres.</summary>
public float BasinDepthM;
/// <summary>Endorheic only: the basin's area in cells.</summary>
public long BasinAreaPx;
/// <summary>
/// ⚠ Sea only. True when this outlet was DROPPED by the <c>MinOutletSeparationPx</c> rule
/// because a larger outlet sits within that radius. Kept in the distribution (it is a real
/// drainage) but excluded from ranking — see the tool's note on what separation discards.
/// </summary>
public bool SuppressedBySeparation;
/// <summary>
/// The REAL upland stem — the max-accumulation traced course from <c>DrainageAnalysis</c>'s own
/// <c>TraceStem</c>, bound after selection. Null for candidates outside the promoted set.
/// ⚠ Downstream-first and decimated ×4, as the analysis produces it.
/// ⚠⚠ This is the erosion-carved course, NOT <c>Giant.ProvisionalRoute</c> — the steepest-descent
/// placeholder ("the comb") is deliberately never drawn here; replacing it is rivers/03's job,
/// and drawing it would mislead a count judgment.
/// </summary>
public List<(float x, float y)> Course;
/// <summary>1-based rank in the unified descending ranking. 0 until ranked.</summary>
public int Rank;
/// <summary>
/// ⭐ rivers/03 — THE ANALYSIS'S OWN routed/lake-ender verdict, copied from <c>Giant.Kind</c> at
/// bind time. Endorheic only ("" for sea candidates).
///
/// ⚠⚠ READ THE TEST BEFORE TRUSTING THE NAME. `DrainageAnalysis` assigns this as
/// <c>(basinHasLake[id] &amp;&amp; !SouthernCandidate) ? "lake-ender" : "routed"</c> — i.e. purely on
/// **whether the terminal basin holds classify water**. It is NOT a path test: "routed" means
/// "this basin is a dry pan, so it SHOULD be routed", not "a route to the sea exists". Whether
/// one actually does is what `RiverRouting.RouteToOcean` decides, and the two CAN disagree.
/// rivers/03 reports both per river rather than silently picking one.
/// </summary>
public string AnalysisKind = "";
/// <summary>Endorheic only: the analysis found classify water in this terminal basin.</summary>
public bool TerminalInClassifyWater;
public string TerminusName => IsSea ? "sea" : "endorheic";
}
}

View file

@ -0,0 +1,233 @@
using System;
using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ THE CANDIDATE SET — one implementation, shared by every task that ranks rivers.
///
/// Extracted from `RiverPromotionTool` at rivers/03, unchanged in behaviour, because routing needs
/// exactly the same promoted set the count gate was judged on. **Two copies of this enumeration
/// would be two answers to "which rivers does the island have", and the epic rests on there being
/// one.** `RiverPromotionTool` now delegates here; its plates are byte-identical across the change.
///
/// ═══ WHAT IT DOES, AND WHAT IT DELIBERATELY DOES NOT ═══
///
/// It derives the COMPLETE candidate set from the arrays `DrainageAnalysis.Plan` exposes —
/// `Dir` / `Acc` / `BasinId` / `BasinInflow` / `FullFilled` — rather than from `Plan.Trunks` /
/// `Plan.Giants`, which are already truncated by the analysis's lean reporting caps. Ranking over
/// the truncated lists would measure the caps rather than the terrain.
///
/// ⚠ **`DrainageAnalysis` is reused, never rebuilt.** Every derived quantity here is a
/// reconstruction of a value the analysis computed internally, from state it exposes. Nothing the
/// analysis owns is reimplemented — least of all stem tracing, which is BOUND from the analysis's
/// own `Trunk` / `Giant` (see <see cref="BindCourses"/>).
/// </summary>
public static class RiverCandidates
{
/// <summary>The enumeration's full result: the ranking, plus what the separation rule cost.</summary>
public sealed class Enumeration
{
/// <summary>Separated and above the floor, descending by <c>DrainagePx</c>, `Rank` assigned.</summary>
public List<RiverCandidate> Ranked;
public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells;
public int TerminalBasins, SeaOutletsAll;
/// <summary>Sea outlets dropped by the separation rule — ALL of them, incl. one-cell trickles.</summary>
public int SuppressedCount;
public long SuppressedPx;
/// <summary>⭐ The two that matter: only outlets clearing the floor could ever have been promoted.</summary>
public int SuppressedAboveFloor;
public long SuppressedAboveFloorPx;
/// <summary>Of those, suppressed by an outlet on a DIFFERENT landmass — not a delta mouth by any definition.</summary>
public int SuppressedCrossLandmass;
public long SuppressedCrossLandmassPx;
public List<long> SuppressedAboveFloorAccs = new();
public int SeaCount { get { int s = 0; foreach (var c in Ranked) if (c.IsSea) s++; return s; } }
}
/// <summary>
/// Enumerate every candidate major drainage, cap-free.
///
/// SEA every cell with <c>Dir == D_SEA</c>, carrying <c>Acc</c> there, then the
/// analysis's own greedy <c>MinOutletSeparationPx</c> rule so three mouths of one
/// delta are not three rivers.
/// ENDORHEIC every terminal basin in <c>BasinId</c>, carrying <c>BasinInflow[id]</c>, with
/// terminal cell / area / depth re-derived from the exposed surfaces.
///
/// ⚠⚠ Throws unless the metric-comparability identity holds exactly — see below.
/// </summary>
public static Enumeration Enumerate(DrainageAnalysis.Plan plan, float[,] height, int n,
long floorPx, int separationPx, RegionLabels regions)
{
int total = n * n;
var r = new Enumeration
{
LandCells = plan.LandCells, SeaReachingCells = plan.SeaReachingCells,
EndorheicCells = plan.EndorheicCells, UnroutedCells = plan.UnroutedCells,
TerminalBasins = plan.TerminalBasinCount,
};
// ---- SEA: every outlet, then the separation rule ----
var outlets = new List<(int cell, long acc)>();
long seaSum = 0;
for (int i = 0; i < total; i++)
if (plan.Dir[i] == DrainageAnalysis.D_SEA) { outlets.Add((i, plan.Acc[i])); seaSum += plan.Acc[i]; }
outlets.Sort((a, b) => b.acc.CompareTo(a.acc));
r.SeaOutletsAll = outlets.Count;
var sea = new List<RiverCandidate>();
var kept = new List<int>();
foreach (var (cell, acc) in outlets)
{
int cx = cell / n, cy = cell % n;
bool far = true; int suppressor = -1;
foreach (int pcell in kept)
{
float ddx = cx - pcell / n, ddy = cy - pcell % n;
if (ddx * ddx + ddy * ddy < (float)separationPx * separationPx) { far = false; suppressor = pcell; break; }
}
var c = new RiverCandidate { IsSea = true, Cell = cell, X = cx, Y = cy, TermX = cx, TermY = cy, DrainagePx = acc, SuppressedBySeparation = !far };
if (far) kept.Add(cell);
else
{
r.SuppressedCount++; r.SuppressedPx += acc;
if (acc >= floorPx)
{
r.SuppressedAboveFloor++; r.SuppressedAboveFloorPx += acc; r.SuppressedAboveFloorAccs.Add(acc);
// ⚠⚠ IS THE SUPPRESSOR EVEN ON THE SAME LANDMASS? The separation rule is a plain
// Euclidean distance test — it has no idea what land a coastline belongs to. On
// this deliberately fragmented archipelago (→ D-063) an ISLAND's only river can
// be suppressed by a mainland mouth 400 px away ACROSS WATER. Measured, not
// argued; the rule itself is NOT changed (it belongs to the analysis).
if (regions != null && suppressor >= 0)
{
int a = regions.Id[cell], b = regions.Id[suppressor];
if (a != 0 && b != 0 && a != b) { r.SuppressedCrossLandmass++; r.SuppressedCrossLandmassPx += acc; }
}
}
}
if (acc >= floorPx) sea.Add(c);
}
// ---- ENDORHEIC: every terminal basin, metrics re-derived ----
// After the analysis's reversion, BasinId is non-zero ONLY on terminal-basin cells, and
// Filled == the original height there — so FullFilled height IS the fill depth, and the
// basin minimum is the argmin of height over the basin's cells. Both reconstruct exactly
// what the analysis computed internally as basinMinCell / basinDepthM / basinAreaPx.
int maxId = 0;
for (int i = 0; i < total; i++) if (plan.BasinId[i] > maxId) maxId = plan.BasinId[i];
var area = new long[maxId + 1];
var minCell = new int[maxId + 1];
var minH = new float[maxId + 1];
var depth = new float[maxId + 1];
for (int id = 0; id <= maxId; id++) { minCell[id] = -1; minH[id] = float.MaxValue; }
for (int i = 0; i < total; i++)
{
int id = plan.BasinId[i];
if (id == 0) continue;
area[id]++;
float h = height[i / n, i % n];
if (h < minH[id]) { minH[id] = h; minCell[id] = i; }
float d = WorldScale.MetresFromRaw(plan.FullFilled[i] - h);
if (d > depth[id]) depth[id] = d;
}
var endo = new List<RiverCandidate>();
long endoSum = 0;
for (int id = 1; id <= maxId; id++)
{
if (minCell[id] < 0) continue;
long inflow = id < plan.BasinInflow.Length ? plan.BasinInflow[id] : 0;
endoSum += inflow;
if (inflow < floorPx) continue;
endo.Add(new RiverCandidate
{
IsSea = false, Cell = minCell[id], X = minCell[id] / n, Y = minCell[id] % n,
TermX = minCell[id] / n, TermY = minCell[id] % n, // replaced at bind time by the stem's pooling point
DrainagePx = inflow, BasinId = id, BasinAreaPx = area[id], BasinDepthM = depth[id],
});
}
// ═══ ⚠⚠ THE COMPARABILITY ASSERTION — the whole unified ranking rests on this ═══
//
// Both metrics are counts of contributing LAND CELLS on the same D8 field, and every land
// cell has exactly one destination — so the two populations partition the land exactly.
// If this identity ever fails, the two numbers are not the same unit and ranking them in
// one list is meaningless. It is asserted per seed rather than argued in a comment.
long partition = seaSum + endoSum + plan.UnroutedCells;
if (seaSum != plan.SeaReachingCells || endoSum != plan.EndorheicCells || partition != plan.LandCells)
throw new InvalidOperationException(
"[RiverCandidates] METRIC COMPARABILITY VIOLATION — the unified ranking is not sound on this field.\n" +
$" Σ Acc over sea outlets = {seaSum:N0}, expected SeaReachingCells = {plan.SeaReachingCells:N0}\n" +
$" Σ BasinInflow = {endoSum:N0}, expected EndorheicCells = {plan.EndorheicCells:N0}\n" +
$" sum + unrouted = {partition:N0}, expected LandCells = {plan.LandCells:N0}\n" +
"Sea-outlet drainage area and endorheic credited inflow must be the same unit over the same " +
"population for one ranking to mean anything. Refusing to rank. (rivers/02 Part 0 §2.)");
GD.Print($" ✅ comparability: Σ sea Acc {seaSum:N0} + Σ BasinInflow {endoSum:N0} + unrouted {plan.UnroutedCells:N0} == land {plan.LandCells:N0} — same unit, exact partition");
// ---- the unified ranking: one list, both termini, descending by contributing cells ----
var ranked = new List<RiverCandidate>();
foreach (var c in sea) if (!c.SuppressedBySeparation) ranked.Add(c);
ranked.AddRange(endo);
ranked.Sort((a, b) => b.DrainagePx.CompareTo(a.DrainagePx));
for (int i = 0; i < ranked.Count; i++) ranked[i].Rank = i + 1;
r.Ranked = ranked;
return r;
}
/// <summary>
/// Bind each candidate in <paramref name="need"/> to the <c>Trunk</c> / <c>Giant</c> the
/// analysis already traced, so plates draw REAL upland stems rather than anything reimplemented
/// here. Sea binds by outlet cell (identical greedy pick, identical order); endorheic binds by
/// BASIN ID — not by terminal coordinates, because a flat basin floor can have several cells at
/// the minimum height and the analysis's DFS tie-break need not match a row-major scan.
///
/// ⚠ Also transfers the analysis's own <see cref="RiverCandidate.Kind"/> and
/// <see cref="RiverCandidate.TerminalInClassifyWater"/> for endorheic candidates — rivers/03
/// needs the reference's routed/lake-ender verdict to compare against its own.
/// </summary>
public static void BindCourses(DrainageAnalysis.Plan plan, int n, List<RiverCandidate> need, string what)
{
var byOutlet = new Dictionary<int, DrainageAnalysis.Trunk>();
foreach (var t in plan.Trunks) byOutlet[(int)t.Outlet.x * n + (int)t.Outlet.y] = t;
var byBasin = new Dictionary<int, DrainageAnalysis.Giant>();
foreach (var g in plan.Giants)
{
int cell = (int)g.Terminal.x * n + (int)g.Terminal.y;
int id = plan.BasinId[cell];
if (id > 0 && !byBasin.ContainsKey(id)) byBasin[id] = g;
}
int missing = 0;
foreach (var c in need)
{
if (c.Course != null) continue;
if (c.IsSea)
{
if (byOutlet.TryGetValue(c.Cell, out var t)) { c.Course = t.Course; c.TermX = (int)t.Outlet.x; c.TermY = (int)t.Outlet.y; }
}
else
{
// ⚠ Take the RIVER's terminus from the Giant, not the basin minimum this candidate
// is keyed on — see RiverCandidate.TermX. They differ on a flat basin floor, and
// marking the wrong one draws every endorheic stem detached from its own endpoint.
if (byBasin.TryGetValue(c.BasinId, out var g))
{
c.Course = g.Course; c.TermX = (int)g.Terminal.x; c.TermY = (int)g.Terminal.y;
c.AnalysisKind = g.Kind;
c.TerminalInClassifyWater = g.TerminalInClassifyWater;
}
}
if (c.Course == null) missing++;
}
if (missing > 0)
throw new InvalidOperationException(
$"[RiverCandidates] {missing} of {need.Count} candidates in {what} have no traced stem. The analysis's " +
"reporting caps are what produce the courses, so they must cover every candidate being drawn — " +
"raise ISLA_PROMOTE_MAX. Refusing to render a plate with rivers drawn as bare markers.");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,754 @@
using System;
using System.Collections.Generic;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ LOWLAND ROUTING (rivers/03) — the ROUTING PORTION of the reference's `RiverCarvePass`,
/// ported faithfully (D-050). **Courses only. This file reads heights and writes none.**
///
/// ═══ ⛔ THE RED LINE ═══
///
/// **Nothing here fills water, creates a water body, or mutates any height field.** It produces
/// polylines. The bed CARVE (`CarveRiver`, mutates render height, flood-guarded) and the STEPPED
/// WATER model (`AddSteppedWater`, creates bodies) are the reference's separate stages and are
/// separate later tasks. Verified at rivers/03 Part 0: in the reference, routing is pure — the
/// carve mutates, and `AddSteppedWater` is a call the CALLER makes afterwards, not something
/// `Apply` does. Lake-enders target EXISTING classify water; no lake is ever created.
///
/// ═══ ⭐ WHY THE COST MODEL IS THE LOAD-BEARING PIECE ═══
///
/// **An endorheic terminal is a local minimum by definition** — a downhill path out of it does not
/// exist, so "can it flow to the sea?" cannot be answered by descent. It is answered by cost: the
/// cheapest LOWGROUND path is allowed to climb over the basin's rim, paying heavily for it
/// (uphill penalised, never forbidden). That is the route-version of an overflow channel — a
/// channel over the spill, **with no water filled**.
///
/// SHORT cost ≈ distance, uphill lightly penalised — heads direct, avoids walls. (Rejected
/// by the reference's own gate as "a dead-straight canal"; ported for completeness.)
/// LOWGROUND cost ≈ BEING high (per px of travel) plus heavily for CLIMBING, so the cheapest
/// corridor is the lowest ground even when that wanders. **The locked style.**
///
/// ═══ ⚠⚠ THE CONSTANTS ARE DECLARED == EFFECTIVE, AND THAT WAS CHECKED ═══
///
/// `00_ground` warned that the reference's effective river tunables live in `ConfigManager`, not in
/// the `Params` initializers (WidthScale 1.0→1.75, DepthScale 1.0→1.5). **Those are carve-time and
/// out of scope here.** The four ROUTING cost constants below are `private const` inside
/// `RiverCarvePass` with no `ConfigManager` key and no `[Export]` anywhere in the reference repo —
/// verified by grep at rivers/03 Part 0 — so for routing, declared IS effective. The one routing
/// value that does come from config is the STYLE, effective `"lowground"`, which equals the
/// declared default.
/// </summary>
public static class RiverRouting
{
public const byte StyleShort = 0;
public const byte StyleLowground = 1;
// ⚠ Ported verbatim. SHORT pays lightly for climbing (8 per metre of rise, so a 10 m wall costs
// like an 80 px detour). LOWGROUND pays for BEING high (1 per metre of elevation per px) plus
// heavily for climbing (50 per metre).
public const float ShortUphillPerM = 8f;
public const float LowgroundElevPerM = 1f;
public const float LowgroundBase = 0.05f;
public const float LowgroundUphillPerM = 50f;
/// <summary>The reference's smallest water body a lake-ender may target (`RiverLakeMinTargetPx`,
/// effective 20,000 — declared and config agree). "Nearest wet pixel" routed one into a 3-cell
/// puddle a few hundred px short of the obvious lagoon; that was the task-23 gate finding.</summary>
public const int LakeMinTargetPx = 20_000;
// 8-connectivity in the reference's exact order — the tie-break structure is part of the result.
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 };
/// <summary>One lowland route, with the diagnostics the gate needs to judge it.</summary>
public sealed class Route
{
/// <summary>Terminal → target, 1-px steps, as Dijkstra produced it. Empty when no path exists.</summary>
public List<(float x, float y)> Path = new();
/// <summary>The same reach after RDP + Chaikin. This is what is drawn and spliced.</summary>
public List<(float x, float y)> Smoothed = new();
/// <summary>⭐ Did a path exist at all? Empty list on no path — never thrown.</summary>
public bool Reached;
/// <summary>Dijkstra cost at the goal (cost-model units, not metres).</summary>
public float Cost;
/// <summary>⭐⭐ THE RIM: the largest single-step climb on the route, metres. The number that
/// says whether a route crawls over a saddle or vaults a wall.</summary>
public float MaxStepUphillM;
/// <summary>⭐ Total metres climbed along the route, and how many steps climbed at all.</summary>
public float TotalUphillM;
public int UphillSteps;
/// <summary>Highest point on the route, metres above sea — the rim's absolute height.</summary>
public float MaxElevM;
/// <summary>Net climb from the terminal to the route's high point, metres — what "over the rim" costs.</summary>
public float RimClimbM;
/// <summary>⭐ WHERE the route tops out — the rim cell, ringed on the plate.</summary>
public (float x, float y) RimPoint;
public float LenPx, StraightPx, WanderRatio;
/// <summary>Cells settled by the search — the honest cost of a Dijkstra at this map size.</summary>
public long Expanded;
public (float x, float y) Target;
}
/// <summary>
/// ⭐ Deterministic Dijkstra from a start cell to the nearest cell of <paramref name="targets"/>
/// under the selected cost model. Ported from `RiverCarvePass.RouteToOcean`.
///
/// ⚠ **Returns an empty path when no path exists — it never throws.** That contract is
/// load-bearing: "no affordable route" is a RESULT (the river is a lake-ender), not an error.
///
/// ⚠ `targets` is a generic mask: `OceanMask` for a route to the sea, significant-water for a
/// lake-ender's extension. One routine, two uses — as the reference has it.
///
/// Determinism: the priority is `(cost, cellIndex)`, so equal costs break on the lower index and
/// the result cannot depend on heap internals. The search settles a cell once (`closed`) and
/// stops the moment it DEQUEUES a target, so the first target reached is the cheapest.
/// </summary>
public static Route RouteTo(float[,] height, int n, bool[] targets, int sx, int sy, byte style, float sea)
{
int total = n * n;
var gcost = new float[total];
var parent = new int[total];
var closed = new bool[total];
Array.Fill(gcost, float.MaxValue);
Array.Fill(parent, -1);
// ⚠ Elevation is clamped at sea: below-sea ground is not "cheaper than sea level", it is sea
// level. Without the clamp a route would dive for the deepest hole it could find.
float ElevM(int x, int y) => MathF.Max(0f, WorldScale.MetresFromRaw(height[x, y] - sea));
var pq = new PriorityQueue<int, (float c, int i)>();
int start = sx * n + sy;
gcost[start] = 0f;
pq.Enqueue(start, (0f, start));
int goal = -1;
long expanded = 0;
while (pq.Count > 0)
{
int c = pq.Dequeue();
if (closed[c]) continue;
closed[c] = true;
expanded++;
if (targets[c]) { goal = c; break; }
int cx = c / n, cy = c % n;
float hc = height[cx, 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 (closed[ni]) continue;
float dhM = MathF.Max(0f, WorldScale.MetresFromRaw(height[nx, ny] - hc));
float step = style == StyleShort
? DIST[k] + dhM * ShortUphillPerM
: DIST[k] * (LowgroundBase + ElevM(nx, ny) * LowgroundElevPerM)
+ dhM * LowgroundUphillPerM;
float nc = gcost[c] + step;
if (nc < gcost[ni])
{
gcost[ni] = nc;
parent[ni] = c;
pq.Enqueue(ni, (nc, ni));
}
}
}
var r = new Route { Expanded = expanded };
if (goal < 0) return r; // no path — an empty route, reported upstream
for (int c = goal; c >= 0; c = parent[c]) r.Path.Add((c / n, c % n));
r.Path.Reverse();
r.Reached = true;
r.Cost = gcost[goal];
r.Target = r.Path[^1];
Measure(r, height, n, sea);
r.Smoothed = SmoothCourse(r.Path);
return r;
}
/// <summary>
/// The diagnostics the gate reads — measured on the RAW path, before smoothing, because the
/// rim it crossed is a fact about the terrain and must not be a function of the pretty pass.
/// </summary>
private static void Measure(Route r, float[,] height, int n, float sea)
{
float startElev = ElevAt(r.Path[0]);
float maxElev = startElev;
r.RimPoint = r.Path[0];
for (int i = 1; i < r.Path.Count; i++)
{
var a = r.Path[i - 1]; var b = r.Path[i];
float dx = b.x - a.x, dy = b.y - a.y;
r.LenPx += MathF.Sqrt(dx * dx + dy * dy);
float climb = ElevAt(b) - ElevAt(a);
if (climb > 0f) { r.TotalUphillM += climb; r.UphillSteps++; }
if (climb > r.MaxStepUphillM) r.MaxStepUphillM = climb;
if (ElevAt(b) > maxElev) { maxElev = ElevAt(b); r.RimPoint = b; }
}
r.MaxElevM = maxElev;
r.RimClimbM = maxElev - startElev;
var s = r.Path[0]; var e = r.Path[^1];
r.StraightPx = MathF.Sqrt((e.x - s.x) * (e.x - s.x) + (e.y - s.y) * (e.y - s.y));
// ⚠ Wander is POLYLINE length over straight-line — a cell count undercounts diagonal steps
// and can read below 1, which is geometrically impossible. (The reference's own fix.)
r.WanderRatio = r.StraightPx > 1f ? r.LenPx / r.StraightPx : 1f;
float ElevAt((float x, float y) p) =>
MathF.Max(0f, WorldScale.MetresFromRaw(height[(int)p.x, (int)p.y] - sea));
}
// ---- Route smoothing — ported verbatim: RDP(4.0) + 4 Chaikin passes, endpoints pinned ------
//
// ⚠⚠ THIS IS APPLIED TO THE LOWLAND REACH ONLY, NEVER THE UPLAND STEM, and that split is not a
// style preference — it is a measured result. The Dijkstra's 45° kinks live on near-flat ground
// where a rounded corner costs nothing. The upland stems already thread the erosion-carved
// valley FLOORS; smoothing them cuts the corners off the valleys themselves, which in the
// reference took the max cut from 14.6 m to 27.3 m.
/// <summary>RDP tol 4 + 4 Chaikin corner-cutting passes, endpoints pinned.</summary>
public static List<(float x, float y)> SmoothCourse(List<(float x, float y)> raw)
{
if (raw.Count < 3) return raw;
var dec = Rdp(raw, 0, raw.Count - 1, 4.0f);
if (dec.Count < 3) return raw;
var sm = dec;
for (int pass = 0; pass < 4; pass++)
{
var nxt = new List<(float x, float y)>(sm.Count * 2) { sm[0] };
for (int i = 0; i + 1 < sm.Count; i++)
{
var a = sm[i]; var b = sm[i + 1];
nxt.Add((a.x * 0.75f + b.x * 0.25f, a.y * 0.75f + b.y * 0.25f));
nxt.Add((a.x * 0.25f + b.x * 0.75f, a.y * 0.25f + b.y * 0.75f));
}
nxt.Add(sm[^1]);
sm = nxt;
}
return sm;
}
private static List<(float x, float y)> Rdp(List<(float x, float y)> pts, int i0, int i1, float tol)
{
if (i1 - i0 <= 1) return new List<(float x, float y)> { pts[i0], pts[i1] };
var a = pts[i0]; var b = pts[i1];
float abx = b.x - a.x, aby = b.y - a.y;
float abLen = MathF.Sqrt(abx * abx + aby * aby);
float maxD = 0f; int maxI = i0;
for (int i = i0 + 1; i < i1; i++)
{
float d = abLen < 1e-6f
? MathF.Sqrt((pts[i].x - a.x) * (pts[i].x - a.x) + (pts[i].y - a.y) * (pts[i].y - a.y))
: MathF.Abs(abx * (a.y - pts[i].y) - (a.x - pts[i].x) * aby) / abLen;
if (d > maxD) { maxD = d; maxI = i; }
}
if (maxD <= tol) return new List<(float x, float y)> { pts[i0], pts[i1] };
var left = Rdp(pts, i0, maxI, tol);
var right = Rdp(pts, maxI, i1, tol);
left.RemoveAt(left.Count - 1);
left.AddRange(right);
return left;
}
/// <summary>The three classes the MIX is made of.</summary>
public enum RiverClass
{
/// <summary>Sea-reaching already, exactly as erosion carved it. No lowland route needed.</summary>
OceanTrunk,
/// <summary>An endorheic basin connected to the coast by a routed over-the-rim channel.</summary>
RoutedGiant,
/// <summary>Stays inland: terminates at a significant lake, or at its own terminal.</summary>
LakeEnder,
// ═══ rivers/03b — two new termini, from the three approved DIVERGENCES ═══
/// <summary>⭐ rivers/03b (fix 3): a dry-basin router that reached a SIGNIFICANT LAKE before it
/// reached the sea, and terminates there. In the reference a router targets ocean only, so it
/// would skirt the lake and carry on — which is what this corrects. **No water is created.**</summary>
LakeFed,
/// <summary>⚠ rivers/03b (fix 1): a dry-basin router whose cheapest route to the sea had to
/// climb a rim HIGHER THAN THE CAP. The reference routes at any cost, which produced an
/// uphill river over a 66.7 m wall. Refused: the course ends at its own terminal — a real
/// terminal basin. **Nothing is filled; it just ends there.**</summary>
WalledOff,
}
/// <summary>
/// ⚠⚠ THE THREE DELIBERATE DIVERGENCES FROM THE REFERENCE (rivers/03b), off by default.
///
/// Defaults reproduce rivers/03's faithful port EXACTLY — no cap, ocean-only targets, no
/// confluence — so that batch stays re-runnable bit-for-bit. The refinement task turns them on.
/// **None of these is a port. Each is a motivated correction of a faithful behaviour that
/// produced a physically-wrong result**, on the developer's explicit call.
/// </summary>
public sealed class Options
{
/// <summary>⭐ FIX 1 — the rim cap, metres. A route to the sea that must climb higher than
/// this above its terminal is refused and the river becomes a walled-off lake-ender.
/// Infinity = the reference's behaviour (route at any cost).</summary>
public float RimCapM = float.PositiveInfinity;
/// <summary>⭐ FIX 3 — include significant lakes in a ROUTER's target mask, so a river stops
/// at the nearer of {ocean, significant lake} instead of skirting a lake to reach the sea.
/// False = the reference's behaviour (routers target ocean only).</summary>
public bool LakeTargetForRouters;
/// <summary>
/// ⭐⭐ rivers/03c FIX A — lake-termination becomes a PREFERENCE instead of an unconditional
/// capture. Set > 0 to enable; it then supersedes the plain nearest-of-union rule above.
///
/// ⚠⚠ WHY rivers/03b OVERSHOT. "Nearest of {ocean lake}" lets a lake that is merely a
/// *little* closer capture a river that had a clear shot at the coast — and it moved **12
/// rivers** to lake-fed, roughly halving the island's sea mouths (5/5/6/5 → 4/2/3/3). The
/// rule here is deliberately sea-biased instead:
/// <code>
/// lake-fed iff cost_lake &lt; LakePreferRatio × cost_ocean
/// </code>
/// so a lake must be MATERIALLY cheaper to reach, not just nearer. **Lower ratio → more sea
/// rivers.** Both costs are recorded per router whether or not the lake wins, so the knob can
/// be read off the table without a re-run.
/// </summary>
public float LakePreferRatio;
/// <summary>
/// ⭐⭐ rivers/03c FIX B — a NATURAL lake-ender terminates at the water inside its OWN
/// terminal basin, at any size.
///
/// ⚠ The 20,000 px significance threshold is what exiled `999999937 #3` from its own home:
/// its basin's lake was sub-threshold, so it marched ~5,800 px along the shoreline hunting a
/// distant "significant" body. A basin's own water is where its flow goes regardless of how
/// big it is. **The threshold still applies to ROUTERS choosing a DISTANT lake** — a dry
/// basin still cannot connect itself to a three-cell puddle.
/// </summary>
public bool OwnBasinLakeEnder;
/// <summary>⭐ FIX 2 — the confluence post-pass: courses laid biggest-first join on true cell
/// intersection instead of running as parallel duplicates to the same mouth.
/// False = the reference's behaviour (no dedup, no join).</summary>
public bool Confluence;
/// <summary>rivers/03's faithful settings — every divergence off.</summary>
public static Options Faithful => new();
}
/// <summary>One promoted river, classified, routed and assembled.</summary>
public sealed class RoutedRiver
{
public RiverCandidate Candidate;
public RiverClass Class;
/// <summary>The lowland reach actually used: the ocean route for a routed giant, the lake
/// route for a lake-ender. Null for trunks.</summary>
public Route Lowland;
/// <summary>⭐ The ocean route computed for EVERY giant, including lake-enders — see the note
/// on <see cref="RouteAll"/>. This is what makes an affordability threshold judgeable.</summary>
public Route OceanProbe;
/// <summary>Lake-enders: did the extension reach a SIGNIFICANT body (vs the classify fallback, vs nothing)?</summary>
public bool LakeReached, LakeWasFallback;
/// <summary>Head → terminus, stem + smoothed lowland reach.</summary>
public List<(float x, float y)> Course;
public string Why = "";
// ═══ rivers/03b ═══
/// <summary>⚠ The rim climb that was tested against the cap, and whether it was refused.</summary>
public float CappedRimM;
public bool RefusedByCap;
/// <summary>⭐ rivers/03c FIX A's lever, recorded for EVERY router — lake-fed or not — so the
/// developer can read off which ratio value flips which river without a re-run.
/// <see cref="CostRatio"/> is cost_lake / cost_ocean; a river is lake-fed iff it is below
/// the configured ratio. NaN where the leg was not reachable.</summary>
public float CostOcean = float.NaN, CostLake = float.NaN, CostRatio = float.NaN;
/// <summary>Lake-enders (fix B): the route to its own basin's water, for the coast-hugger check.</summary>
public bool OwnBasinTargeted;
/// <summary>The class this river WOULD have had under the reference's rules — so every
/// reclassification the divergences caused is legible rather than silent.</summary>
public RiverClass FaithfulClass;
/// <summary>The full course rasterised to cells — what the confluence test intersects on.</summary>
public List<(int x, int y)> CellPath;
/// <summary>⭐ What this river draws: its OWN reach, truncated at its junction if it joined.
/// The union of every river's own reach is the dendritic tree.</summary>
public List<(float x, float y)> OwnPath;
/// <summary>The rank of the river this one flows into, or 0 if it keeps its own terminus.</summary>
public int ConfluenceParentRank;
public bool Joined;
public (int x, int y) JunctionCell;
/// <summary>How many leading cells of <see cref="CellPath"/> are the NATURAL upland stem.
/// Everything after is the lowland reach routing added — the plate colours the two apart.</summary>
public int StemCells;
/// <summary>Does this river's own course end at the sea, before any confluence?</summary>
public bool ReachesSea => Class == RiverClass.OceanTrunk || Class == RiverClass.RoutedGiant;
}
/// <summary>
/// ⭐⭐ CLASSIFY AND ROUTE THE PROMOTED SET.
///
/// ═══ ⚠⚠⚠ WHAT DECIDES routed-vs-lake-ender, AND WHY IT IS NOT A PATH TEST ═══
///
/// rivers/03's task states the sort as *"an affordable over-the-rim LOWGROUND path to the ocean
/// exists → routed-through; none → lake-ender."* **Ported literally, that test classifies
/// everything as routed, because on an 8-connected grid with all-finite costs a path to the
/// ocean ALWAYS exists.** `RouteTo` returns empty only when the queue drains without reaching a
/// target, which cannot happen when the ocean is reachable at *some* price. There is no "none".
/// The word doing the work is *affordable*, and no threshold is specified anywhere.
///
/// **So the reference's sort is used, because it is the one that actually discriminates:**
/// <code>
/// Kind = (basinHasLake[id] &amp;&amp; !SouthernCandidate) ? "lake-ender" : "routed"
/// </code>
/// i.e. **does the terminal basin hold classify water?** A basin that is already a lake is a
/// natural lake-ender; a dry pan gets routed to the sea. That is `DrainageAnalysis`'s own
/// verdict, carried on `Giant.Kind`, and this port consumes it rather than inventing a rule.
/// (v2 has no towns, so `southernPick` is 1 and the southern override never fires.)
///
/// ⭐ **And the missing threshold is surfaced rather than guessed:** the ocean route is computed
/// for EVERY giant, lake-enders included (<see cref="RoutedRiver.OceanProbe"/>), so the batch can
/// report what each one WOULD cost and how high a rim it WOULD have to cross. That turns
/// "affordable" from an unstated assumption into a number the developer can put a bar under.
/// **Nothing is locked here — the classification shown is the reference's.**
/// </summary>
public static List<RoutedRiver> RouteAll(List<RiverCandidate> promoted, float[,] height, int n,
bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, byte style,
Action<string> log, Options opt = null, int[] basinId = null)
{
opt ??= Options.Faithful;
if (opt.OwnBasinLakeEnder && basinId == null)
throw new InvalidOperationException(
"[RiverRouting] OwnBasinLakeEnder needs Plan.BasinId to know which water is a basin's OWN. " +
"Pass it; refusing to silently fall back to the distant-significant-body rule that produced the coast-hugger.");
// ⭐ FIX 3 — the router's target mask. With the divergence off this is the ocean alone, which
// is the reference. With it on, a significant lake is an equally valid place for a river to
// stop, so the Dijkstra halts at whichever it reaches first and a river can no longer skirt
// a lake on its way to a distant coast.
bool[] routerTargets = isOcean;
if (opt.LakeTargetForRouters)
{
routerTargets = new bool[n * n];
for (int i = 0; i < routerTargets.Length; i++)
routerTargets[i] = isOcean[i] || isSignificantWater[i];
}
var outp = new List<RoutedRiver>();
foreach (var c in promoted)
{
var rr = new RoutedRiver { Candidate = c };
if (c.IsSea)
{
// A natural ocean trunk needs no lowland route: erosion already carried it to the
// coast, and its outlet is ON the coast by construction. The stem IS the course.
rr.Class = RiverClass.OceanTrunk;
rr.Course = new List<(float x, float y)>(c.Course);
rr.Course.Reverse();
rr.Why = "sea outlet — erosion already reaches the coast; no lowland route needed";
outp.Add(rr);
log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px TRUNK (natural, {rr.Course.Count} pts)");
continue;
}
// ⭐ The ocean probe, for every giant — the affordability evidence (rivers/03).
var probe = RouteTo(height, n, isOcean, c.TermX, c.TermY, style, sea);
rr.OceanProbe = probe;
// ⚠ `basinHasLake` is KEPT as the sort (rivers/03's finding): a basin that already holds a
// visible lake is a natural lake-ender and its river feeds its own lake — it is not routed
// anywhere. Only DRY basins are candidate routers. None of the three divergences touches this.
bool refLakeEnder = c.AnalysisKind == "lake-ender";
if (!refLakeEnder)
{
rr.FaithfulClass = RiverClass.RoutedGiant;
Route route;
bool stoppedAtLake;
if (opt.LakePreferRatio > 0f)
{
// ⭐⭐ rivers/03c FIX A — the two legs are costed SEPARATELY and compared, instead of
// racing in one search. That is the whole difference: a shared search returns
// whichever is nearer, this one returns the sea unless the lake is materially cheaper.
var lakeLeg = RouteTo(height, n, isSignificantWater, c.TermX, c.TermY, style, sea);
rr.CostOcean = probe.Reached ? probe.Cost : float.NaN;
rr.CostLake = lakeLeg.Reached ? lakeLeg.Cost : float.NaN;
rr.CostRatio = probe.Reached && lakeLeg.Reached && probe.Cost > 0f
? lakeLeg.Cost / probe.Cost : float.NaN;
// ⚠ No reachable lake → the sea, always. No reachable ocean → the lake if there is one.
stoppedAtLake = lakeLeg.Reached && probe.Reached
&& lakeLeg.Cost < opt.LakePreferRatio * probe.Cost;
if (lakeLeg.Reached && !probe.Reached) stoppedAtLake = true;
route = stoppedAtLake ? lakeLeg : probe;
}
else
{
// rivers/03b — the nearest of the union mask, whichever that turns out to be.
route = opt.LakeTargetForRouters
? RouteTo(height, n, routerTargets, c.TermX, c.TermY, style, sea)
: probe;
stoppedAtLake = route.Reached
&& isSignificantWater[(int)route.Target.x * n + (int)route.Target.y]
&& !isOcean[(int)route.Target.x * n + (int)route.Target.y];
}
rr.Lowland = route;
rr.CappedRimM = route.Reached ? route.RimClimbM : 0f;
if (!route.Reached)
{
rr.Class = RiverClass.RoutedGiant;
rr.Why = "dry pan → routed, but NO path to a target was found (unexpected — report)";
}
else if (stoppedAtLake)
{
// It ends at a significant lake — because that lake was nearer (03b) or materially
// cheaper (03c). NO WATER CREATED: the course simply ends at an existing body.
rr.Class = RiverClass.LakeFed;
rr.Why = opt.LakePreferRatio > 0f
? $"dry pan → LAKE-FED: reaching a significant lake costs {rr.CostLake:N0} vs {rr.CostOcean:N0} to the sea (ratio {rr.CostRatio:F3} < {opt.LakePreferRatio:F2}) — materially cheaper, so it ends at the lake"
: $"dry pan → reached a SIGNIFICANT LAKE at ({(int)route.Target.x},{(int)route.Target.y}) before the sea, {route.LenPx:F0} px away — terminates there (faithful: would have skirted it for the coast)";
}
else if (route.RimClimbM > opt.RimCapM)
{
// ⭐ FIX 1 — the cheapest way to the sea still climbs a wall. Refuse it. The course
// ends at its own terminal, a real terminal basin. NOTHING IS FILLED.
rr.Class = RiverClass.WalledOff;
rr.RefusedByCap = true;
rr.Lowland = null;
rr.Why = $"dry pan → WALLED OFF: cheapest route to the sea climbs {route.RimClimbM:F1} m > cap {opt.RimCapM:F0} m (cost {route.Cost:N0}) — ends at its own terminal";
}
else
{
rr.Class = RiverClass.RoutedGiant;
rr.Why = $"dry pan → routed to the SEA; rim climb {route.RimClimbM:F1} m ≤ cap {(float.IsInfinity(opt.RimCapM) ? "none" : opt.RimCapM.ToString("F0") + " m")}, cost {route.Cost:N0}" +
(opt.LakePreferRatio > 0f && !float.IsNaN(rr.CostRatio) ? $"; the nearest lake was not materially cheaper (ratio {rr.CostRatio:F3} ≥ {opt.LakePreferRatio:F2})" : "");
}
}
else
{
rr.Class = RiverClass.LakeEnder;
// The stem pools on dry ground short of its lake BECAUSE the pooling point is a local
// minimum — a blind descent dead-ends there immediately. Route with the same lowground
// Dijkstra so the course actually joins the water.
// ⚠ Lake-enders route with LOWGROUND regardless of the style knob (the reference's rule).
Route ext;
if (opt.OwnBasinLakeEnder)
{
// ⭐⭐ rivers/03c FIX B — its OWN basin's water, at any size. This is where its flow
// goes; it has no business hunting a distant body. Killing the coast-hugger outright.
var ownWater = new bool[n * n];
int owned = 0;
for (int i = 0; i < ownWater.Length; i++)
if (basinId[i] == c.BasinId && isClassifyWater[i] && !isOcean[i]) { ownWater[i] = true; owned++; }
if (owned > 0)
{
ext = RouteTo(height, n, ownWater, c.TermX, c.TermY, StyleLowground, sea);
rr.OwnBasinTargeted = ext.Reached;
}
else
{
// ⚠ Should not happen — basinHasLake is what put it in this branch — but a
// basin whose water is all ocean-masked would land here. Report, do not crash.
ext = new Route();
}
}
else
{
var far = RouteTo(height, n, isSignificantWater, c.TermX, c.TermY, StyleLowground, sea);
ext = far;
if (!ext.Reached)
{
// Fall back to ANY classify water, so a seed whose lake-ender genuinely has only
// small ponds still connects rather than dead-ending.
var fb = RouteTo(height, n, isClassifyWater, c.TermX, c.TermY, StyleLowground, sea);
if (fb.Reached) { ext = fb; rr.LakeWasFallback = true; }
}
}
if (ext.Reached) { rr.Lowland = ext; rr.LakeReached = true; }
rr.FaithfulClass = RiverClass.LakeEnder;
rr.Why = rr.LakeReached
? (rr.OwnBasinTargeted
? $"terminal basin holds classify water → natural lake-ender; terminates at its OWN basin's water {ext.LenPx:F0} px away (fix B — no distant-body hunt, so no coast-hugging)"
: $"terminal basin holds classify water → natural lake-ender; joins {(rr.LakeWasFallback ? "a small body (fallback)" : "a significant body")} {ext.LenPx:F0} px away")
: "terminal basin holds classify water → natural lake-ender; no water body reachable, course ends at its terminal";
}
rr.Course = Assemble(c.Course, rr.Lowland);
outp.Add(rr);
log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px {ClassLabel(rr.Class),-11} " +
$"probe{(probe.Reached ? $" cost {probe.Cost,12:N0} rim {probe.RimClimbM,6:F1} m maxstep {probe.MaxStepUphillM,5:F2} m len {probe.LenPx,6:F0} px wander {probe.WanderRatio:F2}" : " NO PATH")}" +
$"{(rr.Class == RiverClass.LakeEnder ? $" | lake {(rr.LakeReached ? (rr.LakeWasFallback ? "fallback" : "significant") : "NONE")}" : "")}" +
$"{(rr.RefusedByCap ? " REFUSED BY CAP" : "")}" +
$"{(rr.Class == RiverClass.LakeFed ? $" LAKE-FED (ratio {rr.CostRatio:F3})" : "")}" +
$"{(!float.IsNaN(rr.CostRatio) && rr.Class == RiverClass.RoutedGiant ? $" SEA (lake ratio {rr.CostRatio:F3})" : "")}" +
$"{(rr.OwnBasinTargeted ? $" own-basin water, {rr.Lowland.LenPx:F0} px" : "")}");
}
if (opt.Confluence) Confluence(outp, log);
else foreach (var rr in outp) rr.OwnPath = rr.Course;
return outp;
}
public static string ClassLabel(RiverClass c) => c switch
{
RiverClass.OceanTrunk => "TRUNK",
RiverClass.RoutedGiant => "ROUTED",
RiverClass.LakeFed => "LAKE-FED",
RiverClass.WalledOff => "WALLED-OFF",
_ => "LAKE-ENDER",
};
// ═══ ⭐⭐ FIX 2 — THE CONFLUENCE POST-PASS (rivers/03b) ═══════════════════════════════════
//
// ⚠⚠ A DIVERGENCE, NOT A PORT. The reference lays every route independently and never dedups or
// joins them, which rivers/03 measured: on EVERY seed two routed rivers arrived at the identical
// ocean cell without ever having met. Two channels reaching the same mouth as parallel
// duplicates is not geography; two channels that meet and continue as one is.
//
// The rule, deliberately strict: courses are laid BIGGEST-FIRST by drainage, and a later course
// joins an earlier one only on TRUE CELL INTERSECTION — the later course's rasterised cell path
// actually reaching a cell an earlier one occupies. **Never proximity.** Two rivers running 3 px
// apart down the same valley stay two rivers; that is a question for the carve's channel width,
// not for routing to guess at.
/// <summary>
/// Join intersecting courses into a dendritic tree. Biggest-first, so the largest drainage is
/// the trunk and smaller ones become its tributaries — the later river is truncated at the
/// FIRST (most-upstream) cell it shares with an already-laid course, and adopts that course's
/// downstream and terminus from there.
/// </summary>
internal static void Confluence(List<RoutedRiver> rivers, Action<string> log)
{
var order = new List<RoutedRiver>(rivers);
order.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx));
// cell -> (the river occupying it, and how far along that river's cell path it sits)
var owner = new Dictionary<(int x, int y), (RoutedRiver river, int idx)>();
int joins = 0;
foreach (var r in order)
{
r.CellPath = Rasterise(r.Course);
var stemOnly = new List<(float x, float y)>(r.Candidate.Course);
stemOnly.Reverse();
r.StemCells = Rasterise(stemOnly).Count;
if (r.CellPath.Count == 0) { r.OwnPath = r.Course; continue; }
// The first cell of THIS course that someone bigger already occupies.
int hit = -1;
(RoutedRiver river, int idx) into = default;
for (int i = 0; i < r.CellPath.Count; i++)
if (owner.TryGetValue(r.CellPath[i], out into)) { hit = i; break; }
if (hit < 0)
{
// Keeps its own route and its own mouth.
r.OwnPath = r.Course;
for (int i = 0; i < r.CellPath.Count; i++)
if (!owner.ContainsKey(r.CellPath[i])) owner[r.CellPath[i]] = (r, i);
continue;
}
// ⭐ It joins. Truncate here and adopt the parent's downstream from the junction on.
var parent = into.river;
r.Joined = true;
r.ConfluenceParentRank = parent.Candidate.Rank;
r.JunctionCell = r.CellPath[hit];
joins++;
// What it DRAWS is its own reach only, up to the junction — the union of every river's
// own reach is the tree. Drawing the adopted downstream too would just overdraw the parent.
r.OwnPath = new List<(float x, float y)>();
for (int i = 0; i <= hit; i++) r.OwnPath.Add((r.CellPath[i].x, r.CellPath[i].y));
// The full course of record: its own reach, then the parent's from the junction to the sea.
var full = new List<(int x, int y)>();
for (int i = 0; i <= hit; i++) full.Add(r.CellPath[i]);
for (int i = into.idx + 1; i < parent.CellPath.Count; i++) full.Add(parent.CellPath[i]);
r.CellPath = full;
r.Course = new List<(float x, float y)>();
foreach (var cpt in full) r.Course.Add((cpt.x, cpt.y));
// Only its OWN reach becomes occupiable, so a third river can join this tributary.
for (int i = 0; i <= hit; i++)
if (!owner.ContainsKey(full[i])) owner[full[i]] = (r, i);
log($" ⭐ CONFLUENCE: #{r.Candidate.Rank} ({r.Candidate.DrainagePx:N0} px) joins #{parent.Candidate.Rank} " +
$"({parent.Candidate.DrainagePx:N0} px) at ({r.JunctionCell.x},{r.JunctionCell.y}) — " +
$"{hit} px of its own reach, then adopts #{parent.Candidate.Rank}'s downstream and terminus");
}
if (joins == 0) log(" (no confluences — every course keeps its own mouth)");
}
/// <summary>
/// ⭐ THE ROOT of a confluence chain — the river whose terminus this one actually ends at. A
/// tributary's mouth is its trunk's mouth, so this is what mouth counting and terminus class
/// must both be read through.
/// </summary>
public static RoutedRiver Root(RoutedRiver r, List<RoutedRiver> all)
{
var cur = r;
// The chain is finite and strictly increasing in drainage (biggest-first laying), so it
// cannot cycle; the guard is belt-and-braces against a future change to the ordering.
for (int guard = 0; guard < all.Count + 1 && cur.Joined; guard++)
{
RoutedRiver parent = null;
foreach (var o in all) if (o.Candidate.Rank == cur.ConfluenceParentRank) { parent = o; break; }
if (parent == null) break;
cur = parent;
}
return cur;
}
/// <summary>
/// Rasterise a polyline to a deduped 1-px cell path. ⚠ The confluence test is a TRUE CELL
/// intersection, so the courses must be compared as the cells they occupy, not as the sparse
/// vertices the analysis decimated them to (stems are decimated ×4, routes are Chaikin-smoothed).
/// </summary>
private static List<(int x, int y)> Rasterise(List<(float x, float y)> pts)
{
var outp = new List<(int x, int y)>();
if (pts == null || pts.Count == 0) return outp;
void Push(int x, int y)
{
if (outp.Count > 0 && outp[^1].x == x && outp[^1].y == y) return;
outp.Add((x, y));
}
for (int i = 0; i + 1 < pts.Count; i++)
{
var a = pts[i]; var b = pts[i + 1];
float dx = b.x - a.x, dy = b.y - a.y;
int steps = Math.Max(1, (int)MathF.Ceiling(MathF.Max(MathF.Abs(dx), MathF.Abs(dy))));
for (int s = 0; s < steps; s++)
Push((int)MathF.Round(a.x + dx * s / steps), (int)MathF.Round(a.y + dy * s / steps));
}
Push((int)MathF.Round(pts[^1].x), (int)MathF.Round(pts[^1].y));
return outp;
}
/// <summary>
/// ⭐ Assemble one river's full course: upland stem (head → terminal) + the smoothed lowland
/// reach (terminal → target).
///
/// ⚠ `Course` from the analysis is DOWNSTREAM-FIRST and decimated ×4, so it is reversed to run
/// head → terminal, exactly as the reference does. The route's first point IS the terminal, so
/// it is skipped when splicing — otherwise the join carries a duplicate vertex.
///
/// ⚠ The reference then DENSIFIES the spliced polyline to ~1-px samples. That is done inside
/// `CarveRiver`, for the bed stamp — it is carve-time and deliberately not done here: this task
/// produces courses, and a densified polyline draws and measures identically.
/// </summary>
public static List<(float x, float y)> Assemble(List<(float x, float y)> uplandStem, Route lowland)
{
var pts = new List<(float x, float y)>(uplandStem);
pts.Reverse(); // downstream-first → head → terminal
if (lowland != null && lowland.Smoothed != null && lowland.Smoothed.Count > 1)
pts.AddRange(lowland.Smoothed.GetRange(1, lowland.Smoothed.Count - 1));
return pts;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -126,6 +126,10 @@ namespace IslaApocalypse.Tools
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
int task = EnvInt("ISLA_TASK", 4);
string descr = EnvStr("ISLA_BATCH", "seed_gallery");
@ -180,7 +184,7 @@ namespace IslaApocalypse.Tools
var poolPass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
poolPass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
@ -288,13 +292,18 @@ namespace IslaApocalypse.Tools
public ulong ElapsedMs;
}
/// <remarks>
/// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. chat2/04 is the PRE-FAMILY committed-curve
/// gallery (`terrain-curve-v1`); the re-baseline flipped the bare defaults family-ON, and this
/// tool must keep producing the curve gallery it was judged as. → TerrainGenConfig.WithFamilyOff().
/// </remarks>
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,
};
}.WithFamilyOff();
private static SeedMetrics WriteSeed(string batchRoot, Pass1Result p1, Pass2Result p2,
float sea, CurveAnchors anchors, bool skipRaw)

View file

@ -675,6 +675,82 @@ namespace IslaApocalypse.Tools
return c;
}
// ═══ ⭐⭐ ANCHOR RESOLUTION — A MISSING ANCHOR IS LOUD (rivers/01) ═══════════════════════════
//
// ═══ THE FAILURE MODE THIS CLOSES ═══
//
// Every anchor check used to be written as:
//
// if (File.Exists(dump) && mapSize == 8192) hard.Add(DumpRegression(...));
// else GD.Print(" ⚠ skipped — no dump at …");
//
// so a moved, renamed or deleted anchor did not make the oracle FAIL. It made the oracle
// NOT RUN — and a batch with a silently-skipped check prints an all-PASS table and reads
// exactly like a clean one. The `INDEX.md` is then evidence for a claim nothing checked.
//
// > ### ⚠ This is the INVERSE of the hazard the re-baseline guards against.
// > The re-baseline stops an oracle PASSING FOR THE WRONG REASON. This stops one
// > DISAPPEARING FOR NO REASON. Both end with a green table and an unproven claim, and the
// > rivers/01 batch-root migration is exactly the event that would have triggered the second
// > one — nine anchors moved under `<chat>/` in a single commit.
//
// The two cases the old code conflated are now separated, in ONE place (`LoadAnchor`) so every
// anchor site behaves identically:
// MISSING FILE the anchor moved, was renamed, or was never written. THROWS — the check
// cannot run, and dropping it quietly is the failure this exists to close.
// WRONG SIZE the anchor exists, but only at the size it was captured. A legitimate case
// (a probe run at another size); reported loudly and recorded INCONCLUSIVE,
// which is a FAIL in the table. ⚠ Deliberately NOT a throw: a guard that fires
// on ordinary small-map probe work is a guard people learn to route around.
/// <summary>
/// ⭐ Load a regression anchor's `.f32`, separating the two failures the old code conflated.
///
/// FILE ABSENT → THROWS. The anchor moved, was renamed, or was never written.
/// This is the migration hazard, and it is not survivable: the
/// check cannot run and must not be quietly dropped.
/// PRESENT, WRONG SIZE → returns null, LOUDLY. A legitimate case — an anchor exists only
/// at the size it was captured, and a batch run at another size
/// genuinely cannot check against it. The caller's
/// <see cref="DumpRegression"/> records it INCONCLUSIVE, which is
/// a FAIL in the table, never a pass.
///
/// ⚠ The distinction matters because only ONE of them means something is broken. Throwing on a
/// size mismatch would make every small-map probe run refuse, and a guard that fires on ordinary
/// work is a guard people route around.
/// </summary>
/// <param name="checkId">The oracle check this anchor feeds, e.g. "a10" — named in the message.</param>
/// <param name="envVar">The env override that can re-point it, e.g. "ISLA_T10_SOURCE".</param>
/// <param name="dumpPath">The resolved absolute path.</param>
/// <param name="mapSize">The field size to read.</param>
public static float[,] LoadAnchor(string checkId, string envVar, string dumpPath, int mapSize)
{
if (!System.IO.File.Exists(dumpPath))
throw new InvalidOperationException(
$"[Oracle] MISSING ANCHOR for check '{checkId}' — nothing at:\n" +
$" {dumpPath}\n" +
"An oracle whose anchor is absent does not fail, it does not RUN — and a batch with a " +
"silently-skipped check prints an all-PASS table that reads exactly like a clean one. " +
"Refusing to render evidence for a claim nothing checked.\n" +
$"→ Re-point it with {envVar}, or regenerate the anchor. If the anchor is genuinely " +
"retired, DELETE THE CHECK — never leave one aimed at nothing. (rivers/01.)");
long expected = (long)mapSize * mapSize * 4;
long actual = new System.IO.FileInfo(dumpPath).Length;
if (actual != expected)
{
int anchorSize = (int)System.Math.Round(System.Math.Sqrt(actual / 4.0));
Godot.GD.PrintErr(
$" ⚠⚠ {checkId}: NOT CHECKED — the anchor exists but was captured at {anchorSize}, " +
$"and this run is at {mapSize} ({actual:N0} bytes, expected {expected:N0}). This is a size " +
$"mismatch, NOT a missing file: the batch is simply not proven against it at this size. " +
$"Run at {anchorSize} to check it. The oracle records INCONCLUSIVE, which is a FAIL — never a pass.");
return null;
}
return HeightField.Load(dumpPath, mapSize);
}
/// <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

@ -41,7 +41,6 @@ namespace IslaApocalypse.Tools
/// 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
{
@ -64,7 +63,6 @@ namespace IslaApocalypse.Tools
private const int DefaultMapSize = 4096;
private const int DefaultCalibSize = 2048;
private const int GallerySize = 8192;
public override void _Ready()
{
@ -91,6 +89,10 @@ namespace IslaApocalypse.Tools
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
int task = EnvInt("ISLA_TASK", 8);
string descr = EnvStr("ISLA_BATCH", "southern_stretch_explore");
@ -103,11 +105,8 @@ namespace IslaApocalypse.Tools
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 p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
@ -136,16 +135,26 @@ namespace IslaApocalypse.Tools
TerrainGenConfig Cfg(int size, int seed, string label, float stretch, bool sinkerStretched, bool sinkerOn = true, bool edgeOn = true)
{
// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. This tool is chat-2 shaping DEVELOPMENT:
// it was authored and judged before the shape family existed, and its regression checks
// hold pass 1 against the FAMILY-OFF `02_pass1_port` dump. The re-baseline flipped the
// bare defaults family-ON, so without this pin every config here would silently acquire
// stretch + fragmentation and every anchor check would fail for a configuration reason.
// → TerrainGenConfig.WithFamilyOff().
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,
RegionLabeling = true,
SouthernSinker = sinkerOn, EdgeNoise = edgeOn,
};
}.WithFamilyOff();
// …then the swept axis, AFTER the pin. Coastal fragmentation stays OFF here: chat2/08
// predates it, and this tool's ladder measures the stretch ALONE.
c.SouthStretch = stretch;
c.SouthBandStartFrac = bandStart;
c.SouthBandFeatherFrac = bandFeather;
c.StretchSinker = sinkerStretched;
return c;
}
@ -255,34 +264,31 @@ namespace IslaApocalypse.Tools
var offCfg = Cfg(calibSize, plate, "off", 0f, stretchSinker);
Pass1Result p1 = Topography.Generate(offCfg);
var curveOff = offCfg.Clone(); curveOff.Curve = false;
// ⭐ a1 KEPT at rivers/01 — the family-off pass-1 guard (config pinned family-off). ⚠ loud.
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));
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, stretch OFF == Phase-1 .f32 dump",
Shaping.Shape(p1, curveOff).Height, ShapingOracle.LoadAnchor("a1", "ISLA_PHASE1_SOURCE", p1Dump, calibSize), calibSize, p1Dump));
// ⭐ a3b — stretch ON at the ladder's TOP: north of the band bit-identical to the tag's own dump.
// ⭐ a3c KEPT and RE-POINTED — the north-locked invariant is the load-bearing claim of the
// southern stretch (→ D-065) and it does NOT need an external anchor: the unstretched field
// is generated right here. Re-pointing it off `03_mountain_restore` is what let that dump
// retire without losing the guarantee.
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));
Pass2Result pUnstretched = Shaping.Shape(p1, offCfg);
hard.Add(ShapingOracle.NorthLocked("a3c",
$"stretch {maxStretch:G3} ON: north of the band bit-identical to the SAME-RUN unstretched field; changes only in/below the band",
pTop.Height, pUnstretched.Height, 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)");
// ⚑ RETIRED at rivers/01 — a3 (`03_mountain_restore`) and a4b (`04_seed_gallery`).
// a3 was a curve-development intermediate, subsumed by the terrain-shape-v1 acceptance.
// a4b asserted the north-lock against the PRE-FAMILY 8192 gallery — but the north-lock is
// now proven scale-free against a same-run field (a3c above), so the external anchor bought
// nothing except an 8192 generation on every run.
// The dump is NOT deleted (file-safety; regenerable, and the record of what was judged);
// its `INDEX.md` is marked superseded. → XX_Human/output/rivers/01_*.report.md §A4.
}
// ═══ 4. THE LADDER — 5 levels × 2 seeds ═══
@ -420,7 +426,7 @@ namespace IslaApocalypse.Tools
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
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s)); // family-off PINNED (rivers/01), not defaulted
pass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
@ -433,11 +439,14 @@ namespace IslaApocalypse.Tools
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
// ⭐ rivers/01: family-off PINNED, like the pool it shapes. (The family acts in pass 1 and
// `Shaping.Shape` never reads it, so this is inert today — pinned anyway so "the whole
// calibration is family-off" is a total claim rather than a field-by-field one.)
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
};
}.WithFamilyOff();
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);

View file

@ -242,14 +242,20 @@ namespace IslaApocalypse.Tools
// ---- PASS 1b — the coast shelf + offshore islets (chat2/05) ----------
//
// ⚠⚠ BOTH DEFAULT OFF, DELIBERATELY — and that is a decision to revisit, not an oversight.
// ⚠⚠ BOTH STAY OFF AFTER THE rivers/01 RE-BASELINE — and each for its own reason.
//
// 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.
// SHELF OFF because the LOCKED SHAPE has no shelf. `terrain-shape-v1` (a59e52f) was
// judged with `CoastShelf = false`, so turning it on here would produce terrain
// the developer never approved and would break the bit-identity this default set
// exists to guarantee. The shelf is below-sea only and invisible until water
// renders — EVALUATING IT IS ITS OWN LATER TASK (→ D-041), once water renders.
// (The rivers kickoff's "set shelf ON" was a mis-statement; corrected by the
// developer via master before rivers/01 ran.)
// ISLETS OFF because it is the DROPPED mechanism (→ D-063): islands are ORGANIC-ONLY,
// produced by the southern stretch + coastal fragmentation and then IDENTIFIED by
// the region layer — never placed. The pass is kept, not deleted, because
// `OffshoreSettings.Faithful` is a live port-fidelity control and `Pass1Result`
// carries the offshore seam. ⚠ NEVER RE-ENABLE IT IN THE BARE DEFAULT.
/// <summary>
/// The submarine coast shelf (<c>IslandFalloff.CoastShelf</c>). Below-sea only,
@ -289,16 +295,27 @@ namespace IslaApocalypse.Tools
/// <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.
/// ⭐ ON BY DEFAULT since rivers/01 — it is part of the LOCKED SHAPE (`terrain-shape-v1`).
///
/// ⚠ It was default-OFF through chat 2 because turning it on moves the calibration pool's land
/// histogram, the curve knots and every pre-family regression dump at once. rivers/01 is the
/// task that owned that flip: the calibration pool is now pinned FAMILY-OFF
/// (<see cref="WithFamilyOff"/>), so the knots are unmoved and the flip is terrain-only.
/// </summary>
public bool SpeckRevert = false;
public bool SpeckRevert = true;
/// <summary>The revert threshold, as a fraction of the map's AREA (scale-free). → <see cref="RegionPass.ThresholdMidFrac"/>.</summary>
public float MinLandComponentFrac = RegionPass.ThresholdMidFrac;
/// <summary>
/// The revert threshold, as a fraction of the map's AREA (scale-free).
///
/// ⚠⚠ THE LOCKED-SHAPE VALUE, PINNED AS A LITERAL — 2.5e-7 ≈ 4 cells at 4096, ≈ 17 at 8192.
/// It is deliberately NOT <see cref="RegionPass.ThresholdMidFrac"/> (3e-5, ~2,013 cells at
/// 8192), which was the pre-re-baseline default and is 120× larger: at that threshold the
/// revert eats real islands rather than specks. The three named `RegionPass.Threshold*Frac`
/// values are the chat2/07 exploration ladder; this is the value chat2/0910 froze and the
/// developer judged. Changing it changes `terrain-shape-v1`. (rivers/01, from
/// `FragGalleryTool.FrozenSpeckFrac`.)
/// </summary>
public float MinLandComponentFrac = 2.5e-7f;
// ---- PASS 1 — THE SOUTHERN STRETCH (chat2/08, exploration) ----------------
//
@ -311,14 +328,30 @@ namespace IslaApocalypse.Tools
// 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 SWEPT AXIS. 0 = off (bit-identical to the unstretched field everywhere). Stretch
/// factor inside the band: 1 ⇒ the southward distance is halved, 3 ⇒ quartered.
///
/// ⭐ 2 IS THE LOCKED-SHAPE VALUE since rivers/01 (→ D-065). With coastal fragmentation it is
/// one of the two mechanisms that MAKE the islands (→ D-063).
/// </summary>
public float SouthStretch = 2f;
/// <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 band's FIXED latitude line, fraction of the map (y runs south). Sea identity is hard
/// above it. A constant for a whole batch.
/// ⚠ Pinned as a LITERAL at the locked-shape value (equals `SouthernStretch.DefaultBandStartFrac`
/// today). The literal is the pin: retuning that constant must not silently move
/// `terrain-shape-v1`. (rivers/01.)
/// </summary>
public float SouthBandStartFrac = 0.70f;
/// <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>
/// The feather width across which the stretch ramps 0 → 1 (smoothstep), fraction of the map.
/// ⚠ Pinned as a LITERAL at the locked-shape value (equals `SouthernStretch.DefaultBandFeatherFrac`
/// today) — same reason as <see cref="SouthBandStartFrac"/>. (rivers/01.)
/// </summary>
public float SouthBandFeatherFrac = 0.05f;
/// <summary>
/// Does the SOUTHERN SINKER ride the stretched distance (true — it is part of the southern
@ -326,7 +359,8 @@ namespace IslaApocalypse.Tools
/// 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;
/// <remarks>⚠ Pinned as a LITERAL at the locked-shape value (equals `SouthernStretch.DefaultStretchSinker` today). rivers/01.</remarks>
public bool StretchSinker = true;
// ---- PASS 1 — COASTAL FRAGMENTATION (chat2/09, exploration) ------------------
//
@ -336,34 +370,59 @@ namespace IslaApocalypse.Tools
// 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 SWEPT AXIS. 0 = off (bit-identical everywhere). Peak |Δfalloff| (pre-power) at the
/// window's centre.
///
/// ⭐ 0.5 IS THE LOCKED-SHAPE VALUE since rivers/01 — chat2/09's `frag_4`, frozen by chat2/10
/// across an 8-seed gallery and tagged `terrain-shape-v1`. With the southern stretch it is one
/// of the two mechanisms that MAKE the islands (→ D-063).
/// </summary>
public float FragmentAmp = 0.5f;
/// <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 fragmentation noise's frequency, periods per map width — the neck/lobe scale.
/// ⚠ Pinned as a LITERAL at the locked-shape value (equals `CoastalFragment.DefaultFreqPerMapWidth`
/// today); the literal is the pin. (rivers/01.)
/// </summary>
public float FragmentFreqPerMapWidth = 12f;
/// <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>
/// 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.
/// ⚠ Pinned as LITERALS at the locked-shape values (equal `CoastalFragment.DefaultBandCentre` /
/// `DefaultBandHalfWidth` today). (rivers/01.)
/// </summary>
public float FragmentBandCentre = 0.66f;
public float FragmentBandHalfWidth = 0.18f;
/// <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;
/// <remarks>⚠ Pinned as a LITERAL at the locked-shape value (equals `CoastalFragment.DefaultBitesOnly` today). rivers/01.</remarks>
public bool FragmentBitesOnly = false;
// ---- 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).
// ⭐ DEFAULT ON since rivers/01 — the locked baseline the rivers epic routes on is the ERODED
// render field. The governors + physics are the reference ConfigManager's declared defaults,
// clamped as it clamped them (→ ErosionPass).
//
// ⚠ This flag is INERT unless a caller explicitly runs `ErosionPass.Apply` — nothing in
// `Topography.Generate` or `Shaping.Shape` reads it. So flipping it moves no field on its own;
// it makes "erode by default" the answer for the callers that DO ask.
/// <summary>⭐ Erosion on/off. Render only. Default OFF (see above).</summary>
public bool Erosion = false;
/// <summary>
/// ⭐ Erosion on/off. RENDER MAP ONLY — the classify field never sees it (→ D-046), proven by
/// `ErosionPass`'s flood guard. Default ON since rivers/01 (see above); the erosion A/B sets
/// it to false explicitly for its OFF half.
/// </summary>
public bool Erosion = true;
/// <summary>Governor 1 — droplet count. Reference 250000, clamp [0, 50,000,000].</summary>
public int ErosionDropletCount = 250000;
@ -401,6 +460,75 @@ namespace IslaApocalypse.Tools
/// <summary>The scale object every distance and frequency in the generator derives from.</summary>
public GenerationScale Scale => new GenerationScale(MapSize);
// ═══ ⭐⭐ THE FAMILY-OFF PIN (rivers/01) ═══════════════════════════════════════════════════
//
// ═══ WHY THIS EXISTS — the preserve mechanism for `terrain-shape-v1` ═══
//
// The locked shape's curve knots are PERCENTILES OF THE FAMILY-OFF LAND DISTRIBUTION, measured
// by chat2/01 over a 6-seed pool at 2048 and applied to FAMILY-ON generation. That was not a
// choice at the time — it was simply what the bare defaults produced, because the shape family
// defaulted off.
//
// rivers/01 flipped those defaults ON. Left alone, every calibration pool would have moved
// with them (fragmentation removes coastal land, the stretch adds southern land — both change
// the land CDF), the six knots would have moved, and with them the render field of EVERY
// batch, including `10_frag4_seed_gallery` (= `terrain-shape-v1`) and `11_erosion`. The
// developer's ruling was to PRESERVE the locked terrain bit-identically, so the pool is pinned
// here instead of the knots being baked: CALIBRATION STAYS LIVE, its INPUT DISTRIBUTION is
// what is held still.
//
// ⚠ At the moment it was introduced this was a NO-OP BY CONSTRUCTION: it sets exactly the
// values the bare defaults carried the instant before the flip. That is what made the flip
// provably terrain-only — and what the rivers/01 acceptance confirmed byte-for-byte at 8192².
//
// > ### ⚑ THE JUDGED-AND-PARKED PROPERTY (recorded for the vault, rivers/01)
// > The curve knots are percentiles of the FAMILY-OFF land distribution, applied to FAMILY-ON
// > terrain. That is a real asymmetry and it is DELIBERATE, not an oversight: re-pooling on
// > family-on land would move the locked shape the developer judged. Same disposition as the
// > mid-slope feather — documented, revisit only at the final palette / in the mesher if it
// > ever visibly bothers. → `Vision - Threads - Open Questions.md`.
//
// ═══ WHAT IT IS FOR, AND WHAT IT IS NOT FOR ═══
//
// USE IT for a config whose job is to REPRODUCE A PRE-FAMILY FIELD: a curve-calibration
// pool, or the "off" half of a regression check against a family-off `.f32` dump.
// DO NOT use it for generation — the locked shape IS the family, and the bare defaults now
// carry it.
/// <summary>
/// ⭐ Pin the SHAPE FAMILY and erosion OFF on this config, independent of this class's
/// evolving defaults, and return it for chaining. → the block above for why.
///
/// Sets: <see cref="CoastShelf"/> false · <see cref="Offshore"/> Off ·
/// <see cref="SpeckRevert"/> false · <see cref="SouthStretch"/> 0 ·
/// <see cref="FragmentAmp"/> 0 · <see cref="Erosion"/> false.
///
/// ⚠ It deliberately does NOT touch <see cref="MinLandComponentFrac"/> or the band/window
/// shape dials: with the revert off and the amplitudes at zero those are unread, so pinning
/// them would assert an independence that does not exist. It also does not touch the CURVE
/// (knots, anchors, calibration, climb knobs) — the family and the curve are separate axes,
/// and a calibration pool is pass-1 only.
/// </summary>
public TerrainGenConfig WithFamilyOff()
{
CoastShelf = false;
Offshore = new OffshoreSettings(); // Mode = Off
SpeckRevert = false;
SouthStretch = 0f;
FragmentAmp = 0f;
Erosion = false;
return this;
}
/// <summary>
/// ⭐ A bare pass-1 config with the shape family pinned OFF — THE CALIBRATION POOL'S CONFIG.
/// Every curve-calibration pool in `Tools/` builds its fields through this, so there is one
/// place where "what distribution were the knots measured on?" is answered.
/// → <see cref="WithFamilyOff"/>.
/// </summary>
public static TerrainGenConfig CalibrationPool(int mapSize, int seed) =>
new TerrainGenConfig { MapSize = mapSize, Seed = seed }.WithFamilyOff();
/// <summary>
/// ⚠ DEEP on <see cref="Anchors"/>. <c>MemberwiseClone</c> is shallow, so two configs cloned
/// from one parent would share a single mutable anchor object and an A/B that edited one
@ -421,6 +549,10 @@ namespace IslaApocalypse.Tools
$"[base={BaseNoise} falloff={IslandFalloff} edge={EdgeNoise} sinker={SouthernSinker} " +
$"trench={Trench} spine={MountainSpine}] " +
$"[curve={Curve} detail={ShelfDetail} relief={ShelfReliefAmpM:F1}m edge={ShelfEdgeVariationM:F1}m " +
$"knots={(Knots == null ? "-" : Knots.Name)}]";
$"knots={(Knots == null ? "-" : Knots.Name)}] " +
// rivers/01: the shape family is now a DEFAULT, so a run header must state it — otherwise
// "the defaults" stops being a readable claim the moment anyone asks which defaults.
$"[stretch={SouthStretch:G3} frag={FragmentAmp:G3} speck={(SpeckRevert ? $"{MinLandComponentFrac:G3}" : "off")} " +
$"shelf={CoastShelf} islets={Offshore?.Mode} erosion={Erosion}]";
}
}

View file

@ -43,6 +43,10 @@ namespace IslaApocalypse.Tools
public override void _Ready()
{
ToolingPaths.Configure(OS.GetUserDataDir());
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat1"));
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
@ -81,7 +85,11 @@ namespace IslaApocalypse.Tools
GD.Print("\n--- ABLATION LADDER (seed " + seeds[0] + ") ---");
foreach (var (label, mutate) in Ladder())
{
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seeds[0], VariantLabel = label };
// ⭐⭐ rivers/01 — FAMILY-OFF PINNED. This tool AUTHORED `02_pass1_port`, the Phase-1
// regression anchor that survived the re-baseline. If it picked up the family-on
// defaults it could no longer regenerate its own dump, and the last link between
// today's generator and the Phase-1 port would break silently.
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seeds[0], VariantLabel = label }.WithFamilyOff();
mutate(cfg);
rows.Add(RunOne(cfg, batchRoot, skipRaw));
}
@ -92,7 +100,7 @@ namespace IslaApocalypse.Tools
GD.Print("\n--- SEED BATCH (full pass-1) ---");
foreach (int seed in seeds)
{
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "full" };
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "full" }.WithFamilyOff(); // ⭐ see the ladder above
rows.Add(RunOne(cfg, batchRoot, skipRaw));
}

View file

@ -0,0 +1,116 @@
using System;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ THE LOCKED SHAPE — `terrain-shape-v1` (commit <c>a59e52f</c>), AS AN ASSERTION.
///
/// ═══ ⚠⚠ THIS TYPE INVERTED AT rivers/01. READ THIS BEFORE USING IT. ═══
///
/// It used to be a PRESET: <c>Apply(cfg)</c> stamped the locked shape onto a bare config, because
/// the bare defaults did not reproduce the terrain the developer had judged. That was the single
/// most expensive fact in the codebase — *"run the default generator" ≠ "the terrain the developer
/// locked"* — and a fresh chat comparing bare-default output against the locked renders would see
/// differences that were CONFIGURATION, not regression.
///
/// **rivers/01 re-baselined the defaults so that `new TerrainGenConfig()` IS the locked shape.**
/// So <c>Apply</c> is GONE — there is nothing left to apply, and re-stamping the values on top of
/// the defaults would mask a default drift instead of catching it.
///
/// > ### What survives is the OPPOSITE job: these constants are now the ASSERTION TARGET.
/// > They are the values the developer judged, written down once, and <see cref="Assert"/> holds
/// > the live defaults against them. If a default is ever edited, the batch tools that claim to
/// > render the locked shape REFUSE TO RUN rather than quietly rendering something else.
///
/// ⚠ DO NOT "fix" a drift by editing these constants — they are the record of what was approved,
/// and `10_frag4_seed_gallery` / `11_erosion` are its pixels. A deliberate shape change moves the
/// defaults AND these constants AND re-runs the acceptance, in one task, as rivers/01 did.
///
/// ⚠ THE SHELF IS OFF, DELIBERATELY. The locked shape has no coast shelf; evaluating it (→ D-041)
/// is its own later task, once water renders. ⚠ THE ISLETS ARE OFF, PERMANENTLY (→ D-063): islands
/// are organic-only, made by the stretch + fragmentation and identified by the region layer.
///
/// ⚠ EROSION IS NOT PART OF THIS SHAPE. `terrain-shape-v1` is the pass-1/1c/2a field; erosion is
/// pass 2b, on top, render-only (`11_erosion` = `ea291ea`). It is asserted separately.
/// </summary>
public static class TerrainShapeV1
{
/// <summary>The tagged commit this shape is defined by. ⚠ Reference the COMMIT — the tag is annotated and local-only until the developer pushes it.</summary>
public const string Commit = "a59e52f";
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>
/// ⭐ THE DEFAULT-DRIFT GUARD. Throws unless a bare <see cref="TerrainGenConfig"/> carries the
/// locked shape exactly. Every batch tool that renders or asserts `terrain-shape-v1` calls this
/// before it generates anything.
///
/// ⚠ It is a THROW, not a warning, for the same reason <see cref="FileSafety"/> is: a batch
/// that renders the wrong terrain still produces beautiful, browsable, wrong PNGs, and a human
/// gate cannot see a default from a picture.
/// </summary>
/// <param name="who">The calling tool, for the message.</param>
public static void Assert(string who)
{
var d = new TerrainGenConfig();
string bad = null;
void Want(string name, object got, object want)
{
if (!Equals(got, want)) bad = (bad == null ? "" : bad + "; ") + $"{name} = {got}, expected {want}";
}
Want(nameof(d.SouthStretch), d.SouthStretch, Stretch);
Want(nameof(d.SouthBandStartFrac), d.SouthBandStartFrac, BandStart);
Want(nameof(d.SouthBandFeatherFrac), d.SouthBandFeatherFrac, BandFeather);
Want(nameof(d.StretchSinker), d.StretchSinker, StretchSinker);
Want(nameof(d.FragmentAmp), d.FragmentAmp, FragmentAmp);
Want(nameof(d.FragmentFreqPerMapWidth), d.FragmentFreqPerMapWidth, FragmentFreq);
Want(nameof(d.FragmentBandCentre), d.FragmentBandCentre, BandCentre);
Want(nameof(d.FragmentBandHalfWidth), d.FragmentBandHalfWidth, BandHalfWidth);
Want(nameof(d.FragmentBitesOnly), d.FragmentBitesOnly, BitesOnly);
Want(nameof(d.SpeckRevert), d.SpeckRevert, true);
Want(nameof(d.MinLandComponentFrac), d.MinLandComponentFrac, SpeckFrac);
Want(nameof(d.CoastShelf), d.CoastShelf, false);
Want(nameof(d.RegionLabeling), d.RegionLabeling, true);
Want("Offshore.Mode", d.Offshore.Mode, OffshoreMode.Off);
if (bad != null)
throw new InvalidOperationException(
$"[{who}] LOCKED-SHAPE DRIFT: the bare TerrainGenConfig no longer reproduces " +
$"terrain-shape-v1 ({Commit}) — {bad}. Refusing to run: this tool's output is only " +
"meaningful if the defaults ARE the locked shape. → Tools/Scripts/TerrainShapeV1.cs (rivers/01).");
}
/// <summary>
/// The same guard for the EROSION default, kept separate because erosion is pass 2b and is not
/// part of the shape. Tools that render the erosion A/B set the flag per variant and call this
/// only if they rely on the default.
/// </summary>
public static void AssertErosionDefaultOn(string who)
{
if (!new TerrainGenConfig().Erosion)
throw new InvalidOperationException(
$"[{who}] EROSION DEFAULT DRIFT: bare TerrainGenConfig.Erosion is false; rivers/01 " +
"made it true (the rivers baseline routes on the eroded render field). Refusing to run.");
}
/// <summary>
/// One line for a run header, READ FROM THE LIVE DEFAULTS rather than from the constants —
/// so the header states what actually ran, and <see cref="Assert"/> states whether that is
/// still the locked shape.
/// </summary>
public static string Describe()
{
var d = new TerrainGenConfig();
return $"terrain-shape-v1 ({Commit}) from the BARE DEFAULTS: frag amp {d.FragmentAmp} freq {d.FragmentFreqPerMapWidth} " +
$"window {d.FragmentBandCentre}±{d.FragmentBandHalfWidth} · stretch {d.SouthStretch} " +
$"(band {d.SouthBandStartFrac}/{d.SouthBandFeatherFrac}, sinker {(d.StretchSinker ? "stretched" : "real-y")}) · " +
$"speck revert {d.MinLandComponentFrac:G2} · offshore {d.Offshore.Mode} · shelf {(d.CoastShelf ? "ON" : "OFF")} · labeling {(d.RegionLabeling ? "ON" : "OFF")}";
}
}
}

View file

@ -8,12 +8,33 @@ Real batch output is written under `ISLA_OUTPUT_DIR` (default `user://output/bat
## Layout
```
batches/NN_<name>/ NN = the task number that ran it
batches/<chat>/NN_<name>/ <chat> = the chat namespace · NN = the task number that ran it
├── INDEX.md what varied, what to look at, what was concluded
├── scratch/ intermediates — PERSISTENT, never cleaned
└── <seed>_<variant>/ one directory per generated world
```
### ⭐ The `<chat>` segment (rivers/01)
**`NN` is the AUTHORING TASK NUMBER, and task numbers restart at 00 in every build chat** — so a flat
`batches/` collides the moment a second chat exists. It did: chat 1's `02_pass1_port` and chat 2's
`02_curve_continuous` are both "batch 02", and nothing in either name says which chat made it. On the
real pile there were **four colliding prefixes (02, 03, 04, 06)** across 25 batches, separable only by
SLUG.
The slug is set by `ToolingPaths.ConfigureChat(...)` and is **required** — with none set, `BatchRoot`
throws rather than writing to the un-namespaced root. Each tool defaults to its own authoring chat, so
re-running it reproduces its batch in place; **`ISLA_CHAT` redirects a run to another namespace**, which
is what keeps an acceptance run from overwriting the very anchor it is checking against.
> ### ⚠ Writes are namespaced; historical READS carry the prefix themselves.
> `BatchRoot(task, descriptor)` inserts `<chat>`. `BatchesRoot` is the plain root, and every
> `ISLA_*_SOURCE` anchor composes against it — so an anchor default is written out in full, e.g.
> `"chat1/02_pass1_port"`. Namespacing only the writes would silently orphan every historical read.
> **That is why a missing anchor now THROWS** (`ShapingOracle.LoadAnchor`): a moved anchor used to make
> its oracle not RUN, and a batch with a silently-skipped check prints an all-PASS table that reads
> exactly like a clean one.
`INDEX.md` is not optional. A/B comparisons are browsed by a human, and a flat directory of
same-named PNGs is not browsable.