fuzzyworld

tower / commit

feat(desert): mesas, dune fields, dry washes and salt pans, and a Capital with a waterfront

51c39d8bb65bcdd642cc788c14662a851925e5f3 · Pavel Safronov · 2026-09-17 15:00:25 -0700 · parents a088e5d58b49

Claude Code usage: this commit ~200.0K out / ~85.1M cached · cost unknown (no per-model trailer) · session 1 tally 2.4K in / 1.9M out / 596.4M cached · usage by commit and session

feat(desert): mesas, dune fields, dry washes and salt pans, and a Capital with a waterfront

Phase 5 of world-generation.md, plus D-070. §20, D-069 and D-070 have the detail.

The Dry Reach is now the whole grammar of a desert, because Tower had seen deserts:
tiers of rock over dune fields whose crests run across the wind Tower sends there,
rocky hills, a network of dry washes routed by the same drainage the ranges are
eroded by, salt pans in the low ground, and the shoreline benches of lakes that
never stood. None of it is a remnant (§0.1).

What a tile cannot work out for itself is compiled on a 250 m grid — the plateau
field, the washes, the pans — and the terraces are cut as the tile samples it, so a
cliff band is as sharp as the tile is fine rather than as sharp as a grid cell.
Coverage thresholds are taken as quantiles of the noise as measured over this
desert; guessed against an assumed spread they put mesas over everything or
nowhere.

Two findings: a flat desert has no drainage at all, so Tower crowns the Reach 150 m
in its middle and the washes run off it; and widening a one-cell channel by
blurring averages the channel away, so a wash is dilated first and blurred after.
Tower also keeps its rock 6.5 km clear of rivers it had already routed, or a mesa
across The Narrows leaves the world's drainage in a slot — the recorded consequence
is that the canyon through the lower Dry Reach now runs about 780 m.

Materials gained salt and sandstone, bare rock is claimed by the desert rather than
inferred from mountain relief alone, and the ground shader lays strata by elevation
so the beds line up across separate mesas. The grid machinery the ranges had
privately moved to terrain/grid.ts and is now shared.

The Capital is a footprint rather than a dot: settlements carry a size and a radius,
and the Capital is 20 km across, centred on the axial midpoint, which is just wide
enough to put its spinward edge on The Deep's shore. design.md had it standing on a
shore it was 11 km inland of; the author chose to make the city big enough to be
both things at once.

Usage: 2.4K in / 1.9M out / 596.4M cached (session tally)
rtk:   3.3M saved, 37.4% over 3642 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 .vitest/json/output.json                       |   1 +
 README.md                                      |  33 +-
 content/world.json                             |  29 +-
 decisions.md                                   |  61 ++++
 design.md                                      |  42 ++-
 docs/screenshots/desktop-exterior-true.png     | Bin 534371 -> 538823 bytes
 docs/screenshots/desktop-exterior.png          | Bin 510088 -> 527653 bytes
 docs/screenshots/desktop-interior-dawn.png     | Bin 466498 -> 492539 bytes
 docs/screenshots/desktop-interior-night.png    | Bin 299304 -> 295555 bytes
 docs/screenshots/desktop-interior-overhead.png | Bin 509234 -> 509330 bytes
 docs/screenshots/desktop-interior-suncap.png   | Bin 384016 -> 399943 bytes
 docs/screenshots/desktop-interior.png          | Bin 846058 -> 863312 bytes
 docs/screenshots/desktop-map-sightline.png     | Bin 917532 -> 939872 bytes
 docs/screenshots/desktop-map.png               | Bin 907090 -> 973258 bytes
 docs/screenshots/desktop-reader.png            | Bin 895119 -> 957878 bytes
 docs/screenshots/desktop-tour-deep.png         | Bin 342244 -> 342461 bytes
 docs/screenshots/desktop-tour-falls.png        | Bin 923859 -> 962167 bytes
 docs/screenshots/desktop-tour-girdle.png       | Bin 675092 -> 742554 bytes
 docs/screenshots/desktop-tour-mesas.png        | Bin 0 -> 806627 bytes
 docs/screenshots/desktop-tour-narrows.png      | Bin 689037 -> 724197 bytes
 docs/screenshots/desktop-tour-pan.png          | Bin 0 -> 701953 bytes
 docs/screenshots/desktop-tour-river.png        | Bin 533479 -> 538717 bytes
 docs/screenshots/desktop-tour-ship.png         | Bin 414025 -> 413989 bytes
 docs/screenshots/desktop-tour-squall.png       | Bin 404218 -> 407719 bytes
 docs/screenshots/desktop-tour-storm.png        | Bin 388165 -> 466188 bytes
 docs/screenshots/desktop-tour-summit.png       | Bin 914702 -> 986118 bytes
 docs/screenshots/mobile-exterior.png           | Bin 115025 -> 119350 bytes
 docs/screenshots/mobile-interior.png           | Bin 264804 -> 266383 bytes
 docs/screenshots/mobile-map.png                | Bin 239624 -> 242719 bytes
 docs/screenshots/mobile-panel.png              | Bin 100781 -> 101248 bytes
 docs/screenshots/overview.png                  | Bin 2254247 -> 2588884 bytes
 index.html                                     |  40 +++
 src/terrain/compile.ts                         |  21 +-
 src/terrain/desert.ts                          | 422 +++++++++++++++++++++++++
 src/terrain/field.ts                           | 111 ++++++-
 src/terrain/grid.ts                            | 179 +++++++++++
 src/terrain/mountains.ts                       | 165 +---------
 src/terrain/palette.ts                         |  28 +-
 src/terrain/service.ts                         |   4 +-
 src/terrain/tile.ts                            |  27 +-
 src/ui/app.ts                                  | 113 ++++++-
 src/views/interior-model.ts                    |  26 +-
 src/views/interior.ts                          |   9 +-
 src/views/map.ts                               |  22 ++
 src/views/scenic.ts                            |  88 +++++-
 src/views/terrain-lod.ts                       |   2 +
 src/world/schema.ts                            |  97 +++++-
 static/world.json                              |  27 +-
 test/desert.test.ts                            | 169 ++++++++++
 test/terrain.test.ts                           |  39 ++-
 tools/screenshots.mjs                          |  44 ++-
 world-generation.md                            |  68 +++-
 52 files changed, 1648 insertions(+), 219 deletions(-)

diff --git a/.vitest/json/output.json b/.vitest/json/output.json
new file mode 100644
index 0000000..29205e1
--- /dev/null
+++ b/.vitest/json/output.json
@@ -0,0 +1 @@
+{"numTotalTestSuites":1,"numPassedTestSuites":0,"numFailedTestSuites":1,"numPendingTestSuites":0,"numTotalTests":1,"numPassedTests":0,"numFailedTests":1,"numPendingTests":0,"numTodoTests":0,"snapshot":{"added":0,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0,"didUpdate":false},"startTime":1789675306635,"success":false,"testResults":[{"assertionResults":[{"ancestorTitles":[],"fullName":"probe","status":"failed","title":"probe","duration":30.458458000000064,"failureMessages":["Error: {\"inside\":{\"max\":1,\"mean\":0.855,\"over\":0.855},\"plateau\":{\"max\":1,\"mean\":0.214,\"over\":0.214},\"cut\":{\"max\":9.625,\"mean\":1.375,\"over\":0.584},\"salt\":{\"max\":1,\"mean\":0.006,\"over\":0.006},\"sand\":{\"max\":1,\"mean\":0.665,\"over\":0.652},\"bench\":{\"max\":1,\"mean\":0.003,\"over\":0.003},\"samples\":[{\"h\":951,\"rock\":0,\"salt\":0,\"desert\":1},{\"h\":935,\"rock\":0,\"salt\":0,\"desert\":1},{\"h\":976,\"rock\":0.01,\"salt\":0,\"desert\":1},{\"h\":910,\"rock\":0,\"salt\":0,\"desert\":1},{\"h\":1363,\"rock\":1,\"salt\":0,\"desert\":1}]}\n    at /Users/pavel/code/tower/test/tmp2.test.ts:21:9\n    at file:///Users/pavel/code/tower/node_modules/vitest/dist/chunks/run.CQOUYP-x.js:1628:35\n    at file:///Users/pavel/code/tower/node_modules/vitest/dist/chunks/run.CQOUYP-x.js:2783:26\n    at file:///Users/pavel/code/tower/node_modules/vitest/dist/chunks/run.CQOUYP-x.js:3319:20\n    at new Promise (<anonymous>)\n    at runWithCancel (file:///Users/pavel/code/tower/node_modules/vitest/dist/chunks/run.CQOUYP-x.js:3314:10)\n    at file:///Users/pavel/code/tower/node_modules/vitest/dist/chunks/run.CQOUYP-x.js:3299:20\n    at new Promise (<anonymous>)\n    at runWithTimeout (file:///Users/pavel/code/tower/node_modules/vitest/dist/chunks/run.CQOUYP-x.js:3257:10)\n    at file:///Users/pavel/code/tower/node_modules/vitest/dist/chunks/run.CQOUYP-x.js:3876:64"],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1789675310914,"endTime":1789675310944.4585,"status":"failed","message":"","name":"/Users/pavel/code/tower/test/tmp2.test.ts"}]}
\ No newline at end of file
diff --git a/README.md b/README.md
index dd52c03..2617f7e 100644
--- a/README.md
+++ b/README.md
@@ -36,13 +36,14 @@ 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).** Phases 1 to 4 are built —
+**In progress: [world-generation.md](world-generation.md).** Phases 1 to 5 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. Mountain ranges are summits
 joined by ridges and cut by water into valleys; lakes, seas and rivers are water with
 waves, flow and waterfalls; Tower publishes a weather plan, and ships sail the ring seas
-by it. Phases 5–8 add deserts, forests of individual trees, settlements and roads.
+by it. The Dry Reach has mesas, dune fields, dry washes and salt pans. Phases 6–8 add
+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
@@ -66,7 +67,11 @@ and writes [`docs/screenshots/`](docs/screenshots/). Taking them *through the UI
 the point: a shot taken this way fails when the UI fails, so a black canvas ends up
 in the picture instead of hiding behind a passing unit test.
 
-The whole tour takes about **a minute and a half**, and waits on the world being
+A scene the tour photographs is held at one moment with the water's clock stopped too, so
+the sea in a shot is the same sea every run and a divergence means a real change.
+`--only <ids>` retakes a few shots and leaves the sheet alone.
+
+The whole tour takes about **three minutes**, and waits on the world being
 generated rather than on fixed delays: a shot is taken once the terrain has compiled, the
 map is drawn, and every tile of ground the view wants has arrived. It is kept light on the
 machine: the world is **compiled once** for the whole tour and every shot starts from that
@@ -90,6 +95,8 @@ another machine will show a large one-off divergence rather than a regression.
 | [tour-summit](docs/screenshots/desktop-tour-summit.png) | scenic tour: round Snowcrown's summit, 4,300 m |
 | [tour-river](docs/screenshots/desktop-tour-river.png) | scenic tour: down the Long Water to its 167 m fall into The Deep |
 | [tour-squall](docs/screenshots/desktop-tour-squall.png) | scenic tour: a ship caught out by a gale Tower gave no warning of |
+| [tour-mesas](docs/screenshots/desktop-tour-mesas.png) | scenic tour: past the mesas of the Dry Reach, strata lining up across all of them |
+| [tour-pan](docs/screenshots/desktop-tour-pan.png) | scenic tour: over a salt pan, ringed by the shorelines of a lake that never stood |
 | [tour-narrows](docs/screenshots/desktop-tour-narrows.png) | scenic tour: through The Narrows, the one gap in all three ridges |
 | [tour-deep](docs/screenshots/desktop-tour-deep.png) | scenic tour: out from the Capital over The Deep |
 | [tour-storm](docs/screenshots/desktop-tour-storm.png) | scenic tour: the published storm on the Sunward Sea, every ship in harbour |
@@ -138,9 +145,10 @@ regressions, not things that were already broken.
 ```
 npm install
 npm run dev      # http://localhost:5173, plus a LAN URL and a QR code for it
-npm test         # 167 tests; canon figures are pinned to design.md
+npm test         # 178 tests; canon figures are pinned to design.md
 npm run build
 npm run screenshots  # drive the app and rephotograph docs/screenshots/
+npm run screenshots -- --only tour-storm   # just those shots, for working on one view
 ```
 
 `npm run dev` binds to every interface (`vite --host`) and prints a scannable QR
@@ -268,6 +276,11 @@ caption — still **walks you there**, which is the only way to cross the world
 one move: most of it is beyond the haze horizon from any given spot, so travelling
 is more use than turning.
 
+**The desert is a desert.** The Dry Reach rings the world with tiers of rock standing over
+dune fields, hills, dry washes routed the way water would have routed them, and salt pans
+in its lowest ground with the shorelines of lakes that never stood stepped around them.
+None of it is a remnant: Tower had seen deserts on Earth and built one.
+
 **The water is water.** Lakes, seas and rivers are surfaces at their own levels, with
 waves sized from each body's wind and fetch, rivers running downstream, and rapids white.
 A river that cannot slope falls: the Long Water drops 680 m off the range into a gorge,
@@ -365,6 +378,18 @@ sea (it is published the moment you add it), **revise** an event from now with a
 the storm eased because someone foolish went sailing — delete one, and set how many days
 the plan takes to repeat. None of this regenerates the ground.
 
+Select a **settlement** to set its size — hamlet, village, town, city or station — and how
+far it reaches across. A settlement is a point with an extent, not a traced outline: Tower
+lays the streets out inside it. The Capital is 20 km across, centred on the axial midpoint
+of the world, which is just wide enough to put its spinward edge on The Deep's shore.
+
+Select the **desert** to lay out its landforms: how high its dunes stand, how far apart,
+and which way Tower blows the sand; how much of it is rocky hills; how tall its plateaus
+are, how much of the ground they cover, and how many cliff bands they are cut into; and how
+much past water Tower quotes there — dry washes, how many salt pans, and whether they are
+ringed by the shorelines of lakes that never stood. The hint underneath says what was
+actually made: "24% standing rock, 37% dune field, 51% cut by washes, 4 salt pans."
+
 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/content/world.json b/content/world.json
index 6a3192b..65ee6bc 100644
--- a/content/world.json
+++ b/content/world.json
@@ -597,7 +597,27 @@
         "zTo": 218000
       },
       "upliftM": 780,
