fuzzyworld

tower / commit

feat(weather): Tower's published weather, ships that read it, and a scenic tour

a088e5d58b49e82cbce8192f64b60be667cf61c5 · Pavel Safronov · 2026-09-17 12:46:49 -0700 · parents 80bcc97a8c88

Claude Code usage: this commit ~400.0K out / ~67.4M cached · cost unknown (no per-model trailer) · session 1 tally 2.1K in / 1.7M out / 511.3M cached · usage by commit and session

feat(weather): Tower's published weather, ships that read it, and a scenic tour

Phase 4 of world-generation.md, plus the scenic tour the author asked for before
phase 5. §18, §19, D-067 and D-068 have the detail.

The weather plan is data in the world file, and it keeps both halves of Tower's
habit: what it published, and what it later changed its mind about. Waves follow
what Tower actually does; ships follow what people were told, so a sailor warned
of a storm stays in harbour even after Tower quietly calls it off, and a squall
announced an hour ahead catches the fleet at sea. Everything is a pure function
of the plan and the hour, so scrubbing the clock is the same as waiting.

The sea state left the compile with it: water vertices name their body and the
shader looks their waves up, so wind and ship counts no longer touch the ground.
Whitecaps cover the share of a sea Monahan's fit gives for the wind. Ships are
instanced procedural hulls, sails and jibs on lanes round the ring seas, riding
the same wave sum the shader draws.

A float32 fault surfaced under a storm: the water shader's coordinate around the
ring was good to only 6 m, so whitecaps and river ripples smeared into streaks
radiating from the eye. Water now measures from a reference near the eye that
steps in whole noise periods.

The tour flies nine scenes of thirty seconds each, worked out from the compiled
world rather than written down, and the screenshot tour photographs them in place
of four shots placed by hand.

Also corrected the docs on writer mode: it has always been the default, with
?reader=1 to preview.

Usage: 2.1K in / 1.7M out / 511.3M cached (session tally)
rtk:   3.4M saved, 37.7% over 3656 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 README.md                                      |  53 +++-
 content/world.json                             | 134 ++++++++-
 decisions.md                                   |  65 +++++
 design.md                                      |  59 +++-
 docs/screenshots/desktop-exterior-true.png     | Bin 539616 -> 534371 bytes
 docs/screenshots/desktop-exterior.png          | Bin 507001 -> 510088 bytes
 docs/screenshots/desktop-interior-dawn.png     | Bin 463201 -> 466498 bytes
 docs/screenshots/desktop-interior-falls.png    | Bin 634992 -> 0 bytes
 docs/screenshots/desktop-interior-night.png    | Bin 298259 -> 299304 bytes
 docs/screenshots/desktop-interior-overhead.png | Bin 508161 -> 509234 bytes
 docs/screenshots/desktop-interior-range.png    | Bin 732424 -> 0 bytes
 docs/screenshots/desktop-interior-suncap.png   | Bin 383649 -> 384016 bytes
 docs/screenshots/desktop-interior.png          | Bin 771885 -> 846058 bytes
 docs/screenshots/desktop-map-sightline.png     | Bin 915694 -> 917532 bytes
 docs/screenshots/desktop-map.png               | Bin 908759 -> 907090 bytes
 docs/screenshots/desktop-reader.png            | Bin 892028 -> 895119 bytes
 docs/screenshots/desktop-tour-deep.png         | Bin 0 -> 342244 bytes
 docs/screenshots/desktop-tour-falls.png        | Bin 0 -> 923859 bytes
 docs/screenshots/desktop-tour-girdle.png       | Bin 0 -> 675092 bytes
 docs/screenshots/desktop-tour-narrows.png      | Bin 0 -> 689037 bytes
 docs/screenshots/desktop-tour-river.png        | Bin 0 -> 533479 bytes
 docs/screenshots/desktop-tour-ship.png         | Bin 0 -> 414025 bytes
 docs/screenshots/desktop-tour-squall.png       | Bin 0 -> 404218 bytes
 docs/screenshots/desktop-tour-storm.png        | Bin 0 -> 388165 bytes
 docs/screenshots/desktop-tour-summit.png       | Bin 0 -> 914702 bytes
 docs/screenshots/mobile-exterior.png           | Bin 114766 -> 115025 bytes
 docs/screenshots/mobile-interior.png           | Bin 224461 -> 264804 bytes
 docs/screenshots/mobile-panel.png              | Bin 95792 -> 100781 bytes
 docs/screenshots/overview.png                  | Bin 1539806 -> 2254247 bytes
 index.html                                     |  40 ++-
 src/style.css                                  |  18 ++
 src/terrain/compile.ts                         |  25 +-
 src/terrain/field.ts                           |  21 +-
 src/terrain/sea-state.ts                       |  73 +++++
 src/terrain/service.ts                         |   5 +
 src/terrain/ships.ts                           | 137 ++++++++++
 src/terrain/tile.ts                            |  13 +-
 src/ui/app.ts                                  | 236 +++++++++++++++-
 src/views/interior-model.ts                    | 363 ++++++++++++++++++++++--
 src/views/interior.ts                          | 217 ++++++++++++++-
 src/views/scenic.ts                            | 365 +++++++++++++++++++++++++
 src/world/schema.ts                            | 117 +++++++-
 src/world/store.ts                             |  12 +-
 src/world/weather.ts                           | 142 ++++++++++
 static/world.json                              | 134 ++++++++-
 test/scenic.test.ts                            |  83 ++++++
 test/weather.test.ts                           | 168 ++++++++++++
 test/world.test.ts                             |   4 +-
 tools/screenshots.mjs                          | 124 +++++++--
 world-generation.md                            | 110 +++++++-
 50 files changed, 2583 insertions(+), 135 deletions(-)

diff --git a/README.md b/README.md
index 4de7e10..dd52c03 100644
--- a/README.md
+++ b/README.md
@@ -36,12 +36,13 @@ 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 and 2 are built —
+**In progress: [world-generation.md](world-generation.md).** Phases 1 to 4 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, and mountain ranges are
-summits joined by ridges and cut by water into valleys. Phases 3–8 add water with waves
-and flow, ships, deserts, forests of individual trees, settlements and roads.
+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.
 
 - [design.md](design.md) — canon, physics and application architecture
 - [world-generation.md](world-generation.md) — how the world is generated, and the plan
@@ -57,7 +58,7 @@ travel there — see [decisions.md](decisions.md) D-043.
 
 ## Screenshots
 
-[![All ten views on one sheet](docs/screenshots/overview.png)](docs/screenshots/overview.png)
+[![Every shot on one sheet](docs/screenshots/overview.png)](docs/screenshots/overview.png)
 
 `npm run screenshots` drives the real application in a headless browser — clicking
 the view buttons, picking places out of the list, dragging to orbit and to look up —
@@ -84,8 +85,15 @@ another machine will show a large one-off divergence rather than a regression.
 | [exterior](docs/screenshots/desktop-exterior.png) | x-ray hull — the inner surface projected out, with the caps and the collector |
 | [exterior-true](docs/screenshots/desktop-exterior-true.png) | true hull — bare skin, and the land visible through the far cap window |
 | [interior](docs/screenshots/desktop-interior.png) | standing at The Narrows, the ground curving up on both sides |
