Compare commits

..

30 commits

Author SHA1 Message Date
1eacd22972 docs: river carving pass in the generator README (terrain-water task 22)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 19:16:37 -04:00
6960c2a2e8 feat: river bed carving with SHORT/LOWGROUND lowland routing A/B (terrain-water task 22, C0b part 2a)
Executes the frozen task-21b plan as real terrain — the first river pass to
modify the render map. NO WATER (part 2b waters the gated routing style).

RiverCarvePass (D-035 numeric): reruns DrainageAnalysis in-pipeline
(deterministic — same seed, same eroded surface, same plan), routes each routed
giant's lowland reach to the nearest ocean by deterministic Dijkstra under two
config-gated cost models — 'short' (distance + uphill penalty; heads direct) vs
'lowground' (cost ~ elevation above sea; follows the lowest ground and
meanders) — then carves every promoted course as a parabolic channel with a
smoothstep shoulder. Width/depth grow downstream with sqrt(drainage); bed
profile forced monotone non-increasing toward the outlet (water must flow) and
clamped to sea + RiverSeaMargin EVERYWHERE, with below-sea cells read-only and
the crater core excluded — the erosion flood-guard discipline, asserted per
generation by the water-pixel A/B (throws on any change).

Pipeline slot: AFTER GenerateTowns (towns read the render map for land/slope/
water checks — carving first would move towns and destabilise every A/B) and
BEFORE roads (a full run's A* should see the beds).

ISLA_SERVER_CONFIG env override added to ConfigManager: batch/A-B tooling loads
an alternate config file and the developer's live ServerConfig.json is never
written by tooling again (the task-19 restore near-miss class is retired).

Measured, seed 1280587109 (both variants): oracle md5-identical to baseline
(BIOM/WBID bitwise equal), 0 newly-below-sea cells, below-sea untouched, island
top 457.65 m exact, lowest carved cell exactly sea+margin (37.85 m), and
Rivers='off' is bitwise identical to the task-19 blueprint. All routed giants
reach the ocean in both styles. Wander (polyline/straight): SHORT 1.02-1.06 vs
LOWGROUND 1.24-1.38; SHORT carves 811k m3 (cuts through rises, max cumulative
cut 14.6 m), LOWGROUND 481k m3 (goes around, 14.3 m). Pass cost ~25 s (plan
21 s, routing 1.4-2.5 s, carve 0.3 s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 19:16:37 -04:00
a53d4e8512 docs: the 21b mixed promotion in the generator README (terrain-water task 21b)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:34:02 -04:00
2de9502e4f feat: mixed river plan with provisional routes (terrain-water task 21b, C0b part 1 revised)
Still PURE ANALYSIS — zero terrain change, zero water; the source blueprint is
never written (md5-verified). Extends task 21 per the developer's b+c decision:
promote ~5-6 rivers, the natural ocean trunks PLUS the top endorheic giants.

Giants (top GiantCount terminal basins by per-basin total inflow) get their main
stem anchored on the STRONGEST FEEDER into the basin, not the basin's deepest
cell — on a flat basin floor the deepest cell sees only local trickles (the
task-21 lesson applied to stems). Classification is by what the terminal BASIN
holds, not the stem's single pooling cell (a stem can pool on dry ground a few
hundred px short of its lagoon and still be a lagoon river): basin holds a
classify lake -> LAKE-ENDER; dry pan -> ROUTED; the giant pooling nearest the
southernmost town is the SOUTHERN CANDIDATE and always ROUTED (shown, not
forced). Routed giants carry a PROVISIONAL route: steepest descent on the FULL
(no-terminal) epsilon fill, so the basin overtops at its spill and the walk
follows the terrain's own drainage to the ocean — the technique part 2 carves
with, here only drawn and flagged provisionalRoute_NOT_WATER in the JSON.

Seed 1280587109 result (defaults, 6 rivers): 3 ocean trunks (unchanged from
task 21) + GIANT 1 [ROUTED - SOUTHERN CANDIDATE] 2.27M px pooling in the SE
lagoon, 932-px route via spill (6598,5866) to the ocean — the island's biggest
river serving the south; GIANT 2 [LAKE-ENDER] 1.82M px ending at the E lagoon;
GIANT 3 [ROUTED] 1.76M px SW dry-pan system, 1716-px route to the SW coast.
All provisional routes reach the ocean. Analysis 22 s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:33:41 -04:00
c60510beb7 docs: the river-plan tool in the generator README (terrain-water task 21)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:06:29 -04:00
ae97e48229 feat: drainage-network promotion analysis — the river PLAN (terrain-water task 21, C0b part 1)
Pure analysis over the ERODED render map: changes zero terrain, adds zero water.
Priority-flood (task-03 family, Barnes heap+pit, 8-connected) with a one-ulp
epsilon so every filled pit keeps a strictly descending routing path — the ~16.5k
erosion pits route through; depressions >= 2 m deep and >= 10k px survive as
terminal basins (70 on the working seed). D8 flow directions on that routing
surface (D8 as a COMPUTATION, not the reverted carving use), Kahn-propagated flow
accumulation, then promotion: TRUE-ocean outlets (WBID 1 only — two of the first
draft's three 'sea-reaching' trunks actually ended in enclosed lagoons, which is
exactly the overclaim the gate must not inherit) ranked by drainage area, top ~3
with outlet separation become trunks; max-accumulation stems; mountain-exit from
sustained along-stem grade; lean deduped tributaries; lean endorheic terminals
credited with per-basin TOTAL inflow (acc at the deepest cell undercounts flat
lagoon beds 10x — measured).

Output: console report + a JSON plan sidecar next to the source blueprint —
deliberately NOT a blueprint section, so the plan cannot masquerade as realized
water. The source .dat is never written (md5-verified). Headless tool, ~21 s
analysis on 8K; RIVERPLAN_* env dials.

Seed 1280587109 findings for the gate: 3 ocean trunks spread west/north/east
(436k/409k/325k px); the island's five LARGEST systems (1.1M-2.3M px) are all
endorheic — three end in big enclosed lagoons (classify lakes, 19-20 m basins),
two in dry pans; 62.9% of land does not drain to the open ocean, the drainage
restatement of task 18's 'erosion cannot cut the lowlands'. The 2.27M-px giant
terminates in the SE lagoon ~1.1k px from the southernmost town (filed south
report, not enforced).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:06:21 -04:00
78e042b805 docs: crater erosion modes and the retired 1.2x exclusion (terrain-water task 19)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 05:50:22 -04:00
f810ff44dc fix: shrink erosion's crater exclusion to the strike core; add FULL/FEATHER modes (terrain-water task 19)
Task 17's hard 1.2 x CraterRadius cutoff left a visible un-eroded disc with a hard
edge. Measured (radius dump, seed 1280587109): the carve writes only inside 0.80 x
(640 px) and its displacement is EXACTLY 0 beyond that, so the 640-960 px annulus
was 620,811 LAND cells of ordinary terrain held smooth for no geometric reason.
That annulus is now eroded — 98% of those cells are touched.

The protected core is the carve's own extent (CraterErosionCore, 0.80 x).
CraterErosionMode selects the transition: "full" applies full strength at the core
boundary (older-crater look), "feather" ramps 0->full out to CraterErosionFeather
(1.05 x, mirroring the detail pass) for a younger-crater look with no seam.
Implemented as a WEIGHT that scales carve and deposit amounts, not a skip, which is
what makes feather a one-liner. Default "feather" pending the gate.

Core = the carve radius is load-bearing, not tidy: the carve runs AFTER erosion and
scales height toward the sea target, so it amplifies any erosion delta inside its
radius. Measured at a 0.50 core: 79 cells newly below the rendered sea and 113,310
below-sea cells disturbed — the in-pass flood guard cannot see this because it
measures before the carve. At 0.80 the two passes touch disjoint cells and the
guarantee is exact again (0/0/0). A narrower core also reclaims nothing extra, since
the over-protected annulus lies entirely outside the carve. MapGenerator now owns
CRATER_CARVE_FACTOR as the single source of that 0.80 and warns loudly if the
configured core is narrower.

Verified both modes, seed 1280587109: 1_biomes/0_water md5-identical to erosion-OFF,
BIOM/WBID bitwise equal, 0 newly below/above sea, 0 below-sea cells modified, 0
cells modified inside the core, island top 457.65 m unchanged, isotropy 1.005/1.007.
Bay-to-ocean connection demonstrated explicitly by flood fill through rendered
water to the map's north border (2,122,935 cells), not merely argued from the sea
clamp.

EROS body version -> 3: craterExclFactor replaced by craterCoreFactor +
craterFeatherFactor + craterMode. Round-trip harness PASS with a real v3 payload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 05:50:22 -04:00
85e7bceb72 docs: erosion hierarchy defaults, EROS v2, and where erosion cannot reach (terrain-water task 18)
Records the four governors and the task-18 defaults, and states the measured
limit plainly: erosion concentrates ~9x on the curve's shelf risers because
that is where sustained slope is, so flat shelves and lowlands are barely
touched and the lowland continuation of a trunk is river promotion's job, not
erosion's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 04:16:33 -04:00
cc6a24d2b9 fix: brush-spread deposition + deposit-cap governor; retune erosion for a drainage hierarchy (terrain-water task 18)
Deposition now spreads over the same cone brush as carving, and the per-cell
erosion ledger becomes a signed NET displacement ledger, so both caps are
measured from the height the pass found. Brush-spreading alone cut the spike
15.49 -> 6.12 m at task-17 dials, but NOT at hierarchy dials (long paths carry
far more sediment; a loaded droplet meeting a rise dumps min(rise, load) at
once -> 25.5 m). So deposition also gets governor 4, ErosionDepositCap
(default 6 m, <= 0 = unbounded), asserted on exit like the carve cap.

Dial defaults retuned for a drainage HIERARCHY: 250k droplets x 384 steps at
inertia 0.35 / evaporation 0.004, erode 0.12, carve cap 15 m. Diagnosis was
that lifetime 48 let a droplet travel at most 48 px on a ~3000 px island
radius, so paths could not overlap into trunks: the deepest task-17 features
were 43x36 px patches. Now 177 channel systems of 200+ cells at the 3 m
threshold, top one 204x279 px with 14 tributary tips; cells past 5 m up 8.6x
(4,948 -> 42,385) while the fine rills are retained. Verified descending, not
contour-locked: 0/155 components have drop/extent < 0.12 (median 1.13).

EROS body version -> 2 (deposit cap inserted after the carve cap); v1 payloads
are skipped whole by the existing rule. HydraulicErosion.VERSION now reads
BlueprintFormat.EROS_VERSION instead of restating it — the local copy had
already drifted and stamped a v2 body as v1, which readers decode with every
float shifted by one field.

Oracle green on both seeds (1280587109, 1512575962): 1_biomes/0_water
md5-identical erosion-ON vs OFF, BIOM/WBID bitwise equal. Flood guard 0 new
water px, 0 below-sea cells touched, crater zone untouched, island top
unchanged, carve field isotropic to 1.4% across the folded 45deg period.
Erosion pass 34 s at these dials.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 04:16:33 -04:00
56ac17e670 docs: erosion pass in the generator README (terrain-water task 17)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 02:42:26 -04:00
e1e1fe3227 feat: droplet hydraulic erosion — carve+deposit, three governors, sea clamp (terrain-water task 17, Phase C0)
Erosion: "off"|"v1" gate (default off, pending the developer's gate). Output-only:
applied to the render map after detail, before the crater carve; the classify path
reads pre-erosion heights, so biomes/water stay byte-identical (verified: OFF run
bit-identical to the task-14 reference; ON run BIOM/WBID identical to OFF).
Governors: droplet count / lifetime / per-cell carve cap (ledger-enforced, thrown
on violation). Sea clamp: below-sea cells read-only, land never carved below
sea+margin — rendered coastline provably fixed (asserted per generation). Crater
excluded at 1.2×R. Deterministic from resolvedSeed+9271 (PCG32). New EROS
blueprint section (writer/parser/harness/format doc). Supersedes the reverted D8
approach with the organic droplet model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 02:42:26 -04:00
34b0e1c9e3 feat: scene lighting — angled sun, procedural sky, ambient (terrain-water task 16)
The debug scene had one DirectionalLight3D at identity rotation (shining
horizontally along -Z), no sky, and ambient sourced from the background,
which with no sky is the flat clear colour. Terrain read flat, shadowed
faces crushed, nothing to reflect. We have been judging terrain and water
against that -- a bad-evidence trap.

Minimal and standard, nothing else:
  * sun angled to 52 deg elevation, shadows ENABLED, shadow max distance
    2500 m (the default 100 m is useless at this world scale)
  * WorldEnvironment gains a ProceduralSkyMaterial sky, background_mode Sky
  * ambient sourced from that sky at 0.45 energy, so shadow sides read
No post-fx, no volumetrics, no SSAO, no tonemap change -- renderer-chat work.

MEASURED on seed 1280587109, identical camera, before vs after (mean region
colour off the rendered frames):

  land      luma  36 -> 122
  mountain  luma  76 -> 188, blue-excess -0.3 -> +49.7  (snow now reads as
            lit snow catching sky, not flat grey)

TERRAIN JUDGEABILITY IS THE WIN, and it is large.

BUT THE TASK'S PREMISE DID NOT HOLD, and it should not be quietly buried:
"the sky is what fixes the [water] grey, not re-tuning the water" -- it
does not. Same measurement, water region:

  water     luma  61 -> 116, blue-excess -3.7 -> -6.2   (BRIGHTER, and
            LESS blue -- it reads as bright grey-brown, not as water)

Mechanism: the water material is non-metallic at roughness 0.35 with
VertexColorUseAsAlbedo and alpha 0.62 in the shallows. A rough dielectric
barely shows a sky reflection; what you actually see is ~62 % water tint
over ~38 % seabed, and the seabed is exactly what the new ambient just
brightened. Lighting the world lights what shows THROUGH the water more
than it lights the water.

The fix is a water-material dial -- WATER_ALPHA_SHALLOW/DEEP, or giving the
surface enough specular to return the new sky -- and this task is forbidden
from touching task 13's material, correctly, since that is a separate
judgement. Left for the developer; reported rather than silently worked
around.

The camera's colinear LookAt warning is UNCHANGED and still present. It
comes from cam.LookAt() pointing straight down in ServerChunkManager, not
from the light, so angling the sun could never have cleared it. Verified.

Scene file only -- no code changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 02:10:03 -04:00
b9c7675e78 docs: the two-clause water presence rule (terrain-water task 15)
Server/README.md: why water presence is not just WBID. Records the
classify-vs-render identity that makes the second clause both necessary
(inside the crater carve) and safe (provably inert everywhere else), so
the next reader does not have to re-derive it from the commit log — or,
worse, "simplify" the second clause away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 00:57:05 -04:00
69bf91b3d6 fix: shoreline seam — water follows the RENDERED coastline (terrain-water task 15)
Closes the C1-polish carryover. The cause was confirmed by a read-only dump
BEFORE any code was written, per the filed note's discipline; the chat had
guessed twice from screenshots.

DIAGNOSIS. WBID is classified from the UNCURVED heightmap — the classify
path that keeps the biome/water oracle byte-identical through all of
Phase B — while the mesh renders the CURVED one. Outside the crater those
two agree EXACTLY, and provably so: the curve is identity at sea and
monotonic, so Apply(raw) < sea iff raw < sea. Inside the crater they do
not. The carve lerps two different bases toward one target --
classify from raw, rendered from Apply(raw) -- and Apply(raw) < raw
throughout the lowland band (the toe compresses raw 0.15..0.516 into
0.15..0.206). So the rendered surface sinks FASTER than the classify
surface, leaving an annulus that renders below the waterline while WBID
still calls it dry.

MEASURED, seed 1825907253, map-wide:
  wet columns                        32,799,866
  WATER-OVER-DRY (wet, mesh above Y)      1,101   scattered, not the story
  DRY-UNDER-WATER (dry, mesh below sea)  375,824   <- the seam
  ...of which inside the crater carve:      100 %
Dump across the gradient at z=905: at x=3800 the classify surface sits at
47.55 m while the mesh renders 22.38 m -- 25 m apart, no water drawn. The
ring is west of the Capitol, which is exactly the developer's "flush on
the right, lifted toward the left".

Neither of the task's two candidate causes, and worth saying so: the water
LEVEL is flat and correct at 37.65 everywhere it is drawn (rules out B),
and the per-cell flat sheet is a rounding error next to this (1,101 px vs
375,824). It is the filed note's classify-vs-render mismatch, with the
crater carve as the mechanism.

THE FIX. Presence now takes two clauses: the blueprint's classification,
OR the rendered ground actually being under the ocean's surface. The
second clause tests exactly what the mesh draws (exactSurfaceY) against the
OCEAN BODY'S OWN level from WBTB -- the runtime still derives nothing, it
only notices that the ground it is drawing is under a surface the
blueprint gave it. By the identity above that clause can only ever fire
inside the carve, which is precisely the flooded bay it exists for.

MEASURED after, same seed: 241,415 -> 358,576 water columns, 454 -> 648
chunks, 117,161 seam columns recovered in the loaded region. Each run now
reports its own seam count.

2D PIPELINE UNTOUCHED, verified rather than asserted: the only changed
file is the runtime chunk manager, and a regeneration is byte-identical to
task 13's in every section but PRMS (timestamp + git hash). All four
snapshot PNGs md5-identical.

Static flat sheet, no animation, no water DATA change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 00:56:45 -04:00
3b5bc5e5d6 docs: water at rest across the Core/Client/Server READMEs (terrain-water task 13)
Core: BlockRegistry gains WATER, with the reason it is wire-safe (block IDs
are never serialized) AND the reason it is a registry identity only rather
than a voxel the terrain path writes. Corrects the now-false line "Nothing
at runtime consumes the water data yet" -- it does, as of this task.
ChunkData's new per-column water fields and the Constants tunables.

Client: how the water sheet is built and, more importantly, WHY it is a
second mesh instead of a block in the density field -- the trap here is
that writing water into a Marching-Cubes field fuses it into the terrain
instead of laying it on top, and that is not obvious until you have done
it. Notes translucency came free from the separate material.

Server: the per-column water rule, which section is the authority for what
(WBID presence, WSRF level, WBTB fallback), that an unknown body leaves the
column dry rather than guessing, and why depth for shading comes from
blueprint heights rather than the clamped rendered geometry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 02:33:06 -04:00
9c255a4fc6 feat: render the water we already had (terrain-water task 13, Phase C1)
The blueprint has carried water since task 03 -- WBID per-pixel body id,
WBTB body table, WSRF per-pixel surface level. Nothing ever drew it. Now
the runtime does. No new water data: the blueprint is the authority on
where water is and at what level, and this only reads it.

WHY WATER IS ITS OWN MESH, not a block in the terrain field. The terrain
is a Marching-Cubes iso-surface over ChunkData.Densities. Writing WATER
into that field would not lay a sheet on top of the seabed -- it would
move the iso-surface, fusing the sea into the terrain as if it were solid
ground. A second surface is the only way water can sit at ITS level
independent of the ground under it. So ChunkRenderer builds a water
MeshInstance3D as a child of the chunk's terrain mesh.

TRANSPARENCY IS SHIPPED, NOT DEFERRED (Step 2.4's cheap branch). Because
water is a distinct MeshInstance3D it carries its own StandardMaterial3D,
so alpha is one flag and Godot sorts transparent surfaces after opaque
ones by itself. Zero mesher changes. Alpha runs 0.62 shallow -> 0.97 deep,
so the shelved coast stays readable and open ocean closes up.

WHICH DATA DROVE IT: WSRF for the level (per-pixel, quantised to 1/32768
raw ~ 7.7 mm, far under the 1 m voxel, and no table lookup), WBID for
presence (it is the water stage's own classification output -- the same
set the biome grid's Ocean/Lake pixels form by construction), WBTB as the
fallback when a column is flagged wet but carries the WSRF no-water
sentinel. A body the table does not know leaves the column DRY rather
than guessing a level.

Details worth keeping:
- Water Y uses Constants.HEIGHT_SCALE, the SAME mapping as the terrain
  surface, named so the sheet and the seabed cannot drift apart if the
  vertical band is ever retuned.
- Depth for shading comes from BLUEPRINT heights, not rendered geometry:
  the terrain render clamps its floor at Y=2, so every deep-ocean column
  would otherwise read as one flat ~36 m and the gradient would die
  exactly where the ocean gets interesting.
- A cell is drawn if ANY corner is wet, flat at the highest wet level.
  Drawing onto a partly-dry cell is deliberate -- it carries the sheet
  under the shoreline where the opaque terrain hides it. Only-fully-wet
  cells retreat the waterline a metre and leave a dry gap around every
  coast and lake.
- Non-metallic, roughness 0.35: the scene environment is minimal, and a
  metallic surface reads near-black when there is nothing to reflect.

BlockRegistry gains WATER (id 11). Wire-safe: block IDs are never
serialized -- the blueprint's section table stores heights, biome ordinals
and water data, never block IDs, and chunks are not persisted yet.

MEASURED on seed 1825907253: 241,415 water columns across 454 of 4096
chunks; surface Y 37.6..37.6 m (flat, = 0.15 x 251 -- the flat sea model,
rendered); depth to 32.3 m, matching an independent read of the blueprint
exactly. Both an ocean and a lake fall in the default view.

NO REGRESSION to the 2D pipeline, verified section by section: a
regeneration of the working seed is byte-identical to task 12's run in
TCRV, TDTL, HGTS, BIOM, WBID, WBTB, WSRF, TOWN and all four road
sections; only PRMS differs, and only in its write timestamp. All four
snapshot PNGs md5-identical.

No storms, no waves, no animation, no flow -- water at rest. Those are C2+.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 02:32:04 -04:00
a6d4b870fc fix: IslandAxis defaults to the gate's verdict, 1.15/0.90 (terrain-water task 13)
Task 11 shipped 1.30/0.78 as the default; the developer's gate REJECTED that
elongation. The defaults stayed rejected, so any generation that omitted the
two keys silently produced the rejected island and a different biome baseline
(task 11 measured elongation at 12-15% of biome pixels). The developer's own
ServerConfig.json omits them. Flagged in task 12 §4, fixed here.

Both defaults now name LEGACY_AXIS_X/Y, so the default and the bad-value
restore point at one place instead of drifting apart.

VERIFIED, not assumed: generated seed 1825907253 with IslandAxisX/Y absent
from ServerConfig.json entirely -- the generator reports
"Island falloff: axis 1.15x/0.90y".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 02:20:35 -04:00
6fd00cbfa5 chore: Godot .uid for IslandFalloff (terrain-water task 11)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 00:53:41 -04:00
28095f25c5 fix: hold the coast shelf strictly below sea in float32 (terrain-water task 11)
The shelf remap is strictly positive on positive depth, so in exact
arithmetic it cannot move the waterline -- which is the whole reason it is
separable from elongation and safe to toggle against an md5 oracle.

In float32 it can. For a pixel a few microns under water, the shallower
depth falls below the ULP of the sea constant, so (sea - depth) rounds back
up to exactly sea, and the `H < sea` test then calls it land. The batch
measured it: coast-only vs steep came out with land area identical, zero
above-sea heights changed, and 5 biome pixels of 67 M moved.

Five pixels is immaterial on its own. Falsifying the invariant is not --
"the coast shelf is biome-neutral" is now written in BLUEPRINT_FORMAT.md,
and an invariant that holds to six decimal places is the kind that bites
somebody later with a mysterious oracle failure. Clamping to
BitDecrement(sea) makes it exact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 00:22:39 -04:00
5ba868f038 docs: island falloff, coast shelf, offshore islets (terrain-water task 11)
Tools/Scripts/README.md: the falloff dials and what they actually do --
including that IslandAxisX is a weak lever because the island is already
Trench-clamped at ~90% of the map width, so nobody re-derives that the hard
way. The spine crest fix, and an explicit note that the spine's AXIS is
still a straight line down the map centre: known, deferred, its own task.

BLUEPRINT_FORMAT.md config-knobs section: the three new dials, each labelled
with whether it moves biomes. CoastProfile does NOT (it cannot move the
waterline, so HGTS changes while BIOM/WBID/WBTB/WSRF stay bit-identical);
IslandAxis* and OffshoreIslandDensity DO, by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 23:31:40 -04:00
6c318e4df0 feat: offshore islands (terrain-water task 11)
Sparse discoverable islets from a low-frequency ocean noise layer, seeded
resolved+7607 at 14 undulations/island (~585 px blobs).

Placed by LERPING the seabed toward a target crest (34 m raw above sea)
rather than adding to it, so an islet can surface at any ambient depth
instead of only where the seafloor happens to be shallow. After the curve's
toe compresses them they stand 5-8 m above the water: low, sandy, ringed by
beach -- which is what the existing biome rules make of that height, with no
biome-rule change.

THE THREE CONSTRAINTS, each by construction rather than by hope:

  Never merges with the mainland. The raise is EXACTLY zero wherever the
  ambient water is shallower than 14 m, so any continuous path from the
  mainland shore to an islet must cross that depth contour, and every pixel
  on it is untouched water. Measured: 0 of 13 islets merged, closest
  approach 78 px.

  Never touches the Trench. A distance mask zeroes the layer by 0.86 of
  half-map, and the ramp starts at 0.90. Measured max islet reach 0.795.
  All four corners stay below sea.

  Never spawns inland. Depth alone is not a test -- a deep lake and the
  carved crater bay are also below sea. The pre-Trench falloff is the honest
  discriminator (mainland coast sits near f = 0.66; islets need f > 0.72),
  and it is axis-invariant, because elongation moves WHERE a given f occurs,
  not the f at which land ends.

Two bugs found and fixed by measuring instead of trusting the first run:

  1. The density dial treated [-1,1] as the noise's actual range. Simplex
     is concentrated well inside it -- this field measures 0.050..0.958 --
     so "top 6%" resolved to a threshold almost nothing cleared: the first
     build raised 171 px on the entire map, none above sea. The threshold is
     now CALIBRATED against the field's own distribution (quantile over a
     1M-sample stride grid, deterministic from the seed), so the dial means
     what it says whatever FastNoiseLite returns.
  2. At 26/island the layer produced 77 islets under 200 px -- scatter, not
     "a handful". Retuned to 14/island at density 0.02: 13 islets of
     200-4792 px.

The pass prints its own islet area, so every run says what it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 23:30:15 -04:00
566abe9784 feat: submarine coast shelf + elongation dials (terrain-water task 11)
COAST. The task's premise -- "the island edge is the steepest part" -- is
right, and the diagnostic locates it precisely: it is the SEABED, not the
land. HeightCurve.Apply returns early at and below sea level, so tasks
05-09 reshaped the land and never touched the water. Measured on the same
seed, distance-from-shore vs height:

                       land reaches 10 m   seabed reaches 10 m   gradient
  curve OFF                   31 px               31 px       0.254/0.258
  curve ON (ships)           186 px               31 px       0.038/0.258

So the landward side is ALREADY the broad shallow shelf terrain_design.md
asks for -- 54% of the island sits under 14 m -- and flattening it further
would only make it mushy. What is left is a shoreline that shelves gently
on land and then drops 6.7x steeper the moment it goes under.

CoastShelf fixes that side: depth' = depth * (1 - 0.775*exp(-depth/100 m)).
The seabed leaves the waterline at 22.5% of its former gradient and
recovers smoothly, so deep water and the Trench keep their shape. Measured
after: 5 m depth at 43 px (was 15), 10 m at 75 (was 31), 30 m at 150 (was
94) -- a 2.4-2.9x wider shallows.

It is strictly positive for positive depth, so it CANNOT move the waterline
by one pixel. Biomes and water bodies come out bit-identical, which is why
the coast is separable from elongation in the batch rather than tangled
with it -- contrary to the task's expectation that all coast work moves
biomes.

ELONGATION. IslandAxisX/Y replace the 1.15/0.90 literals (the spine shares
AxisX, as it always shared the literal). Sized by replaying candidate
falloffs against real terrain rather than guessing -- the replay reproduces
the shipped extents exactly, bbox and land count.

That replay says the task's suggested 1.30/0.85 buys +2.0% aspect, because
THE ISLAND IS ALREADY TRENCH-CLAMPED IN X: it spans 90.5% of the map width,
and 1.15 -> 1.40 moves the west coast only 393 -> ~360 px. Aspect responds
almost entirely to AxisY, which costs land:

  1.30/0.85  +2.0% aspect  +2.9% land      1.30/0.80  +4.9%  -2.2%
  1.30/0.82  +3.7% aspect  -0.1% land      1.30/0.75 +11.5%  -7.3%

Default 1.30/0.78 -- a visible step (+~8% aspect, measured 1.137 -> 1.200)
at ~4% land. The developer's eye tunes it from the batch; the table above
is the price list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 23:29:49 -04:00
896a428d47 fix: round the mountain-spine crest, the real centre line (terrain-water task 11)
The faint mid-map ridge is NOT the squircle/ellipse falloff blend that the
task suspected. Measured, read-only, before touching anything:

  bare falloff, slope step at centre   +2.05e-07  (SMALLER than the
    off-axis controls +2.36e-06 / +3.05e-06 -- Pow(2.5) crushes it,
    because the falloff is ~0 there)
  mountain spine, slope step at centre -7.58e-04  (170x the controls)

On real terrain, with the by-design Trench walls excluded, x = 4096..4099
hold the top FOUR slope-step locations out of 5999 columns. Column-mean
height runs 109.6 -> 135.5 -> 110.7 m across it: a symmetric ridge peaking
exactly on the centre column. Not the noise domain either -- the noise is
sampled over [0, MapSize], so coordinate zero is at the map CORNER. And not
seed-dependent: present on all 10 seeds checked, 6 positive AND 4 negative
(ranks 0, 18, 0, 83), so the "positive seeds only" impression does not hold.

Cause: the spine is Pow(1 - |x - cx|, 3), and |.| peaks at the axis with a
slope discontinuity of magnitude 2. SmoothAbs replaces |d| with
d^2/sqrt(d^2+e^2) -- zero with zero slope at the axis, converging to |d|
away from it -- so the crest rounds without dropping. e = 0.03 (~141 px)
cuts the 1-px kink by 99.3% (-7.64e-04 -> -5.41e-06, against a natural
profile curvature of ~1.1e-07), fills at most 3.9 m, and decays under
0.5 m by ~1100 px.

DEFERRED, reported not fixed: the spine's AXIS is still hard-coded dead
straight down x = centre for the full map height. That is what the
derivative render actually shows -- everything west of centre slopes east,
everything east slopes west. Rounding the crest does not make the range
meander. Per the task's "fix only if trivial, else report and defer" rule,
and confirmed with the developer, the axis is left alone: a wandering spine
is a world-shape change that rebaselines biomes on every seed, and it
deserves its own task and its own gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 23:28:45 -04:00
59f49ac8a2 fix: detail must yield to the crater carve (terrain-water task 10)
Batch 10_detail_edge measured 532 new below-sea pixels on seed
1158286446 and 44 on 72563200 -- exported terrain under the sea scalar
that the water grid (classify-driven, and correctly identical) calls dry.
Located: every one of them strictly inside the crater's physical carve
radius, 201-323 px out of 640.

Mechanism, confirmed against the data: detail moves a column's PRE-carve
height, and the carve is Lerp(curvedH, target, t). At t ~ 0.45-0.62 a
pre-carve drop of up to 8.4 m (relief plus the band-shift the warp
implies) still passes 0.02-3.53 m through, which is enough to push a
column sitting 0-3.45 m above the sea inside the bowl under it.

The crater carve is supposed to be the FINAL authority on its own
terrain, so detail now yields to it: CraterDetailWeight is 0 inside
0.80 x CraterRadius (exactly the carve's own radius) and feathers to
full by 1.05 x, scaling both the edge shift and the relief skin. Inside
the carve, B is bit-identical to A by construction, so the count is zero
rather than small. The reverted incision pass carried the same exclusion
for the same reason -- the principle outlived the pass that motivated it.

Re-running the 6 B_detail generations; A_off and the continuity run are
untouched by a detail-only change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 21:23:28 -04:00
af1a6d912b docs: TDTL v2 body, shelf-edge variation, erosion deferred (terrain-water task 10)
BLUEPRINT_FORMAT.md: TDTL rewritten for the v2 body (30 B) with the
version-skip rule spelled out -- this is the one section whose body is
VERSIONED rather than extended, because v1's layout was retired rather
than grown, and a reader that guesses would mint plausible nonsense.
Records edgeAmpM as APPLIED (post-clamp), so the file always describes
the terrain rather than the request. Config-knobs section updated:
ShelfEdgeVariation is metres of INPUT height -- a boundary displacement,
not an elevation.

Tools/Scripts/README.md: the two detail passes and why neither can breach
the red ceiling or the 420 m cap (K2/K6 do not move). Adds an explicit
"no rivers, no erosion, no flow routing anywhere in this pipeline" note
with the reverted D8 pass and its lesson on the record, so the next
reader does not re-derive it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 20:55:39 -04:00
ed16a7f668 tune: edge warp 5 m @12/island -> 12 m @20/island (terrain-water task 10)
Sized from measurement, not taste-first. |grad raw| at the K3/K4/K5
contours on seed 1375359975 is p50 0.00088 raw/px, so the first try (5 m,
12 undulations/island) displaced the shelf boundary a MEAN of 3.7 px with
a roughness ratio of 1.03 -- real, and invisible at map scale. At 12 m
and 20/island the boundary moves a mean 8.4 px (bench) / 9.1 px
(plateau), roughness ratio 1.09, and the outline grows the notches, coves
and peninsulas the sketch asked for.

Measured cost at 12 m (A off vs B, same seed): lowland and toe/red bands
bit-identical in both pixel count and slope -- the K2 pin holding exactly,
as designed. Foothill riser p50 0.681 -> 0.676 m/px, p90 1.445 -> 1.478,
max 3.09 -> 3.81. Bench p50 0.21 -> 0.232 (micro-relief included), still
buildable. Peaks max identical at 6.55.

Safety bound restated from "half the margin to a fixed knot" to 2/3 of
the smaller adjacent band, which is the constraint that actually matters:
the squeezed band never compresses below a third of its nominal width, so
its slope never more than triples. That puts the ceiling at 16.45 m for
the v5 knots and leaves the 12 m default real headroom if the gate says
"more".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 20:52:08 -04:00
492b56a87c feat: shelf-edge variation -- the red-line scalloping (terrain-water task 10)
PASS B, replacing the reverted incision. The developer's sketch asked for
the shelf/riser BOUNDARY to be organic, not for water: notches, coves and
small peninsulas where a flat shelf meets its riser, instead of the clean
oval contour the curve produces.

Mechanism (the task's preferred "boundary warp", in its most
monotonic-safe form): every shelf/riser boundary is the contour where the
raw height crosses K3, K4 or K5, so the boundary is warped by SLIDING
THOSE THREE KNOTS per column -- edgeShift = simplex(resolvedSeed + 7507,
12 per island width) x ShelfEdgeVariation. The contours then wander in and
out of the terrain instead of tracing an iso-height line. Noise-warped by
construction, so there is no grid direction for an artifact to line up on
-- the failure mode of the thing this replaces.

Why slide knots rather than perturb a weight or the input height:
monotonicity becomes structural instead of conditional. The curve is
strictly monotonic for ANY ordered knot set, so no derivative bound, no
amplitude-vs-feather-width tuning, no way for a dial to invert a column.
MaxEdgeShift keeps the set ordered (half the smallest margin to a fixed
knot = 12.3 m of input height for the v5 knots); the config dial is
clamped to it, loudly. AssertMonotonic now sweeps 24 corners -- the 8
modulation extremes x {-max, 0, +max} shift -- and checks the bound first.

K1, K2 and K6 never move, which buys the guarantees exactly rather than
statistically: below K2 and above K6 a warped column is bit-identical to
an unwarped one, so the red ceiling still floors every shelf edge (storm
ladder safe), the 420 m cap still caps, and the toe and summit spikes are
untouched. The block slides rigidly, so the bench and mid-riser keep their
exact widths -- shelf interiors stay flat, riser interiors keep their
profile, and only the foothill riser and plateau stretch to absorb it.
The micro-relief mask takes the same shift, so pass A's skin follows the
shelf wherever pass B moved its edge.

Dial ShelfEdgeVariation (default 5 m of INPUT height -- a boundary
displacement, not an elevation change) under the existing TerrainDetail
gate; both passes stay one judged unit. TDTL v2 body records relief and
edge amp/frequency/seed-offset plus the applied clamp bound; writer,
parser and harness follow. No blueprint was written between the revert
and this commit, so v2 only ever means relief + edge warp on disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 20:31:41 -04:00
3b6d166458 revert: the D8 drainage incision, whole (terrain-water task 10)
The task-10 draft's PASS B shipped and produced the canonical grid
artifact: thousands of straight, disconnected, pooling scratches. Per-
cell steepest descent on a regular grid can only route along its eight
neighbour headings, so at map scale the "channels" read as hatching,
not drainage. Reverted entirely rather than tuned -- no K, p or mask
setting fixes a directional basis. Rivers and erosion move to Phase C
as a hydraulic-erosion pass over FINAL terrain.

Removed: FlowAccumulation / IncisionWeight / EdgeBlend and the INC_*,
SEA_CLAMP, SHELF_INC_WEIGHT and CRATER_* constants; RunIncisionPass and
the pass-2b call; the six incision fields from TDTL (writer, parser,
harness). KEPT, untouched and developer-approved: pass A, the shelf
micro-relief skin, and its ShelfReliefAmp dial.

The crater carve folds back into the single pass-2 loop it came from,
carving two LOCALS written once each. That is what the code did before
the draft split it out, and it makes the aliased double-carve that the
split introduced (fix 1b98fb5) structurally impossible rather than
merely fixed -- there is no longer a read-modify-write to get wrong.

TDTL bodies are now versioned (BlueprintFormat.TDTL_VERSION = 2) and the
parser skips a body version it does not know instead of misreading the
longer v1 layout into plausible nonsense. The only v1 payloads that
exist are in the reverted batch's own tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 20:30:44 -04:00
e3f7b508e7 chore: Godot .uid for TerrainDetailPass (terrain-water task 10)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 20:01:42 -04:00
30 changed files with 3281 additions and 294 deletions

View file

@ -17,6 +17,21 @@ A `MeshInstance3D` created per chunk by the server, which then calls `RenderChun
placed by their footprint and carry their full height internally. placed by their footprint and carry their full height internally.
- **Backface rendering.** `CullMode` is disabled, so the world is still visible from underneath or - **Backface rendering.** `CullMode` is disabled, so the world is still visible from underneath or
from inside terrain. from inside terrain.
- **Water at rest** (task 13). `BuildWaterSurface` adds a second `MeshInstance3D` as a child, a flat
sheet at each cell's water level, from the per-column water data the server read out of the
blueprint. Skipped entirely for chunks with no water.
**Why a separate mesh rather than a water block.** The terrain is a Marching-Cubes iso-surface
over the density field. Putting water into that field would not lay a sheet on top of the seabed —
it would move the iso-surface, fusing the sea into the terrain as if it were solid ground. A
second surface is the only way water can sit at *its* level independent of the ground beneath it.
That also makes translucency nearly free: a distinct `MeshInstance3D` carries its own
`StandardMaterial3D`, so alpha is one flag and Godot sorts transparent surfaces after opaque ones
itself. Colour and alpha both ramp with depth (shallow teal and clearer → deep navy and near
opaque), so the shelved coast stays readable.
Water is **flat and static** — no waves, no displacement, no animation. Those are Phase C2+.
## Notes ## Notes

View file

@ -30,6 +30,133 @@ namespace IslaApocalypse.Client
0, 0,
data.ChunkPosition.Y * Constants.CHUNK_SIZE_Z * Constants.VOXEL_SCALE data.ChunkPosition.Y * Constants.CHUNK_SIZE_Z * Constants.VOXEL_SCALE
); );
// 3. Water at rest (task 13) — its own mesh, its own material.
BuildWaterSurface(data);
}
/// <summary>
/// The water sheet, as a SEPARATE MeshInstance3D child.
///
/// Why separate rather than a block in the terrain field: the terrain is a
/// Marching-Cubes iso-surface over ChunkData.Densities. Writing water into
/// that field would not lay a sheet on top of the seabed — it would move the
/// iso-surface, fusing the water into the terrain as if the sea were solid
/// ground. A second surface is the only way to have water sit AT its own
/// level, independent of the ground beneath it.
///
/// It also makes translucency nearly free, which is why this task ships it
/// rather than deferring: a distinct MeshInstance3D carries its own
/// StandardMaterial3D, so alpha is one flag and Godot sorts transparent
/// surfaces after opaque ones on its own. No mesher change at all.
///
/// Water at rest is FLAT — one level per cell, taken from the blueprint's own
/// body levels. No waves, no displacement, no animation (those are C2+).
/// </summary>
private void BuildWaterSurface(ChunkData data)
{
if (!data.HasAnyWater) return; // most inland chunks: nothing to build
var st = new SurfaceTool();
st.Begin(Mesh.PrimitiveType.Triangles);
int cellsX = Constants.CHUNK_SIZE_X;
int cellsZ = Constants.CHUNK_SIZE_Z;
bool any = false;
for (int x = 0; x < cellsX; x++)
{
for (int z = 0; z < cellsZ; z++)
{
// A cell is drawn if ANY of its four corners is wet, and it is drawn
// FLAT at the deepest-standing level among those wet corners.
//
// Drawing on a partly-dry cell is deliberate: it carries the sheet
// right up under the shoreline, where the opaque terrain in front of
// it hides the submerged part. The alternative — only fully-wet
// cells — retreats the waterline a metre from shore and leaves a
// visible dry gap all the way around every coast and lake.
float level = Constants.NO_WATER;
float d00 = data.WaterSurfaceY[x, z];
float d10 = data.WaterSurfaceY[x + 1, z];
float d01 = data.WaterSurfaceY[x, z + 1];
float d11 = data.WaterSurfaceY[x + 1, z + 1];
if (d00 > level) level = d00;
if (d10 > level) level = d10;
if (d01 > level) level = d01;
if (d11 > level) level = d11;
if (level <= Constants.NO_WATER) continue;
// Depth for colour: the deepest wet corner of the cell, so a shelving
// coast reads as a gradient rather than a staircase of flat tiles.
float depth = Mathf.Max(
Mathf.Max(data.WaterDepthM[x, z], data.WaterDepthM[x + 1, z]),
Mathf.Max(data.WaterDepthM[x, z + 1], data.WaterDepthM[x + 1, z + 1]));
Color c = DepthColor(depth);
float s = Constants.VOXEL_SCALE;
var p00 = new Vector3(x * s, level, z * s);
var p10 = new Vector3((x + 1) * s, level, z * s);
var p01 = new Vector3(x * s, level, (z + 1) * s);
var p11 = new Vector3((x + 1) * s, level, (z + 1) * s);
// Two triangles, wound so the surface faces up.
AddVert(st, c, p00); AddVert(st, c, p01); AddVert(st, c, p11);
AddVert(st, c, p00); AddVert(st, c, p11); AddVert(st, c, p10);
any = true;
}
}
if (!any) return;
var water = new MeshInstance3D();
water.Mesh = st.Commit();
var mat = new StandardMaterial3D();
mat.VertexColorUseAsAlbedo = true;
// Vertex alpha is only honoured when the material is actually transparent.
mat.Transparency = BaseMaterial3D.TransparencyEnum.Alpha;
// Lighting note: the scene's environment is minimal (one default
// DirectionalLight3D, a blue ambient), so a metallic surface reads almost
// black — metal shows its surroundings, and there is little to show. Water
// is therefore non-metallic with moderate roughness: albedo stays visible
// under weak ambient, and it still catches a highlight when a light does
// hit it. Left deliberately in the same lighting world as the terrain so
// the two improve together when the scene gets proper lighting.
mat.Roughness = 0.35f;
mat.Metallic = 0.0f;
// Both faces: the camera starts above, but a player will swim under it.
mat.CullMode = BaseMaterial3D.CullModeEnum.Disabled;
water.MaterialOverride = mat;
// Child of this chunk renderer, so it inherits the chunk's world offset
// and dies with the chunk.
AddChild(water);
water.Position = Vector3.Zero;
water.Name = "Water";
}
private static void AddVert(SurfaceTool st, Color c, Vector3 p)
{
// SetColor/SetNormal must be set immediately before AddVertex — same rule
// the terrain mesher follows.
st.SetColor(c);
st.SetNormal(Vector3.Up);
st.AddVertex(p);
}
/// <summary>
/// Shallow teal to deep navy, with alpha closing up as it deepens, so the
/// shelved coast stays readable and open ocean does not.
/// </summary>
private static Color DepthColor(float depthMeters)
{
float t = Mathf.Clamp(depthMeters / Constants.WATER_DEEP_METERS, 0f, 1f);
t = t * t * (3f - 2f * t); // smoothstep: hold the shallows, then fall away
Color c = Constants.WATER_SHALLOW.Lerp(Constants.WATER_DEEP, t);
c.A = Mathf.Lerp(Constants.WATER_ALPHA_SHALLOW, Constants.WATER_ALPHA_DEEP, t);
return c;
} }
} }
} }

View file

@ -56,7 +56,8 @@ Every section: `[u32 tag][u64 payload-length in bytes][payload]`.
| `BIOM` / `0x4D4F4942` | `MapSize²` × `u8` biome ordinal, same pixel order | Mandatory. Ordinals from `Enums.cs::Biome`**append-only, never reorder** (the ordinal IS the wire value). Writer refuses ordinals > 255; reader rejects ordinals ≥ the known biome count (parse-time validation; the palette's runtime Dirt fallback for in-memory values is unchanged). Length must equal `MapSize²`. | | `BIOM` / `0x4D4F4942` | `MapSize²` × `u8` biome ordinal, same pixel order | Mandatory. Ordinals from `Enums.cs::Biome`**append-only, never reorder** (the ordinal IS the wire value). Writer refuses ordinals > 255; reader rejects ordinals ≥ the known biome count (parse-time validation; the palette's runtime Dirt fallback for in-memory values is unchanged). Length must equal `MapSize²`. |
| `TOWN` / `0x4E574F54` | `i32 count`, then per town: `f32 X` · `f32 Y` · `u8 tier` · `u8 isHighwayNode` (0/1) | Tier ordinals from `Enums.cs::TownTier`, append-only, range-checked on read. The highway-node flag is what the generator's road topology was built from (v1 dropped it); carried and exposed on the parsed blueprint, consumed by nothing server-side yet. | | `TOWN` / `0x4E574F54` | `i32 count`, then per town: `f32 X` · `f32 Y` · `u8 tier` · `u8 isHighwayNode` (0/1) | Tier ordinals from `Enums.cs::TownTier`, append-only, range-checked on read. The highway-node flag is what the generator's road topology was built from (v1 dropped it); carried and exposed on the parsed blueprint, consumed by nothing server-side yet. |
| `TCRV` / `0x56524354` | 50 B base: `u16 curveVersion` · `f32 knot1..knot4` · `f32 spikeMax` · `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 benchLo` · `f32 benchHi` · `f32 peakCap` · `f32 tailSlope`. **When `curveVersion ≥ 4`, a 36 B modulation extension follows:** `f32 benchAmp` · `f32 plateauAmp` · `f32 shelfSpanMin` · `f32 shelfSpanMax` · `f32 elevFreqIslands` · `f32 strengthFreqIslands` · `i32 benchSeedOffset` · `i32 plateauSeedOffset` · `i32 strengthSeedOffset`. **When `curveVersion ≥ 5`, a 9 B preset extension follows:** `u8 presetId` (1 = compact, 2 = balanced) · `f32 k5` · `f32 k6` — with the four base knot slots this makes the effective curve unambiguous from the record alone (record total 95 B; the length-framed section + version byte keep every layout change safe) | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v3"`); absent = raw legacy profile. **The `u16 curveVersion` selects the field semantics and the unserialized anchor set:** v1 — knots t1..t4, spikeMax = pooled calibration max, benchLo/Hi = the 50 m plateau's lo/hi. v2 — as v1 but spikeMax = the seed's effective raw pre-curve maximum (per-seed spike normalizer). v3 — knots = K1..K4 of six (K5 = 0.930304, K6 = 1.050720 are version constants, not serialized), spikeMax per-seed, benchLo/benchHi = the fixed 100 m / 220 m shelves. **v4 (current)** — as v3, but benchLo/benchHi are the shelf **BASE** anchors and the extension record carries the spatial-modulation parameters: shelf elevations vary ±benchAmp/±plateauAmp and shelf strength blends the output span across `[shelfSpanMin, shelfSpanMax]`, via Simplex fields seeded `resolvedWorldSeed + seedOffset` at `freq/MapSize` (frequencies stated in undulations per island width). Because spikeMax is per-seed (v2+), blueprints are not reproducible from curve constants alone — that is why it is recorded. **Metadata only:** exported heights are already curved; nothing re-applies the map. Calibration provenance: `HeightCurve.cs` header + the task-05/06/07 reports. | | `TCRV` / `0x56524354` | 50 B base: `u16 curveVersion` · `f32 knot1..knot4` · `f32 spikeMax` · `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 benchLo` · `f32 benchHi` · `f32 peakCap` · `f32 tailSlope`. **When `curveVersion ≥ 4`, a 36 B modulation extension follows:** `f32 benchAmp` · `f32 plateauAmp` · `f32 shelfSpanMin` · `f32 shelfSpanMax` · `f32 elevFreqIslands` · `f32 strengthFreqIslands` · `i32 benchSeedOffset` · `i32 plateauSeedOffset` · `i32 strengthSeedOffset`. **When `curveVersion ≥ 5`, a 9 B preset extension follows:** `u8 presetId` (1 = compact, 2 = balanced) · `f32 k5` · `f32 k6` — with the four base knot slots this makes the effective curve unambiguous from the record alone (record total 95 B; the length-framed section + version byte keep every layout change safe) | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v3"`); absent = raw legacy profile. **The `u16 curveVersion` selects the field semantics and the unserialized anchor set:** v1 — knots t1..t4, spikeMax = pooled calibration max, benchLo/Hi = the 50 m plateau's lo/hi. v2 — as v1 but spikeMax = the seed's effective raw pre-curve maximum (per-seed spike normalizer). v3 — knots = K1..K4 of six (K5 = 0.930304, K6 = 1.050720 are version constants, not serialized), spikeMax per-seed, benchLo/benchHi = the fixed 100 m / 220 m shelves. **v4 (current)** — as v3, but benchLo/benchHi are the shelf **BASE** anchors and the extension record carries the spatial-modulation parameters: shelf elevations vary ±benchAmp/±plateauAmp and shelf strength blends the output span across `[shelfSpanMin, shelfSpanMax]`, via Simplex fields seeded `resolvedWorldSeed + seedOffset` at `freq/MapSize` (frequencies stated in undulations per island width). Because spikeMax is per-seed (v2+), blueprints are not reproducible from curve constants alone — that is why it is recorded. **Metadata only:** exported heights are already curved; nothing re-applies the map. Calibration provenance: `HeightCurve.cs` header + the task-05/06/07 reports. |
| `TDTL` / `0x4C544454` | 38 B: `u16 detailVersion` · `f32 reliefAmpM` · `f32 reliefFreqIslands` · `f32 incK` · `f32 incP` · `f32 incCapM` · `f32 seaClampRaw` · `f32 craterExclFactor` · `f32 shelfIncWeight` · `i32 reliefSeedOffset` | Optional — present iff the terrain detail passes shaped this blueprint's `HGTS` (config `TerrainDetail: "v1"`, requires the curve): shelf micro-relief (±reliefAmpM, shelf-ness weighted) and D8 drainage incision (`depth = K·accum^p·slope`, capped, riser-masked, sea+1 m clamped, crater-excluded). **Metadata only** — heights are already detailed; the incision channels are the designated future river routes (Phase C). Machinery: `Tools/Scripts/TerrainDetailPass.cs`. | | `TDTL` / `0x4C544454` | 30 B: `u16 detailVersion` · `f32 reliefAmpM` · `f32 reliefFreqIslands` · `i32 reliefSeedOffset` · `f32 edgeAmpM` · `f32 edgeFreqIslands` · `i32 edgeSeedOffset` · `f32 edgeMaxShiftM` | Optional — present iff the terrain detail passes shaped this blueprint's `HGTS` (config `TerrainDetail: "v1"`, requires the curve). **`detailVersion` selects the body layout and a reader that does not recognise it SKIPS the section** (leaving detail metadata null) rather than misreading a differently shaped payload — the one section whose body is versioned rather than extended, because v1's layout was retired rather than grown. **v1 (retired, never shipped)** — shelf micro-relief + D8 drainage incision, 38 B; the incision produced grid-aligned artifacts and was reverted whole, so the only v1 payloads that exist are in that batch's own tree. **v2 (current)** — shelf micro-relief (±`reliefAmpM` output metres, shelf-ness weighted) + shelf-edge variation: a per-column shift of the curve's shelf/riser knot block K3/K4/K5, drawn from a Simplex field seeded `resolvedWorldSeed + edgeSeedOffset` at `edgeFreqIslands/MapSize`, amplitude ±`edgeAmpM` **metres of INPUT height** — a displacement of the shelf boundary contour, not an elevation change. `edgeAmpM` is recorded **as applied**, after the clamp to `edgeMaxShiftM` (the preset's band-squeeze bound), so the record always describes the terrain rather than the request. **Metadata only** — heights are already detailed. Machinery: `Tools/Scripts/TerrainDetailPass.cs`. |
| `EROS` / `0x534F5245` | 67 B: `u16 erosionVersion` · `i32 dropletCount` · `i32 lifetime` · `i32 brushRadius` · `i32 seedOffset` · `f32 carveCapM` · `f32 depositCapM` · `f32 seaMarginM` · `f32 inertia` · `f32 capacityFactor` · `f32 minSlopeM` · `f32 erodeRate` · `f32 depositRate` · `f32 evaporation` · `f32 gravity` · `f32 craterCoreFactor` · `f32 craterFeatherFactor` · `u8 craterMode` | Optional — present iff the droplet hydraulic-erosion pass shaped this blueprint's `HGTS` (config `Erosion: "v1"`, terrain-water tasks 1719). **`erosionVersion` selects the body layout; an unrecognised version is SKIPPED whole** (erosion metadata left null), same rule as `TDTL`. **v1 (task 17)** — 58 B, no `depositCapM`; deposition was bilinear over 4 cells and unbounded, which built isolated cones (measured 15.5 m). **v2 (task 18)** — 62 B; deposition brush-spread and per-cell bounded. **v3 (task 19, current)** — replaces v2's single `craterExclFactor` with `craterCoreFactor` (the protected strike core), `craterFeatherFactor` and `craterMode` (`0` = full, `1` = feather). Only v2+ payloads exist outside tasks 1718's own batch trees. The FOUR governors are `dropletCount`/`lifetime`/`carveCapM`/`depositCapM`; both caps are enforced against one per-cell NET displacement ledger (positive = carved below the height the pass found, negative = built up above it) and asserted on exit. `seaMarginM` is the flood-guard clamp — no cell is carved below sea + margin, and below-sea cells are untouched in BOTH directions, so the rendered coastline cannot move **and the crater's flooded bay can be neither carved open nor silted shut regardless of `craterMode`**. Crater radii are FACTORS of `PRMS.CraterRadius`: nothing inside `craterCoreFactor ×` it is modified; `feather` ramps erosion 0→full from there out to `craterFeatherFactor ×` it, `full` applies full strength immediately. Droplets are deterministic from `resolvedWorldSeed + seedOffset` (PCG32). Remaining fields are the droplet-model strength dials; slopes/amounts in metres (1 raw = 251 m). All values recorded **as applied** (post config clamping). **Metadata only** — heights are already eroded, and the classify-side sections (`BIOM`/`WBID`/…) never saw the pass by design. Machinery: `Tools/Scripts/HydraulicErosion.cs`. |
| `WBID` / `0x44494257` | `MapSize²` × `u16` water-body id, same pixel order as `HGTS` | Optional (absent = no water data, e.g. a legacy re-encode). `0` = no water, `1` = **the** ocean body, `2..N` = lakes. Ids assigned in deterministic scan order (X outer / Y inner, first-encountered pixel), lakes labeled with the **same 4-connectivity as `CalculateTrueOcean`**. Membership is exactly the generator's water classification — the biome grid's Ocean/Lake pixels and this grid's nonzero pixels are the same set **by construction** (shared predicates). Length must equal `2·MapSize²`. | | `WBID` / `0x44494257` | `MapSize²` × `u16` water-body id, same pixel order as `HGTS` | Optional (absent = no water data, e.g. a legacy re-encode). `0` = no water, `1` = **the** ocean body, `2..N` = lakes. Ids assigned in deterministic scan order (X outer / Y inner, first-encountered pixel), lakes labeled with the **same 4-connectivity as `CalculateTrueOcean`**. Membership is exactly the generator's water classification — the biome grid's Ocean/Lake pixels and this grid's nonzero pixels are the same set **by construction** (shared predicates). Length must equal `2·MapSize²`. |
| `WBTB` / `0x42544257` | `i32 count`, then per body (20 B): `u16 id` · `u8 type` (0 ocean, 1 lake) · `u8 salinity` (0 fresh, 1 salt) · `f32 surfaceLevel` · `i32 pixelCount` · `f32 centroidX` · `f32 centroidY` | Optional, paired with `WBID`. **`surfaceLevel` is a documented TRANSITIONAL rule:** one flat level per body — `GetSeaLevel` at the body's pixel centroid (ocean: at the map centre) under the still-live latitude field; superseded by the flat-scalar sea model (minted, lands with the coast change set). The field's per-pixel slope is deliberately NOT baked into any section. **Salinity is a provisional default** (ocean salt, lake fresh) — a placeholder for the future fresh/salt irrigation mechanic, not a mechanic. | | `WBTB` / `0x42544257` | `i32 count`, then per body (20 B): `u16 id` · `u8 type` (0 ocean, 1 lake) · `u8 salinity` (0 fresh, 1 salt) · `f32 surfaceLevel` · `i32 pixelCount` · `f32 centroidX` · `f32 centroidY` | Optional, paired with `WBID`. **`surfaceLevel` is a documented TRANSITIONAL rule:** one flat level per body — `GetSeaLevel` at the body's pixel centroid (ocean: at the map centre) under the still-live latitude field; superseded by the flat-scalar sea model (minted, lands with the coast change set). The field's per-pixel slope is deliberately NOT baked into any section. **Salinity is a provisional default** (ocean salt, lake fresh) — a placeholder for the future fresh/salt irrigation mechanic, not a mechanic. |
| `WSRF` / `0x46525357` | `MapSize²` × `u16` quantized water-surface elevation, same pixel order | Optional, paired with `WBID`. `0` is the reserved **no-water sentinel**; a real level `L` (raw height units) encodes as `1 + round(L × 32768)` so it can never encode to 0; decode `(q 1)/32768` (`BlueprintFormat.EncodeWaterLevel`/`DecodeWaterLevel`). Covers `[0 … ~1.99997]` raw at `1/32768` raw ≈ **7.7 mm** of world height (1 raw = 251 m) — far finer than the 1 m voxel. Nonzero exactly where `WBID` is nonzero; the value is the pixel's body level. | | `WSRF` / `0x46525357` | `MapSize²` × `u16` quantized water-surface elevation, same pixel order | Optional, paired with `WBID`. `0` is the reserved **no-water sentinel**; a real level `L` (raw height units) encodes as `1 + round(L × 32768)` so it can never encode to 0; decode `(q 1)/32768` (`BlueprintFormat.EncodeWaterLevel`/`DecodeWaterLevel`). Covers `[0 … ~1.99997]` raw at `1/32768` raw ≈ **7.7 mm** of world height (1 raw = 251 m) — far finer than the 1 m voxel. Nonzero exactly where `WBID` is nonzero; the value is the pixel's body level. |
@ -113,11 +114,50 @@ body).
shelves at 100±12 m / 220±20 m, per-seed-normalized 420 m summit spires, 60 % lowland) shapes shelves at 100±12 m / 220±20 m, per-seed-normalized 420 m summit spires, 60 % lowland) shapes
`HGTS`. `HGTS`.
- **`TerrainDetail`** (`"v1"` | `"off"`, default `"v1"`; no-op without the curve) — the task-10 - **`TerrainDetail`** (`"v1"` | `"off"`, default `"v1"`; no-op without the curve) — the task-10
detail passes as one judged unit: shelf micro-relief (`ShelfReliefAmp` metres, default 3) and detail passes as one judged unit: shelf micro-relief (`ShelfReliefAmp`, default 3 m of output
drainage incision. When on, `TDTL` records the parameters. Biome/water classification is curve-invariant by height) and shelf-edge variation (`ShelfEdgeVariation`, default 12 m of **input** height — how
far the shelf/riser boundary contour wanders, clamped at load to the preset's band-squeeze
bound, loudly). Both are output-height only. When on, `TDTL` records the parameters as applied.
Biome/water classification is curve-invariant by
construction (it classifies the retained uncurved heights); town positions and everything 3D construction (it classifies the retained uncurved heights); town positions and everything 3D
follow the curved terrain. When on, `TCRV` records the effective parameters including the follow the curved terrain. When on, `TCRV` records the effective parameters including the
per-seed `spikeMax`. per-seed `spikeMax`.
- **`Erosion`** (`"v1"` | `"off"`, default `"off"` — opt-in until the developer's gate approves
it) — the droplet hydraulic-erosion pass (tasks 1718): carve-and-deposit drainage detailing of
`HGTS` after the detail passes and before the crater carve. FOUR governor dials
(`ErosionDropletCount` / `ErosionDropletLifetime` / `ErosionCarveCap` / `ErosionDepositCap`,
defaults 250 000 / 384 / 15 m / 6 m; bounds clamped loudly at load) plus the sea-clamp margin
and strength constants — the full dial list and semantics live in the `EROS` row above and
`ConfigManager.cs`. The task-18 defaults tune for a drainage HIERARCHY: droplets live long
enough (384 steps at inertia 0.35) for their paths to overlap and deepen shared low lines into
trunk channels rather than dying as short independent scratches, and the raised carve cap lets
those trunks separate from the fine rills instead of both piling against one ceiling.
Output-height only: the classify path reads pre-erosion heights, so `BIOM` and every water
section stay bit-identical with erosion on or off, and the sea clamp keeps even the RENDERED
coastline fixed. When on, `EROS` records the parameters as applied.
- **`CraterErosionMode`** (`"feather"` | `"full"`, default `"feather"` pending the task-19 gate),
with **`CraterErosionCore`** (`0.50`) and **`CraterErosionFeather`** (`1.05`), both factors of
`CraterRadius` — how erosion treats the crater surrounds. Task 17's hard `1.2 ×` cutoff left a
visible un-eroded disc: the carve writes only inside `0.80 ×` and its displacement is exactly 0
beyond that, so 620 811 land cells of ordinary terrain were held smooth for no geometric reason
(measured, seed 1280587109). Only the deep strike core is protected now; `feather` ramps erosion
in across `core → feather` for a younger-looking crater with no seam, `full` weathers the
surrounds like any other terrain. Neither mode can affect the flooded bay or its sea connection —
that is the sea clamp's guarantee, not the exclusion's.
- **`CoastProfile`** (`"wide"` | `"steep"`, default `"wide"`) — the submarine shelf. The height
curve is identity at and below sea level, so it never reshaped the seabed; `"wide"` compresses
shallow depth so the shallows reach 2.42.9× further out. It is strictly positive for positive
depth and therefore **cannot move the waterline**, so `HGTS` changes while `BIOM`, `WBID`,
`WBTB` and `WSRF` stay bit-identical. `"steep"` is the pre-task-11 seabed.
- **`IslandAxisX`** / **`IslandAxisY`** (default `1.30` / `0.78`; pre-task-11 `1.15` / `0.90`) —
the falloff axis ratios, and the island's proportions. These **move the coastline**, so unlike
the curve and detail work they legitimately change `BIOM` and the water sections: an elongated
seed has a new biome baseline. Rejected (with a loud restore to legacy) if ≤ 0.01, which would
divide by ~zero and silently yield an all-ocean map.
- **`OffshoreIslandDensity`** (default `0.02`, `0` disables, clamped to 0.5) — the fraction of the
offshore noise field above the islet threshold, calibrated per seed against the field's own
distribution. Adds land, so it also moves `BIOM` and the water sections.
### Deliberately NOT a section: basins (`BSIN`) ### Deliberately NOT a section: basins (`BSIN`)