-      "edgeM": 20000
+      "edgeM": 20000,
+      "dunes": {
+        "coverage": 0.5,
+        "heightM": 80,
+        "spacingM": 1100,
+        "directionDeg": 0
+      },
+      "hills": {
+        "coverage": 0.3,
+        "heightM": 160
+      },
+      "plateaus": {
+        "coverage": 0.28,
+        "heightM": 520,
+        "terraces": 3
+      },
+      "pastWater": {
+        "washes": 0.7,
+        "saltPans": 4,
+        "shorelines": true
+      }
     },
     {
       "id": "the-uplands",
@@ -846,16 +866,17 @@
       "id": "the-capital",
       "name": "The Capital",
       "blurb": "",
-      "notes": "z = 270 km, the axial midpoint — the only centre a cylinder has. On The Deep's antispinward shore, so the city looks across 32 km of water at a far shore standing 2.6 km up in its sky. Deepward of The Narrows: it sits behind the world's only gate.",
+      "notes": "z = 270 km, the axial midpoint — the only centre a cylinder has. 20 km across, so the city holds the centre of the world and still reaches The Deep: its spinward edge is the waterfront, and from there it looks across 18 km of water at a far shore standing 2.6 km up in its sky. Deepward of The Narrows: it sits behind the world's only gate.",
       "public": true,
       "seed": 1711426401,
       "kind": "settlement",
       "geometry": {
         "type": "point",
-        "theta": 0.02,
+        "theta": 0.04,
         "z": 270000
       },
-      "size": "city"
+      "size": "city",
+      "radiusM": 10000
     },
     {
       "id": "the-spindle",
diff --git a/decisions.md b/decisions.md
index 2b7af5e..982e76e 100644
--- a/decisions.md
+++ b/decisions.md
@@ -2238,3 +2238,64 @@ honest about its warnings is also a better story than a fleet that vanishes on c
 readers were the default and `?writer=1` enabled editing. The app has always done the
 opposite — writer by default, `?reader=1` to preview — and the README said so. The docs now
 match the code; D-011's reasoning is unchanged.
+
+---
+
+## D-069 — The desert is quoted, not remembered
+
+**Built.** `world-generation.md` phase 5; §20 there has the detail.
+
+**Tower laid out the whole grammar of a desert, dry channels included.** The Dry Reach now
+has tiered plateaus and mesas over sand, dune fields whose crests run across the wind Tower
+sends, rocky hills, a network of dry washes, salt pans in its low ground, and the shoreline
+benches of lakes that never stood. None of it is history — §0.1 — and the washes are routed
+by the same drainage the ranges erode with precisely because a channel network that ignores
+drainage is the one thing that would look wrong.
+
+**Two findings worth keeping.** A flat desert has no drainage: water fans out from every
+edge and nothing gathers, so Tower crowns the Reach 150 m in its middle and the washes run
+off it. And widening a one-cell channel by blurring destroys it — dilate first, blur after.
+
+**Terracing belongs to the tiles, not to the compile.** The grid stores how far up the
+plateau the ground stands; the tile turns that into flat tops and steep risers at its own
+detail. The other way round, every cliff band is one grid cell wide, which is a ramp.
+
+**Tower does not build rock across water it has already routed.** Mesas dropped across The
+Narrows turned the world's whole drainage into a kilometre-deep slot. Rivers carry a 6.5 km
+corridor clear of plateau rock, which reads as the sand-and-gravel valley a river makes
+through a mesa field. The consequence recorded: the Long Water's canyon through the lower
+Dry Reach now runs about 780 m deep where the old test expected under 700, because the rim
+above it is the desert's rock as often as the ridge's.
+
+**Materials gained `salt`, and rock is now claimed rather than inferred.** Bare rock came
+only from mountain relief before; it now comes from the desert's plateaus and wash floors
+and from any steep desert face, since sand does not stand at 60°. The ground shader lays
+strata by elevation — a level in Tower is a constant radius — so the bands line up across
+separate mesas, which is what makes a mesa field read as one formation.
+
+---
+
+## D-070 — The Capital is a footprint, and it reaches the water
+
+**Built** alongside phase 5, at the author's suggestion.
+
+**The Capital was a dot 11 km inland of the lake it was said to stand on.** design.md §1.11
+described it as sitting on The Deep's antispinward shore; the generated water starts
+10.75 km spinward of its marker, on ground falling from 300 m to the water's 120 m. The fix
+the author chose is the better one: make the city big enough to be both things at once.
+
+**So a settlement is now a point with an extent** — a size and a radius — rather than a
+marker alone. The author places where a town stands and how far it reaches; Tower lays the
+streets and buildings out inside that footprint (phase 7). The map draws the extent as a
+ring around the marker, and each size carries the usual radius a new settlement starts from.
+
+**The Capital is 20 km across, centred on the axial midpoint of the world** — the only
+centre a cylinder has — which is just wide enough that its spinward edge is the waterfront.
+It holds the centre of the world and a shore on The Deep, and nothing had to move to make
+that true but 2 km of marker.
+
+**A canon figure corrected on the way:** §1.11 said the far shore was "32 km away and
+2.6 km above eye level". Thirty-two kilometres around the cylinder would stand 9.9 km above
+the eye; 2.6 km is the figure for the 18 km of water The Deep actually fills, which is what
+the crossing is. The outline is 154 × 32 km; a flooded basin's water is narrower than its
+outline, as it should be.
diff --git a/design.md b/design.md
index e421164..bbac896 100644
--- a/design.md
+++ b/design.md
@@ -547,16 +547,32 @@ along the axis**, which is the natural grain of an engineered climate (§1.9.4):
 | 0–42 km | **The Sunward Sea** | ring ocean at the foot of the sun cap |
 | 55–140 km | **the range** | Westwall, The Sisters, **Snowcrown** (4,300 m), Eastwall — the named high points of **The Girdle**, which rings the world at z 90 km (D-051) |
 | 105–150 km | **The Uplands** | ground Tower raised to about 1,000 m, ringing the world, so the rivers off the range can run down to The Narrows (D-064) |
-| 148–218 km | **The Dry Reach** | desert **ringing the world**, 22,000 km², 13% of the surface |
+| 148–218 km | **The Dry Reach** | desert **ringing the world**, 22,000 km², 13% of the surface: mesas over dune fields, dry washes, and salt pans ringed by shorelines (D-069) |
 | 176/190/203 km | **three ridges** | ringing, concentric — and **The Narrows** is the only breach in all three |
 | 214–430 km | **Greatwood** | forest belt |
 | 248–402 km | **The Deep** | 154 × 32 km sea, long down the axis and narrow around |
-| 270 km | **The Capital / The Spindle** | axial midpoint, on The Deep's antispinward shore |
+| 270 km | **The Capital / The Spindle** | axial midpoint; 20 km across, its spinward edge on The Deep's shore (D-070) |
 | 496–540 km | **The Deepward Sea** | ring ocean at the foot of the far cap |
 
 Both ring seas carry ninety sailing ships each, and Tower's published weather plan
 (D-067) runs a 28-day cycle of storms, gales and a calm over them and The Deep.
 
+#### What the desert is made of
+
+The Dry Reach is not sand with some rock in it; it is the whole grammar of a desert,
+because Tower had seen deserts (D-069). Tiers of rock stand over dune fields whose crests
+run across the wind Tower sends there; rocky hills break up the open ground; a network of
+**dry washes** is routed by the same drainage the ranges are eroded by, because a channel
+network that ignores drainage is the one thing that would look wrong. In the lowest ground
+there are **salt pans**, dead flat, ringed by the **shoreline benches** of lakes that never
+stood. The middle of the Reach stands 150 m above its rim so that the washes have somewhere
+to run, and Tower keeps its rock 6.5 km clear of the rivers it had already routed — a mesa
+across The Narrows would leave the world's drainage in a slot.
+
+Rock is laid in beds, and a bed in Tower is a **constant radius**, so the strata line up
+across separate mesas however far apart they stand. That is what makes a field of them read
+as one formation eroded out of one plateau.
+
 #### Why the desert rings
 
 **On a cylinder you cannot go around a band.** Every desert on Earth can be
@@ -596,9 +612,16 @@ under them is mostly cut — The Narrows about 500 m deep — and built up by no
 #### The Deep is oriented for the view
 
 Long down the axis and narrow around it, so you cross it the **short** way — around
-θ, where the floor curves up. From the Capital the far shore is 32 km away and
-stands **2.6 km above eye level**. Crossing it lengthwise instead would be dead
-flat. The orientation is the whole point.
+θ, where the floor curves up. The authored outline is 154 × 32 km and the water fills about
+18 km of that width; from the Capital's waterfront the far shore is that 18 km away and
+stands **2.6 km above eye level**. Crossing it lengthwise instead would be dead flat. The
+orientation is the whole point.
+
+**The Capital is a footprint, not a dot** (D-070). It is 20 km across, centred on the axial
+midpoint — the only centre a cylinder has — which is just wide enough that its spinward edge
+is on The Deep's shore, 9.8 km out, where the ground has come down from 300 m to the
+water's 120 m. So the city holds both the middle of the world and a waterfront. The streets
+and buildings inside that footprint are phase 7's.
 
 It is also the world's flywheel: 19× the diurnal heat storage of bare ground, and
 5.9 days at full power to move its temperature one kelvin. Tower still supplies
@@ -672,6 +695,8 @@ src/
                     main thread and under vitest alike (world-generation.md §5)
     compile.ts      the world compile: shapes, river profiles, lake levels
     mountains.ts    summits, ridge graphs, steady-state erosion, pinned summits
+    grid.ts         grids on the surface: drainage, blur, sampling — shared by the above
+    desert.ts       plateaus and mesas, dry washes, salt pans, abandoned shorelines
     water.ts        wind seas, fetch, river speed and foam: the water's physics
     sea-state.ts    every lake and sea's waves and whitecaps now, from the weather
     ships.ts        ship lanes, positions from the clock, the wave sum they ride
@@ -1079,7 +1104,8 @@ Phase 1 ✅ (D-063): world file v2, the elevation function, the worker pool, til
 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. Phase 3
 ✅ (D-066): water as surfaces with waves and flow, and waterfalls that lean. Phase 4 ✅
-(D-067): Tower's weather plan with revisions, a day on the clock, and ships. Phases 5–8
-bring deserts, forests, settlements and roads — procedural scenery this section used to
-list as out of scope. Clouds and rain stay
+(D-067): Tower's weather plan with revisions, a day on the clock, and ships. Phase 5 ✅
+(D-069): the desert's mesas, dunes, washes, salt pans and shorelines. Phases 6–8 bring
+forests, settlements and roads — procedural scenery 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 a5a49f2..0648b8b 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 f41595a..a62bc3e 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 f67ad41..0113287 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 1a7b366..47bdd3f 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 d1cee16..ee8670c 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-suncap.png b/docs/screenshots/desktop-interior-suncap.png
index 0e878fe..b0119b5 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 e2ec16e..3715955 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 8e95896..b39ed84 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 5719dbf..6740459 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 6050db2..5e9f17c 100644
Binary files a/docs/screenshots/desktop-reader.png and b/docs/screenshots/desktop-reader.png differ
diff --git a/docs/screenshots/desktop-tour-deep.png b/docs/screenshots/desktop-tour-deep.png
index 16cfcfe..91fe3d1 100644
Binary files a/docs/screenshots/desktop-tour-deep.png and b/docs/screenshots/desktop-tour-deep.png differ
diff --git a/docs/screenshots/desktop-tour-falls.png b/docs/screenshots/desktop-tour-falls.png
index 152abd5..4b2fbf5 100644
Binary files a/docs/screenshots/desktop-tour-falls.png and b/docs/screenshots/desktop-tour-falls.png differ
diff --git a/docs/screenshots/desktop-tour-girdle.png b/docs/screenshots/desktop-tour-girdle.png
index 2e4666d..aa2d2e3 100644
Binary files a/docs/screenshots/desktop-tour-girdle.png and b/docs/screenshots/desktop-tour-girdle.png differ
diff --git a/docs/screenshots/desktop-tour-mesas.png b/docs/screenshots/desktop-tour-mesas.png
new file mode 100644
index 0000000..36345a0
Binary files /dev/null and b/docs/screenshots/desktop-tour-mesas.png differ
diff --git a/docs/screenshots/desktop-tour-narrows.png b/docs/screenshots/desktop-tour-narrows.png
index 5308203..f3daaba 100644
Binary files a/docs/screenshots/desktop-tour-narrows.png and b/docs/screenshots/desktop-tour-narrows.png differ
diff --git a/docs/screenshots/desktop-tour-pan.png b/docs/screenshots/desktop-tour-pan.png
new file mode 100644
index 0000000..550b3dc
Binary files /dev/null and b/docs/screenshots/desktop-tour-pan.png differ
diff --git a/docs/screenshots/desktop-tour-river.png b/docs/screenshots/desktop-tour-river.png
index 102d240..45cefda 100644
Binary files a/docs/screenshots/desktop-tour-river.png and b/docs/screenshots/desktop-tour-river.png differ
diff --git a/docs/screenshots/desktop-tour-ship.png b/docs/screenshots/desktop-tour-ship.png
index 451cdae..b32a83b 100644
Binary files a/docs/screenshots/desktop-tour-ship.png and b/docs/screenshots/desktop-tour-ship.png differ
diff --git a/docs/screenshots/desktop-tour-squall.png b/docs/screenshots/desktop-tour-squall.png
index 5fe4344..b023fa5 100644
Binary files a/docs/screenshots/desktop-tour-squall.png and b/docs/screenshots/desktop-tour-squall.png differ
diff --git a/docs/screenshots/desktop-tour-storm.png b/docs/screenshots/desktop-tour-storm.png
index d057f13..958a624 100644
Binary files a/docs/screenshots/desktop-tour-storm.png and b/docs/screenshots/desktop-tour-storm.png differ
diff --git a/docs/screenshots/desktop-tour-summit.png b/docs/screenshots/desktop-tour-summit.png
index bdcf9a9..449ed59 100644
Binary files a/docs/screenshots/desktop-tour-summit.png and b/docs/screenshots/desktop-tour-summit.png differ
diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png
index 477746b..4275098 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 a92edb0..c291002 100644
Binary files a/docs/screenshots/mobile-interior.png and b/docs/screenshots/mobile-interior.png differ
diff --git a/docs/screenshots/mobile-map.png b/docs/screenshots/mobile-map.png
index 1c95243..d2d9f35 100644
Binary files a/docs/screenshots/mobile-map.png and b/docs/screenshots/mobile-map.png differ
diff --git a/docs/screenshots/mobile-panel.png b/docs/screenshots/mobile-panel.png
index 2945722..8e6689a 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 9508aad..41f9b32 100644
Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ
diff --git a/index.html b/index.html
index 91a9fa2..bbf48ea 100644
--- a/index.html
+++ b/index.html
@@ -174,6 +174,46 @@
           <input id="f-wind-speed" type="range" min="0" max="30" step="1" /></label>
         <p class="hint" id="f-sea-state"></p>
       </div>
+      <div id="f-settlement" hidden>
+        <label>Size <select id="f-size">
+          <option value="hamlet">Hamlet</option>
+          <option value="village">Village</option>
+          <option value="town">Town</option>
+          <option value="city">City</option>
+          <option value="station">Station</option>
+        </select></label>
+        <label>Across, km <span class="dim">— Tower lays the streets out inside this</span>
+          <input id="f-extent" type="number" min="0.1" max="80" step="0.5" /></label>
+      </div>
+      <div id="f-desert" hidden>
+        <p class="sub">Dunes <span class="dim">— crests run across the wind Tower sends here</span></p>
+        <div class="pair">
+          <label>Height, m <input id="f-dune-height" type="number" min="0" max="300" step="5" /></label>
+          <label>Spacing, m <input id="f-dune-spacing" type="number" min="50" max="4000" step="50" /></label>
+        </div>
+        <div class="pair">
+          <label>Blowing toward, ° <input id="f-dune-dir" type="number" min="0" max="359" step="15" /></label>
+          <label>Coverage <input id="f-dune-cover" type="number" min="0" max="1" step="0.05" /></label>
+        </div>
+        <p class="sub">Rock</p>
+        <div class="pair">
+          <label>Hills, m <input id="f-hill-height" type="number" min="0" max="600" step="10" /></label>
+          <label>Hill coverage <input id="f-hill-cover" type="number" min="0" max="1" step="0.05" /></label>
+        </div>
+        <div class="pair">
+          <label>Plateaus, m <input id="f-plat-height" type="number" min="0" max="1500" step="20" /></label>
+          <label>Coverage <input id="f-plat-cover" type="number" min="0" max="1" step="0.05" /></label>
+        </div>
+        <label>Terraces <span class="dim">— cliff bands between flat tops</span>
+          <input id="f-plat-terraces" type="number" min="1" max="8" step="1" /></label>
+        <p class="sub">Past water <span class="dim">— marks of water that never ran here</span></p>
+        <div class="pair">
+          <label>Washes <input id="f-washes" type="number" min="0" max="1" step="0.1" /></label>
+          <label>Salt pans <input id="f-pans" type="number" min="0" max="12" step="1" /></label>
+        </div>
+        <label class="row"><input id="f-shorelines" type="checkbox" /> Shorelines around the pans</label>
+        <p class="hint" id="f-desert-state"></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/src/terrain/compile.ts b/src/terrain/compile.ts
index 1f56dfa..656c3a3 100644
--- a/src/terrain/compile.ts
+++ b/src/terrain/compile.ts
@@ -13,8 +13,9 @@
  * the answer.
  */
 import { RADIUS_M } from '../canon/constants.js'
-import type { Feature, Lake, Mountains, River, Sea, World } from '../world/schema.js'
+import type { Desert as DesertFeature, Feature, Lake, Mountains, River, Sea, World } from '../world/schema.js'
 import { type CMountainGroup, compileMountains } from './mountains.js'
+import { type CDesert, compileDeserts } from './desert.js'
 import { FALL_MIN_M, foamOf, manningSpeed } from './water.js'
 import { hash3 } from './noise.js'
 import {
@@ -108,6 +109,8 @@ export interface CompiledWorld {
   seed: number
   ground: { baseM: number; reliefM: number }
   uplifts: CUplift[]
+  /** The deserts' compiled landforms: plateaus, washes, salt pans (desert.ts). */
+  deserts: CDesert[]
   mountains: CMountainGroup[]
   basins: CBasin[]
   rivers: CRiver[]
@@ -132,7 +135,7 @@ export function compileWorld(world: World, onStep: StepListener = () => {}): Com
   const cw: CompiledWorld = {
     seed: world.seed,
     ground: { baseM: world.ground.baseM, reliefM: world.ground.reliefM },
-    uplifts: [], mountains: [], basins: [], rivers: [], falls: [],
+    uplifts: [], deserts: [], mountains: [], basins: [], rivers: [], falls: [],
   }
   const seedOf = (f: Feature): number => hash3(world.seed, f.seed, 0x5eed)
 
@@ -155,6 +158,20 @@ export function compileWorld(world: World, onStep: StepListener = () => {}): Com
     })
   }
 