-| [interior-range](docs/screenshots/desktop-interior-range.png) | from Snowcrown's summit: the Girdle's ridges and valleys running round the world |
-| [interior-falls](docs/screenshots/desktop-interior-falls.png) | the Long Water falling 680 m off the range into its gorge |
+| [tour-falls](docs/screenshots/desktop-tour-falls.png) | scenic tour: a helicopter circling the Long Water's 680 m fall |
+| [tour-ship](docs/screenshots/desktop-tour-ship.png) | scenic tour: following a ship round the Sunward Sea |
+| [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-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 |
+| [tour-girdle](docs/screenshots/desktop-tour-girdle.png) | scenic tour: nine kilometres over the Girdle |
 | [interior-overhead](docs/screenshots/desktop-interior-overhead.png) | looking up: the far side 100 km overhead is clearer than ground 100 km away |
 | [interior-dawn](docs/screenshots/desktop-interior-dawn.png) | dawn — a lit sky over dark ground, the light still 27 km away |
 | [reader](docs/screenshots/desktop-reader.png) | `?reader=1` — terrain, features, a detail card and the probe, no editing |
@@ -130,7 +138,7 @@ 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         # 120 tests; canon figures are pinned to design.md
+npm test         # 167 tests; canon figures are pinned to design.md
 npm run build
 npm run screenshots  # drive the app and rephotograph docs/screenshots/
 ```
@@ -265,6 +273,21 @@ waves sized from each body's wind and fetch, rivers running downstream, and rapi
 A river that cannot slope falls: the Long Water drops 680 m off the range into a gorge,
 and the falling water drifts antispinward as it goes — the spin, shown by what falls.
 
+**The weather is Tower's, and it is published.** Waves follow Tower's weather plan: a
+storm builds the sea over a few hours, whitecaps spread with the wind, and the sea keeps
+running after the wind drops. **Ships** sail both ring seas, each on its own lane round the
+world, riding the waves drawn under it with its mainsail set to the wind. When a gale or
+storm has been published for their sea in time, they stay in harbour — even if Tower has
+since called it off, because people act on what they were told — and a squall announced
+too late catches them at sea. Set the **Day** in the view panel, or run the clock, to move
+through the plan.
+
+**Or take the scenic tour.** *Scenic tour* in the view panel flies you round the sights,
+a new one every thirty seconds: circling the great waterfall, following a ship, round
+Snowcrown, down the Long Water into The Deep, a ship caught in a squall, through The
+Narrows, over The Deep, a storm at sea, and high over the Girdle. **N** skips ahead;
+**Esc**, a drag or a flight key stops it and leaves you where it was.
+
 **Or fly.** W A S D or the arrow keys move you, E climbs and Q descends, and shift
 goes five times faster. Speed follows your height above the ground: a brisk walk
 at standing height, and a sea crossed in seconds from ten kilometres up, with nothing
@@ -330,9 +353,17 @@ cuts it, and its snowline. A change regenerates the range — a few seconds for
 Girdle, under the status dialog — and edits made meanwhile are folded into the next.
 
 Select a **lake or sea** to set its wind: the direction it blows toward and its speed.
-The editor says what sea that raises — a 10 m/s wind over The Deep makes waves under a
-metre; the same wind round a ring sea, which never runs out of water to blow over, makes
-2 m, and 20 m/s makes 8.6. Until Tower's weather plan arrives, that wind is steady.
+That is its usual wind, which blows whenever the weather plan has nothing scheduled. The
+editor says what sea is running there *now*, by the clock — a 10 m/s wind over The Deep
+makes waves under a metre; the same wind round a ring sea, which never runs out of water
+to blow over, makes 2 m, and 20 m/s makes 8.6. A sea also has a number of ships.
+
+Open **Weather** to see Tower's plan: what is blowing now and the next fortnight, with any
+revision listed under the event it changed and the original struck through. Readers see
+only what has been published. As the writer you can **schedule** weather over a lake or
+sea (it is published the moment you add it), **revise** an event from now with a reason —
+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.
 
 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
diff --git a/content/world.json b/content/world.json
index a8c5fd2..6a3192b 100644
--- a/content/world.json
+++ b/content/world.json
@@ -902,5 +902,137 @@
       },
       "size": "station"
     }
-  ]
+  ],
+  "weather": {
+    "cycleDays": 28,
+    "events": [
+      {
+        "id": "sunward-sea-squall",
+        "target": "sunward-sea",
+        "kind": "gale",
+        "start": {
+          "day": 11,
+          "hour": 9
+        },
+        "durationH": 5,
+        "wind": {
+          "directionDeg": 120,
+          "speedMS": 22
+        },
+        "publishedAt": {
+          "day": 11,
+          "hour": 8
+        },
+        "revisions": []
+      },
+      {
+        "id": "sunward-sea-storm",
+        "target": "sunward-sea",
+        "kind": "storm",
+        "start": {
+          "day": 3,
+          "hour": 6
+        },
+        "durationH": 18,
+        "wind": {
+          "directionDeg": 90,
+          "speedMS": 28
+        },
+        "publishedAt": {
+          "day": 1,
+          "hour": 12
+        },
+        "revisions": []
+      },
+      {
+        "id": "deepward-sea-gale",
+        "target": "deepward-sea",
+        "kind": "gale",
+        "start": {
+          "day": 9,
+          "hour": 14
+        },
+        "durationH": 10,
+        "wind": {
+          "directionDeg": 270,
+          "speedMS": 20
+        },
+        "publishedAt": {
+          "day": 8,
+          "hour": 0
+        },
+        "revisions": []
+      },
+      {
+        "id": "sunward-sea-calm",
+        "target": "sunward-sea",
+        "kind": "calm",
+        "start": {
+          "day": 15,
+          "hour": 0
+        },
+        "durationH": 24,
+        "wind": {
+          "directionDeg": 90,
+          "speedMS": 1
+        },
+        "publishedAt": {
+          "day": 13,
+          "hour": 9
+        },
+        "revisions": []
+      },
+      {
+        "id": "deepward-sea-storm",
+        "target": "deepward-sea",
+        "kind": "storm",
+        "start": {
+          "day": 20,
+          "hour": 18
+        },
+        "durationH": 30,
+        "wind": {
+          "directionDeg": 60,
+          "speedMS": 30
+        },
+        "publishedAt": {
+          "day": 17,
+          "hour": 9
+        },
+        "revisions": [
+          {
+            "at": {
+              "day": 21,
+              "hour": 6
+            },
+            "kind": "gale",
+            "wind": {
+              "directionDeg": 60,
+              "speedMS": 18
+            },
+            "reason": "A fishing boat that ignored the plan is still out. Tower is holding the storm down to a gale until it is in."
+          }
+        ]
+      },
+      {
+        "id": "the-deep-gale",
+        "target": "the-deep",
+        "kind": "gale",
+        "start": {
+          "day": 24,
+          "hour": 10
+        },
+        "durationH": 8,
+        "wind": {
+          "directionDeg": 0,
+          "speedMS": 18
+        },
+        "publishedAt": {
+          "day": 23,
+          "hour": 10
+        },
+        "revisions": []
+      }
+    ]
+  }
 }
diff --git a/decisions.md b/decisions.md
index bebb3da..2b7af5e 100644
--- a/decisions.md
+++ b/decisions.md
@@ -290,6 +290,9 @@ diffable and versioned, with no service to run or pay for. Drafts sit in
 **Coordinates in the file are canon (θ, z), never map pixels,** so the world
 survives any change to how the map is drawn. Consistent with D-007.
 
+*Correction (D-068): as built, writer mode has always been the default and `?reader=1`
+turns it off; `?writer=1` does nothing. The reasoning below holds either way.*
+
 **Writer mode is a UI mode, not a security boundary.** `?writer=1` is guessable and
 that is fine — an editor with no persistence and no backend can only alter the
 visitor's own copy. Recorded explicitly so it is never mistaken for access control
@@ -2173,3 +2176,65 @@ full pool of six workers. The compiled world is now cached in IndexedDB — whic
 readers get too, as §5.4 planned — keyed so a new build never reads an old world. The tour
 compiles once, hands every shot the warm cache, caps each page at two workers, and reports
 any shot that compiled anyway. It takes about 100 s and averages about 3.4 cores.
+
+---
+
+## D-067 — Tower's weather is a published plan, and ships read it
+
+**Built.** `world-generation.md` phase 4; §18 there has the detail.
+
+**The plan keeps both halves.** An event records when Tower published it; a revision
+records when Tower changed its mind, and why. The world then has two readings of the same
+plan: what Tower does (the event as revised so far) and what people were told (the event as
+published). The waves follow the first. The ships follow the second, so a sailor warned of
+a storm stays in harbour even after Tower quietly calls it off — and a storm nobody was told
+about catches the fleet at sea. This is the author's answer to D-062 question 2 made
+mechanical.
+
+**Weather and ships are functions of the clock, not simulations.** The interior's clock
+gained a day number, counted from 0 (D-062). Wind, sea state, whitecaps and every ship's
+position are pure functions of the world file and the hour, so a link to a moment shows the
+same sea to everyone, and scrubbing the clock is the same as waiting.
+
+**The sea state left the compile.** Phase 3 baked waves into the compiled world; with
+weather that changes hourly that would recompile the world for every gust. Water vertices
+now name their body and the shader looks the waves up. Wind and ship counts no longer
+change the terrain signature.
+
+**A float32 precision fault was fixed on the way.** The water shader's around-the-ring
+coordinate was only good to about 6 m, and whitecaps drew as streaks radiating from the
+eye. Water now measures from a reference near the eye that steps in whole noise periods,
+with each wave's phase at the reference added from float64. River ripples were suffering
+from the same fault unnoticed.
+
+**Simplifications, each recorded:** no tacking, no harbours drawn, ships only on ring
+seas, flat sails. The default plan is 28 days long and repeats: storms and gales on both
+cap seas and The Deep, a flat calm, and one storm Tower holds down to a gale because a
+fishing boat ignored the plan.
+
+---
+
+## D-068 — A scenic tour, and screenshots taken from it
+
+**Built**, between phases 4 and 5 at the author's request. `world-generation.md` §19 has
+the detail.
+
+**Particular things want a way to be found.** With a 678 m waterfall, a fleet and scheduled
+storms in the world, standing still and dragging is a poor way to see them, and a
+screenshot placed by hand at coordinates goes stale the moment the world is edited. The
+interior view now flies a tour of nine scenes, thirty seconds each — helicopter arcs,
+flights along rivers and over The Deep, chase cameras on ships — each worked out from the
+compiled world rather than written down, so it follows edits. The screenshot tour
+photographs those scenes, replacing four hand-placed shots, and a test flies all of them.
+
+**Ships can be caught out.** A ship chased through bad weather needs a ship at sea in bad
+weather, and D-067's harbour rule emptied the sea the moment any gale was published, even
+one published as it began. Ships now go in only for weather announced at least three hours
+ahead — the time they are given to get in — and ride out anything announced later. The
+default plan gains one such squall on the Sunward Sea, on day 11. Tower keeping the fleet
+honest about its warnings is also a better story than a fleet that vanishes on cue.
+
+**A documentation fault, corrected.** design.md §3.1 and world-generation.md §10 said
+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.
diff --git a/design.md b/design.md
index 677df5b..e421164 100644
--- a/design.md
+++ b/design.md
@@ -554,6 +554,9 @@ along the axis**, which is the natural grain of an engineered climate (§1.9.4):
 | 270 km | **The Capital / The Spindle** | axial midpoint, on The Deep's antispinward shore |
 | 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.
+
 #### Why the desert rings
 
 **On a cylinder you cannot go around a band.** Every desert on Earth can be
@@ -670,6 +673,8 @@ src/
     compile.ts      the world compile: shapes, river profiles, lake levels
     mountains.ts    summits, ridge graphs, steady-state erosion, pinned summits
     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
     falls.ts        waterfall sheets, leaning antispinward as they fall
     cache.ts        the compiled world kept in IndexedDB between visits
     field.ts        the elevation function: one answer to "how high is it here"
@@ -683,10 +688,12 @@ src/
   views/
     palette.ts      one colour per feature kind, shared by map, list and editor
     terrain-lod.ts  the ground as a quadtree of tiles, refined around the eye
+    scenic.ts       the scenic tour's camera moves, worked out from the world
   world/
     schema.ts       world.json v2 types + validation: features, not places
     store.ts        feature CRUD, localStorage drafts, JSON import/export
     instruments.ts  sightline, viewshed, lighting probe, travel (§3.3)
+    weather.ts      Tower's weather plan read at an hour: what it does, what it told people
   state/
     store.ts        selected point, time-of-day, spin phase, view mode, writer flag
   views/
@@ -722,7 +729,7 @@ type TowerState = {
   spinPhase: number  // 0..2pi, rotation for star motion
   spinning: boolean
   view: 'exterior' | 'map' | 'interior'
-  writer: boolean    // ?writer=1 -- a UI mode, not a security boundary
+  writer: boolean    // true unless ?reader=1 -- a UI mode, not a security boundary
   probe: { from: SurfacePoint; to: SurfacePoint } | null
 }
 ```
@@ -888,11 +895,23 @@ seen from within the world, which is why it is the only place it is drawn.
 — a level in Tower is a constant radius, so a lake curves up with the floor — drawn over
 the ground with the depth test finding the shoreline, and the ground under it drawn as
 bed. Deep water is dark and shallow water shows the bed through it. Waves are sized from
-each body's wind and the fetch it blows over, by the relations fitted to real seas, and
-drawn as four components that displace the surface near the eye and shade it beyond;
+each body's wind — Tower's weather plan, or the body's usual wind when nothing is
+scheduled (D-067) — and the fetch it blows over, by the relations fitted to real seas, and
+drawn as six components that displace the surface near the eye and shade it beyond;
 rivers carry ripples downstream at the speed Manning's equation gives them, and turn
-white on their rapids. Water mirrors the hazed land overhead, and glints where it
-mirrors the beam.
+white on their rapids. Whitecaps cover the share of a sea the wind speed gives. Water
+mirrors the hazed land overhead, and glints where it mirrors the beam.
+
+**Ships** sail the ring seas (D-067): procedural hulls, mainsails and jibs, instanced, each
+on a lane round the world at a position that is a function of the clock, riding the waves
+drawn under it with the mainsail set to the wind. While a gale or storm is published for
+their sea in time to get in, they are in harbour — off the water, since harbours are not
+drawn yet; a squall announced too late catches them out.
+
+**The scenic tour** (D-068) flies the view itself: a scene every thirty seconds — circling
+a waterfall or a summit, flying down a river or over The Deep, a chase camera on a ship in
+calm or in a squall — fading through black between them. The scenes are worked out from
+the world, so they follow its edits, and the screenshot tour photographs them.
 
 **Waterfalls lean.** A river's profile steps where it cannot slope — off a range, into
 The Deep — and a step is a fall with a gorge below it. The falling sheet leaves the lip at
@@ -915,7 +934,7 @@ placed, named and kept.
 
 ### 3.1 Two modes over one data file
 
-| | Reader (default) | Writer (`?writer=1`) |
+| | Reader (`?reader=1`) | Writer (the default, for now) |
 |---|---|---|
 | World content | committed `world.json`, read-only | editable, drafts in `localStorage` |
 | Instruments (§3.3) | sightline probe only | all |
@@ -926,8 +945,12 @@ There is no backend — GitLab Pages is static. The loop is: the author edits in
 browser, exports `world.json`, commits it, and the published site serves it. Simple,
 versioned in git alongside the prose, and diffable.
 
-**Writer mode is a UI mode, not a security boundary.** Anyone may set `?writer=1`;
-they can only edit their own local copy, which persists nowhere and reaches nobody.
+**Writer mode is the default while the world is being built** — the app has always
+shipped that way, and making readers the default is a one-line change when it is ready to
+publish. §3.1 once said the opposite; see D-068.
+
+**Writer mode is a UI mode, not a security boundary.** Anyone can use it; they can only
+edit their own local copy, which persists nowhere and reaches nobody.
 This is fine, and arguably good — a reader who finds it gets a sandbox. It must not
 be mistaken for access control.
 
@@ -950,7 +973,18 @@ be mistaken for access control.
     "seed": 1437950893,                 // written once at creation, never from the id
     "levelM": 120, "depthM": 330, "basin": "flooded", "shoreM": 11000
     // ...and whatever parameters the kind has: world-generation.md §3
-  }]
+  }],
+  "weather": {                          // Tower's published plan (D-067)
+    "cycleDays": 28,                    // the plan repeats; 0 for never
+    "events": [{
+      "id": "deepward-sea-storm", "target": "deepward-sea", "kind": "calm|breeze|gale|storm",
+      "start": { "day": 20, "hour": 18 }, "durationH": 30,
+      "wind": { "directionDeg": 60, "speedMS": 30 },   // toward; 0 deepward, 90 spinward
+      "publishedAt": { "day": 17, "hour": 9 },
+      "revisions": [{ "at": { "day": 21, "hour": 6 }, "kind": "gale",
+                      "wind": { "directionDeg": 60, "speedMS": 18 }, "reason": "..." }]
+    }]
+  }
 }
 ```
 
@@ -1044,7 +1078,8 @@ the same reason.
 Phase 1 ✅ (D-063): world file v2, the elevation function, the worker pool, tiled
 ground, the status dialog, keyboard flight, the map drawn from the features. 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. Phases 2–8
-bring procedural scenery, settlements, and weather as Tower's published schedule of
-wind and waves — which this section used to list as out of scope. Clouds and rain stay
+✅ (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
 undrawn (D-049).
diff --git a/docs/screenshots/desktop-exterior-true.png b/docs/screenshots/desktop-exterior-true.png
index 768ac18..a5a49f2 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 b74863c..f41595a 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 9769d67..f67ad41 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-falls.png b/docs/screenshots/desktop-interior-falls.png
deleted file mode 100644
index b3e4614..0000000
Binary files a/docs/screenshots/desktop-interior-falls.png and /dev/null differ
diff --git a/docs/screenshots/desktop-interior-night.png b/docs/screenshots/desktop-interior-night.png
index 2697ac5..1a7b366 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 3a722d9..d1cee16 100644
Binary files a/docs/screenshots/desktop-interior-overhead.png and b/docs/screenshots/desktop-interior-overhead.png differ
diff --git a/docs/screenshots/desktop-interior-range.png b/docs/screenshots/desktop-interior-range.png
deleted file mode 100644
index fc5fdf0..0000000
Binary files a/docs/screenshots/desktop-interior-range.png and /dev/null differ
diff --git a/docs/screenshots/desktop-interior-suncap.png b/docs/screenshots/desktop-interior-suncap.png
index 3dc377e..0e878fe 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 9c66fac..e2ec16e 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 3b818e1..8e95896 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 af547ff..5719dbf 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 da7b098..6050db2 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
new file mode 100644
index 0000000..16cfcfe
Binary files /dev/null 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
new file mode 100644
index 0000000..152abd5
Binary files /dev/null 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
new file mode 100644
index 0000000..2e4666d
Binary files /dev/null and b/docs/screenshots/desktop-tour-girdle.png differ
diff --git a/docs/screenshots/desktop-tour-narrows.png b/docs/screenshots/desktop-tour-narrows.png
new file mode 100644
index 0000000..5308203
Binary files /dev/null and b/docs/screenshots/desktop-tour-narrows.png differ
diff --git a/docs/screenshots/desktop-tour-river.png b/docs/screenshots/desktop-tour-river.png
new file mode 100644
index 0000000..102d240
Binary files /dev/null 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
new file mode 100644
index 0000000..451cdae
Binary files /dev/null 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
new file mode 100644
index 0000000..5fe4344
Binary files /dev/null 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
new file mode 100644
index 0000000..d057f13
Binary files /dev/null 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
new file mode 100644
index 0000000..bdcf9a9
Binary files /dev/null and b/docs/screenshots/desktop-tour-summit.png differ
diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png
index efa353a..477746b 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 3598266..a92edb0 100644
Binary files a/docs/screenshots/mobile-interior.png and b/docs/screenshots/mobile-interior.png differ
diff --git a/docs/screenshots/mobile-panel.png b/docs/screenshots/mobile-panel.png
index 7c944aa..2945722 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 4506473..9508aad 100644
Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ
diff --git a/index.html b/index.html
index 1c200d7..91a9fa2 100644
--- a/index.html
+++ b/index.html
@@ -65,6 +65,11 @@
       <div id="interior-options" class="hidden">
         <label>Eye height <span id="eye-value" class="dim"></span>
           <input id="f-eye" type="range" min="0" max="1000" step="1" value="0" /></label>
+        <button id="f-tour" type="button">Scenic tour</button>
+        <p class="hint">A new sight every thirty seconds — flying down a river, circling a
+          waterfall or a summit, following a ship through weather. <b>N</b> skips ahead;
+          <b>Esc</b>, a drag or a flight key stops it where it is.</p>
+        <label>Day <input id="f-day" type="number" min="0" step="1" value="0" /></label>
         <label>Time <span id="time-value" class="dim"></span>
           <input id="f-time" type="range" min="0" max="1439" step="1" value="720" /></label>
         <label class="row"><input id="f-cycle" type="checkbox" /> Run the clock</label>
@@ -91,6 +96,39 @@
       </div>
     </details>
 
+    <details class="sec" id="weather-section">
+      <summary>Weather <span id="weather-now" class="dim"></span></summary>
+      <p class="hint">Tower publishes its weather plans so people can plan around
+        them, and changes them when it sees fit. Times are world time, counted from
+        day 0; run the clock in the interior view to move through them.</p>
+      <ul id="weather-board" class="board"></ul>
+      <div id="weather-edit">
+        <p class="sub" id="w-title">Schedule weather</p>
+        <label>Over <select id="w-target"></select></label>
+        <label>Kind <select id="w-kind">
+          <option value="calm">Calm</option>
+          <option value="breeze">Breeze</option>
+          <option value="gale">Gale</option>
+          <option value="storm" selected>Storm</option>
+        </select></label>
+        <div class="pair">
+          <label>Day <input id="w-day" type="number" min="0" step="1" /></label>
+          <label>Hour <input id="w-hour" type="number" min="0" max="23.5" step="0.5" /></label>
+        </div>
+        <div class="pair">
+          <label>Hours <input id="w-duration" type="number" min="0.5" step="0.5" value="12" /></label>
+          <label>Wind, m/s <input id="w-speed" type="number" min="0" max="60" step="1" value="28" /></label>
+        </div>
+        <label>Blowing toward, degrees <span class="dim">— 0 deepward, 90 spinward</span>
+          <input id="w-dir" type="number" min="0" max="359" step="15" value="90" /></label>
+        <label id="w-reason-row" hidden>Why Tower changed its mind <input id="w-reason" type="text" /></label>
+        <button id="w-submit" type="button">Publish now</button>
+        <button id="w-cancel" type="button" hidden>Cancel</button>
+        <label>The plan repeats every, days <span class="dim">— 0 for never</span>
+          <input id="w-cycle" type="number" min="0" step="1" /></label>
+      </div>
+    </details>
+
     <details class="sec" id="tools-section">
       <summary>Add</summary>
       <div id="tools" class="tools"></div>
@@ -129,7 +167,7 @@
         <p class="hint">Changing any of these regenerates the range: a few seconds for the Girdle.</p>
       </div>
       <div id="f-water" hidden>
-        <p class="sub">Wind <span class="dim">— until Tower's weather plan, the steady wind over this water</span></p>
+        <p class="sub">Wind <span class="dim">— the usual wind here, whenever Tower's weather plan has nothing else scheduled</span></p>
         <label>Blowing toward, degrees <span class="dim">— 0 deepward, 90 spinward</span>
           <input id="f-wind-dir" type="number" step="15" min="0" max="359" /></label>
         <label>Speed <span id="f-wind-speed-value" class="dim"></span>
diff --git a/src/style.css b/src/style.css
index d697b7f..d0ef367 100644
--- a/src/style.css
+++ b/src/style.css
@@ -138,6 +138,14 @@ input[type=range] { width: 100%; margin-top: 6px; accent-color: var(--accent); }
 .peaks input { width: 100%; box-sizing: border-box; }
 .peaks button { padding: 4px 0; }
 .save.error { color: var(--danger); }
+.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+.board { list-style: none; margin: 0 0 10px; padding: 0; }
+.board li { padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 12px; }
+.board li.now { color: var(--accent); }
+.board .rev { color: var(--dim); font-size: 11px; margin-top: 2px; }
+.board s { color: var(--dim); }
+.board .acts { display: flex; gap: 6px; margin-top: 4px; }
+.board .acts button { width: auto; margin: 0; padding: 3px 10px; font-size: 11px; }
 
 /**
  * Narrow viewports: the sidebar becomes a drawer.
@@ -190,3 +198,13 @@ input[type=range] { width: 100%; margin-top: 6px; accent-color: var(--accent); }
      aligned columns, and wrapping them destroys the thing they are for. */
   .overlay { font-size: 9px; padding: 6px 8px; }
 }
+
+/* The scenic tour (interior.ts): a black fade between scenes, and the scene's title. */
+.tour-fade { position: absolute; inset: 0; background: #000; pointer-events: none; opacity: 0; }
+.tour-caption {
+  position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
+  max-width: min(640px, calc(100% - 32px)); text-align: center; white-space: pre-line;
+  background: rgba(10,13,20,0.72); border: 1px solid var(--line); border-radius: 6px;
+  padding: 8px 14px; font-size: 13px; line-height: 1.5; pointer-events: none;
+}
+@media (max-width: 720px) { .tour-caption { top: 56px; font-size: 11px; } }
diff --git a/src/terrain/compile.ts b/src/terrain/compile.ts
index 6b35f0f..1f56dfa 100644
--- a/src/terrain/compile.ts
+++ b/src/terrain/compile.ts
@@ -15,7 +15,7 @@
 import { RADIUS_M } from '../canon/constants.js'
 import type { Feature, Lake, Mountains, River, Sea, World } from '../world/schema.js'
 import { type CMountainGroup, compileMountains } from './mountains.js'
-import { FALL_MIN_M, fetchOf, foamOf, manningSpeed, windSea } from './water.js'
+import { FALL_MIN_M, foamOf, manningSpeed } from './water.js'
 import { hash3 } from './noise.js'
 import {
   type Box, type Line, type Shape, nearestA, shapeBox, shapeOf, signedDistance, sliceLine, smoothLine,
@@ -46,10 +46,12 @@ export interface CBasin {
   shoreM: number
   rimM: number
   maxInsideM: number
-  /** Its sea state: amplitude of the main wave, its length, and the direction it runs. */
-  waveAmpM: number
-  waveLengthM: number
-  waveDirRad: number
+  /**
+   * Its place in `CompiledWorld.basins`, which is how a water vertex names the body it
+   * belongs to. The sea state is not compiled: it follows the weather, which changes by
+   * the hour without the ground changing (sea-state.ts).
+   */
+  index: number
   seed: number
   box: Box
 }
@@ -179,17 +181,6 @@ export function compileWorld(world: World, onStep: StepListener = () => {}): Com
   return cw
 }
 
-/**
- * A body of water's sea state, from its wind and the fetch the wind has over it. The
- * amplitude is of the main component: a significant wave height is crest to trough of
- * the biggest third, and the shader sums several smaller components under the main one.
- */
-function waves(speedMS: number, directionDeg: number, shape: Shape): { waveAmpM: number; waveLengthM: number; waveDirRad: number } {
-  const dir = (directionDeg * Math.PI) / 180
-  const sea = windSea(speedMS, fetchOf(shape, dir))
-  return { waveAmpM: sea.heightM * 0.35, waveLengthM: sea.lengthM, waveDirRad: dir }
-}
-
 /** The greatest distance from any interior point to the edge, sampled. */
 function maxInside(shape: Shape): number {
   if (shape.type === 'band') return (shape.zTo - shape.zFrom) / 2
@@ -219,7 +210,7 @@ function compileBasin(f: Lake | Sea, seed: number, cw: CompiledWorld): CBasin |
     shoreM: f.shoreM,
     rimM: f.kind === 'lake' ? f.rimM : 0,
     maxInsideM: Math.max(10, maxInside(shape)),
-    ...waves(f.wind.speedMS, f.wind.directionDeg, shape),
+    index: cw.basins.length,
     seed,
     box: grow(shapeBox(shape), flooded ? f.shoreM + 8000 : 50),
   }
diff --git a/src/terrain/field.ts b/src/terrain/field.ts
index 8b13028..bb399e5 100644
--- a/src/terrain/field.ts
+++ b/src/terrain/field.ts
@@ -54,10 +54,8 @@ export interface Sample {
    * `waterCover`, a tint on the ground.
    */
   waterMesh: boolean
-  /** The sea state of the water here: main wave amplitude and length, and its direction. */
-  waveAmpM: number
-  waveLengthM: number
-  waveDirRad: number
+  /** The lake or sea this water belongs to, as an index into the compiled basins, or -1. */
+  body: number
   /** River flow here, m/s along z and around, and how white it is. */
   flowZ: number
   flowA: number
@@ -109,7 +107,7 @@ export class Runtime {
   private indexes: LineIndex[]
   private out: Sample = {
     h: 0, water: -Infinity, waterCover: 0, waterMesh: false,
-    waveAmpM: 0, waveLengthM: 0, waveDirRad: 0, flowZ: 0, flowA: 0, foam: 0,
+    body: -1, flowZ: 0, flowA: 0, foam: 0,
     desert: 0, forest: 0, relief: 0, snowlineM: 3000,
   }
   private nearest: Nearest = { d: 0, seg: 0, t: 0 }
@@ -130,9 +128,7 @@ export class Runtime {
     o.water = -Infinity
     o.waterCover = 0
     o.waterMesh = false
-    o.waveAmpM = 0
-    o.waveLengthM = 0
-    o.waveDirRad = 0
+    o.body = -1
     o.flowZ = 0
     o.flowA = 0
     o.foam = 0
@@ -267,9 +263,7 @@ function setBasinWater(b: CBasin, o: Sample): void {
   o.water = b.levelM
   o.waterMesh = true
   o.waterCover = 1
-  o.waveAmpM = b.waveAmpM
-  o.waveLengthM = b.waveLengthM
-  o.waveDirRad = b.waveDirRad
+  o.body = b.index
   o.flowZ = 0
   o.flowA = 0
   o.foam = 0
@@ -369,10 +363,7 @@ function river(r: CRiver, index: LineIndex, p: CylPoint, z: number, a: number, d
       o.flowZ = ((r.line.pts[2 * i + 2]! - r.line.pts[2 * i]!) / len) * speed
       o.flowA = ((r.line.pts[2 * i + 3]! - r.line.pts[2 * i + 1]!) / len) * speed
       o.foam = Math.max(lerp(r.foam), fall ? 1 : 0)
-      // A river's own chop: short and low, and running with it.
-      o.waveAmpM = 0.04
-      o.waveLengthM = 3
-      o.waveDirRad = Math.atan2(o.flowA, o.flowZ)
+      o.body = -1
     }
   }
   if (!drawn && near.d < hw + cell / 2) o.waterCover = Math.max(o.waterCover, (2 * hw) / cell)
diff --git a/src/terrain/sea-state.ts b/src/terrain/sea-state.ts
new file mode 100644
index 0000000..903d623
--- /dev/null
+++ b/src/terrain/sea-state.ts
@@ -0,0 +1,73 @@
+/**
+ * The sea state of every lake and sea at a moment. See world-generation.md §18.
+ *
+ * Until Phase 4 a body's waves were compiled with the ground, from its fixed wind. Now the
+ * wind follows Tower's weather plan (world/weather.ts), which changes by the hour, and
+ * recompiling the world for every gust would be absurd. So the ground is compiled once,
+ * each water vertex carries only the index of its body, and the waves are looked up here
+ * and handed to the shader as a small table.
+ */
+import type { World } from '../world/schema.js'
+import type { Wind, WeatherKind } from '../world/schema.js'
+import { actualAt, seaWindAt } from '../world/weather.js'
+import type { CBasin, CompiledWorld } from './compile.js'
+import { fetchOf, type WindSea, windSea } from './water.js'
+
+/** Most bodies of water the shader can tell apart. More than any world here needs. */
+export const MAX_BODIES = 32
+
+export interface SeaState {
+  id: string
+  /** The wind blowing now, and the one the waves are still answering. */
+  wind: Wind
+  seaWind: Wind
+  kind: WeatherKind
+  /** Why the weather is not as published, if Tower has revised it. */
+  revised: string | null
+  sea: WindSea
+  /** Amplitude of the shader's main component: the sum of four peaks near Hs / 2. */
+  ampM: number
+  dirRad: number
+  whitecaps: number
+}
+
+/**
+ * The share of a sea's surface in whitecaps, from the wind speed at 10 m (Monahan and
+ * O'Muircheartaigh, 1980): W = 3.84e-6 · U^3.41. About 1% at 10 m/s, 9% at 20, 29% at 28.
+ */
+export function whitecapFraction(windMS: number): number {
+  return Math.min(1, 3.84e-6 * Math.max(0, windMS) ** 3.41)
+}
+
+/** A significant wave height is crest to trough; the shader's main component is 0.35 of it. */
+export const MAIN_COMPONENT = 0.35
+
+export function seaStateOf(world: World, b: CBasin, hours: number): SeaState {
+  const f = world.features.find((x) => x.id === b.id)
+  const baseline = f && (f.kind === 'lake' || f.kind === 'sea') ? f.wind : { directionDeg: 0, speedMS: 0 }
+  const now = actualAt(world.weather, b.id, baseline, hours)
+  const seaWind = seaWindAt(world.weather, b.id, baseline, hours)
+  const dirRad = (seaWind.directionDeg * Math.PI) / 180
+  const sea = windSea(seaWind.speedMS, fetchOf(b.shape, dirRad))
+  return {
+    id: b.id, wind: now.wind, seaWind, kind: now.kind, revised: now.revision?.reason ?? null,
+    sea, ampM: sea.heightM * MAIN_COMPONENT, dirRad, whitecaps: whitecapFraction(now.wind.speedMS),
+  }
+}
+
+/** Numbers per body in the shader's table. */
+export const BODY_STRIDE = 4
+
+/** The shader's table: amplitude, wavelength, direction and whitecaps per body, in basin order. */
+export function seaStateTable(world: World, compiled: CompiledWorld, hours: number, out: Float32Array): SeaState[] {
+  out.fill(0)
+  return compiled.basins.slice(0, MAX_BODIES).map((b) => {
+    const s = seaStateOf(world, b, hours)
+    const o = b.index * BODY_STRIDE
+    out[o] = s.ampM
+    out[o + 1] = s.sea.lengthM
+    out[o + 2] = s.dirRad
+    out[o + 3] = s.whitecaps
+    return s
+  })
+}
diff --git a/src/terrain/service.ts b/src/terrain/service.ts
index 7e7fdcc..92cff17 100644
--- a/src/terrain/service.ts
+++ b/src/terrain/service.ts
@@ -50,6 +50,8 @@ export class TerrainService {
   /** Bumped on every terrain change. Anything built for an older one is thrown away. */
   generation = 0
   compiled: CompiledWorld | null = null
+  /** The latest world handed in, compiled or not yet. */
+  world: World | null = null
   private runtime: Runtime | null = null
   /** The map image of the current generation, or null until it is drawn. */
   mapCanvas: HTMLCanvasElement | null = null
@@ -116,6 +118,9 @@ export class TerrainService {
    * not change the terrain and does nothing here (schema.ts `terrainSignature`).
    */
   async setWorld(world: World): Promise<void> {
+    // Kept whether or not the terrain changed: the weather, winds and ships are read off
+    // the latest world every frame, and none of them needs a compile.
+    this.world = world
     const signature = terrainSignature(world)
     if (signature === this.signature) return
     /**
diff --git a/src/terrain/ships.ts b/src/terrain/ships.ts
new file mode 100644
index 0000000..ff75ce4
--- /dev/null
+++ b/src/terrain/ships.ts
@@ -0,0 +1,137 @@
+/**
+ * Sailing ships on the cap seas. See world-generation.md §18.
+ *
+ * A ship's position is a pure function of world time, like the weather: each one keeps
+ * a lane around the ring at a steady speed, so anyone at any hour sees the same fleet
+ * in the same places without anything having been simulated up to that hour.
+ *
+ * Ships stay in harbour while a gale or a storm is *published* for their sea (weather.ts
+ * `inHarbour`) — people act on what they were told. Harbours are not drawn yet, so a
+ * ship in harbour is simply not on the water.
+ *
+ * Only ring seas (bands) carry ships for now: a lane round a ring never meets a shore.
+ */
+import { CIRCUMFERENCE_M, G0 } from '../canon/constants.js'
+import { hash3, mulberry32 } from './noise.js'
+import type { Sea, World } from '../world/schema.js'
+import { inHarbour } from '../world/weather.js'
+
+export interface ShipLane {
+  sea: string
+  /** Mean distance along the axis, and how far the lane weaves either side of it. */
+  z0: number
+  weaveM: number
+  weaveLengthM: number
+  weavePhase: number
+  /** Where the ship was at hour 0, and which way round it sails: +1 spinward. */
+  a0: number
+  dir: 1 | -1
+  speedMS: number
+  lengthM: number
+}
+
+/** Ships keep at least this far off either shore of a band. */
+export const LANE_MARGIN_M = 4000
+
+/** The lanes on a sea, seeded from the world and the sea, so everyone sees the same fleet. */
+export function lanesOf(sea: Sea, worldSeed: number): ShipLane[] {
+  if (sea.geometry.type !== 'band' || sea.ships <= 0) return []
+  const { zFrom, zTo } = sea.geometry
+  const width = zTo - zFrom - 2 * LANE_MARGIN_M
+  if (width <= 0) return []
+  return Array.from({ length: sea.ships }, (_, i) => {
+    const r = mulberry32(hash3(worldSeed, sea.seed, 0x5419 + i))
+    // Most craft are small: lengths skew toward the short end, 8 m to 45 m.
+    const lengthM = 8 + 37 * r() ** 2.2
+    return {
+      sea: sea.id,
+      z0: zFrom + LANE_MARGIN_M + width * (0.1 + 0.8 * r()),
+      weaveM: 200 + 900 * r(),
+      weaveLengthM: 9000 + 30_000 * r(),
+      weavePhase: r() * Math.PI * 2,
+      a0: r() * CIRCUMFERENCE_M,
+      dir: r() < 0.5 ? 1 : -1,
+      // 3 to 6 m/s: 6 to 12 knots, a sailing vessel's cruise.
+      speedMS: 3 + 3 * r(),
+      lengthM,
+    }
+  })
+}
+
+export interface ShipFix {
+  z: number
+  /** Around, metres, in [0, circumference). */
+  a: number
+  /** Heading, radians from deepward toward spinward. */
+  headingRad: number
+}
+
+/** Where a lane's ship is at `hours`. */
+export function shipAt(lane: ShipLane, hours: number): ShipFix {
+  const travelled = lane.dir * lane.speedMS * hours * 3600
+  const a = (((lane.a0 + travelled) % CIRCUMFERENCE_M) + CIRCUMFERENCE_M) % CIRCUMFERENCE_M
+  const k = (2 * Math.PI) / lane.weaveLengthM
+  const phase = k * a + lane.weavePhase
+  const z = lane.z0 + lane.weaveM * Math.sin(phase)
+  // d(z)/d(a) along the lane, and the direction of travel around.
+  const dzda = lane.weaveM * k * Math.cos(phase)
+  return { z, a, headingRad: Math.atan2(lane.dir, lane.dir * dzda) }
+}
+
+/** Every ship on the water at `hours`: lanes on seas with no published gale or storm. */
+export function shipsAt(world: World, hours: number): { lane: ShipLane; fix: ShipFix }[] {
+  const out: { lane: ShipLane; fix: ShipFix }[] = []
+  for (const f of world.features) {
+    if (f.kind !== 'sea' || inHarbour(world.weather, f.id, hours)) continue
+    for (const lane of lanesOf(f, world.seed)) out.push({ lane, fix: shipAt(lane, hours) })
+  }
+  return out
+}
+
+/**
+ * The wave sum the water shader draws (interior-model.ts WAVES_GLSL), in TypeScript, so a
+ * ship rides the same waves that are drawn under it. Keep the two in step: the shader's
+ * constants are generated from these.
+ */
+// The last two are the wind's own short sea on top of the swell: without them a storm's
+// 600 m waves read as a gentle roll.
+export const WAVE_LEN = [1.0, 0.62, 0.41, 0.27, 0.13, 0.07] as const
+export const WAVE_AMP = [1.0, 0.55, 0.35, 0.22, 0.09, 0.045] as const
+export const WAVE_TURN = [0.0, 0.45, -0.38, 0.85, -0.9, 0.3] as const
+export const WAVE_PHASE = [0.0, 1.7, 4.1, 2.6, 5.3, 0.8] as const
+
+/**
+ * Height of the water at (z, a) metres and its slope along z and a, at `timeS` view
+ * seconds. `a` is taken in (-πR, πR], as the shader takes it from atan.
+ */
+export function waveAt(ampM: number, lengthM: number, dirRad: number, z: number, a: number, timeS: number):
+  { h: number; gz: number; ga: number } {
+  const out = { h: 0, gz: 0, ga: 0 }
+  if (ampM <= 0 || lengthM <= 0) return out
+  const aa = a > CIRCUMFERENCE_M / 2 ? a - CIRCUMFERENCE_M : a
+  const t = timeS % 600
+  for (let i = 0; i < WAVE_LEN.length; i++) {
+    const L = lengthM * WAVE_LEN[i]!
+    const amp = ampM * WAVE_AMP[i]!
+    const th = dirRad + WAVE_TURN[i]!
+    const dz = Math.cos(th), da = Math.sin(th)
+    const k = (2 * Math.PI) / L
+    const w = Math.sqrt(G0 * k)
+    const ph = k * (dz * z + da * aa) - w * t + WAVE_PHASE[i]!
+    out.h += amp * Math.cos(ph)
+    out.gz -= amp * k * Math.sin(ph) * dz
+    out.ga -= amp * k * Math.sin(ph) * da
+  }
+  return out
+}
+
+/**
+ * How far a sail swings out from the centreline for a wind blowing toward `windDirRad`:
+ * all the way out running before it, square along the centreline heading into it, and
+ * always to the side the wind blows toward.
+ */
+export function boomAngle(headingRad: number, windDirRad: number): number {
+  let rel = windDirRad - headingRad
+  rel = Math.atan2(Math.sin(rel), Math.cos(rel))
+  return Math.sign(rel || 1) * (Math.PI - Math.abs(rel)) / 2
+}
diff --git a/src/terrain/tile.ts b/src/terrain/tile.ts
index 23a41e2..ff76795 100644
--- a/src/terrain/tile.ts
+++ b/src/terrain/tile.ts
@@ -163,9 +163,16 @@ export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData {
         wLevel[w] = s.water
         const o = w * WATER_ATTRS
         wAttrs[o] = s.water - s.h
-        wAttrs[o + 1] = s.waveAmpM
-        wAttrs[o + 2] = s.waveLengthM
-        wAttrs[o + 3] = s.waveDirRad
+        if (s.body >= 0) {
+          // A lake or sea: its waves follow the weather, so the vertex names the body and
+          // the shader looks its sea state up (sea-state.ts). Negative, so 0 stays "none".
+          wAttrs[o + 1] = -(s.body + 1)
+        } else {
+          // A river's own chop: short and low, and running with it.
+          wAttrs[o + 1] = 0.04
+          wAttrs[o + 2] = 3
+          wAttrs[o + 3] = Math.atan2(s.flowA, s.flowZ)
+        }
         wAttrs[o + 4] = s.flowZ
         wAttrs[o + 5] = s.flowA
         wAttrs[o + 6] = s.foam
diff --git a/src/ui/app.ts b/src/ui/app.ts
index b04ef2e..9db5b3b 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -13,6 +13,9 @@ import {
   isTracedKind, makeId, newSeed, parseWorld, withKind,
 } from '../world/schema.js'
 import { terrain } from '../terrain/service.js'
+import { seaStateOf } from '../terrain/sea-state.js'
+import { type WeatherEvent, type WeatherKind, WEATHER_KINDS } from '../world/schema.js'
+import { type Occurrence, hoursOf, occurrences, timeOf } from '../world/weather.js'
 import { KIND_COLOR } from '../views/palette.js'
 import { type FeaturePatch, WorldStore, backupFilename, download } from '../world/store.js'
 import { TowerMap } from '../views/map.js'
@@ -281,6 +284,26 @@ export class App {
     }
     showTime()
 
+    $('f-tour').onclick = () => {
+      const v = this.interior
+      if (!v) return
+      if (v.touring) v.stopTour()
+      else {
+        v.startTour()
+        // On a phone the drawer covers the view; the tour is for watching.
+        if (this.narrow.matches) this.setPanel(false)
+      }
+      this.renderStanding()
+    }
+
+    const day = $<HTMLInputElement>('f-day')
+    day.onchange = () => {
+      const v = Number(day.value)
+      if (!Number.isFinite(v) || v < 0 || !this.interior) return
+      this.interior.setDay(v)
+      this.renderStanding()
+    }
+
     const cycle = $<HTMLInputElement>('f-cycle')
     const rate = $<HTMLInputElement>('f-cycle-rate')
     const applyCycle = () => {
@@ -720,6 +743,7 @@ export class App {
     $('f-delete').onclick = () => this.deleteSelected()
     this.wireMountains()
     this.wireWater()
+    this.wireWeather()
   }
 
   /** The wind over a lake or sea, and what sea it raises there. */
@@ -754,12 +778,205 @@ export class App {
     if (document.activeElement !== speed) speed.value = String(f.wind.speedMS)
     $('f-wind-speed-value').textContent = `— ${f.wind.speedMS} m/s`
     const b = terrain().compiled?.basins.find((x) => x.id === f.id)
-    $('f-sea-state').textContent = !b ? '' : b.waveAmpM <= 0
-      ? 'Flat calm.'
-      : `Waves ${(b.waveAmpM / 0.35).toFixed(1)} m high, ${b.waveLengthM.toFixed(0)} m apart, ` +
-        `every ${Math.sqrt((2 * Math.PI * b.waveLengthM) / 9.80665).toFixed(1)} s.` +
-        (f.kind === 'sea' && Math.abs(Math.sin((f.wind.directionDeg * Math.PI) / 180)) > 0.2
-          ? ' The wind runs round the ring, so the sea is fully developed.' : '')
+    const world = terrain().world
+    if (!b || !world) { $('f-sea-state').textContent = ''; return }
+    // Now, by the clock: the wind this body usually has, or whatever the plan has instead.
+    const st = seaStateOf(world, b, this.worldHours())
+    const weather = st.kind === 'calm' || st.kind === 'breeze' ? '' : ` A ${st.kind} is blowing (${st.wind.speedMS.toFixed(0)} m/s).`
+    $('f-sea-state').textContent = (st.sea.heightM < 0.01
+      ? 'Flat calm now.'
+      : `Now: waves ${st.sea.heightM.toFixed(1)} m high, ${st.sea.lengthM.toFixed(0)} m apart, ` +
+        `every ${st.sea.periodS.toFixed(1)} s.`) + weather +
+      (st.revised ? ` Tower changed the plan: ${st.revised}` : '') +
+      (f.kind === 'sea' && Math.abs(Math.sin((st.seaWind.directionDeg * Math.PI) / 180)) > 0.2
+        ? ' The wind runs round the ring, so the sea is fully developed.' : '')
+  }
+
+  // --------------------------------------------------------------- weather
+
+  /**
+   * World time, in hours since the start of day 0. The interior view owns the clock; when
+   * it has not been opened this session, the time it last saved stands in (interior.ts).
+   */
+  private worldHours(): number {
+    if (this.interior) return this.interior.worldHours
+    try {
+      const s = JSON.parse(localStorage.getItem('tower.interior') ?? '{}') as Record<string, unknown>
+      const day = typeof s['day'] === 'number' && s['day'] >= 0 ? Math.floor(s['day']) : 0
+      const phase = typeof s['phase'] === 'number' && s['phase'] >= 0 && s['phase'] < 1 ? s['phase'] : 0.5
+      return day * 24 + phase * 24
+    } catch {
+      return 12
+    }
+  }
+
+  /** The event being revised in the form, or null when the form schedules a new one. */
+  private revising: { event: string; occurrence: Occurrence } | null = null
+  /** The quarter-hour the board was last drawn for, so a running clock redraws it rarely. */
+  private weatherDrawnAt = NaN
+
+  /**
+   * The weather board: Tower's published plan (world-generation.md §4.9). Scheduling an
+   * event publishes it now; revising one adds a revision now, and the original stays on
+   * the board struck through, because people saw it and planned around it.
+   */
+  private wireWeather(): void {
+    const target = $<HTMLSelectElement>('w-target')
+    const kind = $<HTMLSelectElement>('w-kind')
+    const speed = $<HTMLInputElement>('w-speed')
+    /** A kind suggests a wind; the author can still type any speed. */
+    const TYPICAL: Record<WeatherKind, number> = { calm: 1, breeze: 8, gale: 20, storm: 28 }
+    kind.onchange = () => { speed.value = String(TYPICAL[kind.value as WeatherKind]) }
+
+    const cycle = $<HTMLInputElement>('w-cycle')
+    cycle.onchange = () => {
+      const v = Math.max(0, Math.floor(Number(cycle.value) || 0))
+      this.store.setWeather({ ...this.store.weather, cycleDays: v })
+    }
+
+    $('w-cancel').onclick = () => { this.revising = null; this.renderWeather(true) }
+    $('w-submit').onclick = () => {
+      const now = this.worldHours()
+      const read = (id: string, lo: number, hi: number, fallback: number): number => {
+        const v = Number($<HTMLInputElement>(id).value)
+        return Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : fallback
+      }
+      const wind = {
+        directionDeg: ((read('w-dir', -1e6, 1e6, 90) % 360) + 360) % 360,
+        speedMS: read('w-speed', 0, 60, 10),
+      }
+      const k = WEATHER_KINDS.includes(kind.value as WeatherKind) ? (kind.value as WeatherKind) : 'breeze'
+      const plan = structuredClone(this.store.weather)
+      if (this.revising) {
+        const e = plan.events.find((x) => x.id === this.revising!.event)
+        const o = this.revising.occurrence
+        if (e) {
+          // A revision counts from when it is made, within the occurrence it revises.
+          const at = Math.min(o.endH - 0.01, Math.max(now, o.publishedH))
+          e.revisions.push({
+            at: timeOf(at), kind: k, wind,
+            reason: $<HTMLInputElement>('w-reason').value.trim() || 'Tower changed its mind.',
+          })
+        }
+        this.revising = null
+      } else {
+        if (!target.value) return
+        const start = read('w-day', 0, 1e6, 0) * 24 + read('w-hour', 0, 23.99, 12)
+        const ids = new Set(plan.events.map((x) => x.id))
+        let id = `${target.value}-${k}`
+        for (let n = 2; ids.has(id); n++) id = `${target.value}-${k}-${n}`
+        const event: WeatherEvent = {
+          id, target: target.value, kind: k, start: timeOf(start),
+          durationH: read('w-duration', 0.5, 24 * 14, 12), wind,
+          publishedAt: timeOf(Math.min(now, start)), revisions: [],
+        }
+        plan.events.push(event)
+      }
+      this.store.setWeather(plan)
+    }
+    this.renderWeather(true)
+  }
+
+  private renderWeather(force = false): void {
+    const now = this.worldHours()
+    const bucket = Math.floor(now * 4)
+    if (!force && bucket === this.weatherDrawnAt) return
+    this.weatherDrawnAt = bucket
+    // The selected lake or sea's readout is a function of the clock too.
+    if (!force) this.renderWater(this.selectedId ? this.store.get(this.selectedId) : undefined)
+    const plan = this.store.weather
+    const bodies = this.store.features.filter((f) => f.kind === 'lake' || f.kind === 'sea')
+    const nameOf = (id: string) => bodies.find((f) => f.id === id)?.name ?? id
+    const when = (h: number): string => {
+      const t = timeOf(h)
+      const mins = Math.round(t.hour * 60)
+      return `day ${t.day}, ${String(Math.floor(mins / 60)).padStart(2, '0')}:${String(mins % 60).padStart(2, '0')}`
+    }
+    $('weather-now').textContent = `— ${when(now)}`
+
+    const board = $('weather-board')
+    board.replaceChildren()
+    // What is on, and the next fortnight. Readers see only what has been published.
+    const shown = occurrences(plan, null, now, now + 24 * 14)
+      .filter((o) => o.endH > now && (this.writer || o.publishedH <= now))
+    if (!shown.length) {
+      const li = document.createElement('li')
+      li.className = 'dim'
+      li.textContent = 'Nothing scheduled for the next fortnight: the usual winds.'
+      board.append(li)
+    }
+    for (const o of shown) {
+      const li = document.createElement('li')
+      const on = o.startH <= now
+      if (on) li.classList.add('now')
+      const made = o.revisions.filter((r) => hoursOf(r.at) <= now)
+      const head = document.createElement('div')
+      const text = `${o.event.kind[0]!.toUpperCase()}${o.event.kind.slice(1)} over ${nameOf(o.event.target)}, ` +
+        `${when(o.startH)} for ${o.event.durationH} h, ${o.event.wind.speedMS} m/s` + (on ? ' — now' : '')
+      if (made.length) head.append(Object.assign(document.createElement('s'), { textContent: text }))
+      else head.textContent = text
+      li.append(head)
+      if (o.publishedH > now) {
+        li.append(Object.assign(document.createElement('div'), {
+          className: 'rev', textContent: `Not yet published: announced ${when(o.publishedH)}.`,
+        }))
+      }
+      for (const r of o.revisions) {
+        const future = hoursOf(r.at) > now
+        if (future && !this.writer) continue
+        li.append(Object.assign(document.createElement('div'), {
+          className: 'rev',
+          textContent: `${future ? 'Will revise' : 'Revised'} ${when(hoursOf(r.at))}: ${r.kind}, ${r.wind.speedMS} m/s. ${r.reason}`,
+        }))
+      }
+      if (this.writer) {
+        const acts = document.createElement('div')
+        acts.className = 'acts'
+        const revise = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Revise' })
+        revise.onclick = () => {
+          this.revising = { event: o.event.id, occurrence: o }
+          const last = o.revisions.at(-1)
+          $<HTMLSelectElement>('w-kind').value = last?.kind ?? o.event.kind
+          $<HTMLInputElement>('w-speed').value = String(last?.wind.speedMS ?? o.event.wind.speedMS)
+          $<HTMLInputElement>('w-dir').value = String(last?.wind.directionDeg ?? o.event.wind.directionDeg)
+          $<HTMLInputElement>('w-reason').value = ''
+          this.renderWeather(true)
+        }
+        const del = Object.assign(document.createElement('button'), {
+          type: 'button', textContent: 'Delete', className: 'danger',
+        })
+        del.onclick = () => this.store.setWeather({
+          ...plan, events: plan.events.filter((e) => e.id !== o.event.id),
+        })
+        acts.append(revise, del)
+        li.append(acts)
+      }
+      board.append(li)
+    }
+
+    $('weather-edit').hidden = !this.writer
+    if (!this.writer) return
+    const target = $<HTMLSelectElement>('w-target')
+    const chosen = target.value
+    target.replaceChildren(...bodies.map((f) => Object.assign(document.createElement('option'), {
+      value: f.id, textContent: f.name,
+    })))
+    if (bodies.some((f) => f.id === chosen)) target.value = chosen
+    const r = this.revising
+    $('w-title').textContent = r
+      ? `Revise the ${r.occurrence.event.kind} over ${nameOf(r.occurrence.event.target)}, from now`
+      : 'Schedule weather — published as soon as it is added'
+    for (const id of ['w-target', 'w-day', 'w-hour', 'w-duration']) $<HTMLInputElement>(id).disabled = !!r
+    $('w-reason-row').hidden = !r
+    $('w-cancel').hidden = !r
+    $('w-submit').textContent = r ? 'Publish the revision' : 'Publish'
+    const day = $<HTMLInputElement>('w-day')
+    if (document.activeElement !== day && !day.value) {
+      day.value = String(timeOf(now + 24).day)
+      $<HTMLInputElement>('w-hour').value = '12'
+    }
+    const cycle = $<HTMLInputElement>('w-cycle')
+    if (document.activeElement !== cycle) cycle.value = String(plan.cycleDays)
   }
 
   /**
@@ -962,6 +1179,7 @@ export class App {
     $('counts').textContent = `— ${total} feature${total === 1 ? '' : 's'}, ${pub} public`
 
     this.renderList()
+    this.renderWeather(true)
     this.renderEditor()
     this.renderSave()
     this.writeHash()
@@ -1112,6 +1330,12 @@ export class App {
   private syncTime(): void {
     const v = this.interior
     if (!v) return
+    const tour = v.touring ? 'Stop the tour' : 'Scenic tour'
+    if ($('f-tour').textContent !== tour) $('f-tour').textContent = tour
+    const day = $<HTMLInputElement>('f-day')
+    if (document.activeElement !== day && day.value !== String(v.day)) day.value = String(v.day)
+    // The board and the sea-state readout are functions of the clock.
+    this.renderWeather()
     const t = $<HTMLInputElement>('f-time')
     if (document.activeElement === t) return
     const mins = Math.round((((v.dayPhase % 1) + 1) % 1) * 1440) % 1440
diff --git a/src/views/interior-model.ts b/src/views/interior-model.ts
index 8caf698..e9cbeb0 100644
--- a/src/views/interior-model.ts
+++ b/src/views/interior-model.ts
@@ -15,7 +15,7 @@
 import type { Object3D, Texture } from 'three'
 import {
   AdditiveBlending, BackSide, BufferAttribute, BufferGeometry, CircleGeometry, Color, CylinderGeometry,
-  DoubleSide, FrontSide, Group, Matrix4, Mesh, RingGeometry, ShaderMaterial, Vector3,
+  DoubleSide, FrontSide, Group, InstancedMesh, Matrix4, Mesh, RingGeometry, ShaderMaterial, Vector3, Vector4,
 } from 'three'
 import {
   BEAM_RADIUS_M, COLLECTOR_DIAMETER_M, COLLECTOR_RIM_M, COLLECTOR_STANDOFF_M,
@@ -25,6 +25,8 @@ import {
 import { PALETTE_GLSL } from '../terrain/palette.js'
 import { LOWEST_GROUND_M } from '../terrain/tile.js'
 import { fallSheet } from '../terrain/falls.js'
+import { BODY_STRIDE, MAX_BODIES, type SeaState, seaStateTable } from '../terrain/sea-state.js'
+import { WAVE_AMP, WAVE_LEN, WAVE_PHASE, WAVE_TURN, boomAngle, shipsAt, waveAt } from '../terrain/ships.js'
 import { terrain } from '../terrain/service.js'
 import { MIRROR_UV_RADIUS, capGlazingTexture, sunCapGlazingTexture } from './cap-pattern.js'
 import { TerrainLOD, type TerrainLODOptions } from './terrain-lod.js'
@@ -282,19 +284,41 @@ void main() {
 const WAVES_GLSL = /* glsl */`
 uniform float uTime;
 const float G = 9.80665;