View file

@ -21,6 +21,21 @@ namespace IslaApocalypse.Core
public const byte WASTELAND_DIRT = 9; public const byte WASTELAND_DIRT = 9;
public const byte ASPHALT = 10; // For our roads! public const byte ASPHALT = 10; // For our roads!
// Water (terrain-water task 13). Wire-safe to add: block IDs are NEVER
// serialized — the blueprint's section table (BLUEPRINT_FORMAT.md) stores
// heights, biome ordinals and water data, never block IDs, and chunks are
// not persisted yet. So this ID is a runtime-only value and appending to
// the table cannot invalidate a .dat.
//
// NOTE this block is not placed into ChunkData.BlockIDs by the terrain
// path. Terrain is a Marching-Cubes iso-surface over a density field, and
// writing WATER into that field would FUSE the water into the terrain
// surface rather than lay a sheet on top of it. Water is its own mesh
// (ChunkRenderer). The entry exists so water has a real identity in the
// registry — a name and a colour — for the renderer and for whatever
// later needs to ask "what is this".
public const byte WATER = 11;
// This static constructor runs automatically the first time the registry is accessed // This static constructor runs automatically the first time the registry is accessed
static BlockRegistry() static BlockRegistry()
{ {
@ -42,6 +57,10 @@ namespace IslaApocalypse.Core
// Infrastructure // Infrastructure
Blocks.Add(ASPHALT, new BlockData(ASPHALT, "Asphalt", true, new Color(0.15f, 0.15f, 0.15f))); Blocks.Add(ASPHALT, new BlockData(ASPHALT, "Asphalt", true, new Color(0.15f, 0.15f, 0.15f)));
// Water — not solid (you can move through it), shallow-water colour as the
// registry's representative value; the renderer shades by depth from there.
Blocks.Add(WATER, new BlockData(WATER, "Water", false, new Color(0.24f, 0.55f, 0.62f)));
} }
public static BlockData GetBlock(byte id) public static BlockData GetBlock(byte id)