+  /**
+   * The deserts' own landforms stand on the shelf the uplift above raised, so they are
+   * compiled after it and before the ranges — which may stand on a desert's plateau.
+   */
+  // The shelf a desert's landforms stand on: the open ground and the uplift under it, so a
+  // salt pan's floor and its shorelines are elevations in the world rather than in the sand.
+  const shelf = prepare({ ...cw, deserts: [], mountains: [], basins: [], rivers: [] })
+  cw.deserts = compileDeserts(
+    world.features.filter((f): f is DesertFeature => f.kind === 'desert'), world.seed, seedOf, onStep,
+    // The rivers, as authored: the desert keeps its plateaus clear of them (desert.ts).
+    world.features.flatMap((f) => (f.kind === 'river' && f.geometry.type === 'polyline'
+      ? [smoothLine(f.geometry.points, 400)] : [])),
+    (z, a) => shelf.sample(a / RADIUS_M, z, 1).h)
+
   // 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 []
diff --git a/src/terrain/desert.ts b/src/terrain/desert.ts
new file mode 100644
index 0000000..f31ab89
--- /dev/null
+++ b/src/terrain/desert.ts
@@ -0,0 +1,422 @@
+/**
+ * The desert: plateaus and mesas, dry washes, salt pans, and the shorelines of lakes that
+ * never were. See world-generation.md §4.5.
+ *
+ * Nothing here is a remnant (§0.1). Tower built a desert because it had seen deserts, so
+ * it laid out the whole grammar of one: tiers of rock standing over sand, a drainage of dry
+ * channels routed the way water would have routed them, flats of salt in the lowest ground,
+ * and bathtub rings around them at the levels a lake would have stood. The washes are cut
+ * by the same priority-flood routing the ranges erode with (grid.ts) — because a channel
+ * network that does not obey drainage is the one thing that would look wrong.
+ *
+ * What is compiled here is what a tile cannot work out for itself: the plateau field, the
+ * wash network, and where the pans and their shorelines are. Dunes and rocky hills are
+ * noise and are added by the tiles (field.ts), which is also what keeps them crisp — the
+ * plateau's height fraction is compiled smooth and the **terraces are applied as the tile
+ * samples it**, so a cliff band is as sharp as the tile is fine, not as sharp as a 250 m
+ * grid allows.
+ */
+import { type Grid, Heap, blur, drainage, sampleGrid } from './grid.js'
+import { type CylPoint, cylPoint, fbm, hash3, mulberry32, ridged } from './noise.js'
+import {
+  type Box, LineIndex, type Line, type Nearest, nearestA, nearestOnSegments, shapeBox, shapeOf,
+  signedDistance, type Shape,
+} from './shapes.js'
+import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js'
+import type { Desert } from '../world/schema.js'
+
+/** Cell size of the desert grid. Fine enough for a wash, coarse enough for 22,000 km². */
+export const DESERT_CELL_M = 250
+
+export interface CPan {
+  z: number
+  a: number
+  /** Elevation of the pan floor above the datum. */
+  levelM: number
+  radiusM: number
+}
+
+export interface CDesert {
+  id: string
+  shape: Shape
+  box: Box
+  grid: Grid
+  /** 0..1 how much of this cell is desert at all: it fades out over the edge, and is 0
+   * in the corridors kept clear for the rivers. */
+  inside: Float32Array
+  /**
+   * The desert's own broad shape, metres: it crowns toward its middle, so the washes have
+   * somewhere to run. Compiled rather than computed twice, so the routing and the drawn
+   * ground are the same surface by construction.
+   */
+  base: Float32Array
+  /** 0..1 of the way up the plateau, smooth; the tiles terrace it. */
+  plateau: Float32Array
+  /** How deep the washes cut here, metres. */
+  cut: Float32Array
+  /** 0..1 salt crust: the pan floors, and the elevation each pan's floor stands at. */
+  salt: Float32Array
+  panLevelM: Float32Array
+  /** 0..1 how much of the ground here is a shoreline bench, and the pan's elevation. */
+  bench: Float32Array
+  benchLevelM: Float32Array
+  /** Where the dunes are, 0..1: the open sand, away from rock. */
+  sand: Float32Array
+  /** The salt pans Tower laid out, for anything that wants to look at one. */
+  pans: CPan[]
+  plateauHeightM: number
+  terraces: number
+  hillsHeightM: number
+  hillsCoverage: number
+  duneHeightM: number
+  duneSpacingM: number
+  duneDirRad: number
+  /** Whole dune wavelengths around the circumference, so the field closes on itself. */
+  dunePeriods: number
+  duneCoverage: number
+  seed: number
+}
+
+/** Height of the shoreline benches above a pan, and how many stand there. */
+export const BENCH_STEP_M = 20
+export const BENCH_COUNT = 3
+
+/**
+ * The terrace profile: flat tops with steep risers between them. `p` is how far up the
+ * plateau this ground stands, 0..1; the result is the same range, in `terraces` tiers.
+ */
+export function terrace(p: number, terraces: number): number {
+  const x = Math.min(1, Math.max(0, p)) * terraces
+  const tier = Math.min(terraces - 1, Math.floor(x))
+  const f = x - tier
+  // The riser occupies the middle of each tier's band, so tops are flat and faces are steep.
+  const riser = f <= 0.42 ? 0 : f >= 0.78 ? 1 : (f - 0.42) / 0.36
+  const smooth = riser * riser * (3 - 2 * riser)
+  return (tier + smooth) / terraces
+}
+
+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)
+}
+
+/** The threshold on a 0..1 field that leaves `coverage` of it above, from a sample of it. */
+function quantile(samples: number[], coverage: number): number {
+  if (!samples.length) return 1
+  const sorted = [...samples].sort((x, y) => x - y)
+  const i = Math.min(sorted.length - 1, Math.max(0, Math.round((1 - coverage) * (sorted.length - 1))))
+  return sorted[i]!
+}
+
+/** How far the middle of a desert stands above its rim, so that washes have a fall. */
+export const CROWN_M = 150
+
+/** Drainage area past which a wash is a channel: fifteen square kilometres of desert. */
+const WASH_AREA_M2 = 1.5e7
+
+/**
+ * `corridors` are lines the desert keeps its rock away from: the rivers Tower had already
+ * routed through here. A mesa dropped across The Narrows would leave the world's whole
+ * drainage cutting a kilometre-deep slot through it, which is not what Tower would build.
+ */
+export function compileDeserts(
+  features: Desert[], worldSeed: number, seedOf: (f: Desert) => number,
+  onStep: (label: string) => void = () => {}, corridors: Line[] = [],
+  landAt: (z: number, a: number) => number = () => 0,
+): CDesert[] {
+  const out: CDesert[] = []
+  for (const f of features) {
+    const shape = shapeOf(f.geometry)
+    if (!shape) continue
+    out.push(compileDesert(f, shape, seedOf(f), worldSeed, onStep, corridors, landAt))
+  }
+  return out
+}
+
+/**
+ * How wide a river's corridor is kept clear of plateau rock: a valley of sand and gravel
+ * either side of the water, which is also what a river crossing a mesa field looks like.
+ */
+export const CORRIDOR_M = 6500
+
+function compileDesert(
+  f: Desert, shape: Shape, seed: number, worldSeed: number, onStep: (label: string) => void,
+  corridors: Line[], landAt: (z: number, a: number) => number,
+): CDesert {
+  const box = shapeBox(shape)
+  const ring = shape.type === 'band'
+  const margin = f.edgeM * 0.5
+  const z0 = box.z0 - margin
+  const a0 = ring ? 0 : box.a0 - margin
+  const na = ring
+    ? Math.round(CIRCUMFERENCE_M / DESERT_CELL_M)
+    : Math.max(4, Math.ceil((box.a1 + margin - a0) / DESERT_CELL_M) + 1)
+  // A ring's cells divide the circumference exactly, or the grid has a seam where it wraps.
+  const cell = ring ? CIRCUMFERENCE_M / na : DESERT_CELL_M
+  const nz = Math.max(4, Math.ceil((box.z1 + margin - z0) / cell) + 1)
+  const grid: Grid = { z0, a0, cell, nz, na, ring }
+  const n = nz * na
+  const rand = mulberry32(hash3(worldSeed, seed, 0xd35e27))
+
+  const at = (k: number): { z: number; a: number; p: CylPoint } => {
+    const i = Math.floor(k / na), j = k % na
+    const z = z0 + i * cell, a = a0 + j * cell
+    return { z, a, p: cylPoint(a / RADIUS_M, z) }
+  }
+
+  // How much of each cell is desert at all: it fades out over the outer half of the edge.
+  onStep(`Quoting the deserts of Earth in ${f.name}`)
+  const inside = new Float32Array(n)
+  const plateauNoise = new Float32Array(n)
+  const samples: number[] = []
+  const indexes = corridors.map((line) => new LineIndex(line, CORRIDOR_M))
+  const near: Nearest = { d: 0, seg: 0, t: 0 }
+  const clear = (z: number, a: number): number => {
+    let m = 1
+    for (let i = 0; i < corridors.length; i++) {
+      const segs = indexes[i]!.near(z, a)
+      if (!segs.length) continue
+      nearestOnSegments(corridors[i]!, z, a, segs, near)
+      m = Math.min(m, smoothstep(CORRIDOR_M * 0.45, CORRIDOR_M, near.d))
+    }
+    return m
+  }
+  for (let k = 0; k < n; k++) {
+    const { z, a, p } = at(k)
+    // Landforms begin at the desert's boundary and are fully themselves a few km in.
+    inside[k] = smoothstep(0, f.edgeM * 0.35, signedDistance(shape, z, a))
+    if (inside[k]! <= 0) continue
+    inside[k]! *= clear(z, a)
+    if (inside[k]! <= 0) continue
+    // Broad blotches the size of a mesa group, warped so their edges are not noise-smooth.
+    const warp = fbm(seed ^ 0x11, p, 9000, 2, cell) - 0.5
+    plateauNoise[k] = fbm(seed ^ 0x27, p, 26_000, 4, cell) + warp * 0.12
+    if (k % 7 === 0) samples.push(plateauNoise[k]!)
+  }
+
+  /**
+   * The plateau field. A threshold picked to leave the authored coverage above it, and a
+   * narrow band of noise either side of it: that band's width is what makes the cliffs
+   * steep, since the whole rise happens across it.
+   */
+  /**
+   * The crown. A desert that is dead flat has no drainage at all — water on it fans out
+   * from wherever it lands and nothing gathers into a channel — so Tower raised the middle
+   * of the Reach a little, and everything runs off it to the edges.
+   */
+  const half = ring ? (box.z1 - box.z0) / 2 : Math.max(500, Math.min(box.z1 - box.z0, box.a1 - box.a0) / 2)
+  const base = new Float32Array(n)
+  for (let k = 0; k < n; k++) {
+    if (inside[k]! <= 0) continue
+    const { z, a, p } = at(k)
+    const t = Math.min(1, Math.max(0, signedDistance(shape, z, a) / half))
+    base[k] = CROWN_M * (1 - (1 - t) * (1 - t)) * inside[k]!
+      + (fbm(layer(seed, 8), p, 7000, 3, cell) - 0.5) * 40 * inside[k]!
+  }
+
+  const thr = quantile(samples, f.plateaus.coverage)
+  const plateau = new Float32Array(n)
+  for (let k = 0; k < n; k++) {
+    if (inside[k]! <= 0) continue
+    plateau[k] = smoothstep(thr, thr + 0.05, plateauNoise[k]!) * inside[k]!
+  }
+
+  /**
+   * The surface the washes are routed over: the crown, the terraced rock and the rocky
+   * hills, all exactly as the tiles make them (field.ts `desert`), so a wash is cut into
+   * the ground that is actually drawn.
+   */
+  const surface = new Float32Array(n)
+  for (let k = 0; k < n; k++) {
+    if (inside[k]! <= 0) continue
+    const { z, a, p } = at(k)
+    // Above the datum, shelf and all: a pan's floor is a height in the world, and the
+    // benches around it are levels in the world, so both are measured from there.
+    surface[k] = landAt(z, a) + base[k]! + terrace(plateau[k]!, f.plateaus.terraces) * f.plateaus.heightM
+      + hillsAt(f, seed, p, cell, plateau[k]!, inside[k]!)
+  }
+
+  const cut = new Float32Array(n)
+  const salt = new Float32Array(n)
+  const panLevelM = new Float32Array(n)
+  const bench = new Float32Array(n)
+  const benchLevelM = new Float32Array(n)
+  const pans: CPan[] = []
+
+  const work = {
+    receiver: new Int32Array(n), stack: new Int32Array(n), reached: new Uint8Array(n),
+    key: new Float64Array(n), heap: new Heap(n),
+  }
+  /** Off the desert, the ground carries the water away: those cells are the outlets. */
+  const outlet = new Uint8Array(n)
+  for (let k = 0; k < n; k++) if (inside[k]! <= 0.02) outlet[k] = 1
+  // A ring desert has no outside along theta, so its two ends are the way out.
+  if (ring) for (let j = 0; j < na; j++) { outlet[j] = 1; outlet[(nz - 1) * na + j] = 1 }
+
+  if (f.pastWater.washes > 0) {
+    onStep(`Cutting riverbeds for rivers that never ran in ${f.name}`)
+    const top = drainage(grid, surface, outlet, work)
+    // Drainage area, accumulated from the far end of the stack back down to the outlets.
+    const area = new Float64Array(n).fill(cell * cell)
+    for (let s = top - 1; s >= 0; s--) {
+      const k = work.stack[s]!
+      const r = work.receiver[k]!
+      if (r >= 0) area[r]! += area[k]!
+    }
+    for (let k = 0; k < n; k++) {
+      if (inside[k]! <= 0 || area[k]! < WASH_AREA_M2) continue
+      // A wash deepens with what it would drain, slowly: a big one is tens of metres.
+      const depth = 4 + 14 * Math.log10(area[k]! / WASH_AREA_M2)
+      cut[k] = Math.min(55, depth) * f.pastWater.washes * inside[k]!
+    }
+    /**
+     * A wash is wider than one cell and flat-floored. Widening is a *dilation* — the
+     * deepest cut within a couple of cells — and only then a blur for the banks: blurring
+     * a one-cell channel straight away had averaged its depth away to nothing.
+     */
+    const wide = new Float32Array(n)
+    const reach = 2
+    for (let i = 0; i < nz; i++) {
+      for (let j = 0; j < na; j++) {
+        let m = 0
+        for (let di = -reach; di <= reach; di++) {
+          const ii = i + di
+          if (ii < 0 || ii >= nz) continue
+          for (let dj = -reach; dj <= reach; dj++) {
+            const jj = ring ? ((j + dj) % na + na) % na : j + dj
+            if (!ring && (jj < 0 || jj >= na)) continue
+            m = Math.max(m, cut[ii * na + jj]!)
+          }
+        }
+        wide[i * na + j] = m
+      }
+    }
+    const banked = blur(grid, wide, 1)
+    for (let k = 0; k < n; k++) cut[k] = Math.max(cut[k]!, banked[k]!)
+  }
+
+  if (f.pastWater.saltPans > 0) {
+    onStep(`Laying salt in the low ground of ${f.name}`)
+    const floor = new Float32Array(n)
+    for (let k = 0; k < n; k++) floor[k] = surface[k]! - cut[k]!
+    /**
+     * Where a pan goes: the lowest ground, spaced out, so the set pieces land where the
+     * desert is deepest rather than on top of each other. A pan is a disc — Tower is
+     * placing these, not discovering them (§4.5) — flattened to one level, with the
+     * ground outside it stepped down in benches: the rings of a lake that never stood.
+     */
+    const order = Array.from({ length: n }, (_, k) => k)
+      .filter((k) => inside[k]! > 0.9)
+      .sort((x, y) => floor[x]! - floor[y]!)
+    const wanted = f.pastWater.saltPans
+    for (const k of order) {
+      if (pans.length >= wanted) break
+      const { z, a } = at(k)
+      const radiusM = 1500 + 1600 * rand()
+      const spacing = radiusM * 4
+      if (pans.some((q) => Math.hypot(q.z - z, nearestA(q.a, a) - a) < spacing)) continue
+      pans.push({ z, a, levelM: floor[k]!, radiusM })
+    }
+    for (const pan of pans) {
+      const reach = pan.radiusM + (f.pastWater.shorelines ? BENCH_STEP_M * BENCH_COUNT * 24 : 0)
+      const cells = Math.ceil(reach / cell)
+      const ci = Math.round((pan.z - z0) / cell), cj = Math.round((pan.a - a0) / cell)
+      for (let i = Math.max(0, ci - cells); i <= Math.min(nz - 1, ci + cells); i++) {
+        for (let jj = cj - cells; jj <= cj + cells; jj++) {
+          const j = ring ? ((jj % na) + na) % na : jj
+          if (!ring && (j < 0 || j >= na)) continue
+          const k = i * na + j
+          const d = Math.hypot(z0 + i * cell - pan.z, a0 + jj * cell - pan.a)
+          if (d > reach) continue
+          /**
+          * The floor of the pan, and a crust that frays at the very edge. The level is
+          * written well beyond the crust as well, or sampling it near the rim would
+          * interpolate against the zeros outside and tilt the floor.
+          */
+          const flat = 1 - smoothstep(pan.radiusM * 0.94, pan.radiusM, d)
+          if (flat > salt[k]!) salt[k] = flat
+          if (d < pan.radiusM * 1.5) panLevelM[k] = pan.levelM
+          if (f.pastWater.shorelines && d > pan.radiusM * 0.9) {
+            const b = (1 - smoothstep(reach * 0.75, reach, d)) * smoothstep(pan.radiusM * 0.9, pan.radiusM * 1.05, d)
+            if (b > bench[k]!) { bench[k] = b; benchLevelM[k] = pan.levelM }
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * Where the dunes are: the open ground, away from rock and off the salt, and only in the
+   * share of it the authored coverage asks for — thresholded against the *measured* spread
+   * of the noise, as the plateaus are, because a threshold guessed against an assumed
+   * spread puts dunes over everything or nowhere. Blurred, since a dune field is
+   * kilometres across or it is not a field.
+   */
+  const open = new Float32Array(n)
+  const fieldNoise = new Float32Array(n)
+  const fieldSamples: number[] = []
+  for (let k = 0; k < n; k++) {
+    open[k] = inside[k]! * (1 - Math.min(1, plateau[k]! * 2.5)) * (1 - salt[k]!)
+    if (open[k]! <= 0.2) continue
+    const { p } = at(k)
+    fieldNoise[k] = fbm(layer(seed, 5), p, 21_000, 3, cell)
+    if (k % 7 === 0) fieldSamples.push(fieldNoise[k]!)
+  }
+  const fieldThr = quantile(fieldSamples, f.dunes.coverage)
+  for (let k = 0; k < n; k++) {
+    if (open[k]! <= 0.2) { open[k] = 0; continue }
+    open[k]! *= smoothstep(fieldThr - 0.01, fieldThr + 0.04, fieldNoise[k]!)
+  }
+  const sand = blur(grid, open, 4)
+  for (let k = 0; k < n; k++) sand[k] = Math.min(1, Math.max(0, sand[k]! * 1.2))
+
+  return {
+    id: f.id, shape, box: grow(box, f.edgeM * 1.2), grid,
+    inside, base, plateau, cut, salt, panLevelM, bench, benchLevelM, sand, pans,
+    plateauHeightM: f.plateaus.heightM, terraces: f.plateaus.terraces,
+    hillsHeightM: f.hills.heightM, hillsCoverage: f.hills.coverage,
+    duneHeightM: f.dunes.heightM, duneSpacingM: f.dunes.spacingM,
+    duneDirRad: (f.dunes.directionDeg * Math.PI) / 180,
+    dunePeriods: Math.round((CIRCUMFERENCE_M * Math.sin((f.dunes.directionDeg * Math.PI) / 180)) / f.dunes.spacingM),
+    duneCoverage: f.dunes.coverage,
+    seed,
+  }
+}
+
+/**
+ * The rocky hills, as both the compile and the tiles make them: low ridged domes on the
+ * open ground. Shared so the washes are routed over the ground the tiles actually draw.
+ */
+export function hillsAt(
+  f: { hills: { coverage: number; heightM: number } }, seed: number, p: CylPoint, detailM: number,
+  plateau: number, inside: number,
+): number {
+  if (f.hills.heightM <= 0 || f.hills.coverage <= 0) return 0
+  const where = fbm(layer(seed, 3), p, 14_000, 3, detailM)
+  const m = smoothstep(0.92 - f.hills.coverage, 1.02 - f.hills.coverage, where) * inside * (1 - plateau)
+  if (m <= 0) return 0
+  return Math.max(0, (ridged(layer(seed, 4), p, 2600, 3, detailM) - 0.4) * 1.7) * f.hills.heightM * m
+}
+
+/** One layer of a feature's noise, as field.ts numbers them. */
+export const layer = (seed: number, i: number): number => hash3(seed, i, 0x1f7a)
+
+const grow = (b: Box, m: number): Box => ({ z0: b.z0 - m, z1: b.z1 + m, a0: b.a0 - m, a1: b.a1 + m })
+
+/** The compiled fields at a point, for the tiles. */
+export function sampleDesert(d: CDesert, z: number, a: number): {
+  inside: number; base: number; plateau: number; cut: number; salt: number; panLevelM: number
+  bench: number; benchLevelM: number; sand: number
+} {
+  return {
+    inside: sampleGrid(d.grid, d.inside, z, a),
+    base: sampleGrid(d.grid, d.base, z, a),
+    plateau: sampleGrid(d.grid, d.plateau, z, a),
+    cut: sampleGrid(d.grid, d.cut, z, a),
+    salt: sampleGrid(d.grid, d.salt, z, a),
+    panLevelM: sampleGrid(d.grid, d.panLevelM, z, a),
+    bench: sampleGrid(d.grid, d.bench, z, a),
+    benchLevelM: sampleGrid(d.grid, d.benchLevelM, z, a),
+    sand: sampleGrid(d.grid, d.sand, z, a),
+  }
+}
diff --git a/src/terrain/field.ts b/src/terrain/field.ts
index bb399e5..612fd40 100644
--- a/src/terrain/field.ts
+++ b/src/terrain/field.ts
@@ -19,8 +19,9 @@
  * 30 km across passes a few hundred metres; a tile under the observer's feet passes
  * a metre. Same function, same answer, fewer octaves where they could not be seen.
  */
-import { RADIUS_M } from '../canon/constants.js'
+import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js'
 import { type CBasin, type CRiver, type CUplift, type CompiledWorld, EMBANKMENT_SLOPE } from './compile.js'
+import { BENCH_STEP_M, type CDesert, hillsAt, sampleDesert, terrace } from './desert.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'
@@ -65,6 +66,10 @@ export interface Sample {
   forest: number
   /** Relief the mountains added here, metres: what decides bare rock. */
   relief: number
+  /** Bare rock the desert claims here, 0..1: plateau tops, cliff bands, gravel floors. */
+  rock: number
+  /** Salt crust, 0..1: a pan's floor. */
+  salt: number
   snowlineM: number
 }
 
@@ -108,7 +113,7 @@ export class Runtime {
   private out: Sample = {
     h: 0, water: -Infinity, waterCover: 0, waterMesh: false,
     body: -1, flowZ: 0, flowA: 0, foam: 0,
-    desert: 0, forest: 0, relief: 0, snowlineM: 3000,
+    desert: 0, forest: 0, relief: 0, rock: 0, salt: 0, snowlineM: 3000,
   }
   private nearest: Nearest = { d: 0, seg: 0, t: 0 }
 
@@ -135,6 +140,8 @@ export class Runtime {
     o.desert = 0
     o.forest = 0
     o.relief = 0
+    o.rock = 0
+    o.salt = 0
     o.snowlineM = 3000
 
     if (upTo === Stage.MountainsOnly) {
@@ -148,6 +155,10 @@ 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)
     }
+    // The deserts' own landforms, on the shelf the uplift above just raised.
+    for (const d of w.deserts ?? []) {
+      if (inBox(d.box, z, a)) desert(d, p, z, a, detailM, o)
+    }
     let relief = 0
     for (const m of w.mountains) {
       if (!inBox(m.box, z, a)) continue
@@ -218,6 +229,102 @@ function uplift(u: CUplift, p: CylPoint, z: number, a: number, detailM: number,
   else if (u.kind === 'forest') o.forest = Math.max(o.forest, claim)
 }
 
+/**
+ * A desert's landforms on top of its shelf (desert.ts, world-generation.md §4.5).
+ *
+ * The plateau's tiers are cut here rather than compiled, so a cliff band is as sharp as
+ * the tile is fine. Hills and dunes are made here too: a dune needs to know nothing about
+ * the ground next door, only which way Tower blows the wind.
+ */
+function desert(d: CDesert, p: CylPoint, z: number, a: number, detailM: number, o: Sample): void {
+  /**
+   * The compiled fields are read through a small warp. Sampled straight, a cliff band
+   * follows the 250 m grid and its rim reads as a row of facets; warped, the rim wanders
+   * and the grid disappears.
+   */
+  const [wz, wa] = warp(d.seed ^ 0x51, p, z, a, 140, 1100, detailM)
+  const g = sampleDesert(d, wz, wa)
+  if (g.inside <= 0.001) return
+  // The broad crown the washes run off (desert.ts).
+  let h = g.base
+
+  // Rock, in tiers: flat tops with steep faces, roughened across the faces.
+  if (g.plateau > 0.001) {
+    h += terrace(g.plateau, d.terraces) * d.plateauHeightM
+    h += (fbm(layer(d.seed, 2), p, 240, 4, detailM) - 0.5) * 12 * smoothstep(0.02, 0.15, g.plateau)
+    o.rock = Math.max(o.rock, smoothstep(0.02, 0.16, g.plateau))
+  }
+
+  // Rocky hills on the open ground: low ridged domes, where the coverage noise says so.
+  // The same function the washes were routed over (desert.ts), so the two agree.
+  const hills = hillsAt(
+    { hills: { coverage: d.hillsCoverage, heightM: d.hillsHeightM } },
+    d.seed, p, detailM, g.plateau, g.inside * (1 - g.salt))
+  if (hills > 0) {
+    h += hills
+    o.rock = Math.max(o.rock, smoothstep(0.25, 0.8, hills / Math.max(1, d.hillsHeightM)) * 0.6)
+  }
+
+  // Dunes: crests across the wind, gentle to windward and steep on the slip face. Where
+  // the dune fields are was settled in the compile (desert.ts `sand`).
+  if (d.duneHeightM > 0 && g.sand > 0.02) {
+    h += dunes(d, p, z, a, detailM) * d.duneHeightM * g.sand
+  }
+
+  // The washes, cut through all of it, with a gravel floor.
+  if (g.cut > 0.2) {
+    h -= g.cut
+    o.rock = Math.max(o.rock, smoothstep(3, 18, g.cut) * 0.4)
+  }
+
+  /**
+   * A pan is dead flat, and the ground around it steps down to it in the benches a lake
+   * would have left. Both are levels in the *world*, not in the sand — the shelf under the
+   * desert has a hundred metres of its own roll, and a floor that followed it would not be
+   * a floor — so this works on the total elevation and then puts it back.
+   */
+  let total = o.h + h
+  if (g.bench > 0.02) {
+    const above = total - g.benchLevelM
+    if (above > 0) {
+      const stepped = Math.round(above / BENCH_STEP_M) * BENCH_STEP_M
+      total += (stepped - above) * Math.min(1, g.bench * 1.4)
+    }
+  }
+  if (g.salt > 0.02) {
+    const flat = Math.min(1, g.salt)
+    total += (g.panLevelM - total) * flat
+    o.salt = Math.max(o.salt, flat)
+    o.rock *= 1 - flat
+  }
+
+  o.h = total
+}
+
+/**
+ * One dune field's profile, 0..1 of the dune height. Crests run across the wind; each
+ * dune climbs gently to windward and drops at the angle dry sand stands at, and the crest
+ * line wanders along itself rather than ruling straight across the desert.
+ */
+function dunes(d: CDesert, p: CylPoint, z: number, a: number, detailM: number): number {
+  /**
+   * Across the wind, in dune wavelengths. The circumferential part is counted in *whole*
+   * dunes around the world — a pattern that does not close on itself leaves a seam where
+   * theta wraps, and a dune field running round a ring has to close.
+   */
+  const across = (z * Math.cos(d.duneDirRad)) / d.duneSpacingM + d.dunePeriods * (a / CIRCUMFERENCE_M)
+  // The crest line wanders along itself; the noise is on the cylinder, so it closes too.
+  const wander = fbm(layer(d.seed, 6), p, d.duneSpacingM * 7, 3, detailM) - 0.5
+  const x = across + wander * 1.7
+  const f = x - Math.floor(x)
+  // Three-quarters of the wavelength climbing, a quarter falling at about 33°: the angle
+  // of repose of dry sand, which is what makes a slip face.
+  const profile = f < 0.76 ? (f / 0.76) ** 1.35 : 1 - (f - 0.76) / 0.24
+  // Ripples on the sand itself, only ever seen from a few metres up.
+  const ripple = (fbm(layer(d.seed, 7), p, 11, 2, detailM) - 0.5) * 0.05
+  return Math.max(0, profile + ripple)
+}
+
 /**
  * Ground Tower raised: the open ground lifted by the region's uplift, wherever that is
  * higher than what is already here. Taking the higher rather than adding is what lets
diff --git a/src/terrain/grid.ts b/src/terrain/grid.ts
new file mode 100644
index 0000000..28792a4
--- /dev/null
+++ b/src/terrain/grid.ts
@@ -0,0 +1,179 @@
+/**
+ * Grids on the unrolled surface, and the passes that run over them.
+ *
+ * A generator that has to know what the ground next door is doing — a range eroding to a
+ * drainage network, a desert routing washes and finding its closed basins — works on a
+ * grid of cells rather than on one sample at a time. This is the machinery they share:
+ * the grid itself, a priority flood that finds which cell drains into which, a separable
+ * box blur, bilinear sampling for the tiles that read the result back, and a walk over
+ * the cells near a point.
+ *
+ * A grid rings the world when its feature does (`ring`), so a band's drainage and blur
+ * wrap round theta instead of stopping at a seam.
+ */
+import { nearestA } from './shapes.js'
+
+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. */
+export 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
+  }
+}
+
+/** Priority flood from the outlets: receivers, and cells in order from the outlets up. */
+export 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
+}
+
+export const DI = [-1, -1, -1, 0, 0, 1, 1, 1]
+export const DJ = [-1, 0, 1, -1, 1, -1, 0, 1]
+
+/** A separable box blur over a grid: three passes, which is near enough a Gaussian. */
+export 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
+}
+
+/** Call `fn` for every cell within `radius` of (z, a): its index, the distance, and its own (z, a). */
+export 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)
+    }
+  }
+}
+
+/** 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/mountains.ts b/src/terrain/mountains.ts
index 1f01f87..ba30ae8 100644
--- a/src/terrain/mountains.ts
+++ b/src/terrain/mountains.ts
@@ -30,9 +30,12 @@
 import Delaunator from 'delaunator'
 import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js'
 import type { Mountains } from '../world/schema.js'
+import { type Grid, Heap, blur, drainage, forCells, sampleGrid } from './grid.js'
 import { hash3, mulberry32, simplex } from './noise.js'
 import { type Box, type Shape, nearestA, shapeBox, shapeOf, signedDistance } from './shapes.js'
 
+export { type Grid, sampleGrid } from './grid.js'
+
 const C = CIRCUMFERENCE_M
 
 export interface CPeak {
@@ -355,100 +358,11 @@ function upliftField(
 
 // --------------------------------------------------------------------- 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.
  *
@@ -544,39 +458,6 @@ function erode(grid: Grid, field: Float32Array, outlet: Uint8Array, strength: nu
 }
 
 /** 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. */
