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

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

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

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

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

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

203 lines
10 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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