View file

@ -34,6 +34,24 @@ namespace IslaApocalypse.Core
public const uint TAG_WATER_SURFACE = 0x46525357; // "WSRF" public const uint TAG_WATER_SURFACE = 0x46525357; // "WSRF"
public const uint TAG_TERRAIN_CURVE = 0x56524354; // "TCRV" public const uint TAG_TERRAIN_CURVE = 0x56524354; // "TCRV"
public const uint TAG_TERRAIN_DETAIL = 0x4C544454; // "TDTL" public const uint TAG_TERRAIN_DETAIL = 0x4C544454; // "TDTL"
public const uint TAG_EROSION = 0x534F5245; // "EROS"
// TDTL body version. 1 = shelf micro-relief + D8 drainage incision (the
// task-10 draft; the incision was reverted, and the only v1 payloads in
// existence are in that batch's tree). 2 = the current body. A reader that
// meets an unrecognised body version skips it rather than misreading a
// differently shaped payload into plausible-looking nonsense.
public const ushort TDTL_VERSION = 2;
// EROS body version. 1 (task 17) = governors count/lifetime/carve cap, sea
// clamp, brush, strength constants, RNG seed offset, crater exclusion factor;
// the only v1 payloads in existence are in that task's batch tree. 2 (task 18,
// current) inserts the DEPOSIT CAP governor after the carve cap — deposition is
// now brush-spread and per-cell bounded. Same reader rule as TDTL: an unknown
// body version is skipped whole rather than misread into plausible nonsense.
// 3 (task 19, current) replaces the single crater exclusion factor with the
// protected-core factor, a feather-band factor, and the crater mode byte.
public const ushort EROS_VERSION = 3;
// WSRF quantization: u16, 0 reserved as the no-water sentinel. A real level L // WSRF quantization: u16, 0 reserved as the no-water sentinel. A real level L
// (raw blueprint height units) encodes as 1 + round(L × 32768), so a genuine // (raw blueprint height units) encodes as 1 + round(L × 32768), so a genuine

View file

@ -35,6 +35,8 @@ namespace IslaApocalypse.Core
WriteSection(writer, BlueprintFormat.TAG_TERRAIN_CURVE, w => WriteTerrainCurve(w, bp.TerrainCurve)); WriteSection(writer, BlueprintFormat.TAG_TERRAIN_CURVE, w => WriteTerrainCurve(w, bp.TerrainCurve));
if (bp.TerrainDetail != null) if (bp.TerrainDetail != null)
WriteSection(writer, BlueprintFormat.TAG_TERRAIN_DETAIL, w => WriteTerrainDetail(w, bp.TerrainDetail)); WriteSection(writer, BlueprintFormat.TAG_TERRAIN_DETAIL, w => WriteTerrainDetail(w, bp.TerrainDetail));
if (bp.Erosion != null)
WriteSection(writer, BlueprintFormat.TAG_EROSION, w => WriteErosion(w, bp.Erosion));
WriteSection(writer, BlueprintFormat.TAG_HEIGHTS, w => WriteHeights(w, bp)); WriteSection(writer, BlueprintFormat.TAG_HEIGHTS, w => WriteHeights(w, bp));
WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(w, bp)); WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(w, bp));
@ -155,12 +157,28 @@ namespace IslaApocalypse.Core
private static void WriteTerrainDetail(BinaryWriter writer, TerrainDetailInfo d) private static void WriteTerrainDetail(BinaryWriter writer, TerrainDetailInfo d)
{ {
writer.Write(d.Version); writer.Write(d.Version); // u16
writer.Write(d.ReliefAmpM); writer.Write(d.ReliefFreqIslands); writer.Write(d.ReliefAmpM); writer.Write(d.ReliefFreqIslands); // 2 × f32
writer.Write(d.IncK); writer.Write(d.IncP); writer.Write(d.IncCapM); writer.Write(d.ReliefSeedOffset); // i32
writer.Write(d.SeaClampRaw); writer.Write(d.CraterExclFactor); writer.Write(d.EdgeAmpM); writer.Write(d.EdgeFreqIslands); // 2 × f32
writer.Write(d.ShelfIncWeight); writer.Write(d.EdgeSeedOffset); // i32
writer.Write(d.ReliefSeedOffset); writer.Write(d.EdgeMaxShiftM); // f32
}
private static void WriteErosion(BinaryWriter writer, ErosionInfo e)
{
writer.Write(e.Version); // u16
writer.Write(e.DropletCount); writer.Write(e.Lifetime); // 2 × i32
writer.Write(e.BrushRadius); writer.Write(e.SeedOffset); // 2 × i32
writer.Write(e.CarveCapM); writer.Write(e.DepositCapM); // 2 × f32
writer.Write(e.SeaMarginM); // f32
writer.Write(e.Inertia); writer.Write(e.CapacityFactor); // 2 × f32
writer.Write(e.MinSlopeM); // f32
writer.Write(e.ErodeRate); writer.Write(e.DepositRate); // 2 × f32
writer.Write(e.Evaporation); writer.Write(e.Gravity); // 2 × f32
writer.Write(e.CraterCoreFactor); // f32
writer.Write(e.CraterFeatherFactor); // f32
writer.Write(e.CraterMode); // u8
} }
private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp) private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp)

View file

@ -37,6 +37,23 @@ namespace IslaApocalypse.Core
// doesn't get painted like a motorway. // doesn't get painted like a motorway.
public byte[,] ColumnRoadMaterial = new byte[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1]; public byte[,] ColumnRoadMaterial = new byte[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1];
// --- WATER AT REST (terrain-water task 13) ---------------------------
// Per column: the world-space Y of the water SURFACE, or Constants.NO_WATER
// where the blueprint says this column is dry. Filled by the server from
// the blueprint's WSRF/WBID/WBTB — the runtime never decides where water
// is, it only draws what the blueprint already classified.
//
// WaterDepthM is the TRUE depth in metres (water level minus seabed, both
// in blueprint units), kept separately because the rendered seabed is
// clamped at Y=2 and would flatten every deep-ocean column to the same
// value. Colour reads this; geometry reads WaterSurfaceY.
public float[,] WaterSurfaceY = new float[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1];
public float[,] WaterDepthM = new float[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1];
/// <summary>True if any column in this chunk carries water — lets the
/// renderer skip building a water mesh for the many inland chunks.</summary>
public bool HasAnyWater = false;
// For our future delta-save system // For our future delta-save system
public bool IsPlayerProtected = false; public bool IsPlayerProtected = false;
public bool NeedsSaving = false; public bool NeedsSaving = false;
@ -44,6 +61,12 @@ namespace IslaApocalypse.Core
public ChunkData(Vector2I position) public ChunkData(Vector2I position)
{ {
ChunkPosition = position; ChunkPosition = position;
// float[,] defaults to 0, which is a legal-looking water height. Start
// every column explicitly dry instead.
for (int x = 0; x <= Constants.CHUNK_SIZE_X; x++)
for (int z = 0; z <= Constants.CHUNK_SIZE_Z; z++)
WaterSurfaceY[x, z] = Constants.NO_WATER;
} }
} }
} }

View file