@@ -754,26 +635,6 @@ function summitCone(grid: Grid, relief: Float32Array, peaks: CPeak[], landAt: (z
 }
 
 /** 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
 
@@ -798,23 +659,3 @@ export function pinKernel(d: number): number {
   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/palette.ts b/src/terrain/palette.ts
index 4bced05..097e0b1 100644
--- a/src/terrain/palette.ts
+++ b/src/terrain/palette.ts
@@ -10,7 +10,7 @@
  */
 type Ramp = readonly (readonly [number, readonly [number, number, number]])[]
 
-export const RAMPS: Record<'plain' | 'desert' | 'forest' | 'rock', Ramp> = {
+export const RAMPS: Record<'plain' | 'desert' | 'forest' | 'rock' | 'sandstone', Ramp> = {
   plain: [[-40, [104, 118, 88]], [120, [118, 130, 92]], [340, [132, 138, 98]],
     [700, [140, 138, 106]], [1600, [146, 138, 120]]],
   desert: [[400, [198, 178, 132]], [700, [186, 160, 112]], [1000, [166, 138, 96]],
@@ -18,8 +18,13 @@ export const RAMPS: Record<'plain' | 'desert' | 'forest' | 'rock', Ramp> = {
   forest: [[80, [52, 86, 54]], [260, [62, 100, 58]], [500, [76, 108, 64]], [1000, [98, 114, 76]]],
   rock: [[600, [104, 106, 96]], [1400, [124, 122, 114]], [2200, [146, 142, 136]],
     [2900, [166, 162, 156]]],
+  /** Desert rock: the sandstones and mudstones a mesa is cut from, warm and reddened. */
+  sandstone: [[400, [190, 132, 94]], [800, [176, 114, 78]], [1200, [160, 100, 70]],
+    [1800, [146, 96, 72]]],
 }
 export const SNOW = [247, 249, 253] as const
+/** Salt: white, but a dirtier and warmer white than snow (world-generation.md §4.5). */
+export const SALT = [232, 227, 213] as const
 /** Shallow water, and how much darker it gets at full depth. */
 export const WATER = [58, 96, 132] as const
 export const WATER_DEEPEN = [20, 26, 24] as const
@@ -44,7 +49,7 @@ export function ramp(r: Ramp, h: number, out: number[]): void {
 
 /**
  * sRGB 0..255 for a material mix at an elevation. `m` is tile.ts MATERIALS order:
- * plain, desert, forest, rock, snow, water, depth.
+ * plain, desert, forest, rock, snow, water, depth, bed, salt, sandstone.
  */
 export function colourOf(m: ArrayLike<number>, h: number, out: number[]): void {
   const tmp = [0, 0, 0]
@@ -57,7 +62,15 @@ export function colourOf(m: ArrayLike<number>, h: number, out: number[]): void {
     r += tmp[0]! * w; g += tmp[1]! * w; b += tmp[2]! * w
     sum += w
   }
+  const sandstone = m[9] ?? 0
+  if (sandstone > 0) {
+    ramp(RAMPS.sandstone, h, tmp)
+    r += tmp[0]! * sandstone; g += tmp[1]! * sandstone; b += tmp[2]! * sandstone
+    sum += sandstone
+  }
   if (sum > 0) { r /= sum; g /= sum; b /= sum }
+  const salt = m[8] ?? 0
+  r += (SALT[0] - r) * salt; g += (SALT[1] - g) * salt; b += (SALT[2] - b) * salt
   const snow = m[4]!
   r += (SNOW[0] - r) * snow; g += (SNOW[1] - g) * snow; b += (SNOW[2] - b) * snow
   const water = m[5]!, d = m[6]!
@@ -81,15 +94,18 @@ function glslRamp(name: string, r: Ramp): string {
   return lines.join('\n')
 }
 
-/** The palette as GLSL: `groundColour(matA, matB, h)` in sRGB 0..1. */
+/** The palette as GLSL: `groundColour(matA, matB, extra, h)`, `extra` = (salt, sandstone). */
 export const PALETTE_GLSL = [
   glslRamp('rampPlain', RAMPS.plain),
   glslRamp('rampDesert', RAMPS.desert),
   glslRamp('rampForest', RAMPS.forest),
   glslRamp('rampRock', RAMPS.rock),
-  `vec3 groundColour(vec4 a, vec4 b, float h) {
-  float sum = max(a.x + a.y + a.z + a.w, 1e-3);
-  vec3 c = (rampPlain(h) * a.x + rampDesert(h) * a.y + rampForest(h) * a.z + rampRock(h) * a.w) / sum;
+  glslRamp('rampSandstone', RAMPS.sandstone),
+  `vec3 groundColour(vec4 a, vec4 b, vec2 extra, float h) {
+  float sum = max(a.x + a.y + a.z + a.w + extra.y, 1e-3);
+  vec3 c = (rampPlain(h) * a.x + rampDesert(h) * a.y + rampForest(h) * a.z + rampRock(h) * a.w
+    + rampSandstone(h) * extra.y) / sum;
+  c = mix(c, ${vec(SALT)}, extra.x);
   c = mix(c, ${vec(SNOW)}, b.x);
   vec3 water = ${vec(WATER)} - ${vec(WATER_DEEPEN)} * b.z;
   return mix(c, water, b.y);
diff --git a/src/terrain/service.ts b/src/terrain/service.ts
index 92cff17..94c327a 100644
--- a/src/terrain/service.ts
+++ b/src/terrain/service.ts
@@ -154,7 +154,9 @@ export class TerrainService {
       || (f.kind === 'region' && f.upliftM)).length
     // 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
+    // A desert names three steps of its own: its rock, its washes and its salt (desert.ts).
+    const deserts = world.features.filter((f) => f.kind === 'desert').length
+    const total = 1 + counted + ranges + 2 * deserts + 1
     let done = 0
     this.setProgress({ busy: true, label: 'Reading the plans', done: 0, total, startedAt: performance.now() })
 
diff --git a/src/terrain/tile.ts b/src/terrain/tile.ts
index ff76795..5c1f496 100644
--- a/src/terrain/tile.ts
+++ b/src/terrain/tile.ts
@@ -52,7 +52,9 @@ export function tileCentreKm(t: TileKey): [number, number, number] {
  * shader turns them into colour, so a tile carries what the ground *is* and the look
  * of it can change without regenerating anything.
  */
-export const MATERIALS = ['plain', 'desert', 'forest', 'rock', 'snow', 'water', 'depth', 'bed'] as const
+export const MATERIALS = [
+  'plain', 'desert', 'forest', 'rock', 'snow', 'water', 'depth', 'bed', 'salt', 'sandstone',
+] as const
 
 /**
  * Per water vertex, in this order: depth of water over the ground, main wave amplitude,
@@ -107,10 +109,19 @@ export function materialsAt(s: Sample, slope: number, out: Float32Array | number
   // eye: 40 m of river in a 200 m cell is a 20% tint, and it vanished where tiles coarsened.
   // The square root keeps a sub-resolution river continuous with the surface drawn nearer.
   const water = s.waterMesh ? 0 : Math.min(1, Math.sqrt(s.waterCover) * 1.2)
-  const rock = smoothstep(1300, 1900, s.relief)
-  const desert = s.desert * (1 - rock)
-  const forest = s.forest * (1 - rock) * (1 - desert)
-  const plain = Math.max(0, 1 - rock - desert - forest)
+  /**
+   * Bare rock: high ground in a range, whatever the desert claims as rock (plateau tops,
+   * cliff bands, a wash's gravel floor), and any steep desert face — a mesa's riser reads
+   * as rock because it is too steep to hold sand, which is the same reason snow slides off.
+   */
+  const rock = Math.min(1, Math.max(
+    smoothstep(1300, 1900, s.relief),
+    Math.max(s.rock, smoothstep(0.45, 0.85, slope) * s.desert)))
+  // A salt crust is not sand, rock or anything else: it covers them.
+  const salt = s.salt * (1 - water)
+  const desert = s.desert * (1 - rock) * (1 - salt)
+  const forest = s.forest * (1 - rock) * (1 - desert) * (1 - salt)
+  const plain = Math.max(0, 1 - rock - desert - forest - salt)
   // Snow does not lie on cliffs. Blending it out with slope is what turns a white blob
   // into a mountain with rock showing through.
   const holds = 1 - smoothstep(0.55, 1.1, slope)
@@ -118,12 +129,16 @@ export function materialsAt(s: Sample, slope: number, out: Float32Array | number
   out[offset] = plain
   out[offset + 1] = desert
   out[offset + 2] = forest
-  out[offset + 3] = rock
+  // Rock in a desert is sandstone: warm, and bedded. Rock in a range is grey stone.
+  const sandstone = rock * (1 - salt) * s.desert
+  out[offset + 3] = rock * (1 - salt) - sandstone
   out[offset + 4] = snow
   out[offset + 5] = water
   // Water deepens to its darkest over the first 90 m, as the v1 map did.
   out[offset + 6] = s.water > s.h ? Math.min(1, (s.water - s.h) / 90) : 0
   out[offset + 7] = s.waterMesh ? 1 : 0
+  out[offset + 8] = salt
+  out[offset + 9] = sandstone
 }
 
 /**
diff --git a/src/ui/app.ts b/src/ui/app.ts
index 9db5b3b..35292d2 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -9,8 +9,9 @@ import { landStripWidth, slatCount, slatWidth } from '../canon/slats.js'
 import { type SurfacePoint, landStripIndex, isSlat, surfaceDistance } from '../canon/geometry.js'
 import { sightline } from '../world/instruments.js'
 import {
-  type Feature, type FeatureKind, FEATURE_KINDS, anchorOf, geometryTypesFor, isAreaKind, isAutoId,
-  isTracedKind, makeId, newSeed, parseWorld, withKind,
+  type Desert as DesertFeature, type Feature, type FeatureKind, FEATURE_KINDS, anchorOf,
+  geometryTypesFor, isAreaKind, isAutoId, isTracedKind, makeId, newSeed, parseWorld,
+  SETTLEMENT_RADIUS_M, withKind,
 } from '../world/schema.js'
 import { terrain } from '../terrain/service.js'
 import { seaStateOf } from '../terrain/sea-state.js'
@@ -743,6 +744,8 @@ export class App {
     $('f-delete').onclick = () => this.deleteSelected()
     this.wireMountains()
     this.wireWater()
+    this.wireDesert()
+    this.wireSettlement()
     this.wireWeather()
   }
 
@@ -792,6 +795,110 @@ export class App {
         ? ' The wind runs round the ring, so the sea is fully developed.' : '')
   }
 
+  /** Where a settlement stands, how big it is, and what it is called for its size. */
+  private wireSettlement(): void {
+    const selected = (): Extract<Feature, { kind: 'settlement' }> | null => {
+      const f = this.selectedId ? this.store.get(this.selectedId) : undefined
+      return f?.kind === 'settlement' ? f : null
+    }
+    const size = $<HTMLSelectElement>('f-size')
+    size.onchange = () => {
+      const f = selected()
+      if (!f) return
+      const next = size.value as typeof f.size
+      // A size carries its usual extent, unless this one was given its own.
+      const usual = SETTLEMENT_RADIUS_M[f.size]
+      this.store.update(f.id, {
+        size: next,
+        radiusM: Math.abs(f.radiusM - usual) < 1 ? SETTLEMENT_RADIUS_M[next] : f.radiusM,
+      })
+    }
+    const extent = $<HTMLInputElement>('f-extent')
+    extent.onchange = () => {
+      const f = selected()
+      const km = Number(extent.value)
+      if (f && Number.isFinite(km) && km > 0) this.store.update(f.id, { radiusM: (km * 1000) / 2 })
+    }
+  }
+
+  private renderSettlement(f: Feature | undefined): void {
+    $('f-settlement').hidden = f?.kind !== 'settlement'
+    if (f?.kind !== 'settlement') return
+    const size = $<HTMLSelectElement>('f-size')
+    if (document.activeElement !== size) size.value = f.size
+    const extent = $<HTMLInputElement>('f-extent')
+    if (document.activeElement !== extent) extent.value = String(((2 * f.radiusM) / 1000).toFixed(1))
+  }
+
+  /**
+   * A desert's landforms (world-generation.md §4.5). Every one of these regenerates the
+   * desert — a second or so under the status dialog — so they commit on change rather
+   * than while a number is being typed.
+   */
+  private desertFields(): { id: string; get: (f: DesertFeature) => number; set: (f: DesertFeature, v: number) => FeaturePatch }[] {
+    return [
+      { id: 'f-dune-height', get: (f) => f.dunes.heightM, set: (f, v) => ({ dunes: { ...f.dunes, heightM: v } }) },
+      { id: 'f-dune-spacing', get: (f) => f.dunes.spacingM, set: (f, v) => ({ dunes: { ...f.dunes, spacingM: v } }) },
+      { id: 'f-dune-dir', get: (f) => f.dunes.directionDeg, set: (f, v) => ({ dunes: { ...f.dunes, directionDeg: v } }) },
+      { id: 'f-dune-cover', get: (f) => f.dunes.coverage, set: (f, v) => ({ dunes: { ...f.dunes, coverage: v } }) },
+      { id: 'f-hill-height', get: (f) => f.hills.heightM, set: (f, v) => ({ hills: { ...f.hills, heightM: v } }) },
+      { id: 'f-hill-cover', get: (f) => f.hills.coverage, set: (f, v) => ({ hills: { ...f.hills, coverage: v } }) },
+      { id: 'f-plat-height', get: (f) => f.plateaus.heightM, set: (f, v) => ({ plateaus: { ...f.plateaus, heightM: v } }) },
+      { id: 'f-plat-cover', get: (f) => f.plateaus.coverage, set: (f, v) => ({ plateaus: { ...f.plateaus, coverage: v } }) },
+      { id: 'f-plat-terraces', get: (f) => f.plateaus.terraces, set: (f, v) => ({ plateaus: { ...f.plateaus, terraces: v } }) },
+      { id: 'f-washes', get: (f) => f.pastWater.washes, set: (f, v) => ({ pastWater: { ...f.pastWater, washes: v } }) },
+      { id: 'f-pans', get: (f) => f.pastWater.saltPans, set: (f, v) => ({ pastWater: { ...f.pastWater, saltPans: v } }) },
+    ]
+  }
+
+  private selectedDesert(): DesertFeature | null {
+    const f = this.selectedId ? this.store.get(this.selectedId) : undefined
+    return f?.kind === 'desert' ? f : null
+  }
+
+  private wireDesert(): void {
+    for (const field of this.desertFields()) {
+      const input = $<HTMLInputElement>(field.id)
+      input.onchange = () => {
+        const f = this.selectedDesert()
+        const v = Number(input.value)
+        if (f && Number.isFinite(v)) this.store.update(f.id, field.set(f, v))
+      }
+    }
+    const shores = $<HTMLInputElement>('f-shorelines')
+    shores.onchange = () => {
+      const f = this.selectedDesert()
+      if (f) this.store.update(f.id, { pastWater: { ...f.pastWater, shorelines: shores.checked } })
+    }
+    // What was actually made is read off the compiled world, as the sea state is.
+    terrain().onWorld(() => this.renderDesert(this.selectedId ? this.store.get(this.selectedId) : undefined))
+  }
+
+  private renderDesert(f: Feature | undefined): void {
+    $('f-desert').hidden = f?.kind !== 'desert'
+    if (f?.kind !== 'desert') return
+    for (const field of this.desertFields()) {
+      const input = $<HTMLInputElement>(field.id)
+      if (document.activeElement !== input) input.value = String(field.get(f))
+    }
+    const shores = $<HTMLInputElement>('f-shorelines')
+    if (document.activeElement !== shores) shores.checked = f.pastWater.shorelines
+    const d = terrain().compiled?.deserts.find((x) => x.id === f.id)
+    if (!d) { $('f-desert-state').textContent = ''; return }
+    let rock = 0, sand = 0, cut = 0, inside = 0
+    for (let k = 0; k < d.inside.length; k += 7) {
+      if (d.inside[k]! < 0.9) continue
+      inside++
+      if (d.plateau[k]! > 0.5) rock++
+      if (d.sand[k]! > 0.5) sand++
+      if (d.cut[k]! > 3) cut++
+    }
+    const pct = (n: number) => `${Math.round((100 * n) / Math.max(1, inside))}%`
+    $('f-desert-state').textContent =
+      `Made: ${pct(rock)} standing rock, ${pct(sand)} dune field, ${pct(cut)} cut by washes, `
+      + `${d.pans.length} salt pan${d.pans.length === 1 ? '' : 's'}.`
+  }
+
   // --------------------------------------------------------------- weather
 
   /**
@@ -1259,6 +1366,8 @@ export class App {
     $<HTMLInputElement>('f-public').checked = p.public
     this.renderMountains(p)
     this.renderWater(p)
+    this.renderDesert(p)
+    this.renderSettlement(p)
 
     $('f-where').textContent = this.whereText(p)
   }
diff --git a/src/views/interior-model.ts b/src/views/interior-model.ts
index e9cbeb0..c78508d 100644
--- a/src/views/interior-model.ts
+++ b/src/views/interior-model.ts
@@ -171,16 +171,19 @@ const GROUND_VERT = /* glsl */`
 uniform mat4 uFrame;
 attribute vec4 aMatA;
 attribute vec4 aMatB;
+attribute vec2 aExtra;
 varying vec3 vWorld;
 varying vec3 vNormal2;
 varying vec4 vMatA;
 varying vec4 vMatB;
+varying vec2 vExtra;
 
 void main() {
   vWorld = (uFrame * modelMatrix * vec4(position, 1.0)).xyz;
   vNormal2 = normal;
   vMatA = aMatA;
   vMatB = aMatB;
+  vExtra = aExtra;
   gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
   #include <logdepthbuf_vertex>
 }
@@ -197,6 +200,7 @@ varying vec3 vWorld;
 varying vec3 vNormal2;
 varying vec4 vMatA;
 varying vec4 vMatB;
+varying vec2 vExtra;
 ${HAZE_GLSL}
 ${PALETTE_GLSL}
 
@@ -217,7 +221,17 @@ void main() {
   float elevM = (${R.toFixed(1)} - length(vWorld.xy)) * 1000.0;
   // The palette is sRGB; every uniform colour here is linear. Three inserts that
   // decode into its own materials and never into a custom one.
-  vec3 base = pow(groundColour(vMatA, vMatB, elevM), vec3(2.2));
+  vec3 base = pow(groundColour(vMatA, vMatB, vExtra, elevM), vec3(2.2));
+  /**
+   * Strata. Rock is laid in beds, and a bed is a level in Tower — a constant radius — so
+   * the bands line up across separate mesas however far apart they stand, which is what
+   * makes a field of them read as one formation eroded out of one plateau (§4.5).
+   */
+  float bed = sin(elevM * 0.11) * 0.5 + 0.5;
+  float thin = sin(elevM * 0.53) * 0.5 + 0.5;
+  vec3 warm = vec3(1.05, 0.99, 0.93);
+  float rocky = vMatA.w + vExtra.y;
+  base *= mix(vec3(1.0), mix(vec3(0.93), warm, bed * 0.7 + thin * 0.3), rocky * 0.6);
   // Under drawn water the ground is bed: sediment, darker, whatever the land around it.
   base = mix(base, vec3(0.16, 0.14, 0.11), vMatB.w * 0.8);
 
@@ -501,8 +515,14 @@ void main() {
     float t = 1.01 - 0.68 * sqrt(wp.w);
     float cap = smoothstep(t, t + 0.06, crest * 0.7 + patches * 0.45);
     // Foam is lace, not a sheet: broken up finely, and thinner toward the cap's edge.
-    // Resolved only close in: beyond, its 0.8 m cells drew as blocks, so it averages out.
-    float lace = mix(smoothstep(0.25, 0.75, wNoise(sp / 0.8 + drift * 0.1)), 0.5, smoothstep(0.05, 0.3, footprint));
+    /**
+     * Resolved only close in: beyond, its metre-scale cells drew as blocks, so it averages
+     * out. Two octaves, the second rotated, because one octave of value noise thresholded
+     * this hard reads as a honeycomb of its own lattice.
+     */
+    vec2 lp = vec2(sp.x * 0.83 - sp.y * 0.56, sp.x * 0.56 + sp.y * 0.83);
+    float fine = wNoise(sp / 0.8 + drift * 0.1) * 0.6 + wNoise(lp / 2.1 - drift * 0.05) * 0.4;
+    float lace = mix(smoothstep(0.3, 0.8, fine), 0.5, smoothstep(0.05, 0.4, footprint));
     cap *= lace * 0.5 + 0.5 * cap;
     float far = smoothstep(1.0, 8.0, footprint);
     foam = max(foam, mix(cap, wp.w * 0.5, far) * 0.8);
diff --git a/src/views/interior.ts b/src/views/interior.ts
index 0778692..12da44f 100644
--- a/src/views/interior.ts
+++ b/src/views/interior.ts
@@ -109,6 +109,13 @@ interface Probe {
   b: number
 }
 
+/**
+ * The water's clock while a scene is held still, seconds. Waves and foam move with time, so
+ * a held scene has to hold the clock too, or the screenshot tour photographs a different
+ * sea every run and its divergence report means nothing.
+ */
+const FROZEN_TIME_S = 12
+
 /** Seconds a scene change takes to fade out, and in again once the ground has arrived. */
 const TOUR_FADE_S = 0.6
 /** Longest the tour holds black waiting for the ground; past this it shows what there is. */
@@ -1077,7 +1084,7 @@ export class InteriorView {
       if (this.frames % 10 === 0) this.model.setWeather(this.worldHours)
       for (const m of this.shared) {
         m.uniforms['uSpin']!.value = this.spin
-        m.uniforms['uTime']!.value = this.time
+        m.uniforms['uTime']!.value = this.tour?.frozen ? FROZEN_TIME_S : this.time
       }
       if (this.tour) this.stepTour(dt)
       else this.fly(dt)
diff --git a/src/views/map.ts b/src/views/map.ts
index a561d0d..1c69832 100644
--- a/src/views/map.ts
+++ b/src/views/map.ts
@@ -515,6 +515,28 @@ export class TowerMap {
       } else {
         const m = toMap({ theta: p.geometry.theta, z: p.geometry.z })
         const [sx, sy] = this.worldToScreen(m.x, m.y)
+        /**
+         * A settlement is a point with an extent: the marker says where it stands, the ring
+         * says how far it reaches. Drawn only when it is bigger than the marker, so a
+         * hamlet is a dot rather than a dot inside a circle.
+         */
+        if (p.kind === 'settlement') {
+          const r = p.radiusM * this.scale
+          if (r > MARKER_R + 2) {
+            ctx.beginPath()
+            ctx.arc(sx, sy, r, 0, Math.PI * 2)
+            const overTerrain = this.showTerrain && this.terrain
+            if (!overTerrain || selected) {
+              ctx.fillStyle = color + (selected ? '2e' : '22')
+              ctx.fill()
+            }
+            ctx.strokeStyle = overTerrain && !selected ? color + '77' : color
+            ctx.lineWidth = selected ? 2.5 : 1.5
+            ctx.setLineDash(p.public ? [] : [5, 4])
+            ctx.stroke()
+            ctx.setLineDash([])
+          }
+        }
         ctx.beginPath()
         ctx.arc(sx, sy, MARKER_R, 0, Math.PI * 2)
         ctx.fillStyle = color
diff --git a/src/views/scenic.ts b/src/views/scenic.ts
index e3c2717..748b9b2 100644
--- a/src/views/scenic.ts
+++ b/src/views/scenic.ts
@@ -12,6 +12,7 @@
  */
 import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js'
 import type { CFall, CompiledWorld } from '../terrain/compile.js'
+import type { CDesert } from '../terrain/desert.js'
 import { type Line, nearestA, smoothLine } from '../terrain/shapes.js'
 import { lanesOf, shipAt, shipsAt, type ShipLane } from '../terrain/ships.js'
 import type { Feature, Sea, World } from '../world/schema.js'
@@ -144,9 +145,19 @@ function flight(w: SceneWorld, line: Line, from: number, speed: number, clearanc
  */
 function orbit(w: SceneWorld, target: At, radius: number, altM: number, startRad: number,
   sweepRad: number, minClearM: number, fovDeg: number): (t: number) => Pose {
+  return orbitLooking(w, target, target, radius, altM, startRad, sweepRad, minClearM, fovDeg)
+}
+
+/**
+ * The same arc, but circling one point and looking at another: for keeping the camera over
+ * open ground while it watches something that has no room to be circled — a mesa field the
+ * camera would otherwise be flying *over*.
+ */
+function orbitLooking(w: SceneWorld, centre: At, target: At, radius: number, altM: number,
+  startRad: number, sweepRad: number, minClearM: number, fovDeg: number): (t: number) => Pose {
   return (t) => {
     const ang = startRad + (sweepRad * t) / SCENE_S
-    const z = target.z + Math.cos(ang) * radius, a = target.a + Math.sin(ang) * radius
+    const z = centre.z + Math.cos(ang) * radius, a = centre.a + Math.sin(ang) * radius
     const cam = { z, a, altM: Math.max(altM, w.ground(wrapA(a) / RADIUS_M, z) + minClearM) }
     return pose(cam, lookAt(cam, target), fovDeg)
   }
@@ -287,6 +298,81 @@ export const SCENES: Scene[] = [
       return null
     },
   },
+  {
+    id: 'mesas',
+    prepare: (w) => {
+      /**
+       * The broadest stand of rock, not merely the first tall cell: scored by how much
+       * plateau there is within a couple of kilometres, so the camera is put in front of a
+       * mesa field rather than a lone butte.
+       */
+      let best: { z: number; a: number; d: CDesert; score: number } | null = null
+      for (const d of w.compiled.deserts ?? []) {
+        const { grid } = d
+        const reach = Math.round(2000 / grid.cell)
+        for (let i = reach; i < grid.nz - reach; i += 4) {
+          for (let j = 0; j < grid.na; j += 4) {
+            if (d.plateau[i * grid.na + j]! < 0.9) continue
+            let score = 0
+            for (let di = -reach; di <= reach; di += 2) {
+              for (let dj = -reach; dj <= reach; dj += 2) {
+                const jj = grid.ring ? ((j + dj) % grid.na + grid.na) % grid.na : j + dj
+                if (jj < 0 || jj >= grid.na) continue
+                score += d.plateau[(i + di) * grid.na + jj]!
+              }
+            }
+            if (!best || score > best.score) {
+              best = { z: grid.z0 + i * grid.cell, a: grid.a0 + j * grid.cell, d, score }
+            }
+          }
+        }
+      }
+      if (!best) return null
+      const name = feature(w.world, best.d.id)?.name ?? 'the desert'
+      const top = w.ground(wrapA(best.a) / RADIUS_M, best.z)
+      /**
+       * Somewhere on the open plain to watch them from: the nearest low ground a few
+       * kilometres off, so the camera circles over sand and the rock stands over it.
+       */
+      const { grid } = best.d
+      let stand = { z: best.z, a: best.a + 4000 }
+      for (let r = 3500; r <= 7000; r += 500) {
+        for (let k = 0; k < 16; k++) {
+          const ang = (k / 16) * Math.PI * 2
+          const z = best.z + Math.cos(ang) * r, a = best.a + Math.sin(ang) * r
+          const i = Math.round((z - grid.z0) / grid.cell)
+          const j = ((Math.round((a - grid.a0) / grid.cell) % grid.na) + grid.na) % grid.na
+          if (i < 0 || i >= grid.nz) continue
+          if (best.d.plateau[i * grid.na + j]! < 0.03) { stand = { z, a }; r = 1e9; break }
+        }
+      }
+      const standGround = w.ground(wrapA(stand.a) / RADIUS_M, stand.z)
+      return {
+        id: 'mesas', hours: NOON,
+        title: `Past the mesas of ${name}: ${best.d.terraces} tiers of rock, with the strata lining up across all of them`,
+        pose: orbitLooking(w,
+          { z: stand.z, a: stand.a, altM: standGround },
+          { z: best.z, a: best.a, altM: top - best.d.plateauHeightM * 0.45 },
+          1500, standGround + 320, 0.4, 1.3, 220, 55),
+      }
+    },
+  },
+  {
+    id: 'pan',
+    prepare: (w) => {
+      const d = (w.compiled.deserts ?? []).find((x) => x.pans.length > 0)
+      const pan = d?.pans[0]
+      if (!d || !pan) return null
+      const name = feature(w.world, d.id)?.name ?? 'the desert'
+      // Across the pan, low, so the benches around it stand against the sky.
+      const line = straight(pan.z - pan.radiusM * 2.2, pan.a, 0, pan.radiusM * 4.4)
+      return {
+        id: 'pan', hours: NOON,
+        title: `Over a salt pan in ${name}, ringed by the shorelines of a lake that never stood`,
+        pose: flight(w, line, 0, (pan.radiusM * 4.4 - 2500) / SCENE_S, 70, 2500, 60),
+      }
+    },
+  },
   {
     id: 'narrows',
     prepare: (w) => {
diff --git a/src/views/terrain-lod.ts b/src/views/terrain-lod.ts
index 7332643..ea687f4 100644
--- a/src/views/terrain-lod.ts
+++ b/src/views/terrain-lod.ts
@@ -236,6 +236,8 @@ export class TerrainLOD {
     const mats = new InterleavedBuffer(data.materials, MATERIALS.length)
     g.setAttribute('aMatA', new InterleavedBufferAttribute(mats, 4, 0, true))
     g.setAttribute('aMatB', new InterleavedBufferAttribute(mats, 4, 4, true))
+    // Salt and sandstone sit past the two vec4s (tile.ts MATERIALS).
+    g.setAttribute('aExtra', new InterleavedBufferAttribute(mats, 2, 8, true))
     /**
      * The index array is shared, but each tile gets its own attribute around it.
      * Sharing the attribute too would share one GL buffer, and disposing any tile
diff --git a/src/world/schema.ts b/src/world/schema.ts
index b1caa0f..a76d310 100644
--- a/src/world/schema.ts
+++ b/src/world/schema.ts
@@ -139,11 +139,44 @@ export interface River extends Base<'river'> {
   outlet: string | 'auto'
 }
 
+/**
+ * A desert (world-generation.md §4.5). Tower chose every part of this: the dunes run
+ * where Tower sends the wind, and the washes and shorelines are marks of water that never
+ * ran here, quoted from Earth because that is what a desert looks like (§0.1).
+ */
 export interface Desert extends Base<'desert'> {
   /** How far the desert's ground stands above the land around it. */
   upliftM: number
   /** Width of the slope up onto it. */
   edgeM: number
+  dunes: {
+    /** Share of the open ground the dune fields take, 0..1. */
+    coverage: number
+    /** Crest to trough of the big dunes, metres. */
+    heightM: number
+    /** Crest to crest, metres. */
+    spacingM: number
+    /** Which way the wind blows the sand: degrees, 0 deepward, 90 spinward. */
+    directionDeg: number
+  }
+  hills: {
+    coverage: number
+    heightM: number
+  }
+  plateaus: {
+    coverage: number
+    heightM: number
+    /** Cliff bands between flat tops. 1 is a single tier. */
+    terraces: number
+  }
+  /** The marks of water: dry washes, salt pans, and the shorelines of lakes that never were. */
+  pastWater: {
+    /** How dense the wash network is, 0..1; 0 for none. */
+    washes: number
+    /** How many salt pans to lay out, and whether to ring them with abandoned shorelines. */
+    saltPans: number
+    shorelines: boolean
+  }
 }
 
 export interface Forest extends Base<'forest'> {
@@ -153,6 +186,14 @@ export interface Forest extends Base<'forest'> {
 
 export interface Settlement extends Base<'settlement'> {
   size: 'hamlet' | 'village' | 'town' | 'station' | 'city'
+  /**
+   * How far the built-up ground reaches from the marker. A settlement is a point with an
+   * extent rather than a traced outline: Tower lays the streets and buildings out inside it
+   * (world-generation.md §4.7), and what the author places is where it stands and how big
+   * it is. The Capital's is 10 km, which is what reaches The Deep's shore from the axial
+   * midpoint of the world (D-070).
+   */
+  radiusM: number
 }
 
 export interface Route extends Base<'road' | 'path'> {
@@ -298,11 +339,17 @@ export function withKind(common: Common, kind: FeatureKind): Feature {
     case 'river':
       return { ...c, kind, flowM3S: 40, sourceM: 'terrain', outlet: 'auto' }
     case 'desert':
-      return { ...c, kind, upliftM: 300, edgeM: 4000 }
+      return {
+        ...c, kind, upliftM: 300, edgeM: 4000,
+        dunes: { coverage: 0.45, heightM: 60, spacingM: 900, directionDeg: 90 },
+        hills: { coverage: 0.25, heightM: 120 },
+        plateaus: { coverage: 0.3, heightM: 420, terraces: 3 },
+        pastWater: { washes: 0.6, saltPans: 3, shorelines: true },
+      }
     case 'forest':
       return { ...c, kind, upliftM: 0, edgeM: 3000 }
     case 'settlement':
-      return { ...c, kind, size: 'village' }
+      return { ...c, kind, size: 'village', radiusM: SETTLEMENT_RADIUS_M.village }
     case 'road':
     case 'path':
       return { ...c, kind, widthM: kind === 'road' ? 8 : 2 }
@@ -333,6 +380,11 @@ export function isAutoId(id: string, name: string): boolean {
 }
 
 /** A single representative point for a feature, whatever shape it is. */
+/** How far the usual settlement of each size reaches from its marker, metres. */
+export const SETTLEMENT_RADIUS_M: Record<Settlement['size'], number> = {
+  hamlet: 250, village: 600, station: 500, town: 1800, city: 5000,
+}
+
 export function anchorOf(f: Feature): { theta: number; z: number } {
   if (f.kind === 'mountains' && f.peaks[0]) return { theta: f.peaks[0].theta, z: f.peaks[0].z }
   const g = f.geometry
@@ -434,14 +486,51 @@ function fillParams(d: Feature, f: Record<string, unknown>): Feature {
       return { ...d, flowM3S: Math.max(0.1, num(f['flowM3S'], d.flowM3S)),
                sourceM: Number.isFinite(f['sourceM']) ? (f['sourceM'] as number) : 'terrain',
                outlet: typeof f['outlet'] === 'string' && f['outlet'] ? f['outlet'] : 'auto' }
-    case 'desert':
+    case 'desert': {
+      const group = <T extends Record<string, number | boolean>>(src: unknown, def: T): T => {
+        const o = (src && typeof src === 'object' ? src : {}) as Record<string, unknown>
+        const out = { ...def } as Record<string, number | boolean>
+        for (const [k, v] of Object.entries(def)) {
+          out[k] = typeof v === 'boolean' ? (typeof o[k] === 'boolean' ? o[k] : v) : num(o[k], v)
+        }
+        return out as T
+      }
+      const dunes = group(f['dunes'], d.dunes)
+      const plateaus = group(f['plateaus'], d.plateaus)
+      return {
+        ...d, upliftM: num(f['upliftM'], d.upliftM), edgeM: Math.max(1, num(f['edgeM'], d.edgeM)),
+        dunes: {
+          ...dunes,
+          coverage: unit(dunes.coverage, d.dunes.coverage),
+          heightM: Math.max(0, dunes.heightM),
+          spacingM: Math.max(50, dunes.spacingM),
+          directionDeg: ((dunes.directionDeg % 360) + 360) % 360,
+        },
+        hills: {
+          coverage: unit(group(f['hills'], d.hills).coverage, d.hills.coverage),
+          heightM: Math.max(0, group(f['hills'], d.hills).heightM),
+        },
+        plateaus: {
+          coverage: unit(plateaus.coverage, d.plateaus.coverage),
+          heightM: Math.max(0, plateaus.heightM),
+          terraces: Math.max(1, Math.min(8, Math.round(plateaus.terraces))),
+        },
+        pastWater: {
+          washes: unit(group(f['pastWater'], d.pastWater).washes, d.pastWater.washes),
+          saltPans: Math.max(0, Math.min(12, Math.round(group(f['pastWater'], d.pastWater).saltPans))),
+          shorelines: group(f['pastWater'], d.pastWater).shorelines,
+        },
+      }
+    }
     case 'forest':
     case 'region':
       return { ...d, upliftM: num(f['upliftM'], d.upliftM), edgeM: Math.max(1, num(f['edgeM'], d.edgeM)) }
     case 'settlement': {
       const sizes = ['hamlet', 'village', 'town', 'station', 'city'] as const
       const size = sizes.includes(f['size'] as never) ? (f['size'] as Settlement['size']) : d.size
-      return { ...d, size }
+      // A settlement that names no extent gets the usual one for its size.
+      const radiusM = Math.max(50, Math.min(40_000, num(f['radiusM'], SETTLEMENT_RADIUS_M[size])))
+      return { ...d, size, radiusM }
     }
     case 'road':
     case 'path':
diff --git a/static/world.json b/static/world.json
index fa1f2ef..aa32816 100644
--- a/static/world.json
+++ b/static/world.json
@@ -597,7 +597,27 @@
         "zTo": 218000
       },
       "upliftM": 780,
-      "edgeM": 20000
+      "edgeM": 20000,
+      "dunes": {
+        "coverage": 0.5,
+        "heightM": 80,
+        "spacingM": 1100,
+        "directionDeg": 0
+      },
+      "hills": {
+        "coverage": 0.3,
+        "heightM": 160
+      },
+      "plateaus": {
+        "coverage": 0.28,
+        "heightM": 520,
+        "terraces": 3
+      },
+      "pastWater": {
+        "washes": 0.7,
+        "saltPans": 4,
+        "shorelines": true
+      }
     },
     {
       "id": "the-uplands",
@@ -852,10 +872,11 @@
       "kind": "settlement",
       "geometry": {
         "type": "point",
-        "theta": 0.02,
+        "theta": 0.04,
         "z": 270000
       },
-      "size": "city"
+      "size": "city",
+      "radiusM": 10000
     },
     {
       "id": "the-spindle",
diff --git a/test/desert.test.ts b/test/desert.test.ts
new file mode 100644
index 0000000..a106851
--- /dev/null
+++ b/test/desert.test.ts
@@ -0,0 +1,169 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import { RADIUS_M } from '../src/canon/constants.js'
+import { compileWorld } from '../src/terrain/compile.js'
+import { BENCH_STEP_M, CORRIDOR_M, terrace } from '../src/terrain/desert.js'
+import { prepare } from '../src/terrain/field.js'
+import { MATERIALS, materialsAt } from '../src/terrain/tile.js'
+import { type Desert, parseWorld } from '../src/world/schema.js'
+
+const world = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8')))
+const cw = compileWorld(world)
+const rt = prepare(cw)
+const reach = world.features.find((f): f is Desert => f.kind === 'desert')!
+const d = cw.deserts.find((x) => x.id === reach.id)!
+const ground = (z: number, a: number): number => rt.sample(a / RADIUS_M, z, 2).h
+
+/** Every cell of the desert's grid, as (index, z, a). */
+function* cells(step = 3): Generator<[number, number, number]> {
+  const { grid } = d
+  for (let i = 0; i < grid.nz; i += step) {
+    for (let j = 0; j < grid.na; j += step) {
+      yield [i * grid.na + j, grid.z0 + i * grid.cell, grid.a0 + j * grid.cell]
+    }
+  }
+}
+
+describe('the terrace profile', () => {
+  it('stays within its range and climbs in the number of tiers asked for', () => {
+    for (const terraces of [1, 3, 5]) {
+      expect(terrace(0, terraces)).toBe(0)
+      expect(terrace(1, terraces)).toBeCloseTo(1, 6)
+      // As many rises as there are tiers, each with a flat bench before the next.
+      let risers = 0, climbing = false
+      let prev = terrace(0, terraces)
+      for (let p = 0; p <= 1; p += 0.002) {
+        const v = terrace(p, terraces)
+        expect(v).toBeGreaterThanOrEqual(prev - 1e-9)
+        const rising = v - prev > 1e-6
+        if (rising && !climbing) risers++
+        climbing = rising
+        prev = v
+      }
+      expect(risers).toBe(terraces)
+    }
+  })
+
+  it('leaves the tops flat: most of a tier is a level bench', () => {
+    let flat = 0, n = 0
+    for (let p = 0; p <= 1; p += 0.001) {
+      const slope = Math.abs(terrace(p + 0.001, 3) - terrace(p, 3)) / 0.001
+      if (slope < 0.05) flat++
+      n++
+    }
+    expect(flat / n).toBeGreaterThan(0.55)
+  })
+})
+
+describe('the Dry Reach as compiled', () => {
+  it('lays out the plateaus over about the coverage asked for', () => {
+    let inside = 0, rock = 0
+    for (const [k] of cells()) {
+      if (d.inside[k]! < 0.9) continue
+      inside++
+      if (d.plateau[k]! > 0.5) rock++
+    }
+    expect(inside).toBeGreaterThan(1000)
+    expect(rock / inside).toBeGreaterThan(reach.plateaus.coverage * 0.6)
+    expect(rock / inside).toBeLessThan(reach.plateaus.coverage * 1.4)
+  })
+
+  it('cuts washes that run: a network, deep where it has gathered', () => {
+    let cutCells = 0, inside = 0, deepest = 0
+    for (const [k] of cells()) {
+      if (d.inside[k]! < 0.9) continue
+      inside++
+      if (d.cut[k]! > 3) cutCells++
+      deepest = Math.max(deepest, d.cut[k]!)
+    }
+    // Channels, not a lowered desert: a minority of the ground, and tens of metres deep.
+    expect(cutCells / inside).toBeGreaterThan(0.05)
+    expect(cutCells / inside).toBeLessThan(0.6)
+    expect(deepest).toBeGreaterThan(15)
+    expect(deepest).toBeLessThan(60)
+  })
+
+  it('lays the salt pans Tower asked for, spaced out, in the low ground', () => {
+    expect(d.pans).toHaveLength(reach.pastWater.saltPans)
+    for (let i = 0; i < d.pans.length; i++) {
+      for (let j = i + 1; j < d.pans.length; j++) {
+        const p = d.pans[i]!, q = d.pans[j]!
+        expect(Math.hypot(p.z - q.z, p.a - q.a)).toBeGreaterThan(p.radiusM * 2)
+      }
+    }
+  })
+
+  it('makes a pan floor dead flat, and steps the ground around it in benches', () => {
+    const pan = d.pans[0]!
+    const level = ground(pan.z, pan.a)
+    for (const off of [-pan.radiusM * 0.5, -pan.radiusM * 0.25, pan.radiusM * 0.25, pan.radiusM * 0.5]) {
+      expect(Math.abs(ground(pan.z + off, pan.a) - level)).toBeLessThan(2)
+      expect(Math.abs(ground(pan.z, pan.a + off) - level)).toBeLessThan(2)
+    }
+    // Outside it, the ground stands at the levels a lake would have left: heights cluster
+    // on multiples of the bench step above the floor, rather than climbing smoothly.
+    let onStep = 0, n = 0
+    for (let off = pan.radiusM * 1.1; off < pan.radiusM + 900; off += 25) {
+      const above = ground(pan.z + off, pan.a) - level
+      if (above <= 0) continue
+      const away = Math.abs(above / BENCH_STEP_M - Math.round(above / BENCH_STEP_M))
+      if (away < 0.22) onStep++
+      n++
+    }
+    expect(n).toBeGreaterThan(10)
+    expect(onStep / n).toBeGreaterThan(0.5)
+  })
+
+  it('keeps its rock off the rivers Tower had already routed', () => {
+    const trunk = cw.rivers.find((r) => r.id === 'the-long-water')!
+    for (let i = 0; i < trunk.line.s.length; i += 40) {
+      const z = trunk.line.pts[2 * i]!, a = trunk.line.pts[2 * i + 1]!
+      const { grid } = d
+      const gi = Math.round((z - grid.z0) / grid.cell)
+      if (gi < 0 || gi >= grid.nz) continue
+      const gj = ((Math.round((a - grid.a0) / grid.cell) % grid.na) + grid.na) % grid.na
+      expect(d.plateau[gi * grid.na + gj]!).toBeLessThan(0.05)
+    }
+  })
+
+  it('claims rock where the rock is, and salt on the pans', () => {
+    const out = new Float32Array(MATERIALS.length)
+    const pan = d.pans[0]!
+    materialsAt(rt.sample(pan.a / RADIUS_M, pan.z, 2), 0, out)
+    expect(out[MATERIALS.indexOf('salt')]!).toBeGreaterThan(0.8)
+    expect(out[MATERIALS.indexOf('desert')]!).toBeLessThan(0.2)
+
+    // A mesa top: bare rock, and no sand claim on it.
+    let mesa: [number, number] | null = null
+    for (const [k, z, a] of cells()) if (!mesa && d.plateau[k]! > 0.98) mesa = [z, a]
+    materialsAt(rt.sample(mesa![1] / RADIUS_M, mesa![0], 2), 0, out)
+    expect(out[MATERIALS.indexOf('rock')]! + out[MATERIALS.indexOf('sandstone')]!).toBeGreaterThan(0.8)
+  })
+
+  it('stands a dune field up in the sand, crest to trough about the height asked for', () => {
+    let sandy: [number, number] | null = null
+    for (const [k, z, a] of cells()) {
+      if (!sandy && d.sand[k]! > 0.95 && d.plateau[k]! < 0.02 && d.cut[k]! < 1) sandy = [z, a]
+    }
+    expect(sandy).not.toBeNull()
+    // Across the wind — the dunes' crests run across it, so a transect along it crosses them.
+    let lo = Infinity, hi = -Infinity
+    for (let i = 0; i < 40; i++) {
+      const h = ground(sandy![0] + i * 60, sandy![1])
+      lo = Math.min(lo, h); hi = Math.max(hi, h)
+    }
+    expect(hi - lo).toBeGreaterThan(reach.dunes.heightM * 0.3)
+    expect(hi - lo).toBeLessThan(reach.dunes.heightM * 2.5)
+  })
+
+  it('costs about a second of the compile, and no seam where theta wraps', () => {
+    const { grid } = d
+    expect(grid.cell * grid.na).toBeCloseTo(2 * Math.PI * RADIUS_M, 3)
+    for (const z of [152_000, 170_000, 200_000]) {
+      const before = ground(z, -30)
+      const after = ground(z, 2 * Math.PI * RADIUS_M - 30)
+      expect(Math.abs(after - before)).toBeLessThan(0.01)
+    }
+    expect(CORRIDOR_M).toBeGreaterThan(1000)
+  })
+})
diff --git a/test/terrain.test.ts b/test/terrain.test.ts
index f71ccd3..751cd1b 100644
--- a/test/terrain.test.ts
+++ b/test/terrain.test.ts
@@ -1,6 +1,6 @@
 import { readFileSync } from 'node:fs'
 import { describe, expect, it } from 'vitest'
-import { RADIUS_M } from '../src/canon/constants.js'
+import { LENGTH_M, 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'
@@ -256,7 +256,42 @@ describe('water', () => {
       deepest = Math.max(deepest, rim - floor)
     }
     expect(deepest).toBeGreaterThan(300)
-    expect(deepest).toBeLessThan(700)
+    // Since the Dry Reach grew mesas (D-069), the rim above the lower river is the desert's
+    // rock as often as the ridge's, and the deepest section runs about 780 m.
+    expect(deepest).toBeLessThan(1100)
+  })
+
+  it('puts the Capital at the centre of the world with its edge on the water', () => {
+    // D-070: 19 km across, centred on the axial midpoint, reaching The Deep's shore.
+    const capital = world.features.find((f) => f.id === 'the-capital')!
+    expect(capital.kind).toBe('settlement')
+    if (capital.kind !== 'settlement' || capital.geometry.type !== 'point') throw new Error('shape')
+    expect(capital.geometry.z).toBe(LENGTH_M / 2)
+    const { theta, z } = capital.geometry
+    // Dry at the marker, and wet at the spinward edge: the city has a waterfront.
+    expect(rt.sample(theta, z, 5).water).toBe(-Infinity)
+    expect(rt.sample(theta + capital.radiusM / RADIUS_M, z, 5).water).toBeGreaterThan(0)
+    // And the shore is inside the city rather than a long way outside it: within 2 km.
+    expect(rt.sample(theta + (capital.radiusM - 2000) / RADIUS_M, z, 5).water).toBe(-Infinity)
+  })
+
+  it('keeps the plateaus off the river, so The Narrows is a gap and not a slot', () => {
+    // Tower did not raise rock across a route it had already cut (desert.ts CORRIDOR_M).
+    const trunk = cw.rivers.find((r) => r.id === 'the-long-water')!
+    let deepest = 0
+    for (let i = 0; i < trunk.level.length; i++) {
+      const z = trunk.line.pts[2 * i]!
+      if (z < 165_000 || z > 180_000) continue
+      const theta = trunk.line.pts[2 * i + 1]! / RADIUS_M
+      const floor = rt.sample(theta, z, 5).h
+      for (const off of [2000, 3000, 4000, 5000]) {
+        deepest = Math.max(deepest,
+          rt.sample(theta + off / RADIUS_M, z, 5).h - floor,
+          rt.sample(theta - off / RADIUS_M, z, 5).h - floor)
+      }
+    }
+    expect(deepest).toBeGreaterThan(150)
+    expect(deepest).toBeLessThan(500)
   })
 
   it('builds the floor up under a river only a little, now the lowland is raised', () => {
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index 9b34a7d..4d1fcfb 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -334,6 +334,30 @@ const SHOTS = [
       await settle(page, 1400)
     },
   },
+  {
+    id: 'tour-mesas',
+    caption: 'Scenic tour — past the mesas of the Dry Reach, strata lining up across all of them',
+    on: ['desktop'],
+    scene: 'mesas',
+    run: async (page) => {
+      await openView(page, 'interior')
+      const title = await page.evaluate(() => window.tower.showScene('mesas', 16))
+      if (!title) throw new Error('no scene mesas in this world')
+      await settle(page, 1400)
+    },
+  },
+  {
+    id: 'tour-pan',
+    caption: 'Scenic tour — over a salt pan, ringed by the shorelines of a lake that never stood',
+    on: ['desktop'],
+    scene: 'pan',
+    run: async (page) => {
+      await openView(page, 'interior')
+      const title = await page.evaluate(() => window.tower.showScene('pan', 26))
+      if (!title) throw new Error('no scene pan in this world')
+      await settle(page, 1400)
+    },
+  },
   {
     id: 'tour-narrows',
     caption: 'Scenic tour — through The Narrows, the one gap in all three ridges',
@@ -688,6 +712,13 @@ const flag = (name) => {
 }
 const threshold = Number(flag('--threshold') ?? DEFAULT_THRESHOLD)
 const givenUrl = flag('--url')
+/**
+ * `--only tour-storm,interior` retakes just those shots (id prefixes) and leaves the rest
+ * of the sheet alone. For working on one view: the whole tour is 23 shots and three
+ * minutes of somebody's fans.
+ */
+const only = (flag('--only') ?? '').split(',').map((s) => s.trim()).filter(Boolean)
+const wanted = (shot) => only.length === 0 || only.some((o) => shot.id.startsWith(o))
 
 await mkdir(OUT, { recursive: true })
 /**
@@ -697,7 +728,7 @@ await mkdir(OUT, { recursive: true })
 const previous = new Map()
 for (const profile of PROFILES) {
   for (const shot of SHOTS) {
-    if (!shot.on.includes(profile.id)) continue
+    if (!shot.on.includes(profile.id) || !wanted(shot)) continue
     const file = fileFor(profile.id, shot.id)
     const buf = await readFile(file).catch(() => null)
     if (buf) previous.set(`${profile.id}-${shot.id}`, `data:image/png;base64,${buf.toString('base64')}`)
@@ -749,7 +780,7 @@ try {
     const jobs = []
     for (const profile of PROFILES) {
       for (const shot of SHOTS) {
-        if (shot.on.includes(profile.id)) jobs.push({ profile, shot })
+        if (shot.on.includes(profile.id) && wanted(shot)) jobs.push({ profile, shot })
       }
     }
     const results = new Array(jobs.length)
@@ -780,8 +811,13 @@ try {
    */
   const sheet = await launch()
   try {
-    console.log('Overview')
-    await buildOverview(sheet, cells)
+    // The sheet is every shot together, so it is only rebuilt when every shot was taken.
+    if (only.length) {
+      console.log(`Only ${only.join(', ')}: the overview sheet is left as it was.`)
+    } else {
+      console.log('Overview')
+      await buildOverview(sheet, cells)
+    }
 
     if (!previous.size) {
       console.log('\nNothing to compare against — this run is the baseline.')
diff --git a/world-generation.md b/world-generation.md
index 41160a9..486bf24 100644
--- a/world-generation.md
+++ b/world-generation.md
@@ -1,7 +1,8 @@
 # Tower — Live World Generation
 
-**Status: IN PROGRESS.** Accepted by the author (D-062). **Phases 1–4 are built**
-(D-063 to D-067), with a scenic tour between 4 and 5 (§19, D-068); phases 5–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–5 are built**
+(D-063 to D-067, D-069), with a scenic tour between 4 and 5 (§19, D-068); phases 6–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.
 
@@ -439,6 +440,9 @@ Because none of it is history, the generator is free to place these as **set
 pieces** — a salt pan ringed by shorelines where the author wants the view, not where
 a drainage model would demand one.
 
+*As built (§20): all of the above, with the pans placed in the lowest ground rather than
+by hand, and the terraces cut by the tiles rather than compiled.*
+
 ### 4.6 Forest — "trees, planned concentrations, meadows, paths"
 
 **Trees are real size.** Conifers 20–45 m tall with narrow crowns; broadleaf 15–30 m
@@ -501,6 +505,10 @@ other feature. Where it overlaps Greatwood, the Tall Wood's trees replace Greatw
 
 ### 4.7 Settlements — "blocky buildings and a few simple roads"
 
+*As built so far (D-070): a settlement is a **point with an extent** — a size and a radius,
+drawn on the map as a ring — and the Capital is 20 km across, centred on the axial midpoint,
+which reaches The Deep's shore. The layout inside it is this phase's work.*
+
 No usable city generator exists on npm (§7.3), so this is written in-house and kept
 deliberately simple, as asked. One generator handles every size; `size` sets the
 defaults:
@@ -1026,7 +1034,7 @@ screenshot check before any commit.
 | 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 |
+| 5 ✅ | **Desert** | dunes, hills, terraced plateaus, washes, salt pans, shorelines |
 | 6 | **Forest** | density field, stands, meadows, the tall forest, three-distance tree rendering |
 | 7 | **Settlements, roads, paths** | one layout generator for every size, generated settlements and Adopt, routing, grading, buildings |
 | 8 | **Editing polish, performance, phones** | incremental compile everywhere, IndexedDB caches, phone tier, screenshot tour |
@@ -1253,7 +1261,59 @@ find them, and a screenshot at a fixed place was a poor way to show them.
 - **Controls**: *Scenic tour* in the interior's panel starts and stops it; **N** skips to
   the next scene; **Esc**, a drag, the wheel or a flight key stops it where it is.
 - **The screenshot tour photographs the scenes**, each held at one moment
-  (`tower.showScene(id, t)`), in place of four hand-placed shots. A test checks that every
+  (`tower.showScene(id, t)`), in place of four hand-placed shots. A held scene stops the
+  water's clock as well as the world's, or waves and foam made every run diverge; and
+  `--only <ids>` retakes a few shots rather than all twenty-three. A test checks that every
   scene the screenshot tool names exists, and flies every scene over the real world:
   cameras stay in the world and above the ground, move and turn smoothly, and keep a
   followed ship in the middle of the frame.
+
+---
+
+## 20. Phase 5, as built (D-069)
+
+- **A desert is a grid and a pile of noise.** `terrain/desert.ts` compiles what a tile
+  cannot work out alone — the plateau field, the wash network, the pans and their
+  shorelines — on a 250 m grid over the desert (352,000 cells for the Dry Reach, about a
+  second). Dunes and rocky hills are noise, and the tiles make them (field.ts).
+- **The grid machinery is now shared.** `terrain/grid.ts` holds the grid, the priority
+  flood, the box blur, the neighbourhood walk and bilinear sampling, which the ranges and
+  the deserts both use; the ranges' copies moved there unchanged.
+- **Terraces are cut as the tile samples, not in the compile.** The compile stores how far
+  up the plateau each cell stands, smooth; `terrace()` turns that into flat tops and steep
+  risers at whatever detail the tile has. Terraced in the compile, every cliff would be as
+  wide as a grid cell — a 250 m ramp instead of a cliff band.
+- **The plateau threshold is measured, not guessed.** The authored coverage is turned into
+  a threshold by taking that quantile of the noise as sampled over this desert, and the
+  same for the dune fields. Thresholds picked against an assumed spread put mesas over
+  everything or nowhere — which is exactly what the first attempt did.
+- **The washes are routed, not drawn.** The same priority flood the ranges erode with runs
+  over the desert's own surface, and where a cell would drain more than 15 km² it is cut
+  by 4 m and more, to tens of metres on the trunks. Two things this taught:
+  - A flat desert has no drainage at all. The first version fanned out from every edge and
+    gathered nothing, so **Tower crowns its deserts**: the middle of the Reach stands 150 m
+    above its rim, and everything runs off it.
+  - Widening a one-cell channel by blurring it averages the channel away. The wash is
+    **dilated** to its full depth over a couple of cells first, and blurred afterwards for
+    the banks.
+- **Salt pans are set pieces in the low ground** (§4.5 allows exactly this): the lowest
+  cells, spaced apart, each a disc flattened to one level with a crust that frays at the
+  rim, and benches stepped every 20 m around it — the shorelines of a lake that never
+  stood. Both are **levels in the world**, not in the sand: the shelf under the desert
+  rolls by a hundred metres, and a floor that followed it would not be a floor.
+- **Tower keeps its rock off its rivers.** A mesa field dropped across The Narrows left
+  the whole world's drainage cutting a kilometre-deep slot through it. Rivers now carry a
+  6.5 km corridor the plateaus keep out of, which is also what a river crossing a mesa
+  field looks like. Beside the lower Long Water the desert's rock still stands over the
+  river, and that section of canyon now runs about 780 m rather than 480.
+- **Rock is a material, and so is salt.** Materials gained a ninth weight, `salt`, and
+  bare rock is now claimed by the desert's plateaus, its gravel wash floors, and **any
+  steep desert face** — sand does not stand at 60°. The ground shader lays **strata** by
+  elevation, so the bands line up across separate mesas however far apart they stand,
+  which is what makes a field of them read as one eroded formation.
+- **The panel edits all of it**: dune height, spacing, direction and coverage; hills;
+  plateau height, coverage and terraces; washes, pans and shorelines — with a readout of
+  what was actually made ("24% standing rock, 37% dune field, 4 salt pans").
+- **The tour gained two scenes** (§19): past the mesas from the sand, and over a salt pan
+  ringed by its shorelines.
+- **Cost**: the whole world compiles in about 5 s, of which the Dry Reach is about 1 s.