fuzzyworld

tower / commit

feat(mountains): summits, ridges, and valleys cut by water

e7a9f9ddc57847c0a8ac6c041dbd59591e6ec682 · Pavel Safronov · 2026-09-16 20:29:28 -0700 · parents 14d35b7d7be2

Claude Code usage: this commit ~260.3K out / ~85.3M cached · cost unknown (no per-model trailer) · session 1 tally 1.1K in / 983.4K out / 269.1M cached · usage by commit and session

feat(mountains): summits, ridges, and valleys cut by water

Phase 2 of world-generation.md (D-065).

- summits are triangulated with delaunator; the minimum spanning tree and
  some short spurs become ridges with saddles between them
- ridges raise a broad uplift field, blended by a soft maximum
- the field is eroded to the stream-power steady state over a
  priority-flood drainage network, then scaled back to the authored
  massif, so ranges keep their heights and gain branching valleys
- overlapping ranges erode as one group: the Girdle and its massifs
- named summits sit exactly at their elevation above the datum and are
  the highest ground around them; tarns keep a shelf, which puts Sisters
  Tarn at 2,173 m
- regions raise low ground after the mountains, not under them
- summits and range parameters are editable; summits drag on the map
- the terrain service runs one compile at a time and folds edits made
  during it into the next
- a new tour shot from Snowcrown's summit

Usage: 1.1K in / 983.4K out / 269.1M cached (session tally)
rtk:   3.3M saved, 37.7% over 3525 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 README.md                                      |  16 +-
 decisions.md                                   |  47 ++
 design.md                                      |  12 +-
 docs/screenshots/desktop-exterior-true.png     | Bin 489413 -> 490892 bytes
 docs/screenshots/desktop-exterior.png          | Bin 519903 -> 507282 bytes
 docs/screenshots/desktop-interior-dawn.png     | Bin 463879 -> 462721 bytes
 docs/screenshots/desktop-interior-night.png    | Bin 305481 -> 305427 bytes
 docs/screenshots/desktop-interior-overhead.png | Bin 510708 -> 510659 bytes
 docs/screenshots/desktop-interior-range.png    | Bin 0 -> 732982 bytes
 docs/screenshots/desktop-interior-suncap.png   | Bin 384139 -> 384302 bytes
 docs/screenshots/desktop-interior.png          | Bin 410017 -> 408695 bytes
 docs/screenshots/desktop-map-sightline.png     | Bin 928303 -> 915645 bytes
 docs/screenshots/desktop-map.png               | Bin 901096 -> 908594 bytes
 docs/screenshots/desktop-reader.png            | Bin 884742 -> 891868 bytes
 docs/screenshots/mobile-exterior.png           | Bin 116101 -> 114711 bytes
 docs/screenshots/mobile-interior.png           | Bin 117806 -> 116766 bytes
 docs/screenshots/mobile-panel.png              | Bin 80595 -> 80809 bytes
 docs/screenshots/overview.png                  | Bin 1198797 -> 1349204 bytes
 index.html                                     |  15 +
 package-lock.json                              |  16 +
 package.json                                   |   1 +
 src/style.css                                  |   7 +-
 src/terrain/compile.ts                         | 109 +---
 src/terrain/field.ts                           |  82 +--
 src/terrain/mountains.ts                       | 820 +++++++++++++++++++++++++
 src/terrain/service.ts                         |  42 +-
 src/ui/app.ts                                  | 132 +++-
 src/views/map.ts                               |  49 +-
 src/world/schema.ts                            |  14 +-
 src/world/store.ts                             |   8 +-
 test/terrain.test.ts                           | 128 +++-
 tools/screenshots.mjs                          |  19 +
 world-generation.md                            |  62 +-
 33 files changed, 1384 insertions(+), 195 deletions(-)

diff --git a/README.md b/README.md
index 9c15b74..72d35d2 100644
--- a/README.md
+++ b/README.md
@@ -36,12 +36,12 @@ over it, feature editing for fourteen kinds (points, lines, polygons and bands),
 localStorage autosave, backup and static exports, import, permalinks, and the
 sightline probe.
 
-**In progress: [world-generation.md](world-generation.md).** Phase 1 is built —
+**In progress: [world-generation.md](world-generation.md).** Phases 1 and 2 are built —
 the terrain is no longer baked into images by a build step; every mountain, lake,
 sea, river, desert and forest is a feature the browser turns into ground on demand,
-in web workers, from a metre underfoot to the whole world. Phases 2–8 add eroded
-mountains, water with waves and flow, ships, deserts, forests of individual trees,
-settlements and roads.
+in web workers, from a metre underfoot to the whole world, and mountain ranges are
+summits joined by ridges and cut by water into valleys. Phases 3–8 add water with waves
+and flow, ships, deserts, forests of individual trees, settlements and roads.
 
 - [design.md](design.md) — canon, physics and application architecture
 - [world-generation.md](world-generation.md) — how the world is generated, and the plan
@@ -81,6 +81,7 @@ another machine will show a large one-off divergence rather than a regression.
 | [exterior](docs/screenshots/desktop-exterior.png) | x-ray hull — the inner surface projected out, with the caps and the collector |
 | [exterior-true](docs/screenshots/desktop-exterior-true.png) | true hull — bare skin, and the land visible through the far cap window |
 | [interior](docs/screenshots/desktop-interior.png) | standing at The Narrows, the ground curving up on both sides |
+| [interior-range](docs/screenshots/desktop-interior-range.png) | from Snowcrown's summit: the Girdle's ridges and valleys running round the world |
 | [interior-overhead](docs/screenshots/desktop-interior-overhead.png) | looking up: the far side 100 km overhead is clearer than ground 100 km away |
 | [interior-dawn](docs/screenshots/desktop-interior-dawn.png) | dawn — a lit sky over dark ground, the light still 27 km away |
 | [reader](docs/screenshots/desktop-reader.png) | `?reader=1` — terrain, features, a detail card and the probe, no editing |
@@ -309,6 +310,13 @@ The rest are a single click. The ground regenerates as soon as the feature exist
 Drag markers to move them, **shift-click two points** to measure a sightline, **f**
 to fit the view.
 
+Select a **mountain range** to edit its summits: each has a name and an elevation it
+is held at exactly, **Add summit** places another with a click on the map, and a
+summit's triangle can be dragged to move it. The range's other controls set how high
+its unnamed summits stand and how many there are, how rugged it is, how deeply water
+cuts it, and its snowline. A change regenerates the range — a few seconds for the
+Girdle, under the status dialog — and edits made meanwhile are folded into the next.
+
 A **Region** is a label, and it can also be ground Tower raised: give it an uplift and
 everything inside it is lifted to that height above the open ground, wherever that is
 higher than what is there. The Uplands are one.
diff --git a/decisions.md b/decisions.md
index 6c70078..5e32171 100644
--- a/decisions.md
+++ b/decisions.md
@@ -2085,3 +2085,50 @@ generated may go deeper than 950 m below the datum.
   width over the vertex spacing, but only at vertices that landed in the channel. It
   now weighs every vertex by the share of its own cell the channel covers, so a coarse
   tile draws a river as a faint continuous line.
+
+---
+
+## D-065 — Mountains are summits, ridges and valleys water cut
+
+**Built.** `world-generation.md` phase 2, in `src/terrain/mountains.ts`; the full account
+of what changed from the plan is §16 there. In short:
+
+- Summits — authored ones held exactly, generated ones lower than the named summit near
+  them — are triangulated with **delaunator**, and the minimum spanning tree plus some
+  short spurs become ridges with saddles.
+- Ridges are raised into an uplift field with broad, gently concave flanks, blended by a
+  soft maximum so neighbouring ridges do not meet along straight creases.
+- The field is eroded to the **stream-power steady state**, computed directly over a
+  drainage network found by priority flood, rather than by iterating the implicit solver
+  the plan named. Iterating cut almost nothing in a reasonable time, and what it cut ran
+  along the grid. A roughened start, per-pass jitter, and a 1 km² channel threshold with
+  hillslope diffusion are what keep the grid from showing.
+- The steady state is scaled back to the authored field over 8 km, so the massifs keep
+  the author's heights and gain the water's structure.
+- Overlapping ranges are eroded as one group.
+
+**Named summits are the top of their ground, and that took four steps**, because every
+earlier one broke the one before: scale the surroundings down smoothly if anything is
+higher, pin the summit, trim the few cells pinning lifted past a gentle cone, pin again.
+A flat cap instead left a grey disc with a rim around every summit; a single pin left
+neighbours 9 m above Snowcrown.
+
+**A summit's elevation is above the datum.** v1 added a massif's height on top of the
+ground under it, so Snowcrown's 4,300 m stood at 4,447 m. Heights everywhere else in the
+world are above the datum, so the land under a summit is now subtracted before it is
+pinned, and the checks that it is the top compare total heights.
+
+**Tarns keep a shelf.** Eroded honestly, the ranges ran valleys through three of v1's four
+tarns and dropped them to 500 m. A cirque is exactly ground the rivers did not cut, so an
+authored bowl lake now keeps its range at the uncut height around it. Sisters Tarn is at
+2,173 m; design.md puts the ski station beside it at 2,100 m.
+
+**Regions raise ground after the mountains, not before.** Lifted first, The Uplands'
+1,000 m stacked under the Girdle's foot and put Sisters Tarn at 3,665 m.
+
+**One compile at a time.** Dragging a summit updates the world on every pointer move, and
+each would have started a four-second compile. The service now runs one, keeps only the
+latest edit made during it, and runs that next.
+
+**Cost:** a whole-world compile of about 4 s in a browser worker, most of it the Girdle.
+Loading to a drawn map is about 4 s; the status dialog shows for it.
diff --git a/design.md b/design.md
index 9df4b33..7638663 100644
--- a/design.md
+++ b/design.md
@@ -620,6 +620,12 @@ while a prograde run grips harder as it accelerates. Every slope in Tower has a
 handedness. No Earth mountain does. Settlements sit at the tarns — Sisters Station,
 Hollow Station — around 2,100 m, with the runs above them.
 
+**The range as generated** (D-065) is the named summits and a scatter of unnamed ones,
+joined by ridges and cut by water into branching valleys, all eroded as one group with
+the Girdle. The named summits are held at exactly their heights and are the highest
+ground around them; each tarn sits on a shelf the valleys were not allowed to cut, which
+puts Sisters Tarn at 2,173 m.
+
 #### Open questions
 
 - **The Spindle's height.** 3.2 km is a placeholder; an object dropped from its top
@@ -658,7 +664,8 @@ src/
                     evidence of the spin anywhere but the far cap window
   terrain/          world generation, pure TypeScript — runs in workers, on the
                     main thread and under vitest alike (world-generation.md §5)
-    compile.ts      the world compile: shapes, river profiles, lake levels, summits
+    compile.ts      the world compile: shapes, river profiles, lake levels
+    mountains.ts    summits, ridge graphs, steady-state erosion, pinned summits
     field.ts        the elevation function: one answer to "how high is it here"
     noise.ts        seeded simplex noise on the cylinder, and the hash under it
     shapes.ts       signed distance, smoothed lines, on the unrolled (z, θR) plane
@@ -1014,7 +1021,8 @@ the same reason.
 
 **Live world generation** — in progress, planned in world-generation.md (D-062).
 Phase 1 ✅ (D-063): world file v2, the elevation function, the worker pool, tiled
-ground, the status dialog, keyboard flight, the map drawn from the features. Phases 2–8
+ground, the status dialog, keyboard flight, the map drawn from the features. Phase 2 ✅
+(D-065): mountain ranges of summits, ridges and eroded valleys, and summit editing. Phases 2–8
 bring procedural scenery, settlements, and weather as Tower's published schedule of
 wind and waves — which this section used to list as out of scope. Clouds and rain stay
 undrawn (D-049).
diff --git a/docs/screenshots/desktop-exterior-true.png b/docs/screenshots/desktop-exterior-true.png
index 177726b..3947fd1 100644
Binary files a/docs/screenshots/desktop-exterior-true.png and b/docs/screenshots/desktop-exterior-true.png differ
diff --git a/docs/screenshots/desktop-exterior.png b/docs/screenshots/desktop-exterior.png
index a297bfa..6968479 100644
Binary files a/docs/screenshots/desktop-exterior.png and b/docs/screenshots/desktop-exterior.png differ
diff --git a/docs/screenshots/desktop-interior-dawn.png b/docs/screenshots/desktop-interior-dawn.png
index 75e2e9b..5a15f26 100644
Binary files a/docs/screenshots/desktop-interior-dawn.png and b/docs/screenshots/desktop-interior-dawn.png differ
diff --git a/docs/screenshots/desktop-interior-night.png b/docs/screenshots/desktop-interior-night.png
index 5b7b3d3..6a5b868 100644
Binary files a/docs/screenshots/desktop-interior-night.png and b/docs/screenshots/desktop-interior-night.png differ
diff --git a/docs/screenshots/desktop-interior-overhead.png b/docs/screenshots/desktop-interior-overhead.png
index db2e892..fdff69e 100644
Binary files a/docs/screenshots/desktop-interior-overhead.png and b/docs/screenshots/desktop-interior-overhead.png differ
diff --git a/docs/screenshots/desktop-interior-range.png b/docs/screenshots/desktop-interior-range.png
new file mode 100644
index 0000000..44c5839
Binary files /dev/null and b/docs/screenshots/desktop-interior-range.png differ
diff --git a/docs/screenshots/desktop-interior-suncap.png b/docs/screenshots/desktop-interior-suncap.png
index 8ee131e..f5f4a6c 100644
Binary files a/docs/screenshots/desktop-interior-suncap.png and b/docs/screenshots/desktop-interior-suncap.png differ
diff --git a/docs/screenshots/desktop-interior.png b/docs/screenshots/desktop-interior.png
index 41d3b9b..b12c751 100644
Binary files a/docs/screenshots/desktop-interior.png and b/docs/screenshots/desktop-interior.png differ
diff --git a/docs/screenshots/desktop-map-sightline.png b/docs/screenshots/desktop-map-sightline.png
index dedc82a..2669f43 100644
Binary files a/docs/screenshots/desktop-map-sightline.png and b/docs/screenshots/desktop-map-sightline.png differ
diff --git a/docs/screenshots/desktop-map.png b/docs/screenshots/desktop-map.png
index 2f0ec03..eb4c820 100644
Binary files a/docs/screenshots/desktop-map.png and b/docs/screenshots/desktop-map.png differ
diff --git a/docs/screenshots/desktop-reader.png b/docs/screenshots/desktop-reader.png
index 8310124..31a0ec0 100644
Binary files a/docs/screenshots/desktop-reader.png and b/docs/screenshots/desktop-reader.png differ
diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png
index 0485ef0..640e82e 100644
Binary files a/docs/screenshots/mobile-exterior.png and b/docs/screenshots/mobile-exterior.png differ
diff --git a/docs/screenshots/mobile-interior.png b/docs/screenshots/mobile-interior.png
index 8c8d284..b849ea4 100644
Binary files a/docs/screenshots/mobile-interior.png and b/docs/screenshots/mobile-interior.png differ
diff --git a/docs/screenshots/mobile-panel.png b/docs/screenshots/mobile-panel.png
index 8497ed6..56797ef 100644
Binary files a/docs/screenshots/mobile-panel.png and b/docs/screenshots/mobile-panel.png differ
diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png
index 9b29edf..1088278 100644
Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ
diff --git a/index.html b/index.html
index d31c2fa..abac372 100644
--- a/index.html
+++ b/index.html
@@ -113,6 +113,21 @@
       <summary>Selected</summary>
       <label>Name <input id="f-name" type="text" /></label>
       <label>Kind <select id="f-kind"></select></label>