@ -33,17 +33,149 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
// are retired. Default: v5. // are retired. Default: v5.
public static string TerrainCurve = "v5"; public static string TerrainCurve = "v5";
// Terrain detail passes (task 10): "v1" = shelf micro-relief + drainage // Terrain detail passes (task 10): "v1" = shelf micro-relief + shelf-edge
// incision as one judged unit (requires the curve; no-op when it is off); // variation as one judged unit (requires the curve; no-op when it is off);
// "off" disables both. ShelfReliefAmp is the micro-relief amplitude in // "off" disables both. ShelfReliefAmp is the micro-relief amplitude in metres
// metres. Defaults: v1, 3 m. // of OUTPUT height. ShelfEdgeVariation is the shelf-edge warp amplitude in
// metres of INPUT height — how far the shelf/riser boundary contour is
// displaced, not an elevation change; it is clamped at load time to the
// largest shift the curve's bands can absorb. Defaults: v1, 3 m, 12 m.
public static string TerrainDetail = "v1"; public static string TerrainDetail = "v1";
public static float ShelfReliefAmp = 3.0f; public static float ShelfReliefAmp = 3.0f;
public static float ShelfEdgeVariation = 12.0f;
// Hydraulic erosion (task 17, Phase C0): droplet-based carve-and-deposit on
// the RENDER height map only — the classify path (biomes/water) never sees
// it. "off" until the developer's gate approves it; the batch that turns it
// on does so explicitly. The three GOVERNORS hard-bound the pass:
// DropletCount (cost/detail), DropletLifetime (max steps per droplet),
// CarveCap (max erosion depth per cell, metres — the runaway-trench guard
// and what keeps erosion a detailing pass). ErosionSeaMargin is the flood
// guard: no cell is ever carved below sea + margin, and below-sea cells are
// never touched at all, so the rendered coastline cannot move. The remaining
// dials are the standard droplet-model strength constants; slopes/amounts
// are in METRES (1 raw height unit = 251 m).
// Task-18 defaults tune for a DRAINAGE HIERARCHY: long-lived, committed
// droplets (lifetime 384 at inertia 0.35, evaporation 0.004) travel far
// enough down a flank that their paths overlap and deepen shared low lines
// into trunk channels, instead of dying as independent 48-px scratches;
// the carve cap is raised to 15 m so trunks can separate from the fine
// rills instead of both piling up against the same ceiling. A modest
// erode rate keeps the total material moved in detailing range.
// ErosionDepositCap is governor 4 (task 18): brush-spread deposition alone
// does not bound a spike once droplets carry long-path loads.
public static string Erosion = "off";
public static int ErosionDropletCount = 250000;
public static int ErosionDropletLifetime = 384;
public static float ErosionCarveCap = 15.0f; // m per cell
public static float ErosionDepositCap = 6.0f; // m per cell; <= 0 = unbounded
public static float ErosionSeaMargin = 0.5f; // m above sea, carve floor
public static int ErosionBrushRadius = 2; // px
public static float ErosionInertia = 0.35f;
public static float ErosionCapacity = 4.0f;
public static float ErosionMinSlope = 0.02f; // m per px, capacity floor
public static float ErosionErodeRate = 0.12f;
public static float ErosionDepositRate = 0.15f;
public static float ErosionEvaporation = 0.004f;
public static float ErosionGravity = 4.0f;
// How erosion treats the crater surrounds (task 19). The task-17 hard
// 1.2 × CraterRadius cutoff left a visible un-eroded disc: measured, the carve
// writes only inside 0.80 × and its displacement is exactly 0 beyond that, so
// 620 811 LAND cells of ordinary terrain were being held smooth for no
// geometric reason. Now only the deep strike core is protected.
// "full" — full-strength erosion right up to the core boundary. The
// crater formed after the terrain and has weathered since.
// "feather" — erosion ramps 0→full across CraterErosionCore →
// CraterErosionFeather (the detail pass's shape), so the crater
// reads as younger, less-weathered, and there is no seam at all.
// Radii are FACTORS of CraterRadius. The flooded bay and its sea connection do
// NOT depend on these: below-sea cells are read-only in both directions.
public static string CraterErosionMode = "feather";
public static float CraterErosionCore = CRATER_EROSION_CORE_DEFAULT;
public static float CraterErosionFeather = CRATER_EROSION_FEATHER_DEFAULT;
// 0.80 = the carve's OWN extent (MapGenerator's physicalCraterRadius). Keeping the
// core at least this wide is what makes erosion and the carve touch DISJOINT cells,
// which is what keeps the post-carve flood guard exactly zero: the carve runs after
// erosion and scales height toward the sea target, so it AMPLIFIES any erosion delta
// inside its radius and can push a hair-above-sea cell across the waterline. Measured
// at a 0.50 core: 79 cells newly below the rendered sea, 113 310 below-sea cells
// disturbed. A smaller core reclaims nothing extra either — the over-protected annulus
// is 0.80x-1.2x, entirely outside the carve.
public const float CRATER_EROSION_CORE_DEFAULT = 0.80f;
public const float CRATER_EROSION_FEATHER_DEFAULT = 1.05f;
// Rivers (task 22, C0b part 2a): carve the frozen task-21b river plan's
// beds into the RENDER map — ocean trunks, routed giants (lowland reach to
// the sea), lake-enders. NO WATER yet (part 2b). "off" until the routing-
// style gate; the A/B batch turns it on explicitly. RiverRoutingStyle picks
// the lowland routing for routed giants: "short" heads direct (lightly
// terrain-aware), "lowground" follows the lowest ground and wanders like a
// real river — the task-22 gate decides which ships; "lowground" is the
// provisional default pending that verdict. Width/depth scales are taste
// dials on the flow-proportional bed profile. RiverSeaMargin is the bed's
// absolute floor above sea — the erosion flood-guard discipline: no river
// bed may create inland below-sea cells, so the rendered coastline cannot
// move even with rivers carved.
public static string Rivers = "off";
public static string RiverRoutingStyle = "lowground";
public static float RiverWidthScale = 1.0f;
public static float RiverDepthScale = 1.0f;
public static float RiverSeaMargin = 0.2f; // m above sea, bed floor
// Island falloff shaping (task 11).
//
// CoastProfile: "wide" adds the submarine shelf — the height curve is identity
// at and below sea, so it never reached the seabed, which still dropped ~6.7x
// steeper than the land it meets. "steep" is the pre-task-11 seabed, kept for
// A/B. The shelf cannot move the waterline, so biomes and water are identical
// either way. Default: wide.
//
// IslandAxisX/Y: the falloff axis ratios. These MOVE THE COASTLINE and
// therefore move biomes, so the DEFAULT is the shape the gate approved.
// Task 11 shipped 1.30/0.78 as the default and the developer's gate REJECTED
// that elongation; the defaults are back to 1.15/0.90 so that omitting the
// keys can no longer silently produce the rejected island (task 12 §4).
// NOTE, measured in task 11: the island is already Trench-clamped in x at
// ~90% of the map width, so AxisX is a weak lever — aspect responds almost
// entirely to AxisY, which trades against land area.
//
// OffshoreIslandDensity: fraction of the ocean noise field above the islet
// threshold. 0 disables the layer. Islets never touch the Trench and are held
// off the mainland by a depth moat.
public static string CoastProfile = "wide";
public static float IslandAxisX = LEGACY_AXIS_X; // 1.15 — the gate's verdict
public static float IslandAxisY = LEGACY_AXIS_Y; // 0.90
public static float OffshoreIslandDensity = 0.02f;
// The gate-approved island shape, named so the defaults above and the bad-value
// restore below both point at one place. (`const`, so using it in a field
// initialiser declared earlier resolves at compile time.)
public const float LEGACY_AXIS_X = 1.15f;
public const float LEGACY_AXIS_Y = 0.90f;
public static void LoadConfig() public static void LoadConfig()
{ {
string path = "res://ServerConfig.json"; string path = "res://ServerConfig.json";
// Iteration override (task 22): ISLA_SERVER_CONFIG names an alternate
// config FILE to load instead of the repo's ServerConfig.json. Batch and
// A/B runs point this at a scratch config, so the developer's live
// ServerConfig.json is never written by tooling again — the whole
// backup/restore dance (and its task-19 near-miss) goes away. Loud, so
// a forgotten env var cannot silently masquerade as the repo config.
string envPath = OS.GetEnvironment("ISLA_SERVER_CONFIG");
if (!string.IsNullOrEmpty(envPath))
{
if (FileAccess.FileExists(envPath))
{
path = envPath;
GD.Print($"[ConfigManager] ⚠ ISLA_SERVER_CONFIG override: loading '{envPath}' (NOT the repo ServerConfig.json).");
}
else
GD.PrintErr($"[ConfigManager] ISLA_SERVER_CONFIG set but '{envPath}' does not exist — falling back to the repo config.");
}
if (!FileAccess.FileExists(path)) if (!FileAccess.FileExists(path))
{ {
GD.PrintErr("[ConfigManager] ServerConfig.json not found! Defaulting to 8K."); GD.PrintErr("[ConfigManager] ServerConfig.json not found! Defaulting to 8K.");
@ -138,6 +270,125 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
{ {
ShelfReliefAmp = (float)data["ShelfReliefAmp"]; ShelfReliefAmp = (float)data["ShelfReliefAmp"];
} }
if (data.ContainsKey("ShelfEdgeVariation"))
{
ShelfEdgeVariation = (float)data["ShelfEdgeVariation"];
}
// Extract the erosion gate + dials (task 17)
if (data.ContainsKey("Erosion"))
{
string erosion = (string)data["Erosion"];
if (erosion == "off" || erosion == "v1")
Erosion = erosion;
else
GD.PrintErr($"[ConfigManager] Unknown Erosion '{erosion}'. Keeping '{Erosion}'.");
}
if (data.ContainsKey("ErosionDropletCount")) ErosionDropletCount = (int)data["ErosionDropletCount"];
if (data.ContainsKey("ErosionDropletLifetime")) ErosionDropletLifetime = (int)data["ErosionDropletLifetime"];
if (data.ContainsKey("ErosionCarveCap")) ErosionCarveCap = (float)data["ErosionCarveCap"];
if (data.ContainsKey("ErosionDepositCap")) ErosionDepositCap = (float)data["ErosionDepositCap"];
if (data.ContainsKey("ErosionSeaMargin")) ErosionSeaMargin = (float)data["ErosionSeaMargin"];
if (data.ContainsKey("ErosionBrushRadius")) ErosionBrushRadius = (int)data["ErosionBrushRadius"];
if (data.ContainsKey("ErosionInertia")) ErosionInertia = (float)data["ErosionInertia"];
if (data.ContainsKey("ErosionCapacity")) ErosionCapacity = (float)data["ErosionCapacity"];
if (data.ContainsKey("ErosionMinSlope")) ErosionMinSlope = (float)data["ErosionMinSlope"];
if (data.ContainsKey("ErosionErodeRate")) ErosionErodeRate = (float)data["ErosionErodeRate"];
if (data.ContainsKey("ErosionDepositRate")) ErosionDepositRate = (float)data["ErosionDepositRate"];
if (data.ContainsKey("ErosionEvaporation")) ErosionEvaporation = (float)data["ErosionEvaporation"];
if (data.ContainsKey("ErosionGravity")) ErosionGravity = (float)data["ErosionGravity"];
// Governor bounds are enforced HERE, loudly, so a bad dial is a refused
// dial rather than a silently absurd generation. The clamps are wide —
// they exist to catch typos (an extra zero), not to tune.
int rawCount = ErosionDropletCount; int rawLife = ErosionDropletLifetime;
float rawCap = ErosionCarveCap;
ErosionDropletCount = Mathf.Clamp(ErosionDropletCount, 0, 50_000_000);
ErosionDropletLifetime = Mathf.Clamp(ErosionDropletLifetime, 1, 4096);
ErosionCarveCap = Mathf.Clamp(ErosionCarveCap, 0f, 60f);
if (rawCount != ErosionDropletCount || rawLife != ErosionDropletLifetime || rawCap != ErosionCarveCap)
GD.PrintErr($"[ConfigManager] Erosion governor out of bounds — clamped: count {rawCount}->{ErosionDropletCount}, lifetime {rawLife}->{ErosionDropletLifetime}, cap {rawCap}->{ErosionCarveCap} m.");
// Rivers gate + dials (task 22)
if (data.ContainsKey("Rivers"))
{
string rv = (string)data["Rivers"];
if (rv == "off" || rv == "v1")
Rivers = rv;
else
GD.PrintErr($"[ConfigManager] Unknown Rivers '{rv}'. Keeping '{Rivers}'.");
}
if (data.ContainsKey("RiverRoutingStyle"))
{
string st = (string)data["RiverRoutingStyle"];
if (st == "short" || st == "lowground")
RiverRoutingStyle = st;
else
GD.PrintErr($"[ConfigManager] Unknown RiverRoutingStyle '{st}'. Keeping '{RiverRoutingStyle}'.");
}
if (data.ContainsKey("RiverWidthScale")) RiverWidthScale = (float)data["RiverWidthScale"];
if (data.ContainsKey("RiverDepthScale")) RiverDepthScale = (float)data["RiverDepthScale"];
if (data.ContainsKey("RiverSeaMargin")) RiverSeaMargin = (float)data["RiverSeaMargin"];
RiverWidthScale = Mathf.Clamp(RiverWidthScale, 0.1f, 5f);
RiverDepthScale = Mathf.Clamp(RiverDepthScale, 0.1f, 5f);
RiverSeaMargin = Mathf.Clamp(RiverSeaMargin, 0f, 5f);
// Crater erosion treatment (task 19)
if (data.ContainsKey("CraterErosionMode"))
{
string cm = (string)data["CraterErosionMode"];
if (cm == "full" || cm == "feather")
CraterErosionMode = cm;
else
GD.PrintErr($"[ConfigManager] Unknown CraterErosionMode '{cm}'. Keeping '{CraterErosionMode}'.");
}
if (data.ContainsKey("CraterErosionCore")) CraterErosionCore = (float)data["CraterErosionCore"];
if (data.ContainsKey("CraterErosionFeather")) CraterErosionFeather = (float)data["CraterErosionFeather"];
CraterErosionCore = Mathf.Clamp(CraterErosionCore, 0f, 3f);
CraterErosionFeather = Mathf.Clamp(CraterErosionFeather, 0f, 4f);
// A feather band that does not extend past the core is not a band; say so
// rather than silently behaving like "full".
if (CraterErosionMode == "feather" && CraterErosionFeather <= CraterErosionCore)
{
GD.PrintErr($"[ConfigManager] CraterErosionFeather {CraterErosionFeather:F2} must exceed " +
$"CraterErosionCore {CraterErosionCore:F2} — the ramp would have zero width. " +
$"Restoring {CRATER_EROSION_FEATHER_DEFAULT:F2}.");
CraterErosionFeather = CRATER_EROSION_FEATHER_DEFAULT;
}
// Negative is meaningless; 0 is the documented "unbounded" escape hatch.
ErosionDepositCap = Mathf.Clamp(ErosionDepositCap, 0f, 60f);
ErosionSeaMargin = Mathf.Clamp(ErosionSeaMargin, 0f, 5f);
ErosionBrushRadius = Mathf.Clamp(ErosionBrushRadius, 0, 8);
ErosionInertia = Mathf.Clamp(ErosionInertia, 0f, 0.99f);
ErosionCapacity = Mathf.Max(ErosionCapacity, 0f);
ErosionMinSlope = Mathf.Max(ErosionMinSlope, 0f);
ErosionErodeRate = Mathf.Clamp(ErosionErodeRate, 0f, 1f);
ErosionDepositRate = Mathf.Clamp(ErosionDepositRate, 0f, 1f);
ErosionEvaporation = Mathf.Clamp(ErosionEvaporation, 0f, 0.5f);
ErosionGravity = Mathf.Max(ErosionGravity, 0f);
// Extract the island-falloff dials (task 11)
if (data.ContainsKey("CoastProfile"))
{
string coast = (string)data["CoastProfile"];
if (coast == "steep" || coast == "wide")
CoastProfile = coast;
else
GD.PrintErr($"[ConfigManager] Unknown CoastProfile '{coast}'. Keeping '{CoastProfile}'.");
}
if (data.ContainsKey("IslandAxisX")) IslandAxisX = (float)data["IslandAxisX"];
if (data.ContainsKey("IslandAxisY")) IslandAxisY = (float)data["IslandAxisY"];
if (data.ContainsKey("OffshoreIslandDensity")) OffshoreIslandDensity = (float)data["OffshoreIslandDensity"];
// The axis ratios divide map extents; a zero or negative one is a divide-by-
// zero that would silently produce an all-ocean map. Refuse it loudly.
if (IslandAxisX <= 0.01f || IslandAxisY <= 0.01f)
{
GD.PrintErr($"[ConfigManager] IslandAxisX/Y must be > 0.01 (got {IslandAxisX}/{IslandAxisY}). Restoring legacy {LEGACY_AXIS_X}/{LEGACY_AXIS_Y}.");
IslandAxisX = LEGACY_AXIS_X;
IslandAxisY = LEGACY_AXIS_Y;
}
OffshoreIslandDensity = Mathf.Clamp(OffshoreIslandDensity, 0f, 0.5f);
switch (profile) switch (profile)
{ {

View file

@ -75,6 +75,40 @@ namespace IslaApocalypse.Core
/// <summary>Safety margin added to the road cull, in metres.</summary> /// <summary>Safety margin added to the road cull, in metres.</summary>
public const float ROAD_CULL_MARGIN = 3.0f; public const float ROAD_CULL_MARGIN = 3.0f;
// --- WATER AT REST (terrain-water task 13) --------------------------
// The blueprint has carried water since task 03; this is only about
// DRAWING it. Water is a flat sheet at the body's own surface level —
// no waves, no flow, no animation. Those are C2+.
/// <summary>
/// Sentinel in ChunkData.WaterSurfaceY for "this column has no water".
/// Real surface heights are always >= 2 (the terrain clamp's floor), so a
/// negative value is unambiguous.
/// </summary>
public const float NO_WATER = -1.0f;
/// <summary>
/// Raw blueprint height -> world metres. One raw unit is this many metres,
/// and it is exactly the terrain mapping (CHUNK_HEIGHT - 5), named so the
/// water surface and the seabed cannot drift apart if the band is retuned.
/// </summary>
public const float HEIGHT_SCALE = CHUNK_HEIGHT - 5;
// Depth shading. Depth is measured from the BLUEPRINT heights, not the
// rendered geometry: the terrain render clamps its floor at Y=2, so deep
// ocean would otherwise all read as one flat ~36 m and the gradient would
// die exactly where the ocean gets interesting.
public const float WATER_DEEP_METERS = 60.0f; // depth at which the gradient bottoms out
public static readonly Color WATER_SHALLOW = new Color(0.38f, 0.76f, 0.78f);
public static readonly Color WATER_DEEP = new Color(0.05f, 0.17f, 0.42f);
/// <summary>
/// Water alpha, shallow -> deep. Shallows are clearer, so the shelved coast
/// and the seabed under it stay readable; depth closes the surface up.
/// </summary>
public const float WATER_ALPHA_SHALLOW = 0.62f;
public const float WATER_ALPHA_DEEP = 0.97f;
/// <summary> /// <summary>
/// Looks up the carve settings for a road tier. One place to tune. /// Looks up the carve settings for a road tier. One place to tune.
/// </summary> /// </summary>

View file

@ -89,16 +89,37 @@ namespace IslaApocalypse.Core
/// <summary> /// <summary>
/// The terrain detail passes that shaped this blueprint's HGTS (v2 TDTL section, /// The terrain detail passes that shaped this blueprint's HGTS (v2 TDTL section,
/// terrain-water task 10): shelf micro-relief + drainage incision parameters. /// terrain-water task 10): shelf micro-relief + shelf-edge variation parameters.
/// Null when detail was off. Metadata only — heights are already detailed. /// Null when detail was off. Metadata only — heights are already detailed.
/// Version 1 (relief + the reverted D8 incision) never left the task-10 batch
/// tree; the parser rejects it rather than misreading its longer payload.
/// </summary> /// </summary>
public class TerrainDetailInfo public class TerrainDetailInfo
{ {
public ushort Version; public ushort Version;
public float ReliefAmpM, ReliefFreqIslands; public float ReliefAmpM, ReliefFreqIslands;
public float IncK, IncP, IncCapM;
public float SeaClampRaw, CraterExclFactor, ShelfIncWeight;
public int ReliefSeedOffset; public int ReliefSeedOffset;
public float EdgeAmpM, EdgeFreqIslands;
public int EdgeSeedOffset;
public float EdgeMaxShiftM;
}
/// <summary>
/// The hydraulic-erosion pass that detailed this blueprint's HGTS (v2 EROS
/// section, terrain-water task 17): droplet-model governors and strength
/// constants, as APPLIED (post config clamping). Null when erosion was off.
/// Metadata only — heights are already eroded; the classify-side data (biomes,
/// water) never saw the pass by design.
/// </summary>
public class ErosionInfo
{
public ushort Version;
public int DropletCount, Lifetime, BrushRadius, SeedOffset;
public float CarveCapM, DepositCapM, SeaMarginM;
public float Inertia, CapacityFactor, MinSlopeM;
public float ErodeRate, DepositRate, Evaporation, Gravity;
public float CraterCoreFactor, CraterFeatherFactor;
public byte CraterMode; // 0 = full, 1 = feather
} }
public class WorldBlueprint public class WorldBlueprint
@ -131,6 +152,9 @@ namespace IslaApocalypse.Core
// The detail passes that shaped HeightMap (TDTL section); null = no detail. // The detail passes that shaped HeightMap (TDTL section); null = no detail.
public TerrainDetailInfo TerrainDetail; public TerrainDetailInfo TerrainDetail;
// The erosion pass that detailed HeightMap (EROS section); null = no erosion.
public ErosionInfo Erosion;
} }
// 2. The Parser Utility // 2. The Parser Utility
@ -254,7 +278,8 @@ namespace IslaApocalypse.Core
else if (tag == BlueprintFormat.TAG_WATER_SURFACE) sectionOk = ParseWaterGrid(reader, blueprint, payloadLength, isSurface: true); else if (tag == BlueprintFormat.TAG_WATER_SURFACE) sectionOk = ParseWaterGrid(reader, blueprint, payloadLength, isSurface: true);
else if (tag == BlueprintFormat.TAG_WATER_BODY_TABLE) sectionOk = ParseWaterBodyTable(reader, blueprint); else if (tag == BlueprintFormat.TAG_WATER_BODY_TABLE) sectionOk = ParseWaterBodyTable(reader, blueprint);
else if (tag == BlueprintFormat.TAG_TERRAIN_CURVE) sectionOk = ParseTerrainCurve(reader, blueprint); else if (tag == BlueprintFormat.TAG_TERRAIN_CURVE) sectionOk = ParseTerrainCurve(reader, blueprint);
else if (tag == BlueprintFormat.TAG_TERRAIN_DETAIL) sectionOk = ParseTerrainDetail(reader, blueprint); else if (tag == BlueprintFormat.TAG_TERRAIN_DETAIL) sectionOk = ParseTerrainDetail(reader, blueprint, payloadLength);
else if (tag == BlueprintFormat.TAG_EROSION) sectionOk = ParseErosion(reader, blueprint, payloadLength);
else else
{ {
// The property the redesign exists to buy: future sections (water, // The property the redesign exists to buy: future sections (water,
@ -448,19 +473,55 @@ namespace IslaApocalypse.Core
return true; return true;
} }
private static bool ParseTerrainDetail(BinaryReader reader, WorldBlueprint blueprint) private static bool ParseTerrainDetail(BinaryReader reader, WorldBlueprint blueprint, ulong payloadLength)
{ {
var d = new TerrainDetailInfo(); var d = new TerrainDetailInfo();
d.Version = reader.ReadUInt16(); d.Version = reader.ReadUInt16();
if (d.Version != BlueprintFormat.TDTL_VERSION)
{
// A TDTL v1 payload (the reverted relief+incision layout) is LONGER and
// laid out differently; reading it as v2 would silently mint plausible
// nonsense. Skip the body and leave TerrainDetail null — the heights are
// still whatever they are, we just refuse to describe them wrongly.
GD.PrintErr($"[MapDataParser] ⚠ TDTL version {d.Version} is not the current {BlueprintFormat.TDTL_VERSION} — section skipped, detail metadata unavailable.");
reader.BaseStream.Seek((long)payloadLength - 2L, SeekOrigin.Current);
return true;
}
d.ReliefAmpM = reader.ReadSingle(); d.ReliefFreqIslands = reader.ReadSingle(); d.ReliefAmpM = reader.ReadSingle(); d.ReliefFreqIslands = reader.ReadSingle();
d.IncK = reader.ReadSingle(); d.IncP = reader.ReadSingle(); d.IncCapM = reader.ReadSingle();
d.SeaClampRaw = reader.ReadSingle(); d.CraterExclFactor = reader.ReadSingle();
d.ShelfIncWeight = reader.ReadSingle();
d.ReliefSeedOffset = reader.ReadInt32(); d.ReliefSeedOffset = reader.ReadInt32();
d.EdgeAmpM = reader.ReadSingle(); d.EdgeFreqIslands = reader.ReadSingle();
d.EdgeSeedOffset = reader.ReadInt32();
d.EdgeMaxShiftM = reader.ReadSingle();
blueprint.TerrainDetail = d; blueprint.TerrainDetail = d;
return true; return true;
} }
private static bool ParseErosion(BinaryReader reader, WorldBlueprint blueprint, ulong payloadLength)
{
var e = new ErosionInfo();
e.Version = reader.ReadUInt16();
if (e.Version != BlueprintFormat.EROS_VERSION)
{
// Same rule as TDTL: an unrecognised body version is skipped whole
// rather than misread into plausible-looking nonsense.
GD.PrintErr($"[MapDataParser] ⚠ EROS version {e.Version} is not the current {BlueprintFormat.EROS_VERSION} — section skipped, erosion metadata unavailable.");
reader.BaseStream.Seek((long)payloadLength - 2L, SeekOrigin.Current);
return true;
}
e.DropletCount = reader.ReadInt32(); e.Lifetime = reader.ReadInt32();
e.BrushRadius = reader.ReadInt32(); e.SeedOffset = reader.ReadInt32();
e.CarveCapM = reader.ReadSingle(); e.DepositCapM = reader.ReadSingle();
e.SeaMarginM = reader.ReadSingle();
e.Inertia = reader.ReadSingle(); e.CapacityFactor = reader.ReadSingle();
e.MinSlopeM = reader.ReadSingle();
e.ErodeRate = reader.ReadSingle(); e.DepositRate = reader.ReadSingle();
e.Evaporation = reader.ReadSingle(); e.Gravity = reader.ReadSingle();
e.CraterCoreFactor = reader.ReadSingle(); e.CraterFeatherFactor = reader.ReadSingle();
e.CraterMode = reader.ReadByte();
blueprint.Erosion = e;
return true;
}
private static bool ParseRoadTier(BinaryReader reader, List<Vector2[]> into) private static bool ParseRoadTier(BinaryReader reader, List<Vector2[]> into)
{ {
int pathCount = reader.ReadInt32(); int pathCount = reader.ReadInt32();

View file

@ -11,7 +11,11 @@ never track live game events — who is online, which chunks are loaded, what ti
### Voxel materials ### Voxel materials
- **`BlockData.cs`** — struct describing one block type (ID, name, IsSolid, BaseColor). - **`BlockData.cs`** — struct describing one block type (ID, name, IsSolid, BaseColor).
- **`BlockRegistry.cs`** — the byte-ID master list (`AIR` 0, `BEDROCK`, `STONE`, `DIRT`, `SAND`, the - **`BlockRegistry.cs`** — the byte-ID master list (`AIR` 0, `BEDROCK`, `STONE`, `DIRT`, `SAND`, the
three grasses, `SNOW`, `WASTELAND_DIRT`, `ASPHALT`) with a safe `GetBlock` lookup. three grasses, `SNOW`, `WASTELAND_DIRT`, `ASPHALT`, `WATER`) with a safe `GetBlock` lookup.
**Block IDs are never serialized** — the blueprint stores heights, biome ordinals and water data,
never block IDs, and chunks are not persisted — so appending to this table is wire-safe.
`WATER` is a registry identity only: it is not written into `ChunkData.BlockIDs`, because water is
drawn as its own surface rather than as part of the terrain iso-surface (see `Client/README.md`).
- **`BiomePalette.cs`** — decides which material sits where, given biome, depth, and whether the - **`BiomePalette.cs`** — decides which material sits where, given biome, depth, and whether the
column is roadbed. Two paths on purpose: an **integer** classification that decides what is stored column is roadbed. Two paths on purpose: an **integer** classification that decides what is stored
in each voxel, and a **float-depth** version used for rendering that returns the two materials in each voxel, and a **float-depth** version used for rendering that returns the two materials
@ -43,11 +47,17 @@ rendering.
flag, and the optional water-bodies data: `WaterBodyIds`, `WaterBodies` table, `WaterSurfaceQ`). flag, and the optional water-bodies data: `WaterBodyIds`, `WaterBodies` table, `WaterSurfaceQ`).
Dispatches on the first byte: v2 tagged-section files get validation (version gate, size Dispatches on the first byte: v2 tagged-section files get validation (version gate, size
bounds, section-length and ordinal range checks); legacy v1 files still load, intact, with a bounds, section-length and ordinal range checks); legacy v1 files still load, intact, with a
deprecation warning. Nothing at runtime consumes the water data yet. deprecation warning. **The water sections are consumed at runtime since task 13** — the server
reads them per column and the client draws the result (see `Server/README.md`).
- **`ChunkData.cs`** — one chunk's density and block-ID fields, `+1` padded on every axis so the - **`ChunkData.cs`** — one chunk's density and block-ID fields, `+1` padded on every axis so the
mesher can reach into the neighbouring chunk, plus the per-column data the renderer needs. mesher can reach into the neighbouring chunk, plus the per-column data the renderer needs —
including `WaterSurfaceY` (world Y of the water surface, or `Constants.NO_WATER`), `WaterDepthM`
(true depth from blueprint heights) and `HasAnyWater`.
- **`Constants.cs`** — chunk dimensions, `ISO_LEVEL`, `VOXEL_SCALE`, and the visual/road tunables - **`Constants.cs`** — chunk dimensions, `ISO_LEVEL`, `VOXEL_SCALE`, and the visual/road tunables
(material blend band; per-tier road width, shoulder, grade smoothing and surface). (material blend band; per-tier road width, shoulder, grade smoothing and surface), plus the
water-at-rest tunables: `HEIGHT_SCALE` (the one raw-height→metres mapping, shared by the terrain
surface and the water sheet so they cannot drift apart), `NO_WATER`, and the depth-shading
colour/alpha ramp.
### Maths ### Maths
- **`MarchingCubes.cs`** — density field → `ArrayMesh`, with analytical normals and deterministic - **`MarchingCubes.cs`** — density field → `ArrayMesh`, with analytical normals and deterministic

View file

@ -2,13 +2,34 @@
[ext_resource type="Script" uid="uid://c1dxqbgohxr6k" path="res://Server/Scripts/ServerChunkManager.cs" id="1_r150o"] [ext_resource type="Script" uid="uid://c1dxqbgohxr6k" path="res://Server/Scripts/ServerChunkManager.cs" id="1_r150o"]
[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_sky0"]
sky_top_color = Color(0.28, 0.47, 0.79, 1)
sky_horizon_color = Color(0.72, 0.8, 0.86, 1)
sky_curve = 0.12
ground_bottom_color = Color(0.16, 0.15, 0.14, 1)
ground_horizon_color = Color(0.58, 0.56, 0.5, 1)
ground_curve = 0.05
sun_angle_max = 12.0
sun_curve = 0.15
[sub_resource type="Sky" id="Sky_main0"]
sky_material = SubResource("ProceduralSkyMaterial_sky0")
[sub_resource type="Environment" id="Environment_r150o"] [sub_resource type="Environment" id="Environment_r150o"]
background_mode = 2
sky = SubResource("Sky_main0")
ambient_light_source = 3
ambient_light_color = Color(0.37245482, 0.5677467, 1, 1) ambient_light_color = Color(0.37245482, 0.5677467, 1, 1)
ambient_light_energy = 0.45
[node name="World" type="Node3D" unique_id=412138339] [node name="World" type="Node3D" unique_id=412138339]
script = ExtResource("1_r150o") script = ExtResource("1_r150o")
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1569534216] [node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1569534216]
transform = Transform3D(-0.7071068, 0, 0.7071068, 0.5572077, 0.6156615, 0.5572077, -0.4353384, 0.7880108, -0.4353384, 0, 0, 0)
light_energy = 1.0
shadow_enabled = true
directional_shadow_max_distance = 2500.0
[node name="Camera3D" type="Camera3D" parent="." unique_id=1064925728] [node name="Camera3D" type="Camera3D" parent="." unique_id=1064925728]
transform = Transform3D(1, 0, 0, 0, 0.49999997, -0.86602545, 0, 0.86602545, 0.49999997, 2559.9224, 640.90497, -171.46881) transform = Transform3D(1, 0, 0, 0, 0.49999997, -0.86602545, 0, 0.86602545, 0.49999997, 2559.9224, 640.90497, -171.46881)

View file

@ -27,6 +27,24 @@ vector ends up parallel to the view direction, so camera roll is undefined and G
- **Road culling first.** Only the road segments whose bounding box reaches this chunk are kept, - **Road culling first.** Only the road segments whose bounding box reaches this chunk are kept,
each tagged with its `RoadTier`. The padding is derived from the widest shoulder any tier has plus each tagged with its `RoadTier`. The padding is derived from the widest shoulder any tier has plus
a margin, so widening a road cannot silently truncate it at chunk edges. a margin, so widening a road cannot silently truncate it at chunk edges.
- **Water per column** (tasks 13, 15) — the blueprint is the **authority** on where water is and at
what level; the server only reads it. `WSRF` gives the surface level per pixel, and the `WBTB`
body table is the fallback when a column is flagged wet but carries the `WSRF` no-water sentinel.
**Presence takes two clauses.** `WBID` (the water stage's own classification output) OR *the
rendered ground being under the ocean's surface*. The second exists because `WBID` was classified
from the **uncurved** heightmap while the mesh renders the **curved** one. Outside the crater
those agree exactly — the curve is identity at sea and monotonic, so `Apply(raw) < sea` iff
`raw < sea`. Inside it they do not: the carve lerps two different bases toward one target
(classify from `raw`, rendered from `Apply(raw)`, and `Apply(raw) < raw` in the lowland band), so
the rendered surface sinks faster and leaves a ring rendering below the waterline that `WBID`
still calls dry — 375,824 px on the C1 seed, all of it inside the carve. The second clause tests
the height the mesh actually uses against the **ocean body's own level from `WBTB`**, so the
runtime still derives nothing, and by the identity above it can only ever fire inside the carve. A body the table does not know leaves the column **dry** rather than
guessing a level. The level is scaled by the same `HEIGHT_SCALE` as the terrain, so the sheet and
the seabed cannot drift apart. Depth for shading is taken from **blueprint** heights, not rendered
geometry — the terrain render clamps its floor at `Y = 2`, which would otherwise flatten every
deep-ocean column to one value. Each run logs what it drew (`[Server] Water at rest: …`).
- **Surface height per column** (`GetExactSurface`) — the blueprint height scaled into the chunk's - **Surface height per column** (`GetExactSurface`) — the blueprint height scaled into the chunk's
usable vertical band, then modified by any road carving. usable vertical band, then modified by any road carving.
- **Density per voxel**`(y surfaceY)` normalised by the local slope, giving a signed distance to - **Density per voxel**`(y surfaceY)` normalised by the local slope, giving a signed distance to

View file

@ -29,6 +29,17 @@ namespace IslaApocalypse.Server
private Dictionary<Vector2I, ChunkData> _activeChunks = new Dictionary<Vector2I, ChunkData>(); private Dictionary<Vector2I, ChunkData> _activeChunks = new Dictionary<Vector2I, ChunkData>();
public int chunkSize = 24; public int chunkSize = 24;
// Water-at-rest diagnostics (task 13), summed across the chunks generated.
private long _waterColumnsRendered = 0;
private int _chunksWithWater = 0;
private float _waterMinY = float.MaxValue, _waterMaxY = float.MinValue, _waterMaxDepth = 0f;
// Shoreline seam (task 15). The OCEAN body's own surface level, from the
// blueprint's WBTB table; -1 when the blueprint carries no ocean. Columns the
// mesh renders below this but WBID calls dry are the seam, and are counted.
private float _oceanLevelRaw = -1f;
private long _seamColumnsRecovered = 0;
public override void _Ready() public override void _Ready()
{ {
@ -55,6 +66,13 @@ namespace IslaApocalypse.Server
$"vs config MapSize {ConfigManager.MapSize}."); $"vs config MapSize {ConfigManager.MapSize}.");
} }
// The ocean's surface level, for the shoreline-seam rule below. Taken
// from the blueprint's body table (type OCEAN), never derived here —
// the runtime does not compute sea level (D-033).
if (_blueprint.WaterBodies != null)
foreach (var body in _blueprint.WaterBodies)
if (body.Type == WaterBodyInfo.TYPE_OCEAN) { _oceanLevelRaw = body.SurfaceLevel; break; }
GD.Print("[Server] Blueprint loaded. Locating Capitol City..."); GD.Print("[Server] Blueprint loaded. Locating Capitol City...");
// 2. Find the Capitol in the parsed data // 2. Find the Capitol in the parsed data
@ -69,7 +87,8 @@ namespace IslaApocalypse.Server
// Old original png map coords // Old original png map coords
// Vector2 capitolPos = new Vector2(2405, 3296); // hardcoded for now since we know exactly where it is in this seed, which is the "paradise" biome hub // Vector2 capitolPos = new Vector2(2405, 3296); // hardcoded for now since we know exactly where it is in this seed, which is the "paradise" biome hub
// New Manual Test point
// Vector2 capitolPos = new Vector2(4487, 4424);
GD.Print($"[Server] Capitol found at {capitolPos}. Generating chunks..."); GD.Print($"[Server] Capitol found at {capitolPos}. Generating chunks...");
@ -87,6 +106,16 @@ namespace IslaApocalypse.Server
} }
} }
// Say what was actually drawn. The water is the blueprint's, not the
// runtime's, so if this reads zero the question is which of the two
// sides went quiet — and this line answers it without a debugger.
GD.Print($"[Server] Water at rest: {_waterColumnsRendered} water columns across " +
$"{_chunksWithWater} of {_activeChunks.Count} chunks" +
(_waterColumnsRendered > 0
? $"; surface Y {_waterMinY:F1}..{_waterMaxY:F1} m, depth up to {_waterMaxDepth:F1} m; " +
$"{_seamColumnsRecovered} shoreline-seam columns recovered (mesh below the ocean surface, WBID dry)."
: " — nothing to draw here (check the blueprint carries WBID/WSRF)."));
// 5. Teleport the Camera to look down at our creation! // 5. Teleport the Camera to look down at our creation!
Camera3D cam = GetNodeOrNull<Camera3D>("Camera3D"); Camera3D cam = GetNodeOrNull<Camera3D>("Camera3D");
if (cam != null) if (cam != null)
@ -253,6 +282,72 @@ namespace IslaApocalypse.Server
newChunk.ColumnBiomes[x, z] = columnBiome; newChunk.ColumnBiomes[x, z] = columnBiome;
newChunk.ColumnRoadMaterial[x, z] = roadSurface; newChunk.ColumnRoadMaterial[x, z] = roadSurface;
// --- WATER AT REST (task 13) + SHORELINE SEAM (task 15) ------
// The blueprint is the AUTHORITY on where water is and at what
// level; the runtime only draws it. WSRF gives the level per pixel,
// and the WBTB body table is the fallback if a column is flagged wet
// but carries the WSRF no-water sentinel.
//
// PRESENCE, though, takes two clauses. WBID was classified from the UNCURVED
// heightmap — the classify path that keeps the biome/water oracle
// byte-identical — while the mesh renders the CURVED one. Outside
// the crater those two agree exactly, because the curve is identity
// at sea and monotonic, so Apply(raw) < sea iff raw < sea. INSIDE
// the crater they do not: the carve lerps two different bases toward
// one target (classify from raw, rendered from Apply(raw), and
// Apply(raw) < raw throughout the lowland band), so the rendered
// surface sinks faster and leaves a ring that renders below the
// waterline while WBID still calls it dry. Measured on seed
// 1825907253: 375,824 such columns, 100 % of them inside the carve.
//
// So presence is decided by BOTH: the blueprint's classification,
// OR the rendered ground actually being under the ocean's surface.
// The second clause can only ever fire inside the carve (see the
// identity above), which is precisely the flooded bay it exists for.
if (_blueprint.WaterBodyIds != null)
{
ushort bodyId = _blueprint.WaterBodyIds[globalX, globalZ];
float levelRaw = -1f;
if (bodyId != 0)
{
if (_blueprint.WaterSurfaceQ != null)
{
ushort q = _blueprint.WaterSurfaceQ[globalX, globalZ];
if (q != 0) levelRaw = BlueprintFormat.DecodeWaterLevel(q);
}
if (levelRaw < 0f) levelRaw = BodyLevel(bodyId);
}
else if (_oceanLevelRaw >= 0f
&& exactSurfaceY < _oceanLevelRaw * Constants.HEIGHT_SCALE)
{
// Rendered ground below the ocean's own surface level. The
// level still comes from the blueprint (the ocean body's
// WBTB entry) — the runtime derives nothing, it only notices
// that the ground the MESH draws is under that surface.
levelRaw = _oceanLevelRaw;
_seamColumnsRecovered++;
}
if (levelRaw >= 0f)
{
// Same mapping as the terrain, so the sheet and the seabed
// cannot drift apart.
newChunk.WaterSurfaceY[x, z] = Mathf.Clamp(
levelRaw * Constants.HEIGHT_SCALE, 2.0f, Constants.CHUNK_HEIGHT - 2.0f);
// TRUE depth, from blueprint units — see ChunkData.
newChunk.WaterDepthM[x, z] =
Mathf.Max(0f, (levelRaw - _blueprint.HeightMap[globalX, globalZ]) * Constants.HEIGHT_SCALE);
newChunk.HasAnyWater = true;
_waterColumnsRendered++;
float wy = newChunk.WaterSurfaceY[x, z];
if (wy < _waterMinY) _waterMinY = wy;
if (wy > _waterMaxY) _waterMaxY = wy;
if (newChunk.WaterDepthM[x, z] > _waterMaxDepth) _waterMaxDepth = newChunk.WaterDepthM[x, z];
}
}
float hRight = surfaceRight - exactSurfaceY; float hRight = surfaceRight - exactSurfaceY;
float hFwd = surfaceFwd - exactSurfaceY; float hFwd = surfaceFwd - exactSurfaceY;
float slopeX = hRight / Constants.VOXEL_SCALE; float slopeX = hRight / Constants.VOXEL_SCALE;
@ -275,6 +370,7 @@ namespace IslaApocalypse.Server
} // End of Z loop } // End of Z loop
} // End of X loop } // End of X loop
if (newChunk.HasAnyWater) _chunksWithWater++;
_activeChunks.Add(chunkCoord, newChunk); _activeChunks.Add(chunkCoord, newChunk);
var renderer = new IslaApocalypse.Client.ChunkRenderer(); var renderer = new IslaApocalypse.Client.ChunkRenderer();
@ -282,6 +378,21 @@ namespace IslaApocalypse.Server
renderer.RenderChunk(newChunk); renderer.RenderChunk(newChunk);
} }
/// <summary>
/// A water body's flat surface level, from the blueprint's WBTB table. Only
/// used as the fallback when a column is flagged wet by WBID but its WSRF
/// entry is the no-water sentinel — WSRF is the per-pixel authority, this is
/// the per-body one. Returns -1 if the body is unknown, which the caller
/// reads as "leave this column dry" rather than guessing a level.
/// </summary>
private float BodyLevel(ushort bodyId)
{
if (_blueprint.WaterBodies == null) return -1f;
foreach (var body in _blueprint.WaterBodies)
if (body.Id == bodyId) return body.SurfaceLevel;
return -1f;
}
/// <summary> /// <summary>
/// Turns a map-pixel position into a world surface height, with bounds clamping. /// Turns a map-pixel position into a world surface height, with bounds clamping.
/// Same mapping used everywhere else: raw 0-1 heightmap value scaled into the /// Same mapping used everywhere else: raw 0-1 heightmap value scaled into the