-const vec4 WAVE_LEN = vec4(1.0, 0.62, 0.41, 0.27);
-const vec4 WAVE_AMP = vec4(1.0, 0.55, 0.35, 0.22);
-const vec4 WAVE_TURN = vec4(0.0, 0.45, -0.38, 0.85);
-const vec4 WAVE_PHASE = vec4(0.0, 1.7, 4.1, 2.6);
+// Generated from ships.ts, which runs the same sum so a ship rides the waves drawn under it.
+const float WAVE_LEN[${WAVE_LEN.length}] = float[](${WAVE_LEN.map((v) => v.toFixed(3)).join(', ')});
+const float WAVE_AMP[${WAVE_LEN.length}] = float[](${WAVE_AMP.map((v) => v.toFixed(3)).join(', ')});
+const float WAVE_TURN[${WAVE_LEN.length}] = float[](${WAVE_TURN.map((v) => v.toFixed(3)).join(', ')});
+const float WAVE_PHASE[${WAVE_LEN.length}] = float[](${WAVE_PHASE.map((v) => v.toFixed(3)).join(', ')});
+
+/**
+ * Surface coordinates, metres, measured from a reference point near the eye.
+ *
+ * Around the ring the obvious coordinate is atan(y, x) × R, but in float32 that is good
+ * to only about 6 m at Tower's radius: noise and waves finer than that smear into radial
+ * streaks. So the angle is taken from a reference direction instead, where it is small
+ * and exact, and the reference moves with the eye in whole steps of SURF_SNAP_M — a
+ * multiple of every noise period used here — so patterns never jump (setCamera). Waves
+ * add back what the reference's own position contributes to their phase (uWaveOffset).
+ */
+uniform vec4 uSurfRef; // cos and sin of the reference angle, its z in metres, unused
+vec2 surfaceMetres(vec3 world) {
+  float da = atan(uSurfRef.x * world.y - uSurfRef.y * world.x, uSurfRef.x * world.x + uSurfRef.y * world.y);
+  return vec2(world.z * 1000.0 - uSurfRef.z, da * ${(R * 1000).toFixed(1)});
+}
+uniform vec4 uBodies[${MAX_BODIES}];
+uniform float uWaveOffset[${MAX_BODIES * WAVE_LEN.length}];
+float waveOffset(int body, int i) {
+  return body < 0 ? 0.0 : uWaveOffset[body * ${WAVE_LEN.length} + i];
+}
 
 // Height of the wave sum at surface position p (metres), and its gradient, fading each
 // component out below 'footprint' metres of wavelength.