+      <div id="f-mountains" hidden>
+        <p class="sub">Summits <span class="dim">— each stands at exactly its height; drag one on the map to move it</span></p>
+        <ul id="f-peaks" class="peaks"></ul>
+        <button id="f-add-peak" type="button">Add summit</button>
+        <p class="hint" id="f-add-peak-hint" hidden>Click the map to place the summit. Esc cancels.</p>
+        <label>Height of generated summits, m <input id="f-height" type="number" step="50" min="0" /></label>
+        <label>Generated summits <span id="f-gen-value" class="dim"></span>
+          <input id="f-gen" type="range" min="0" max="3" step="0.1" /></label>
+        <label>Ruggedness <span id="f-rugged-value" class="dim"></span>
+          <input id="f-rugged" type="range" min="0" max="1" step="0.05" /></label>
+        <label>Erosion <span id="f-erosion-value" class="dim"></span>
+          <input id="f-erosion" type="range" min="0" max="1" step="0.05" /></label>
+        <label>Snowline, m <input id="f-snow" type="number" step="50" /></label>
+        <p class="hint">Changing any of these regenerates the range: a few seconds for the Girdle.</p>
+      </div>
       <label>ID <span class="dim">— permalink target; follows the name until you edit it</span>
         <input id="f-id" type="text" /></label>
       <label>Public blurb <textarea id="f-blurb" rows="2" placeholder="Shown to readers."></textarea></label>
diff --git a/package-lock.json b/package-lock.json
index c4af37a..530beef 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
       "version": "0.1.0",
       "dependencies": {
         "comlink": "^4.4.2",
+        "delaunator": "^5.1.0",
         "simplex-noise": "^4.0.3",
         "three": "^0.180.0"
       },
@@ -812,6 +813,15 @@
       "integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==",
       "license": "Apache-2.0"
     },
+    "node_modules/delaunator": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
+      "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
+      "license": "ISC",
+      "dependencies": {
+        "robust-predicates": "^3.0.2"
+      }
+    },
     "node_modules/detect-libc": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1278,6 +1288,12 @@
         "node": "^10 || ^12 || >=14"
       }
     },