View file

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

View file

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

View file

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

View file

@ -42,6 +42,11 @@ public sealed class CurveKnots
/// Unchanged from v4: storm-ladder anchors, bench 100±12 m, plateau 220±20 m, /// Unchanged from v4: storm-ladder anchors, bench 100±12 m, plateau 220±20 m,
/// strength modulation (span max 25 m), 420 m cap, per-seed spike normalization, /// strength modulation (span max 25 m), 420 m cap, per-seed spike normalization,
/// modulation fields/seed offsets, the classify-map invariant. /// modulation fields/seed offsets, the classify-map invariant.
///
/// The curve SHAPE is frozen at v5. Task 10 adds no band, anchor or slope — only
/// a per-column `edgeShift` parameter on Apply, which slides the shelf/riser knot
/// block K3/K4/K5 so those three boundaries stop being clean iso-height contours.
/// It is a new input to the same curve, not a new curve.
/// </summary> /// </summary>
public static class HeightCurve public static class HeightCurve
{ {
@ -87,11 +92,29 @@ public static class HeightCurve
return Mathf.Lerp(SHELF_SPAN_MAX, SHELF_SPAN_MIN, Mathf.Clamp(strength01, 0f, 1f)); return Mathf.Lerp(SHELF_SPAN_MAX, SHELF_SPAN_MIN, Mathf.Clamp(strength01, 0f, 1f));
} }
/// <summary>
/// The curve for ONE column. <paramref name="edgeShift"/> (task 10 pass B) slides
/// the shelf/riser knot BLOCK — K3/K4/K5 — up or down by a per-column amount,
/// leaving K1/K2/K6 fixed. Every shelf↔riser boundary is the contour where the
/// raw height crosses one of those three knots, so shifting them makes those
/// contours wander instead of tracing a clean iso-height line: the shelf edge
/// scallops. Because the block moves rigidly, the bench and mid-riser bands keep
/// their exact widths (their interior shapes are translated, not distorted); only
/// the foothill riser and the plateau stretch or compress to absorb the shift.
/// Monotonicity is structural, not conditional — the curve is monotonic for ANY
/// strictly ordered knot set, and TerrainDetailPass.MaxEdgeShift keeps the set
/// ordered by construction. Below K2 and above K6 the output is bit-identical to
/// an unwarped column, which is what makes the red-ceiling floor and the 420 m
/// peak cap exact under the warp.
/// </summary>
public static float Apply(float h, float hMaxSeed, public static float Apply(float h, float hMaxSeed,
float benchLo, float benchSpan, float plateauLo, float plateauSpan, CurveKnots k) float benchLo, float benchSpan, float plateauLo, float plateauSpan, CurveKnots k,
float edgeShift)
{ {
if (h <= SEA) return h; if (h <= SEA) return h;
float k3 = k.K3 + edgeShift, k4 = k.K4 + edgeShift, k5 = k.K5 + edgeShift;
float u, s; float u, s;
if (h < k.K1) if (h < k.K1)
{ {
@ -104,27 +127,27 @@ public static class HeightCurve
u = (h - k.K1) / (k.K2 - k.K1); u = (h - k.K1) / (k.K2 - k.K1);
return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // frozen linear rise return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // frozen linear rise
} }
if (h < k.K3) if (h < k3)
{ {
u = (h - k.K2) / (k.K3 - k.K2); u = (h - k.K2) / (k3 - k.K2);
s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // foothill riser — corner fix 1 s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // foothill riser — corner fix 1
return RED_CEIL + s * (benchLo - RED_CEIL); return RED_CEIL + s * (benchLo - RED_CEIL);
} }
if (h < k.K4) if (h < k4)
{ {
u = (h - k.K3) / (k.K4 - k.K3); u = (h - k3) / (k4 - k3);
return benchLo + u * benchSpan; // bench (min span 6 m — fix 3) return benchLo + u * benchSpan; // bench (min span 6 m — fix 3)
} }
float benchTop = benchLo + benchSpan; float benchTop = benchLo + benchSpan;
if (h < k.K5) if (h < k5)
{ {
u = (h - k.K4) / (k.K5 - k.K4); u = (h - k4) / (k5 - k4);
s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // mid riser — corner fix 1 s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // mid riser — corner fix 1
return benchTop + s * (plateauLo - benchTop); return benchTop + s * (plateauLo - benchTop);
} }
if (h < k.K6) if (h < k.K6)
{ {
u = (h - k.K5) / (k.K6 - k.K5); u = (h - k5) / (k.K6 - k5);
return plateauLo + u * plateauSpan; // plateau return plateauLo + u * plateauSpan; // plateau
} }
float plateauTop = plateauLo + plateauSpan; float plateauTop = plateauLo + plateauSpan;
@ -140,33 +163,45 @@ public static class HeightCurve
/// <summary> /// <summary>
/// Per-generation numeric strict-monotonicity check of the EFFECTIVE curve for /// Per-generation numeric strict-monotonicity check of the EFFECTIVE curve for
/// the selected preset: all 8 modulation-extreme corners × per-seed spikeMax. /// the selected preset: all 8 modulation-extreme corners × the shelf-edge warp
/// The corner fixes lower the slope floors (risers 0.1, spike base 0.05) — the /// extremes (±maxEdgeShift and 0) × per-seed spikeMax — 24 corners. The corner
/// sweep proves they stay strictly positive everywhere. Loud throw on failure. /// fixes lower the slope floors (risers 0.1, spike base 0.05) and the warp
/// squeezes the foothill riser and the plateau; the sweep proves every slope
/// stays strictly positive at the extremes of both. Also checks the knot set
/// itself stays strictly ordered under the warp. Loud throw on failure.
/// </summary> /// </summary>
public static void AssertMonotonic(float hMaxSeed, CurveKnots k) public static void AssertMonotonic(float hMaxSeed, CurveKnots k, float maxEdgeShift)
{ {
if (maxEdgeShift < 0f || k.K2 + maxEdgeShift >= k.K3 || k.K5 + maxEdgeShift >= k.K6)
throw new System.InvalidOperationException(
$"[HeightCurve] EDGE-SHIFT BOUND VIOLATION: maxEdgeShift={maxEdgeShift} does not keep K2<K3±d and K5±d<K6 (preset {k.Name}). Refusing to generate.");
float[] benchLos = { BENCH_BASE - BENCH_AMP, BENCH_BASE + BENCH_AMP }; float[] benchLos = { BENCH_BASE - BENCH_AMP, BENCH_BASE + BENCH_AMP };
float[] plateauLos = { PLATEAU_BASE - PLATEAU_AMP, PLATEAU_BASE + PLATEAU_AMP }; float[] plateauLos = { PLATEAU_BASE - PLATEAU_AMP, PLATEAU_BASE + PLATEAU_AMP };
float[] spans = { SHELF_SPAN_MIN, SHELF_SPAN_MAX }; float[] spans = { SHELF_SPAN_MIN, SHELF_SPAN_MAX };
float[] edgeShifts = maxEdgeShift > 0f
? new float[] { -maxEdgeShift, 0f, maxEdgeShift }
: new float[] { 0f };
foreach (float bl in benchLos) foreach (float bl in benchLos)
{ {
foreach (float pl in plateauLos) foreach (float pl in plateauLos)
{ {
foreach (float sp in spans) foreach (float sp in spans)
{
foreach (float es in edgeShifts)
{ {
float prevH = -7f; float prevH = -7f;
float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k); float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k, es);
void Check(double hd) void Check(double hd)
{ {
float h = (float)hd; float h = (float)hd;
if (h <= prevH) return; // dedupe float32 samples (task-05 fix) if (h <= prevH) return; // dedupe float32 samples (task-05 fix)
float v = Apply(h, hMaxSeed, bl, sp, pl, sp, k); float v = Apply(h, hMaxSeed, bl, sp, pl, sp, k, es);
if (v <= prev) if (v <= prev)
throw new System.InvalidOperationException( throw new System.InvalidOperationException(
$"[HeightCurve] MONOTONICITY VIOLATION at h={h} (preset {k.Name}, hMaxSeed={hMaxSeed}, benchLo={bl}, plateauLo={pl}, span={sp}): {v} <= {prev}. Refusing to generate."); $"[HeightCurve] MONOTONICITY VIOLATION at h={h} (preset {k.Name}, hMaxSeed={hMaxSeed}, benchLo={bl}, plateauLo={pl}, span={sp}, edgeShift={es}): {v} <= {prev}. Refusing to generate.");
prev = v; prev = v;
prevH = h; prevH = h;
} }
@ -178,6 +213,7 @@ public static class HeightCurve
} }
} }
} }
GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION} preset '{k.Name}', 8 modulation corners, effective spikeMax {EffectiveSpikeMax(hMaxSeed, k):F6})."); }
GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION} preset '{k.Name}', 8 modulation corners × edge shifts ±{maxEdgeShift:F6}, effective spikeMax {EffectiveSpikeMax(hMaxSeed, k):F6}).");
} }
} }

View file

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

View file

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

View file

@ -0,0 +1,143 @@
using Godot;
/// <summary>
/// The island-falloff shaping functions (terrain-water task 11) — pure numeric
/// functions of their inputs (D-035; a named future C++ candidate, kept standalone).
///
/// SMOOTH CREST — the mountain spine's ridge axis is the line x = centre, and
/// `1 - |x - cx|` peaks there with a slope discontinuity. Measured on the shipped
/// terrain, that crease is the single largest slope step anywhere on the map once
/// the by-design Trench walls are excluded (top 4 of 5999 columns). SmoothAbs
/// rounds the crest without moving its height.
///
/// COAST SHELF — the height curve is identity at and below sea level, so it never
/// touched the SUBMARINE slope. Measured: land rises from the shoreline at
/// 0.038 m/px while the seabed drops at 0.258 m/px — the shoreline is a shelf on
/// the land side and a ramp on the sea side. CoastShelf compresses shallow depth
/// so the shallows extend much further out, leaving deep water and the Trench
/// essentially untouched.
///
/// OFFSHORE BLOB — sparse discoverable islets, seeded from an ocean noise layer.
///
/// Every one of these is monotone in the sign of (sea - height): none of them can
/// turn water into land or land into water on its own. The coast shelf therefore
/// leaves the biome and water classification bit-identical, which is why it is
/// separable from elongation in the batch.
/// </summary>
public static class IslandFalloff
{
// ---- centre-line crest ------------------------------------------------
// Rounding radius in normalized spine-width units (1.0 = MapSize/2 * IslandAxisX
// ~ 4710 px at 8K with the default axis). 0.03 ~ 141 px: it cuts the crest's
// 1-px kink by 99.3% (-7.64e-04 -> -5.41e-06 raw) while filling at most 3.9 m,
// decaying under 0.5 m by ~1100 px from the axis.
public const float CREST_EPSILON = 0.03f;
/// <summary>
/// A C¹ stand-in for |d|: exactly 0 with zero slope at d = 0, and converging to
/// |d| within e²/(2|d|) away from it. Replaces the V-shaped crest of the spine
/// with a rounded one WITHOUT lowering it — SmoothAbs(0) is 0, so the peak keeps
/// its full height.
/// </summary>
public static float SmoothAbs(float d, float epsilon)
{
float a = Mathf.Abs(d);
return a * a / Mathf.Sqrt(a * a + epsilon * epsilon);
}
// ---- coast shelf ------------------------------------------------------
// depth' = depth * (1 - STRENGTH * exp(-depth / SCALE_M)).
// At the shoreline the seabed starts at (1 - STRENGTH) of its former gradient and
// recovers smoothly, so the shallows widen and the deep ocean keeps its shape.
// C^inf everywhere, and strictly positive for positive depth — it cannot move the
// waterline by even one pixel.
public const float SHELF_STRENGTH = 0.775f; // 0 = off, ->1 = a flat lagoon
public const float SHELF_SCALE_M = 100f; // metres of depth over which it relaxes
/// <summary>Remaps a positive depth in metres. Returns the new depth in metres.</summary>
public static float CoastShelf(float depthMetres)
{
if (depthMetres <= 0f) return depthMetres;
return depthMetres * (1f - SHELF_STRENGTH * Mathf.Exp(-depthMetres / SHELF_SCALE_M));
}
// ---- offshore islands -------------------------------------------------
// Islets are placed by lerping the seabed TOWARD a target height, not by adding to
// it, so they can surface at any ambient depth instead of only where the seafloor
// happens to be shallow.
public const float OFFSHORE_FREQ_ISLANDS = 14f; // ~585 px blobs at 8K — few and sizeable,
// not a scatter of 50 px debris
public const int OFFSHORE_SEED_OFFSET = 7607;
public const float OFFSHORE_ISLAND_H_M = 34f; // target crest, metres above sea (pre-curve)
public const float OFFSHORE_CORE = 0.45f; // fraction of a blob's excess that saturates
// The moat that keeps islets off the mainland. The raise is EXACTLY zero wherever
// the ambient water is shallower than this, so the ring of water between the
// mainland shore and any islet cannot be bridged: a continuous path from shore to
// islet must cross this depth contour, and every pixel on it is untouched water.
public const float OFFSHORE_MIN_DEPTH_M = 14f;
public const float OFFSHORE_DEPTH_FEATHER_M = 10f;
// ...the margin that keeps them out of the Trench ramp (which starts at 0.90)...
public const float OFFSHORE_TRENCH_INNER = 0.78f;
public const float OFFSHORE_TRENCH_OUTER = 0.86f;
// ...and the test for "actually offshore". Depth alone is not enough: a deep LAKE
// or the carved crater bay is also below sea level, and islets have no business in
// either. The pre-Trench falloff is the honest discriminator — the mainland coast
// sits near f = 0.66 (where f^2.5 ~ rawBase - sea), and inland water is far below
// that whatever the axis ratios are, because elongation moves WHERE a given f
// occurs, not the f at which land ends.
public const float OFFSHORE_MIN_FALLOFF = 0.72f;
public const float OFFSHORE_FALLOFF_FEATHER = 0.06f;
/// <summary>
/// Blob weight in [0,1] for one ocean column. <paramref name="threshold"/> comes
/// from CalibrateThreshold, NOT from the density directly — Simplex output is
/// concentrated well inside [-1,1] (in practice it rarely passes ±0.87), so
/// treating density as a fraction of the theoretical range produces a threshold
/// almost nothing clears. That bug shipped in the first task-11 build and raised
/// 171 pixels on the whole map, none of them above sea.
/// </summary>
public static float OffshoreBlob(float noise01, float threshold)
{
if (noise01 <= threshold) return 0f;
float core = Mathf.Max((1f - threshold) * OFFSHORE_CORE, 1e-4f);
float k = Mathf.Clamp((noise01 - threshold) / core, 0f, 1f);
return k * k * (3f - 2f * k);
}
/// <summary>
/// The noise value that <paramref name="density"/> of <paramref name="samples"/>
/// exceed. Sorts a copy, so the caller's array is left alone.
/// </summary>
public static float CalibrateThreshold(float[] samples, float density)
{
if (samples.Length == 0 || density <= 0f) return 1f;
float[] s = (float[])samples.Clone();
System.Array.Sort(s);
int idx = (int)((1f - Mathf.Clamp(density, 0f, 1f)) * (s.Length - 1));
return s[Mathf.Clamp(idx, 0, s.Length - 1)];
}
/// <summary>
/// How much of the blob is allowed here: zero in shallow water near the mainland
/// (the moat), zero anywhere that is not genuinely outside the island body, zero
/// in and near the Trench ramp, full in the open ocean between.
/// </summary>
public static float OffshoreZoneWeight(float ambientDepthMetres, float preTrenchFalloff,
float distX01, float distY01)
{
if (ambientDepthMetres < OFFSHORE_MIN_DEPTH_M) return 0f;
if (preTrenchFalloff < OFFSHORE_MIN_FALLOFF) return 0f;
float w = Mathf.Clamp((ambientDepthMetres - OFFSHORE_MIN_DEPTH_M) / OFFSHORE_DEPTH_FEATHER_M, 0f, 1f);
w *= Mathf.Clamp((preTrenchFalloff - OFFSHORE_MIN_FALLOFF) / OFFSHORE_FALLOFF_FEATHER, 0f, 1f);
float d = Mathf.Max(distX01, distY01);
if (d >= OFFSHORE_TRENCH_OUTER) return 0f;
if (d > OFFSHORE_TRENCH_INNER)
w *= 1f - (d - OFFSHORE_TRENCH_INNER) / (OFFSHORE_TRENCH_OUTER - OFFSHORE_TRENCH_INNER);
return w;
}
}

View file

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

View file