-vec3 waves(vec2 p, float amp, float len, float dir, float footprint) {
+vec3 waves(vec2 p, float amp, float len, float dir, int body, float footprint) {
   vec3 out3 = vec3(0.0);
   if (amp <= 0.0 || len <= 0.0) return out3;
-  for (int i = 0; i < 4; i++) {
+  for (int i = 0; i < ${WAVE_LEN.length}; i++) {
     float L = len * WAVE_LEN[i];
-    float fade = smoothstep(2.0, 8.0, L / max(footprint, 1e-3));
+    float fade = smoothstep(4.0, 16.0, L / max(footprint, 1e-3));
     if (fade <= 0.0) continue;
     float a = amp * WAVE_AMP[i] * fade;
     float th = dir + WAVE_TURN[i];
@@ -303,15 +327,42 @@ vec3 waves(vec2 p, float amp, float len, float dir, float footprint) {
     float w = sqrt(G * k);
     // Time wraps every ten minutes: long enough that nobody sees it, short enough that
     // float precision in the phase never degrades.
-    float ph = k * dot(d, p) - w * mod(uTime, 600.0) + WAVE_PHASE[i];
+    float ph = k * dot(d, p) - w * mod(uTime, 600.0) + WAVE_PHASE[i] + waveOffset(body, i);
     out3.x += a * cos(ph);
     out3.yz -= a * k * sin(ph) * d;
   }
   return out3;
 }
 
-vec2 surfaceMetres(vec3 world) {
-  return vec2(world.z * 1000.0, atan(world.y, world.x) * ${(R * 1000).toFixed(1)});
+/**
+ * A water vertex's waves — amplitude, wavelength, direction, and the fraction of the
+ * surface in whitecaps. A river carries its own chop in the attribute and never breaks; a
+ * lake or sea carries -(index + 1), and its sea state, which follows the weather, is
+ * looked up here (sea-state.ts).
+ */
+/**
+ * The wind's own short sea alone, -1 trough to 1 crest: where whitecaps break. Keyed to
+ * these rather than the whole sum, which a storm's 600 m swell dominates, so caps spread
+ * over the sea instead of lining up along the swell's crests.
+ */
+float chop(vec2 p, float amp, float len, float dir, int body) {
+  if (amp <= 0.0 || len <= 0.0) return 0.0;
+  float h = 0.0, total = 0.0;
+  for (int i = ${WAVE_LEN.length - 3}; i < ${WAVE_LEN.length}; i++) {
+    float L = len * WAVE_LEN[i];
+    float th = dir + WAVE_TURN[i];
+    float k = 6.2831853 / L;
+    float ph = k * dot(vec2(cos(th), sin(th)), p) - sqrt(G * k) * mod(uTime, 600.0) + WAVE_PHASE[i] + waveOffset(body, i);
+    h += WAVE_AMP[i] * cos(ph);
+    total += WAVE_AMP[i];
+  }
+  return h / total;
+}
+
+int bodyOf(vec4 water) { return water.y >= 0.0 ? -1 : int(-water.y - 0.5); }
+vec4 waveParams(vec4 water) {
+  if (water.y >= 0.0) return vec4(water.yzw, 0.0);
+  return uBodies[bodyOf(water)];
 }
 `
 
@@ -331,8 +382,9 @@ void main() {
   // Displace only close in, where vertices are metres apart; beyond, waves are shading.
   float near = 1.0 - smoothstep(0.3, 2.5, distance(uCam, world));
   vec3 p = position;
-  if (near > 0.0 && aWater.y > 0.0) {
-    float h = waves(surfaceMetres(world), aWater.y, aWater.z, aWater.w, 0.5).x;
+  vec4 wp = waveParams(aWater);
+  if (near > 0.0 && wp.x > 0.0) {
+    float h = waves(surfaceMetres(world), wp.x, wp.y, wp.z, bodyOf(aWater), 0.5).x;
     // Up is toward the axis. The tile mesh sits in the tower's frame, so its model
     // matrix is a translation and 'up' can be taken from the world position directly.
     vec3 up = -normalize(vec3(world.xy, 0.0));
@@ -360,7 +412,8 @@ varying vec4 vFlow;
 ${HAZE_GLSL}
 ${WAVES_GLSL}
 
-float wHash(vec2 p) { return fract(sin(dot(p, vec2(41.3, 289.1))) * 43758.5453); }
+// Periodic every 256 cells, which is what lets surfaceMetres' reference step without a seam.
+float wHash(vec2 p) { p = mod(p, 256.0); return fract(sin(dot(p, vec2(41.3, 289.1))) * 43758.5453); }
 float wNoise(vec2 p) {
   vec2 i = floor(p), f = fract(p);
   f = f * f * (3.0 - 2.0 * f);
@@ -377,7 +430,8 @@ void main() {
   vec3 spinward = vec3(-radial.y, radial.x, 0.0);
   vec3 deepward = vec3(0.0, 0.0, 1.0);
 
-  vec3 w = waves(sp, vWater.y, vWater.z, vWater.w, footprint);
+  vec4 wp = waveParams(vWater);
+  vec3 w = waves(sp, wp.x, wp.y, wp.z, bodyOf(vWater), footprint);
   vec2 grad = w.yz;
 
   /**
@@ -431,8 +485,28 @@ void main() {
   // Foam: rapids and falls, and the edge where the water is only centimetres deep.
   float depth = vWater.x;
   float shore = 1.0 - smoothstep(0.0, 0.6, depth);
-  float churn = vFlow.z * (0.55 + 0.45 * wNoise(sp / 0.9 - flow * mod(uTime, 60.0) / 0.9));
-  float foam = clamp(max(churn, shore * 0.6 * step(0.001, vWater.y)), 0.0, 1.0);
+  float churn = vFlow.z * (0.55 + 0.45 * wNoise(sp / 0.8 - flow * mod(uTime, 60.0) / 0.8));
+  float foam = clamp(max(churn, shore * 0.6 * step(0.001, wp.x)), 0.0, 1.0);
+  /**
+   * Whitecaps: the crests break over the share of the sea the wind says (sea-state.ts),
+   * broken up so they read as patches rather than stripes along every crest. Far off, where
+   * single caps are under a pixel, the sea just whitens by that share.
+   */
+  if (wp.w > 0.0) {
+    float crest = chop(sp, wp.x, wp.y, wp.z, bodyOf(vWater)) * 0.5 + 0.5;
+    float drift = mod(uTime, 600.0);
+    float patches = wNoise(sp / 9.6 + vec2(drift * 0.05, 0.0)) * 0.65 + wNoise(sp / 2.4 - drift * 0.2) * 0.35;
+    // The threshold is set so about wp.w of the surface clears it.
+    // Crest and patches together sit near 0.58 ± 0.18: 0.92 clears ~2% of it, 0.64 ~29%.
+    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));
+    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);
+  }
   col = mix(col, vec3(0.85, 0.88, 0.9) * light * (0.34 + 0.8 * dot(n, up)), foam);
 
   // How much ground shows through: shallow water is clear, a few metres is not.
@@ -446,6 +520,135 @@ void main() {
 }
 `
 
+/**
+ * Ships (ships.ts): instanced hulls and sails, lit from the axis and hazed like everything
+ * else. The instances are placed relative to the mesh, which sits at the eye, so their
+ * matrices stay small numbers in float32.
+ */
+const SHIP_VERT = /* glsl */`
+#include <common>
+#include <logdepthbuf_pars_vertex>
+uniform mat4 uFrame;
+varying vec3 vWorld;
+varying vec3 vN;
+void main() {
+  mat4 m = modelMatrix * instanceMatrix;
+  vWorld = (uFrame * m * vec4(position, 1.0)).xyz;
+  vN = mat3(uFrame * m) * normal;
+  gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4(position, 1.0);
+  #include <logdepthbuf_vertex>
+}
+`
+
+const SHIP_FRAG = /* glsl */`
+#include <common>
+#include <logdepthbuf_pars_fragment>
+uniform vec3 uColour;
+uniform float uFrontZ;
+uniform float uFrontWidth;
+uniform float uBelowFront;
+uniform float uAboveFront;
+varying vec3 vWorld;
+varying vec3 vN;
+${HAZE_GLSL}
+void main() {
+  #include <logdepthbuf_fragment>
+  vec3 radial = normalize(vec3(vWorld.xy, 0.0));
+  // Both faces of a sail are lit: the beam is overhead, whichever way it has swung.
+  float lambert = abs(dot(normalize(vN), -radial));
+  float side = smoothstep(uFrontZ - uFrontWidth, uFrontZ, vWorld.z);
+  float daylight = mix(uBelowFront, uAboveFront, side);
+  // Three.Color already converts its hex to linear.
+  vec3 lit = uColour * (0.34 + 0.8 * lambert) * uExposure * daylight;
+  vec3 T = towerTransmittance(uCam, vWorld);
+  gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
+  #include <colorspace_fragment>
+}
+`
+
+/**
+ * A hull one unit long, bow toward +x, deck up +y: pointed at the bow, a transom at the
+ * stern, sides tumbling in to a narrow keel, a sheer rising to the bow, and a deckhouse
+ * aft. Scaled to each ship's length by its instance.
+ */
+function hullGeometry(): BufferGeometry {
+  const STATIONS = 12
+  const halfBeam = (t: number): number =>
+    0.15 * Math.sqrt(Math.max(0, 1 - ((t - 0.4) / 0.6) ** 2)) * (t < 0.4 ? 0.78 + 0.22 * (t / 0.4) : 1)
+  const deckY = (t: number): number => 0.08 + 0.05 * t ** 3
+  const KEEL = -0.05
+  const deck: number[][] = [], keel: number[][] = []
+  for (let i = 0; i <= STATIONS; i++) {
+    const t = i / STATIONS
+    const x = t - 0.5
+    const w = i === STATIONS ? 0 : halfBeam(t)
+    deck.push([x, deckY(t), -w], [x, deckY(t), w])
+    keel.push([x, KEEL, -w * 0.3], [x, KEEL, w * 0.3])
+  }
+  const tris: number[] = []
+  const tri = (a: number[], b: number[], c: number[]) => tris.push(...a, ...b, ...c)
+  const quad = (a: number[], b: number[], c: number[], d: number[]) => { tri(a, b, c); tri(a, c, d) }
+  for (let i = 0; i < STATIONS; i++) {
+    const [dl0, dr0, dl1, dr1] = [deck[2 * i]!, deck[2 * i + 1]!, deck[2 * i + 2]!, deck[2 * i + 3]!]
+    const [kl0, kr0, kl1, kr1] = [keel[2 * i]!, keel[2 * i + 1]!, keel[2 * i + 2]!, keel[2 * i + 3]!]
+    quad(dl0, dr0, dr1, dl1) // deck
+    quad(dl0, dl1, kl1, kl0) // the -z side
+    quad(dr0, kr0, kr1, dr1) // the +z side
+    quad(kl0, kl1, kr1, kr0) // bottom
+  }
+  quad(deck[0]!, keel[0]!, keel[1]!, deck[1]!) // transom
+  // A deckhouse aft: a box on the deck.
+  const box = (x0: number, x1: number, w: number, y0: number, y1: number) => {
+    const p = (x: number, y: number, z: number) => [x, y, z]
+    const c = [p(x0, y0, -w), p(x1, y0, -w), p(x1, y0, w), p(x0, y0, w), p(x0, y1, -w), p(x1, y1, -w), p(x1, y1, w), p(x0, y1, w)]
+    quad(c[4]!, c[7]!, c[6]!, c[5]!) // top
+    quad(c[0]!, c[1]!, c[5]!, c[4]!)
+    quad(c[1]!, c[2]!, c[6]!, c[5]!)
+    quad(c[2]!, c[3]!, c[7]!, c[6]!)
+    quad(c[3]!, c[0]!, c[4]!, c[7]!)
+  }
+  box(-0.38, -0.12, 0.07, deckY(0.2) - 0.01, deckY(0.2) + 0.07)
+  const g = new BufferGeometry()
+  g.setAttribute('position', new BufferAttribute(new Float32Array(tris), 3))
+  g.computeVertexNormals()
+  return g
+}
+
+/** The jib, from the stemhead to high on the mast. Fixed: only the mainsail swings. */
+function jibGeometry(): BufferGeometry {
+  const g = new BufferGeometry()
+  g.setAttribute('position', new BufferAttribute(new Float32Array([0.5, 0.13, 0, 0.12, 0.85, 0, 0.16, 0.1, 0]), 3))
+  g.computeVertexNormals()
+  return g
+}
+
+/**
+ * A mainsail on a unit hull: a triangle from the foot of the mast to its head and back
+ * along the boom. The mast is at the origin, so an instance can swing the sail about it.
+ */
+function sailGeometry(): BufferGeometry {
+  const positions = new Float32Array([
+    0, 0.12, 0, 0, 0.95, 0, -0.44, 0.14, 0, // sail
+    0.004, 0.08, 0, 0.004, 1.0, 0, -0.004, 1.0, 0, // a mast, thin
+    0.004, 0.08, 0, -0.004, 1.0, 0, -0.004, 0.08, 0,
+  ])
+  const g = new BufferGeometry()
+  g.setAttribute('position', new BufferAttribute(positions, 3))
+  g.computeVertexNormals()
+  return g
+}
+
+/**
+ * How far the water's surface reference steps: 256 cells of every noise scale the water
+ * shader uses (0.8, 1.6, 2.4 and 9.6 m), so a step lands the noise exactly on itself.
+ */
+const SURF_SNAP_M = 2457.6
+
+/** Most ships drawn at once: two seas of two hundred each. */
+const MAX_SHIPS = 800
+/** Where the mast stands on a unit hull. */
+const MAST_X = 0.12
+
 /**
  * A waterfall's sheet (falls.ts). Streaks run down it at free-fall speed, faster the
  * further down they are, over a sheet that thins to spray toward its foot.
@@ -812,6 +1015,10 @@ export class InteriorModel {
   readonly ground: Group
   /** Everything to add to a scene, the ground included. */
   readonly meshes: Object3D[]
+  /** The fleet: every hull, and every sail. Both sit at the eye (see SHIP_VERT). */
+  readonly hulls: InstancedMesh
+  readonly sails: InstancedMesh
+  readonly jibs: InstancedMesh
 
   constructor(private opts: InteriorModelOptions) {
     this.groundMat = this.material(GROUND_VERT, GROUND_FRAG, { side: FrontSide, uniforms: {} })
@@ -820,13 +1027,29 @@ export class InteriorModel {
      * the shallows of another would otherwise punch holes in it. Both faces, since a wave
      * near the eye can tip a triangle away.
      */
-    this.waterMat = this.material(WATER_VERT, WATER_FRAG, { side: DoubleSide, uniforms: {} })
+    this.waterMat = this.material(WATER_VERT, WATER_FRAG, { side: DoubleSide, uniforms: {
+      uBodies: { value: this.bodies }, uWaveOffset: { value: this.waveOffsets }, uSurfRef: { value: this.surfRef },
+    } })
     this.waterMat.transparent = true
     this.waterMat.depthWrite = false
     this.lod = new TerrainLOD(terrain(), this.groundMat, this.waterMat, opts.lod)
     this.ground = this.lod.group
     this.falls.name = 'falls'
-    this.meshes = [this.ground, this.falls, ...this.buildCaps(), this.buildBeam()]
+    const fleet = (geometry: BufferGeometry, colour: number, name: string): InstancedMesh => {
+      const m = this.material(SHIP_VERT, SHIP_FRAG, {
+        side: DoubleSide, uniforms: { uColour: { value: new Color(colour) } },
+      })
+      const mesh = new InstancedMesh(geometry, m, MAX_SHIPS)
+      mesh.name = name
+      mesh.count = 0
+      // The instances are placed around the eye every frame; the mesh's own bounds mean nothing.
+      mesh.frustumCulled = false
+      return mesh
+    }
+    this.hulls = fleet(hullGeometry(), 0x4a3526, 'hulls')
+    this.sails = fleet(sailGeometry(), 0xe6ddc8, 'sails')
+    this.jibs = fleet(jibGeometry(), 0xe6ddc8, 'jibs')
+    this.meshes = [this.ground, this.falls, this.hulls, this.sails, this.jibs, ...this.buildCaps(), this.buildBeam()]
     terrain().onWorld(() => this.buildFalls())
     if (terrain().compiled) this.buildFalls()
   }
@@ -873,12 +1096,114 @@ export class InteriorModel {
     }
   }
 
+  /** Each lake and sea's waves, as the shader reads them (sea-state.ts). */
+  readonly bodies = new Float32Array(MAX_BODIES * BODY_STRIDE)
+  /** Each body's wave phases at the surface reference (see surfaceMetres in WAVES_GLSL). */
+  private readonly waveOffsets = new Float32Array(MAX_BODIES * WAVE_LEN.length)
+  private readonly surfRef = new Vector4(1, 0, 0, 0)
+  private surfRefA = 0
+  seaStates: SeaState[] = []
+
+  /**
+   * Put the waves where the weather has them at `hours` since day 0. Cheap, but not free,
+   * and the sea state changes over hours: callers need not run it every frame.
+   */
+  setWeather(hours: number): void {
+    const t = terrain()
+    if (!t.world || !t.compiled) return
+    this.seaStates = seaStateTable(t.world, t.compiled, hours, this.bodies)
+    this.updateWaveOffsets()
+  }
+
+  /** What the surface reference's own position adds to each wave's phase, mod 2π. */
+  private updateWaveOffsets(): void {
+    const n = WAVE_LEN.length
+    const z = this.surfRef.z, a = this.surfRefA
+    for (let b = 0; b < MAX_BODIES; b++) {
+      const o = b * BODY_STRIDE
+      const len = this.bodies[o + 1]!, dir = this.bodies[o + 2]!
+      for (let i = 0; i < n; i++) {
+        if (len <= 0) { this.waveOffsets[b * n + i] = 0; continue }
+        const k = (2 * Math.PI) / (len * WAVE_LEN[i]!)
+        const th = dir + WAVE_TURN[i]!
+        const ph = k * (Math.cos(th) * z + Math.sin(th) * a)
+        this.waveOffsets[b * n + i] = ph - Math.floor(ph / (2 * Math.PI)) * 2 * Math.PI
+      }
+    }
+  }
+
+  /** How far out ships are drawn, km. Past this a hull is under a pixel and all haze. */
+  private static readonly SHIP_RANGE_KM = 40
+
+  /**
+   * Put the fleet where it is at `hours`, riding the waves drawn at `timeS` (the water
+   * shader's clock). Cheap — a few dozen ships — so it runs every frame.
+   */
+  setShips(hours: number, timeS: number, eye: Vector3): void {
+    const t = terrain()
+    const world = t.world, compiled = t.compiled
+    const fleet = [this.hulls, this.sails, this.jibs]
+    if (!world || !compiled) { for (const m of fleet) m.count = 0; return }
+    const R_M = RADIUS_M
+    const hull = new Matrix4(), sail = new Matrix4(), turn = new Matrix4()
+    const up = new Vector3(), fwd = new Vector3(), side = new Vector3(), pos = new Vector3()
+    const deep = new Vector3(0, 0, 1), spin = new Vector3()
+    for (const m of fleet) m.position.copy(eye)
+    let n = 0
+    for (const { lane, fix } of shipsAt(world, hours)) {
+      if (n >= MAX_SHIPS) break
+      const b = compiled.basins.find((x) => x.id === lane.sea)
+      if (!b) continue
+      const theta = fix.a / R_M
+      const cos = Math.cos(theta), sin = Math.sin(theta)
+      // Cull by distance first: the wave sum is the costly part.
+      const rough = pos.set((R_M / 1000) * cos, (R_M / 1000) * sin, fix.z / 1000)
+      if (rough.distanceTo(eye) > InteriorModel.SHIP_RANGE_KM) continue
+      const o = b.index * BODY_STRIDE
+      const amp = this.bodies[o]!, len = this.bodies[o + 1]!, dir = this.bodies[o + 2]!
+      const w = waveAt(amp, len, dir, fix.z, fix.a, timeS)
+      // A hull spans its waves: the longer it is against them, the less it pitches and rolls.
+      const ride = len > 0 ? 1 / (1 + lane.lengthM / (0.35 * len)) : 0
+      const r = (R_M - b.levelM - w.h * (0.4 + 0.6 * ride)) / 1000
+      pos.set(r * cos, r * sin, fix.z / 1000).sub(eye)
+      up.set(-cos, -sin, 0)
+      spin.set(-sin, cos, 0)
+      up.addScaledVector(deep, -w.gz * ride).addScaledVector(spin, -w.ga * ride).normalize()
+      fwd.set(0, 0, 0).addScaledVector(deep, Math.cos(fix.headingRad)).addScaledVector(spin, Math.sin(fix.headingRad))
+      fwd.addScaledVector(up, -fwd.dot(up)).normalize()
+      side.crossVectors(fwd, up)
+      const scale = lane.lengthM / 1000
+      hull.makeBasis(fwd, up, side).scale(new Vector3(scale, scale, scale)).setPosition(pos)
+      this.hulls.setMatrixAt(n, hull)
+      this.jibs.setMatrixAt(n, hull)
+      const state = this.seaStates[b.index]
+      const windRad = ((state?.wind.directionDeg ?? 0) * Math.PI) / 180
+      // The instance's +z points to heading − 90°, and a positive boom angle swings toward
+      // heading + 90°, so the rotation is negated.
+      turn.makeRotationY(-boomAngle(fix.headingRad, windRad))
+      sail.makeTranslation(MAST_X, 0, 0).multiply(turn)
+      this.sails.setMatrixAt(n, hull.clone().multiply(sail))
+      n++
+    }
+    for (const m of fleet) {
+      m.count = n
+      m.instanceMatrix.needsUpdate = true
+    }
+  }
+
   /**
    * The eye, in the tower's frame. Call every frame: it is also what chooses which
    * tiles of ground to draw.
    */
   setCamera(eye: Vector3): void {
     for (const m of this.materials) (m.uniforms['uCam']!.value as Vector3).copy(eye)
+    // The water's surface reference, in whole steps near the eye, computed in float64.
+    const aM = Math.atan2(eye.y, eye.x) * RADIUS_M
+    const refA = Math.round(aM / SURF_SNAP_M) * SURF_SNAP_M
+    const refZ = Math.round((eye.z * 1000) / SURF_SNAP_M) * SURF_SNAP_M
+    this.surfRef.set(Math.cos(refA / RADIUS_M), Math.sin(refA / RADIUS_M), refZ, 0)
+    this.surfRefA = refA
+    this.updateWaveOffsets()
     this.lod.update(eye)
   }
 
diff --git a/src/views/interior.ts b/src/views/interior.ts
index 9453c8d..0778692 100644
--- a/src/views/interior.ts
+++ b/src/views/interior.ts
@@ -31,6 +31,7 @@ import { terrain } from '../terrain/service.js'
 import { EYE_MAX_M, EYE_MIN_M } from '../ui/scales.js'
 import { HAZE_DAY, HAZE_DUSK, InteriorModel, K_GREEN } from './interior-model.js'
 import { DESKTOP_LOD, PHONE_LOD } from './terrain-lod.js'
+import { type Pose, SCENE_S, type ScenePlan, planTour } from './scenic.js'
 
 const KM = 1000
 const R = RADIUS_M / KM
@@ -108,6 +109,26 @@ interface Probe {
   b: number
 }
 
+/** 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. */
+const TOUR_WAIT_S = 4
+
+interface TourState {
+  plans: ScenePlan[]
+  index: number
+  /** Seconds into the current scene. */
+  t: number
+  phase: 'out' | 'wait' | 'in' | 'play'
+  /** 0 clear, 1 black. */
+  fade: number
+  waited: number
+  /** Held at one moment, for the screenshot tour: no fades, no time passing. */
+  frozen: boolean
+  /** The clock as it was, given back when the tour stops. */
+  saved: { day: number; dayPhase: number; cycling: boolean }
+}
+
 /**
  * Keyboard flight (world-generation.md §6.4). Held keys, and what each does: forward
  * and back along the heading, sideways, and down and up.
@@ -200,6 +221,11 @@ export class InteriorView {
   private spin = 0
   /** Time of day, 0..1. 0 is midnight, 0.5 is noon. */
   dayPhase = 0.5
+  /**
+   * Which day it is. The calendar starts at day 0 (D-062), and the weather plan and the
+   * ships are pure functions of day and hour, so this is the only clock they need.
+   */
+  day = 0
   /** Whether the clock is running, and how many world seconds pass per real one. */
   cycling = false
   cycleRate = 900
@@ -210,6 +236,10 @@ export class InteriorView {
   clockDriven = false
 
   private labels: { place: Feature; el: HTMLSpanElement }[] = []
+  /** The scenic tour, while one is playing (scenic.ts). */
+  private tour: TourState | null = null
+  private tourFade = document.createElement('div')
+  private tourCaption = document.createElement('div')
 
   constructor(
     private canvas: HTMLCanvasElement,
@@ -235,10 +265,25 @@ export class InteriorView {
     // A new world moves the ground under the observer: stand on the new ground, and
     // redraw the elevation readout that was drawn against the old one.
     terrain().onWorld(() => {
+      // The scenes are worked out from the world, so an edit re-plans them.
+      if (this.tour) {
+        const id = this.tour.plans[this.tour.index]?.id
+        this.tour.plans = this.planTour()
+        const i = this.tour.plans.findIndex((p) => p.id === id)
+        this.tour.index = Math.max(0, i)
+        if (!this.tour.plans.length) this.stopTour()
+      }
+      this.model.setWeather(this.worldHours)
       this.place()
       this.cb.onLook()
     })
 
+    this.tourFade.className = 'tour-fade'
+    this.tourFade.hidden = true
+    this.tourCaption.className = 'tour-caption'
+    this.tourCaption.hidden = true
+    canvas.parentElement?.append(this.tourFade, this.tourCaption)
+
     this.restoreState()
     this.applyLighting()
     this.attach()
@@ -438,7 +483,19 @@ export class InteriorView {
     const secs = (((this.dayPhase % 1) + 1) % 1) * DAY_LENGTH_S
     const h = Math.floor(secs / 3600)
     const m = Math.floor((secs % 3600) / 60)
-    return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
+    return `day ${this.day}, ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
+  }
+
+  /** Hours since the start of day 0: what the weather and the ships are functions of. */
+  get worldHours(): number {
+    return this.day * 24 + (((this.dayPhase % 1) + 1) % 1) * 24
+  }
+
+  setDay(day: number): void {
+    this.day = Math.max(0, Math.floor(day))
+    this.model.setWeather(this.worldHours)
+    this.cb.onLook()
+    this.queueSave()
   }
 
   /**
@@ -448,6 +505,7 @@ export class InteriorView {
    */
   setDayPhase(p: number): void {
     this.dayPhase = ((p % 1) + 1) % 1
+    this.model.setWeather(this.worldHours)
     this.applyLighting()
     this.cb.onLook()
     this.queueSave()
@@ -650,6 +708,8 @@ export class InteriorView {
    */
   private attach(): void {
     this.canvas.addEventListener('pointerdown', (e) => {
+      // Taking hold of the view takes it back from the tour, from where the tour left it.
+      if (this.tour) this.stopTour()
       this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
       this.canvas.setPointerCapture(e.pointerId)
     })
@@ -683,6 +743,7 @@ export class InteriorView {
     this.canvas.addEventListener('pointercancel', end)
     this.canvas.addEventListener('wheel', (e) => {
       e.preventDefault()
+      if (this.tour) this.stopTour()
       this.fovDeg = Math.max(3, Math.min(90, this.fovDeg * (e.deltaY > 0 ? 1.08 : 1 / 1.08)))
       this.place()
       this.cb.onLook()
@@ -700,6 +761,11 @@ export class InteriorView {
     window.addEventListener('keydown', (e) => {
       if (!this.running || typing(e) || e.metaKey || e.ctrlKey || e.altKey) return
       this.boost = e.shiftKey
+      if (this.tour) {
+        if (e.code === 'Escape') { this.stopTour(); e.preventDefault(); return }
+        if (e.code === 'KeyN') { this.nextScene(); e.preventDefault(); return }
+        if (e.code in FLIGHT_KEYS) this.stopTour()
+      }
       if (!(e.code in FLIGHT_KEYS)) return
       this.held.add(e.code)
       e.preventDefault()
@@ -773,6 +839,7 @@ export class InteriorView {
       localStorage.setItem(STATE_KEY, JSON.stringify({
         theta: this.at.theta, z: this.at.z, eye: this.eyeM,
         yaw: this.yaw, pitch: this.pitch, fov: this.fovDeg,
+        day: this.day, phase: this.dayPhase,
       }))
     } catch {
       // Not persisting where you stood is survivable.
@@ -798,6 +865,8 @@ export class InteriorView {
       if (yaw !== null) this.yaw = normalizeTheta(yaw)
       this.pitch = num(s['pitch'], -1.55, 1.55) ?? this.pitch
       this.fovDeg = num(s['fov'], 3, 90) ?? this.fovDeg
+      this.day = Math.floor(num(s['day'], 0, 1e6) ?? this.day)
+      this.dayPhase = num(s['phase'], 0, 1) ?? this.dayPhase
       if (isLighting(s['lighting'])) {
         this.lighting = s['lighting']
       }
@@ -939,7 +1008,7 @@ export class InteriorView {
         theta: this.at.theta, z: this.at.z, eyeM: this.eyeM,
         yaw: this.yaw, pitch: this.pitch, fovDeg: this.fovDeg,
         groundKm: this.groundKm(this.at),
-        lighting: this.lighting, dayPhase: this.dayPhase, clockDriven: this.clockDriven,
+        lighting: this.lighting, day: this.day, dayPhase: this.dayPhase, clockDriven: this.clockDriven,
       },
       uniforms: {
         uCam: (this.groundMat.uniforms['uCam']!.value as Vector3).toArray(),
@@ -994,18 +1063,25 @@ export class InteriorView {
      * once every 7 min 29 s about the one motionless star on the axis. It is the
      * only unambiguous evidence of the spin anywhere in the view (D-045).
      */
-      if (this.cycling) {
-        this.dayPhase = (this.dayPhase + (dt * this.cycleRate) / DAY_LENGTH_S) % 1
+      if (this.cycling && !this.tour) {
+        const next = this.dayPhase + (dt * this.cycleRate) / DAY_LENGTH_S
+        // Midnight turns the calendar over.
+        this.day += Math.floor(next)
+        this.dayPhase = next % 1
         this.applyLighting()
         this.cb.onLook()
       }
       this.spin = (this.spin + OMEGA_RAD_S * dt) % (2 * Math.PI)
       this.time += dt
+      // The sea state changes over hours; a few times a second is plenty.
+      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
       }
-      this.fly(dt)
+      if (this.tour) this.stepTour(dt)
+      else this.fly(dt)
+      this.model.setShips(this.worldHours, this.time, this.camera.position)
       // Also what refines the ground around the eye, so it runs whether or not the
       // observer moved: tiles keep arriving while they stand still.
       this.model.setCamera(this.camera.position)
@@ -1049,4 +1125,135 @@ export class InteriorView {
   stop(): void {
     this.running = false
   }
+
+  // --------------------------------------------------------------------- tour
+
+  private planTour(): ScenePlan[] {
+    const t = terrain()
+    if (!t.world || !t.compiled) return []
+    return planTour({ world: t.world, compiled: t.compiled, ground: (theta, z) => t.surfaceM(theta, z) })
+  }
+
+  get touring(): boolean {
+    return this.tour !== null && !this.tour.frozen
+  }
+
+  /** The scenes the world has, in order: what the screenshot tour and the panel can ask for. */
+  get sceneIds(): string[] {
+    return this.planTour().map((p) => p.id)
+  }
+
+  /**
+   * Start the scenic tour: a scene every thirty seconds, fading through black between
+   * them, and round again. Any look, drag, wheel or flight key hands the view back.
+   */
+  startTour(fromId?: string): boolean {
+    const plans = this.planTour()
+    if (!plans.length) return false
+    const saved = this.tour?.saved ?? { day: this.day, dayPhase: this.dayPhase, cycling: this.cycling }
+    this.tour = {
+      plans, index: Math.max(0, plans.findIndex((p) => p.id === fromId)), t: 0,
+      phase: 'out', fade: 0, waited: 0, frozen: false, saved,
+    }
+    this.cb.onLook()
+    return true
+  }
+
+  nextScene(): void {
+    const tour = this.tour
+    if (!tour || tour.frozen) return
+    if (tour.phase === 'play' || tour.phase === 'in') tour.phase = 'out'
+  }
+
+  stopTour(): void {
+    const tour = this.tour
+    if (!tour) return
+    this.tour = null
+    this.tourFade.hidden = true
+    this.tourCaption.hidden = true
+    // The clock goes back to where it was; the observer stays where the tour left them.
+    this.day = tour.saved.day
+    this.dayPhase = tour.saved.dayPhase
+    this.cycling = tour.saved.cycling
+    this.model.setWeather(this.worldHours)
+    this.applyLighting()
+    this.place()
+    this.cb.onLook()
+    this.queueSave()
+  }
+
+  /**
+   * Hold one scene at one moment: the screenshot tour's way in. Returns the scene's
+   * title, or null if the world has no such scene.
+   */
+  showScene(id: string, t: number): string | null {
+    const plans = this.planTour()
+    const index = plans.findIndex((p) => p.id === id)
+    if (index < 0) return null
+    const saved = this.tour?.saved ?? { day: this.day, dayPhase: this.dayPhase, cycling: this.cycling }
+    this.tour = { plans, index, t, phase: 'play', fade: 0, waited: 0, frozen: true, saved }
+    this.applyScene(plans[index]!, t)
+    this.tourFade.hidden = true
+    this.tourCaption.hidden = true
+    this.cb.onLook()
+    return plans[index]!.title
+  }
+
+  private stepTour(dt: number): void {
+    const tour = this.tour!
+    const plan = tour.plans[tour.index]
+    if (!plan) { this.stopTour(); return }
+    if (tour.frozen) { this.applyScene(plan, tour.t); return }
+
+    if (tour.phase === 'out') {
+      tour.fade = Math.min(1, tour.fade + dt / TOUR_FADE_S)
+      if (tour.t > 0) tour.t += dt
+      if (tour.fade >= 1) {
+        // A fresh start fades out of the observer's own view into the first scene.
+        if (tour.t > 0) tour.index = (tour.index + 1) % tour.plans.length
+        tour.t = 0
+        tour.waited = 0
+        tour.phase = 'wait'
+      }
+    } else if (tour.phase === 'wait') {
+      tour.waited += dt
+      if ((this.settled && tour.waited > 0.3) || tour.waited > TOUR_WAIT_S) tour.phase = 'in'
+    } else {
+      tour.t += dt
+      if (tour.phase === 'in') {
+        tour.fade = Math.max(0, tour.fade - dt / TOUR_FADE_S)
+        if (tour.fade <= 0) tour.phase = 'play'
+      }
+      if (tour.t >= SCENE_S) tour.phase = 'out'
+    }
+
+    const current = tour.plans[tour.index]!
+    if (tour.phase !== 'out' || tour.t > 0) this.applyScene(current, tour.t)
+    this.tourFade.hidden = tour.fade <= 0
+    this.tourFade.style.opacity = String(tour.fade)
+    this.tourCaption.hidden = false
+    // The readout follows the observer, a few times a second.
+    if (this.frames % 15 === 0) this.cb.onLook()
+    const caption = `${current.title}\n${tour.index + 1} of ${tour.plans.length} · N next · Esc stops`
+    if (this.tourCaption.textContent !== caption) this.tourCaption.textContent = caption
+  }
+
+  /** Put the observer and the clock where a scene has them at `t` seconds. */
+  private applyScene(plan: ScenePlan, t: number): void {
+    const hours = plan.hours + t / 3600
+    this.day = Math.floor(hours / 24)
+    this.dayPhase = (hours - this.day * 24) / 24
+    this.model.setWeather(hours)
+    if (this.clockDriven) this.applyLighting()
+    this.applyPose(plan.pose(t))
+  }
+
+  private applyPose(p: Pose): void {
+    this.at = { theta: normalizeTheta(p.theta), z: Math.min(LENGTH_M, Math.max(0, p.z)) }
+    this.eyeM = Math.max(EYE_MIN_M, Math.min(EYE_MAX_M, p.altM - this.elevationM))
+    this.yaw = normalizeTheta(p.yaw)
+    this.pitch = Math.max(-1.55, Math.min(1.55, p.pitch))
+    this.fovDeg = p.fovDeg
+    this.place()
+  }
 }
diff --git a/src/views/scenic.ts b/src/views/scenic.ts
new file mode 100644
index 0000000..e3c2717
--- /dev/null
+++ b/src/views/scenic.ts
@@ -0,0 +1,365 @@
+/**
+ * The scenic tour: camera moves over the places worth seeing. See world-generation.md §19.
+ *
+ * Each scene is thirty seconds of one move — a bird flying down a river, a helicopter
+ * circling a waterfall or a summit, a chase camera on a ship — worked out from the
+ * compiled world, so it follows the world as it is edited rather than a list of
+ * coordinates. A scene also sets the clock, because what is there depends on it: the fleet
+ * is in harbour for a published storm, and at sea for one Tower gave no warning of.
+ *
+ * Pure functions, no Three.js: the interior view plays the scenes, the screenshot tour
+ * photographs them at a fixed moment, and the tests fly every one over the real world.
+ */
+import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js'
+import type { CFall, CompiledWorld } from '../terrain/compile.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'
+import { HARBOUR_LEAD_H, occurrences } from '../world/weather.js'
+
+/** How long each scene runs, seconds. */
+export const SCENE_S = 30
+
+/** Where the camera is and where it looks. `altM` is the eye's elevation above the datum. */
+export interface Pose {
+  theta: number
+  z: number
+  altM: number
+  /** Radians from deepward toward spinward, as the interior view's heading. */
+  yaw: number
+  pitch: number
+  fovDeg: number
+}
+
+export interface SceneWorld {
+  world: World
+  compiled: CompiledWorld
+  /** Height of the ground or the water standing on it, metres. */
+  ground: (theta: number, z: number) => number
+}
+
+export interface ScenePlan {
+  id: string
+  title: string
+  /** The clock at the start of the scene, hours since day 0. It runs at real time from there. */
+  hours: number
+  pose: (t: number) => Pose
+}
+
+export interface Scene {
+  id: string
+  prepare: (w: SceneWorld) => ScenePlan | null
+}
+
+/** Noon on day 0: a calm, published-nothing day, for scenes with no reason to pick another. */
+const NOON = 12
+
+// ------------------------------------------------------------------ geometry
+
+interface At { z: number; a: number; altM: number }
+
+/**
+ * Heading and pitch from one point to another, on the curved floor. The floor curves up
+ * around the axis, so a summit 6 km spinward stands about 360 m higher in the eye's frame
+ * than its elevation says, and a pitch taken on a flat floor would miss it.
+ */
+export function lookAt(from: At, to: At): { yaw: number; pitch: number } {
+  const phi = (nearestA(to.a, from.a) - from.a) / RADIUS_M
+  const rc = RADIUS_M - from.altM, rt = RADIUS_M - to.altM
+  const across = rt * Math.sin(phi)
+  const up = rc - rt * Math.cos(phi)
+  const along = to.z - from.z
+  return { yaw: Math.atan2(across, along), pitch: Math.atan2(up, Math.hypot(along, across)) }
+}
+
+const wrapA = (a: number): number => ((a % CIRCUMFERENCE_M) + CIRCUMFERENCE_M) % CIRCUMFERENCE_M
+const pose = (at: At, look: { yaw: number; pitch: number }, fovDeg: number): Pose => ({
+  theta: wrapA(at.a) / RADIUS_M, z: at.z, altM: at.altM, yaw: look.yaw, pitch: look.pitch, fovDeg,
+})
+
+/** A point `s` metres along a line, clamped to its ends. */
+function along(line: Line, s: number): { z: number; a: number } {
+  const n = line.s.length
+  if (s <= 0) return { z: line.pts[0]!, a: line.pts[1]! }
+  if (s >= line.s[n - 1]!) return { z: line.pts[2 * n - 2]!, a: line.pts[2 * n - 1]! }
+  let lo = 0, hi = n - 1
+  while (hi - lo > 1) {
+    const mid = (lo + hi) >> 1
+    if (line.s[mid]! <= s) lo = mid
+    else hi = mid
+  }
+  const t = (s - line.s[lo]!) / (line.s[hi]! - line.s[lo]! || 1)
+  return {
+    z: line.pts[2 * lo]! + (line.pts[2 * hi]! - line.pts[2 * lo]!) * t,
+    a: line.pts[2 * lo + 1]! + (line.pts[2 * hi + 1]! - line.pts[2 * lo + 1]!) * t,
+  }
+}
+
+/** A straight line from (z, a), heading `dirRad` (deepward toward spinward), `length` metres. */
+function straight(z: number, a: number, dirRad: number, length: number): Line {
+  const pts: number[] = [], s: number[] = []
+  for (let d = 0; d <= length; d += 50) {
+    pts.push(z + Math.cos(dirRad) * d, a + Math.sin(dirRad) * d)
+    s.push(d)
+  }
+  return { pts, s, box: { z0: 0, z1: 0, a0: 0, a1: 0 } }
+}
+
+/**
+ * Flying along a line: steady speed, a clearance over the ground that does not bob with
+ * every hill — the highest ground within 700 m either side, smoothed — and the eye on a
+ * point further along, at the ground there, so the view dips as the land falls away.
+ */
+function flight(w: SceneWorld, line: Line, from: number, speed: number, clearanceM: number,
+  lookAheadM: number, fovDeg: number): (t: number) => Pose {
+  const to = from + speed * SCENE_S + lookAheadM
+  const STEP = 50
+  const n = Math.ceil((to - from + 2000) / STEP) + 1
+  const raw = new Float64Array(n)
+  for (let i = 0; i < n; i++) {
+    const p = along(line, from - 1000 + i * STEP)
+    raw[i] = w.ground(wrapA(p.a) / RADIUS_M, p.z)
+  }
+  const reach = (arr: Float64Array, r: number, pick: (xs: number[]) => number) =>
+    Float64Array.from(arr, (_, i) => pick(Array.from(arr.subarray(Math.max(0, i - r), Math.min(n, i + r + 1)))))
+  const high = reach(raw, 14, (xs) => Math.max(...xs))
+  const smooth = reach(high, 8, (xs) => xs.reduce((x, y) => x + y, 0) / xs.length)
+  const at = (arr: Float64Array, s: number): number => {
+    const x = Math.min(n - 1, Math.max(0, (s - from + 1000) / STEP))
+    const i = Math.min(n - 2, Math.floor(x))
+    return arr[i]! + (arr[i + 1]! - arr[i]!) * (x - i)
+  }
+  return (t) => {
+    const s = from + speed * t
+    const p = along(line, s)
+    const cam = { ...p, altM: at(smooth, s) + clearanceM }
+    const q = along(line, s + lookAheadM)
+    return pose(cam, lookAt(cam, { ...q, altM: at(raw, s + lookAheadM) }), fovDeg)
+  }
+}
+
+/**
+ * A helicopter's arc round a point: `radius` out, from `startRad` (the direction from the
+ * target to the camera) sweeping `sweepRad`, never lower than `minClearM` over the ground.
+ */
+function orbit(w: SceneWorld, 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 cam = { z, a, altM: Math.max(altM, w.ground(wrapA(a) / RADIUS_M, z) + minClearM) }
+    return pose(cam, lookAt(cam, target), fovDeg)
+  }
+}
+
+/**
+ * A chase camera on a ship: behind it and a little to one side, above the water, drifting
+ * slowly round so the ship is not a still picture. The ship heaves; the camera does not.
+ */
+function chase(w: SceneWorld, lane: ShipLane, h0: number, fovDeg: number): (t: number) => Pose {
+  const level = w.compiled.basins.find((b) => b.id === lane.sea)?.levelM ?? 0
+  const scale = Math.max(1, lane.lengthM / 20)
+  return (t) => {
+    const fix = shipAt(lane, h0 + t / 3600)
+    const back = (70 + 20 * Math.sin(t / 9)) * scale
+    const side = (25 + 35 * t / SCENE_S) * scale
+    const hz = Math.cos(fix.headingRad), ha = Math.sin(fix.headingRad)
+    const cam = { z: fix.z - hz * back - ha * side, a: fix.a - ha * back + hz * side, altM: level + 14 * scale }
+    return pose(cam, lookAt(cam, { z: fix.z, a: fix.a, altM: level + lane.lengthM * 0.25 }), fovDeg)
+  }
+}
+
+// -------------------------------------------------------------------- finding
+
+/** How far along a line its nearest point to (z, a) is. */
+function sAlong(line: Line, z: number, a: number): number {
+  let best = 0, bestD = Infinity
+  for (let i = 0; i < line.s.length; i++) {
+    const d = Math.hypot(line.pts[2 * i]! - z, nearestA(line.pts[2 * i + 1]!, a) - a)
+    if (d < bestD) { bestD = d; best = i }
+  }
+  return line.s[best]!
+}
+
+const feature = (w: World, id: string): Feature | undefined => w.features.find((f) => f.id === id)
+
+/** The tallest fall in the world, or on one river. */
+function tallestFall(c: CompiledWorld, river?: string): CFall | null {
+  let best: CFall | null = null
+  for (const f of c.falls) {
+    if (river && f.river !== river) continue
+    if (!best || f.topM - f.bottomM > best.topM - best.bottomM) best = f
+  }
+  return best
+}
+
+/** The longest ship at sea on `sea` at `hours`, if any is out. */
+function biggestShip(w: World, sea: string, hours: number): ShipLane | null {
+  const out = shipsAt(w, hours).filter((s) => s.lane.sea === sea)
+  return out.reduce<ShipLane | null>((best, s) => (!best || s.lane.lengthM > best.lengthM ? s.lane : best), null)
+}
+
+// --------------------------------------------------------------------- scenes
+
+export const SCENES: Scene[] = [
+  {
+    id: 'falls',
+    prepare: (w) => {
+      const f = tallestFall(w.compiled)
+      if (!f) return null
+      const river = feature(w.world, f.river)?.name ?? 'a river'
+      const drop = f.topM - f.bottomM
+      const target = { z: f.z + f.dz * 60, a: f.a + f.da * 60, altM: f.bottomM + drop * 0.5 }
+      return {
+        id: 'falls', hours: NOON,
+        title: `Circling ${river}'s ${Math.round(drop)} m fall — it leans antispinward as it drops`,
+        // Out in front of the fall and below its lip, looking back at its face, swinging
+        // slowly across it. From above, the gorge hides the water.
+        pose: orbit(w, target, 1700, f.bottomM + drop * 0.7, Math.atan2(f.da, f.dz) - 0.35, 0.7, 60, 55),
+      }
+    },
+  },
+  {
+    id: 'ship',
+    prepare: (w) => {
+      const sea = w.world.features.find((f): f is Sea => f.kind === 'sea' && lanesOf(f, w.world.seed).length > 0)
+      const lane = sea && biggestShip(w.world, sea.id, NOON)
+      if (!sea || !lane) return null
+      return {
+        id: 'ship', hours: NOON, pose: chase(w, lane, NOON, 50),
+        title: `Following a ${Math.round(lane.lengthM)} m ship round ${sea.name}`,
+      }
+    },
+  },
+  {
+    id: 'summit',
+    prepare: (w) => {
+      const peaks = w.world.features.flatMap((f) => (f.kind === 'mountains' ? f.peaks : []))
+      const top = peaks.reduce<(typeof peaks)[number] | null>((b, p) => (!b || p.elevationM > b.elevationM ? p : b), null)
+      if (!top) return null
+      const target = { z: top.z, a: top.theta * RADIUS_M, altM: top.elevationM - 250 }
+      return {
+        id: 'summit', hours: NOON,
+        title: `Round ${top.name}, ${top.elevationM.toLocaleString('en')} m, the highest summit in Tower`,
+        pose: orbit(w, target, 5500, top.elevationM + 450, 0.3, 1.1, 300, 60),
+      }
+    },
+  },
+  {
+    id: 'river',
+    prepare: (w) => {
+      // A river's last big fall, at its mouth: flown down to, and over.
+      let pick: { r: CompiledWorld['rivers'][number]; f: CFall; s: number } | null = null
+      for (const r of w.compiled.rivers) {
+        for (const f of w.compiled.falls) {
+          if (f.river !== r.id || f.topM - f.bottomM < 50) continue
+          const s = sAlong(r.line, f.z, f.a)
+          if (!pick || s / r.line.s[r.line.s.length - 1]! > pick.s / pick.r.line.s[pick.r.line.s.length - 1]!) pick = { r, f, s }
+        }
+      }
+      if (!pick) return null
+      const name = feature(w.world, pick.r.id)?.name ?? 'the river'
+      const outlet = pick.r.outlet ? feature(w.world, pick.r.outlet)?.name : null
+      const drop = Math.round(pick.f.topM - pick.f.bottomM)
+      return {
+        id: 'river', hours: NOON,
+        title: `Down ${name} to its ${drop} m fall${outlet ? ` into ${outlet}` : ''}`,
+        pose: flight(w, pick.r.line, pick.s - 110 * 20, 110, 220, 3500, 65),
+      }
+    },
+  },
+  {
+    id: 'squall',
+    prepare: (w) => {
+      // A blow Tower gave too little warning of for the fleet to get in (weather.ts).
+      for (const o of occurrences(w.world.weather, null, 0, 24 * Math.max(28, w.world.weather.cycleDays))) {
+        if (o.event.kind !== 'gale' && o.event.kind !== 'storm') continue
+        if (o.publishedH <= o.startH - HARBOUR_LEAD_H) continue
+        const sea = feature(w.world, o.event.target)
+        const hours = o.startH + Math.min(2, o.endH - o.startH - 0.5)
+        const lane = sea?.kind === 'sea' ? biggestShip(w.world, sea.id, hours) : null
+        if (!sea || !lane) continue
+        return {
+          id: 'squall', hours, pose: chase(w, lane, hours, 55),
+          title: `Caught out: a ship in a ${o.event.kind} on ${sea.name} that Tower gave no warning of`,
+        }
+      }
+      return null
+    },
+  },
+  {
+    id: 'narrows',
+    prepare: (w) => {
+      const f = feature(w.world, 'the-narrows')
+      if (!f || f.geometry.type !== 'polyline') return null
+      const line = smoothLine(f.geometry.points, 50)
+      const length = line.s[line.s.length - 1]!
+      return {
+        id: 'narrows', hours: NOON,
+        title: `Through ${f.name}, the one gap in all three ridges`,
+        pose: flight(w, line, 0, Math.max(40, Math.min(200, (length - 1500) / SCENE_S)), 350, 1500, 65),
+      }
+    },
+  },
+  {
+    id: 'deep',
+    prepare: (w) => {
+      const capital = feature(w.world, 'the-capital')
+      const deep = feature(w.world, 'the-deep')
+      if (!capital || capital.geometry.type !== 'point' || !deep) return null
+      const { theta, z } = capital.geometry
+      // Spinward, across the lake the short way, low over the water, looking at the far
+      // shore — which the floor's curve lifts well above the eye.
+      const line = straight(z, theta * RADIUS_M, Math.PI / 2, 45_000)
+      return {
+        id: 'deep', hours: NOON,
+        title: `Out from ${capital.name} over ${deep.name} — the far shore climbs the sky ahead`,
+        pose: flight(w, line, 7000, 220, 150, 20_000, 60),
+      }
+    },
+  },
+  {
+    id: 'storm',
+    prepare: (w) => {
+      for (const o of occurrences(w.world.weather, null, 0, 24 * Math.max(28, w.world.weather.cycleDays))) {
+        const sea = feature(w.world, o.event.target)
+        if (o.event.kind !== 'storm' || sea?.kind !== 'sea' || sea.geometry.type !== 'band') continue
+        if (o.revisions.length) continue
+        const hours = o.startH + Math.min(8, (o.endH - o.startH) / 2)
+        const dir = (o.event.wind.directionDeg * Math.PI) / 180
+        const level = w.compiled.basins.find((b) => b.id === sea.id)?.levelM ?? 0
+        const z = (sea.geometry.zFrom + sea.geometry.zTo) / 2
+        // Running before the storm, low, over a sea with nobody on it.
+        const move = { z: Math.cos(dir), a: Math.sin(dir) }
+        return {
+          id: 'storm', hours,
+          title: `The storm Tower published for ${sea.name}: ${o.event.wind.speedMS} m/s, and every ship in harbour`,
+          pose: (t) => {
+            const d = 25 * t
+            const cam = { z: z + move.z * d, a: move.a * d, altM: level + 30 }
+            return pose(cam, lookAt(cam, { z: cam.z + move.z * 600, a: cam.a + move.a * 600, altM: level }), 60)
+          },
+        }
+      }
+      return null
+    },
+  },
+  {
+    id: 'girdle',
+    prepare: (w) => {
+      const g = feature(w.world, 'the-girdle')
+      if (!g || g.geometry.type !== 'band') return null
+      const z = (g.geometry.zFrom + g.geometry.zTo) / 2
+      return {
+        id: 'girdle', hours: NOON,
+        title: `Nine kilometres over ${g.name}, the range climbing round the world ahead`,
+        pose: (t) => ({ theta: (400 * t) / RADIUS_M, z, altM: 9000, yaw: Math.PI / 2, pitch: 0.55, fovDeg: 75 }),
+      }
+    },
+  },
+]
+
+/** Every scene the world can show, in tour order. */
+export function planTour(w: SceneWorld): ScenePlan[] {
+  return SCENES.flatMap((s) => s.prepare(w) ?? [])
+}
diff --git a/src/world/schema.ts b/src/world/schema.ts
index 46f69e3..b1caa0f 100644
--- a/src/world/schema.ts
+++ b/src/world/schema.ts
@@ -103,8 +103,8 @@ export interface Mountains extends Base<'mountains'> {
 
 /**
  * The wind over a body of water. Direction is where it blows *toward*, in degrees from
- * deepward toward spinward — the interior view's heading. Phase 4's weather plan
- * replaces this with Tower's schedule; until then it is the body's steady wind.
+ * deepward toward spinward — the interior view's heading. On a lake or sea it is the
+ * usual wind: what blows whenever Tower's weather plan has nothing else scheduled.
  */
 export interface Wind { directionDeg: number; speedMS: number }
 
@@ -126,6 +126,8 @@ export interface Sea extends Base<'sea'> {
   depthM: number
   shoreM: number
   wind: Wind
+  /** Sailing ships working the sea. A ring sea's sail round the world (world-generation.md §4.4). */
+  ships: number
 }
 
 export interface River extends Base<'river'> {
@@ -189,8 +191,59 @@ export interface World {
   seed: number
   ground: Ground
   features: Feature[]
+  /** Tower's published schedule of wind (world-generation.md §4.9). */
+  weather: WeatherPlan
 }
 
+// ---------------------------------------------------------------------- weather
+
+/**
+ * Tower's weather plan (world-generation.md §4.9). Every wind in Tower is Tower's doing
+ * (design.md §1.9.4); Tower publishes its plans so people can live around them, and
+ * changes them when it chooses. Both halves are kept: what was published, and what Tower
+ * did instead.
+ *
+ * A body of water's own `wind` is its baseline — the wind when nothing is scheduled.
+ * Times are a day number from day 0 and an hour, 0..24 (author, 2026-09-16).
+ */
+export const WEATHER_KINDS = ['calm', 'breeze', 'gale', 'storm'] as const
+export type WeatherKind = (typeof WEATHER_KINDS)[number]
+
+export interface WorldTime { day: number; hour: number }
+
+export interface WeatherRevision {
+  /** When Tower changed its mind. */
+  at: WorldTime
+  kind: WeatherKind
+  wind: Wind
+  reason: string
+}
+
+export interface WeatherEvent {
+  id: string
+  /** The lake or sea it is over. */
+  target: string
+  kind: WeatherKind
+  start: WorldTime
+  durationH: number
+  wind: Wind
+  /** When Tower announced it. Nobody acts on a plan before it is published. */
+  publishedAt: WorldTime
+  revisions: WeatherRevision[]
+}
+
+export interface WeatherPlan {
+  /**
+   * Days after which the plan repeats, or 0 for a plan that happens once. A repeating
+   * plan is a season Tower runs again; a revision belongs to the one occurrence it was
+   * made during.
+   */
+  cycleDays: number
+  events: WeatherEvent[]
+}
+
+export const emptyWeather = (): WeatherPlan => ({ cycleDays: 0, events: [] })
+
 /** A feature as it reaches readers: no working notes. Unpublished ones never get here. */
 export type PublicFeature = Omit<Feature, 'notes'>
 
@@ -199,7 +252,7 @@ export type PublicFeature = Omit<Feature, 'notes'>
 export const defaultGround = (): Ground => ({ baseM: 60, reliefM: 260, notes: '' })
 
 export const emptyWorld = (): World => ({
-  version: WORLD_VERSION, seed: 1, ground: defaultGround(), features: [],
+  version: WORLD_VERSION, seed: 1, ground: defaultGround(), features: [], weather: emptyWeather(),
 })
 
 /** A fresh seed for a new feature — the one place generation is allowed a random number. */
@@ -241,7 +294,7 @@ export function withKind(common: Common, kind: FeatureKind): Feature {
         wind: { directionDeg: 60, speedMS: 6 } }
     case 'sea':
       // A ring sea's wind blows round the world: unlimited fetch (world-generation.md §4.4).
-      return { ...c, kind, levelM: 90, depthM: 420, shoreM: 4000, wind: { directionDeg: 90, speedMS: 9 } }
+      return { ...c, kind, levelM: 90, depthM: 420, shoreM: 4000, wind: { directionDeg: 90, speedMS: 9 }, ships: 90 }
     case 'river':
       return { ...c, kind, flowM3S: 40, sourceM: 'terrain', outlet: 'auto' }
     case 'desert':
@@ -312,6 +365,7 @@ export function parseWorld(raw: unknown): World {
       baseM: num(g.baseM, 60), reliefM: Math.max(0, num(g.reliefM, 260)), notes: str(g.notes),
     }
   }
+  world.weather = parseWeather(src.weather)
   if (!Array.isArray(src.features)) return world
   const seen = new Set<string>()
   for (const f of src.features as unknown as Record<string, unknown>[]) {
@@ -374,7 +428,8 @@ function fillParams(d: Feature, f: Record<string, unknown>): Feature {
       return { ...d, levelM: num(f['levelM'], d.levelM),
                depthM: Math.max(1, num(f['depthM'], d.depthM)),
                shoreM: Math.max(1, num(f['shoreM'], d.shoreM)),
-               wind: parseWind(f['wind'], d.wind) }
+               wind: parseWind(f['wind'], d.wind),
+               ships: Math.max(0, Math.min(400, Math.floor(num(f['ships'], d.ships)))) }
     case 'river':
       return { ...d, flowM3S: Math.max(0.1, num(f['flowM3S'], d.flowM3S)),
                sourceM: Number.isFinite(f['sourceM']) ? (f['sourceM'] as number) : 'terrain',
@@ -396,6 +451,46 @@ function fillParams(d: Feature, f: Record<string, unknown>): Feature {
   }
 }
 
+function parseTime(t: unknown): WorldTime | null {
+  const src = t as Partial<WorldTime> | null
+  if (!src || !Number.isFinite(src.day) || !Number.isFinite(src.hour)) return null
+  return { day: Math.max(0, Math.floor(src.day!)), hour: Math.min(24, Math.max(0, src.hour!)) }
+}
+
+function parseWeather(w: unknown): WeatherPlan {
+  const src = w as Partial<WeatherPlan> | null
+  const plan = emptyWeather()
+  if (!src || typeof src !== 'object') return plan
+  plan.cycleDays = Math.max(0, Math.floor(num(src.cycleDays, 0)))
+  if (!Array.isArray(src.events)) return plan
+  const seen = new Set<string>()
+  for (const e of src.events as unknown as Record<string, unknown>[]) {
+    if (!e || typeof e !== 'object') continue
+    const start = parseTime(e['start'])
+    const target = str(e['target'])
+    if (!start || !target) continue
+    const kind: WeatherKind = WEATHER_KINDS.includes(e['kind'] as WeatherKind) ? (e['kind'] as WeatherKind) : 'breeze'
+    const id = typeof e['id'] === 'string' && e['id'] && !seen.has(e['id']) ? e['id'] : makeId(`${target}-${kind}`, seen)
+    seen.add(id)
+    const revisions = Array.isArray(e['revisions'])
+      ? (e['revisions'] as Record<string, unknown>[]).flatMap((r): WeatherRevision[] => {
+        const at = parseTime(r?.['at'])
+        if (!at) return []
+        const rk = WEATHER_KINDS.includes(r['kind'] as WeatherKind) ? (r['kind'] as WeatherKind) : kind
+        return [{ at, kind: rk, wind: parseWind(r['wind'], { directionDeg: 0, speedMS: 0 }), reason: str(r['reason']) }]
+      })
+      : []
+    plan.events.push({
+      id, target, kind, start,
+      durationH: Math.max(0.5, num(e['durationH'], 6)),
+      wind: parseWind(e['wind'], { directionDeg: 90, speedMS: 10 }),
+      publishedAt: parseTime(e['publishedAt']) ?? start,
+      revisions,
+    })
+  }
+  return plan
+}
+
 function parseWind(w: unknown, fallback: Wind): Wind {
   const src = w as Partial<Wind> | null
   if (!src || typeof src !== 'object') return fallback
@@ -449,6 +544,8 @@ export function toPublicWorld(world: World): World {
   return {
     version: WORLD_VERSION,
     seed: world.seed,
+    // Tower publishes its plans: the weather is public, revisions and their reasons too.
+    weather: world.weather,
     ground: { ...world.ground, notes: '' },
     features: world.features
       .filter((f) => f.public)
@@ -465,8 +562,12 @@ export function terrainSignature(world: World): string {
     seed: world.seed,
     ground: { baseM: world.ground.baseM, reliefM: world.ground.reliefM },
     // The id stays out too: a rename re-ids a feature, and references to it (a
-    // river's outlet) are rewritten with it, which is what shows up here instead.
-    features: world.features.map(
-      ({ id: _i, name: _n, blurb: _b, notes: _no, public: _p, ...rest }) => rest),
+    // river's outlet) are rewritten with it, which is what shows up here instead. Wind
+    // and ships change nothing on the ground either: they are read live (weather.ts).
+    features: world.features.map((f) => {
+      const { id: _i, name: _n, blurb: _b, notes: _no, public: _p, ...rest } = f as Feature & { wind?: unknown; ships?: unknown }
+      const { wind: _w, ships: _s, ...ground } = rest as typeof rest & { wind?: unknown; ships?: unknown }
+      return ground
+    }),
   })
 }
diff --git a/src/world/store.ts b/src/world/store.ts
index ca6b5f0..788fdeb 100644
--- a/src/world/store.ts
+++ b/src/world/store.ts
@@ -6,7 +6,7 @@
  * content once to seed the repo. There is no backend — GitLab Pages is static.
  */
 import {
-  type Feature, type World, emptyWorld, parseWorld, toPublicWorld,
+  type Feature, type WeatherPlan, type World, emptyWorld, parseWorld, toPublicWorld,
 } from './schema.js'
 
 /**
@@ -130,6 +130,16 @@ export class WorldStore {
     return this.world.features.find((p) => p.id === id)
   }
 
+  /** Tower's weather plan. Edits go through `setWeather`, so they are saved. */
+  get weather(): WeatherPlan {
+    return this.world.weather
+  }
+
+  setWeather(plan: WeatherPlan): void {
+    this.world.weather = plan
+    this.touch()
+  }
+
   get takenIds(): ReadonlySet<string> {
     return new Set(this.world.features.map((p) => p.id))
   }
diff --git a/src/world/weather.ts b/src/world/weather.ts
new file mode 100644
index 0000000..bc09416
--- /dev/null
+++ b/src/world/weather.ts
@@ -0,0 +1,142 @@
+/**
+ * Reading Tower's weather plan at a moment. See world-generation.md §4.9.
+ *
+ * Two readings of the same plan, and they differ on purpose:
+ *
+ *   - **what Tower does** (`actualAt`): the event as last revised before now;
+ *   - **what people were told** (`published`): the event as announced, before any
+ *     revision — because that is what they plan around. A sailor told of a storm stays in
+ *     harbour, even after Tower quietly calls the storm off.
+ *
+ * Everything is a pure function of the plan and a time in hours since day 0, so the
+ * whole world agrees on the weather at a moment without anything having to be simulated
+ * up to it.
+ */
+import type { WeatherEvent, WeatherKind, WeatherPlan, WeatherRevision, Wind, WorldTime } from './schema.js'
+
+export const hoursOf = (t: WorldTime): number => t.day * 24 + t.hour
+
+export const timeOf = (hours: number): WorldTime => {
+  const day = Math.floor(hours / 24)
+  return { day, hour: hours - day * 24 }
+}
+
+/** One occurrence of an event: the event itself, or a later repeat of it in a cycling plan. */
+export interface Occurrence {
+  event: WeatherEvent
+  startH: number
+  endH: number
+  publishedH: number
+  /** Revisions made during this occurrence, earliest first. */
+  revisions: WeatherRevision[]
+}
+
+/** Occurrences over `target` (or every target) that overlap [fromH, toH]. */
+export function occurrences(plan: WeatherPlan, target: string | null, fromH: number, toH: number): Occurrence[] {
+  const out: Occurrence[] = []
+  const cycleH = plan.cycleDays * 24
+  for (const e of plan.events) {
+    if (target !== null && e.target !== target) continue
+    const start = hoursOf(e.start)
+    const end = start + e.durationH
+    const published = Math.min(hoursOf(e.publishedAt), start)
+    const repeats = cycleH > 0
+      ? { first: Math.floor((fromH - end) / cycleH), last: Math.ceil((toH - published) / cycleH) }
+      : { first: 0, last: 0 }
+    for (let k = Math.max(0, repeats.first); k <= repeats.last; k++) {
+      const shift = k * cycleH
+      const o = { startH: start + shift, endH: end + shift, publishedH: published + shift }
+      if (o.endH < fromH || o.publishedH > toH) continue
+      const revisions = e.revisions
+        .filter((r) => { const at = hoursOf(r.at); return at >= o.publishedH && at <= o.endH })
+        .sort((x, y) => hoursOf(x.at) - hoursOf(y.at))
+      out.push({ event: e, ...o, revisions })
+    }
+  }
+  return out.sort((x, y) => x.startH - y.startH)
+}
+
+/** The wind a speed reads as, roughly by the Beaufort scale. */
+export function kindOfSpeed(speedMS: number): WeatherKind {
+  if (speedMS < 1.5) return 'calm'
+  if (speedMS < 14) return 'breeze'
+  if (speedMS < 24.5) return 'gale'
+  return 'storm'
+}
+
+export interface WeatherState {
+  kind: WeatherKind
+  wind: Wind
+  /** The occurrence in force, if any. */
+  occurrence: Occurrence | null
+  /** The revision in force, if Tower has changed its mind. */
+  revision: WeatherRevision | null
+}
+
+/** Hours over which a scheduled wind rises from the baseline and falls back to it. */
+export const RAMP_H = 1
+
+/**
+ * What Tower actually does over `target` at `hours`: the latest revision made so far to
+ * the occurrence in force, or the occurrence as scheduled, or the body's baseline. The
+ * wind rises over the first hour and falls over the last, rather than switching.
+ */
+export function actualAt(plan: WeatherPlan, target: string, baseline: Wind, hours: number): WeatherState {
+  const now = occurrences(plan, target, hours, hours).filter((o) => o.startH <= hours && hours < o.endH)
+  const o = now[now.length - 1]
+  if (!o) return { kind: kindOfSpeed(baseline.speedMS), wind: baseline, occurrence: null, revision: null }
+  let revision: WeatherRevision | null = null
+  for (const r of o.revisions) if (hoursOf(r.at) <= hours) revision = r
+  const kind = revision ? revision.kind : o.event.kind
+  const wind = revision ? revision.wind : o.event.wind
+  const ramp = Math.min(1, (hours - o.startH) / RAMP_H, (o.endH - hours) / RAMP_H)
+  return {
+    kind, occurrence: o, revision,
+    wind: {
+      directionDeg: ramp < 0.5 ? baseline.directionDeg : wind.directionDeg,
+      speedMS: baseline.speedMS + (wind.speedMS - baseline.speedMS) * ramp,
+    },
+  }
+}
+
+/**
+ * The wind the *sea* is still answering. Waves take hours to build and keep running
+ * after the wind has dropped, so the sea state follows a weighted memory of the last six
+ * hours of wind, the most recent counting most. The direction is the wind's now.
+ */
+export function seaWindAt(plan: WeatherPlan, target: string, baseline: Wind, hours: number): Wind {
+  let sum = 0, weight = 0
+  for (let back = 0; back <= 6; back += 0.5) {
+    const w = Math.exp(-back / SEA_MEMORY_H)
+    sum += actualAt(plan, target, baseline, hours - back).wind.speedMS * w
+    weight += w
+  }
+  return { directionDeg: actualAt(plan, target, baseline, hours).wind.directionDeg, speedMS: sum / weight }
+}
+/** E-folding time of the sea's memory of the wind. */
+export const SEA_MEMORY_H = 2
+
+/** Hours before a published gale or storm that ships head for harbour. */
+export const HARBOUR_LEAD_H = 3
+
+/**
+ * Whether ships on `target` are in harbour at `hours`: a gale or a storm was published
+ * in time for them to get in — at least `HARBOUR_LEAD_H` before it — and it is on or due
+ * within those hours. People act on what they were told: a storm Tower has since called
+ * off still empties the sea. And a blow announced too late to run for harbour catches the
+ * fleet out, and it rides the weather until the weather ends.
+ */
+export function inHarbour(plan: WeatherPlan, target: string, hours: number): boolean {
+  return occurrences(plan, target, hours, hours + HARBOUR_LEAD_H).some((o) =>
+    (o.event.kind === 'gale' || o.event.kind === 'storm')
+    && o.publishedH <= o.startH - HARBOUR_LEAD_H
+    && o.startH - HARBOUR_LEAD_H <= hours && hours < o.endH)
+}
+
+/** A one-line description of an occurrence as the board shows it. */
+export function describe(o: Occurrence): string {
+  const t = timeOf(o.startH)
+  const hh = String(Math.floor(t.hour)).padStart(2, '0')
+  const mm = String(Math.round((t.hour % 1) * 60)).padStart(2, '0')
+  return `Day ${t.day}, ${hh}:${mm} — ${o.event.kind}, ${o.event.wind.speedMS} m/s, for ${o.event.durationH} h`
+}
diff --git a/static/world.json b/static/world.json
index c377a1b..fa1f2ef 100644
--- a/static/world.json
+++ b/static/world.json
@@ -902,5 +902,137 @@
       },
       "size": "station"
     }
-  ]
+  ],
+  "weather": {
+    "cycleDays": 28,
+    "events": [
+      {
+        "id": "sunward-sea-squall",
+        "target": "sunward-sea",
+        "kind": "gale",
+        "start": {
+          "day": 11,
+          "hour": 9
+        },
+        "durationH": 5,
+        "wind": {
+          "directionDeg": 120,
+          "speedMS": 22
+        },
+        "publishedAt": {
+          "day": 11,
+          "hour": 8
+        },
+        "revisions": []
+      },
+      {
+        "id": "sunward-sea-storm",
+        "target": "sunward-sea",
+        "kind": "storm",
+        "start": {
+          "day": 3,
+          "hour": 6
+        },
+        "durationH": 18,
+        "wind": {
+          "directionDeg": 90,
+          "speedMS": 28
+        },
+        "publishedAt": {
+          "day": 1,
+          "hour": 12
+        },
+        "revisions": []
+      },
+      {
+        "id": "deepward-sea-gale",
+        "target": "deepward-sea",
+        "kind": "gale",
+        "start": {
+          "day": 9,
+          "hour": 14
+        },
+        "durationH": 10,
+        "wind": {
+          "directionDeg": 270,
+          "speedMS": 20
+        },
+        "publishedAt": {
+          "day": 8,
+          "hour": 0
+        },
+        "revisions": []
+      },
+      {
+        "id": "sunward-sea-calm",
+        "target": "sunward-sea",
+        "kind": "calm",
+        "start": {
+          "day": 15,
+          "hour": 0
+        },
+        "durationH": 24,
+        "wind": {
+          "directionDeg": 90,
+          "speedMS": 1
+        },
+        "publishedAt": {
+          "day": 13,
+          "hour": 9
+        },
+        "revisions": []
+      },
+      {
+        "id": "deepward-sea-storm",
+        "target": "deepward-sea",
+        "kind": "storm",
+        "start": {
+          "day": 20,
+          "hour": 18
+        },
+        "durationH": 30,
+        "wind": {
+          "directionDeg": 60,
+          "speedMS": 30
+        },
+        "publishedAt": {
+          "day": 17,
+          "hour": 9
+        },
+        "revisions": [
+          {
+            "at": {
+              "day": 21,
+              "hour": 6
+            },
+            "kind": "gale",
+            "wind": {
+              "directionDeg": 60,
+              "speedMS": 18
+            },
+            "reason": "A fishing boat that ignored the plan is still out. Tower is holding the storm down to a gale until it is in."
+          }
+        ]
+      },
+      {
+        "id": "the-deep-gale",
+        "target": "the-deep",
+        "kind": "gale",
+        "start": {
+          "day": 24,
+          "hour": 10
+        },
+        "durationH": 8,
+        "wind": {
+          "directionDeg": 0,
+          "speedMS": 18
+        },
+        "publishedAt": {
+          "day": 23,
+          "hour": 10
+        },
+        "revisions": []
+      }
+    ]
+  }
 }
diff --git a/test/scenic.test.ts b/test/scenic.test.ts
new file mode 100644
index 0000000..eb28f48
--- /dev/null
+++ b/test/scenic.test.ts
@@ -0,0 +1,83 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import { LENGTH_M, RADIUS_M } from '../src/canon/constants.js'
+import { compileWorld } from '../src/terrain/compile.js'
+import { prepare } from '../src/terrain/field.js'
+import { nearestA } from '../src/terrain/shapes.js'
+import { shipAt, shipsAt } from '../src/terrain/ships.js'
+import { SCENES, SCENE_S, lookAt, planTour } from '../src/views/scenic.js'
+import { parseWorld } from '../src/world/schema.js'
+import { actualAt } from '../src/world/weather.js'
+
+const world = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8')))
+const compiled = compileWorld(world)
+const rt = prepare(compiled)
+const ground = (theta: number, z: number): number => {
+  const s = rt.sample(theta, z, 1)
+  return Math.max(s.h, s.water)
+}
+const plans = planTour({ world, compiled, ground })
+const plan = (id: string) => plans.find((p) => p.id === id)!
+
+describe('the scenic tour', () => {
+  it('finds every scene in the world as authored', () => {
+    expect(plans.map((p) => p.id)).toEqual(SCENES.map((s) => s.id))
+  })
+
+  it('keeps every camera in the world, above the ground, and moving smoothly', () => {
+    for (const p of plans) {
+      let prev: ReturnType<typeof p.pose> | null = null
+      for (let t = 0; t <= SCENE_S; t += 0.5) {
+        const q = p.pose(t)
+        for (const v of Object.values(q)) expect(Number.isFinite(v), `${p.id} at ${t}s`).toBe(true)
+        expect(q.z).toBeGreaterThanOrEqual(0)
+        expect(q.z).toBeLessThanOrEqual(LENGTH_M)
+        expect(q.altM - ground(q.theta, q.z), `${p.id} clearance at ${t}s`).toBeGreaterThan(8)
+        if (prev) {
+          const moved = Math.hypot(q.z - prev.z, nearestA(q.theta * RADIUS_M, prev.theta * RADIUS_M) - prev.theta * RADIUS_M)
+          expect(moved, `${p.id} speed at ${t}s`).toBeLessThan(250)
+          const turn = Math.abs(Math.atan2(Math.sin(q.yaw - prev.yaw), Math.cos(q.yaw - prev.yaw)))
+          expect(turn, `${p.id} turn at ${t}s`).toBeLessThan(0.2)
+        }
+        prev = q
+      }
+    }
+  })
+
+  it('keeps a followed ship in the middle of the frame', () => {
+    for (const id of ['ship', 'squall']) {
+      const p = plan(id)
+      const fleet = shipsAt(world, p.hours)
+      for (const t of [0, 15, 30]) {
+        const q = p.pose(t)
+        const cam = { z: q.z, a: q.theta * RADIUS_M, altM: q.altM }
+        // The ship being followed is the one nearest the camera.
+        const near = fleet.map((s) => shipAt(s.lane, p.hours + t / 3600))
+          .reduce((b, f) => (Math.hypot(f.z - cam.z, nearestA(f.a, cam.a) - cam.a)
+            < Math.hypot(b.z - cam.z, nearestA(b.a, cam.a) - cam.a) ? f : b))
+        const look = lookAt(cam, { z: near.z, a: near.a, altM: q.altM - 10 })
+        expect(Math.abs(Math.atan2(Math.sin(look.yaw - q.yaw), Math.cos(look.yaw - q.yaw)))).toBeLessThan(0.1)
+      }
+    }
+  })
+
+  it('catches a ship out in weather Tower gave no warning of', () => {
+    const p = plan('squall')
+    const sea = world.weather.events.find((e) => p.title.includes(e.kind) && e.id.includes('squall'))!
+    const wind = actualAt(world.weather, sea.target, { directionDeg: 0, speedMS: 0 }, p.hours).wind.speedMS
+    expect(wind).toBeGreaterThanOrEqual(17)
+    expect(shipsAt(world, p.hours).some((s) => s.lane.sea === sea.target)).toBe(true)
+  })
+
+  it('shows a published storm with the sea empty of ships', () => {
+    const p = plan('storm')
+    expect(shipsAt(world, p.hours).filter((s) => p.title.includes('Sunward') && s.lane.sea === 'sunward-sea')).toHaveLength(0)
+  })
+
+  it('photographs only scenes that exist', () => {
+    const tool = readFileSync('tools/screenshots.mjs', 'utf8')
+    const ids = [...tool.matchAll(/scene: '([a-z-]+)'/g)].map((m) => m[1])
+    expect(ids.length).toBeGreaterThan(0)
+    for (const id of ids) expect(SCENES.map((s) => s.id)).toContain(id)
+  })
+})
diff --git a/test/weather.test.ts b/test/weather.test.ts
new file mode 100644
index 0000000..b2c8819
--- /dev/null
+++ b/test/weather.test.ts
@@ -0,0 +1,168 @@
+import { describe, expect, it } from 'vitest'
+import { CIRCUMFERENCE_M } from '../src/canon/constants.js'
+import { boomAngle, lanesOf, shipAt, shipsAt, waveAt } from '../src/terrain/ships.js'
+import { type WeatherPlan, withKind } from '../src/world/schema.js'
+import { actualAt, hoursOf, inHarbour, kindOfSpeed, occurrences, seaWindAt } from '../src/world/weather.js'
+
+const baseline = { directionDeg: 90, speedMS: 8 }
+
+/** A storm on day 5 from 12:00 for 12 hours, published on day 3, over the Sunward Sea. */
+const storm = (over: Partial<WeatherPlan['events'][number]> = {}): WeatherPlan['events'][number] => ({
+  id: 's', target: 'sea', kind: 'storm', start: { day: 5, hour: 12 }, durationH: 12,
+  wind: { directionDeg: 90, speedMS: 28 }, publishedAt: { day: 3, hour: 9 }, revisions: [], ...over,
+})
+const plan = (events: WeatherPlan['events'], cycleDays = 0): WeatherPlan => ({ cycleDays, events })
+
+describe('the weather as Tower makes it', () => {
+  it('is the baseline when nothing is scheduled', () => {
+    const s = actualAt(plan([storm()]), 'sea', baseline, hoursOf({ day: 4, hour: 0 }))
+    expect(s.wind).toEqual(baseline)
+    expect(s.occurrence).toBeNull()
+  })
+
+  it('is the scheduled wind during the event, rising over its first hour', () => {
+    const p = plan([storm()])
+    expect(actualAt(p, 'sea', baseline, hoursOf({ day: 5, hour: 18 })).wind.speedMS).toBe(28)
+    expect(actualAt(p, 'sea', baseline, hoursOf({ day: 5, hour: 12.5 })).wind.speedMS).toBeCloseTo(18, 6)
+  })
+
+  it('follows Tower\'s latest revision, from the moment it was made', () => {
+    const p = plan([storm({ revisions: [{
+      at: { day: 5, hour: 15 }, kind: 'calm', wind: { directionDeg: 90, speedMS: 1 },
+      reason: 'a boat is out that should not be',
+    }] })])
+    expect(actualAt(p, 'sea', baseline, hoursOf({ day: 5, hour: 14 })).kind).toBe('storm')
+    const after = actualAt(p, 'sea', baseline, hoursOf({ day: 5, hour: 16 }))
+    expect(after.kind).toBe('calm')
+    expect(after.revision?.reason).toContain('boat')
+  })
+
+  it('only affects its own body of water', () => {
+    expect(actualAt(plan([storm()]), 'other', baseline, hoursOf({ day: 5, hour: 18 })).wind).toEqual(baseline)
+  })
+
+  it('repeats a cycling plan, and keeps a revision to the occurrence it was made in', () => {
+    const p = plan([storm({ revisions: [{
+      at: { day: 5, hour: 13 }, kind: 'breeze', wind: { directionDeg: 90, speedMS: 6 }, reason: 'eased',
+    }] })], 28)
+    expect(actualAt(p, 'sea', baseline, hoursOf({ day: 5, hour: 18 })).kind).toBe('breeze')
+    // Twenty-eight days on, the storm comes round again, unrevised.
+    expect(actualAt(p, 'sea', baseline, hoursOf({ day: 33, hour: 18 })).kind).toBe('storm')
+    expect(occurrences(p, 'sea', 0, 24 * 60)).toHaveLength(3)
+  })
+})
+
+describe('the sea state lags the wind', () => {
+  it('builds over hours rather than jumping with the wind', () => {
+    const p = plan([storm()])
+    const early = seaWindAt(p, 'sea', baseline, hoursOf({ day: 5, hour: 13.5 })).speedMS
+    const late = seaWindAt(p, 'sea', baseline, hoursOf({ day: 5, hour: 20 })).speedMS
+    expect(early).toBeLessThan(late)
+    expect(late).toBeGreaterThan(25)
+  })
+
+  it('keeps running after the wind drops', () => {
+    const p = plan([storm()])
+    const justAfter = seaWindAt(p, 'sea', baseline, hoursOf({ day: 6, hour: 1 })).speedMS
+    expect(justAfter).toBeGreaterThan(baseline.speedMS + 5)
+  })
+})
+
+describe('ships and the published plan', () => {
+  it('stay in harbour for a published storm, from a few hours before it', () => {
+    const p = plan([storm()])
+    expect(inHarbour(p, 'sea', hoursOf({ day: 5, hour: 8 }))).toBe(false)
+    expect(inHarbour(p, 'sea', hoursOf({ day: 5, hour: 10 }))).toBe(true)
+    expect(inHarbour(p, 'sea', hoursOf({ day: 5, hour: 20 }))).toBe(true)
+    expect(inHarbour(p, 'sea', hoursOf({ day: 6, hour: 1 }))).toBe(false)
+  })
+
+  it('go out anyway for a storm nobody was told about, and are caught out by it', () => {
+    const p = plan([storm({ publishedAt: { day: 5, hour: 12 } })])
+    expect(inHarbour(p, 'sea', hoursOf({ day: 5, hour: 10 }))).toBe(false)
+    expect(inHarbour(p, 'sea', hoursOf({ day: 5, hour: 14 }))).toBe(false)
+  })
+
+  it('are caught out by a storm announced too late to run for harbour', () => {
+    const p = plan([storm({ publishedAt: { day: 5, hour: 11 } })])
+    expect(inHarbour(p, 'sea', hoursOf({ day: 5, hour: 18 }))).toBe(false)
+  })
+
+  it('stay in for a storm Tower has quietly called off: people act on what they were told', () => {
+    const p = plan([storm({ revisions: [{
+      at: { day: 4, hour: 0 }, kind: 'calm', wind: { directionDeg: 0, speedMS: 1 }, reason: 'changed its mind',
+    }] })])
+    expect(actualAt(p, 'sea', baseline, hoursOf({ day: 5, hour: 18 })).kind).toBe('calm')
+    expect(inHarbour(p, 'sea', hoursOf({ day: 5, hour: 18 }))).toBe(true)
+  })
+})
+
+describe('reading a wind', () => {
+  it('names speeds roughly as Beaufort does', () => {
+    expect(kindOfSpeed(0.5)).toBe('calm')
+    expect(kindOfSpeed(8)).toBe('breeze')
+    expect(kindOfSpeed(20)).toBe('gale')
+    expect(kindOfSpeed(30)).toBe('storm')
+  })
+})
+
+describe('ships', () => {
+  const sea = {
+    ...withKind({
+      id: 'sea', name: 'Sea', blurb: '', notes: '', public: true, seed: 7,
+      geometry: { type: 'band', zFrom: 0, zTo: 42_000 },
+    }, 'sea'),
+  } as Extract<ReturnType<typeof withKind>, { kind: 'sea' }>
+
+  it('are the same fleet for everyone: lanes come from the seeds', () => {
+    expect(lanesOf(sea, 1)).toEqual(lanesOf(sea, 1))
+    expect(lanesOf(sea, 1)).not.toEqual(lanesOf(sea, 2))
+    expect(lanesOf(sea, 1)).toHaveLength(sea.ships)
+  })
+
+  it('keep off the shores and sail at a cruising speed', () => {
+    for (const lane of lanesOf(sea, 1)) {
+      for (const h of [0, 3.3, 50, 1000]) {
+        const { z } = shipAt(lane, h)
+        expect(z).toBeGreaterThan(0)
+        expect(z).toBeLessThan(42_000)
+      }
+      expect(lane.speedMS).toBeGreaterThanOrEqual(3)
+      expect(lane.speedMS).toBeLessThanOrEqual(6)
+    }
+  })
+
+  it('move at their speed, in the direction they head', () => {
+    const lane = lanesOf(sea, 1)[0]!
+    const a0 = shipAt(lane, 10).a, a1 = shipAt(lane, 10 + 1 / 60).a
+    let da = a1 - a0
+    da -= Math.round(da / CIRCUMFERENCE_M) * CIRCUMFERENCE_M
+    expect(Math.sign(da)).toBe(lane.dir)
+    expect(Math.abs(da)).toBeCloseTo(lane.speedMS * 60, 6)
+    expect(Math.sign(Math.sin(shipAt(lane, 10).headingRad))).toBe(lane.dir)
+  })
+
+  it('stay in harbour for a published storm', () => {
+    const world = {
+      version: 2, seed: 1, ground: { baseM: 0, reliefM: 0, notes: '' }, features: [sea],
+      weather: plan([storm()]),
+    } as unknown as Parameters<typeof shipsAt>[0]
+    expect(shipsAt(world, hoursOf({ day: 4, hour: 0 }))).toHaveLength(sea.ships)
+    expect(shipsAt(world, hoursOf({ day: 5, hour: 18 }))).toHaveLength(0)
+  })
+
+  it('ride a wave sum whose slope is the derivative of its height', () => {
+    const at = (z: number, a: number) => waveAt(1.2, 80, 0.7, z, a, 123.4)
+    const e = 0.01
+    const w = at(500, 900)
+    expect(w.gz).toBeCloseTo((at(500 + e, 900).h - at(500 - e, 900).h) / (2 * e), 4)
+    expect(w.ga).toBeCloseTo((at(500, 900 + e).h - at(500, 900 - e).h) / (2 * e), 4)
+  })
+
+  it('let the sail out running before the wind and sheet it in heading into it', () => {
+    expect(Math.abs(boomAngle(0, 0))).toBeCloseTo(Math.PI / 2, 6)
+    expect(Math.abs(boomAngle(0, Math.PI))).toBeCloseTo(0, 6)
+    expect(boomAngle(0, Math.PI / 2)).toBeCloseTo(Math.PI / 4, 6)
+    expect(boomAngle(0, -Math.PI / 2)).toBeCloseTo(-Math.PI / 4, 6)
+  })
+})
diff --git a/test/world.test.ts b/test/world.test.ts
index ee3a769..d03b891 100644
--- a/test/world.test.ts
+++ b/test/world.test.ts
@@ -182,7 +182,7 @@ describe('the committed world loads without losing anything', () => {
 })
 
 describe('the public export is the privacy boundary', () => {
-  const world = { version: 2, seed: 1, ground: { baseM: 60, reliefM: 260, notes: 'GROUND SECRET' },
+  const world = { version: 2, seed: 1, weather: { cycleDays: 0, events: [] }, ground: { baseM: 60, reliefM: 260, notes: 'GROUND SECRET' },
     features: [
       place({ id: 'a', name: 'Shown', notes: 'SECRET', blurb: 'visible' }),
       place({ id: 'b', name: 'Hidden', public: false, notes: 'ALSO SECRET' }),
@@ -221,7 +221,7 @@ describe('WorldStore', () => {
   it('treats an empty draft as no draft and loads the seed instead', async () => {
     // Opening the app once while the seed was empty used to persist an empty world
     // that then shadowed the real one forever.
-    store.replaceAll({ version: 2, seed: 1, ground: { baseM: 0, reliefM: 0, notes: '' }, features: [] })
+    store.replaceAll({ version: 2, seed: 1, ground: { baseM: 0, reliefM: 0, notes: '' }, features: [], weather: { cycleDays: 0, events: [] } })
     store.flush()
     const fresh = new WorldStore()
     vi.stubGlobal('fetch', async () => ({ ok: true, json: async () => ({
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index 7037ddc..9b34a7d 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -272,45 +272,113 @@ const SHOTS = [
       await settle(page, 1400)
     },
   },
+  // The scenic tour's scenes (src/views/scenic.ts), each held at one moment. The scenes are
+  // worked out from the world, so these follow it as it is edited; a test checks every id.
   {
-    id: 'interior-range',
-    caption: 'Interior — from Snowcrown\'s summit, 4,300 m: the Girdle\'s ridges and valleys running round the world',
+    id: 'tour-falls',
+    caption: 'Scenic tour — a helicopter circling the Long Water\'s 680 m fall, leaning antispinward as it drops',
     on: ['desktop'],
+    scene: 'falls',
     run: async (page) => {
       await openView(page, 'interior')
-      await pick(page, 'Snowcrown')
-      // Standing on the summit looking deepward along the range, a little down, so the
-      // ridges below and the ring of the Girdle climbing overhead are both in frame.
-      await page.evaluate(() => {
-        const tw = window.tower
-        tw.yaw = Math.PI / 2
-        tw.pitch = -0.12
-        tw.fovDeg = 70
-        tw.setEyeHeight(30)
-      })
+      const title = await page.evaluate(() => window.tower.showScene('falls', 10))
+      if (!title) throw new Error('no scene falls in this world')
       await settle(page, 1400)
     },
   },
   {
-    id: 'interior-falls',
-    caption: 'Interior — the Long Water falling 680 m off the range into its gorge, leaning antispinward as it drops',
+    id: 'tour-ship',
+    caption: 'Scenic tour — following a ship round the Sunward Sea',
     on: ['desktop'],
+    scene: 'ship',
     run: async (page) => {
       await openView(page, 'interior')
-      await pick(page, 'The Long Water')
-      await settle(page, 300)
-      // Downstream of the world's tallest fall, looking back up the gorge at it.
-      await page.evaluate(() => {
-        const tw = window.tower
-        const falls = window.towerTerrain.compiled.falls
-        const f = falls.reduce((a, b) => (b.topM - b.bottomM > a.topM - a.bottomM ? b : a))
-        const z = f.z + f.dz * 1500, a = f.a + f.da * 1500
-        tw.visit({ theta: a / 50000, z })
-        tw.yaw = (Math.atan2(f.a - a, f.z - z) + 2 * Math.PI) % (2 * Math.PI)
-        tw.pitch = 0.3
-        tw.fovDeg = 55
-        tw.setEyeHeight(30)
-      })
+      const title = await page.evaluate(() => window.tower.showScene('ship', 12))
+      if (!title) throw new Error('no scene ship in this world')
+      await settle(page, 1400)
+    },
+  },
+  {
+    id: 'tour-summit',
+    caption: 'Scenic tour — round Snowcrown\'s summit, 4,300 m',
+    on: ['desktop'],
+    scene: 'summit',
+    run: async (page) => {
+      await openView(page, 'interior')
+      const title = await page.evaluate(() => window.tower.showScene('summit', 8))
+      if (!title) throw new Error('no scene summit in this world')
+      await settle(page, 1400)
+    },
+  },
+  {
+    id: 'tour-river',
+    caption: 'Scenic tour — down the Long Water to its 167 m fall into The Deep',
+    on: ['desktop'],
+    scene: 'river',
+    run: async (page) => {
+      await openView(page, 'interior')
+      const title = await page.evaluate(() => window.tower.showScene('river', 16))
+      if (!title) throw new Error('no scene river in this world')
+      await settle(page, 1400)
+    },
+  },
+  {
+    id: 'tour-squall',
+    caption: 'Scenic tour — a ship caught out by a gale Tower gave no warning of',
+    on: ['desktop'],
+    scene: 'squall',
+    run: async (page) => {
+      await openView(page, 'interior')
+      const title = await page.evaluate(() => window.tower.showScene('squall', 12))
+      if (!title) throw new Error('no scene squall in this world')
+      await settle(page, 1400)
+    },
+  },
+  {
+    id: 'tour-narrows',
+    caption: 'Scenic tour — through The Narrows, the one gap in all three ridges',
+    on: ['desktop'],
+    scene: 'narrows',
+    run: async (page) => {
+      await openView(page, 'interior')
+      const title = await page.evaluate(() => window.tower.showScene('narrows', 15))
+      if (!title) throw new Error('no scene narrows in this world')
+      await settle(page, 1400)
+    },
+  },
+  {
+    id: 'tour-deep',
+    caption: 'Scenic tour — out from the Capital over The Deep, the far shore climbing the sky',
+    on: ['desktop'],
+    scene: 'deep',
+    run: async (page) => {
+      await openView(page, 'interior')
+      const title = await page.evaluate(() => window.tower.showScene('deep', 14))
+      if (!title) throw new Error('no scene deep in this world')
+      await settle(page, 1400)
+    },
+  },
+  {
+    id: 'tour-storm',
+    caption: 'Scenic tour — the storm Tower published for the Sunward Sea, and every ship in harbour',
+    on: ['desktop'],
+    scene: 'storm',
+    run: async (page) => {
+      await openView(page, 'interior')
+      const title = await page.evaluate(() => window.tower.showScene('storm', 10))
+      if (!title) throw new Error('no scene storm in this world')
+      await settle(page, 1400)
+    },
+  },
+  {
+    id: 'tour-girdle',
+    caption: 'Scenic tour — nine kilometres over the Girdle, the range climbing round the world ahead',
+    on: ['desktop'],
+    scene: 'girdle',
+    run: async (page) => {
+      await openView(page, 'interior')
+      const title = await page.evaluate(() => window.tower.showScene('girdle', 10))
+      if (!title) throw new Error('no scene girdle in this world')
       await settle(page, 1400)
     },
   },
diff --git a/world-generation.md b/world-generation.md
index 2ba6bce..41160a9 100644
--- a/world-generation.md
+++ b/world-generation.md
@@ -1,7 +1,7 @@
 # Tower — Live World Generation
 
-**Status: IN PROGRESS.** Accepted by the author (D-062). **Phases 1–3 are built**
-(D-063 to D-066); phases 4–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–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
 D-006; see §11 for every existing decision it touches, and §15 for where Phase 1
 departed from the plan and why.
 
@@ -405,6 +405,8 @@ Hulls and sails are built procedurally (a lofted hull, a mast, one to three sail
 that are planes with a billow), a few hundred triangles each, instanced. Sails set
 to the sea's wind, so a ship on a ring route sailing against the wind tacks.
 
+*As built (§18): ring routes only, no harbours drawn, flat sails, and no tacking yet.*
+
 **Scale check.** A 40 m ship subtends 0.9 arc-minutes at 150 km, the edge of the haze
 horizon. At a 60° field on a 1,500 px-wide view one pixel is 2.4 arc-minutes, so it is
 under half a pixel: invisible to the naked eye there, a speck through the binoculars
@@ -583,6 +585,9 @@ still not drawn (D-049).
 pattern of a few scheduled storms per month on each cap sea, so the feature is visible
 before anyone authors an event.
 
+*As built (§18): the plan is written into `content/world.json` directly rather than by the
+port script, and deserts do not read it yet (Phase 5).*
+
 ---
 
 ## 5. Generation pipeline
@@ -753,6 +758,7 @@ The interior already has eye height to 25 km and the drag-the-ground gesture
   multiplies it by five. Climbing is a fixed factor per second — ×2 a second, ×7.4 with
   shift — since the same ×5 there would take 100 m to 40 km in a single second.
 - Terrain clearance: the eye never goes below ground + 1.7 m.
+- **Scenic tour** (§19): a scene every thirty seconds, flown by the view itself.
 - Eye height ceiling raised toward the axis (~45 km) so the whole world can be seen
   from the middle of the air, if wanted.
 
@@ -879,7 +885,7 @@ estimated at 2–3× the desktop time, 10–20 s, which the status dialog carrie
 
 ## 10. Editing
 
-Writer mode (`?writer=1`) gains a tool per concept:
+Writer mode (the default; `?reader=1` turns it off) gains a tool per concept:
 
 | Concept | Placing | Sub-tools |
 |---|---|---|
@@ -1019,7 +1025,7 @@ screenshot check before any commit.
 | 1 ✅ | **Foundation** | schema v2 + port script; status dialog; elevation function with ground + today's landforms reimplemented; worker pool; quadtree tile renderer on the cylinder; surface splatting; ground query; keyboard flight; map preview from the coarse field. Delete `tools/terrain.mjs`, the PNGs, `raster.ts`. **Parity checkpoint:** today's world, without the bitmap |
 | 2 ✅ | **Mountains** | ridge graph, erosion, peak editing |
 | 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 |
+| 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 |
 | 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 |
@@ -1126,7 +1132,8 @@ draws in about 1.2 s.
   diamond, and a line of them down every river.
 - **Waves**: `water.ts` gives JONSWAP fetch-limited seas capped at Pierson–Moskowitz, and
   the tests pin §4.2 and §4.4's printed figures. Lakes and seas have a `wind`; the editor
-  shows the sea it raises. Phase 4's weather plan replaces the steady wind.
+  shows the sea it raises. Phase 4 made that wind the baseline under Tower's weather plan
+  (§18).
 - **Rivers**: speed by Manning, foam by grade. Ripples are advected downstream in the
   shader by a two-phase flow map.
 - **Waterfalls come from the profile.** The least-earthwork fit pools reaches and steps
@@ -1157,3 +1164,96 @@ draws in about 1.2 s.
   caps each page at two workers: it had been compiling the world fifteen times, four at a
   time, with a full pool each, and the author's laptop ran hot for it.
 - **Cost**: 99–112 fps with water in view, 2.1–2.6 M triangles, on the author's laptop.
+
+---
+
+## 18. Phase 4, as built (D-067)
+
+- **The clock has a day.** The interior's clock is `day + time of day`, from day 0; the
+  panel has a Day field, running the clock turns the calendar over at midnight, and the
+  day and time are remembered across reloads. World time in hours is the one input the
+  weather and the ships need.
+- **The weather plan is data in the world file** (`weather: { cycleDays, events }`), and
+  `world/weather.ts` reads it as pure functions of the plan and the hour:
+  - **What Tower does** (`actualAt`): the event in force, as last revised *so far* — a
+    revision counts from the moment it was made, and only within the occurrence it was
+    made in. The wind rises over the event's first hour and falls over its last.
+  - **What people were told** (`inHarbour`): the event as published, revisions ignored.
+    Ships stay in harbour from three hours before a published gale or storm until it ends
+    — including a storm Tower has quietly called off. That is the point of keeping both.
+    A blow published with less than three hours' warning catches the fleet at sea, and it
+    rides the weather out (changed in D-068; the default plan has one such squall).
+  - **A repeating plan**: `cycleDays` repeats every event (28 in the default plan), so the
+    seas stay eventful without authoring a year of storms.
+  - **The sea lags the wind** (`seaWindAt`): waves answer an exponentially weighted memory
+    of the last six hours of wind (2 h e-folding), so a storm builds and a sea runs on
+    after it.
+- **Waves are no longer compiled.** Phase 3 baked each body's sea state into the compiled
+  world, which would have meant recompiling for every gust. Now a water vertex carries only
+  its body's index, `terrain/sea-state.ts` computes every body's waves from the weather a
+  few times a second, and the shader reads them from a small table. Wind and ship count are
+  out of the terrain signature, so editing them no longer regenerates anything.
+- **Whitecaps** cover the share of the sea Monahan and O'Muircheartaigh's fit gives for
+  the wind, `3.84e-6 · U^3.41` — about 2% at 9 m/s, 9% at 20, 29% at 28. They break on
+  two new short components (the wind's own sea on top of the swell); keyed to the whole
+  sum they lined up along a storm's 600 m swell crests and left the troughs bare.
+- **A precision fix, found through the storm.** The water shader measured position around
+  the ring as `atan(y, x) × 50 km`, which in float32 is good to about 6 m: anything finer
+  — whitecap noise, short waves, river ripples — smeared into streaks radiating from the
+  eye. Surface coordinates are now measured from a reference point near the eye that moves
+  in whole 2,457.6 m steps (256 cells of every noise scale used), and each wave adds back
+  its phase at the reference, computed in float64. Distant long waves also fade out
+  earlier (from 16 pixels of wavelength rather than 8), which removes a moiré of stripes a
+  storm swell drew near the horizon.
+- **Ships** (`terrain/ships.ts`): each ring sea has `ships` lanes (default 90), seeded from
+  the world and the sea. A ship's position is a pure function of the hour: a steady 3–6 m/s
+  round the ring, weaving gently in z, kept 4 km off either shore. Lengths run 8–45 m,
+  mostly small. The hull, mainsail, jib and mast are procedural and instanced — three draw
+  calls for every ship in the world; ships are drawn within 40 km. Each ship rides the same
+  wave sum the shader draws (a TypeScript port, tested against its own derivative), heaving
+  and pitching less the longer it is against the waves. The mainsail swings out running
+  before the wind and in toward the centreline heading into it.
+- **Not built yet:** tacking (a ship on a lane into the wind sails straight into it, sail
+  sheeted in), harbours (a ship in harbour is simply not on the water), ships on polygon
+  seas and lakes, and weather over deserts (Phase 5).
+- **The weather board** is a panel section in every view: upcoming occurrences for the
+  next fortnight, the one in force highlighted, revisions listed under their event with the
+  original struck through. Readers see only what has been published. Writers can schedule
+  an event (published the moment it is added), revise one from now with a reason, delete
+  one, and set the cycle. A selected lake or sea's readout shows its waves *now*.
+
+---
+
+## 19. The scenic tour, as built (D-068)
+
+Added between phases 4 and 5, at the author's request: once the world had particular
+things in it — a fall, a ship, a storm — standing still and dragging was a poor way to
+find them, and a screenshot at a fixed place was a poor way to show them.
+
+- **A scene is thirty seconds of one camera move**, worked out from the compiled world
+  (`views/scenic.ts`): a helicopter's arc, a bird's flight along a line, or a chase camera
+  on a ship. Scenes find their subjects — the tallest fall, the highest summit, the biggest
+  ship at sea, a published storm, a squall Tower gave no warning of — so they follow the
+  world as it is edited, and a scene whose subject is gone drops out of the tour.
+- **The scenes**, in order: circling the Long Water's 678 m fall from below its lip;
+  following a ship round the Sunward Sea; round Snowcrown; down the Long Water to its
+  167 m fall into The Deep; a ship caught out in a squall; through The Narrows; out from
+  the Capital over The Deep; the published storm on the Sunward Sea, with nobody out; and
+  nine kilometres over the Girdle.
+- **Flights hold a steady clearance** over the highest ground within 700 m, smoothed, so
+  the camera does not bob with every hill, and look at the ground a set distance ahead, so
+  the view tips down as land falls away. Orbits never drop below a clearance over the
+  ground. Aim is taken on the curved floor (`lookAt`): 6 km around, the floor has risen
+  360 m in the eye's frame.
+- **Each scene sets the clock** and runs it at real time: ships and weather are functions
+  of the hour, and the storm scenes need theirs. The observer's own clock comes back when
+  the tour stops; where they are does not, so stopping leaves them at the view they liked.
+- **Between scenes the view fades to black and waits for the ground** — up to four
+  seconds — so a scene opens on its terrain rather than on the coarse tiles of the moment.
+- **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
+  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.