+    "node_modules/robust-predicates": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
+      "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
+      "license": "Unlicense"
+    },
     "node_modules/rolldown": {
       "version": "1.2.7",
       "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz",
diff --git a/package.json b/package.json
index 360fc82..66240c6 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
   "homepage": "https://pavelsafronov.gitlab.io/tower/",
   "dependencies": {
     "comlink": "^4.4.2",
+    "delaunator": "^5.1.0",
     "simplex-noise": "^4.0.3",
     "three": "^0.180.0"
   }
diff --git a/src/style.css b/src/style.css
index 267c25f..d697b7f 100644
--- a/src/style.css
+++ b/src/style.css
@@ -121,7 +121,7 @@ button.danger:hover { background: #2a1a1a; }
 #d-name { font-size: 14px; font-weight: 600; }
 label { display: block; margin-bottom: 9px; color: var(--dim); font-size: 11px; }
 label.row { display: flex; align-items: center; gap: 7px; color: var(--fg); font-size: 12px; }
-input[type=text], select, textarea {
+input[type=text], input[type=number], select, textarea {
   width: 100%; margin-top: 3px; padding: 6px 8px; border-radius: 5px;
   border: 1px solid var(--line); background: #0e1219; color: var(--fg);
   font: inherit; font-size: 12px; resize: vertical;
@@ -132,6 +132,11 @@ input[type=range] { width: 100%; margin-top: 6px; accent-color: var(--accent); }
 .links a { color: var(--accent); text-decoration: none; }
 .links a:hover { text-decoration: underline; }
 .save { font-size: 11px; color: var(--dim); margin-bottom: 10px; }
+.sub { font-size: 11px; color: var(--dim); margin: 4px 0 6px; }
+.peaks { list-style: none; margin: 0 0 8px; padding: 0; }
+.peaks li { display: grid; grid-template-columns: 1fr 76px 26px; gap: 6px; margin-bottom: 6px; align-items: center; }
+.peaks input { width: 100%; box-sizing: border-box; }
+.peaks button { padding: 4px 0; }
 .save.error { color: var(--danger); }
 
 /**
diff --git a/src/terrain/compile.ts b/src/terrain/compile.ts
index a13761a..6a959e5 100644
--- a/src/terrain/compile.ts
+++ b/src/terrain/compile.ts
@@ -14,6 +14,7 @@
  */
 import { RADIUS_M } from '../canon/constants.js'
 import type { Feature, Lake, Mountains, River, Sea, World } from '../world/schema.js'
+import { type CMountainGroup, compileMountains } from './mountains.js'
 import { hash3 } from './noise.js'
 import {
   type Box, type Line, type Shape, nearestA, shapeBox, shapeOf, signedDistance, smoothLine,
@@ -34,31 +35,6 @@ export interface CUplift {
   box: Box
 }
 
-export interface CPeak {
-  z: number
-  a: number
-  elevationM: number
-  /** Dome radius: the distance from the summit to the edge of the range. */
-  radiusM: number
-  /** Multiplier that lands the dome's highest point on `elevationM`. */
-  scale: number
-}
-
-export interface CMountains {
-  id: string
-  shape: Shape
-  peaks: CPeak[]
-  heightM: number
-  ruggedness: number
-  snowlineM: number
-  /** Deepest point inside a peakless polygon, which is where its crest stands. */
-  maxInsideM: number
-  /** Normaliser for a peakless polygon's dome. */
-  scale: number
-  seed: number
-  box: Box
-}
-
 export interface CBasin {
   id: string
   kind: 'lake' | 'sea'
@@ -95,7 +71,7 @@ export interface CompiledWorld {
   seed: number
   ground: { baseM: number; reliefM: number }
   uplifts: CUplift[]
-  mountains: CMountains[]
+  mountains: CMountainGroup[]
   basins: CBasin[]
   rivers: CRiver[]
 }
@@ -141,12 +117,20 @@ export function compileWorld(world: World, onStep: StepListener = () => {}): Com
     })
   }
 
-  for (const f of world.features) {
-    if (f.kind !== 'mountains') continue
-    onStep(`Raising ${f.name}`)
-    const m = compileMountains(f, seedOf(f), cw)
-    if (m) cw.mountains.push(m)
-  }
+  // Authored tarns keep a shelf of ground in whatever range they sit in (mountains.ts).
+  const benches = world.features.flatMap((f) => {
+    if (f.kind !== 'lake' || f.basin !== 'bowl') return []
+    const shape = shapeOf(f.geometry)
+    if (!shape || shape.type !== 'polygon') return []
+    const b = shape.box
+    return [{ z: (b.z0 + b.z1) / 2, a: (b.a0 + b.a1) / 2, radiusM: Math.max(b.z1 - b.z0, b.a1 - b.a0) / 2 }]
+  })
+  // The land a range stands on: open ground and the shelves already compiled, at the
+  // detail the observer's feet are sampled at, so a summit's elevation is exact there.
+  const land = prepare({ ...cw, mountains: [], basins: [], rivers: [] })
+  cw.mountains = compileMountains(
+    world.features.filter((f): f is Mountains => f.kind === 'mountains'), world.seed, seedOf, onStep, benches,
+    (z, a) => land.sample(a / RADIUS_M, z, 1).h)
 
   for (const f of world.features) {
     if (f.kind !== 'lake' && f.kind !== 'sea') continue
@@ -159,64 +143,6 @@ export function compileWorld(world: World, onStep: StepListener = () => {}): Com
   return cw
 }
 
-// -------------------------------------------------------------------- mountains
-
-function compileMountains(f: Mountains, seed: number, cw: CompiledWorld): CMountains | null {
-  const shape = shapeOf(f.geometry)
-  if (!shape) return null
-  const m: CMountains = {
-    id: f.id, shape, peaks: [], heightM: f.heightM, ruggedness: f.ruggedness,
-    snowlineM: f.snowlineM, maxInsideM: 0, scale: 1, seed, box: shapeBox(shape),
-  }
-  if (shape.type === 'band') {
-    // The ridge line wanders by up to 35% of the half-width (field.ts), so allow for it.
-    m.box = grow(m.box, ((shape.zTo - shape.zFrom) / 2) * 0.35)
-    return m
-  }
-
-  // Summits take their radius from how far they stand from the edge of the range.
-  for (const p of f.peaks) {
-    const a = p.theta * RADIUS_M
-    const radiusM = Math.max(2000, signedDistance(shape, p.z, a))
-    m.peaks.push({ z: p.z, a, elevationM: p.elevationM, radiusM, scale: 1 })
-  }
-  if (!m.peaks.length) m.maxInsideM = Math.max(1000, maxInside(shape))
-  // The warp moves the dome by up to 30% of its radius, so the box has to allow it.
-  m.box = grow(shape.box, Math.max(...m.peaks.map((p) => p.radiusM), m.maxInsideM) * 0.35)
-
-  /**
-   * Land the summit on its declared elevation. Ridged noise overshoots by an amount
-   * that depends on the noise, so the only honest way is to measure: sample the dome
-   * on a grid, take its highest point, and scale. The scale is a compile-time
-   * constant, so every tile still computes its own heights without asking any other.
-   * Each peak is measured alone, since a range takes the highest of its domes.
-   */
-  const highest = (probe: CMountains, z0: number, a0: number, r: number): number => {
-    const rt = prepare({ ...cw, uplifts: [], mountains: [probe], basins: [], rivers: [] })
-    const GRID = 96
-    let max = 0
-    for (let i = 0; i < GRID; i++) {
-      for (let j = 0; j < GRID; j++) {
-        const z = z0 - r + (2 * r * (i + 0.5)) / GRID
-        const a = a0 - r + (2 * r * (j + 0.5)) / GRID
-        max = Math.max(max, elevationStage(rt, a / RADIUS_M, z, 150, Stage.MountainsOnly))
-      }
-    }
-    return max
-  }
-  for (const p of m.peaks) {
-    const max = highest({ ...m, peaks: [{ ...p, scale: 1 }] }, p.z, p.a, p.radiusM)
-    p.scale = max > 0 ? p.elevationM / max : 1
-  }
-  if (!m.peaks.length) {
-    const b = shape.box
-    const max = highest({ ...m, scale: 1 }, (b.z0 + b.z1) / 2, (b.a0 + b.a1) / 2,
-      Math.max(b.z1 - b.z0, b.a1 - b.a0) / 2)
-    m.scale = max > 0 ? f.heightM / max : 1
-  }
-  return m
-}
-
 /** The greatest distance from any interior point to the edge, sampled. */
 function maxInside(shape: Shape): number {
   if (shape.type === 'band') return (shape.zTo - shape.zFrom) / 2
@@ -411,8 +337,7 @@ function compileRivers(rivers: River[], cw: CompiledWorld, onStep: StepListener)
      * fall by at least `grade` per metre everywhere. Fitting `level + grade·s` as
      * non-increasing is exactly that constraint, and the trend is taken back out after.
      */
-    const top = Math.min(cap, surface[0]!)
-    const grade = Math.min(MIN_GRADE, Math.max(0, (top - mouth) / total) * 0.5) || MIN_GRADE
+    const grade = MIN_GRADE
     /**
      * The ends are held, by weight: the mouth has to meet the water it flows into, and
      * a source in a lake has to leave at the lake's level.
diff --git a/src/terrain/field.ts b/src/terrain/field.ts
index 30d44c8..57ab746 100644
--- a/src/terrain/field.ts
+++ b/src/terrain/field.ts
@@ -10,7 +10,8 @@
  *
  *     ground                gentle relief everywhere
  *   + uplift                desert and forest shelves
- *   + mountains             the highest of any range here, on top of the land
+ *   + mountains             eroded ranges, on top of the land
+ *   ∨ raised regions        low ground lifted to a height
  *   − basins                lakes and seas, carved, with their water level
  *   ∧ rivers                valleys cut down to each river's long profile
  *
@@ -19,7 +20,8 @@
  * a metre. Same function, same answer, fewer octaves where they could not be seen.
  */
 import { RADIUS_M } from '../canon/constants.js'
-import { type CBasin, type CMountains, type CRiver, type CUplift, type CompiledWorld, EMBANKMENT_SLOPE } from './compile.js'
+import { type CBasin, type CRiver, type CUplift, type CompiledWorld, EMBANKMENT_SLOPE } from './compile.js'
+import { type CMountainGroup, pinKernel, sampleGrid } from './mountains.js'
 import { type CylPoint, cylPoint, fbm, hash3, ridged } from './noise.js'
 import { type Box, LineIndex, type Nearest, nearestA, nearestOnSegments, signedDistance } from './shapes.js'
 
@@ -125,10 +127,6 @@ export class Runtime {
     for (const u of w.uplifts) {
       if (u.kind !== 'region' && inBox(u.box, z, a)) uplift(u, p, z, a, detailM, o)
     }
-    // Raised regions last: they lift the ground *to* a height, not by one (D-064).
-    for (const u of w.uplifts) {
-      if (u.kind === 'region' && inBox(u.box, z, a)) raise(u, p, z, a, detailM, g0, o)
-    }
     let relief = 0
     for (const m of w.mountains) {
       if (!inBox(m.box, z, a)) continue
@@ -137,6 +135,11 @@ export class Runtime {
     }
     o.h += relief
     o.relief = relief
+    // Raised regions after the mountains: they lift low ground *to* a height, not by one,
+    // so they fill a lowland and leave a range's foot standing where it already is (D-064).
+    for (const u of w.uplifts) {
+      if (u.kind === 'region' && inBox(u.box, z, a)) raise(u, p, z, a, detailM, g0, o)
+    }
     for (const b of w.basins) if (inBox(b.box, z, a)) basin(b, p, z, a, detailM, o)
     if (upTo === Stage.Basins) return o
     w.rivers.forEach((r, i) => {
@@ -208,52 +211,29 @@ function raise(u: CUplift, p: CylPoint, z: number, a: number, detailM: number, g
   o.h = Math.max(o.h, g0 + m * (u.upliftM + n * u.noiseAmpM))
 }
 
-/** How high a range stands here, above the land under it. */
-function mountain(m: CMountains, p: CylPoint, z: number, a: number, detailM: number): number {
-  const s = m.shape
-  if (s.type === 'band') {
-    // A ring range: a ridge along the band's centre line, broken into peaks.
-    const half = (s.zTo - s.zFrom) / 2
-    const [wz] = warp(m.seed, p, z, a, half * 0.35, 30_000, detailM)
-    const d = Math.abs(wz - (s.zFrom + s.zTo) / 2)
-    if (d > half) return 0
-    const sigma = half / 4
-    const bump = Math.exp(-(d * d) / (2 * sigma * sigma))
-    let scale = 1 + 0.25 * fbm(layer(m.seed, 1), p, 5000, 3, detailM)
-    if (m.ruggedness > 0) {
-      const rg = ridged(layer(m.seed, 2), p, 14_000, 6, detailM)
-      scale *= 1 - m.ruggedness + m.ruggedness * rg * 1.6
-    }
-    return bump * m.heightM * scale
-  }
-
-  const dome = (dd: number, wp: CylPoint): number => {
-    const shape = Math.cos((dd * Math.PI) / 2) ** 1.7
-    const rg = ridged(layer(m.seed, 3), wp, 15_000, 6, detailM)
-    // Ridges bite hardest on the flanks and least at the summit, which is what stops
-    // a massif reading as a smooth blister.
-    const flank = Math.sin(dd * Math.PI) * 0.8 + 0.2
-    return shape * (1 - m.ruggedness * flank + m.ruggedness * flank * rg * 1.9)
-  }
-
-  if (!m.peaks.length) {
-    const [wz, wa] = warp(m.seed, p, z, a, m.maxInsideM * 0.3, m.maxInsideM * 0.9, detailM)
-    const sd = signedDistance(s, wz, wa)
-    if (sd <= 0) return 0
-    const dd = 1 - Math.min(1, sd / m.maxInsideM)
-    return m.heightM * m.scale * dome(dd, cylPoint(wa / RADIUS_M, wz))
-  }
-
-  let best = 0
-  for (const pk of m.peaks) {
-    const reach = pk.radiusM * 1.35
-    if (Math.abs(z - pk.z) > reach || Math.abs(nearestA(a, pk.a) - pk.a) > reach) continue
-    const [wz, wa] = warp(m.seed, p, z, a, pk.radiusM * 0.3, pk.radiusM * 0.87, detailM)
-    const dd = Math.hypot(wz - pk.z, nearestA(wa, pk.a) - pk.a) / pk.radiusM
-    if (dd >= 1) continue
-    best = Math.max(best, pk.elevationM * pk.scale * dome(dd, cylPoint(wa / RADIUS_M, wz)))
+/**
+ * How high a range stands here, above the land under it: the eroded relief grid
+ * (mountains.ts), and the detail no 200 m grid can hold laid over it.
+ *
+ * The detail scales with the relief, so a valley floor stays smooth and a crest breaks
+ * into rock. It is kept off the authored summits, which were pinned to their exact
+ * height and would otherwise gain or lose a few tens of metres to the noise.
+ */
+function mountain(m: CMountainGroup, p: CylPoint, z: number, a: number, detailM: number): number {
+  const r = sampleGrid(m, m.relief, z, a)
+  if (r <= 0) return 0
+  let clear = 1
+  for (const s of m.summits) {
+    const d = Math.hypot(z - s.z, nearestA(a, s.a) - s.a)
+    clear = Math.min(clear, 1 - pinKernel(d))
   }
-  return best
+  if (clear <= 0) return r
+  const rock = (ridged(layer(m.seed, 3), p, 1600, 4, detailM) - 0.47) * 2
+  const grain = fbm(layer(m.seed, 4), p, 180, 4, detailM)
+  // Kept small: at a few percent of the relief the grid's 200 m facets break up; much more
+  // and every slope turns to lumps under an overhead light.
+  const detail = r * ((0.008 + 0.014 * m.ruggedness) * rock + 0.0025 * grain)
+  return Math.max(0, r + detail * clear)
 }
 
 /** Carve a lake or sea basin and set its water where the floor is below its level. */
diff --git a/src/terrain/mountains.ts b/src/terrain/mountains.ts
new file mode 100644
index 0000000..1f01f87
--- /dev/null
+++ b/src/terrain/mountains.ts
@@ -0,0 +1,820 @@
+/**
+ * Mountains: summits, the ridges between them, and the valleys water cuts into them.
+ * See world-generation.md §4.1.
+ *
+ * v1 made a mountain as a dome with ridged noise laid over it. That gives one summit
+ * somewhere near the middle of a blister and nothing a person would call a ridge,
+ * a saddle or a valley. This builds a range the way the landform is actually read:
+ *
+ *  1. **Summits.** The authored peaks, plus generated ones scattered through the range,
+ *     each lower than the named summit it stands near.
+ *  2. **A ridge graph.** The summits are triangulated, and the minimum spanning tree of
+ *     the triangulation — with a few of the remaining short edges as spurs — becomes the
+ *     ridge lines. Each ridge dips to a saddle between its two summits.
+ *  3. **An uplift field.** Height falls away from every ridge along a concave flank,
+ *     blended between neighbouring ridges rather than cut off where they meet.
+ *  4. **Erosion to steady state.** Treating that field as the rate the ground is being
+ *     raised, the stream-power law has a steady state that can be computed directly
+ *     over a drainage network — each cell stands above the cell it drains to by just
+ *     enough slope to carry away what rises — and that state is what a range worn by
+ *     water for a long time looks like: branching valleys, sharp divides. It is then
+ *     scaled back to the massif's authored shape, so the Girdle stays 2,900 m high.
+ *  5. **Pinning.** Every authored summit is put back at exactly its elevation.
+ *
+ * Ranges that overlap are one group, eroded together, so the Girdle and the four named
+ * massifs standing on it make one drainage rather than five that meet at a seam.
+ *
+ * The result is a grid of relief, plain data in the compiled world. Tiles sample it and
+ * add fine detail on top (field.ts), so no tile ever needs to know about another.
+ */
+import Delaunator from 'delaunator'
+import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js'
+import type { Mountains } from '../world/schema.js'
+import { hash3, mulberry32, simplex } from './noise.js'
+import { type Box, type Shape, nearestA, shapeBox, shapeOf, signedDistance } from './shapes.js'
+
+const C = CIRCUMFERENCE_M
+
+export interface CPeak {
+  z: number
+  a: number
+  elevationM: number
+  /** True for a summit the author placed; generated summits are free to move. */
+  authored: boolean
+}
+
+export interface CMountainGroup {
+  /** The features eroded together. The first is the one the status dialog names. */
+  ids: string[]
+  name: string
+  /** True when the group rings the world, so its grid wraps around theta. */
+  ring: boolean
+  /** Grid origin, spacing and size. Row `i` is z, column `j` is a. */
+  z0: number
+  a0: number
+  cell: number
+  nz: number
+  na: number
+  /** Relief above the land under the range, metres, one per cell. */
+  relief: Float32Array
+  /** Every summit, authored and generated. */
+  peaks: CPeak[]
+  /**
+   * Just the authored ones, with `elevationM` as height above the land under them. Fine
+   * detail is kept off them so they stay at their height.
+   */
+  summits: CPeak[]
+  ruggedness: number
+  snowlineM: number
+  seed: number
+  box: Box
+}
+
+/** Grid spacing. Finer than the 264 m v1 raster; tiles add the detail below it. */
+export const MOUNTAIN_CELL_M = 200
+
+interface Member {
+  f: Mountains
+  shape: Shape
+  box: Box
+  maxInside: number
+}
+
+// -------------------------------------------------------------------- grouping
+
+/** Overlapping ranges, as groups of members. A ring overlaps anything it spans in z. */
+function groups(members: Member[]): Member[][] {
+  const parent = members.map((_, i) => i)
+  const find = (i: number): number => (parent[i] === i ? i : (parent[i] = find(parent[i]!)))
+  const overlaps = (p: Box, q: Box): boolean => {
+    if (p.z1 < q.z0 || q.z1 < p.z0) return false
+    if (!Number.isFinite(p.a0) || !Number.isFinite(q.a0)) return true
+    const shift = nearestA((q.a0 + q.a1) / 2, (p.a0 + p.a1) / 2) - (q.a0 + q.a1) / 2
+    return !(p.a1 < q.a0 + shift || q.a1 + shift < p.a0)
+  }
+  for (let i = 0; i < members.length; i++) {
+    for (let j = i + 1; j < members.length; j++) {
+      if (overlaps(members[i]!.box, members[j]!.box)) parent[find(i)] = find(j)
+    }
+  }
+  const byRoot = new Map<number, Member[]>()
+  members.forEach((m, i) => {
+    const r = find(i)
+    byRoot.set(r, [...(byRoot.get(r) ?? []), m])
+  })
+  return [...byRoot.values()]
+}
+
+/** The greatest distance from any interior point to the edge, sampled. */
+function maxInside(shape: Shape): number {
+  if (shape.type === 'band') return (shape.zTo - shape.zFrom) / 2
+  const b = shape.box
+  const N = 48
+  let best = 0
+  for (let i = 0; i < N; i++) {
+    for (let j = 0; j < N; j++) {
+      best = Math.max(best, signedDistance(shape,
+        b.z0 + ((b.z1 - b.z0) * (i + 0.5)) / N, b.a0 + ((b.a1 - b.a0) * (j + 0.5)) / N))
+    }
+  }
+  return best
+}
+
+/**
+ * How much a member claims a point, 0..1: full well inside, fading out past its edge.
+ * A ring's claim is its distance from the band's centre line, since a ring range is a
+ * ridge along it rather than a plateau filling it.
+ */
+function claim(m: Member, z: number, a: number): number {
+  if (m.shape.type === 'band') {
+    const half = (m.shape.zTo - m.shape.zFrom) / 2
+    const d = Math.abs(z - (m.shape.zFrom + m.shape.zTo) / 2)
+    return smoothstep(half, half * 0.35, d)
+  }
+  return smoothstep(-2000, Math.min(6000, m.maxInside * 0.4), signedDistance(m.shape, z, a))
+}
+
+const smoothstep = (e0: number, e1: number, x: number): number => {
+  const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0)))
+  return t * t * (3 - 2 * t)
+}
+
+// ---------------------------------------------------------------------- summits
+
+/**
+ * Generated summits for one range, on a jittered grid so they are spread without
+ * clumping. A ring's run along its centre line, a little off it either side; a massif's
+ * fill its outline and stand taller toward its middle.
+ */
+function generatedPeaks(m: Member, rand: () => number): CPeak[] {
+  const out: CPeak[] = []
+  const density = m.f.generatedPeaks / 100e6 // per m²
+  if (density <= 0) return out
+  const spacing = 1 / Math.sqrt(density)
+  if (m.shape.type === 'band') {
+    const zc = (m.shape.zFrom + m.shape.zTo) / 2
+    const half = (m.shape.zTo - m.shape.zFrom) / 2
+    // Along a ring the summits sit roughly a spacing apart around the circumference.
+    const count = Math.max(3, Math.round(C / spacing))
+    for (let k = 0; k < count; k++) {
+      const a = ((k + 0.2 + 0.6 * rand()) / count) * C
+      const z = zc + (rand() - 0.5) * half * 0.5
+      out.push({ z, a, elevationM: m.f.heightM * (0.55 + 0.45 * rand()), authored: false })
+    }
+    return out
+  }
+  const b = m.shape.box
+  for (let z = b.z0; z < b.z1; z += spacing) {
+    for (let a = b.a0; a < b.a1; a += spacing) {
+      const pz = z + rand() * spacing
+      const pa = a + rand() * spacing
+      const sd = signedDistance(m.shape, pz, pa)
+      if (sd < m.maxInside * 0.2) continue
+      const depth = Math.min(1, sd / m.maxInside)
+      out.push({ z: pz, a: pa, elevationM: m.f.heightM * (0.35 + 0.45 * depth) * (0.75 + 0.25 * rand()), authored: false })
+    }
+  }
+  return out
+}
+
+// ----------------------------------------------------------------- ridge graph
+
+interface Ridge { p: number; q: number; saddle: number }
+
+/**
+ * Ridges between summits: the minimum spanning tree of their Delaunay triangulation,
+ * which connects every summit by its shortest links, plus the shortest of the leftover
+ * edges as spurs, so a range has branches and the odd enclosed cirque rather than one
+ * long line. Rings are triangulated with a copy of the seam's summits a circumference
+ * away, so ridges cross theta = 0 like anywhere else.
+ */
+function ridges(peaks: CPeak[], ring: boolean, rand: () => number, ruggedness: number): Ridge[] {
+  const n = peaks.length
+  if (n < 2) return []
+  const coords: number[] = []
+  const owner: number[] = []
+  peaks.forEach((p, i) => { coords.push(p.z, p.a); owner.push(i) })
+  if (ring) {
+    peaks.forEach((p, i) => {
+      if (p.a < C * 0.25) { coords.push(p.z, p.a + C); owner.push(i) }
+      if (p.a > C * 0.75) { coords.push(p.z, p.a - C); owner.push(i) }
+    })
+  }
+  const d = new Delaunator(Float64Array.from(coords))
+  const edges = new Map<string, { p: number; q: number; len: number }>()
+  for (let e = 0; e < d.triangles.length; e++) {
+    const i = d.triangles[e]!
+    const j = d.triangles[e % 3 === 2 ? e - 2 : e + 1]!
+    const p = owner[i]!, q = owner[j]!
+    if (p === q) continue
+    const key = p < q ? `${p}:${q}` : `${q}:${p}`
+    const len = Math.hypot(coords[2 * i]! - coords[2 * j]!, coords[2 * i + 1]! - coords[2 * j + 1]!)
+    const had = edges.get(key)
+    if (!had || len < had.len) edges.set(key, { p: Math.min(p, q), q: Math.max(p, q), len })
+  }
+  // Kruskal. Links between two high summits are cheaper, so ridges follow the high
+  // ground rather than wandering down to a low summit and back up.
+  const sorted = [...edges.values()].sort((x, y) => cost(x) - cost(y))
+  function cost(e: { p: number; q: number; len: number }): number {
+    return e.len / (0.5 + Math.min(peaks[e.p]!.elevationM, peaks[e.q]!.elevationM) / 4000)
+  }
+  const parent = peaks.map((_, i) => i)
+  const find = (i: number): number => (parent[i] === i ? i : (parent[i] = find(parent[i]!)))
+  const out: Ridge[] = []
+  const tree = new Set<string>()
+  for (const e of sorted) {
+    const a = find(e.p), b = find(e.q)
+    if (a === b) continue
+    parent[a] = b
+    tree.add(`${e.p}:${e.q}`)
+    out.push(ridge(e))
+  }
+  const treeLens = out.length ? sorted.filter((e) => tree.has(`${e.p}:${e.q}`)).map((e) => e.len) : [0]
+  const median = [...treeLens].sort((x, y) => x - y)[Math.floor(treeLens.length / 2)]!
+  for (const e of sorted) {
+    if (tree.has(`${e.p}:${e.q}`)) continue
+    if (e.len < median * 1.25 && rand() < 0.25 + 0.35 * ruggedness) out.push(ridge(e))
+  }
+  return out
+
+  function ridge(e: { p: number; q: number; len: number }): Ridge {
+    const low = Math.min(peaks[e.p]!.elevationM, peaks[e.q]!.elevationM)
+    // A saddle is deeper on a long ridge; never so deep that the ridge disappears.
+    const drop = Math.min(low * 0.45, e.len * (0.015 + 0.02 * rand()))
+    return { p: e.p, q: e.q, saddle: low - drop }
+  }
+}
+
+// ---------------------------------------------------------------- uplift field
+
+/**
+ * Height a ridge or summit gives a cell: the crest height at the nearest point along it,
+ * falling away across a concave flank — steep under the crest, easing out at the foot.
+ * The flank's reach scales with the crest's height, so high ridges have broad roots.
+ */
+function flank(crest: number, d: number): number {
+  const reach = crest / FLANK_SLOPE
+  if (d >= reach) return 0
+  return crest * (1 - d / reach) ** 1.2
+}
+/**
+ * Crest height over flank reach: the mean slope from crest to foot, about 9°. A 4,300 m
+ * summit has roots 27 km out and still stands 2,000 m high 12 km from its top, which is
+ * the size of v1's massifs and where their tarns were. The flanks are only gently
+ * concave; the steepness a range actually has comes from erosion cutting into them.
+ */
+const FLANK_SLOPE = 0.16
+
+function upliftField(
+  grid: Grid, peaks: CPeak[], edges: Ridge[], members: Member[], rand: () => number, seed: number,
+): Float32Array {
+  /**
+   * The ridges are combined by a soft maximum — the fourth root of the sum of fourth
+   * powers — rather than a hard one. A hard maximum of cone-like flanks meets its
+   * neighbour along a straight crease, and a range drawn that way reads as a folded
+   * paper model. The distance to each ridge is also nudged by noise, so flanks wander.
+   */
+  const acc = new Float32Array(grid.nz * grid.na)
+  const wobble = simplex(hash3(seed, 17, 0))
+  const splat = (za: number, aa: number, zb: number, ab: number, h: (t: number) => number, crestMax: number): void => {
+    const reach = crestMax / FLANK_SLOPE
+    const zLo = Math.min(za, zb) - reach, zHi = Math.max(za, zb) + reach
+    const aLo = Math.min(aa, ab) - reach, aHi = Math.max(aa, ab) + reach
+    const i0 = Math.max(0, Math.floor((zLo - grid.z0) / grid.cell))
+    const i1 = Math.min(grid.nz - 1, Math.ceil((zHi - grid.z0) / grid.cell))
+    const j0 = Math.floor((aLo - grid.a0) / grid.cell)
+    const j1 = Math.ceil((aHi - grid.a0) / grid.cell)
+    const ez = zb - za, ea = ab - aa
+    const len2 = ez * ez + ea * ea
+    for (let i = i0; i <= i1; i++) {
+      const z = grid.z0 + i * grid.cell
+      for (let jj = j0; jj <= j1; jj++) {
+        let j = jj
+        if (grid.ring) j = ((jj % grid.na) + grid.na) % grid.na
+        else if (jj < 0 || jj >= grid.na) continue
+        const a = grid.a0 + jj * grid.cell
+        const t = len2 > 0 ? Math.max(0, Math.min(1, ((z - za) * ez + (a - aa) * ea) / len2)) : 0
+        const d = Math.hypot(z - (za + ez * t), a - (aa + ea * t))
+        const jitter = 1 + 0.3 * wobble(z / 9000, a / 9000, 0)
+        const v = flank(h(t), d * jitter)
+        if (v <= 0) continue
+        const k = i * grid.na + j
+        const v2 = v * v
+        acc[k]! += v2 * v2
+      }
+    }
+  }
+
+  for (const r of edges) {
+    const p = peaks[r.p]!, q = peaks[r.q]!
+    // Unwrap across the seam so a ridge takes the short way round.
+    const qa = grid.ring ? nearestA(q.a, p.a) : q.a
+    // A little asymmetry in where the saddle falls, so ridges are not all symmetric.
+    const mid = 0.35 + 0.3 * rand()
+    const h = (t: number): number => {
+      const base = p.elevationM + (q.elevationM - p.elevationM) * t
+      const dip = t < mid ? Math.sin((t / mid) * (Math.PI / 2)) : Math.sin(((1 - t) / (1 - mid)) * (Math.PI / 2))
+      const saddleBase = p.elevationM + (q.elevationM - p.elevationM) * mid
+      return base - (saddleBase - r.saddle) * dip * dip
+    }
+    splat(p.z, p.a, q.z, qa, h, Math.max(p.elevationM, q.elevationM))
+  }
+  // Every summit stands on its own, too, so a lone peak and a ridge's end both have a top.
+  for (const p of peaks) splat(p.z, p.a, p.z, p.a, () => p.elevationM, p.elevationM)
+
+  const out = new Float32Array(grid.nz * grid.na)
+  for (let k = 0; k < out.length; k++) out[k] = Math.sqrt(Math.sqrt(acc[k]!))
+
+  // Fade out past the edge of every member's claim. Each member only visits its own box.
+  const claimed = new Float32Array(grid.nz * grid.na)
+  for (const m of members) {
+    const i0 = Math.max(0, Math.floor((m.box.z0 - grid.z0) / grid.cell))
+    const i1 = Math.min(grid.nz - 1, Math.ceil((m.box.z1 - grid.z0) / grid.cell))
+    let j0 = 0, j1 = grid.na - 1
+    if (Number.isFinite(m.box.a0) && !grid.ring) {
+      const shift = nearestA((m.box.a0 + m.box.a1) / 2, grid.a0 + (grid.na * grid.cell) / 2) - (m.box.a0 + m.box.a1) / 2
+      j0 = Math.max(0, Math.floor((m.box.a0 + shift - grid.a0) / grid.cell))
+      j1 = Math.min(grid.na - 1, Math.ceil((m.box.a1 + shift - grid.a0) / grid.cell))
+    }
+    const ring = grid.ring && Number.isFinite(m.box.a0)
+    const jStart = ring ? Math.floor((m.box.a0 - grid.a0) / grid.cell) : j0
+    const jEnd = ring ? Math.ceil((m.box.a1 - grid.a0) / grid.cell) : j1
+    for (let i = i0; i <= i1; i++) {
+      const z = grid.z0 + i * grid.cell
+      for (let jj = jStart; jj <= jEnd; jj++) {
+        const j = ring ? ((jj % grid.na) + grid.na) % grid.na : jj
+        const k = i * grid.na + j
+        const c = claim(m, z, grid.a0 + jj * grid.cell)
+        if (c > claimed[k]!) claimed[k] = c
+      }
+    }
+  }
+  for (let k = 0; k < out.length; k++) out[k]! *= claimed[k]!
+  return out
+}
+
+// --------------------------------------------------------------------- erosion
+
+export interface Grid {
+  z0: number
+  a0: number
+  cell: number
+  nz: number
+  na: number
+  ring: boolean
+}
+
+/** A binary min-heap of cell indices keyed by height, on typed arrays. */
+class Heap {
+  private keys: Float64Array
+  private ids: Int32Array
+  size = 0
+  constructor(capacity: number) {
+    this.keys = new Float64Array(capacity)
+    this.ids = new Int32Array(capacity)
+  }
+  push(key: number, id: number): void {
+    let i = this.size++
+    while (i > 0) {
+      const parent = (i - 1) >> 1
+      if (this.keys[parent]! <= key) break
+      this.keys[i] = this.keys[parent]!
+      this.ids[i] = this.ids[parent]!
+      i = parent
+    }
+    this.keys[i] = key
+    this.ids[i] = id
+  }
+  pop(): number {
+    const top = this.ids[0]!
+    const key = this.keys[--this.size]!
+    const id = this.ids[this.size]!
+    let i = 0
+    for (;;) {
+      let child = 2 * i + 1
+      if (child >= this.size) break
+      if (child + 1 < this.size && this.keys[child + 1]! < this.keys[child]!) child++
+      if (this.keys[child]! >= key) break
+      this.keys[i] = this.keys[child]!
+      this.ids[i] = this.ids[child]!
+      i = child
+    }
+    this.keys[i] = key
+    this.ids[i] = id
+    return top
+  }
+}
+
+/** Drainage area past which ground is a channel rather than hillslope: 1 km². */
+const CHANNEL_AREA_M2 = 1e6
+/** Hillslope gradient at full uplift. The rescaling to the authored shape sets heights. */
+const STEADY_SLOPE = 0.6
+
+/** Priority flood from the outlets: receivers, and cells in order from the outlets up. */
+function drainage(grid: Grid, h: Float32Array, outlet: Uint8Array, out: {
+  receiver: Int32Array; stack: Int32Array; reached: Uint8Array; key: Float64Array; heap: Heap
+}): number {
+  const n = grid.nz * grid.na
+  const { receiver, stack, reached, key, heap } = out
+  reached.fill(0)
+  heap.size = 0
+  for (let k = 0; k < n; k++) {
+    if (!outlet[k]) continue
+    reached[k] = 1
+    receiver[k] = -1
+    key[k] = h[k]!
+    heap.push(h[k]!, k)
+  }
+  let top = 0
+  while (heap.size) {
+    const c = heap.pop()
+    stack[top++] = c
+    const ci = Math.floor(c / grid.na), cj = c % grid.na
+    for (let e = 0; e < 8; e++) {
+      const ni = ci + DI[e]!
+      if (ni < 0 || ni >= grid.nz) continue
+      let nj = cj + DJ[e]!
+      if (grid.ring) nj = (nj + grid.na) % grid.na
+      else if (nj < 0 || nj >= grid.na) continue
+      const nk = ni * grid.na + nj
+      if (reached[nk]) continue
+      reached[nk] = 1
+      receiver[nk] = c
+      key[nk] = Math.max(h[nk]!, key[c]!)
+      heap.push(key[nk]!, nk)
+    }
+  }
+  return top
+}
+const DI = [-1, -1, -1, 0, 0, 1, 1, 1]
+const DJ = [-1, 0, 1, -1, 1, -1, 0, 1]
+
+/**
+ * Erode to the stream-power steady state, and scale it back to the authored shape.
+ *
+ * Under E = K·A^m·S with n = 1, ground raised at rate U settles where erosion keeps
+ * pace: slope S = U / (K·A^m). Over a drainage network that gives every cell's height
+ * directly — its receiver's height plus the run between them times that slope — in one
+ * pass up from the outlets. Divides drain almost nothing, so their slope is steep and
+ * they stand high; valleys drain a great deal and run nearly level. That is the branching
+ * the uplift field alone never has. The network depends on the heights, so the pass is
+ * repeated a few times with the network re-found by priority flood each time
+ * (Cordonnier et al., 2016).
+ *
+ * The steady state's heights have their own scale. So it is scaled, cell by cell, by the
+ * ratio of the authored field to the steady state, both blurred over a few kilometres:
+ * the broad shape comes back — the Girdle 2,900 m high, Snowcrown's massif 4,300 m — and
+ * the ridges and valleys within it are the water's. `strength` blends the result with the
+ * uncut field.
+ */
+function erode(grid: Grid, field: Float32Array, outlet: Uint8Array, strength: number, seed: number): Float32Array {
+  const n = grid.nz * grid.na
+  if (strength <= 0) return field
+  const work = {
+    receiver: new Int32Array(n), stack: new Int32Array(n), reached: new Uint8Array(n),
+    key: new Float64Array(n), heap: new Heap(n),
+  }
+  const area = new Float64Array(n)
+  /**
+   * Start from the field roughened at valley scales. Water crossing a smooth plane takes
+   * one direction everywhere and cuts parallel gullies along the grid's diagonals; a
+   * few kilometres of unevenness is what lets streams find each other and join.
+   */
+  const rough = simplex(hash3(seed, 29, 0))
+  const h = new Float32Array(n)
+  for (let i = 0; i < grid.nz; i++) {
+    for (let j = 0; j < grid.na; j++) {
+      const k = i * grid.na + j
+      const z = i * grid.cell, a = j * grid.cell
+      const wob = rough(z / 6000, a / 6000, 1.7) * 0.6 + rough(z / 2200, a / 2200, 5.3) * 0.4
+      h[k] = field[k]! * (1 + 0.18 * wob)
+    }
+  }
+  const jitter = new Float32Array(n)
+  const cellArea = grid.cell * grid.cell
+  // Uplift rate, scaled to 0..1 by the highest field value. The absolute rate only sets
+  // the steady state's overall scale, which the rescaling below replaces anyway.
+  let peak = 0
+  for (let k = 0; k < n; k++) peak = Math.max(peak, field[k]!)
+  if (peak <= 0) return field
+
+  for (let pass = 0; pass < 8; pass++) {
+    // A different small jitter each pass, so ties on flat ground break a new way and the
+    // network never settles onto the grid's own axes.
+    const jr = mulberry32(hash3(seed, 31, pass))
+    for (let k = 0; k < n; k++) jitter[k] = h[k]! + (jr() - 0.5) * grid.cell * 0.15
+    const top = drainage(grid, jitter, outlet, work)
+    const { receiver, stack } = work
+    for (let s = 0; s < top; s++) area[stack[s]!] = cellArea
+    for (let s = top - 1; s >= 0; s--) {
+      const c = stack[s]!, r = receiver[c]!
+      if (r >= 0) area[r]! += area[c]!
+    }
+    for (let s = 0; s < top; s++) {
+      const c = stack[s]!, r = receiver[c]!
+      if (r < 0) { h[c] = 0; continue }
+      const ci = Math.floor(c / grid.na), ri = Math.floor(r / grid.na)
+      let dj = (r % grid.na) - (c % grid.na)
+      if (grid.ring && Math.abs(dj) > 1) dj = -Math.sign(dj)
+      const run = grid.cell * Math.hypot(ri - ci, dj)
+      const u = field[c]! / peak
+      /**
+       * Channels only begin past a critical drainage area. Below it the ground is
+       * hillslope, and runs at the slope the threshold gives rather than steepening to
+       * every single-cell catchment — without it each cell is its own gully and the
+       * whole range is ribbed in parallel grooves one cell wide.
+       */
+      const a = Math.max(area[c]!, CHANNEL_AREA_M2)
+      h[c] = h[r]! + run * u * STEADY_SLOPE * Math.sqrt(CHANNEL_AREA_M2 / a)
+    }
+    // Hillslope diffusion: soil creep rounds what water leaves sharp.
+    const soft = blur(grid, h, 1)
+    for (let k = 0; k < n; k++) if (!outlet[k]) h[k] = h[k]! * 0.5 + soft[k]! * 0.5
+  }
+
+  // Over 8 km: the massifs' shape is the author's; the ridges and valleys within it are the water's.
+  const blurField = blur(grid, field, 40)
+  const blurSteady = blur(grid, h, 40)
+  const out = new Float32Array(n)
+  for (let k = 0; k < n; k++) {
+    const scaled = blurSteady[k]! > 1e-3 ? h[k]! * (blurField[k]! / blurSteady[k]!) : 0
+    out[k] = field[k]! + (scaled - field[k]!) * strength
+  }
+  return out
+}
+
+/** Separable box blur, `radius` cells, three passes each way — close to a Gaussian. */
+function blur(grid: Grid, src: Float32Array, radius: number): Float32Array {
+  let a = Float32Array.from(src)
+  let b = new Float32Array(src.length)
+  const { nz, na } = grid
+  const width = 2 * radius + 1
+  for (let pass = 0; pass < 3; pass++) {
+    for (let i = 0; i < nz; i++) {
+      let sum = 0
+      for (let d = -radius; d <= radius; d++) {
+        const j = grid.ring ? ((d % na) + na) % na : Math.min(na - 1, Math.max(0, d))
+        sum += a[i * na + j]!
+      }
+      for (let j = 0; j < na; j++) {
+        b[i * na + j] = sum / width
+        const outJ = grid.ring ? ((j - radius) % na + na) % na : Math.max(0, j - radius)
+        const inJ = grid.ring ? (j + radius + 1) % na : Math.min(na - 1, j + radius + 1)
+        sum += a[i * na + inJ]! - a[i * na + outJ]!
+      }
+    }
+    ;[a, b] = [b, a]
+    for (let j = 0; j < na; j++) {
+      let sum = 0
+      for (let d = -radius; d <= radius; d++) sum += a[Math.min(nz - 1, Math.max(0, d)) * na + j]!
+      for (let i = 0; i < nz; i++) {
+        b[i * na + j] = sum / width
+        sum += a[Math.min(nz - 1, i + radius + 1) * na + j]! - a[Math.max(0, i - radius) * na + j]!
+      }
+    }
+    ;[a, b] = [b, a]
+  }
+  return a
+}
+
+// ------------------------------------------------------------------ the compile
+
+/** A place a range must keep a shelf of ground: a cirque for an authored tarn. */
+export interface Bench { z: number; a: number; radiusM: number }
+
+/**
+ * `landAt` is the height of the ground a range stands on, before any range: authored
+ * summit elevations are above the datum, like every other height in the world, so each
+ * summit is raised to its elevation less the land under it.
+ */
+export function compileMountains(
+  features: Mountains[], worldSeed: number, seedOf: (f: Mountains) => number,
+  onStep: (label: string) => void, benches: Bench[] = [], landAt: (z: number, a: number) => number = () => 0,
+): CMountainGroup[] {
+  const members: Member[] = []
+  for (const f of features) {
+    onStep(`Raising ${f.name}`)
+    const shape = shapeOf(f.geometry)
+    if (!shape) continue
+    const inside = maxInside(shape)
+    const reach = shape.type === 'band' ? 0 : Math.max(f.heightM, ...f.peaks.map((p) => p.elevationM)) / FLANK_SLOPE * 0.3
+    const box = shapeBox(shape)
+    members.push({ f, shape, maxInside: inside, box: { z0: box.z0 - reach, z1: box.z1 + reach, a0: box.a0 - reach, a1: box.a1 + reach } })
+  }
+
+  return groups(members).map((ms) => {
+    // The largest range names the group, so the Girdle and not a summit on it is eroded.
+    ms.sort((x, y) => area(y) - area(x))
+    const lead = ms[0]!
+    const seed = hash3(worldSeed, seedOf(lead.f), 0x4d7)
+    const rand = mulberry32(seed)
+    const ring = ms.some((m) => m.shape.type === 'band')
+    const cell = MOUNTAIN_CELL_M
+
+    const z0 = Math.max(0, Math.min(...ms.map((m) => m.box.z0)) - cell * 2)
+    const z1 = Math.max(...ms.map((m) => m.box.z1)) + cell * 2
+    let a0 = 0, a1 = C
+    if (!ring) {
+      // Unwrap every box to the lead's side of the seam before taking the extent.
+      const ref = (lead.box.a0 + lead.box.a1) / 2
+      const spans = ms.map((m) => {
+        const shift = nearestA((m.box.a0 + m.box.a1) / 2, ref) - (m.box.a0 + m.box.a1) / 2
+        return [m.box.a0 + shift, m.box.a1 + shift] as const
+      })
+      a0 = Math.min(...spans.map((s) => s[0])) - cell * 2
+      a1 = Math.max(...spans.map((s) => s[1])) + cell * 2
+    }
+    const nz = Math.ceil((z1 - z0) / cell) + 1
+    const na = ring ? Math.round(C / cell) : Math.ceil((a1 - a0) / cell) + 1
+    // A ring's cells are stretched a hair so a whole number of them closes the circle.
+    const grid: Grid = { z0, a0, cell: ring ? C / na : cell, nz, na, ring }
+
+    // Summits: authored first, then generated ones kept below the named summits near them.
+    // Summits carry their height above the land under them from here on: that is what
+    // the relief grid holds.
+    const authored: CPeak[] = ms.flatMap((m) => m.f.peaks.map((p) => ({
+      z: p.z, a: ring ? ((p.theta * RADIUS_M) % C + C) % C : nearestA(p.theta * RADIUS_M, (a0 + a1) / 2),
+      elevationM: p.elevationM - landAt(p.z, p.theta * RADIUS_M), authored: true,
+    })))
+    const generated = ms.flatMap((m) => generatedPeaks(m, rand)).map((p) => ({
+      ...p, a: ring ? p.a : nearestA(p.a, (a0 + a1) / 2),
+    })).filter((p) => {
+      for (const q of authored) {
+        const d = Math.hypot(p.z - q.z, nearestA(p.a, q.a) - q.a)
+        if (d < 1500) return false
+        const radius = q.elevationM / FLANK_SLOPE
+        if (d < radius) p.elevationM = Math.min(p.elevationM, q.elevationM * (0.5 + 0.4 * (d / radius)))
+      }
+      return true
+    })
+    const peaks = [...authored, ...generated]
+    const rugged = Math.max(...ms.map((m) => m.f.ruggedness))
+    const edges = ridges(peaks, ring, rand, rugged)
+
+    const field = upliftField(grid, peaks, edges, ms, rand, seed)
+
+    onStep(`Letting rain find its way down ${lead.f.name}`)
+    // Outlets: the grid's z edges, a bounded grid's a edges, and anywhere the range has
+    // faded to nothing — water leaves the range there.
+    const outlet = new Uint8Array(nz * na)
+    for (let i = 0; i < nz; i++) {
+      for (let j = 0; j < na; j++) {
+        const k = i * na + j
+        if (i === 0 || i === nz - 1 || (!ring && (j === 0 || j === na - 1)) || field[k]! < 1) outlet[k] = 1
+      }
+    }
+    const strength = Math.max(...ms.map((m) => m.f.erosion))
+    const relief = erode(grid, field, outlet, strength, seed)
+    keepBenches(grid, field, relief, benches)
+    crown(grid, relief, authored)
+    pin(grid, relief, authored)
+    summitCone(grid, relief, authored, landAt)
+    pin(grid, relief, authored)
+
+    return {
+      ids: ms.map((m) => m.f.id),
+      name: lead.f.name,
+      ring,
+      z0: grid.z0, a0: grid.a0, cell: grid.cell, nz, na,
+      relief,
+      peaks,
+      summits: authored,
+      ruggedness: rugged,
+      snowlineM: Math.min(...ms.map((m) => m.f.snowlineM)),
+      seed,
+      box: { z0: grid.z0, z1: grid.z0 + (nz - 1) * grid.cell, a0: ring ? -Infinity : grid.a0, a1: ring ? Infinity : grid.a0 + (na - 1) * grid.cell },
+    }
+  })
+
+  function area(m: Member): number {
+    if (m.shape.type === 'band') return (m.shape.zTo - m.shape.zFrom) * C
+    return (m.shape.box.z1 - m.shape.box.z0) * (m.shape.box.a1 - m.shape.box.a0)
+  }
+}
+
+/**
+ * Keep a shelf where an authored tarn sits. A cirque is ground the ice hollowed but the
+ * rivers did not cut down, so it stands at the height of the uncut range around it —
+ * which is where v1's tarns and the ski stations beside them were, around 2,100 m
+ * (design.md §1.11). Without this the steady state cuts a valley through every one.
+ * The lake itself is carved into the shelf afterwards (field.ts).
+ */
+function keepBenches(grid: Grid, field: Float32Array, relief: Float32Array, benches: Bench[]): void {
+  for (const b of benches) {
+    const shelf = sampleGrid(grid, field, b.z, b.a)
+    if (shelf <= 0) continue
+    const inner = b.radiusM + 600, outer = inner + 2500
+    forCells(grid, b.z, b.a, outer, (k, d) => {
+      const w = d <= inner ? 1 : 1 - smoothstep(inner, outer, d)
+      relief[k] = Math.max(relief[k]!, relief[k]! + (shelf - relief[k]!) * w)
+    })
+  }
+}
+
+/**
+ * Nothing close to an authored summit may stand above it. The steady state has its own
+ * idea of where the high ground is, and a named summit is named because it is the top.
+ * So where anything within a few kilometres is higher, the ground around the summit is
+ * scaled down — by the full ratio out to 2.5 km, easing to none at 7 km — which keeps its
+ * ridges and valleys, where capping it flat would leave a disc with a rim.
+ */
+function crown(grid: Grid, relief: Float32Array, peaks: CPeak[]): void {
+  const INNER = 2500, OUTER = 7000
+  for (const p of peaks) {
+    let highest = 0
+    forCells(grid, p.z, p.a, INNER, (k) => { highest = Math.max(highest, relief[k]!) })
+    if (highest <= p.elevationM) continue
+    const ratio = p.elevationM / highest
+    forCells(grid, p.z, p.a, OUTER, (k, d) => {
+      const w = d <= INNER ? 1 : 1 - smoothstep(INNER, OUTER, d)
+      relief[k]! *= 1 - w * (1 - ratio)
+    })
+  }
+}
+
+/**
+ * The last word on a summit being the top. Pinning raises a summit and, a little less,
+ * the cells right around it, which can leave a neighbour a few metres above the summit
+ * it was lowered to meet. So within 2.5 km nothing stands above a cone falling 80 m per
+ * kilometre, 15 m lower beyond the summit's own cell. After `crown` only a handful of cells are above it, so this trims them
+ * without leaving a flat disc; the summit is pinned once more after.
+ */
+function summitCone(grid: Grid, relief: Float32Array, peaks: CPeak[], landAt: (z: number, a: number) => number): void {
+  for (const p of peaks) {
+    // In total height, since the land under the range is not flat: the summit's land,
+    // plus its relief, against each cell's land plus its relief.
+    const summitLand = landAt(p.z, p.a)
+    // Past the summit's own cell, 15 m more: room for the final pin's small correction,
+    // which spreads to the neighbours too.
+    forCells(grid, p.z, p.a, 2500, (k, d, z, a) => {
+      const allowed = summitLand + p.elevationM - 0.08 * d - (d > grid.cell ? 15 : 0) - landAt(z, a)
+      relief[k] = Math.min(relief[k]!, allowed)
+    })
+  }
+}
+
+/** Visit every cell within `radius` of (z, a), with its distance. */
+function forCells(
+  grid: Grid, z: number, a: number, radius: number, fn: (k: number, d: number, z: number, a: number) => void,
+): void {
+  const cells = Math.ceil(radius / grid.cell)
+  const ci = Math.round((z - grid.z0) / grid.cell)
+  const aa = grid.ring ? a : nearestA(a, grid.a0 + ((grid.na - 1) * grid.cell) / 2)
+  const cj = Math.round((aa - grid.a0) / grid.cell)
+  for (let i = ci - cells; i <= ci + cells; i++) {
+    if (i < 0 || i >= grid.nz) continue
+    for (let jj = cj - cells; jj <= cj + cells; jj++) {
+      let j = jj
+      if (grid.ring) j = ((jj % grid.na) + grid.na) % grid.na
+      else if (jj < 0 || jj >= grid.na) continue
+      const cz = grid.z0 + i * grid.cell, ca = grid.a0 + jj * grid.cell
+      const d = Math.hypot(cz - z, ca - aa)
+      if (d <= radius) fn(i * grid.na + j, d, cz, ca)
+    }
+  }
+}
+
+/** The pinning kernel's radius: how far around a summit the correction is spread. */
+export const PIN_RADIUS_M = 1500
+
+/**
+ * Put every authored summit back at exactly its elevation, as a smooth bump spread over
+ * the cells around it. Repeated, because the grid is sampled between its nodes: one
+ * pass lands within a few percent of the correction, and three are exact to a millimetre.
+ */
+function pin(grid: Grid, relief: Float32Array, peaks: CPeak[]): void {
+  for (let pass = 0; pass < 4; pass++) {
+    for (const p of peaks) {
+      const delta = p.elevationM - sampleGrid(grid, relief, p.z, p.a)
+      if (Math.abs(delta) < 1e-3) continue
+      forCells(grid, p.z, p.a, PIN_RADIUS_M, (k, d) => { relief[k]! += delta * pinKernel(d) })
+    }
+  }
+}
+
+/** 1 at the summit, easing to 0 at PIN_RADIUS_M. Shared with field.ts's detail mask. */
+export function pinKernel(d: number): number {
+  if (d >= PIN_RADIUS_M) return 0
+  const u = 1 - d / PIN_RADIUS_M
+  return u * u * (3 - 2 * u)
+}
+
+/** Bilinear value of a grid at (z, a), zero outside a bounded grid. */
+export function sampleGrid(g: Grid, relief: Float32Array, z: number, a: number): number {
+  const fi = (z - g.z0) / g.cell
+  if (fi < 0 || fi > g.nz - 1) return 0
+  let fj: number
+  if (g.ring) {
+    fj = ((((a - g.a0) / g.cell) % g.na) + g.na) % g.na
+  } else {
+    fj = (nearestA(a, g.a0 + ((g.na - 1) * g.cell) / 2) - g.a0) / g.cell
+    if (fj < 0 || fj > g.na - 1) return 0
+  }
+  const i0 = Math.min(g.nz - 2, Math.floor(fi)), j0 = Math.min(g.ring ? g.na - 1 : g.na - 2, Math.floor(fj))
+  const ti = fi - i0, tj = fj - j0
+  const j1 = g.ring ? (j0 + 1) % g.na : j0 + 1
+  const at = (i: number, j: number): number => relief[i * g.na + j]!
+  const top = at(i0, j0) * (1 - tj) + at(i0, j1) * tj
+  const bot = at(i0 + 1, j0) * (1 - tj) + at(i0 + 1, j1) * tj
+  return top * (1 - ti) + bot * ti
+}
diff --git a/src/terrain/service.ts b/src/terrain/service.ts
index 256cf26..e944c0d 100644
--- a/src/terrain/service.ts
+++ b/src/terrain/service.ts
@@ -43,6 +43,9 @@ interface Slot {
 export class TerrainService {
   private slots: Slot[]
   private signature = ''
+  /** A compile is running; edits made meanwhile wait here, and only the latest is kept. */
+  private compiling = false
+  private pending: World | null = null
   /** Bumped on every terrain change. Anything built for an older one is thrown away. */
   generation = 0
   compiled: CompiledWorld | null = null
@@ -96,35 +99,58 @@ export class TerrainService {
   async setWorld(world: World): Promise<void> {
     const signature = terrainSignature(world)
     if (signature === this.signature) return
+    /**
+     * One compile at a time, and edits made during one collapse into the next. Dragging a
+     * summit updates the world on every pointer move; each of those starting a compile of
+     * a few seconds would queue minutes of work that is stale before it starts. The world
+     * is copied because the store edits it in place.
+     */
+    if (this.compiling) {
+      this.pending = structuredClone(world)
+      return
+    }
     this.signature = signature
+    this.compiling = true
+    try {
+      await this.generate(world)
+    } finally {
+      this.compiling = false
+    }
+    const next = this.pending
+    this.pending = null
+    if (next) await this.setWorld(next)
+  }
+
+  private async generate(world: World): Promise<void> {
     const gen = ++this.generation
 
     // Every compile step, plus drawing the map.
     const counted = world.features.filter((f) =>
       ['desert', 'forest', 'mountains', 'lake', 'sea', 'river'].includes(f.kind)
       || (f.kind === 'region' && f.upliftM)).length
-    const total = 1 + counted + 1
+    // Mountain ranges add an erosion step per group; counting one per range is an upper bound.
+    const ranges = world.features.filter((f) => f.kind === 'mountains').length
+    const total = 1 + counted + ranges + 1
     let done = 0
     this.setProgress({ busy: true, label: 'Reading the plans', done: 0, total, startedAt: performance.now() })
 
     const compiled = await this.slots[0]!.api.compile(world, proxy((label: string) => {
-      if (gen !== this.generation) return
       this.setProgress({ label, done: done++ })
     }))
-    if (gen !== this.generation) return
 
     await Promise.all(this.slots.map((s) => s.api.load(compiled, gen)))
-    if (gen !== this.generation) return
     this.compiled = compiled
     this.runtime = prepare(compiled)
     for (const fn of this.worldListeners) fn()
 
     this.setProgress({ label: 'Drawing the map', done: total - 1 })
     const canvas = await this.renderMap(gen)
-    if (gen !== this.generation || !canvas) return
-    this.mapCanvas = canvas
-    for (const fn of this.mapListeners) fn()
-    this.setProgress({ busy: false, done: total })
+    if (canvas) {
+      this.mapCanvas = canvas
+      for (const fn of this.mapListeners) fn()
+    }
+    // Still busy if an edit arrived meanwhile: its compile starts as this one returns.
+    this.setProgress({ busy: this.pending !== null, done: total })
   }
 
   /** The map image, rendered in bands across the whole pool. */
diff --git a/src/ui/app.ts b/src/ui/app.ts
index e06fbe3..eed29a0 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -14,7 +14,7 @@ import {
 } from '../world/schema.js'
 import { terrain } from '../terrain/service.js'
 import { KIND_COLOR } from '../views/palette.js'
-import { WorldStore, backupFilename, download } from '../world/store.js'
+import { type FeaturePatch, WorldStore, backupFilename, download } from '../world/store.js'
 import { TowerMap } from '../views/map.js'
 import type { ExteriorView } from '../views/exterior.js'
 import type { InteriorView, Lighting } from '../views/interior.js'
@@ -106,12 +106,15 @@ export class App {
   private tool: Tool = 'select'
   private selectedId: string | null = null
   private draft: [number, number][] = []
+  /** The range a summit is about to be added to by the next click on the map, if any. */
+  private addingPeakTo: string | null = null
   private probeFrom: SurfacePoint | null = null
 
   constructor(private store: WorldStore) {
     this.map = new TowerMap($<HTMLCanvasElement>('map'), {
       onPick: (p, hit, shift) => this.onPick(p, hit, shift),
       onDragPlace: (id, p) => this.onDragPlace(id, p),
+      onDragPeak: (id, index, p) => this.onDragPeak(id, index, p),
       onHover: (p) => this.onHover(p),
     })
     this.buildViews()
@@ -528,6 +531,16 @@ export class App {
   private onPick(p: SurfacePoint, hit: Feature | null, shift: boolean): void {
     if (shift) return this.onProbe(p)
 
+    if (this.addingPeakTo) {
+      const f = this.store.get(this.addingPeakTo)
+      this.setAddingPeak(null)
+      if (f?.kind !== 'mountains') return
+      this.store.update(f.id, {
+        peaks: [...f.peaks, { name: '', theta: p.theta, z: p.z, elevationM: f.heightM }],
+      })
+      return
+    }
+
     if (this.tool === 'select') {
       this.selectedId = hit?.id ?? null
       return this.render()
@@ -573,6 +586,19 @@ export class App {
     this.store.update(id, { geometry: { type: 'point', theta: p.theta, z: p.z } })
   }
 
+  private onDragPeak(id: string, index: number, p: SurfacePoint): void {
+    const f = this.store.get(id)
+    if (f?.kind !== 'mountains' || !f.peaks[index]) return
+    const peaks = f.peaks.map((pk, i) => (i === index ? { ...pk, theta: p.theta, z: p.z } : pk))
+    this.store.update(id, { peaks })
+  }
+
+  private setAddingPeak(id: string | null): void {
+    this.addingPeakTo = id
+    $('f-add-peak-hint').hidden = id === null
+    $<HTMLButtonElement>('f-add-peak').classList.toggle('active', id !== null)
+  }
+
   private onHover(p: SurfacePoint | null): void {
     const el = $('cursor')
     if (!p) {
@@ -591,7 +617,10 @@ export class App {
 
   private onKey(e: KeyboardEvent): void {
     if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
-    if (e.key === 'Escape') return this.setTool('select')
+    if (e.key === 'Escape') {
+      this.setAddingPeak(null)
+      return this.setTool('select')
+    }
     if (e.key === 'Enter' && isTracedKind(this.tool as FeatureKind)) {
       const area = isAreaKind(this.tool as FeatureKind)
       if (this.draft.length >= (area ? 3 : 2)) {
@@ -666,7 +695,7 @@ export class App {
   }
 
   private wireEditor(): void {
-    const patch = (p: Partial<Omit<Feature, 'id'>>) => {
+    const patch = (p: FeaturePatch) => {
       if (this.selectedId) this.store.update(this.selectedId, p)
     }
     $<HTMLInputElement>('f-name').oninput = (e) =>
@@ -689,6 +718,102 @@ export class App {
     $<HTMLInputElement>('f-public').onchange = (e) =>
       patch({ public: (e.target as HTMLInputElement).checked })
     $('f-delete').onclick = () => this.deleteSelected()
+    this.wireMountains()
+  }
+
+  /**
+   * A range's summits and parameters. Sliders commit on release rather than while being
+   * dragged: every change regenerates the range, which takes a few seconds for the
+   * Girdle, and the service would only throw the in-between values away (service.ts).
+   */
+  private selectedRange(): Extract<Feature, { kind: 'mountains' }> | null {
+    const f = this.selectedId ? this.store.get(this.selectedId) : undefined
+    return f?.kind === 'mountains' ? f : null
+  }
+
+  private wireMountains(): void {
+    const range = () => this.selectedRange()
+    const num = (id: string, key: 'heightM' | 'snowlineM', min: number) => {
+      const el = $<HTMLInputElement>(id)
+      el.onchange = () => {
+        const f = range()
+        const v = Number(el.value)
+        if (f && Number.isFinite(v)) this.store.update(f.id, { [key]: Math.max(min, v) })
+      }
+    }
+    num('f-height', 'heightM', 0)
+    num('f-snow', 'snowlineM', -1000)
+    for (const [id, key] of [['f-gen', 'generatedPeaks'], ['f-rugged', 'ruggedness'], ['f-erosion', 'erosion']] as const) {
+      const el = $<HTMLInputElement>(id)
+      el.oninput = () => { $(`${id}-value`).textContent = `— ${Number(el.value).toFixed(2)}` }
+      el.onchange = () => {
+        const f = range()
+        if (f) this.store.update(f.id, { [key]: Number(el.value) })
+      }
+    }
+    $('f-add-peak').onclick = () => {
+      const f = range()
+      this.setAddingPeak(this.addingPeakTo || !f ? null : f.id)
+    }
+  }
+
+  private renderMountains(f: Feature | undefined): void {
+    const box = $('f-mountains')
+    box.hidden = f?.kind !== 'mountains'
+    if (f?.kind !== 'mountains') {
+      this.setAddingPeak(null)
+      return
+    }
+    const setIfIdle = (id: string, v: string) => {
+      const el = $<HTMLInputElement>(id)
+      if (document.activeElement !== el) el.value = v
+    }
+    setIfIdle('f-height', String(f.heightM))
+    setIfIdle('f-snow', String(f.snowlineM))
+    for (const [id, v] of [['f-gen', f.generatedPeaks], ['f-rugged', f.ruggedness], ['f-erosion', f.erosion]] as const) {
+      setIfIdle(id, String(v))
+      $(`${id}-value`).textContent = `— ${v.toFixed(2)}`
+    }
+
+    // The summit rows are rebuilt only when their count changes, so typing a name is
+    // never interrupted by a re-render replacing the field under the cursor.
+    const list = $('f-peaks')
+    if (list.childElementCount !== f.peaks.length || list.dataset['range'] !== f.id) {
+      list.replaceChildren()
+      list.dataset['range'] = f.id
+      f.peaks.forEach((_, i) => {
+        const li = document.createElement('li')
+        const name = document.createElement('input')
+        name.type = 'text'
+        name.placeholder = 'Unnamed summit'
+        const elev = document.createElement('input')
+        elev.type = 'number'
+        elev.step = '10'
+        elev.title = 'Elevation, m'
+        const del = document.createElement('button')
+        del.type = 'button'
+        del.textContent = '×'
+        del.title = 'Remove this summit'
+        const edit = (patch: Partial<{ name: string; elevationM: number }>) => {
+          const cur = this.selectedRange()
+          if (!cur?.peaks[i]) return
+          this.store.update(cur.id, { peaks: cur.peaks.map((p, k) => (k === i ? { ...p, ...patch } : p)) })
+        }
+        name.oninput = () => edit({ name: name.value })
+        elev.onchange = () => { if (Number.isFinite(Number(elev.value))) edit({ elevationM: Number(elev.value) }) }
+        del.onclick = () => {
+          const cur = this.selectedRange()
+          if (cur) this.store.update(cur.id, { peaks: cur.peaks.filter((_, k) => k !== i) })
+        }
+        li.append(name, elev, del)
+        list.append(li)
+      })
+    }
+    ;[...list.children].forEach((li, i) => {
+      const [name, elev] = li.querySelectorAll('input')
+      if (name && document.activeElement !== name) name.value = f.peaks[i]!.name
+      if (elev && document.activeElement !== elev) elev.value = String(f.peaks[i]!.elevationM)
+    })
   }
 
   /**
@@ -873,6 +998,7 @@ export class App {
     const notes = $<HTMLTextAreaElement>('f-notes')
     if (document.activeElement !== notes) notes.value = p.notes
     $<HTMLInputElement>('f-public').checked = p.public
+    this.renderMountains(p)
 
     $('f-where').textContent = this.whereText(p)
   }
diff --git a/src/views/map.ts b/src/views/map.ts
index af9feab..a561d0d 100644
--- a/src/views/map.ts
+++ b/src/views/map.ts
@@ -22,6 +22,8 @@ import { KIND_COLOR } from './palette.js'
 export interface MapCallbacks {
   onPick: (p: SurfacePoint, hit: Feature | null, shiftKey: boolean) => void
   onDragPlace: (id: string, p: SurfacePoint) => void
+  /** A summit of a range dragged to a new place. */
+  onDragPeak: (id: string, index: number, p: SurfacePoint) => void
   onHover: (p: SurfacePoint | null) => void
 }
 
@@ -49,7 +51,7 @@ export class TowerMap {
   private terrain: CanvasImageSource | null = null
   showTerrain = true
 
-  private dragging: { id: string } | null = null
+  private dragging: { id: string; peak: number | null } | null = null
   private panning: { x: number; y: number; ox: number; oy: number } | null = null
   private moved = false
 
@@ -144,8 +146,12 @@ export class TowerMap {
       const { px, py } = this.eventPoint(e)
       this.moved = false
       const hit = this.hitTest(px, py)
-      if (this.dragEnabled && hit && hit.geometry.type === 'point' && !e.shiftKey) {
-        this.dragging = { id: hit.id }
+      const peak = this.dragEnabled && !e.shiftKey ? this.hitPeak(px, py) : null
+      if (peak) {
+        this.dragging = { id: peak.feature.id, peak: peak.index }
+        this.canvas.setPointerCapture(e.pointerId)
+      } else if (this.dragEnabled && hit && hit.geometry.type === 'point' && !e.shiftKey) {
+        this.dragging = { id: hit.id, peak: null }
         this.canvas.setPointerCapture(e.pointerId)
       } else {
         this.panning = { x: px, y: py, ox: this.offsetX, oy: this.offsetY }
@@ -157,7 +163,9 @@ export class TowerMap {
       const { px, py } = this.eventPoint(e)
       if (this.dragging) {
         this.moved = true
-        this.cb.onDragPlace(this.dragging.id, this.surfaceAt(px, py))
+        const at = this.surfaceAt(px, py)
+        if (this.dragging.peak === null) this.cb.onDragPlace(this.dragging.id, at)
+        else this.cb.onDragPeak(this.dragging.id, this.dragging.peak, at)
       } else if (this.panning) {
         if (Math.hypot(px - this.panning.x, py - this.panning.y) > 3) this.moved = true
         this.offsetX = this.panning.ox + (px - this.panning.x)
@@ -175,7 +183,10 @@ export class TowerMap {
       this.dragging = null
       this.panning = null
       if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId)
-      if (!this.moved && !wasDragging) this.cb.onPick(this.surfaceAt(px, py), this.hitTest(px, py), e.shiftKey)
+      // A summit clicked without dragging selects its range, like clicking the range.
+      if (!this.moved && (!wasDragging || wasDragging.peak !== null)) {
+        this.cb.onPick(this.surfaceAt(px, py), this.hitPeak(px, py)?.feature ?? this.hitTest(px, py), e.shiftKey)
+      }
     }
     this.canvas.addEventListener('pointerup', end)
     this.canvas.addEventListener('pointercancel', () => {
@@ -198,6 +209,20 @@ export class TowerMap {
     }, { passive: false })
   }
 
+  /** The summit under the cursor, if any, and which of its range's summits it is. */
+  hitPeak(px: number, py: number): { feature: Feature; index: number } | null {
+    for (let i = this.places.length - 1; i >= 0; i--) {
+      const f = this.places[i]!
+      if (f.kind !== 'mountains') continue
+      for (let k = f.peaks.length - 1; k >= 0; k--) {
+        const m = toMap({ theta: f.peaks[k]!.theta, z: f.peaks[k]!.z })
+        const [sx, sy] = this.worldToScreen(m.x, m.y)
+        if (Math.hypot(px - sx, py - (sy - 1)) <= 9) return { feature: f, index: k }
+      }
+    }
+    return null
+  }
+
   /** Topmost place under the cursor, markers taking precedence over areas. */
   hitTest(px: number, py: number): Feature | null {
     for (let i = this.places.length - 1; i >= 0; i--) {
@@ -434,6 +459,16 @@ export class TowerMap {
         ctx.strokeRect(x0, y0, x1 - x0, h)
         ctx.setLineDash([])
         this.label(p.name, (x0 + x1) / 2, y0 + h - 10, color, true)
+        if (p.kind === 'mountains') {
+          for (const pk of p.peaks) {
+            const m = toMap({ theta: pk.theta, z: pk.z })
+            const [sx, sy] = this.worldToScreen(m.x, m.y)
+            this.peak(sx, sy, color, selected)
+            // An unnamed summit is a triangle and nothing more: the range's name on it
+            // would read as a second summit of that name.
+            if (pk.name) this.label(pk.name, sx + 8, sy + 4, color, false)
+          }
+        }
       } else if (p.geometry.type === 'polyline') {
         ctx.beginPath()
         this.tracePath(p.geometry.points)
@@ -467,7 +502,9 @@ export class TowerMap {
             const m = toMap({ theta: pk.theta, z: pk.z })
             const [sx, sy] = this.worldToScreen(m.x, m.y)
             this.peak(sx, sy, color, selected)
-            this.label(pk.name || p.name, sx + 8, sy + 4, color, false)
+            // An unnamed summit is a triangle and nothing more: the range's name on it
+            // would read as a second summit of that name.
+            if (pk.name) this.label(pk.name, sx + 8, sy + 4, color, false)
           }
         } else {
           const c = centroid(p.geometry.points)
diff --git a/src/world/schema.ts b/src/world/schema.ts
index 127ae00..3996972 100644
--- a/src/world/schema.ts
+++ b/src/world/schema.ts
@@ -90,9 +90,14 @@ export interface Mountains extends Base<'mountains'> {
    * rises to `heightM` at its middle, and a band to `heightM` along its centre line.
    */
   peaks: Peak[]
+  /** How high the generated summits stand: a ring's crest, a massif's middle. */
   heightM: number
-  /** 0..1, how far ridged noise breaks the form into crests. */
+  /** Unnamed summits generated per 100 km² of the range, around the authored ones. */
+  generatedPeaks: number
+  /** 0..1: how many spurs the ridges branch into, and how rough the rock is. */
   ruggedness: number
+  /** 0..1: how deep water cuts valleys into the range (mountains.ts). */
+  erosion: number
   snowlineM: number
 }
 
@@ -218,7 +223,10 @@ export function withKind(common: Common, kind: FeatureKind): Feature {
   const c = { ...common }
   switch (kind) {
     case 'mountains':
-      return { ...c, kind, peaks: [], heightM: 2400, ruggedness: 0.55, snowlineM: 3000 }
+      return {
+        ...c, kind, peaks: [], heightM: 2400, generatedPeaks: 0.6, ruggedness: 0.55, erosion: 0.6,
+        snowlineM: 3000,
+      }
     case 'lake':
       return { ...c, kind, levelM: 'auto', depthM: 60, basin: 'bowl', shoreM: 2000, rimM: 20 }
     case 'sea':
@@ -338,7 +346,9 @@ function fillParams(d: Feature, f: Record<string, unknown>): Feature {
             : [])
         : []
       return { ...d, peaks, heightM: num(f['heightM'], d.heightM),
+               generatedPeaks: Math.max(0, num(f['generatedPeaks'], d.generatedPeaks)),
                ruggedness: unit(f['ruggedness'], d.ruggedness),
+               erosion: unit(f['erosion'], d.erosion),
                snowlineM: num(f['snowlineM'], d.snowlineM) }
     }
     case 'lake':
diff --git a/src/world/store.ts b/src/world/store.ts
index c7a81c3..ca6b5f0 100644
--- a/src/world/store.ts
+++ b/src/world/store.ts
@@ -14,6 +14,12 @@ import {
  * where it is rather than read: it holds places, not features, and would load empty.
  */
 const STORAGE_KEY = 'tower.world.v2'
+
+/**
+ * Some fields of some kind of feature. A union of each kind's partial, so a patch may
+ * name a range's `peaks` or a lake's `levelM`, which a partial of the union could not.
+ */
+export type FeaturePatch = Feature extends infer F ? (F extends Feature ? Partial<Omit<F, 'id'>> : never) : never
 const SAVE_DEBOUNCE_MS = 400
 
 export type SaveState = 'saved' | 'saving' | 'error'
@@ -134,7 +140,7 @@ export class WorldStore {
     return feature
   }
 
-  update(id: string, patch: Partial<Omit<Feature, 'id'>>): void {
+  update(id: string, patch: FeaturePatch): void {
     const feature = this.get(id)
     if (!feature) return
     Object.assign(feature, patch)
diff --git a/test/terrain.test.ts b/test/terrain.test.ts
index e72121b..87eb2f4 100644
--- a/test/terrain.test.ts
+++ b/test/terrain.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
 import { RADIUS_M } from '../src/canon/constants.js'
 import { compileWorld } from '../src/terrain/compile.js'
 import { Stage, prepare } from '../src/terrain/field.js'
+import { nearestA } from '../src/terrain/shapes.js'
 import { parseWorld, type Mountains, type River } from '../src/world/schema.js'
 
 const world = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8')))
@@ -15,7 +16,10 @@ const rt = prepare(cw)
 describe('the world compiles', () => {
   it('ports every v1 feature, plus the ground raised under the rivers', () => {
     expect(world.features).toHaveLength(29)
-    expect(cw.mountains).toHaveLength(8)
+    // Eight ranges in two groups: the Girdle with the four massifs on it, and the
+    // three desert ridges, which overlap each other.
+    expect(cw.mountains).toHaveLength(2)
+    expect(cw.mountains.flatMap((g) => g.ids)).toHaveLength(8)
     expect(cw.basins).toHaveLength(7)
     expect(cw.rivers).toHaveLength(4)
     // The Dry Reach, Greatwood, and The Uplands (D-064).
@@ -40,12 +44,17 @@ describe('the world compiles', () => {
 })
 
 describe('determinism', () => {
+  // Compiling is the slow part of this file — a few seconds, mostly erosion — so the
+  // second compile is shared by both checks rather than done once for each.
+  const second = compileWorld(world)
+
   it('compiles the same world to the same data twice', () => {
-    expect(JSON.stringify(compileWorld(world))).toBe(JSON.stringify(cw))
+    expect(second.mountains.map((g) => Array.from(g.relief))).toEqual(cw.mountains.map((g) => Array.from(g.relief)))
+    expect(JSON.stringify({ ...second, mountains: [] })).toBe(JSON.stringify({ ...cw, mountains: [] }))
   })
 
   it('samples the same point to the same height from a fresh runtime', () => {
-    const again = prepare(compileWorld(world))
+    const again = prepare(second)
     for (const [theta, z] of [[0.3, 88_000], [0.02, 270_000], [3, 500_000], [-1, 180_000]] as const) {
       expect(again.sample(theta, z, 1).h).toBe(rt.sample(theta, z, 1).h)
     }
@@ -61,33 +70,110 @@ describe('determinism', () => {
 })
 
 describe('mountains', () => {
-  it('lands every authored summit close to its elevation', () => {
+  it('groups the ranges that overlap, so they erode as one', () => {
+    const girdle = cw.mountains.find((g) => g.ids.includes('the-girdle'))!
+    for (const id of ['snowcrown', 'the-sisters', 'westwall', 'eastwall']) expect(girdle.ids).toContain(id)
+    expect(girdle.name).toBe('The Girdle')
+    expect(girdle.ring).toBe(true)
+  })
+
+  it('puts every authored summit at exactly its elevation above the datum', () => {
+    for (const f of world.features.filter((x): x is Mountains => x.kind === 'mountains')) {
+      for (const p of f.peaks) {
+        // The whole ground, not the range alone: an elevation is above the datum.
+        const h = prepare({ ...cw, basins: [], rivers: [] }).sample(p.theta, p.z, 1).h
+        expect(Math.abs(h - p.elevationM)).toBeLessThan(1)
+      }
+    }
+  })
+
+  it('makes every authored summit the top of its own ground', () => {
+    const ground = prepare({ ...cw, basins: [], rivers: [] })
     for (const f of world.features.filter((x): x is Mountains => x.kind === 'mountains')) {
       for (const p of f.peaks) {
-        // The dome is normalised to its highest point, which the ridged noise puts
-        // near the summit rather than exactly on it; Phase 2 pins it exactly.
-        // Measured on this range alone: the Girdle runs under the named summits and
-        // takes the higher of the two, so the world as a whole can exceed a summit.
-        const alone = prepare({ ...cw, mountains: cw.mountains.filter((m) => m.id === f.id) })
-        let best = 0
-        for (let i = -20; i <= 20; i++) {
-          for (let j = -20; j <= 20; j++) {
-            best = Math.max(best, alone.sample(p.theta + (j * 300) / RADIUS_M, p.z + i * 300, 50,
-              Stage.MountainsOnly).h)
-          }
+        let higher = 0
+        for (let k = 0; k < 64; k++) {
+          const t = (k / 64) * 2 * Math.PI
+          const d = 250 + (k % 4) * 80
+          const h = ground.sample(p.theta + (Math.sin(t) * d) / RADIUS_M, p.z + Math.cos(t) * d, 1).h
+          if (h > p.elevationM) higher++
         }
-        expect(best).toBeGreaterThan(p.elevationM * 0.9)
-        expect(best).toBeLessThan(p.elevationM * 1.05)
+        expect(higher).toBe(0)
       }
     }
   })
+
+  it('joins the named summits by ridges that stay high', () => {
+    // Walk from Snowcrown to The Sisters along the highest path the grid allows, and
+    // check the lowest point on it — the pass between them — is still well up the
+    // mountain. They are 29 km apart, so the pass is a real one, not a notch.
+    const g = cw.mountains.find((x) => x.ids.includes('the-girdle'))!
+    // Summits in a group carry their height above the land, so find them by place.
+    const away = (p: { z: number; a: number }, theta: number, z: number) =>
+      Math.hypot(p.z - z, nearestA(p.a, theta * RADIUS_M) - theta * RADIUS_M)
+    const nearest = (theta: number, z: number) => g.summits.reduce((best, p) =>
+      away(p, theta, z) < away(best, theta, z) ? p : best)
+    const snow = nearest(0.3, 88_000)
+    const sisters = nearest(-0.52, 94_000)
+    expect(highestPathMin(g, snow, sisters)).toBeGreaterThan(1000)
+  })
+
+  it('cuts valleys: the range has low ground inside it, not only at its edge', () => {
+    const g = cw.mountains.find((x) => x.ids.includes('the-girdle'))!
+    // Cells well inside the range (within 15 km of the ring's centre line) that are
+    // under a fifth of the crest height are valley floors.
+    const zc = 90_000
+    let inside = 0, low = 0
+    for (let i = 0; i < g.nz; i++) {
+      const z = g.z0 + i * g.cell
+      if (Math.abs(z - zc) > 15_000) continue
+      for (let j = 0; j < g.na; j += 3) {
+        inside++
+        if (g.relief[i * g.na + j]! < 2900 * 0.2) low++
+      }
+    }
+    expect(low / inside).toBeGreaterThan(0.05)
+  })
 })
 
+/** The best-connected path's lowest point between two summits: a bottleneck search. */
+function highestPathMin(
+  g: { z0: number; a0: number; cell: number; nz: number; na: number; relief: Float32Array },
+  from: { z: number; a: number }, to: { z: number; a: number },
+): number {
+  const idx = (p: { z: number; a: number }) =>
+    Math.round((p.z - g.z0) / g.cell) * g.na + ((Math.round((p.a - g.a0) / g.cell) % g.na) + g.na) % g.na
+  const start = idx(from), goal = idx(to)
+  const best = new Float32Array(g.nz * g.na).fill(-1)
+  const queue: [number, number][] = [[g.relief[start]!, start]]
+  best[start] = g.relief[start]!
+  while (queue.length) {
+    queue.sort((x, y) => y[0] - x[0])
+    const [v, c] = queue.shift()!
+    if (c === goal) return v
+    if (v < best[c]!) continue
+    const ci = Math.floor(c / g.na), cj = c % g.na
+    for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+      const ni = ci + di!, nj = (cj + dj! + g.na) % g.na
+      if (ni < 0 || ni >= g.nz) continue
+      const nk = ni * g.na + nj
+      const nv = Math.min(v, g.relief[nk]!)
+      if (nv > best[nk]!) { best[nk] = nv; queue.push([nv, nk]) }
+    }
+  }
+  return -1
+}
+
 describe('water', () => {
-  it('resolves every tarn to a level inside its own bowl', () => {
-    for (const b of cw.basins.filter((x) => x.basin === 'bowl')) {
-      expect(b.levelM).toBeGreaterThan(1000)
-      expect(b.levelM).toBeLessThan(4500)
+  it('keeps every tarn up in its range, on the shelf the range keeps for it', () => {
+    // Sisters Tarn's ski station is at about 2,100 m (design.md §1.11). Cold Tarn
+    // stands furthest from its summit, on the lowest ground of the four.
+    const level = Object.fromEntries(cw.basins.map((b) => [b.id, b.levelM]))
+    expect(level['sisters-tarn']).toBeGreaterThan(1800)
+    expect(level['sisters-tarn']).toBeLessThan(2500)
+    for (const id of ['hollow-tarn', 'sisters-tarn', 'east-tarn', 'west-tarn']) {
+      expect(level[id]).toBeGreaterThan(600)
+      expect(level[id]).toBeLessThan(4300)
     }
   })
 
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index a687fc3..e1e612f 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -272,6 +272,25 @@ const SHOTS = [
       await settle(page, 1400)
     },
   },
+  {
+    id: 'interior-range',
+    caption: 'Interior — from Snowcrown\'s summit, 4,300 m: the Girdle\'s ridges and valleys running round the world',
+    on: ['desktop'],
+    run: async (page) => {
+      await openView(page, 'interior')
+      await pick(page, 'Snowcrown')
+      // Standing on the summit looking deepward along the range, a little down, so the
+      // ridges below and the ring of the Girdle climbing overhead are both in frame.
+      await page.evaluate(() => {
+        const tw = window.tower
+        tw.yaw = Math.PI / 2
+        tw.pitch = -0.12
+        tw.fovDeg = 70
+        tw.setEyeHeight(30)
+      })
+      await settle(page, 1400)
+    },
+  },
   {
     id: 'interior-overhead',
     caption: 'Interior — looking up: the far side 100 km overhead is clearer than ground 100 km away',
diff --git a/world-generation.md b/world-generation.md
index 2a9f5e2..23bec88 100644
--- a/world-generation.md
+++ b/world-generation.md
@@ -1,7 +1,7 @@
 # Tower — Live World Generation
 
-**Status: IN PROGRESS.** Accepted by the author (D-062). **Phase 1 is built** (D-063);
-phases 2–8 are not. This document replaces the terrain pipeline of D-019 and reverses
+**Status: IN PROGRESS.** Accepted by the author (D-062). **Phases 1 and 2 are built**
+(D-063, D-064, D-065); phases 3–8 are not. This document replaces the terrain pipeline of D-019 and reverses
 D-006; see §11 for every existing decision it touches, and §15 for where Phase 1
 departed from the plan and why.
 
@@ -285,6 +285,11 @@ libraries (if any) it uses. Library choices are justified in §7.
 normalised to the declared height. It gives one summit at an arbitrary spot in a
 blister of noise, and no real ridges, saddles or valleys.
 
+> **As built (D-065)**, stage 3 is not an iterative erosion but the stream-power
+> **steady state**, computed directly over the drainage network and scaled back to the
+> authored massif; overlapping ranges erode as one group; and authored tarns keep a
+> shelf. The description below is kept as the plan; §15 says why each changed.
+
 **Generation, in three stages.**
 
 1. **A ridge graph.** The authored peaks plus `generatedPeaks` extra summits
@@ -760,7 +765,7 @@ The interior already has eye height to 25 km and the drag-the-ground gesture
 | Package | Version | License | Use | Why this one |
 |---|---|---|---|---|
 | **simplex-noise** ✅ Phase 1 | 4.0.3 | MIT | 2D/3D simplex for every field | TypeScript, tree-shakable, takes a seeded random function; 3D is what makes noise on the cylinder seamless |
-| **delaunator** | 5.1.0 | ISC | mountain ridge graph; city road graph faces | the standard, fast, tiny; updated 2026 |
+| **delaunator** ✅ Phase 2 | 5.1.0 | ISC | mountain ridge graph; city road graph faces | the standard, fast, tiny; updated 2026 |
 | **flatbush** | 4.6.2 | ISC | spatial index of feature influence boxes | static packed R-tree, ideal for "which features touch this tile"; transferable to workers as one buffer |
 | **clipper2-js** | 1.2.4 | Boost | polygon offset (road insets, setbacks), clipping (blocks to boundary and shore) | offsetting is the operation needed, and `polygon-clipping` does not offset. **Check its bundle cost before adopting**; the package ships 1.8 MB unpacked, mostly sources |
 | **comlink** ✅ Phase 1 | 4.4.2 | Apache-2.0 | worker RPC | removes the message-passing boilerplate for the worker pool; 4 KB |
@@ -1012,7 +1017,7 @@ screenshot check before any commit.
 | # | Phase | Delivers |
 |---|---|---|
 | 1 ✅ | **Foundation** | schema v2 + port script; status dialog; elevation function with ground + today's landforms reimplemented; worker pool; quadtree tile renderer on the cylinder; surface splatting; ground query; keyboard flight; map preview from the coarse field. Delete `tools/terrain.mjs`, the PNGs, `raster.ts`. **Parity checkpoint:** today's world, without the bitmap |
-| 2 | **Mountains** | ridge graph, erosion, peak editing |
+| 2 ✅ | **Mountains** | ridge graph, erosion, peak editing |
 | 3 | **Water** | seas and lakes with surfaces and waves; rivers with profiles, flow and waterfalls |
 | 4 | **Weather and ships** | weather plan with revisions and the day clock; plan board in the panel; procedural hulls, routes, harbour waits, heave on the waves |
 | 5 | **Desert** | dunes, hills, terraced plateaus, washes, salt pans, shorelines |
@@ -1056,6 +1061,55 @@ What the plan did not know until the world was generated honestly:
 - **Per-tile memory.** Normals travel as signed bytes, and every CPU copy of a tile is
   dropped once it is on the GPU.
 
+## 16. Phase 2, as built (D-065)
+
+Mountains are `src/terrain/mountains.ts`. What changed from §4.1 on contact with the
+real ranges:
+
+- **Overlapping ranges are one group.** The Girdle and the four massifs standing on it
+  are summits on one ring, triangulated, joined and eroded together; the three desert
+  ridges, which overlap each other, are a second group. Separately eroded, the massifs'
+  valleys would have met the ring's at a seam.
+- **The ridges are blended, not maxed.** A hard maximum of cone-like flanks meets its
+  neighbour along a straight crease and the range reads as folded paper. The flanks are
+  combined by a soft maximum (the fourth root of the sum of fourth powers) and their
+  distances nudged by noise.
+- **Flanks are broad.** A 4,300 m summit's roots reach 27 km and it still stands 2,000 m
+  high 12 km out — v1's massif size, and where its tarns and ski stations were.
+- **Erosion is the steady state, not iterations.** Braun–Willett iterations over a
+  smooth uplift field cut almost nothing in a sensible number of passes, and what they
+  did cut ran in parallel grooves along the grid. The stream-power steady state can be
+  written down directly over a priority-flood drainage network — each cell above its
+  receiver by the slope that keeps pace with uplift — and eight passes, each re-finding
+  the network, give branching valleys and sharp divides (Cordonnier et al., 2016). Three
+  things were needed to stop the grid showing through: a surface roughened at valley
+  scales to start from, a fresh jitter on the flood each pass, and a **channel
+  threshold** of 1 km² with a little hillslope diffusion, below which ground is slope
+  rather than a one-cell gully.
+- **The steady state is scaled back to the author's shape.** Its heights have their own
+  scale, so each cell is multiplied by the authored field over the steady state, both
+  blurred over 8 km. The Girdle stays 2,900 m and Snowcrown's massif 4,300 m; the ridges
+  and valleys inside them are the water's. `erosion` blends that with the uncut field.
+- **Summits are the top, exactly.** Where anything within a few kilometres stands above
+  a named summit the ground around it is scaled down smoothly; the summit is pinned to its
+  elevation; a gentle cone trims the few cells pinning lifts past it; and it is pinned
+  again. Fine detail is kept off the summit. Authored summits are exact to under a metre
+  **above the datum** — the land under a summit is subtracted before it is pinned, where
+  v1 added its 4,300 m on top of whatever ground was there — and higher than everything
+  within half a kilometre (tested).
+- **Tarns keep a shelf.** The steady state cut valleys through three of v1's four tarns.
+  A cirque is ground the rivers did not cut down, so each authored bowl lake keeps its
+  range at the uncut height around it. Sisters Tarn resolves to 2,173 m, beside the ski
+  station design.md puts at 2,100 m.
+- **Editing.** Summits are listed in the editor with name and elevation, added by clicking
+  the map and dragged to move; the range's generated-summit height, count, ruggedness,
+  erosion and snowline are controls. Every change regenerates the range, and the service
+  now runs **one compile at a time**, collapsing edits made during it into the next: a
+  ten-step drag regenerated twice, not ten times.
+- **Cost.** A compile is about 4 s in a browser worker for the whole world, nearly all of
+  it the Girdle's erosion; tiles sample the 200 m relief grid and add detail at a few
+  percent of the relief below it.
+
 Parity, against the committed v1 screenshots: the map matches in layout, colour and the
 desert's ragged edge; standing at The Narrows and looking up from Greatwood match
 within a few metres of elevation. Cold load of the production build to a fully detailed