@ -27,13 +27,14 @@ public partial class MapGenerator : TextureRect
private float[,] _heightMap; private float[,] _heightMap;
// Classification heightmap (task 05): the UNCURVED heights (plus the crater // Classification heightmap (task 05): the UNCURVED, UNERODED heights (plus the
// carve), i.e. exactly what the curve-off pipeline produces. Biome rules, the // crater carve), i.e. exactly what the curve-off pipeline produces. Biome rules,
// two flood fills, and the shared water predicates read THIS map, so biome and // the two flood fills, and the shared water predicates read THIS map, so biome
// water output is identical with the curve on or off — the bit-identical-biomes // and water output is identical with the curve and erosion on or off — the
// oracle holds by construction. Towns, roads, diagnostics, and the exported // bit-identical-biomes oracle holds by construction. Towns, roads, diagnostics,
// heights use the curved _heightMap (they live in the 3D world). When the curve // and the exported heights use the curved (and, when on, eroded) _heightMap
// is off this is the SAME array as _heightMap (aliased, no copy). // (they live in the 3D world). When every render-only pass is off this is the
// SAME array as _heightMap (aliased, no copy).
private float[,] _heightMapClassify; private float[,] _heightMapClassify;
private bool _curveOn; private bool _curveOn;
@ -49,11 +50,35 @@ public partial class MapGenerator : TextureRect
// v5: the selected knot preset; null = curve off. // v5: the selected knot preset; null = curve off.
private CurveKnots _curveKnots; private CurveKnots _curveKnots;
// Task-10 detail passes (shelf micro-relief + drainage incision): gated by // Hydraulic erosion (task 17): output-only droplet pass on the RENDER map,
// TerrainDetail, active only with the curve on (the masks are curve-band // after detail, before the crater carve. The classify map never sees it.
// defined). The relief noise seeds from resolvedSeed + 7409. private bool _erosionOn;
private bool _riversOn;
private byte _craterErosionMode;
// The carve's radius as a fraction of CraterRadius — the ONLY place this number
// lives on the generator side. Erosion's protected core must be at least this
// wide or the two passes overlap; see the guard before the erosion call.
private const float CRATER_CARVE_FACTOR = 0.80f;
// Task-10 detail passes (shelf micro-relief + shelf-edge variation): gated by
// TerrainDetail, active only with the curve on (both are defined in terms of
// the curve's bands). The relief noise seeds from resolvedSeed + 7409, the
// edge-warp noise from resolvedSeed + 7507. _edgeAmpRaw is the config dial in
// raw height units, already clamped to _maxEdgeShiftRaw.
private bool _detailOn; private bool _detailOn;
private FastNoiseLite _reliefNoise; private FastNoiseLite _reliefNoise;
private FastNoiseLite _edgeNoise;
private float _edgeAmpRaw;
private float _maxEdgeShiftRaw;
// Task-11 island-falloff shaping: the submarine coast shelf and the offshore
// islet layer. Both act on BELOW-SEA height in pass 1; the axis ratios that
// reshape the island itself are read straight from config in GenerateTopography.
private bool _coastWide;
private bool _offshoreOn;
private FastNoiseLite _offshoreNoise;
private float _offshoreThreshold = 1f;
// The seed's raw pre-curve height maximum (post noise/falloff/Trench/spine, // The seed's raw pre-curve height maximum (post noise/falloff/Trench/spine,
// pre-carve) — the v2 curve's per-seed spike normalizer. Computed in // pre-carve) — the v2 curve's per-seed spike normalizer. Computed in
@ -95,9 +120,16 @@ public partial class MapGenerator : TextureRect
_heightMap = new float[MapSize, MapSize]; _heightMap = new float[MapSize, MapSize];
_curveKnots = ConfigManager.TerrainCurve == "v5" ? HeightCurve.V5 : null; _curveKnots = ConfigManager.TerrainCurve == "v5" ? HeightCurve.V5 : null;
_curveOn = _curveKnots != null; _curveOn = _curveKnots != null;
_erosionOn = ConfigManager.Erosion == "v1";
_riversOn = ConfigManager.Rivers == "v1";
_craterErosionMode = ConfigManager.CraterErosionMode == "feather"
? HydraulicErosion.CRATER_MODE_FEATHER : HydraulicErosion.CRATER_MODE_FULL;
// (The monotonicity assertion now runs inside GenerateTopography, against the // (The monotonicity assertion now runs inside GenerateTopography, against the
// effective per-seed curve, once hMaxSeed is known.) // effective per-seed curve, once hMaxSeed is known.)
_heightMapClassify = _curveOn ? new float[MapSize, MapSize] : _heightMap; // Classify gets its own array whenever ANY render-only pass diverges the two
// maps — the curve, or erosion (which must not leak into classify even with
// the curve off; aliased arrays would be exactly that leak).
_heightMapClassify = (_curveOn || _erosionOn) ? new float[MapSize, MapSize] : _heightMap;
_tempMap = new float[MapSize, MapSize]; _tempMap = new float[MapSize, MapSize];
_biomeMap = new Biome[MapSize, MapSize]; _biomeMap = new Biome[MapSize, MapSize];
_isTrueOcean = new bool[MapSize, MapSize]; _isTrueOcean = new bool[MapSize, MapSize];
@ -119,10 +151,52 @@ public partial class MapGenerator : TextureRect
} }
_detailOn = _curveOn && ConfigManager.TerrainDetail == "v1"; _detailOn = _curveOn && ConfigManager.TerrainDetail == "v1";
if (_detailOn) if (_detailOn)
{
_reliefNoise = MakeModulationNoise(TerrainDetailPass.RELIEF_SEED_OFFSET, TerrainDetailPass.RELIEF_FREQ_ISLANDS); _reliefNoise = MakeModulationNoise(TerrainDetailPass.RELIEF_SEED_OFFSET, TerrainDetailPass.RELIEF_FREQ_ISLANDS);
_edgeNoise = MakeModulationNoise(TerrainDetailPass.EDGE_SEED_OFFSET, TerrainDetailPass.EDGE_FREQ_ISLANDS);
// The edge warp slides K3/K4/K5; the dial is clamped to the largest shift
// that keeps the knot set strictly ordered, so monotonicity can never be
// a tuning question. Clamping is loud — a silently ignored dial is worse
// than a refused one.
_maxEdgeShiftRaw = TerrainDetailPass.MaxEdgeShift(_curveKnots);
_edgeAmpRaw = Mathf.Max(ConfigManager.ShelfEdgeVariation, 0f) / 251f;
if (_edgeAmpRaw > _maxEdgeShiftRaw)
{
GD.PrintErr($"[MapGenerator] ShelfEdgeVariation {ConfigManager.ShelfEdgeVariation:F2} m exceeds the preset's safe bound {_maxEdgeShiftRaw * 251f:F2} m — clamping.");
_edgeAmpRaw = _maxEdgeShiftRaw;
}
GD.Print($"[MapGenerator] TerrainDetail v1: relief ±{ConfigManager.ShelfReliefAmp:F1} m @ {TerrainDetailPass.RELIEF_FREQ_ISLANDS:F0}/island, edge warp ±{_edgeAmpRaw * 251f:F2} m of input height @ {TerrainDetailPass.EDGE_FREQ_ISLANDS:F0}/island (bound {_maxEdgeShiftRaw * 251f:F2} m).");
}
else if (ConfigManager.TerrainDetail == "v1" && !_curveOn) else if (ConfigManager.TerrainDetail == "v1" && !_curveOn)
GD.Print("[MapGenerator] TerrainDetail v1 requires the curve — no-op with TerrainCurve off."); GD.Print("[MapGenerator] TerrainDetail v1 requires the curve — no-op with TerrainCurve off.");
_coastWide = ConfigManager.CoastProfile == "wide";
_offshoreOn = ConfigManager.OffshoreIslandDensity > 0f;
if (_offshoreOn)
{
_offshoreNoise = MakeModulationNoise(IslandFalloff.OFFSHORE_SEED_OFFSET, IslandFalloff.OFFSHORE_FREQ_ISLANDS);
// Calibrate the islet threshold against the field's ACTUAL distribution
// rather than the theoretical [-1,1]: sample on a stride grid and take the
// quantile. Deterministic from the seed, and it makes the density dial mean
// what it says whatever FastNoiseLite's output range turns out to be.
const int stride = 8;
int side = MapSize / stride;
float[] samples = new float[side * side];
for (int i = 0; i < side; i++)
for (int j = 0; j < side; j++)
samples[i * side + j] = (_offshoreNoise.GetNoise2D(i * stride, j * stride) + 1f) * 0.5f;
_offshoreThreshold = IslandFalloff.CalibrateThreshold(samples, ConfigManager.OffshoreIslandDensity);
GD.Print($"[MapGenerator] Offshore islet threshold {_offshoreThreshold:F4} " +
$"(density {ConfigManager.OffshoreIslandDensity:F3} of {samples.Length} samples; field range {System.Linq.Enumerable.Min(samples):F3}..{System.Linq.Enumerable.Max(samples):F3}).");
}
GD.Print($"[MapGenerator] Island falloff: axis {ConfigManager.IslandAxisX:F2}x/{ConfigManager.IslandAxisY:F2}y, " +
$"coast '{ConfigManager.CoastProfile}'" +
(_coastWide ? $" (shelf strength {IslandFalloff.SHELF_STRENGTH:F3}, scale {IslandFalloff.SHELF_SCALE_M:F0} m)" : "") +
$", offshore islets density {ConfigManager.OffshoreIslandDensity:F3}" +
(_offshoreOn ? $" @ {IslandFalloff.OFFSHORE_FREQ_ISLANDS:F0}/island, crest {IslandFalloff.OFFSHORE_ISLAND_H_M:F0} m, moat {IslandFalloff.OFFSHORE_MIN_DEPTH_M:F0} m" : " (off)") + ".");
// THE CRATER FIX: Push it into the ocean (scales via percentage of MapSize!) // THE CRATER FIX: Push it into the ocean (scales via percentage of MapSize!)
// Supposedly! We will have to test this manually on other map sizes to confirm the crater is properly scaled and submerged on the north coast! // Supposedly! We will have to test this manually on other map sizes to confirm the crater is properly scaled and submerged on the north coast!
float randomX = (float)GD.RandRange(0.35f, 0.65f); float randomX = (float)GD.RandRange(0.35f, 0.65f);
@ -156,6 +230,15 @@ public partial class MapGenerator : TextureRect
GD.Print($"{T()} Towns placed: {_towns.Count}."); GD.Print($"{T()} Towns placed: {_towns.Count}.");
await CaptureStage("2_towns"); await CaptureStage("2_towns");
// --- RIVERS (task 22, C0b part 2a): carve the frozen plan's beds. ---
// AFTER towns (GenerateTowns reads the render map for land/slope/water
// checks, so carving first would move towns and destabilise every A/B) and
// BEFORE roads (a full run's A* should see the carved beds). RENDER map
// only: biomes and WBID are already computed from classify — the oracle is
// untouched by construction. NO WATER — part 2b.
if (_riversOn)
CarveRivers();
if (ConfigManager.SkipRoads) if (ConfigManager.SkipRoads)
{ {
// Iteration toggle (terrain-water task 03): the road pass is ~25 min of a // Iteration toggle (terrain-water task 03): the road pass is ~25 min of a
@ -309,17 +392,39 @@ public partial class MapGenerator : TextureRect
StrengthSeedOffset = HeightCurve.STRENGTH_SEED_OFFSET, StrengthSeedOffset = HeightCurve.STRENGTH_SEED_OFFSET,
PresetId = _curveKnots.PresetId, K5 = _curveKnots.K5, K6 = _curveKnots.K6 PresetId = _curveKnots.PresetId, K5 = _curveKnots.K5, K6 = _curveKnots.K6
} : null, } : null,
// EROS: the erosion params AS APPLIED (post config clamping) — a blueprint
// with eroded heights is self-describing without the config that made it.
Erosion = _erosionOn ? new ErosionInfo
{
Version = HydraulicErosion.VERSION,
DropletCount = ConfigManager.ErosionDropletCount,
Lifetime = ConfigManager.ErosionDropletLifetime,
BrushRadius = ConfigManager.ErosionBrushRadius,
SeedOffset = HydraulicErosion.SEED_OFFSET,
CarveCapM = ConfigManager.ErosionCarveCap,
DepositCapM = ConfigManager.ErosionDepositCap,
SeaMarginM = ConfigManager.ErosionSeaMargin,
Inertia = ConfigManager.ErosionInertia,
CapacityFactor = ConfigManager.ErosionCapacity,
MinSlopeM = ConfigManager.ErosionMinSlope,
ErodeRate = ConfigManager.ErosionErodeRate,
DepositRate = ConfigManager.ErosionDepositRate,
Evaporation = ConfigManager.ErosionEvaporation,
Gravity = ConfigManager.ErosionGravity,
CraterCoreFactor = ConfigManager.CraterErosionCore,
CraterFeatherFactor = ConfigManager.CraterErosionFeather,
CraterMode = _craterErosionMode
} : null,
TerrainDetail = _detailOn ? new TerrainDetailInfo TerrainDetail = _detailOn ? new TerrainDetailInfo
{ {
Version = TerrainDetailPass.VERSION, Version = TerrainDetailPass.VERSION,
ReliefAmpM = ConfigManager.ShelfReliefAmp, ReliefAmpM = ConfigManager.ShelfReliefAmp,
ReliefFreqIslands = TerrainDetailPass.RELIEF_FREQ_ISLANDS, ReliefFreqIslands = TerrainDetailPass.RELIEF_FREQ_ISLANDS,
IncK = TerrainDetailPass.INC_K, IncP = TerrainDetailPass.INC_P, ReliefSeedOffset = TerrainDetailPass.RELIEF_SEED_OFFSET,
IncCapM = TerrainDetailPass.INC_CAP_M, EdgeAmpM = _edgeAmpRaw * 251f, // as APPLIED (post-clamp), not as configured
SeaClampRaw = TerrainDetailPass.SEA_CLAMP, EdgeFreqIslands = TerrainDetailPass.EDGE_FREQ_ISLANDS,
CraterExclFactor = TerrainDetailPass.CRATER_EXCL_FACTOR, EdgeSeedOffset = TerrainDetailPass.EDGE_SEED_OFFSET,
ShelfIncWeight = TerrainDetailPass.SHELF_INC_WEIGHT, EdgeMaxShiftM = _maxEdgeShiftRaw * 251f
ReliefSeedOffset = TerrainDetailPass.RELIEF_SEED_OFFSET
} : null, } : null,
FormatVersion = 2, FormatVersion = 2,
Params = new BlueprintParams Params = new BlueprintParams
@ -412,6 +517,9 @@ public partial class MapGenerator : TextureRect
private void GenerateTopography() private void GenerateTopography()
{ {
Vector2 center = new Vector2(MapSize / 2.0f, MapSize / 2.0f); Vector2 center = new Vector2(MapSize / 2.0f, MapSize / 2.0f);
float axisX = ConfigManager.IslandAxisX;
float axisY = ConfigManager.IslandAxisY;
long offshoreLandPx = 0; // pixels the islet layer lifted from water to land
for (int x = 0; x < MapSize; x++) for (int x = 0; x < MapSize; x++)
{ {
for (int y = 0; y < MapSize; y++) for (int y = 0; y < MapSize; y++)
@ -423,11 +531,13 @@ public partial class MapGenerator : TextureRect
_tempMap[x, y] = temperature; _tempMap[x, y] = temperature;
// --- 2. THE ORIGINAL ISLAND FALLOFF --- // --- 2. THE ORIGINAL ISLAND FALLOFF ---
float nx = Mathf.Abs(x - center.X) / (MapSize / 2.0f * 1.15f); // Axis ratios are config dials since task 11 (were the literals
float ny = Mathf.Abs(y - center.Y) / (MapSize / 2.0f * 0.90f); // 1.15 / 0.90). They move the coastline, so they move biomes.
float nx = Mathf.Abs(x - center.X) / (MapSize / 2.0f * axisX);
float ny = Mathf.Abs(y - center.Y) / (MapSize / 2.0f * axisY);
float squircleFalloff = Mathf.Max(nx, ny); float squircleFalloff = Mathf.Max(nx, ny);
Vector2 ellipticalPos = new Vector2((x - center.X) / 1.15f, (y - center.Y) / 0.90f); Vector2 ellipticalPos = new Vector2((x - center.X) / axisX, (y - center.Y) / axisY);
float ellipticalFalloff = ellipticalPos.Length() / (MapSize / 1.3f); float ellipticalFalloff = ellipticalPos.Length() / (MapSize / 1.3f);
float finalFalloff = Mathf.Lerp(ellipticalFalloff, squircleFalloff, 0.5f); float finalFalloff = Mathf.Lerp(ellipticalFalloff, squircleFalloff, 0.5f);
@ -445,6 +555,10 @@ public partial class MapGenerator : TextureRect
finalFalloff += southDepth * 0.6f; // Sink the stretched land bridges! finalFalloff += southDepth * 0.6f; // Sink the stretched land bridges!
} }
// Captured before the power and before the Trench wall: this is the
// "how far past the island body are we" number the offshore layer reads.
float preTrenchFalloff = finalFalloff;
finalFalloff = Mathf.Pow(finalFalloff, 2.5f); finalFalloff = Mathf.Pow(finalFalloff, 2.5f);
float distX = Mathf.Abs(x - center.X) / (MapSize / 2.0f); float distX = Mathf.Abs(x - center.X) / (MapSize / 2.0f);
@ -453,11 +567,15 @@ public partial class MapGenerator : TextureRect
if (distY > 0.90f) finalFalloff += (distY - 0.90f) * 15.0f; if (distY > 0.90f) finalFalloff += (distY - 0.90f) * 15.0f;
// --- 3. THE MOUNTAIN SPINE --- // --- 3. THE MOUNTAIN SPINE ---
// The ridge axis is the line x = centre. `1 - |x - cx|` peaks there with
// a slope discontinuity, which put the single largest slope step on the
// whole map at exactly that column (task-11 diagnostic). SmoothAbs rounds
// the crest — same peak height, continuous slope through it.
float mountainSpine = 0f; float mountainSpine = 0f;
if (temperature < 0.65f) if (temperature < 0.65f)
{ {
float distanceToCenterX = Mathf.Abs(x - center.X) / (MapSize / 2.0f * 1.15f); float distanceToCenterX = Mathf.Abs(x - center.X) / (MapSize / 2.0f * axisX);
mountainSpine = 1.0f - distanceToCenterX; mountainSpine = 1.0f - IslandFalloff.SmoothAbs(distanceToCenterX, IslandFalloff.CREST_EPSILON);
float southernFade = Mathf.Clamp((0.65f - temperature) * 4.0f, 0.0f, 1.0f); float southernFade = Mathf.Clamp((0.65f - temperature) * 4.0f, 0.0f, 1.0f);
mountainSpine = Mathf.Pow(mountainSpine, 3.0f) * southernFade * 0.6f; mountainSpine = Mathf.Pow(mountainSpine, 3.0f) * southernFade * 0.6f;
} }
@ -468,46 +586,111 @@ public partial class MapGenerator : TextureRect
// hMaxSeed) and the crater carve are applied in PASS 2 below. // hMaxSeed) and the crater carve are applied in PASS 2 below.
float rawBase = (_noise.GetNoise2D(x, y) + 1.0f) / 2.0f; float rawBase = (_noise.GetNoise2D(x, y) + 1.0f) / 2.0f;
float finalH = rawBase + mountainSpine - (finalFalloff * FalloffStrength); float finalH = rawBase + mountainSpine - (finalFalloff * FalloffStrength);
// --- 5. THE COAST SHELF (task 11) ---
// The height curve is identity at and below sea, so it never reached the
// seabed. Compress shallow depth so the shallows reach much further out.
// Strictly positive depth stays strictly positive, so the waterline — and
// with it every biome and water body — cannot move by a single pixel.
float seaHere = GetSeaLevel(temperature);
if (_coastWide && finalH < seaHere)
{
float depthM = (seaHere - finalH) * 251f;
// The remap is strictly positive on positive depth, so in exact
// arithmetic the waterline cannot move. In float32 it can: for a
// pixel a few microns under water, (sea - shallowerDepth) rounds
// back up to exactly sea, and `H < sea` then calls it land. That
// cost 5 px of 67 M on the first batch — immaterial in itself, but
// it falsifies the invariant the whole separability argument rests
// on. Hold the result strictly below sea and the invariant is exact.
finalH = Mathf.Min(seaHere - IslandFalloff.CoastShelf(depthM) / 251f,
System.MathF.BitDecrement(seaHere));
}
// --- 6. OFFSHORE ISLANDS (task 11) ---
// Sparse islets lerped TOWARD a target crest rather than added to the
// seabed, so they surface at any ambient depth. Held off the mainland by
// a depth moat and out of the Trench ramp by a distance mask.
if (_offshoreOn && finalH < seaHere)
{
float ambientDepthM = (seaHere - finalH) * 251f;
float zone = IslandFalloff.OffshoreZoneWeight(
ambientDepthM, preTrenchFalloff,
Mathf.Abs(x - center.X) / (MapSize / 2.0f),
Mathf.Abs(y - center.Y) / (MapSize / 2.0f));
if (zone > 0f)
{
float v = (_offshoreNoise.GetNoise2D(x, y) + 1.0f) * 0.5f;
float blob = IslandFalloff.OffshoreBlob(v, _offshoreThreshold) * zone;
if (blob > 0f)
{
float before = finalH;
finalH = Mathf.Lerp(finalH, seaHere + IslandFalloff.OFFSHORE_ISLAND_H_M / 251f, blob);
if (before < seaHere && finalH >= seaHere) offshoreLandPx++;
}
}
}
if (finalH > _hMaxSeed) _hMaxSeed = finalH; if (finalH > _hMaxSeed) _hMaxSeed = finalH;
_heightMap[x, y] = finalH; _heightMap[x, y] = finalH;
} }
} }
if (_offshoreOn)
GD.Print($"{T()} [Offshore] islet layer lifted {offshoreLandPx} px above sea " +
$"({offshoreLandPx / (float)(MapSize * MapSize) * 100f:F3}% of the map).");
// The v2 curve is SEED-DEPENDENT: its spike maps [t4, hMaxSeed] onto the peak // The v2 curve is SEED-DEPENDENT: its spike maps [t4, hMaxSeed] onto the peak
// band, so the monotonicity assertion must run against the EFFECTIVE per-seed // band, so the monotonicity assertion must run against the EFFECTIVE per-seed
// curve — after hMaxSeed is known, before any pixel is curved. // curve — after hMaxSeed is known, before any pixel is curved.
if (_curveOn) HeightCurve.AssertMonotonic(_hMaxSeed, _curveKnots); if (_curveOn) HeightCurve.AssertMonotonic(_hMaxSeed, _curveKnots, _detailOn ? _edgeAmpRaw : 0f);
// --- PASS 2: curve (task 05/06) + crater carve --- // --- PASS 2: curve (task 05/06) + detail (task 10), then erosion (task 17),
// then the crater carve — three sub-passes (2a/2b/2c) in that order. ---
// Curve applied AFTER noise + falloff + Trench, BEFORE the crater carve, so // Curve applied AFTER noise + falloff + Trench, BEFORE the crater carve, so
// the carve cuts into curved terrain and the rim/bowl shape is untouched by // the carve cuts into curved terrain and the rim/bowl shape is untouched by
// the curve. Identity at and below sea + this ordering preserve the // the curve. Identity at and below sea + this ordering preserve the
// Trench/ocean-border guarantee and the crater by construction. classifyH // Trench/ocean-border guarantee and the crater by construction. classifyH
// stays uncurved — see _heightMapClassify; hMaxSeed never touches it. // stays uncurved — see _heightMapClassify; hMaxSeed never touches it.
// --- PASS 2a: curve + shelf micro-relief (task 10 pass A) ---
// classify stays RAW; curved gets the v5 curve plus, when TerrainDetail is on, // classify stays RAW; curved gets the v5 curve plus, when TerrainDetail is on,
// the shelf-ness-weighted noise skin (risers and peaks untouched). // the shelf-edge warp (pass B — the knot block slides per column, so the
// shelf/riser boundary contours scallop) and the shelf-ness-weighted noise
// skin (pass A — risers and peaks untouched). Both are read-only consumers of
// `raw`; neither can move a column below the red ceiling or above the cap,
// because K2 and K6 never move and the curve is monotonic between them.
float reliefAmpRaw = ConfigManager.ShelfReliefAmp / 251f; float reliefAmpRaw = ConfigManager.ShelfReliefAmp / 251f;
float physicalCraterRadius = _impactRadius * CRATER_CARVE_FACTOR;
for (int x = 0; x < MapSize; x++) for (int x = 0; x < MapSize; x++)
{ {
for (int y = 0; y < MapSize; y++) for (int y = 0; y < MapSize; y++)
{ {
float raw = _heightMap[x, y]; float raw = _heightMap[x, y];
float classifyH = raw;
float curvedH; float curvedH;
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
if (_curveOn) if (_curveOn)
{ {
// per-column shelf modulation (v4) — anchors and strength from the // v4: per-column shelf modulation — anchors and strength from the
// low-frequency fields; ordering safety by construction. // low-frequency fields; ordering safety by construction (amplitudes
// bounded; asserted at all 8 field-extreme corners per generation).
float benchLo = HeightCurve.BENCH_BASE + _benchNoise.GetNoise2D(x, y) * HeightCurve.BENCH_AMP; float benchLo = HeightCurve.BENCH_BASE + _benchNoise.GetNoise2D(x, y) * HeightCurve.BENCH_AMP;
float plateauLo = HeightCurve.PLATEAU_BASE + _plateauNoise.GetNoise2D(x, y) * HeightCurve.PLATEAU_AMP; float plateauLo = HeightCurve.PLATEAU_BASE + _plateauNoise.GetNoise2D(x, y) * HeightCurve.PLATEAU_AMP;
float shelfSpan = HeightCurve.ShelfSpan((_strengthNoise.GetNoise2D(x, y) + 1f) * 0.5f); float shelfSpan = HeightCurve.ShelfSpan((_strengthNoise.GetNoise2D(x, y) + 1f) * 0.5f);
curvedH = HeightCurve.Apply(raw, _hMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan, _curveKnots);
if (_detailOn) // Detail yields to the crater: zero inside the carve, feathered just
// outside it, so the carve stays the final authority on its terrain.
float wCrater = _detailOn
? TerrainDetailPass.CraterDetailWeight(distToCrater, _impactRadius)
: 0f;
float edgeShift = _detailOn ? _edgeNoise.GetNoise2D(x, y) * _edgeAmpRaw * wCrater : 0f;
curvedH = HeightCurve.Apply(raw, _hMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan, _curveKnots, edgeShift);
if (_detailOn && wCrater > 0f)
{ {
float wShelf = TerrainDetailPass.ShelfWeight(raw, _curveKnots); float wShelf = TerrainDetailPass.ShelfWeight(raw, _curveKnots, edgeShift);
if (wShelf > 0f) if (wShelf > 0f)
curvedH += _reliefNoise.GetNoise2D(x, y) * reliefAmpRaw * wShelf; curvedH += _reliefNoise.GetNoise2D(x, y) * reliefAmpRaw * wShelf * wCrater;
} }
} }
else else
@ -515,105 +698,193 @@ public partial class MapGenerator : TextureRect
curvedH = raw; curvedH = raw;
} }
_heightMapClassify[x, y] = raw; // uncurved; carve joins in pass 2c _heightMapClassify[x, y] = classifyH;
_heightMap[x, y] = curvedH; _heightMap[x, y] = curvedH;
} }
} }
// --- PASS 2b: drainage incision (task 10 pass B) --- // --- EROSION (task 17): render map ONLY — after detail, BEFORE the crater
if (_curveOn && _detailOn) // carve. Output-only by construction: _heightMapClassify was finalized above
RunIncisionPass(); // (bar the carve) and the pass never sees it, so biomes/water classify
// pre-erosion — the oracle. The pass's sea clamp plus its below-sea
// --- PASS 2c: THE CRATER CARVE (The Flooded Bay & Landbridge Fix!) --- // read-only rule mean the RENDERED coastline cannot move either; that is
// The carve remains the FINAL authority on its own terrain: applied after // verified here, not assumed, by counting render-map water pixels A/B.
// curve/relief/incision, to both maps, with the original expression. if (_erosionOn)
float physicalCraterRadius = _impactRadius * 0.80f;
for (int x = 0; x < MapSize; x++)
{ {
ulong tEro0 = Time.GetTicksMsec();
float[,] seaMap = null;
float seaFlat = ConfigManager.SeaLevelValue;
if (ConfigManager.SeaLevelModel != "flat")
{
seaMap = new float[MapSize, MapSize];
for (int x = 0; x < MapSize; x++)
for (int y = 0; y < MapSize; y++) for (int y = 0; y < MapSize; y++)
seaMap[x, y] = GetSeaLevel(_tempMap[x, y]);
}
// Erosion's protected core must cover the carve, or the two passes touch the
// same cells and the carve — which runs AFTER erosion and scales height
// toward the sea target — amplifies erosion's delta across the waterline.
// Refusing to fail silently: the in-pass flood guard below cannot see this,
// because it measures before the carve.
if (ConfigManager.CraterErosionCore < CRATER_CARVE_FACTOR)
GD.PrintErr($"[MapGenerator] ⚠ CraterErosionCore {ConfigManager.CraterErosionCore:F2} is " +
$"inside the carve radius ({CRATER_CARVE_FACTOR:F2} × CraterRadius). Erosion will modify " +
"carve-authored terrain, and the crater carve will then amplify those deltas — expect a " +
"handful of rendered-waterline crossings inside the bay that the erosion flood guard cannot see.");
long wetBefore = CountRenderWaterPixels(seaMap, seaFlat);
var p = new HydraulicErosion.Params
{
DropletCount = ConfigManager.ErosionDropletCount,
Lifetime = ConfigManager.ErosionDropletLifetime,
CarveCapM = ConfigManager.ErosionCarveCap,
DepositCapM = ConfigManager.ErosionDepositCap,
SeaMarginM = ConfigManager.ErosionSeaMargin,
BrushRadius = ConfigManager.ErosionBrushRadius,
Inertia = ConfigManager.ErosionInertia,
CapacityFactor = ConfigManager.ErosionCapacity,
MinSlopeM = ConfigManager.ErosionMinSlope,
ErodeRate = ConfigManager.ErosionErodeRate,
DepositRate = ConfigManager.ErosionDepositRate,
Evaporation = ConfigManager.ErosionEvaporation,
Gravity = ConfigManager.ErosionGravity,
CraterMode = _craterErosionMode,
Seed = _noise.Seed + HydraulicErosion.SEED_OFFSET
};
var st = HydraulicErosion.Apply(_heightMap, MapSize, seaMap, seaFlat,
_impactCenter.X, _impactCenter.Y,
_impactRadius * ConfigManager.CraterErosionCore,
_impactRadius * ConfigManager.CraterErosionFeather, p);
long wetAfter = CountRenderWaterPixels(seaMap, seaFlat);
if (wetAfter != wetBefore)
throw new System.InvalidOperationException(
$"[MapGenerator] EROSION FLOOD-GUARD VIOLATION: render-map water pixels {wetBefore} -> {wetAfter}. Refusing to generate.");
GD.Print($"{T()} [Erosion] crater '{ConfigManager.CraterErosionMode}': core {_impactRadius * ConfigManager.CraterErosionCore:F0} px" +
(ConfigManager.CraterErosionMode == "feather"
? $" -> feather to {_impactRadius * ConfigManager.CraterErosionFeather:F0} px" : " (hard edge, full strength beyond)") + ".");
GD.Print($"{T()} [Erosion] v1: {st.Spawned} droplets ({st.SkippedNoLand} skipped), {st.Steps} steps, " +
$"{(Time.GetTicksMsec() - tEro0) / 1000.0:F1}s wall. Eroded {st.ErodedVolumeM3:F0} m³ over {st.ModifiedCells} touched cells " +
$"(max cell carve {st.MaxCellErosionM:F2} m vs cap {p.CarveCapM:F2} m), deposited {st.DepositedVolumeM3:F0} m³ " +
$"(max cell deposit {st.MaxCellDepositM:F2} m vs cap {p.DepositCapM:F2} m). " +
$"Deaths: {st.DiedSea} sea / {st.DiedEdge} edge / {st.DiedDry} dry / {st.DiedLifetime} lifetime. " +
$"Water pixels {wetBefore} -> {wetAfter} (flood guard holds).");
}
// --- PASS 2c: CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) ---
// The carve stays LAST — the final authority on its own terrain (erosion is
// also excluded within 1.2× CraterRadius, entirely clear of this 0.8×
// physical carve). Both heights are read into locals BEFORE either write, so
// the all-passes-off aliasing (classify and height are the same array)
// cannot double-carve. We only carve the physical hole at 80% of the radius
// to guarantee a landbridge!
int cx0 = Mathf.Max(0, (int)(_impactCenter.X - physicalCraterRadius) - 1);
int cx1 = Mathf.Min(MapSize - 1, (int)(_impactCenter.X + physicalCraterRadius) + 1);
int cy0 = Mathf.Max(0, (int)(_impactCenter.Y - physicalCraterRadius) - 1);
int cy1 = Mathf.Min(MapSize - 1, (int)(_impactCenter.Y + physicalCraterRadius) + 1);
for (int x = cx0; x <= cx1; x++)
{
for (int y = cy0; y <= cy1; y++)
{ {
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter); float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
if (distToCrater >= physicalCraterRadius) continue;
// We only carve the physical hole at 80% of the radius to guarantee a landbridge!
if (distToCrater < physicalCraterRadius)
{
float craterDepth = 1.0f - (distToCrater / physicalCraterRadius); float craterDepth = 1.0f - (distToCrater / physicalCraterRadius);
// Dialed back to -0.15f as per your excellent instinct! // Dialed back to -0.15f as per your excellent instinct!
float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f; float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f;
// Read BOTH before writing EITHER: with the curve off the two maps float classifyH = _heightMapClassify[x, y];
// alias the same array, and a sequential read-modify-write carved float curvedH = _heightMap[x, y];
// the crater twice (caught by the task-10 continuity oracle). _heightMapClassify[x, y] = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f);
float preClassify = _heightMapClassify[x, y]; _heightMap[x, y] = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f);
float preCurved = _heightMap[x, y];
_heightMapClassify[x, y] = Mathf.Lerp(preClassify, carveTarget, craterDepth * 0.9f);
_heightMap[x, y] = Mathf.Lerp(preCurved, carveTarget, craterDepth * 0.9f);
}
} }
} }
} }
/// <summary> /// <summary>
/// Task-10 pass B: D8 flow accumulation over the curved+relieved land, then /// The river-carving pass (task 22): recomputes the frozen task-21b plan on the
/// depth = K · accum^p · slope, masked to the risers (shelves feathered to 30 %, /// eroded surface, routes the routed giants' lowland reaches (SHORT/LOWGROUND),
/// toe and peaks zero, crater excluded), capped, and clamped to sea + 1 m. /// and carves every promoted bed. Render map only; flood-guarded exactly like
/// Prints its own MEASURED depth distribution — the tuning/report source. /// erosion (water-pixel count A/B around the pass, throw on any change).
/// </summary> /// </summary>
private void RunIncisionPass() private void CarveRivers()
{ {
ulong t0 = Time.GetTicksMsec(); ulong tRiv0 = Time.GetTicksMsec();
int n = MapSize; float[,] seaMap = null;
int total = n * n; float seaFlat = ConfigManager.SeaLevelValue;
if (ConfigManager.SeaLevelModel != "flat")
float[] flat = new float[total];
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
flat[x * n + y] = _heightMap[x, y];
int[] accum = TerrainDetailPass.FlowAccumulation(flat, n, out float[] drop);
double accumSeconds = (Time.GetTicksMsec() - t0) / 1000.0;
float capRaw = TerrainDetailPass.INC_CAP_M / 251f;
float exclR = _impactRadius * TerrainDetailPass.CRATER_EXCL_FACTOR;
float featherR = _impactRadius * TerrainDetailPass.CRATER_FEATHER_FACTOR;
long incised = 0, clampHits = 0;
var depthsM = new System.Collections.Generic.List<float>(1 << 20);
for (int x = 0; x < n; x++)
{ {
for (int y = 0; y < n; y++) seaMap = new float[MapSize, MapSize];
{ for (int x = 0; x < MapSize; x++)
float raw = _heightMapClassify[x, y]; for (int y = 0; y < MapSize; y++)
float w = TerrainDetailPass.IncisionWeight(raw, _curveKnots); seaMap[x, y] = GetSeaLevel(_tempMap[x, y]);
if (w <= 0f) continue;
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
if (distToCrater < exclR) continue;
if (distToCrater < featherR)
w *= (distToCrater - exclR) / (featherR - exclR);
int i = x * n + y;
float depth = TerrainDetailPass.INC_K
* Mathf.Pow(accum[i], TerrainDetailPass.INC_P) * drop[i];
depth = Mathf.Min(depth, capRaw) * w;
if (depth <= 0f) continue;
float nh = _heightMap[x, y] - depth;
if (nh < TerrainDetailPass.SEA_CLAMP)
{
nh = TerrainDetailPass.SEA_CLAMP;
clampHits++;
}
float realized = _heightMap[x, y] - nh;
if (realized * 251f >= 0.5f) { incised++; depthsM.Add(realized * 251f); }
_heightMap[x, y] = nh;
} }
long wetBefore = CountRenderWaterPixels(seaMap, seaFlat);
float topBefore = 0f;
for (int x = 0; x < MapSize; x++)
for (int y = 0; y < MapSize; y++)
if (_heightMap[x, y] > topBefore) topBefore = _heightMap[x, y];
// Masks from the SHARED water predicates — identical by construction to the
// WBID-derived masks the task-21b tool used.
bool[] isOcean = new bool[MapSize * MapSize];
bool[] isClassifyWater = new bool[MapSize * MapSize];
for (int x = 0; x < MapSize; x++)
for (int y = 0; y < MapSize; y++)
{
isOcean[x * MapSize + y] = IsOceanPixel(x, y);
isClassifyWater[x * MapSize + y] = IsWaterPixel(x, y);
} }
depthsM.Sort(); float southX = -1f, southY = -1f;
float P(double q) => depthsM.Count == 0 ? 0 : depthsM[Mathf.Clamp((int)(q * depthsM.Count), 0, depthsM.Count - 1)]; foreach (var t in _towns)
double totalSeconds = (Time.GetTicksMsec() - t0) / 1000.0; if (t.Position.Y > southY) { southX = t.Position.X; southY = t.Position.Y; }
GD.Print($"{T()} [Incision] accumulation {accumSeconds:F1}s, total {totalSeconds:F1}s.");
GD.Print($"{T()} [Incision] incised cells (≥0.5 m): {incised}; depth m: p50 {P(0.5):F1}, p90 {P(0.9):F1}, p99 {P(0.99):F1}, max {(depthsM.Count > 0 ? depthsM[depthsM.Count - 1] : 0):F1}; sea-clamp hits {clampHits}."); var p = new RiverCarvePass.Params
{
RoutingStyle = ConfigManager.RiverRoutingStyle == "short"
? RiverCarvePass.STYLE_SHORT : RiverCarvePass.STYLE_LOWGROUND,
WidthScale = ConfigManager.RiverWidthScale,
DepthScale = ConfigManager.RiverDepthScale,
SeaMarginM = ConfigManager.RiverSeaMargin
};
var st = RiverCarvePass.Apply(_heightMap, MapSize, isOcean, isClassifyWater,
southX, southY, seaMap, seaFlat,
_impactCenter.X, _impactCenter.Y,
_impactRadius * ConfigManager.CraterErosionCore,
() => (Time.GetTicksMsec()) / 1000.0, p);
long wetAfter = CountRenderWaterPixels(seaMap, seaFlat);
if (wetAfter != wetBefore)
throw new System.InvalidOperationException(
$"[MapGenerator] RIVER FLOOD-GUARD VIOLATION: render-map water pixels {wetBefore} -> {wetAfter}. Refusing to generate.");
float topAfter = 0f;
for (int x = 0; x < MapSize; x++)
for (int y = 0; y < MapSize; y++)
if (_heightMap[x, y] > topAfter) topAfter = _heightMap[x, y];
GD.Print($"{T()} [Rivers] v1 '{ConfigManager.RiverRoutingStyle}': plan {st.AnalysisSeconds:F1}s, " +
$"routing {st.RoutingSeconds:F1}s, carve {st.CarveSeconds:F1}s " +
$"({(Time.GetTicksMsec() - tRiv0) / 1000.0:F1}s total). " +
$"{st.CarvedCells} cell-writes, {st.CarvedVolumeM3:F0} m³, max cut {st.MaxCutM:F1} m. " +
$"Water pixels {wetBefore} -> {wetAfter} (flood guard holds); island top {topBefore * 251f:F2} -> {topAfter * 251f:F2} m.");
foreach (var r in st.Rivers)
GD.Print($"{T()} [Rivers] {r.Name} [{r.Kind}{(r.SouthernCandidate ? " SOUTHERN" : "")}]: " +
$"drainage {r.DrainagePx}, course {r.CourseLenPx} px" +
(r.RouteLenPx > 0 ? $", lowland route {r.RouteLenPx} px (straight {r.RouteStraightPx:F0}, wander {r.WanderRatio:F2})" : "") +
$", {(r.ReachedOcean ? "reaches ocean/terminal" : "*** ROUTE INCOMPLETE ***")}, " +
$"max cut {r.MaxCutM:F1} m, {r.VolumeM3:F0} m³.");
}
// Render-map water pixel count — the erosion flood-guard's external check.
private long CountRenderWaterPixels(float[,] seaMap, float seaFlat)
{
long wet = 0;
for (int x = 0; x < MapSize; x++)
for (int y = 0; y < MapSize; y++)
if (_heightMap[x, y] < (seaMap != null ? seaMap[x, y] : seaFlat))
wet++;
return wet;
} }
private void CalculateTrueOcean() private void CalculateTrueOcean()

View file

@ -11,6 +11,21 @@ Generates the entire 2D blueprint. Roughly in order:
2. **Topography** — FastNoiseLite base height plus a mountain spine, minus a squircle distance 2. **Topography** — FastNoiseLite base height plus a mountain spine, minus a squircle distance
falloff, giving a guaranteed island. Noise frequency is divided by `scaleFactor` falloff, giving a guaranteed island. Noise frequency is divided by `scaleFactor`
(`MapSize / 1024f`) so terrain features stay the same real-world size at any map profile. (`MapSize / 1024f`) so terrain features stay the same real-world size at any map profile.
The island's proportions are the `IslandAxisX`/`IslandAxisY` dials (`1.30`/`0.78`; the
pre-task-11 shape was `1.15`/`0.90`). **`IslandAxisX` is a weak lever** — the island is
already Trench-clamped at ~90 % of the map width, so aspect responds almost entirely to
`IslandAxisY`, which trades against land area. These move the coastline, so they move biomes.
The mountain spine shares `IslandAxisX` for its width, and its crest is rounded
(`IslandFalloff.SmoothAbs`) — `1 - |x - centre|` used to peak with a slope discontinuity that
was, measured, the largest slope step on the map outside the Trench walls. **The spine's AXIS
is still a straight line down the map centre; that is known, deferred, and its own task.**
With `CoastProfile: "wide"` (the default) the *seabed* leaving the shoreline is shelved
(`IslandFalloff.CoastShelf`): the height curve is identity at and below sea level, so it never
reached the water, and the shoreline used to shelve gently on land then drop 6.7× steeper the
moment it went under. The shelf cannot move the waterline — it is strictly positive for
positive depth — so biomes and water are bit-identical with it on or off. `OffshoreIslandDensity`
(`0.02`, `0` disables) seeds sparse discoverable islets in the open ocean; they are held off the
mainland by a depth moat and out of the Trench by a distance mask, both by construction.
When `TerrainCurve: "v5"` (the default — the task-09 gate's BALANCED winner), the calibrated When `TerrainCurve: "v5"` (the default — the task-09 gate's BALANCED winner), the calibrated
height-redistribution curve height-redistribution curve
(`HeightCurve.cs` — the terraced ascent with spatially modulated shelves: flat farmable (`HeightCurve.cs` — the terraced ascent with spatially modulated shelves: flat farmable
@ -19,10 +34,83 @@ Generates the entire 2D blueprint. Roughly in order:
ring — and a per-seed-normalized summit spike to the 420 m cap) reshapes above-sea terrain ring — and a per-seed-normalized summit spike to the 420 m cap) reshapes above-sea terrain
after noise/falloff/Trench and before the crater carve; biome classification reads a after noise/falloff/Trench and before the crater carve; biome classification reads a
retained uncurved map, so biomes are identical either way. With `TerrainDetail: "v1"` (the retained uncurved map, so biomes are identical either way. With `TerrainDetail: "v1"` (the
default) two detail passes follow the curve: shelf micro-relief (±3 m rolling skin on the default) two detail passes ride along with the curve (`TerrainDetailPass.cs`): shelf
benches/plateaus) and D8 drainage incision (rain-cut ravines in the risers — the future micro-relief (`ShelfReliefAmp`, ±3 m rolling skin on the benches/plateaus) and shelf-edge
river routes; `TerrainDetailPass.cs`). Drops the `0_height` hillshade snapshot (hypsometric variation (`ShelfEdgeVariation`, 12 m) — a per-column shift of the shelf/riser knot block that
bands × NW hillshade) in both modes. makes the shelf edge scallop into notches, coves and peninsulas instead of tracing a clean
height contour. Both touch exported heights only, and neither can reach below the red ceiling
or above the 420 m cap: the curve's K2 and K6 knots do not move, so a warped column is
bit-identical to an unwarped one outside the shelf/riser stack. Drops the `0_height` hillshade
snapshot (hypsometric bands × NW hillshade) in both modes.
**Erosion (tasks 1718, Phase C0)** — with `Erosion: "v1"` (default **"off"**, opt-in until the
developer's gate approves it) a droplet-based hydraulic erosion pass (`HydraulicErosion.cs`,
standalone numeric, deterministic from the resolved seed) details the curved+detailed render
map after the detail passes and before the crater carve: droplets walk downhill with inertia,
eroding steep fast stretches and depositing where the ground flattens — both spread over the
same cone brush, so neither carving nor dumping can spike a single cell. **Four** hard
governors bound it (`ErosionDropletCount`/`ErosionDropletLifetime`/`ErosionCarveCap`/
`ErosionDepositCap`; both caps are per-cell metres against one net-displacement ledger and are
asserted on exit), a sea clamp forbids carving below sea + margin and leaves below-sea cells
untouched in both directions (the rendered coastline cannot move — asserted every generation),
and the crater is handled by `CraterErosionMode` (task 19): nothing inside the protected strike
core (`CraterErosionCore`, **0.80 ×** CraterRadius — the carve's own extent) is modified, and
outside it either `"full"` applies full strength at once or `"feather"` ramps 0→full out to
`CraterErosionFeather` (1.05 ×). Task 17's hard 1.2 × cutoff was replaced because it held
**620 811 land cells** of ordinary terrain smooth for no geometric reason — measured, the carve's
displacement is exactly 0 beyond 0.80 × — which read as an un-eroded disc with a hard edge.
Keeping the core at the carve radius is what makes erosion and the carve touch **disjoint**
cells; a narrower core lets the carve, which runs afterwards and scales height toward the sea
target, amplify erosion's deltas across the waterline (measured at a 0.50 core: 79 rendered
waterline crossings). The flooded bay and its sea connection never depend on the crater mode —
below-sea cells are read-only in both directions, so the bay can be neither carved open nor
silted shut. Output-height only: biome/water
classification reads the retained pre-erosion map, so `1_biomes`/`0_water` are bit-identical
with erosion on or off.
The task-18 defaults tune for a drainage **hierarchy** — fine rills everywhere feeding a set of
clearly deeper convergent channels — by letting droplets live long enough (384 steps at inertia
0.35, low evaporation) that their paths overlap and deepen shared low lines, and by raising the
carve cap to 15 m so trunks separate from rills instead of both piling against one ceiling.
Measured on seed 1280587109: ~1.9 M cells carved past 0.5 m, 42 k past 5 m, 1.4 k past 10 m,
and 177 connected channel systems of 200+ cells at the 3 m threshold. Erosion concentrates
~9× on the curve's shelf risers, because that is where sustained slope exists; the flat
shelves and lowlands are barely touched, so **the lowland continuation of a trunk is not
erosion's to cut** — that is river promotion's job. **No rivers yet** — the carved channels are
the designated future river routes (Phase C0b promotes them by flow accumulation).
The predecessor D8 drainage-incision pass was written and reverted in task 10 — per-cell
steepest descent on a regular grid can only route along eight headings, and at map scale that
reads as straight hatching, not drainage; the droplet model is the working replacement (its
carve field measures isotropic to within 1.5 % across the folded 45° grid period).
**River plan (task 21, C0b part 1)**`DrainageAnalysis.cs` + the headless
`RiverPlanTool.tscn` produce a river PLAN from an erosion-ON blueprint: priority-flood with a
one-ulp epsilon resolves the ~15k erosion pits for ROUTING ONLY (terrain untouched), deep+large
depressions survive as terminal basins, D8 flow directions + Kahn accumulation build the
drainage network, and the top ~3 TRUE-ocean outlets (the ocean body, WBID 1 — enclosed lagoons
do not count as "the sea") become trunk candidates with lean tributaries, mountain-exit handoff
points, and lean endorheic terminals. Task 21b extends the plan to the developer's MIXED
promotion: the ocean trunks PLUS the top endorheic giants (`RIVERPLAN_GIANT_N`, default 3),
each classified — a giant whose terminal basin holds a classify lake stays a **lake-ender**
(rivers ending in lakes are real geography); a dry-pan giant, and always the **SOUTHERN
CANDIDATE** (the giant pooling nearest the southernmost town), gets a **PROVISIONAL route** to
the ocean: steepest descent on the full (no-terminal) fill, so the basin overtops at its spill
and the walk follows the terrain's own drainage to the sea — drawn dashed for the gate, never
carved. Output is a JSON *plan* sidecar + console report — deliberately NOT a blueprint
section, so a plan can never masquerade as realized water. Part 2 (task 22) carves and waters
the gated plan. Pure analysis: the source blueprint is never written.
**River carving (task 22, C0b part 2a)** — with `Rivers: "v1"` (default **"off"**, pending the
routing-style gate) the frozen task-21b plan is executed as real terrain (`RiverCarvePass.cs`):
the drainage analysis reruns in-pipeline (deterministic), each routed giant gets a lowland
route to the nearest ocean by deterministic Dijkstra — `RiverRoutingStyle: "short"` (direct,
uphill-penalised) or `"lowground"` (cost ≈ elevation; follows the lowest ground and meanders;
the provisional default) — and every promoted course is carved as a parabolic channel with a
smoothstep shoulder, width/depth growing downstream with drainage (`RiverWidthScale`/
`RiverDepthScale`), bed made monotone non-increasing toward the outlet and clamped to
sea + `RiverSeaMargin` everywhere (the erosion flood-guard discipline: below-sea cells
read-only, zero new below-sea cells, coastline provably fixed; asserted per generation).
Slots AFTER towns (town placement reads the render map and must not shift) and BEFORE roads.
Crater core excluded. **NO WATER yet** — part 2b waters the gated routing style.
3. **Sea level and water** — sea level per the configured model (`SeaLevelModel`: `"flat"` scalar 3. **Sea level and water** — sea level per the configured model (`SeaLevelModel`: `"flat"` scalar
— the default, `SeaLevelValue` 0.15 — or the legacy `"field"` latitude Lerp); flood fill — the default, `SeaLevelValue` 0.15 — or the legacy `"field"` latitude Lerp); flood fill
separates true ocean from inland lakes; a mainland fill guarantees one contiguous landmass. separates true ocean from inland lakes; a mainland fill guarantees one contiguous landmass.

View file

@ -0,0 +1,361 @@
using System;
using System.Collections.Generic;
/// <summary>
/// River bed carving — C0b part 2a (terrain-water task 22). The first river pass
/// that MODIFIES terrain: executes the frozen task-21b plan by carving channel
/// beds for the promoted rivers. NO WATER — part 2b puts water into these beds
/// once the routing-style gate picks SHORT or LOWGROUND.
///
/// Standalone numeric (D-035 family). Runs the task-21 DrainageAnalysis in-
/// pipeline (deterministic: same seed → same eroded surface → same plan), then:
///
/// 1. LOWLAND ROUTING (the A/B): each routed giant gets a route from its
/// pooling terminal to the nearest OCEAN cell by deterministic Dijkstra.
/// SHORT — cost ≈ distance, uphill penalised: heads direct, avoids walls.
/// LOWGROUND — cost ≈ elevation above sea: follows the lowest available
/// ground and wanders like a real lowland river.
/// 2. BED CARVING: every promoted course (trunks, routed giants + their lowland
/// reaches, lake-enders, tributaries) is stamped as a parabolic channel with
/// a smoothstep shoulder — a bed for water to sit in, not a canyon. Width and
/// depth grow downstream with drainage. The bed elevation is made MONOTONE
/// NON-INCREASING toward the outlet (water must flow), and is clamped to
/// sea + margin everywhere — the flood-guard discipline erosion established:
/// below-sea cells are read-only, no carve may create inland below-sea cells,
/// so the rendered coastline cannot move. The bed meets the ocean AT the
/// coast, where the terrain itself descends through sea level.
/// 3. Crater: nothing inside the protected core is modified (erosion's rule).
///
/// Output-only: the caller applies this to the RENDER map after classify-side
/// data (biomes, WBID) is already computed — the oracle stays byte-identical.
/// </summary>
public static class RiverCarvePass
{
public const float M_PER_UNIT = 251f;
public const byte STYLE_SHORT = 0;
public const byte STYLE_LOWGROUND = 1;
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 };
// Routing cost constants. SHORT pays lightly for climbing (8 per metre of rise,
// so a 10 m wall costs like an 80 px detour — walls are avoided, direction is
// kept). LOWGROUND pays for BEING high (1 per metre of elevation per px) plus
// heavily for climbing, so the cheapest corridor is the lowest ground even when
// that wanders.
private const float SHORT_UPHILL_PER_M = 8f;
private const float LOWGROUND_ELEV_PER_M = 1f;
private const float LOWGROUND_BASE = 0.05f;
private const float LOWGROUND_UPHILL_PER_M = 50f;
// Bed geometry: sizes grow downstream from head to mouth, scaled by
// sqrt(drainage / 1e6) so a 2.3M px giant carves roughly 2.3× deeper/wider at
// the mouth than a 0.4M px trunk. Kept channel-scale, per the erosion
// detailing philosophy.
private const float DEPTH_HEAD_M = 1.0f;
private const float DEPTH_MOUTH_M = 6.0f; // × sizeFactor × DepthScale
private const float HALFWIDTH_HEAD_PX = 2.0f;
private const float HALFWIDTH_MOUTH_PX = 12.0f; // × sizeFactor × WidthScale
private const float MIN_BED_SLOPE = 0.002f; // m per px of enforced descent
public class Params
{
public byte RoutingStyle = STYLE_LOWGROUND;
public float WidthScale = 1.0f;
public float DepthScale = 1.0f;
public float SeaMarginM = 0.2f; // bed floor above sea, everywhere
public DrainageAnalysis.Params PlanParams = new();
}
public class RiverStat
{
public string Name;
public string Kind;
public bool SouthernCandidate;
public long DrainagePx;
public int CourseLenPx;
public int RouteLenPx; // lowland reach only (routed giants)
public float RouteStraightPx;
public float WanderRatio; // routeLen / straight-line
public bool ReachedOcean;
public float MaxCutM;
public double VolumeM3;
}
public class Stats
{
public List<RiverStat> Rivers = new();
public long CarvedCells;
public double CarvedVolumeM3;
public float MaxCutM;
public double AnalysisSeconds, RoutingSeconds, CarveSeconds;
}
public static Stats Apply(float[,] height, int mapSize, bool[] isOcean,
bool[] isClassifyWater, float southX, float southY,
float[,] seaMap, float seaFlat,
float craterCx, float craterCy, float craterCoreRadius,
Func<double> secondsNow, Params p)
{
int n = mapSize;
var stats = new Stats();
float SeaAt(int x, int y) => seaMap != null ? seaMap[x, y] : seaFlat;
float coreSq = craterCoreRadius * craterCoreRadius;
// --- the frozen plan, recomputed deterministically in-pipeline ---
double t0 = secondsNow();
var plan = DrainageAnalysis.Run(height, mapSize, isOcean, isClassifyWater, southX, southY, p.PlanParams);
stats.AnalysisSeconds = secondsNow() - t0;
// --- lowland routing for the routed giants (the A/B) ---
t0 = secondsNow();
var giantRoutes = new List<List<(float x, float y)>>();
foreach (var g in plan.Giants)
{
if (g.Kind != "routed") { giantRoutes.Add(null); continue; }
giantRoutes.Add(RouteToOcean(height, n, isOcean,
(int)g.Terminal.x, (int)g.Terminal.y, p.RoutingStyle, SeaAt));
}
stats.RoutingSeconds = secondsNow() - t0;
// --- carve ---
t0 = secondsNow();
int riverIdx = 0;
foreach (var t in plan.Trunks)
{
riverIdx++;
var course = new List<(float x, float y)>(t.Course);
course.Reverse(); // head → mouth
var rs = CarveRiver($"trunk{riverIdx}", "ocean-trunk", t.DrainageAreaPx,
course, null, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
rs.ReachedOcean = true; // outlet is on the coast by construction
foreach (var trib in t.Tributaries)
CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
}
int gi = 0;
foreach (var g in plan.Giants)
{
var route = giantRoutes[gi]; gi++;
var course = new List<(float x, float y)>(g.Course);
course.Reverse(); // head → terminal
var rs = CarveRiver($"giant{gi}", g.Kind, g.DrainageAreaPx,
course, route, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
rs.SouthernCandidate = g.SouthernCandidate;
rs.ReachedOcean = g.Kind != "routed" || (route != null && route.Count > 0);
foreach (var trib in g.Tributaries)
CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
}
stats.CarveSeconds = secondsNow() - t0;
return stats;
}
/// <summary>
/// Deterministic Dijkstra from the start cell to the nearest ocean cell under
/// the selected style's cost model. Returns the path start → ocean (1-px steps),
/// or an empty list if no path exists (reported upstream, never asserted away).
/// </summary>
private static List<(float x, float y)> RouteToOcean(float[,] height, int n,
bool[] isOcean, int sx, int sy, byte style, Func<int, int, float> seaAt)
{
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);
float ElevM(int x, int y) => MathF.Max(0f, (height[x, y] - seaAt(x, y)) * M_PER_UNIT);
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;
while (pq.Count > 0)
{
int c = pq.Dequeue();
if (closed[c]) continue;
closed[c] = true;
if (isOcean[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, (height[nx, ny] - hc) * M_PER_UNIT);
float step = style == STYLE_SHORT
? DIST[k] + dhM * SHORT_UPHILL_PER_M
: DIST[k] * (LOWGROUND_BASE + ElevM(nx, ny) * LOWGROUND_ELEV_PER_M)
+ dhM * LOWGROUND_UPHILL_PER_M;
float nc = gcost[c] + step;
if (nc < gcost[ni])
{
gcost[ni] = nc;
parent[ni] = c;
pq.Enqueue(ni, (nc, ni));
}
}
}
var path = new List<(float x, float y)>();
if (goal >= 0)
{
for (int c = goal; c >= 0; c = parent[c])
path.Add((c / n, c % n));
path.Reverse();
}
return path;
}
private static void CarveTributary(DrainageAnalysis.Stream trib, float[,] height, int n,
Func<int, int, float> seaAt, float coreSq, float craterCx, float craterCy,
Params p, Stats stats)
{
var course = new List<(float x, float y)>(trib.Course);
course.Reverse(); // head → confluence
CarveRiver(null, "tributary", trib.DrainageAreaPx, course, null,
height, n, seaAt, coreSq, craterCx, craterCy, p, stats);
}
/// <summary>
/// Carves one river: densify the course, build a monotone-descending clamped
/// bed profile, stamp the channel. Returns the per-river stat (also appended
/// to stats.Rivers unless name is null — tributaries fold into the totals).
/// </summary>
private static RiverStat CarveRiver(string name, string kind, long drainagePx,
List<(float x, float y)> upland, List<(float x, float y)> lowlandRoute,
float[,] height, int n, Func<int, int, float> seaAt,
float coreSq, float craterCx, float craterCy, Params p, Stats stats)
{
// Full head→mouth polyline: upland stem, then the lowland reach if any.
var pts = new List<(float x, float y)>(upland);
if (lowlandRoute != null && lowlandRoute.Count > 1)
pts.AddRange(lowlandRoute.GetRange(1, lowlandRoute.Count - 1));
// Densify to ~1-px samples (plan courses are decimated ×4).
var dense = new List<(float x, float y)>();
for (int i = 0; i + 1 < pts.Count; i++)
{
var a = pts[i]; var b = pts[i + 1];
float segLen = MathF.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
int steps = Math.Max(1, (int)MathF.Ceiling(segLen));
for (int s2 = 0; s2 < steps; s2++)
dense.Add((a.x + (b.x - a.x) * s2 / steps, a.y + (b.y - a.y) * s2 / steps));
}
if (pts.Count > 0) dense.Add(pts[^1]);
if (dense.Count < 2) return new RiverStat();
float sizeFactor = MathF.Sqrt(drainagePx / 1_000_000f);
var rs = new RiverStat
{
Name = name, Kind = kind, DrainagePx = drainagePx,
CourseLenPx = dense.Count,
RouteLenPx = lowlandRoute?.Count ?? 0
};
if (lowlandRoute != null && lowlandRoute.Count > 1)
{
var a = lowlandRoute[0]; var b = lowlandRoute[^1];
rs.RouteStraightPx = MathF.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
// Wander = POLYLINE length over straight-line — cell count undercounts
// diagonal steps and can read below 1, which is geometrically impossible.
float polyLen = 0f;
for (int i = 1; i < lowlandRoute.Count; i++)
{
float sdx = lowlandRoute[i].x - lowlandRoute[i - 1].x;
float sdy = lowlandRoute[i].y - lowlandRoute[i - 1].y;
polyLen += MathF.Sqrt(sdx * sdx + sdy * sdy);
}
rs.RouteLenPx = (int)polyLen;
rs.WanderRatio = rs.RouteStraightPx > 1f ? polyLen / rs.RouteStraightPx : 1f;
}
// Bed profile: raw = terrain depth(t); then monotone non-increasing
// downstream; then clamped to sea + margin. The clamp can flatten the tail
// near the mouth — allowed: non-increasing is what water needs, and the
// flood guard is absolute.
int m = dense.Count;
var bed = new float[m];
var depth = new float[m];
var halfW = new float[m];
for (int i = 0; i < m; i++)
{
float t = m > 1 ? (float)i / (m - 1) : 1f;
depth[i] = (DEPTH_HEAD_M + (DEPTH_MOUTH_M * sizeFactor - DEPTH_HEAD_M) * t) * p.DepthScale;
if (depth[i] < 0.5f) depth[i] = 0.5f;
halfW[i] = (HALFWIDTH_HEAD_PX + (HALFWIDTH_MOUTH_PX * sizeFactor - HALFWIDTH_HEAD_PX) * t) * p.WidthScale;
if (halfW[i] < 1.5f) halfW[i] = 1.5f;
int cx = (int)dense[i].x, cy = (int)dense[i].y;
bed[i] = height[cx, cy] - depth[i] / M_PER_UNIT;
}
for (int i = 1; i < m; i++)
{
float maxAllowed = bed[i - 1] - MIN_BED_SLOPE / M_PER_UNIT;
if (bed[i] > maxAllowed) bed[i] = maxAllowed;
}
for (int i = 0; i < m; i++)
{
int cx = (int)dense[i].x, cy = (int)dense[i].y;
float floor = seaAt(cx, cy) + p.SeaMarginM / M_PER_UNIT;
if (bed[i] < floor) bed[i] = floor;
}
// Stamp: parabolic channel to the rim, smoothstep shoulder back to terrain.
for (int i = 0; i < m; i++)
{
float hw = halfW[i];
float outer = hw * 2f;
int cx0 = (int)MathF.Floor(dense[i].x - outer), cx1 = (int)MathF.Ceiling(dense[i].x + outer);
int cy0 = (int)MathF.Floor(dense[i].y - outer), cy1 = (int)MathF.Ceiling(dense[i].y + outer);
float rimH = bed[i] + depth[i] / M_PER_UNIT;
for (int x = cx0; x <= cx1; x++)
{
if (x < 0 || x >= n) continue;
for (int y = cy0; y <= cy1; y++)
{
if (y < 0 || y >= n) continue;
float rx = x - dense[i].x, ry = y - dense[i].y;
float r = MathF.Sqrt(rx * rx + ry * ry);
if (r > outer) continue;
float ddx = x - craterCx, ddy = y - craterCy;
if (ddx * ddx + ddy * ddy < coreSq) continue; // crater core protected
float sea = seaAt(x, y);
float old = height[x, y];
if (old < sea) continue; // below-sea cells read-only
float target;
if (r <= hw)
{
float f = r / hw;
target = bed[i] + (depth[i] / M_PER_UNIT) * f * f;
}
else
{
float f = (r - hw) / hw; // 0..1 across the shoulder
f = f * f * (3f - 2f * f); // smoothstep
target = rimH + (old - rimH) * f;
}
float floor = sea + p.SeaMarginM / M_PER_UNIT;
if (target < floor) target = floor;
if (target < old)
{
float cutM = (old - target) * M_PER_UNIT;
height[x, y] = target;
stats.CarvedCells++;
stats.CarvedVolumeM3 += cutM;
if (cutM > stats.MaxCutM) stats.MaxCutM = cutM;
if (cutM > rs.MaxCutM) rs.MaxCutM = cutM;
rs.VolumeM3 += cutM;
}
}
}
}
if (name != null) stats.Rivers.Add(rs);
return rs;
}
}

View file

@ -0,0 +1,286 @@
using Godot;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using IslaApocalypse.Core;
/// <summary>
/// The river-plan tool (C0b part 1, terrain-water task 21). Headless, harness-style:
///
/// 1. load an EROSION-ON blueprint through the real parser,
/// 2. run DrainageAnalysis over its (eroded) heightmap — pure analysis,
/// 3. print the full plan report to the console,
/// 4. write the plan as a JSON SIDECAR next to the source file.
///
/// It never writes the blueprint. The sidecar is deliberately NOT a blueprint
/// section: sections are for realized world data, and this is a PLAN the developer
/// gates before part 2 carves anything — a plan that read as actual water would be
/// exactly the masquerade task 21 forbids. Part 2 owns the durable representation.
///
/// Run: Godot --headless --path <repo> res://Tools/Scenes/RiverPlanTool.tscn
/// Env: RIVERPLAN_SRC (source .dat; default user://MapData_Seed_1280587109.dat),
/// RIVERPLAN_OUT (sidecar path; default <src dir>/RiverPlan_Seed_<seed>.json),
/// RIVERPLAN_* dial overrides (see ReadParams).
/// Exit 0 = plan written, 1 = failure.
/// </summary>
public partial class RiverPlanTool : Node
{
public override void _Ready()
{
bool ok = false;
try { ok = RunPlan(); }
catch (System.Exception e) { GD.PrintErr($"[RiverPlan] EXCEPTION: {e}"); }
GD.Print(ok ? "[RiverPlan] RESULT: PLAN WRITTEN" : "[RiverPlan] RESULT: FAIL");
GetTree().Quit(ok ? 0 : 1);
}
private static float EnvF(string k, float d) =>
float.TryParse(OS.GetEnvironment(k), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : d;
private static int EnvI(string k, int d) =>
int.TryParse(OS.GetEnvironment(k), out var v) ? v : d;
private static DrainageAnalysis.Params ReadParams()
{
var p = new DrainageAnalysis.Params();
p.EndorheicMinDepthM = EnvF("RIVERPLAN_ENDO_MIN_DEPTH_M", p.EndorheicMinDepthM);
p.EndorheicMinAreaPx = EnvI("RIVERPLAN_ENDO_MIN_AREA_PX", p.EndorheicMinAreaPx);
p.EndorheicMinInflowPx = EnvI("RIVERPLAN_ENDO_MIN_INFLOW_PX", p.EndorheicMinInflowPx);
p.EndorheicMaxCount = EnvI("RIVERPLAN_ENDO_MAX_COUNT", p.EndorheicMaxCount);
p.TrunkCount = EnvI("RIVERPLAN_TRUNK_COUNT", p.TrunkCount);
p.TrunkCount = EnvI("RIVERPLAN_OCEAN_N", p.TrunkCount); // 21b alias
p.GiantCount = EnvI("RIVERPLAN_GIANT_N", p.GiantCount);
p.MinOutletSeparationPx = EnvI("RIVERPLAN_OUTLET_SEPARATION_PX", p.MinOutletSeparationPx);
p.StemMinAccPx = EnvI("RIVERPLAN_STEM_MIN_ACC_PX", p.StemMinAccPx);
p.TributaryMinAccPx = EnvI("RIVERPLAN_TRIB_MIN_ACC_PX", p.TributaryMinAccPx);
p.TributaryMaxPerTrunk = EnvI("RIVERPLAN_TRIB_MAX_PER_TRUNK", p.TributaryMaxPerTrunk);
p.ExitGradeMin = EnvF("RIVERPLAN_EXIT_GRADE_MIN", p.ExitGradeMin);
p.ExitWindowPx = EnvI("RIVERPLAN_EXIT_WINDOW_PX", p.ExitWindowPx);
p.SeaLevel = EnvF("RIVERPLAN_SEA_LEVEL", p.SeaLevel);
return p;
}
private bool RunPlan()
{
string src = OS.GetEnvironment("RIVERPLAN_SRC");
if (string.IsNullOrEmpty(src))
src = ProjectSettings.GlobalizePath("user://MapData_Seed_1280587109.dat");
GD.Print($"[RiverPlan] source blueprint: {src}");
ulong t0 = Time.GetTicksMsec();
WorldBlueprint bp = MapDataParser.LoadMapDataFromPath(src);
if (bp == null) { GD.PrintErr("[RiverPlan] blueprint load failed."); return false; }
if (bp.Erosion == null)
GD.PrintErr("[RiverPlan] ⚠ source carries no EROS section — analysing an UNERODED " +
"surface; the plan will still compute but is not the C0b input the task means.");
ulong t1 = Time.GetTicksMsec();
GD.Print($"[RiverPlan] loaded in {(t1 - t0) / 1000.0:F1}s " +
$"(seed {bp.Params?.WorldSeed}, {bp.MapSize}², erosion {(bp.Erosion != null ? $"v{bp.Erosion.Version}" : "ABSENT")}).");
if (bp.WaterBodyIds == null)
{ GD.PrintErr("[RiverPlan] source carries no WBID — cannot identify THE OCEAN; refusing."); return false; }
// THE OCEAN body (WBID == 1) is the only water that counts as "the sea":
// enclosed lagoons are depressions a river may legitimately END in, not
// destinations that make a trunk "sea-reaching".
int nn = bp.MapSize;
bool[] isOcean = new bool[nn * nn];
bool[] isClassifyWater = new bool[nn * nn];
for (int x = 0; x < nn; x++)
for (int y = 0; y < nn; y++)
{
ushort wb = bp.WaterBodyIds[x, y];
isOcean[x * nn + y] = wb == 1;
isClassifyWater[x * nn + y] = wb != 0;
}
// Southernmost town — the 21b SOUTHERN CANDIDATE anchor (shown, not forced).
float southX = -1f, southY = -1f;
foreach (var t in bp.Towns)
if (t.Position.Y > southY) { southX = t.Position.X; southY = t.Position.Y; }
var p = ReadParams();
var plan = DrainageAnalysis.Run(bp.HeightMap, bp.MapSize, isOcean, isClassifyWater, southX, southY, p);
ulong t2 = Time.GetTicksMsec();
GD.Print($"[RiverPlan] analysis in {(t2 - t1) / 1000.0:F1}s.");
// ---- console report ----
GD.Print($"[RiverPlan] routing: {plan.LandCells} land cells; " +
$"{plan.SeaReachingCells} drain to sea ({100.0 * plan.SeaReachingCells / plan.LandCells:F1}%), " +
$"{plan.EndorheicCells} endorheic ({100.0 * plan.EndorheicCells / plan.LandCells:F1}%), " +
$"{plan.UnroutedCells} unrouted (should be ~0).");
GD.Print($"[RiverPlan] depressions: {plan.PitsFilledCount} pits filled through for routing, " +
$"{plan.TerminalBasinCount} qualified as terminal basins " +
$"(depth ≥ {p.EndorheicMinDepthM} m and area ≥ {p.EndorheicMinAreaPx} px).");
GD.Print("[RiverPlan] top outlets by drainage area (pre-separation):");
foreach (var (x, y, a) in plan.AllOutletsTop)
GD.Print($"[RiverPlan] ({x},{y}) {a} px");
int ti = 0;
foreach (var t in plan.Trunks)
{
ti++;
GD.Print($"[RiverPlan] TRUNK {ti}: outlet ({t.Outlet.x:F0},{t.Outlet.y:F0}), " +
$"drainage {t.DrainageAreaPx} px, stem {t.Course.Count * 4} px, " +
(t.ExitFound
? $"mountain-exit ({t.MountainExit.x:F0},{t.MountainExit.y:F0}) at {t.MountainExitElevM:F0} m"
: "mountain-exit NOT FOUND (stem never sustains the exit grade)") +
$", {t.Tributaries.Count} tributaries.");
foreach (var tr in t.Tributaries)
GD.Print($"[RiverPlan] trib: joins near head ({tr.Course[0].x:F0},{tr.Course[0].y:F0}), " +
$"drainage {tr.DrainageAreaPx} px");
}
foreach (var e in plan.Endorheics)
{
ushort wb = bp.WaterBodyIds[(int)e.Terminal.x, (int)e.Terminal.y];
GD.Print($"[RiverPlan] ENDORHEIC terminal ({e.Terminal.x:F0},{e.Terminal.y:F0}): " +
$"drainage {e.DrainageAreaPx} px into a basin {e.BasinDepthM:F1} m deep, {e.BasinAreaPx} px" +
(wb > 1 ? $" — terminates IN classify lake/lagoon WBID {wb} (river-feeds-lake)" : " — dry closed basin") + ".");
}
// ---- 21b: the promoted giants ----
int gi = 0;
foreach (var g in plan.Giants)
{
gi++;
GD.Print($"[RiverPlan] GIANT {gi} [{g.Kind.ToUpper()}{(g.SouthernCandidate ? " SOUTHERN CANDIDATE" : "")}]: " +
$"drainage {g.DrainageAreaPx} px, pools at ({g.Terminal.x:F0},{g.Terminal.y:F0}) " +
$"({(g.TerminalInClassifyWater ? "in classify water" : "dry pan")}, basin {g.BasinDepthM:F1} m / {g.BasinAreaPx} px), " +
(g.ExitFound ? $"mountain-exit ({g.MountainExit.x:F0},{g.MountainExit.y:F0}) at {g.MountainExitElevM:F0} m, " : "") +
$"{g.Tributaries.Count} tributaries" +
(g.ProvisionalRoute != null
? $"; PROVISIONAL route {g.ProvisionalRoute.Count * 4} px via spill ({g.Spill.x:F0},{g.Spill.y:F0}) " +
(g.RouteReachedOcean ? "-> reaches the OCEAN" : "-> DID NOT reach the ocean (walk stuck — report)")
: "; ends at its lake") + ".");
}
// ---- the southern-town report (filed fact, not a constraint) ----
if (bp.Towns.Count > 0)
{
TownLocation south = bp.Towns[0];
foreach (var t in bp.Towns)
if (t.Position.Y > south.Position.Y) south = t;
GD.Print($"[RiverPlan] southernmost town: tier {south.Tier} at " +
$"({south.Position.X:F0},{south.Position.Y:F0}).");
ti = 0;
foreach (var t in plan.Trunks)
{
ti++;
float best = float.MaxValue;
foreach (var (x, y) in t.Course)
{
float dx = x - south.Position.X, dy = y - south.Position.Y;
float d2 = dx * dx + dy * dy;
if (d2 < best) best = d2;
}
GD.Print($"[RiverPlan] SOUTH REPORT trunk {ti}: outlet y={t.Outlet.y:F0} " +
$"({(t.Outlet.y > bp.MapSize * 0.55f ? "southern" : t.Outlet.y < bp.MapSize * 0.45f ? "northern" : "central")} coast); " +
$"course passes {Mathf.Sqrt(best):F0} px from the southernmost town.");
}
}
// ---- JSON sidecar ----
string outPath = OS.GetEnvironment("RIVERPLAN_OUT");
if (string.IsNullOrEmpty(outPath))
outPath = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(src) ?? ".",
$"RiverPlan_Seed_{bp.Params?.WorldSeed}.json");
System.IO.File.WriteAllText(outPath, ToJson(bp, plan));
GD.Print($"[RiverPlan] plan sidecar written: {outPath}");
return true;
}
// Hand-rolled, invariant-culture JSON for a fixed schema — deterministic output,
// no serializer reflection surprises.
private static string ToJson(WorldBlueprint bp, DrainageAnalysis.Plan plan)
{
var ci = CultureInfo.InvariantCulture;
var sb = new StringBuilder(1 << 20);
void Pt(StringBuilder b, (float x, float y) v) =>
b.Append('[').Append(v.x.ToString("F1", ci)).Append(',').Append(v.y.ToString("F1", ci)).Append(']');
void Course(List<(float x, float y)> c)
{
sb.Append('[');
for (int i = 0; i < c.Count; i++) { if (i > 0) sb.Append(','); Pt(sb, c[i]); }
sb.Append(']');
}
sb.Append("{\n\"_WARNING\": \"RIVER *PLAN* — analysis output for the task-21 gate. ");
sb.Append("Nothing here is realized water or terrain. Part 2 (task 22) consumes this; ");
sb.Append("nothing at runtime may read it as water.\",\n");
sb.Append($"\"seed\": {bp.Params?.WorldSeed ?? 0}, \"mapSize\": {bp.MapSize},\n");
var p = plan.P;
sb.Append($"\"params\": {{\"endoMinDepthM\": {p.EndorheicMinDepthM.ToString(ci)}, ");
sb.Append($"\"endoMinAreaPx\": {p.EndorheicMinAreaPx}, \"endoMinInflowPx\": {p.EndorheicMinInflowPx}, ");
sb.Append($"\"endoMaxCount\": {p.EndorheicMaxCount}, \"trunkCount\": {p.TrunkCount}, ");
sb.Append($"\"minOutletSeparationPx\": {p.MinOutletSeparationPx}, \"stemMinAccPx\": {p.StemMinAccPx}, ");
sb.Append($"\"tribMinAccPx\": {p.TributaryMinAccPx}, \"tribMaxPerTrunk\": {p.TributaryMaxPerTrunk}, ");
sb.Append($"\"exitGradeMin\": {p.ExitGradeMin.ToString(ci)}, \"exitWindowPx\": {p.ExitWindowPx}, ");
sb.Append($"\"seaLevel\": {p.SeaLevel.ToString(ci)}}},\n");
sb.Append($"\"routing\": {{\"landCells\": {plan.LandCells}, \"seaReaching\": {plan.SeaReachingCells}, ");
sb.Append($"\"endorheic\": {plan.EndorheicCells}, \"unrouted\": {plan.UnroutedCells}, ");
sb.Append($"\"pitsFilled\": {plan.PitsFilledCount}, \"terminalBasins\": {plan.TerminalBasinCount}}},\n");
sb.Append("\"trunks\": [\n");
for (int i = 0; i < plan.Trunks.Count; i++)
{
var t = plan.Trunks[i];
sb.Append(" {\"outlet\": "); Pt(sb, t.Outlet);
sb.Append($", \"drainageAreaPx\": {t.DrainageAreaPx}, \"exitFound\": {(t.ExitFound ? "true" : "false")}, ");
sb.Append("\"mountainExit\": "); Pt(sb, t.MountainExit);
sb.Append($", \"mountainExitElevM\": {t.MountainExitElevM.ToString("F1", ci)},\n \"course\": ");
Course(t.Course);
sb.Append(",\n \"tributaries\": [");
for (int j = 0; j < t.Tributaries.Count; j++)
{
var tr = t.Tributaries[j];
if (j > 0) sb.Append(',');
sb.Append($"\n {{\"drainageAreaPx\": {tr.DrainageAreaPx}, \"course\": ");
Course(tr.Course);
sb.Append('}');
}
sb.Append("]\n }");
if (i < plan.Trunks.Count - 1) sb.Append(',');
sb.Append('\n');
}
sb.Append("],\n\"giants\": [\n");
for (int i = 0; i < plan.Giants.Count; i++)
{
var g = plan.Giants[i];
sb.Append(" {\"kind\": \"").Append(g.Kind).Append("\", ");
sb.Append($"\"southernCandidate\": {(g.SouthernCandidate ? "true" : "false")}, ");
sb.Append($"\"drainageAreaPx\": {g.DrainageAreaPx}, ");
sb.Append("\"terminal\": "); Pt(sb, g.Terminal);
sb.Append($", \"terminalInClassifyWater\": {(g.TerminalInClassifyWater ? "true" : "false")}, ");
sb.Append($"\"basinDepthM\": {g.BasinDepthM.ToString("F2", ci)}, \"basinAreaPx\": {g.BasinAreaPx}, ");
sb.Append($"\"exitFound\": {(g.ExitFound ? "true" : "false")}, \"mountainExit\": "); Pt(sb, g.MountainExit);
sb.Append($", \"mountainExitElevM\": {g.MountainExitElevM.ToString("F1", ci)},\n \"course\": ");
Course(g.Course);
if (g.ProvisionalRoute != null)
{
sb.Append(",\n \"spill\": "); Pt(sb, g.Spill);
sb.Append($", \"routeReachedOcean\": {(g.RouteReachedOcean ? "true" : "false")}");
sb.Append(",\n \"provisionalRoute_NOT_WATER\": ");
Course(g.ProvisionalRoute);
}
sb.Append(",\n \"tributaries\": [");
for (int j = 0; j < g.Tributaries.Count; j++)
{
var tr = g.Tributaries[j];
if (j > 0) sb.Append(',');
sb.Append($"\n {{\"drainageAreaPx\": {tr.DrainageAreaPx}, \"course\": ");
Course(tr.Course);
sb.Append('}');
}
sb.Append("]\n }");
if (i < plan.Giants.Count - 1) sb.Append(',');
sb.Append('\n');
}
sb.Append("],\n\"endorheics\": [");
for (int i = 0; i < plan.Endorheics.Count; i++)
{
var e = plan.Endorheics[i];
if (i > 0) sb.Append(',');
sb.Append("\n {\"terminal\": "); Pt(sb, e.Terminal);
sb.Append($", \"drainageAreaPx\": {e.DrainageAreaPx}, ");
sb.Append($"\"basinDepthM\": {e.BasinDepthM.ToString("F2", ci)}, \"basinAreaPx\": {e.BasinAreaPx}, ");
sb.Append($"\"terminalWbid\": {bp.WaterBodyIds[(int)e.Terminal.x, (int)e.Terminal.y]}}}");
}
sb.Append("\n]\n}\n");
return sb.ToString();
}
}

View file

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

View file

@ -122,6 +122,7 @@ public partial class RoundTripHarness : Node
ok &= CompareWater(a, b); ok &= CompareWater(a, b);
ok &= CompareTerrainCurve(a, b); ok &= CompareTerrainCurve(a, b);
ok &= CompareTerrainDetail(a, b); ok &= CompareTerrainDetail(a, b);
ok &= CompareErosion(a, b);
if (ok) if (ok)
GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " + GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " +
@ -229,8 +230,8 @@ public partial class RoundTripHarness : Node
return false; return false;
} }
var da = a.TerrainDetail; var db = b.TerrainDetail; var da = a.TerrainDetail; var db = b.TerrainDetail;
float[] fa = { da.Version, da.ReliefAmpM, da.ReliefFreqIslands, da.IncK, da.IncP, da.IncCapM, da.SeaClampRaw, da.CraterExclFactor, da.ShelfIncWeight, da.ReliefSeedOffset }; float[] fa = { da.Version, da.ReliefAmpM, da.ReliefFreqIslands, da.ReliefSeedOffset, da.EdgeAmpM, da.EdgeFreqIslands, da.EdgeSeedOffset, da.EdgeMaxShiftM };
float[] fb = { db.Version, db.ReliefAmpM, db.ReliefFreqIslands, db.IncK, db.IncP, db.IncCapM, db.SeaClampRaw, db.CraterExclFactor, db.ShelfIncWeight, db.ReliefSeedOffset }; float[] fb = { db.Version, db.ReliefAmpM, db.ReliefFreqIslands, db.ReliefSeedOffset, db.EdgeAmpM, db.EdgeFreqIslands, db.EdgeSeedOffset, db.EdgeMaxShiftM };
for (int i = 0; i < fa.Length; i++) for (int i = 0; i < fa.Length; i++)
if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i])) if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i]))
{ {
@ -241,6 +242,34 @@ public partial class RoundTripHarness : Node
return true; return true;
} }
private bool CompareErosion(WorldBlueprint a, WorldBlueprint b)
{
if (a.Erosion == null && b.Erosion == null)
{
GD.Print("[Harness] EROS: absent in source — nothing to compare (and none reappeared).");
return true;
}
if (a.Erosion == null || b.Erosion == null)
{
GD.PrintErr("[Harness] EROS presence mismatch between source and reread.");
return false;
}
var ea = a.Erosion; var eb = b.Erosion;
bool same = ea.Version == eb.Version
&& ea.DropletCount == eb.DropletCount && ea.Lifetime == eb.Lifetime
&& ea.BrushRadius == eb.BrushRadius && ea.SeedOffset == eb.SeedOffset
&& ea.CraterMode == eb.CraterMode;
float[] fa = { ea.CarveCapM, ea.DepositCapM, ea.SeaMarginM, ea.Inertia, ea.CapacityFactor, ea.MinSlopeM,
ea.ErodeRate, ea.DepositRate, ea.Evaporation, ea.Gravity, ea.CraterCoreFactor, ea.CraterFeatherFactor };
float[] fb = { eb.CarveCapM, eb.DepositCapM, eb.SeaMarginM, eb.Inertia, eb.CapacityFactor, eb.MinSlopeM,
eb.ErodeRate, eb.DepositRate, eb.Evaporation, eb.Gravity, eb.CraterCoreFactor, eb.CraterFeatherFactor };
for (int i = 0; i < fa.Length; i++)
if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i])) same = false;
if (!same) { GD.PrintErr("[Harness] EROS fields differ."); return false; }
GD.Print($"[Harness] EROS equal (erosion v{ea.Version}).");
return true;
}
private bool CompareRoads(string tier, List<Vector2[]> a, List<Vector2[]> b) private bool CompareRoads(string tier, List<Vector2[]> a, List<Vector2[]> b)
{ {
if (a.Count != b.Count) if (a.Count != b.Count)

View file

@ -1,51 +1,105 @@
using Godot; using Godot;
using System;
/// <summary> /// <summary>
/// The terrain DETAIL passes (terrain-water task 10) — pure numeric array machinery /// The terrain DETAIL passes (terrain-water task 10) — pure numeric functions
/// (D-035; a named future C++ candidate, kept standalone): /// (D-035; a named future C++ candidate, kept standalone):
/// ///
/// PASS A — shelf micro-relief: a medium-frequency noise skin (±ShelfReliefAmp, /// PASS A — shelf micro-relief: a medium-frequency noise skin (±ShelfReliefAmp,
/// default 3 m) weighted by shelf-ness, so the compressed shelves get their /// default 3 m) weighted by shelf-ness, so the compressed shelves get their
/// rolling texture back while risers and peaks stay untouched. /// rolling texture back while risers and peaks stay untouched.
/// ///
/// PASS B — drainage incision: D8 steepest-descent flow routing + accumulation /// PASS B — shelf-edge variation: a per-column shift of the shelf/riser KNOT
/// over the curved terrain; depth = K · accum^p · localSlope (capped), masked to /// BLOCK (K3/K4/K5) by a low-frequency noise field, so the boundary where a
/// the risers (feathered ~30 % onto shelves, zero on the toe and above the /// shelf meets its riser wanders in and out instead of tracing a clean height
/// plateau top, zero near the crater), clamped so carved terrain never drops /// contour — organic notches, coves and peninsulas at the shelf edge.
/// below sea + 1 m. The channels double as the future river routes (Phase C).
/// ///
/// Ordering (enforced by the caller): curve → micro-relief → incision → crater /// Both are output-height only; the classify map never sees either of them.
/// carve. The classify map never sees any of it. ///
/// NOTE — what this file deliberately does NOT contain: the task-10 draft's D8
/// flow routing / accumulation / drainage incision. It shipped, produced the
/// canonical grid artifact (thousands of straight, disconnected, pooling
/// scratches along the D8 neighbour directions) and was reverted whole. Rivers
/// and erosion are Phase C work — a hydraulic-erosion pass over FINAL terrain,
/// not a per-cell steepest-descent carve on a grid.
/// </summary> /// </summary>
public static class TerrainDetailPass public static class TerrainDetailPass
{ {
public const ushort VERSION = 1; // The TDTL body version is owned by the format (Core); the pass just stamps it.
public const ushort VERSION = IslaApocalypse.Core.BlueprintFormat.TDTL_VERSION;
// Pass A — micro-relief. // Pass A — micro-relief.
public const float RELIEF_AMP_DEFAULT_M = 3f; // config dial: ShelfReliefAmp (metres) public const float RELIEF_AMP_DEFAULT_M = 3f; // config dial: ShelfReliefAmp (metres)
public const float RELIEF_FREQ_ISLANDS = 40f; // ~40 undulations per island width (~200 m features) public const float RELIEF_FREQ_ISLANDS = 40f; // ~40 undulations per island width (~200 m features)
public const int RELIEF_SEED_OFFSET = 7409; public const int RELIEF_SEED_OFFSET = 7409;
// Pass B — incision. K/p tuned against the depth targets (gullies 815 m, // Pass B — shelf-edge variation. The amplitude is stated in metres of INPUT
// trunks ~25 m, cap 30 m); the tuning run's achieved distribution is in the // height (raw × 251): it is how far, in raw-height terms, a shelf boundary
// task-10 report. // contour is displaced — not an output elevation change. What the eye sees is
public const float INC_K = 1.40f; // the LATERAL wander, which is that displacement divided by the local raw
public const float INC_P = 0.45f; // concave: many fingers, few deep trunks // gradient. Measured on seed 1375359975: |∇raw| at the K3/K4/K5 contours is
public const float INC_CAP_M = 30f; // IncisionMax // p50 0.00088 raw/px, so 12 m of input height buys a median peak displacement
public const float SEA_CLAMP = 0.15f + 1f / 251f; // carved height ≥ sea + 1 m // of ~54 px and a mean of ~8 px along the boundary — coves and notches, which
public const float SHELF_INC_WEIGHT = 0.3f; // shelves get washes, not gorges // is where the developer's sketch sits. 5 m (the first try) moved the boundary
public const float CRATER_EXCL_FACTOR = 1.2f; // zero incision inside this × CraterRadius // a mean 3.7 px and was invisible at map scale.
public const float CRATER_FEATHER_FACTOR = 1.4f; // ...feathering to full by this × CraterRadius public const float EDGE_AMP_DEFAULT_M = 12f; // config dial: ShelfEdgeVariation (metres of input height)
public const float EDGE_FREQ_ISLANDS = 20f; // ~410 px wavelength at 8K — coves and notches at the
public const int EDGE_SEED_OFFSET = 7507; // scale of the sketch, not a fringe of teeth
// The shift squeezes whichever of the foothill riser / plateau bands it moves
// into. Bounding it at 2/3 of the smaller band means that band never compresses
// below a THIRD of its nominal width — i.e. its slope never more than triples,
// even where peak noise lands on a boundary. That is the real constraint; knot
// ordering follows from it.
public const float EDGE_SAFETY_FRACTION = 2f / 3f;
// The crater carve is the final authority on its own terrain. Detail is masked
// out inside the physical carve radius (0.80 × CraterRadius — exactly where the
// carve applies) and feathers to full by 1.05 ×. Without this, detail moves a
// column's PRE-carve height, the carve's Lerp passes a fraction of that through,
// and columns sitting a metre or two above the sea inside the bowl get pushed
// under it — 532 px on seed 1158286446 in the first batch, terrain below the sea
// scalar that the (classify-driven, and correctly unchanged) water grid calls dry.
public const float CRATER_DETAIL_EXCL_FACTOR = 0.80f;
public const float CRATER_DETAIL_FEATHER_FACTOR = 1.05f;
/// <summary>
/// Detail weight from distance to the impact centre: 0 inside the carve, 1 well
/// outside it, linear between. <paramref name="craterRadius"/> is the configured
/// CraterRadius (the carve itself uses 0.80 × of it).
/// </summary>
public static float CraterDetailWeight(float distToCrater, float craterRadius)
{
float excl = craterRadius * CRATER_DETAIL_EXCL_FACTOR;
if (distToCrater <= excl) return 0f;
float feather = craterRadius * CRATER_DETAIL_FEATHER_FACTOR;
if (distToCrater >= feather) return 1f;
return (distToCrater - excl) / (feather - excl);
}
/// <summary>
/// The largest per-column knot shift this preset allows: bounded by the band
/// squeeze above, which also keeps the knot set strictly ordered
/// (K2 &lt; K3+d, K5+d &lt; K6) with a third of each band to spare. K1/K2/K6 never
/// move, so the toe, the orange/red bands and the summit spike are
/// bit-identical whatever the warp does — which is what makes the red-ceiling
/// floor and the 420 m cap exact rather than statistical.
/// </summary>
public static float MaxEdgeShift(CurveKnots k)
{
return EDGE_SAFETY_FRACTION * Mathf.Min(k.K3 - k.K2, k.K6 - k.K5);
}
/// <summary> /// <summary>
/// Shelf-ness weight from the RAW input height: 1 mid-shelf, feathering to 0 /// Shelf-ness weight from the RAW input height: 1 mid-shelf, feathering to 0
/// through the risers (feather extends 30 % of the band half-width past each /// through the risers (feather extends 30 % of the band half-width past each
/// shelf edge). Covers both shelves. /// shelf edge). Covers both shelves. <paramref name="edgeShift"/> is the same
/// per-column warp the curve is evaluated with, so the micro-relief skin
/// follows the shelf wherever pass B has moved its boundary.
/// </summary> /// </summary>
public static float ShelfWeight(float raw, CurveKnots k) public static float ShelfWeight(float raw, CurveKnots k, float edgeShift)
{ {
return Mathf.Max(BandBump(raw, k.K3, k.K4), BandBump(raw, k.K5, k.K6)); return Mathf.Max(BandBump(raw, k.K3 + edgeShift, k.K4 + edgeShift),
BandBump(raw, k.K5 + edgeShift, k.K6));
} }
private static float BandBump(float h, float lo, float hi) private static float BandBump(float h, float lo, float hi)
@ -55,89 +109,4 @@ public static class TerrainDetailPass
// full inside 60 % of the band, linear feather to zero at 130 % // full inside 60 % of the band, linear feather to zero at 130 %
return Mathf.Clamp(1f - (t - 0.6f) / 0.7f, 0f, 1f); return Mathf.Clamp(1f - (t - 0.6f) / 0.7f, 0f, 1f);
} }
/// <summary>
/// Incision mask from the RAW input height: 0 below the red-ceiling input (K2)
/// and above the plateau top (K6); 1 on the riser bands; SHELF_INC_WEIGHT on the
/// shelf bands; smooth feathers (15 % of the local band width) at every boundary.
/// </summary>
public static float IncisionWeight(float raw, CurveKnots k)
{
if (raw <= k.K2 || raw >= k.K6) return 0f;
if (raw < k.K3) // foothill riser: feather in from K2, feather toward shelf weight at K3
return EdgeBlend(raw, k.K2, k.K3, 0f, 1f, SHELF_INC_WEIGHT);
if (raw < k.K4) // bench
return SHELF_INC_WEIGHT;
if (raw < k.K5) // mid riser
return EdgeBlend(raw, k.K4, k.K5, SHELF_INC_WEIGHT, 1f, SHELF_INC_WEIGHT);
// plateau band: shelf weight, feathering to zero at K6
float w = (k.K6 - raw) / ((k.K6 - k.K5) * 0.15f);
return Mathf.Min(SHELF_INC_WEIGHT, Mathf.Clamp(w, 0f, 1f) * SHELF_INC_WEIGHT);
}
private static float EdgeBlend(float h, float lo, float hi, float wIn, float wMid, float wOut)
{
float f = (hi - lo) * 0.15f;
if (h < lo + f) return Mathf.Lerp(wIn, wMid, (h - lo) / f);
if (h > hi - f) return Mathf.Lerp(wMid, wOut, (h - (hi - f)) / f);
return wMid;
}
/// <summary>
/// D8 flow accumulation over a height field (row-major idx = x·n + y).
/// Steepest-descent routing (drop / distance, diagonals ÷√2), deterministic
/// tie-break (fixed neighbour order, first winner). Cells with no lower
/// neighbour are pits/outlets (no outflow). accum = upslope contributing cells
/// including self; steepestDrop = drop per pixel toward the chosen neighbour.
/// </summary>
public static int[] FlowAccumulation(float[] h, int n, out float[] steepestDrop)
{
int total = n * n;
int[] downstream = new int[total];
steepestDrop = new float[total];
int[] dx = { 1, -1, 0, 0, 1, 1, -1, -1 };
int[] dy = { 0, 0, 1, -1, 1, -1, 1, -1 };
float[] invDist = { 1f, 1f, 1f, 1f, 0.7071068f, 0.7071068f, 0.7071068f, 0.7071068f };
for (int x = 0; x < n; x++)
{
for (int y = 0; y < n; y++)
{
int i = x * n + y;
float hc = h[i];
float best = 0f;
int bestIdx = -1;
for (int d = 0; d < 8; d++)
{
int nx = x + dx[d], ny = y + dy[d];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
float grade = (hc - h[ni]) * invDist[d];
if (grade > best)
{
best = grade;
bestIdx = ni;
}
}
downstream[i] = bestIdx;
steepestDrop[i] = best;
}
}
// Height-descending order: each cell pushes its accumulated count downstream.
float[] keys = (float[])h.Clone();
int[] order = new int[total];
for (int i = 0; i < total; i++) order[i] = i;
Array.Sort(keys, order); // ascending
int[] accum = new int[total];
for (int i = 0; i < total; i++) accum[i] = 1;
for (int i = total - 1; i >= 0; i--)
{
int c = order[i];
int d = downstream[c];
if (d >= 0) accum[d] += accum[c];
}
return accum;
}
} }

View file

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