fuzzyworld

tower / commit

feat(far-end): sea ice, the dim, frosted panes and ice mountains at the far cap (D-091..D-093)

e18dad04207a475f3429748d34ce73bc31a0b4a6 · Pavel Safronov · 2026-09-29 22:14:46 -0700 · parents 9f91e995a59b

Claude Code usage: this commit ~313.6K out / ~95.5M cached · cost unknown (no per-model trailer) · session 5 tally 776 in / 424.4K out / 102.8M cached · usage by commit and session

feat(far-end): sea ice, the dim, frosted panes and ice mountains at the far cap (D-091..D-093)

The Deepward Sea freezes in bands: open water to 512 km, pack ice as
110 m Voronoi floes to 525, fast ice beyond, and against the window the
Cap Range — rock Tower raised for the ice to stand on, buried in ice,
2–3 km up the glass. Tower's aimed light falls toward the ice and leaves
the fast ice the rod's dusk. The far cap frosts; Tower clears one of its
282 panes per turn, in a seeded order reshuffled every 35.1-hour loop.
Ships keep to the open water; the map whitens by ice cover.

Terrain workers that fail to start are replaced at once rather than
after 15 s: a script error is caught from the worker's error event, and
an empty script by staying silent after its siblings answered (D-092).

Usage: 776 in / 424.4K out / 102.8M cached (session tally)
rtk:   1.3M saved, 19.9% over 3180 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 README.md                                       |   6 +-
 content/world.json                              |  26 +-
 decisions.md                                    | 142 +++++++++
 design.md                                       |  49 +++-
 docs/screenshots/desktop-detail.png             | Bin 1290048 -> 1323509 bytes
 docs/screenshots/desktop-exterior-true.png      | Bin 549207 -> 666872 bytes
 docs/screenshots/desktop-exterior.png           | Bin 564157 -> 558794 bytes
 docs/screenshots/desktop-interior-morning.png   | Bin 681845 -> 539977 bytes
 docs/screenshots/desktop-interior-night.png     | Bin 509940 -> 573700 bytes
 docs/screenshots/desktop-interior-overhead.png  | Bin 822672 -> 842744 bytes
 docs/screenshots/desktop-interior-suncap.png    | Bin 524208 -> 534331 bytes
 docs/screenshots/desktop-interior.png           | Bin 1053645 -> 1032193 bytes
 docs/screenshots/desktop-map-sightline.png      | Bin 1106067 -> 1146750 bytes
 docs/screenshots/desktop-map.png                | Bin 1265897 -> 1278086 bytes
 docs/screenshots/desktop-tour-canopy.png        | Bin 910146 -> 909624 bytes
 docs/screenshots/desktop-tour-climb.png         | Bin 790888 -> 789013 bytes
 docs/screenshots/desktop-tour-deep.png          | Bin 641161 -> 640339 bytes
 docs/screenshots/desktop-tour-docking.png       | Bin 148229 -> 149060 bytes
 docs/screenshots/desktop-tour-falls-glide.png   | Bin 993069 -> 992517 bytes
 docs/screenshots/desktop-tour-falls.png         | Bin 1004114 -> 1003429 bytes
 docs/screenshots/desktop-tour-far-cap-peek.png  | Bin 879017 -> 1152600 bytes
 docs/screenshots/desktop-tour-far-cap.png       | Bin 449416 -> 890446 bytes
 docs/screenshots/desktop-tour-giants.png        | Bin 314944 -> 314314 bytes
 docs/screenshots/desktop-tour-girdle.png        | Bin 1043188 -> 1041976 bytes
 docs/screenshots/desktop-tour-grand.png         | Bin 265291 -> 256808 bytes
 docs/screenshots/desktop-tour-hull-skim.png     | Bin 142029 -> 141114 bytes
 docs/screenshots/desktop-tour-mesas.png         | Bin 945619 -> 945753 bytes
 docs/screenshots/desktop-tour-moonlight.png     | Bin 706100 -> 710474 bytes
 docs/screenshots/desktop-tour-ridge.png         | Bin 1087176 -> 1099430 bytes
 docs/screenshots/desktop-tour-ship.png          | Bin 665661 -> 589325 bytes
 docs/screenshots/desktop-tour-squall.png        | Bin 876900 -> 877665 bytes
 docs/screenshots/desktop-tour-summit.png        | Bin 1093972 -> 1093418 bytes
 docs/screenshots/desktop-tour-sun-circuit.png   | Bin 617454 -> 617415 bytes
 docs/screenshots/desktop-tour-thermal.png       | Bin 890883 -> 888123 bytes
 docs/screenshots/desktop-tour-train-narrows.png | Bin 902075 -> 908461 bytes
 docs/screenshots/desktop-tour-train-ring.png    | Bin 845135 -> 860546 bytes
 docs/screenshots/mobile-exterior.png            | Bin 123340 -> 123292 bytes
 docs/screenshots/mobile-interior.png            | Bin 314026 -> 284912 bytes
 docs/screenshots/mobile-map.png                 | Bin 346514 -> 339852 bytes
 docs/screenshots/mobile-panel.png               | Bin 98093 -> 98015 bytes
 docs/screenshots/overview.png                   | Bin 4169483 -> 4370928 bytes
 src/terrain/compile.ts                          |  17 +-
 src/terrain/field.ts                            |  64 +++-
 src/terrain/ice.ts                              | 127 ++++++++
 src/terrain/map-image.ts                        |  11 +-
 src/terrain/palette.ts                          |   5 +
 src/terrain/service.ts                          |  83 ++++--
 src/terrain/ships.ts                            |  17 +-
 src/terrain/tile.ts                             |  11 +-
 src/views/forest-view.ts                        |   2 +-
 src/views/interior-model.ts                     | 372 ++++++++++++++++++++++--
 src/views/interior.ts                           |   2 +-
 src/world/frost.ts                              | 147 ++++++++++
 src/world/schema.ts                             |  37 ++-
 src/world/sky.ts                                |  10 +
 test/far-end.test.ts                            | 195 +++++++++++++
 test/terrain.test.ts                            |   6 +-
 world-generation.md                             |  22 +-
 58 files changed, 1274 insertions(+), 77 deletions(-)

diff --git a/README.md b/README.md
index eaa8c5f..bfeb24c 100644
--- a/README.md
+++ b/README.md
@@ -413,10 +413,12 @@ the only rule anyone has for judging distance, read against sky.
 heat out as light: it soaks it up and radiates it to space from its outer face, 1–2 TW over
 the whole cap. So the window is a cold wall, with a cold wind running down it onto the
 Deepward Sea, and Tower keeps that end dim and lets it freeze: open water at freezing, then
-pack ice, then fast ice frozen to the foot of the cap, dusk by day and dark by night. The
+pack ice, then fast ice, dusk by day and dark by night — and against the window **ice
+mountains**, the Cap Range, 2 to 3 km high: rock Tower raised at the cap's foot for the ice
+to stand on, since ice alone spreads out before it gets higher than a few hundred metres. The
 glass frosts too, and Tower clears it one pane per turn of the world, in an order reshuffled
 every 35.1-hour loop, so close to the window is white with a scatter of clear, starry
-triangles. None of this is drawn yet. Tower can do this because it controls how much heat
+triangles — from inside and from outside. Tower can do this because it controls how much heat
 the hull loses where, up to about 5 TW (design.md §1.9.4, §1.9.6; D-090). The sun cap is
 double-glazed and does none of this: the Sunward Sea is a temperate one.
 
diff --git a/content/world.json b/content/world.json
index 3ac16ee..857f737 100644
--- a/content/world.json
+++ b/content/world.json
@@ -35,7 +35,7 @@
       "id": "deepward-sea",
       "name": "The Deepward Sea",
       "blurb": "",
-      "notes": "The same at the far cap, and the larger of the two. A 314 km shoreline with a 50 km cliff behind it, and the only windows in the world at its foot (§1.4). The window is a cold wall, so this is a polar sea: ice-cold throughout, open to z 512 km, pack ice to 525, and dim fast ice frozen to the cap beyond (§1.9.6, D-090). Not drawn yet.",
+      "notes": "The same at the far cap, and the larger of the two. A 314 km shoreline with a 50 km cliff behind it, and the only windows in the world at its foot (§1.4). The window is a cold wall, so this is a polar sea: ice-cold throughout, open to z 512 km, pack ice to 525, and dim fast ice beyond, against the ice-buried Cap Range that stands against the window (§1.9.6, D-090, D-093).",
       "public": true,
       "seed": 1346489887,
       "kind": "sea",
@@ -46,7 +46,29 @@
       },
       "levelM": 90,
       "depthM": 420,
-      "shoreM": 16000
+      "shoreM": 16000,
+      "ice": {
+        "edgeM": 512000,
+        "fastM": 525000,
+        "range": {
+          "fromM": 524000,
+          "heightM": 2400
+        }
+      }
+    },
+    {
+      "id": "the-cap-range",
+      "name": "The Cap Range",
+      "blurb": "",
+      "notes": "D-093. Provisional name. A ring of rock Tower raised at the foot of the far cap for the ice to stand on, rising from the Deepward Sea's bed to about 2,400 m against the window and buried in ice 350 m thick; its sharpest ridges stand out of the ice as bare rock. Grounded ice alone could not stand more than a few hundred metres here. Fed by snow off the window wind and by the meltwater of every pane Tower clears. The ice meets the fast ice in a low front where the rock leaves the water, about 528.5 km, and climbs 2 to 3 km up the window. Terrain comes from the Deepward Sea's ice.range; this feature is its name.",
+      "public": true,
+      "seed": 1602214957,
+      "kind": "region",
+      "geometry": {
+        "type": "band",
+        "zFrom": 528500,
+        "zTo": 540000
+      }
     },
     {
       "id": "sun-cap",
diff --git a/decisions.md b/decisions.md
index ec19cc2..31574ff 100644
--- a/decisions.md
+++ b/decisions.md
@@ -3053,3 +3053,145 @@ guess.
 all the same things — radiated, frosted, and sent a cold wind onto the Sunward Sea. Double, its
 inner pane stays near the air's temperature, so the Sunward Sea stays temperate and the far end
 is the world's only cold place.
+
+## D-091 — Drawing the far end: sea ice, the dim, and the frosted panes
+
+*Builds D-090.* Asked for by the author, 2026-09-29: "please commit and implement". Also
+recorded here: the author chose **double glazing for the sun cap** (D-090), so the Sunward
+Sea has no ice and needs nothing drawn.
+
+**The ice is in the world file.** A sea may carry `ice: { edgeM, fastM }` — the ice edge and
+where the fast ice begins, as z — and the Deepward Sea does: 512 and 525 km. The fast ice runs
+on from `fastM` to the end of the sea beyond it, whichever end that is. It is part of the
+terrain signature, so changing it rebuilds the tiles, as it must: every water sample carries
+its ice cover (`terrain/ice.ts`, `field.ts seaIce`).
+
+- **Cover.** None on the open water; the pack closes from 0.3 at its edge to 0.95 at the fast
+  ice; 1 on the fast ice. The edge wanders 1.5 km either way by noise and the fast ice's 400 m,
+  so neither runs ruler-straight round the world.
+- **Floes.** The water shader breaks the pack into Voronoi cells about 110 m across — straight
+  edges at odd angles, which is how ice breaks — each ice or open by its own hash against the
+  cover. Floes stop short of open neighbours, opening leads up to 12 m wide where the pack is
+  loose, and meet ice neighbours in a dark crack. The fast ice is one sheet, cracked in cells
+  of 660 m. Floes are big enough to need no surface reference: absolute metres are good to
+  centimetres in float32, the grid around is a whole number of cells, and the hash is a
+  permutation mod 289, exact in float. Far off, the sea whitens by its share of ice.
+- **Ice stills the sea.** Waves die away under the pack and are gone under the fast ice.
+- **Ships keep to the open water**, their lanes 2 km short of the ice edge.
+- **The map** whitens the water by the cover, and the exterior's x-ray, which is the map,
+  with it.
+
+**The dim.** Tower's aimed light — the sun's and the moon's — falls from the open water's
+shore (the sea's end away from the ice) to 0.35 at the ice edge, 0.1 at the fast ice, and
+nothing on it. The rod is never aimed, so by night nothing changes but the moon, and by day
+the fast ice keeps the rod's glow, taken as a twentieth of the sun's light (`ROD_GLOW_BY_DAY`
+— tens of W/m² against about a thousand). Each lit surface is multiplied by its share **put
+through the same 0.35 power the exposure gives the light dial**: an eye standing in the dim
+has adapted to it, and at a straight twentieth the fast ice drew as a black hole. So it reads
+as dusk, a third as bright as elsewhere. The stars go by the light where the eye is, so from
+the fast ice the brightest of them show at dusk.
+
+**The frost** (`world/frost.ts`). The window's 282 panes are named by lattice cell and half,
+the same triangles the mullions draw. Tower clears one per turn of the world (7 min 29 s) in
+an order shuffled by the world's seed and the loop's number, reshuffled every 35.1-hour loop;
+a cleared pane frosts over again as 1 − e^(−age / 10 h). Ten hours is chosen to leave about one
+pane in ten nearly clear, §1.9.6's guess — the frost rate is rough to a factor of ten, and this
+is the knob for it. The pane being cleared thaws across its turn. The clock writes the frost
+into a 32 × 32 table the far cap's shader reads, so both views, inside and out, show the same
+panes clear at the same moment. Frost is feathered rather than a grey sheet, and lit by the
+light at the cap, which is the fast ice's dusk.
+
+**Left open** (design.md §5): the sun is drawn as bright from the fast ice as from anywhere,
+though Tower aims none of its light there.
+
+**Tests** (`test/far-end.test.ts`): the bands and their cover, a sea with its ice the other
+way round, ships clear of the ice, the dim by day and by night, 282 panes (216 whole) found
+from points inside them, a loop of 35.1 hours, every pane cleared once a loop in a reshuffled
+order, a pane thawing across its turn, no jump between loops, and about one pane in ten clear.
+
+## D-092 — A dead terrain worker is replaced at once, not after 15 s
+
+*Amends D-080.*
+
+**Reported** (author, 2026-09-29): "every so often, the page freezes on the loading screen
+and there are errors in the console. a reload fixes it, or just waiting." The console showed
+Firefox's "Attempting to create a Worker from an empty source" many times, and three workers
+failing with an `error` event.
+
+**What it was.** D-080 made a silent worker survivable but slow: it was given 15 s to answer
+its ping before being replaced, so a load with a dead worker sat on "Reading the plans" for
+15 s or more — the "just waiting". Workers die two ways, and both were waiting out that
+deadline:
+
+- **A script that throws or fails to load** fires the worker's `error` event. That was only
+  logged; the ping, which comlink never settles for a dead worker, went on waiting.
+- **An empty script** — the "empty source" — fires nothing. Firefox starts it, it runs
+  nothing, and it never answers.
+
+Why Firefox is handed an empty or broken script is still not pinned down. It was not
+reproduced by fifteen plain reloads, nor by changing files mid-load. The likeliest cause is
+the dev server answering while it rebuilds after an edit: the author reported it while files
+were being edited under an open page. So this is a cure, not a prevention.
+
+**What changed** (`src/terrain/service.ts`):
+
+- The start-up ping is raced against the worker's `error` event, so a script that failed is
+  replaced the moment it says so.
+- A silent worker is judged **against its siblings**. They load the same modules at the same
+  time, so once one has answered, a worker still silent after that sibling's own load time
+  again, and never less than 3 s, is taken to be empty rather than slow. Only a pool with no
+  worker alive at all still waits out the 15 s.
+- A dead worker is replaced up to four times, 0.4 s, 0.8 s and 1.2 s apart, rather than once.
+  Each failure is a `console.warn` naming the worker, and a worker that came back says which
+  try it started on.
+
+**Checked in Firefox**, with the worker's script replaced by Playwright for the first few
+requests:
+
+| Sabotage | Before | After |
+|---|---|---|
+| none | 7.4 s | 7.4 s |
+| three scripts that throw | 22.8 s | **7.7 s** — no delay |
+| three empty scripts | 22.3 s | **11.1 s** |
+| all six empty, twice over | — | 38.6 s, and all six start on the third try |
+
+## D-093 — Ice mountains at the far cap: the Cap Range
+
+**Asked for** (author, 2026-09-29): "the ice at the far cap should slope up, since the
+snow/ice would build up closer to the window. i'm imagining ice mountains that go a bit up
+the window, even."
+
+**Why ice alone was not enough.** The ice is thickest against the window — snow off the
+window wind and the meltwater of every cleared pane (D-090) come down at the cap's foot — so
+it should rise toward the glass. But grounded ice spreads under its own weight until the
+stress at its bed is what the bed will take, which caps it at the plastic profile of an ice
+sheet, H² = H0² + (2τ/ρg)·x. With τ = 150 kPa, the top of what ice sheets show, it has to
+fill 420 m of sea before any of it stands out, and over 8 km it reached about 280 m above the
+water: built and drawn, it read from the pack as a pale strip under 1° high at the foot of a
+50 km window. Three ways on were offered — keep that, raise the sea bed (500–700 m, still a
+kerb), or a rock range under the ice. **Chosen by the author: the range.**
+
+**What it is.** The **Cap Range** (provisional name): rock Tower raised at the foot of the far
+cap for the ice to stand on, as it raised the Uplands (D-064). It is part of the Deepward
+Sea's ice in the world file — `ice.range: { fromM: 524000, heightM: 2400 }` — and a label
+feature, a region band from 528.5 km to the cap, carries its name.
+
+- **Rock** rises from the sea bed at 524 km to about 2,400 m at the cap, as t^1.6 so it
+  steepens toward the window; ±30% round the ring, in massifs and saddles 30 km across; and
+  ridged by up to 0.7 of its height, so it is a range and not a ramp.
+- **Ice** mantles it from where the rock leaves the water (about 528.5 km), in a front of a
+  few tens of metres over the fast ice, thickening as an ice sheet does to 350 m. It drapes
+  the ridges (60% of their relief) rather than burying them level; where a crest stands out
+  of it, it is bare rock.
+- **At the window** it stands 2.2 to 3.3 km up the glass. The far cap's shader buries the
+  glass and frame in ice to the height the ground stands at the cap, read off the terrain
+  into a strip round the ring; the kilometre of bore between the world's end and the glass
+  is ice too.
+- **The sea no longer meets the cap.** The fast ice now runs 525 to 528.5 km and ends
+  against the range. The Deepward Sea's band in the world file still runs to 540 km: the
+  range stands in it.
+
+**Tests** (`test/far-end.test.ts`): the rock from bed to height, steepening; the ice's margin
+at the waterline and its mantle thickening to 350 m; and on the compiled world, all round the
+ring, dry ground 1.5–4 km high at the cap that varies, fast ice at 526 km, open water at
+505. `test/terrain.test.ts` counts the new feature and probes the Deepward Sea short of it.
diff --git a/design.md b/design.md
index 88980b9..42dedf0 100644
--- a/design.md
+++ b/design.md
@@ -638,16 +638,31 @@ the foot of the cap is where the physics was going anyway.
 |---|---|---|---|
 | 496–512 | **open water** | grey sea at about freezing, with sea smoke where the cold wind crosses it | dark |
 | 512–525 | **pack ice** | floes made at the fast ice's edge, carried sunward by the wind, melting where they meet open water | dark |
-| 525–540 | **fast ice** | dusk — the rod's glow and nothing aimed | night; the sun goes out overhead at dusk |
-| 540 | **the window** | white frost with a scatter of clear panes | stars through the clear panes |
+| 525–528.5 | **fast ice** | dusk — the rod's glow and nothing aimed | night; the sun goes out overhead at dusk |
+| 528.5–540 | **the Cap Range** | ice mountains, 2–3 km high against the glass, a few crests bare rock | the same, dark |
+| 540 | **the window** | white frost with a scatter of clear panes, its foot buried in ice | stars through the clear panes |
 
 - **The dark is dusk by day, not darkness.** The rod's own glow cannot be aimed away
   (§1.3.2) and runs on past the sun to the cap (D-079), so the fast ice gets tens of W/m²
   by day — a permanent overcast dusk — and real night only by night. The sun still goes
   all the way to the cap (§1.3.4), so once a day it goes out directly over the ice, and
   the moon sets out from the same place.
+- **Ice mountains stand against the window** (D-093). Snow off the window wind and the
+  meltwater of every cleared pane come down at the cap's foot and freeze there, so the ice
+  is thickest against the glass. But grounded ice cannot stand much higher than a few
+  hundred metres over a few kilometres — it spreads under its own weight until the stress
+  at its bed is what the bed will take, about 150 kPa for cold ice frozen down — and on a
+  420 m sea that is a kerb: 280 m against a 50 km window. So **Tower raised a range there
+  for the ice to stand on**, as it raised the Uplands (D-064): the **Cap Range**, rock rising
+  from the sea bed at 524 km to about 2,400 m at the cap, higher in some places round the
+  ring than others. Ice mantles it from where the rock leaves the water, about 528.5 km, in a
+  low front over the fast ice, thickening as an ice sheet does to 350 m on the heights, and
+  it drapes the ridges rather than burying them level, so the range reads as mountains and
+  its sharpest crests stand out as bare rock. Against the glass it stands **2 to 3 km up the
+  window**, and the window's foot is buried in it. The sea no longer meets the cap: the
+  fast ice ends against the range.
 - **The fast ice holds.** The window wind pushes on it with about 1,500 N per metre of
-  cliff; 3 m of ice frozen to the cap holds some 3 MN. **Tower has no tides** — its moon
+  shore; 3 m of ice frozen to the range's foot holds some 3 MN. **Tower has no tides** — its moon
   is light (§1.3.5) — so the thing that breaks Earth's fast ice out is absent. Storm swell
   is the threat to it, and the Deepward Sea is on the weather plan.
 - **The whole sea is ice-cold, not only the ice.** With no Coriolis in the plane of the
@@ -684,6 +699,16 @@ GW, and the meltwater runs down the cap onto the ice.
   with a scatter of black, starry triangles, perhaps one in ten; how long a pane stays
   clear rests on the frost rate, which is rough to a factor of ten.
 
+**As drawn** (D-091). The Deepward Sea's `ice` in the world file puts the bands where the
+table has them, and the water shader draws the pack as floes and leads and the fast ice as
+one cracked sheet. Tower's aimed light falls from the shore to about a third at the ice
+edge, a tenth at the fast ice and nothing on it, which leaves the fast ice the rod's
+twentieth by day; the views show that through the same 0.35 power the exposure gives the
+light dial, so the fast ice reads as dusk, not black. The frost closes a cleared pane as
+1 − e^(−age / 10 h), which leaves about one pane in ten nearly clear, and the pane being
+cleared thaws across its turn. Both views share the window, so the frost is the same from
+outside.
+
 ### 1.10 Slat orientation — RESOLVED: none
 
 Axial strips versus rings was weighed at length (D-016) and answered by removing
@@ -709,7 +734,8 @@ along the axis**, which is the natural grain of an engineered climate (§1.9.4):
 | 214–430 km | **Greatwood** | forest belt: about three billion trees, with meadows Tower left open in it (D-071) |
 | 248–402 km | **The Deep** | 154 × 32 km sea, long down the axis and narrow around |
 | 270 km | **The Capital / The Spindle** | axial midpoint; 20 km across, its spinward edge on The Deep's shore (D-070) |
-| 496–540 km | **The Deepward Sea** | ring ocean at the foot of the far cap: open water to 512 km, pack ice to 525, dim fast ice to the cap (§1.9.6, D-090) |
+| 496–540 km | **The Deepward Sea** | ring ocean at the foot of the far cap: open water to 512 km, pack ice to 525, dim fast ice to the Cap Range (§1.9.6, D-090) |
+| 528.5–540 km | **The Cap Range** | ice mountains Tower raised against the far cap's window, 2–3 km high (D-093) |
 | 110–480 km | **The Axial Line** | railway down the length of the world, through The Narrows (D-086) |
 | 140 km | **The Uplands Ring** | railway round the world across the Uplands (D-086) |
 
@@ -845,9 +871,10 @@ puts Sisters Tarn at 2,173 m.
 - **Three.js** for the exterior and interior 3D views.
 - **Canvas 2D** for the map view.
 - **Web workers** for world generation (world-generation.md §5): a pool of up to six,
-  driven through **comlink**. Each must answer a ping before it is given the world, and
-  one that does not is replaced once, then dropped, so a dead worker costs seconds rather
-  than freezing the load (D-080). Noise is **simplex-noise**, sampled in 3D on the
+  driven through **comlink**. Each must answer a ping before it is given the world. One
+  whose script fails, or that is still silent a few seconds after its siblings answered,
+  is replaced at once, up to four tries, then dropped, so a dead worker costs a few
+  seconds rather than freezing the load (D-080, D-092). Noise is **simplex-noise**, sampled in 3D on the
   cylinder itself so nothing tears at θ = 0. Those two are the only runtime
   dependencies besides Three.js.
 - **GitLab CI → GitLab Pages**, publishing `dist/` as `static/`.
@@ -1358,6 +1385,8 @@ Things decided to be worth coming back to, not scheduled.
 - **The moon's light** (D-088). It is drawn as fed by Tower lifting the banked beam, so the rod
   is the same either side of it; the sun instead takes its share from the rod and leaves it
   dimmer past it (D-079). Canon could have the moon do the same, or settle why it does not.
-- **Drawing the far end** (D-090). The Deepward Sea's ice bands and the far cap's frost
-  and pane clearing are canon but not drawn. The world file has no kind for sea ice yet,
-  and the panes need the seeded shuffle of §1.9.6.
+- **The sun seen from the ice** (D-091). Tower aims none of the sun's light at the fast
+  ice, so by §1.3.1's own rule the sun should look dim from there — a shoal lit toward
+  everyone else. It is drawn as bright as from anywhere. Whether the sun going out over the
+  ice each evening is seen from the ice as a blaze or as a dark disc in lit air is the
+  author's call.
diff --git a/docs/screenshots/desktop-detail.png b/docs/screenshots/desktop-detail.png
index fded338..1f2a0fd 100644
Binary files a/docs/screenshots/desktop-detail.png and b/docs/screenshots/desktop-detail.png differ
diff --git a/docs/screenshots/desktop-exterior-true.png b/docs/screenshots/desktop-exterior-true.png
index 2b1f072..2fad180 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 d677705..f710f40 100644
Binary files a/docs/screenshots/desktop-exterior.png and b/docs/screenshots/desktop-exterior.png differ
diff --git a/docs/screenshots/desktop-interior-morning.png b/docs/screenshots/desktop-interior-morning.png
index b9cdcee..43bc8e4 100644
Binary files a/docs/screenshots/desktop-interior-morning.png and b/docs/screenshots/desktop-interior-morning.png differ
diff --git a/docs/screenshots/desktop-interior-night.png b/docs/screenshots/desktop-interior-night.png
index c96000f..0c449b9 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 3ba21bb..8cbb934 100644
Binary files a/docs/screenshots/desktop-interior-overhead.png and b/docs/screenshots/desktop-interior-overhead.png differ
diff --git a/docs/screenshots/desktop-interior-suncap.png b/docs/screenshots/desktop-interior-suncap.png
index 2e8a0b1..0506a6f 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 5c21ce4..87c2820 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 ae17180..96f8c2f 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 c5eaa7d..6edd7af 100644
Binary files a/docs/screenshots/desktop-map.png and b/docs/screenshots/desktop-map.png differ
diff --git a/docs/screenshots/desktop-tour-canopy.png b/docs/screenshots/desktop-tour-canopy.png
index a720a53..f628a0b 100644
Binary files a/docs/screenshots/desktop-tour-canopy.png and b/docs/screenshots/desktop-tour-canopy.png differ
diff --git a/docs/screenshots/desktop-tour-climb.png b/docs/screenshots/desktop-tour-climb.png
index c087bfa..72dead3 100644
Binary files a/docs/screenshots/desktop-tour-climb.png and b/docs/screenshots/desktop-tour-climb.png differ
diff --git a/docs/screenshots/desktop-tour-deep.png b/docs/screenshots/desktop-tour-deep.png
index 667d9c4..0c8de0e 100644
Binary files a/docs/screenshots/desktop-tour-deep.png and b/docs/screenshots/desktop-tour-deep.png differ
diff --git a/docs/screenshots/desktop-tour-docking.png b/docs/screenshots/desktop-tour-docking.png
index 6a98485..1398995 100644
Binary files a/docs/screenshots/desktop-tour-docking.png and b/docs/screenshots/desktop-tour-docking.png differ
diff --git a/docs/screenshots/desktop-tour-falls-glide.png b/docs/screenshots/desktop-tour-falls-glide.png
index ea101db..77fb670 100644
Binary files a/docs/screenshots/desktop-tour-falls-glide.png and b/docs/screenshots/desktop-tour-falls-glide.png differ
diff --git a/docs/screenshots/desktop-tour-falls.png b/docs/screenshots/desktop-tour-falls.png
index 8d3e7c4..332cb31 100644
Binary files a/docs/screenshots/desktop-tour-falls.png and b/docs/screenshots/desktop-tour-falls.png differ
diff --git a/docs/screenshots/desktop-tour-far-cap-peek.png b/docs/screenshots/desktop-tour-far-cap-peek.png
index 8526ccf..79032f3 100644
Binary files a/docs/screenshots/desktop-tour-far-cap-peek.png and b/docs/screenshots/desktop-tour-far-cap-peek.png differ
diff --git a/docs/screenshots/desktop-tour-far-cap.png b/docs/screenshots/desktop-tour-far-cap.png
index d623dc7..7f30f81 100644
Binary files a/docs/screenshots/desktop-tour-far-cap.png and b/docs/screenshots/desktop-tour-far-cap.png differ
diff --git a/docs/screenshots/desktop-tour-giants.png b/docs/screenshots/desktop-tour-giants.png
index 9f213bb..ea33a1b 100644
Binary files a/docs/screenshots/desktop-tour-giants.png and b/docs/screenshots/desktop-tour-giants.png differ
diff --git a/docs/screenshots/desktop-tour-girdle.png b/docs/screenshots/desktop-tour-girdle.png
index 78444e5..0dd695c 100644
Binary files a/docs/screenshots/desktop-tour-girdle.png and b/docs/screenshots/desktop-tour-girdle.png differ
diff --git a/docs/screenshots/desktop-tour-grand.png b/docs/screenshots/desktop-tour-grand.png
index 58ec769..5168357 100644
Binary files a/docs/screenshots/desktop-tour-grand.png and b/docs/screenshots/desktop-tour-grand.png differ
diff --git a/docs/screenshots/desktop-tour-hull-skim.png b/docs/screenshots/desktop-tour-hull-skim.png
index 69e65cf..8a5a5c4 100644
Binary files a/docs/screenshots/desktop-tour-hull-skim.png and b/docs/screenshots/desktop-tour-hull-skim.png differ
diff --git a/docs/screenshots/desktop-tour-mesas.png b/docs/screenshots/desktop-tour-mesas.png
index 8967fb7..dcc57aa 100644
Binary files a/docs/screenshots/desktop-tour-mesas.png and b/docs/screenshots/desktop-tour-mesas.png differ
diff --git a/docs/screenshots/desktop-tour-moonlight.png b/docs/screenshots/desktop-tour-moonlight.png
index 2d3faad..15a1ecc 100644
Binary files a/docs/screenshots/desktop-tour-moonlight.png and b/docs/screenshots/desktop-tour-moonlight.png differ
diff --git a/docs/screenshots/desktop-tour-ridge.png b/docs/screenshots/desktop-tour-ridge.png
index 6de2231..e86a0c4 100644
Binary files a/docs/screenshots/desktop-tour-ridge.png and b/docs/screenshots/desktop-tour-ridge.png differ
diff --git a/docs/screenshots/desktop-tour-ship.png b/docs/screenshots/desktop-tour-ship.png
index 60d1182..3d0b206 100644
Binary files a/docs/screenshots/desktop-tour-ship.png and b/docs/screenshots/desktop-tour-ship.png differ
diff --git a/docs/screenshots/desktop-tour-squall.png b/docs/screenshots/desktop-tour-squall.png
index 809b890..4088d79 100644
Binary files a/docs/screenshots/desktop-tour-squall.png and b/docs/screenshots/desktop-tour-squall.png differ
diff --git a/docs/screenshots/desktop-tour-summit.png b/docs/screenshots/desktop-tour-summit.png
index 425c8b8..1d558ad 100644
Binary files a/docs/screenshots/desktop-tour-summit.png and b/docs/screenshots/desktop-tour-summit.png differ
diff --git a/docs/screenshots/desktop-tour-sun-circuit.png b/docs/screenshots/desktop-tour-sun-circuit.png
index a47143d..359145a 100644
Binary files a/docs/screenshots/desktop-tour-sun-circuit.png and b/docs/screenshots/desktop-tour-sun-circuit.png differ
diff --git a/docs/screenshots/desktop-tour-thermal.png b/docs/screenshots/desktop-tour-thermal.png
index f770b85..6b0cc5b 100644
Binary files a/docs/screenshots/desktop-tour-thermal.png and b/docs/screenshots/desktop-tour-thermal.png differ
diff --git a/docs/screenshots/desktop-tour-train-narrows.png b/docs/screenshots/desktop-tour-train-narrows.png
index 5ad486f..d71cb11 100644
Binary files a/docs/screenshots/desktop-tour-train-narrows.png and b/docs/screenshots/desktop-tour-train-narrows.png differ
diff --git a/docs/screenshots/desktop-tour-train-ring.png b/docs/screenshots/desktop-tour-train-ring.png
index 65c145d..e96b5c7 100644
Binary files a/docs/screenshots/desktop-tour-train-ring.png and b/docs/screenshots/desktop-tour-train-ring.png differ
diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png
index f3ce31f..f3cf840 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 411a5c9..a983ce6 100644
Binary files a/docs/screenshots/mobile-interior.png and b/docs/screenshots/mobile-interior.png differ
diff --git a/docs/screenshots/mobile-map.png b/docs/screenshots/mobile-map.png
index 19eb113..a5025e8 100644
Binary files a/docs/screenshots/mobile-map.png and b/docs/screenshots/mobile-map.png differ
diff --git a/docs/screenshots/mobile-panel.png b/docs/screenshots/mobile-panel.png
index fac8fcc..a7c0ca4 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 cc00d81..de3a9bc 100644
Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ
diff --git a/src/terrain/compile.ts b/src/terrain/compile.ts
index 70006ef..03c2033 100644
--- a/src/terrain/compile.ts
+++ b/src/terrain/compile.ts
@@ -14,13 +14,14 @@
  */
 import { RADIUS_M } from '../canon/constants.js'
 import type {
-  Desert as DesertFeature, Feature, Forest as ForestFeature, Lake, Mountains, River, Sea, World,
+  Desert as DesertFeature, Feature, Forest as ForestFeature, Lake, Mountains, River, Sea, SeaIce, World,
 } from '../world/schema.js'
 import { type CMountainGroup, compileMountains } from './mountains.js'
 import { type CDesert, compileDeserts } from './desert.js'
 import { type CForest, compileForests } from './forest.js'
 import { FALL_MIN_M, foamOf, manningSpeed } from './water.js'
 import { hash3 } from './noise.js'
+import { iceSide } from './ice.js'
 import {
   type Box, type Line, type Shape, nearestA, shapeBox, shapeOf, signedDistance, sliceLine, smoothLine,
 } from './shapes.js'
@@ -58,6 +59,14 @@ export interface CBasin {
   index: number
   seed: number
   box: Box
+  /** A sea's ice, or null (D-090). */
+  ice: SeaIce | null
+  /**
+   * Where the open water begins on the far side from the ice, z in metres: where Tower's
+   * aimed light begins to fall toward it (ice.ts `aimedShare`). A band sea's end away from
+   * the ice; anything else has no open-water dim, and this is the ice edge.
+   */
+  openM: number
 }
 
 /** A waterfall: where a river's surface steps down, and by how much. */
@@ -243,6 +252,12 @@ function compileBasin(f: Lake | Sea, seed: number, cw: CompiledWorld): CBasin |
     index: cw.basins.length,
     seed,
     box: grow(shapeBox(shape), flooded ? f.shoreM + 8000 : 50),
+    ice: f.kind === 'sea' ? f.ice : null,
+    openM: 0,
+  }
+  if (b.ice) {
+    b.openM = shape.type !== 'band' ? b.ice.edgeM
+      : iceSide(b.ice) > 0 ? shape.zFrom : shape.zTo
   }
 
   const level = f.levelM
diff --git a/src/terrain/field.ts b/src/terrain/field.ts
index 7f848ae..d44debd 100644
--- a/src/terrain/field.ts
+++ b/src/terrain/field.ts
@@ -25,6 +25,7 @@ import { BENCH_STEP_M, type CDesert, hillsAt, sampleDesert, terrace } from './de
 import { type CForest, canopyAt, stemsAt } from './forest.js'
 import { type CMountainGroup, pinKernel, sampleGrid } from './mountains.js'
 import { type CylPoint, cylPoint, fbm, hash3, ridged } from './noise.js'
+import { iceCover, iceMantle, iceMargin, iceSide, rangeRock } from './ice.js'
 import { type Box, LineIndex, type Nearest, nearestA, nearestOnSegments, signedDistance } from './shapes.js'
 
 export const Stage = {
@@ -62,6 +63,10 @@ export interface Sample {
   flowZ: number
   flowA: number
   foam: number
+  /** How much of a sea's surface here is ice, 0..1: pack ice below 1, fast ice at 1 (D-090). */
+  ice: number
+  /** Grounded ice standing out of the sea here, 0..1: the ice wall at the cap (D-093). */
+  glacier: number
   /** 0..1 claims on the surface. The shader turns these into colour. */
   desert: number
   forest: number
@@ -116,7 +121,7 @@ export class Runtime {
   private indexes: LineIndex[]
   private out: Sample = {
     h: 0, water: -Infinity, waterCover: 0, waterMesh: false,
-    body: -1, flowZ: 0, flowA: 0, foam: 0,
+    body: -1, flowZ: 0, flowA: 0, foam: 0, ice: 0, glacier: 0,
     desert: 0, forest: 0, relief: 0, rock: 0, salt: 0, stems: 0, canopyM: 0, snowlineM: 3000,
   }
   private nearest: Nearest = { d: 0, seg: 0, t: 0 }
@@ -141,6 +146,8 @@ export class Runtime {
     o.flowZ = 0
     o.flowA = 0
     o.foam = 0
+    o.ice = 0
+    o.glacier = 0
     o.desert = 0
     o.forest = 0
     o.relief = 0
@@ -404,6 +411,55 @@ function setBasinWater(b: CBasin, o: Sample): void {
   o.flowZ = 0
   o.flowA = 0
   o.foam = 0
+  o.ice = 0
+}
+
+/**
+ * The ice mountains at the foot of the cap (ice.ts, D-093): the range Tower raised, and the
+ * ice buried in it. The rock is ridged — a range, not a ramp — and ridged harder the higher it
+ * stands, so round the world the heights vary by a kilometre and more; the ice lies smoother,
+ * and the sharpest ridges stand out of it as bare rock.
+ */
+function glacier(b: CBasin, p: CylPoint, z: number, detailM: number, o: Sample): void {
+  const range = b.ice!.range
+  if (!range || b.shape.type !== 'band') return
+  const side = iceSide(b.ice!)
+  const cap = side > 0 ? b.shape.zTo : b.shape.zFrom
+  const span = Math.abs(cap - range.fromM)
+  const t = side > 0 ? (z - range.fromM) / span : (range.fromM - z) / span
+  if (t <= 0) return
+  const bed = o.h
+  // The range stands higher in some places round the ring than others: massifs and saddles.
+  const height = range.heightM * (1 + 0.3 * fbm(layer(b.seed, 97), p, 30_000, 2, detailM))
+  const rock = rangeRock(t, bed, height)
+  const up = Math.max(0, rock - b.levelM)
+  const ridge = (ridged(layer(b.seed, 96), p, 4000, 4, detailM) - 0.45) * 1.4 * up
+  const rockTop = rock + ridge
+  /**
+   * The ice, from where the rock leaves the water. It drapes the ridges rather than burying
+   * them level — ice on a range follows the rock, thinner over the crests — so the range
+   * still reads as mountains through it, and the sharpest crests stand out bare.
+   */
+  const margin = iceMargin(b.levelM, bed, height)
+  const ice = t > margin ? rock + 0.6 * ridge + iceMantle((t - margin) * span) : -Infinity
+  o.h = Math.max(rockTop, ice, o.h)
+  // White where the ice is over the rock, bare where a ridge stands out of it.
+  o.glacier = ice >= rockTop ? 1 : ice > rockTop - 60 ? (ice - rockTop + 60) / 60 : 0
+  o.relief = Math.max(o.relief, up * (1 - o.glacier))
+}
+
+/**
+ * A sea's ice here (ice.ts). The bands are rings, but no ice edge runs ruler-straight round
+ * a world: the pack's edge wanders a kilometre and a half either way, where the wind and the
+ * water have left it, and the fast ice's, cracked off along one line, a few hundred metres.
+ */
+function seaIce(b: CBasin, p: CylPoint, z: number, detailM: number, o: Sample): void {
+  if (o.body !== b.index) return
+  const ice = b.ice!
+  const side = iceSide(ice)
+  const edge = ice.edgeM + side * fbm(layer(b.seed, 93), p, 9000, 3, detailM) * 1500
+  const fast = ice.fastM + side * fbm(layer(b.seed, 94), p, 5000, 2, detailM) * 400
+  o.ice = iceCover((z - edge) / (fast - edge))
 }
 
 /** Carve a lake or sea basin and set its water where the floor is below its level. */
@@ -422,7 +478,11 @@ function basin(b: CBasin, p: CylPoint, z: number, a: number, detailM: number, o:
       : smoothstep(0, b.shoreM, sd)
     if (m <= 0.001) return
     o.h -= b.depthM * m ** 0.7
-    if (m > 0.05 && o.h < b.levelM) setBasinWater(b, o)
+    if (b.ice) glacier(b, p, z, detailM, o)
+    if (m > 0.05 && o.h < b.levelM) {
+      setBasinWater(b, o)
+      if (b.ice) seaIce(b, p, z, detailM, o)
+    }
     return
   }
   const sd = signedDistance(b.shape, z, a)
diff --git a/src/terrain/ice.ts b/src/terrain/ice.ts
new file mode 100644
index 0000000..a411143
--- /dev/null
+++ b/src/terrain/ice.ts
@@ -0,0 +1,127 @@
+/**
+ * A sea's ice, and the dim Tower keeps over it. See design.md §1.9.6, D-090, D-091.
+ *
+ * The far cap's window is a cold wall, and Tower lets the Deepward Sea freeze against it in
+ * bands: open water, then pack ice, then fast ice frozen to the foot of the cap. It keeps
+ * that end of the world dim to match — it aims less and less of its light at the sea toward
+ * the ice, and none at the fast ice, which gets only the rod's glow.
+ *
+ * Both are functions of z alone, because the bands are rings: the sea closes on itself
+ * around the world, and nothing about the ice changes with theta but the floes.
+ */
+import { G0 } from '../canon/constants.js'
+import type { SeaIce } from '../world/schema.js'
+
+/** Which way the ice lies from the edge: +1 if the fast ice is deepward of it, else -1. */
+export const iceSide = (ice: SeaIce): number => Math.sign(ice.fastM - ice.edgeM)
+
+/** How far into the ice a point is, in units of the pack's width: 0 at the edge, 1 at the fast ice. */
+export function intoIce(ice: SeaIce, z: number): number {
+  return (z - ice.edgeM) / (ice.fastM - ice.edgeM)
+}
+
+/** Pack ice at its edge covers this much of the sea, and packs to this at the fast ice. */
+export const PACK_AT_EDGE = 0.3
+export const PACK_AT_FAST = 0.95
+
+/**
+ * How much of the sea's surface is ice at a depth `t` into the ice (`intoIce`): none on
+ * the open water, the pack closing up across its band, and 1 — solid — on the fast ice.
+ */
+export function iceCover(t: number): number {
+  if (t < 0) return 0
+  if (t >= 1) return 1
+  return PACK_AT_EDGE + (PACK_AT_FAST - PACK_AT_EDGE) * t
+}
+
+/**
+ * Tower's aimed light at the far end, as a share of what it aims everywhere else
+ * (D-091). It begins to fall at the open water's shore, is down to `AIMED_AT_EDGE` by the
+ * ice edge and `AIMED_AT_FAST` by the fast ice, and is nothing over the fast ice itself.
+ */
+export const AIMED_AT_EDGE = 0.35
+export const AIMED_AT_FAST = 0.1
+
+/**
+ * What still lights the fast ice by day when Tower aims nothing at it: the rod's own glow,
+ * which cannot be aimed away (§1.3.2). Tens of W/m² against the ~1,000 of a noon under the
+ * sun — about a twentieth, the permanent overcast dusk §1.9.6 describes.
+ */
+export const ROD_GLOW_BY_DAY = 0.05
+
+/**
+ * The share of Tower's aimed light at `z` (sun and moon both), given the ice and where its
+ * open water begins — the far side of the open water from the ice, `openM`.
+ */
+export function aimedShare(ice: SeaIce, openM: number, z: number): number {
+  const t = intoIce(ice, z)
+  if (t >= 1) return 0
+  if (t >= 0) return AIMED_AT_EDGE + (AIMED_AT_FAST - AIMED_AT_EDGE) * t
+  // On the open water, from the shore to the edge.
+  const s = (z - openM) / (ice.edgeM - openM)
+  if (s <= 0) return 1
+  const k = Math.min(1, s)
+  return 1 + (AIMED_AT_EDGE - 1) * k * k * (3 - 2 * k)
+}
+
+/** The sky's light by where it comes from, W/m², as sky.ts `lightMix` splits it. */
+export interface LightMix { sun: number; rod: number; moon: number }
+
+/**
+ * The light on the ground at `z` as a share of what the sky gives everywhere else. The rod
+ * is never aimed and is the same everywhere; the sun and the moon are, and fall with
+ * `aimedShare` — the sun no lower than the rod's own glow by day. The shaders do the same
+ * sum per pixel; this one sets the eye's exposure where the observer stands, so the far end
+ * is dusk to someone standing on it rather than black.
+ */
+export function lightShare(ice: SeaIce, openM: number, z: number, mix: LightMix): number {
+  const total = mix.sun + mix.rod + mix.moon
+  if (total <= 0) return 1
+  const aimed = aimedShare(ice, openM, z)
+  return (mix.sun * Math.max(aimed, ROD_GLOW_BY_DAY) + mix.rod + mix.moon * aimed) / total
+}
+
+/**
+ * The ice mountains at the foot of the cap (D-093). Grounded ice cannot stand much higher
+ * than a few hundred metres over a few kilometres — it spreads under its own weight until
+ * the stress at its bed is what the bed will take — so ice that climbs kilometres up the
+ * window has to be standing on something. Tower raised a range there for it, as it raised the
+ * Uplands, and the snow off the window wind and the meltwater of every pane it clears keep it
+ * buried.
+ *
+ * The rock rises from the sea bed at the range's start to its height at the cap, steepening
+ * toward the window. Ice mantles it from where it leaves the water, thickening with the plastic
+ * profile of an ice sheet,
+ *
+ *     H² = H0² + (2 τ / ρ g) · x
+ *
+ * x in from the ice's margin, to `ICE_MANTLE_M` on the heights. τ is 150 kPa, the top of what
+ * ice sheets show: cold ice frozen to its bed. The highest ridges of rock stand out of it.
+ */
+export const ICE_DENSITY = 917
+export const ICE_YIELD_PA = 150_000
+/** How thick the ice lies on the range once it is well up it, m. */
+export const ICE_MANTLE_M = 350
+/** The ice's edge where the rock leaves the water: a front of a few tens of metres. */
+export const ICE_FRONT_M = 45
+/** How the rock steepens toward the cap: its height goes as t^RANGE_RISE across the range. */
+export const RANGE_RISE = 1.6
+
+/** The range's rock at `t` of the way across it (0 at its start, 1 at the cap), smoothed. */
+export function rangeRock(t: number, bedM: number, heightM: number): number {
+  const k = Math.min(1, Math.max(0, t))
+  return bedM + (heightM - bedM) * k ** RANGE_RISE
+}
+
+/** How far across the range the rock stands `belowM` under the water: where the ice begins. */
+export function iceMargin(levelM: number, bedM: number, heightM: number, belowM = 40): number {
+  const f = (levelM - belowM - bedM) / (heightM - bedM)
+  return f <= 0 ? 0 : f >= 1 ? 1 : f ** (1 / RANGE_RISE)
+}
+
+/** The ice's thickness `xM` metres in from its margin. */
+export function iceMantle(xM: number): number {
+  if (xM < 0) return 0
+  const k = (2 * ICE_YIELD_PA) / (ICE_DENSITY * G0)
+  return Math.min(ICE_MANTLE_M, Math.sqrt(ICE_FRONT_M * ICE_FRONT_M + k * xM))
+}
diff --git a/src/terrain/map-image.ts b/src/terrain/map-image.ts
index 11b475e..caf2309 100644
--- a/src/terrain/map-image.ts
+++ b/src/terrain/map-image.ts
@@ -11,7 +11,7 @@
 import { CIRCUMFERENCE_M, LENGTH_M, RADIUS_M } from '../canon/constants.js'
 import type { Runtime } from './field.js'
 import { cylPoint, fbm } from './noise.js'
-import { colourOf } from './palette.js'
+import { ICE, colourOf } from './palette.js'
 import { MATERIALS, materialsAt } from './tile.js'
 
 const HILLSHADE = { azimuthDeg: 315, altitudeDeg: 42, strength: 0.55 }
@@ -29,6 +29,8 @@ export function renderMapRows(
   const rows = y1 - y0 + 2
   const shown = new Float64Array(rows * width)
   const mats = new Float32Array((y1 - y0) * width * MATERIALS.length)
+  // Sea ice (D-090) is not a material of the ground: it lies on the water, and covers it.
+  const ice = new Float32Array((y1 - y0) * width)
   const seed = rt.world.seed
 
   for (let r = 0; r < rows; r++) {
@@ -42,6 +44,7 @@ export function renderMapRows(
         materialsAt(s, 0, mats, o)
         // The map has no water surfaces to draw over the ground: the water is the colour.
         if (s.waterMesh) { mats[o + 5] = 1; mats[o + 7] = 0 }
+        ice[(r - 1) * width + x] = s.ice
       }
     }
   }
@@ -68,6 +71,12 @@ export function renderMapRows(
       for (let i = 0; i < MATERIALS.length; i++) m[i] = mats[base * MATERIALS.length + i]!
       m[4] = m[4]! * (1 - Math.min(1, Math.max(0, (slope - 0.55) / 0.55)))
       colourOf(m, h, colour)
+      const c = ice[base]!
+      if (c > 0) {
+        colour[0] = colour[0]! + (ICE[0] - colour[0]!) * c
+        colour[1] = colour[1]! + (ICE[1] - colour[1]!) * c
+        colour[2] = colour[2]! + (ICE[2] - colour[2]!) * c
+      }
 
       let shade = (nx * lx + ny * ly + lz) / Math.hypot(nx, ny, 1)
       shade = 1 - HILLSHADE.strength + HILLSHADE.strength * Math.max(0, shade) * 1.35
diff --git a/src/terrain/palette.ts b/src/terrain/palette.ts
index 097e0b1..d1275cb 100644
--- a/src/terrain/palette.ts
+++ b/src/terrain/palette.ts
@@ -25,6 +25,11 @@ export const RAMPS: Record<'plain' | 'desert' | 'forest' | 'rock' | 'sandstone',
 export const SNOW = [247, 249, 253] as const
 /** Salt: white, but a dirtier and warmer white than snow (world-generation.md §4.5). */
 export const SALT = [232, 227, 213] as const
+/**
+ * Sea ice with snow on it (D-090): white, but a bluer, colder white than snow on land, and
+ * greyer — it is lit by dusk, and wind-packed rather than fresh.
+ */
+export const ICE = [214, 224, 232] as const
 /** Shallow water, and how much darker it gets at full depth. */
 export const WATER = [58, 96, 132] as const
 export const WATER_DEEPEN = [20, 26, 24] as const
diff --git a/src/terrain/service.ts b/src/terrain/service.ts
index c3c5b0d..945fe6d 100644
--- a/src/terrain/service.ts
+++ b/src/terrain/service.ts
@@ -49,6 +49,12 @@ interface Slot {
   worker: Worker
   api: Remote<WorkerApi>
   inFlight: number
+  /**
+   * Rejects the moment the worker reports an error — for a worker still starting, that its
+   * script did not load. A comlink call to such a worker never settles, so the start-up
+   * ping is raced against this rather than waiting out its deadline (D-092).
+   */
+  failed: Promise<never>
 }
 
 /**
@@ -58,6 +64,13 @@ interface Slot {
  */
 const WORKER_START_MS = 15_000
 const WORKER_LOAD_MS = 30_000
+/**
+ * How many times a worker that fails to start is replaced before the pool does without it,
+ * and how long to wait before each try. A script caught mid-rebuild by the dev server comes
+ * back empty; a moment later it is whole (D-092).
+ */
+const WORKER_TRIES = 4
+const WORKER_RETRY_MS = 400
 
 class Timeout extends Error {}
 
@@ -117,10 +130,15 @@ export class TerrainService {
   private spawn(index: number): Slot {
     const worker = new Worker(this.workerUrl, { type: 'module' })
     // A worker whose script fails to load or throws says so here and nowhere else.
-    worker.addEventListener('error', (e) => {
-      console.error(`Terrain worker ${index} failed:`, e.message || e)
+    const failed = new Promise<never>((_, reject) => {
+      worker.addEventListener('error', (e) => {
+        console.warn(`Terrain worker ${index} reported an error:`, e.message || 'its script did not load')
+        reject(new Error(`Terrain worker ${index} failed to start`))
+      })
     })
-    return { worker, api: wrap<WorkerApi>(worker), inFlight: 0 }
+    // Nobody may be waiting on it by the time a live worker throws: that is logged above.
+    failed.catch(() => {})
+    return { worker, api: wrap<WorkerApi>(worker), inFlight: 0, failed }
   }
 
   /**
@@ -129,26 +147,47 @@ export class TerrainService {
    * The page used to wait on all of them with no limit, so one worker that never started
    * froze the world on the compile's last step — "Teaching Eastwall Water which way is
    * down" — with nothing in the console but Firefox's "Attempting to create a Worker from
-   * an empty source" (D-080). Now a worker that does not answer a ping is replaced once,
-   * and dropped if its replacement does not answer either. The pool is smaller for it,
-   * but the world loads; an empty pool is the one case that is an error.
+   * an empty source" (D-080). Then a silent worker was given 15 s before it was replaced,
+   * which was the freeze the author still saw "every so often" (D-092).
+   *
+   * Now a worker is replaced as soon as it is known to be dead, and tried up to
+   * `WORKER_TRIES` times. Dead shows two ways. A script that throws, or fails to load,
+   * says so in the worker's `error` event. An **empty** script says nothing at all — Firefox
+   * starts it, it runs nothing, and it never answers — so it is judged against its siblings:
+   * they load the same modules at the same time, so once one of them has answered, a
+   * worker still silent a few seconds later is not slow but empty. Only a pool with no
+   * worker alive at all waits out the full `WORKER_START_MS`. The pool may end smaller, but
+   * the world loads; an empty pool is the one case that is an error.
    */
   private async ensureWorkers(): Promise<void> {
-    const alive = await Promise.all(this.slots.map(async (slot, i) => {
-      try {
-        await within(slot.api.ping(), WORKER_START_MS, `Terrain worker ${i}`)
-        return slot
-      } catch (e) {
-        console.error(`${(e as Error).message} — replacing it.`)
-        slot.worker.terminate()
-        const fresh = this.spawn(i)
+    const began = performance.now()
+    let firstAnswer: (ms: number) => void
+    const answered = new Promise<number>((resolve) => { firstAnswer = resolve })
+    // Once a sibling has answered: its own load time again, and never less than 3 s.
+    const siblingDeadline = (): Promise<never> => answered.then((ms) =>
+      new Promise<never>((_, reject) => setTimeout(
+        () => reject(new Error('silent after its siblings answered: an empty script')),
+        Math.max(3000, ms))))
+    const alive = await Promise.all(this.slots.map(async (first, i) => {
+      let slot = first
+      for (let attempt = 1; ; attempt++) {
         try {
-          await within(fresh.api.ping(), WORKER_START_MS, `Terrain worker ${i} (replacement)`)
-          return fresh
-        } catch (e2) {
-          console.error(`${(e2 as Error).message} — carrying on without it.`)
-          fresh.worker.terminate()
-          return null
+          await within(Promise.race([
+            slot.api.ping().then(() => firstAnswer(performance.now() - began)),
+            slot.failed,
+            siblingDeadline(),
+          ]), WORKER_START_MS, `Terrain worker ${i}`)
+          if (attempt > 1) console.info(`Terrain worker ${i} started on try ${attempt}.`)
+          return slot
+        } catch (e) {
+          slot.worker.terminate()
+          if (attempt >= WORKER_TRIES) {
+            console.error(`${(e as Error).message}, ${attempt} times — carrying on without it.`)
+            return null
+          }
+          console.warn(`Terrain worker ${i}: ${(e as Error).message} — replacing it.`)
+          await new Promise((r) => setTimeout(r, WORKER_RETRY_MS * attempt))
+          slot = this.spawn(i)
         }
       }
     }))
@@ -336,7 +375,7 @@ export class TerrainService {
     const s = this.runtime.sample(theta, z, detailM)
     return {
       h: s.h, water: s.water > s.h, stems: s.stems, canopyM: s.canopyM,
-      desert: s.desert, rock: s.rock,
+      desert: s.desert, rock: s.rock, glacier: s.glacier > 0,
     }
   }
 }
@@ -349,6 +388,8 @@ export interface GroundAt {
   canopyM: number
   desert: number
   rock: number
+  /** Grounded sea ice: the ice wall at the cap (D-093). */
+  glacier: boolean
 }
 
 let instance: TerrainService | null = null
diff --git a/src/terrain/ships.ts b/src/terrain/ships.ts
index ff75ce4..beadc2f 100644
--- a/src/terrain/ships.ts
+++ b/src/terrain/ships.ts
@@ -33,10 +33,23 @@ export interface ShipLane {
 /** 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. */
+/**
+ * How far short of the ice edge a frozen sea's water counts as open for a ship's lane (D-090):
+ * the edge wanders 1.5 km either way (field.ts `seaIce`), and loose floes lie off it.
+ */
+export const ICE_CLEAR_M = 2000
+
+/**
+ * The lanes on a sea, seeded from the world and the sea, so everyone sees the same fleet.
+ * On a sea with ice they keep to the open water.
+ */
 export function lanesOf(sea: Sea, worldSeed: number): ShipLane[] {
   if (sea.geometry.type !== 'band' || sea.ships <= 0) return []
-  const { zFrom, zTo } = sea.geometry
+  let { zFrom, zTo } = sea.geometry
+  if (sea.ice) {
+    if (sea.ice.fastM > sea.ice.edgeM) zTo = Math.min(zTo, sea.ice.edgeM - ICE_CLEAR_M)
+    else zFrom = Math.max(zFrom, sea.ice.edgeM + ICE_CLEAR_M)
+  }
   const width = zTo - zFrom - 2 * LANE_MARGIN_M
   if (width <= 0) return []
   return Array.from({ length: sea.ships }, (_, i) => {
diff --git a/src/terrain/tile.ts b/src/terrain/tile.ts
index 4109771..170a6e2 100644
--- a/src/terrain/tile.ts
+++ b/src/terrain/tile.ts
@@ -58,7 +58,7 @@ export const MATERIALS = [
 
 /**
  * Per water vertex, in this order: depth of water over the ground, main wave amplitude,
- * wavelength and direction; flow along z and around; whiteness; spare.
+ * wavelength and direction; flow along z and around; whiteness; ice cover (field.ts).
  */
 export const WATER_ATTRS = 8
 
@@ -145,7 +145,8 @@ export function materialsAt(s: Sample, slope: number, out: Float32Array | number
   // Snow does not lie on cliffs. Blending it out with slope is what turns a white blob
   // into a mountain with rock showing through.
   const holds = 1 - smoothstep(0.55, 1.1, slope)
-  const snow = (1 - water) * smoothstep(s.snowlineM, s.snowlineM + 550, shown) * holds
+  // Grounded sea ice is white whatever its slope: an ice front is ice, not a cliff of rock.
+  const snow = Math.max((1 - water) * smoothstep(s.snowlineM, s.snowlineM + 550, shown) * holds, s.glacier)
   out[offset] = plain
   out[offset + 1] = desert
   out[offset + 2] = forest
@@ -178,6 +179,8 @@ export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData {
 
   const shown = new Float64Array(W * W)
   const mats = new Float32Array(W * W * MATERIALS.length)
+  // Grounded sea ice (D-093), which stays white however steep its front.
+  const glacier = new Uint8Array(W * W)
   // The water on the tile's own grid: its level (NaN where none is drawn) and attributes.
   const wLevel = new Float64Array(n * n).fill(NaN)
   const wAttrs = new Float32Array(n * n * WATER_ATTRS)
@@ -214,6 +217,7 @@ export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData {
         wAttrs[o + 4] = s.flowZ
         wAttrs[o + 5] = s.flowA
         wAttrs[o + 6] = s.foam
+        wAttrs[o + 7] = s.ice
       }
       if (s.canopyM > 0 && u >= 1 && u <= n && v >= 1 && v <= n) {
         const c = ((v - 1) * n + (u - 1)) * CANOPY_ATTRS
@@ -230,6 +234,7 @@ export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData {
       }
       // Slope is not known until the neighbours are, so snow is thinned by it below.
       materialsAt(s, 0, mats, k * MATERIALS.length)
+      glacier[k] = s.glacier > 0.5 ? 1 : 0
     }
   }
 
@@ -268,7 +273,7 @@ export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData {
     // Slope matters for snow, and only now is it known.
     const slope = Math.hypot(gz, ga)
     const base = k * MATERIALS.length
-    const holds = 1 - smoothstep(0.55, 1.1, slope)
+    const holds = glacier[k] ? 1 : 1 - smoothstep(0.55, 1.1, slope)
     for (let m = 0; m < MATERIALS.length; m++) {
       const w = m === 4 ? mats[base + m]! * holds : mats[base + m]!
       materials[index * MATERIALS.length + m] = Math.round(w * 255)
diff --git a/src/views/forest-view.ts b/src/views/forest-view.ts
index a227b0b..5b6642b 100644
--- a/src/views/forest-view.ts
+++ b/src/views/forest-view.ts
@@ -272,7 +272,7 @@ class Field {
     this.h = new Float32Array(n * n)
     this.stems = new Float32Array(n * n)
     this.water = new Uint8Array(n * n)
-    const blank: GroundAt = { h: 0, water: false, stems: 0, canopyM: 0, desert: 0, rock: 0 }
+    const blank: GroundAt = { h: 0, water: false, stems: 0, canopyM: 0, desert: 0, rock: 0, glacier: false }
     for (let i = 0; i < n; i++) {
       for (let j = 0; j < n; j++) {
         const z = this.z0 + i * cell, a = this.a0 + j * cell
diff --git a/src/views/interior-model.ts b/src/views/interior-model.ts
index b532175..c2198ae 100644
--- a/src/views/interior-model.ts
+++ b/src/views/interior-model.ts
@@ -14,8 +14,10 @@
  */
 import type { Object3D, Texture } from 'three'
 import {
-  AdditiveBlending, BackSide, BufferAttribute, BufferGeometry, CircleGeometry, Color, CylinderGeometry,
-  DoubleSide, FrontSide, Group, InstancedMesh, Matrix3, Matrix4, Mesh, RingGeometry, ShaderMaterial, SphereGeometry,
+  AdditiveBlending, BackSide, BufferAttribute, BufferGeometry, CircleGeometry, Color, CylinderGeometry, DataTexture,
+  DoubleSide, FrontSide, Group, InstancedMesh, LinearFilter, Matrix3, Matrix4, Mesh, NearestFilter, RGBAFormat,
+  RepeatWrapping, RingGeometry,
+  ShaderMaterial, SphereGeometry,
   Vector3, Vector4,
 } from 'three'
 import {
@@ -24,9 +26,11 @@ import {
   HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M, RAYLEIGH_FLOOR_PER_M,
   MOON_RADIUS_M, SPECIFIC_GAS_CONSTANT, SUN_RADIUS_M, TEMPERATURE_K,
 } from '../canon/constants.js'
-import type { Sky } from '../world/sky.js'
-import { PALETTE_GLSL } from '../terrain/palette.js'
+import { type Sky, lightMix } from '../world/sky.js'
+import { PANE_KM, frostAt, panes } from '../world/frost.js'
+import { ICE, PALETTE_GLSL } from '../terrain/palette.js'
 import { LOWEST_GROUND_M } from '../terrain/tile.js'
+import { AIMED_AT_EDGE, AIMED_AT_FAST, ROD_GLOW_BY_DAY, lightShare as lightShareAt } from '../terrain/ice.js'
 import { fallSheet } from '../terrain/falls.js'
 import { ForestView } from './forest-view.js'
 import { BODY_STRIDE, MAX_BODIES, type SeaState, seaStateTable } from '../terrain/sea-state.js'
@@ -204,6 +208,35 @@ float direct(vec3 p, vec3 n) {
   return uSun.y * max(dot(n, sunDir(p)), 0.0) + uSun.z * max(dot(n, upAt(p)), 0.0)
     + uMoon.y * max(dot(n, moonDir(p)), 0.0);
 }
+
+/**
+ * The far end's dim (D-090, D-091). Tower aims less of its light at the sea toward the ice
+ * and none at the fast ice, which the rod's glow alone lights by day. \`uDim\` is where the
+ * open water begins, the ice edge and the fast ice's edge, km along the axis, and which way
+ * the ice lies from its edge (0: no iced sea). \`uLightMix\` is the sky's light split into
+ * sun, rod and moon, summing to 1 (sky.ts \`lightMix\`). The sums are ice.ts's, which sets
+ * the eye by them where the observer stands.
+ */
+uniform vec4 uDim;
+uniform vec3 uLightMix;
+float aimedShare(float zKm) {
+  if (uDim.w == 0.0) return 1.0;
+  float t = (zKm - uDim.y) / (uDim.z - uDim.y);
+  if (t >= 1.0) return 0.0;
+  if (t >= 0.0) return mix(${AIMED_AT_EDGE.toFixed(4)}, ${AIMED_AT_FAST.toFixed(4)}, t);
+  float s = clamp((zKm - uDim.x) / (uDim.y - uDim.x), 0.0, 1.0);
+  return mix(1.0, ${AIMED_AT_EDGE.toFixed(4)}, s * s * (3.0 - 2.0 * s));
+}
+/**
+ * How lit a place looks against the rest of the world, 0..1: multiplies everything lit
+ * there. The watts go through the same 0.35 power the exposure puts the dial's watts
+ * through (\`setSky\`), because an eye in the dim has adapted to it: the fast ice at a
+ * twentieth of the light looks like dusk, a third as bright, not like a hole.
+ */
+float lightShare(vec3 p) {
+  float a = aimedShare(p.z);
+  return pow(uLightMix.x * max(a, ${ROD_GLOW_BY_DAY.toFixed(4)}) + uLightMix.y + uLightMix.z * a, 0.35);
+}
 `
 
 /**
@@ -307,7 +340,7 @@ void main() {
 
   // Lit by the sun wherever on the axis it is (SUN_GLSL). The rest of the world is lit
   // land, and lit land is what fills the shadows.
-  vec3 lit = base * (0.34 + 0.80 * direct(vWorld, normalize(vNormal2))) * uExposure;
+  vec3 lit = base * (0.34 + 0.80 * direct(vWorld, normalize(vNormal2))) * uExposure * lightShare(vWorld);
 
   vec3 T = towerTransmittance(uCam, vWorld);
   gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
@@ -413,6 +446,112 @@ vec4 waveParams(vec4 water) {
 }
 `
 
+/**
+ * Sea ice (D-090): the pack's floes and the fast ice's cracks, on the Deepward Sea's rings.
+ *
+ * A floe is a cell of a jittered grid — a Voronoi cell, which is the shape ice breaks into:
+ * straight edges, meeting at odd angles. Each cell is ice or open water by its own hash
+ * against the cover the tile carries (field.ts \`seaIce\`), so the pack loosens toward its
+ * edge on its own. Where two ice cells meet there is a crack; where ice meets open water
+ * the floe stops short of the cell's edge, which opens the leads.
+ *
+ * Floes are 110 m and bigger, so unlike the waves they need no surface reference: absolute
+ * metres along the axis and around are good to centimetres in float32. The grid around is a
+ * whole number of cells, so the world closes on itself without a seam, and the hash is a
+ * permutation mod 289 — exact in float, so the same floe on every GPU.
+ */
+/** The frost table: a texel per lattice cell, with room for every pane's (a, b) (frost.ts). */
+const FROST_SIZE = 32
+const FROST_OFFSET = 16
+
+const FLOE_M = 110
+const ICE_LINEAR = `vec3(${ICE.map((c) => ((c / 255) ** 2.2).toFixed(4)).join(', ')})`
+const FLOES_AROUND = Math.round((2 * Math.PI * R * 1000) / FLOE_M)
+const FAST_CRACK_M = 660
+const FAST_AROUND = Math.round((2 * Math.PI * R * 1000) / FAST_CRACK_M)
+const ICE_GLSL = /* glsl */`
+float icePermute(float x) { return mod((34.0 * x + 1.0) * x, 289.0); }
+float iceHash(vec2 c, float salt) {
+  vec2 m = mod(c, 289.0);
+  return icePermute(icePermute(icePermute(m.x) + m.y) + salt) / 289.0;
+}
+/** Waves under ice: the pack damps the sea, and fast ice stills it. */
+float iceCalm(float cover) { return 1.0 - smoothstep(0.0, 0.6, cover); }
+
+/**
+ * The two nearest cell centres to a point, in cells: x, y the nearest cell, z the distance
+ * to the edge between it and the second nearest (in cells), w that second cell's index
+ * packed as offset (0..8).
+ */
+vec4 iceCells(vec2 x, float around, out vec2 second) {
+  vec2 n = floor(x), f = fract(x);
+  float d1 = 9.0, d2 = 9.0;
+  vec2 c1 = n, c2 = n, p1 = vec2(0.0), p2 = vec2(0.0);
+  for (int j = -1; j <= 1; j++) {
+    for (int i = -1; i <= 1; i++) {
+      vec2 g = vec2(float(i), float(j));
+      vec2 c = n + g;
+      c.y = mod(c.y, around);
+      vec2 pt = g + 0.1 + 0.8 * vec2(iceHash(c, 11.0), iceHash(c, 23.0));
+      float d = dot(pt - f, pt - f);
+      if (d < d1) { d2 = d1; c2 = c1; p2 = p1; d1 = d; c1 = c; p1 = pt; }
+      else if (d < d2) { d2 = d; c2 = c; p2 = pt; }
+    }
+  }
+  // Distance to the bisector between the two nearest centres: the edge between the cells.
+  vec2 mid = 0.5 * (p1 + p2);
+  float edge = dot(mid - f, normalize(p2 - p1));
+  second = c2;
+  return vec4(c1, edge, 0.0);
+}
+
+/**
+ * The ice at a point of the sea: x how much of the pixel is ice, y how dark with cracks,
+ * z the shade of the floe it is on. \`cover\` is the tile's, 1 on fast ice.
+ */
+vec3 seaIce(vec3 world, float cover, float footprint) {
+  vec2 m = vec2(world.z * 1000.0, atan(world.y, world.x) * ${(R * 1000).toFixed(1)});
+  if (m.y < 0.0) m.y += ${(2 * Math.PI * R * 1000).toFixed(1)};
+  float fast = step(0.999, cover);
+  vec3 out3 = vec3(0.0, 0.0, 1.0);
+
+  // The pack: floes over open leads.
+  vec2 x = vec2(m.x / ${FLOE_M.toFixed(1)}, m.y / ${(2 * Math.PI * R * 1000 / FLOES_AROUND).toFixed(4)});
+  vec2 c2;
+  vec4 v = iceCells(x, ${FLOES_AROUND.toFixed(1)}, c2);
+  bool ice1 = fast > 0.5 || iceHash(v.xy, 37.0) < cover;
+  bool ice2 = fast > 0.5 || iceHash(c2, 37.0) < cover;
+  float edgeM = v.z * ${FLOE_M.toFixed(1)};
+  float aa = max(footprint, 0.05);
+  // Leads open as the pack loosens: floes stop short of the cell edge by up to 12 m.
+  float lead = mix(12.0, 1.0, cover);
+  float onIce = ice1 ? (ice2 ? 1.0 : smoothstep(lead - aa, lead + aa, edgeM)) : 0.0;
+  // A crack where two floes meet: a metre and a half of darker, wetter ice.
+  float crack = (ice1 && ice2 && fast < 0.5) ? 1.0 - smoothstep(0.0, 1.5 + aa, edgeM) : 0.0;
+  // Each floe its own white: older floes whiter, younger greyer toward the edge of the pack.
+  float shade = 0.84 + 0.16 * iceHash(v.xy, 51.0);
+  shade *= mix(0.72, 1.0, smoothstep(0.3, 0.8, cover));
+
+  // Far off, a floe is under a pixel: the sea whitens by its share of ice instead.
+  float far = smoothstep(${(FLOE_M * 0.12).toFixed(1)}, ${(FLOE_M * 0.6).toFixed(1)}, footprint);
+  float share = fast > 0.5 ? 1.0 : cover * (1.0 - 0.02 * lead);
+  out3.x = mix(onIce, share, far);
+  out3.y = mix(crack, 0.0, far);
+  out3.z = mix(shade, 0.9 * mix(0.72, 1.0, smoothstep(0.3, 0.8, cover)), far);
+
+  // The fast ice is one sheet, cracked across in long lines and ridged where it pressed.
+  if (fast > 0.5) {
+    vec2 xb = vec2(m.x / ${FAST_CRACK_M.toFixed(1)}, m.y / ${(2 * Math.PI * R * 1000 / FAST_AROUND).toFixed(4)});
+    vec2 cb;
+    vec4 vb = iceCells(xb, ${FAST_AROUND.toFixed(1)}, cb);
+    float line = 1.0 - smoothstep(0.0, 2.0 + aa, vb.z * ${FAST_CRACK_M.toFixed(1)});
+    out3.y = max(out3.y, line * 0.6 * (1.0 - smoothstep(20.0, 120.0, footprint)));
+    out3.z = 0.9 + 0.1 * iceHash(vb.xy, 51.0);
+  }
+  return out3;
+}
+`
+
 const WATER_VERT = /* glsl */`
 #include <common>
 #include <logdepthbuf_pars_vertex>
@@ -424,6 +563,7 @@ varying vec3 vWorld;
 varying vec4 vWater;
 varying vec4 vFlow;
 ${WAVES_GLSL}
+${ICE_GLSL}
 void main() {
   vec3 world = (uFrame * modelMatrix * vec4(position, 1.0)).xyz;
   // Displace only close in, where vertices are metres apart; beyond, waves are shading.
@@ -432,6 +572,7 @@ void main() {
   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;
+    h *= iceCalm(aFlow.w);
     // 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));
@@ -455,6 +596,7 @@ varying vec4 vFlow;
 ${HAZE_GLSL}
 ${SUN_GLSL}
 ${WAVES_GLSL}
+${ICE_GLSL}
 
 // 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); }
@@ -476,7 +618,7 @@ void main() {
 
   vec4 wp = waveParams(vWater);
   vec3 w = waves(sp, wp.x, wp.y, wp.z, bodyOf(vWater), footprint);
-  vec2 grad = w.yz;
+  vec2 grad = w.yz * iceCalm(vFlow.w);
 
   /**
    * Flow: ripples carried downstream. Two copies of a noise field advected by the flow
@@ -508,7 +650,7 @@ void main() {
   // Schlick, with water's 2% at normal incidence.
   float fresnel = 0.02 + 0.98 * pow(1.0 - cosV, 5.0);
 
-  float light = uExposure * (0.4 + 0.6 * direct(vWorld, up));
+  float light = uExposure * (0.4 + 0.6 * direct(vWorld, up)) * lightShare(vWorld);
 
   // Deep water is dark blue-green; what it reflects inside Tower is the far side of the
   // world, which from here is haze-blue land.
@@ -524,9 +666,9 @@ void main() {
   vec3 refl = reflect(view, n);
   float toAxis = max(0.0, dot(normalize(vec3(refl.xy, 0.0) + 1e-6), up));
   col += vec3(1.0, 0.95, 0.85) * pow(toAxis, 5000.0) * 0.45 * light * uSun.z;
-  col += vec3(1.0, 0.96, 0.88) * pow(max(dot(refl, sunDir(vWorld)), 0.0), 2000.0) * 2.5 * uSun.y * uExposure;
+  col += vec3(1.0, 0.96, 0.88) * pow(max(dot(refl, sunDir(vWorld)), 0.0), 2000.0) * 2.5 * uSun.y * uExposure * aimedShare(vWorld.z);
   // And the moon, on a moonlit night: a path of light across the sea toward it.
-  col += vec3(0.85, 0.9, 1.0) * pow(max(dot(refl, moonDir(vWorld)), 0.0), 1500.0) * 1.2 * uMoon.y * uExposure;
+  col += vec3(0.85, 0.9, 1.0) * pow(max(dot(refl, moonDir(vWorld)), 0.0), 1500.0) * 1.2 * uMoon.y * uExposure * aimedShare(vWorld.z);
 
   // Foam: rapids and falls, and the edge where the water is only centimetres deep.
   float depth = vWater.x;
@@ -561,10 +703,27 @@ void main() {
   }
   col = mix(col, vec3(0.85, 0.88, 0.9) * light * (0.34 + 0.8 * dot(n, up)), foam);
 
+  /**
+   * Sea ice (D-090): white over the water where the floes are, with the leads between them
+   * open and dark, and the fast ice one cracked sheet. Matte — snow on ice mirrors nothing —
+   * and lit like the foam, by the same light, which at the far end is a dusk.
+   */
+  float ice = 0.0;
+  if (vFlow.w > 0.001) {
+    vec3 floe = seaIce(vWorld, vFlow.w, footprint);
+    ice = floe.x;
+    vec3 white = ${ICE_LINEAR} * floe.z * (1.0 - 0.45 * floe.y);
+    // Snow on it, drifted: a mottle close in, where a metre is a few pixels.
+    float drift = wNoise(sp / 2.4) * 0.6 + wNoise(sp / 9.6) * 0.4;
+    white *= 1.0 + (drift - 0.5) * 0.12 * (1.0 - smoothstep(0.5, 4.0, footprint));
+    col = mix(col, white * light * 1.1, ice);
+  }
+
   // How much ground shows through: shallow water is clear, a few metres is not.
   float alpha = clamp(1.0 - exp(-depth / 3.5), 0.2, 0.97);
   alpha = max(alpha, fresnel);
   alpha = max(alpha, foam);
+  alpha = max(alpha, ice);
 
   vec3 T = towerTransmittance(uCam, vWorld);
   gl_FragColor = vec4(col * T + uHaze * uExposure * (1.0 - T), alpha);
@@ -656,7 +815,7 @@ void main() {
   // Thinner stands let the floor show through, so they are lighter than closed canopy.
   base *= 1.0 + 0.35 * (1.0 - clamp(vCanopy.y, 0.0, 1.0));
   // Under the canopy the light is what has come through it: green, and a tenth as much.
-  vec3 lit = base * (under ? 0.55 : 0.22 + 0.90 * lambert) * uExposure;
+  vec3 lit = base * (under ? 0.55 : 0.22 + 0.90 * lambert) * uExposure * lightShare(vWorld);
 
   vec3 T = towerTransmittance(uCam, vWorld);
   gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
@@ -719,7 +878,7 @@ void main() {
   float lambert = direct(vWorld, n);
   // Ambient is high for foliage: under a crown the light is what the crowns around it have
   // bounced, and a canopy with none of that reads as pitch black rather than as shade.
-  vec3 lit = foliageColour(vPart, vTint) * (0.34 + 0.85 * lambert) * uExposure;
+  vec3 lit = foliageColour(vPart, vTint) * (0.34 + 0.85 * lambert) * uExposure * lightShare(vWorld);
   vec3 T = towerTransmittance(uCam, vWorld);
   gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
   #include <colorspace_fragment>
@@ -807,7 +966,7 @@ void main() {
   float shade = 0.22 + 0.52 * smoothstep(0.0, 0.9, v);
   // As bright as a tree mesh's crown at the handover: the same sun, the same curve.
   float sun = (0.34 + 0.85 * direct(vWorld, upAt(vWorld))) / 1.19;
-  vec3 lit = foliageColour(part, vCard.y) * shade * sun * uExposure;
+  vec3 lit = foliageColour(part, vCard.y) * shade * sun * uExposure * lightShare(vWorld);
   vec3 T = towerTransmittance(uCam, vWorld);
   gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
   #include <colorspace_fragment>
@@ -849,7 +1008,7 @@ void main() {
   float lambert = uSun.y * abs(dot(n, sunDir(vWorld))) + uSun.z * abs(dot(n, upAt(vWorld)))
     + uMoon.y * abs(dot(n, moonDir(vWorld)));
   // Three.Color already converts its hex to linear.
-  vec3 lit = uColour * (0.34 + 0.8 * lambert) * uExposure;
+  vec3 lit = uColour * (0.34 + 0.8 * lambert) * uExposure * lightShare(vWorld);
   vec3 T = towerTransmittance(uCam, vWorld);
   gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
   #include <colorspace_fragment>
@@ -1005,7 +1164,7 @@ void main() {
   float edge = smoothstep(0.0, 0.12, vUv.x) * smoothstep(1.0, 0.88, vUv.x);
   float alpha = (0.55 + 0.45 * streak) * edge * (1.0 - 0.6 * vUv.y);
   vec3 col = vec3(0.86, 0.9, 0.93) * uExposure * (0.75 + 0.35 * streak)
-    * (0.45 + 0.6 * direct(vWorld, upAt(vWorld)));
+    * (0.45 + 0.6 * direct(vWorld, upAt(vWorld))) * lightShare(vWorld);
   vec3 T = towerTransmittance(uCam, vWorld);
   gl_FragColor = vec4(col * T + uHaze * uExposure * (1.0 - T), alpha);
   #include <colorspace_fragment>
@@ -1125,17 +1284,50 @@ vec3 starField(vec3 dir) {
 }
 `
 
-/** The bore between the world's end and the glazing. Plain structure, hazed. */
+const FROST_NOISE_GLSL = /* glsl */`
+float frostHash(vec2 p) { return fract(sin(dot(mod(p, 289.0), vec2(127.1, 311.7))) * 43758.5453); }
+float frostNoise(vec2 p) {
+  vec2 i = floor(p), f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  return mix(mix(frostHash(i), frostHash(i + vec2(1, 0)), f.x),
+             mix(frostHash(i + vec2(0, 1)), frostHash(i + vec2(1, 1)), f.x), f.y);
+}
+`
+
+/**
+ * How high the ice wall stands against the far cap (D-093), metres above the datum, by the
+ * angle round the axis: a strip the terrain fills in from the ground at the cap's foot
+ * whenever the world changes (\`setCapIce\`). Zero where there is no grounded ice.
+ */
+const CAP_ICE_MAX_M = 4080
+const CAP_ICE_SAMPLES = 2048
+const CAP_ICE_GLSL = /* glsl */`
+uniform sampler2D uCapIce;
+float capIceTop(vec2 xy) {
+  float u = fract(atan(xy.y, xy.x) / 6.2831853);
+  return texture2D(uCapIce, vec2(u, 0.5)).r * ${CAP_ICE_MAX_M.toFixed(1)};
+}
+`
+
+/**
+ * The bore between the world's end and the glazing. Plain structure, hazed — or, where the
+ * ice wall stands against the cap (D-093), buried in it: the kilometre of bore is all below
+ * the ice's top, so it is ice, lit like the rest of the far end.
+ */
 const REVEAL_FRAG = /* glsl */`
 #include <common>
 #include <logdepthbuf_pars_fragment>
 uniform vec3 uTint;
 varying vec3 vWorld;
 ${HAZE_GLSL}
+${SUN_GLSL}
+${CAP_ICE_GLSL}
 void main() {
   #include <logdepthbuf_fragment>
   vec3 T = towerTransmittance(uCam, vWorld);
-  gl_FragColor = vec4(uTint * uExposure * T + uHaze * uExposure * (1.0 - T), 1.0);
+  vec3 wall = uTint;
+  if (capIceTop(vWorld.xy) > 1.0) wall = ${ICE_LINEAR} * lightShare(vWorld);
+  gl_FragColor = vec4(wall * uExposure * T + uHaze * uExposure * (1.0 - T), 1.0);
   #include <colorspace_fragment>
 }
 `
@@ -1221,15 +1413,47 @@ const GLAZED_CAP_FRAG = /* glsl */`
 #include <logdepthbuf_pars_fragment>
 uniform vec3 uTint;
 uniform sampler2D uGlazing;
+uniform sampler2D uFrost;
 varying vec3 vWorld;
 ${HAZE_GLSL}
 ${SUN_GLSL}
 ${STAR_GLSL}
+
+/**
+ * The frost on the pane a point is in (D-090, frost.ts): the lattice cell and which half
+ * of it, looked up in a small table the clock rewrites — lower triangles in red, upper in
+ * green, cell (a, b) at texel (a + ${FROST_OFFSET}, b + ${FROST_OFFSET}).
+ */
+float paneFrost(vec2 xy) {
+  float B = xy.y / ${(PANE_KM * Math.sqrt(3) / 2).toFixed(6)};
+  float A = xy.x / ${PANE_KM.toFixed(6)} - B * 0.5;
+  vec2 c = floor(vec2(A, B));
+  float up = step(1.0, (A - c.x) + (B - c.y));
+  vec4 t = texture2D(uFrost, (c + ${FROST_OFFSET.toFixed(1)} + 0.5) / ${FROST_SIZE.toFixed(1)});
+  return mix(t.r, t.g, up);
+}
+${FROST_NOISE_GLSL}
+${CAP_ICE_GLSL}
+
 void main() {
   #include <logdepthbuf_fragment>
   vec2 uv = vWorld.xy / ${(2 * R).toFixed(1)} + 0.5;
   vec4 frame = texture2D(uGlazing, uv);
   vec3 mullion = pow(frame.rgb, vec3(2.2)) * uTint * uExposure;
+  /**
+   * Frost (D-090). The glass is the coldest thing in the world and whitens on its inside;
+   * Tower clears a pane a turn. Frost is feathered, not a sheet, so a pane half-built reads
+   * as crystals spreading rather than as grey glass — and it is lit by what lights the room
+   * at the cap, which at the far end is the rod's dusk.
+   */
+  float thick = paneFrost(vWorld.xy);
+  // Crystals at tens of metres, in streaks a few hundred long; beyond a pixel they average.
+  vec2 fp = vec2(vWorld.x * 0.8 - vWorld.y * 0.6, vWorld.x * 0.6 + vWorld.y * 0.8);
+  float feather = frostNoise(fp / vec2(0.05, 0.2)) * 0.6 + frostNoise(vWorld.xy / 0.03 + 9.1) * 0.4;
+  float fine = smoothstep(0.02, 0.4, length(fwidth(vWorld.xy)));
+  feather = mix(feather, 0.5, fine);
+  float frost = clamp(thick * (0.8 + 0.4 * feather) * 1.1, 0.0, 0.96);
+  vec3 rime = vec3(0.74, 0.79, 0.84) * uExposure * lightShare(vWorld) * (0.9 + 0.2 * feather);
   /**
    * The stars are NOT scaled by uExposure. Nothing Tower does to its own beam
    * changes how bright Vega is — the exposure models the beam lighting the world,
@@ -1237,7 +1461,29 @@ void main() {
    * same fact: the eye adapts, so the stars are there at noon and unseeable.
    */
   vec3 sky = uSeeThrough < 0.5 ? starField(vWorld - uCam) : vec3(0.0);
-  vec3 face = mix(sky, mullion, frame.a);
+  vec3 face = mix(mix(sky, rime, frost), mullion, frame.a);
+  // Seen from outside the frost covers the pane as the frame covers its own width.
+  float cover = frame.a + (1.0 - frame.a) * frost;
+  vec3 solid = (mullion * frame.a + rime * (1.0 - frame.a) * frost) / max(cover, 1e-4);
+  /**
+   * The ice wall (D-093) stands against the glass as high as it stands against the cap, so
+   * the window's foot is buried in it — frame and panes alike — to the wall's own height
+   * there, with a ragged top where meltwater from the panes above ran down and froze.
+   */
+  float onIce = 0.0;
+  float top = capIceTop(vWorld.xy);
+  if (top > 1.0) {
+    float elevM = (${R.toFixed(1)} - length(vWorld.xy)) * 1000.0;
+    float around = atan(vWorld.y, vWorld.x) * ${(R * 1000).toFixed(1)};
+    float runs = frostNoise(vec2(around / 18.0, 0.5)) * 0.7 + frostNoise(vec2(around / 90.0, 3.5)) * 0.3;
+    float edge = top + (runs - 0.4) * 40.0;
+    float aa = max(length(fwidth(vWorld.xy)) * 1000.0, 1.0);
+    onIce = 1.0 - smoothstep(edge - aa, edge + aa, elevM);
+    vec3 ice = ${ICE_LINEAR} * uExposure * lightShare(vWorld) * (0.85 + 0.2 * runs);
+    face = mix(face, ice, onIce);
+    solid = mix(solid, ice, onIce / max(max(cover, onIce), 1e-4));
+    cover = max(cover, onIce);
+  }
   /**
    * The bullseye mirror (D-048) is returning 1.7 MW/m² back up the column, so it
    * is lit by the beam rather than by the room: it blazes at noon, banks at night
@@ -1255,7 +1501,7 @@ void main() {
   // Lit by whatever beam arrives: by night the banked beam, by day what gets past the
   // sun (D-079), so the rod ends on a lit mirror at every hour.
   vec3 glow = mirror * vec3(1.0, 0.96, 0.88) * uExposure * 2.6 * (uSun.z + uSun.y * BEAM_PAST_SUN);
-  gl_FragColor = windowColour(face + glow, mullion + glow, frame.a);
+  gl_FragColor = windowColour(face + glow, solid + glow, cover);
   #include <colorspace_fragment>
 }
 `
@@ -1771,6 +2017,9 @@ export class InteriorModel {
       uSun: { value: new Vector3(L / 2, 1, 0) },
       // The moon's position (km), its light, its ramp and its signed phase (SUN_GLSL).
       uMoon: { value: new Vector4(L / 2, 0, 0, 0) },
+      // The far end's dim, and the sky's light by where it comes from (SUN_GLSL, D-091).
+      uDim: { value: new Vector4(0, 0, 0, 0) },
+      uLightMix: { value: new Vector3(0, 1, 0) },
     }
   }
 
@@ -1781,10 +2030,10 @@ export class InteriorModel {
    * that really is behind it: the land beyond the near cap, the collector and the
    * stars beyond the far one.
    */
-  private windowMaterial(frag: string, tint: number, glazing: Texture): ShaderMaterial {
+  private windowMaterial(frag: string, tint: number, glazing: Texture, extra: Record<string, { value: unknown }> = {}): ShaderMaterial {
     const m = this.material(FLAT_VERT, frag, {
       side: DoubleSide,
-      uniforms: { uTint: { value: new Color(tint) }, uGlazing: { value: glazing } },
+      uniforms: { uTint: { value: new Color(tint) }, uGlazing: { value: glazing }, ...extra },
     })
     m.transparent = this.opts.seeThrough
     return m
@@ -1808,7 +2057,7 @@ export class InteriorModel {
 
     const far = new Mesh(
       new CircleGeometry(R, 128),
-      this.windowMaterial(GLAZED_CAP_FRAG, 0xbcc2cc, capGlazingTexture()))
+      this.windowMaterial(GLAZED_CAP_FRAG, 0xbcc2cc, capGlazingTexture(), { uFrost: { value: this.frostTexture }, uCapIce: { value: this.capIceTexture } }))
     far.name = 'farCap'
     far.position.z = L + HULL_D
 
@@ -1818,7 +2067,7 @@ export class InteriorModel {
     bore.rotateX(Math.PI / 2)
     bore.translate(0, 0, L + HULL_D / 2)
     const reveal = new Mesh(bore, this.material(FLAT_VERT, REVEAL_FRAG, {
-      side: BackSide, uniforms: { uTint: { value: new Color(0x9aa0aa) } },
+      side: BackSide, uniforms: { uTint: { value: new Color(0x9aa0aa) }, uCapIce: { value: this.capIceTexture } },
     }))
     reveal.name = 'bore'
     /**
@@ -1887,9 +2136,78 @@ export class InteriorModel {
    */
   private skyFrame = new Matrix3()
 
-  setSky(sky: Sky): { exposure: number; haze: Color } {
+  /**
+   * The far cap's frost, pane by pane (frost.ts), as the table GLAZED_CAP_FRAG reads.
+   * Rewritten from the clock whenever it has moved: 282 panes, nothing to it.
+   */
+  private readonly frostTexture = (() => {
+    const t = new DataTexture(new Uint8Array(FROST_SIZE * FROST_SIZE * 4), FROST_SIZE, FROST_SIZE, RGBAFormat)
+    t.magFilter = NearestFilter
+    t.minFilter = NearestFilter
+    t.needsUpdate = true
+    return t
+  })()
+  private readonly frost = new Float32Array(panes().length)
+  private frostAtS = NaN
+
+  /**
+   * How high the ice wall stands against the far cap, round the ring (CAP_ICE_GLSL, D-093):
+   * read off the ground at the cap's foot once per generation of the world. 2048 samples is
+   * one every 153 m of the circumference, filtered between.
+   */
+  private readonly capIceTexture = (() => {
+    const t = new DataTexture(new Uint8Array(CAP_ICE_SAMPLES * 4), CAP_ICE_SAMPLES, 1, RGBAFormat)
+    t.magFilter = LinearFilter
+    t.minFilter = LinearFilter
+    t.wrapS = RepeatWrapping
+    t.needsUpdate = true
+    return t
+  })()
+  private capIceGeneration = -1
+
+  private updateCapIce(): void {
+    const t = terrain()
+    if (!t.compiled || t.generation === this.capIceGeneration) return
+    this.capIceGeneration = t.generation
+    const data = this.capIceTexture.image.data as Uint8Array
+    for (let i = 0; i < CAP_ICE_SAMPLES; i++) {
+      // Just short of the end: the ground there is the wall's top against the cap.
+      const g = t.groundAt(((i + 0.5) / CAP_ICE_SAMPLES) * 2 * Math.PI, LENGTH_M - 20, 20)
+      // Dry ground at the foot of the cap is the range: its ice, or a ridge of it standing bare.
+      const top = g && !g.water ? Math.max(0, g.h) : 0
+      data[i * 4] = Math.round(Math.min(1, top / CAP_ICE_MAX_M) * 255)
+    }
+    this.capIceTexture.needsUpdate = true
+  }
+
+  private updateFrost(hours: number): void {
+    const tS = hours * 3600
+    const seed = terrain().world?.seed ?? 0
+    if (tS === this.frostAtS) return
+    this.frostAtS = tS
+    frostAt(seed, tS, this.frost)
+    const data = this.frostTexture.image.data as Uint8Array
+    panes().forEach((p, i) => {
+      const o = ((p.b + FROST_OFFSET) * FROST_SIZE + (p.a + FROST_OFFSET)) * 4
+      data[o + p.up] = Math.round(this.frost[i]! * 255)
+    })
+    this.frostTexture.needsUpdate = true
+  }
+
+  setSky(sky: Sky, eyeZ?: number): { exposure: number; haze: Color } {
+    this.updateFrost(sky.hours)
+    this.updateCapIce()
+    /**
+     * The far end's dim (D-091): the sea Tower lets freeze, if there is one. The shaders
+     * dim what is lit there (SUN_GLSL \`lightShare\`); the stars go by the light where the
+     * eye is, so someone on the fast ice at dusk sees the brightest of them.
+     */
+    const iced = terrain().compiled?.basins.find((b) => b.ice) ?? null
+    const mix = lightMix(sky)
+    const total = mix.sun + mix.rod + mix.moon
+    const local = iced && eyeZ !== undefined ? lightShareAt(iced.ice!, iced.openM, eyeZ, mix) : 1
     const exposure = (sky.watts / BRIGHTNESS_TEMPERATE_W_M2) ** 0.35
-    const starGain = (BRIGHTNESS_NIGHT_W_M2 / Math.max(sky.watts, BRIGHTNESS_NIGHT_W_M2)) ** 0.6
+    const starGain = (BRIGHTNESS_NIGHT_W_M2 / Math.max(sky.watts * local, BRIGHTNESS_NIGHT_W_M2)) ** 0.6
     const haze = new Color(sky.watts <= BRIGHTNESS_TWILIGHT_W_M2 ? HAZE_DUSK : HAZE_DAY)
     const zKm = (sky.sunZ ?? 0) / KM
     // The banked rod lights the night; with the beam shut down there is not even that.
@@ -1904,6 +2222,12 @@ export class InteriorModel {
       ;(m.uniforms['uHaze']!.value as Color).copy(haze)
       ;(m.uniforms['uSun']!.value as Vector3).set(zKm, sky.power, rod)
       ;(m.uniforms['uMoon']!.value as Vector4).set(moonKm, sky.moonLight, sky.moonRamp, phase)
+      const dim = m.uniforms['uDim']!.value as Vector4
+      if (iced) dim.set(iced.openM / KM, iced.ice!.edgeM / KM, iced.ice!.fastM / KM, Math.sign(iced.ice!.fastM - iced.ice!.edgeM))
+      else dim.set(0, 0, 0, 0)
+      const lm = m.uniforms['uLightMix']!.value as Vector3
+      if (total > 0) lm.set(mix.sun / total, mix.rod / total, mix.moon / total)
+      else lm.set(0, 1, 0)
     }
     this.sun.position.set(0, 0, zKm)
     this.sun.visible = sky.power > 0
diff --git a/src/views/interior.ts b/src/views/interior.ts
index d8505b9..b6482e4 100644
--- a/src/views/interior.ts
+++ b/src/views/interior.ts
@@ -444,7 +444,7 @@ export class InteriorView {
 
   /** Light the world for the clock's moment. Every frame, so a running clock is smooth. */
   private applyLighting(): void {
-    const { exposure, haze } = this.model.setSky(this.sky)
+    const { exposure, haze } = this.model.setSky(this.sky, this.at.z)
     // Scaled, so `dark` clears to black rather than to a dusk-coloured sky that
     // nothing in the scene is bright enough to justify.
     this.renderer.setClearColor(haze.clone().multiplyScalar(exposure), 1)
diff --git a/src/world/frost.ts b/src/world/frost.ts
new file mode 100644
index 0000000..b86a332
--- /dev/null
+++ b/src/world/frost.ts
@@ -0,0 +1,147 @@
+/**
+ * The far cap's frost, and the order Tower clears it in. See design.md §1.9.6, D-090, D-091.
+ *
+ * The far cap is single-glazed, and cold glass frosts on its inner face. Tower clears one
+ * pane per turn of the world, in an order it shuffles afresh every loop. The order is drawn
+ * from the world's seed and the loop's number, so which panes are clear at any moment is a
+ * pure function of the clock — the same in both views, and in the book — with no history to
+ * keep.
+ *
+ * Panes are the triangles of the cap's lattice (D-078): side `PANE_KM`, a vertex on the
+ * axis, and one family of mullions parallel to x. A pane is named by its lattice cell (a, b)
+ * and which half of it it is: with basis (s, 0) and (s/2, h), every point is a·e1 + b·e2,
+ * and the cell splits along a + b = whole into a lower and an upper triangle.
+ */
+import { RADIUS_M, SPIN_PERIOD_S } from '../canon/constants.js'
+import { hash3, mulberry32 } from '../terrain/noise.js'
+
+const R_KM = RADIUS_M / 1000
+/** A pane's side, km: six to the cap's radius, as the mullions are drawn (cap-pattern.ts). */
+export const PANE_KM = R_KM / 6
+const H_KM = (PANE_KM * Math.sqrt(3)) / 2
+
+export interface Pane {
+  a: number
+  b: number
+  /** 0 for the lower triangle of the cell, 1 for the upper. */
+  up: 0 | 1
+}
+
+/** The pane a point on the cap is in, km from the axis. */
+export function paneAt(x: number, y: number): Pane {
+  const B = y / H_KM
+  const A = x / PANE_KM - B / 2
+  const a = Math.floor(A), b = Math.floor(B)
+  return { a, b, up: A - a + (B - b) >= 1 ? 1 : 0 }
+}
+
+/** A pane's three corners, km. */
+export function paneCorners(p: Pane): [number, number][] {
+  const at = (a: number, b: number): [number, number] => [a * PANE_KM + (b * PANE_KM) / 2, b * H_KM]
+  return p.up
+    ? [at(p.a + 1, p.b), at(p.a + 1, p.b + 1), at(p.a, p.b + 1)]
+    : [at(p.a, p.b), at(p.a + 1, p.b), at(p.a, p.b + 1)]
+}
+
+/** Nearest distance from the axis to a segment. */
+function segmentDistance([x0, y0]: [number, number], [x1, y1]: [number, number]): number {
+  const dx = x1 - x0, dy = y1 - y0
+  const t = Math.max(0, Math.min(1, -(x0 * dx + y0 * dy) / (dx * dx + dy * dy)))
+  return Math.hypot(x0 + dx * t, y0 + dy * t)
+}
+
+/**
+ * Every pane with glass in it: each triangle the cap's disc reaches into, whole or cut by
+ * the rim. In a fixed order, so a pane's index means the same pane everywhere.
+ */
+export function farCapPanes(): Pane[] {
+  const out: Pane[] = []
+  const reach = Math.ceil(R_KM / H_KM) + 2
+  for (let b = -reach; b <= reach; b++) {
+    for (let a = -2 * reach; a <= 2 * reach; a++) {
+      for (const up of [0, 1] as const) {
+        const c = paneCorners({ a, b, up })
+        // A triangle meets the disc where its nearest point to the axis is inside it. The
+        // axis itself is a corner of six of them, so the edges are enough.
+        const near = Math.min(segmentDistance(c[0]!, c[1]!), segmentDistance(c[1]!, c[2]!),
+          segmentDistance(c[2]!, c[0]!))
+        if (near < R_KM - 1e-9) out.push({ a, b, up })
+      }
+    }
+  }
+  return out
+}
+
+/** The panes, built once. */
+let panesCached: Pane[] | null = null
+export function panes(): Pane[] {
+  return (panesCached ??= farCapPanes())
+}
+
+/** One pane is cleared per turn of the world (D-090): 7 min 29 s. */
+export const TURN_S = SPIN_PERIOD_S
+
+/** A loop clears every pane once: 282 turns, 35.1 hours. */
+export const loopS = (): number => panes().length * TURN_S
+
+/**
+ * How fast frost closes a cleared pane: its cover is 1 - e^(-age / FROST_TAU_S). Ten hours
+ * leaves about one pane in ten nearly clear at any moment, which is §1.9.6's guess; the frost
+ * rate is rough to a factor of ten, and this is the knob that answers for it.
+ */
+export const FROST_TAU_S = 10 * 3600
+
+/** The order of loop `k`: pane indices in the order they are cleared, shuffled from the seed. */
+export function loopOrder(seed: number, k: number): number[] {
+  const n = panes().length
+  const order = Array.from({ length: n }, (_, i) => i)
+  const r = mulberry32(hash3(seed, k, 0xf405))
+  for (let i = n - 1; i > 0; i--) {
+    const j = Math.floor(r() * (i + 1))
+    const t = order[i]!
+    order[i] = order[j]!
+    order[j] = t
+  }
+  return order
+}
+
+/** Where each pane falls in loop `k`'s order: the inverse of `loopOrder`. */
+function slots(seed: number, k: number): number[] {
+  const order = loopOrder(seed, k)
+  const slot = new Array<number>(order.length)
+  order.forEach((pane, s) => { slot[pane] = s })
+  return slot
+}
+
+const cover = (ageS: number): number => 1 - Math.exp(-Math.max(0, ageS) / FROST_TAU_S)
+
+/** The two loops' slots last asked for: a frame at a time, the loop rarely changes. */
+let memo: { seed: number; k: number; now: number[]; before: number[] } | null = null
+
+/**
+ * The frost on every pane at `tS` seconds of world time, 0 (clear) to 1 (white), into
+ * `out`, in `panes()` order. The pane being cleared this turn thaws across it.
+ */
+export function frostAt(seed: number, tS: number, out: Float32Array): Float32Array {
+  const L = loopS()
+  const k = Math.floor(tS / L)
+  if (!memo || memo.seed !== seed || memo.k !== k) {
+    memo = { seed, k, now: slots(seed, k), before: slots(seed, k - 1) }
+  }
+  const start = k * L
+  const s = Math.floor((tS - start) / TURN_S)
+  const progress = (tS - start) / TURN_S - s
+  const n = panes().length
+  for (let i = 0; i < n; i++) {
+    const mine = memo.now[i]!
+    // When this pane last came clear: earlier in this loop, or in the loop before.
+    const clearedAt = mine < s ? start + (mine + 1) * TURN_S : start - L + (memo.before[i]! + 1) * TURN_S
+    if (mine === s) {
+      // Being cleared now: the frost it had built by the start of its turn, thawing.
+      out[i] = cover(start + s * TURN_S - clearedAt) * (1 - progress)
+    } else {
+      out[i] = cover(tS - clearedAt)
+    }
+  }
+  return out
+}
diff --git a/src/world/schema.ts b/src/world/schema.ts
index 5fd1239..846fb84 100644
--- a/src/world/schema.ts
+++ b/src/world/schema.ts
@@ -128,6 +128,26 @@ export interface Sea extends Base<'sea'> {
   wind: Wind
   /** Sailing ships working the sea. A ring sea's sail round the world (world-generation.md §4.4). */
   ships: number
+  /** Ice Tower lets stand on it, and the dim it keeps over it, or null for an open sea (D-090). */
+  ice: SeaIce | null
+}
+
+/**
+ * A sea's ice, in bands across the axis (design.md §1.9.6, D-090). Open water up to
+ * `edgeM`, pack ice from there to `fastM`, and fast ice from `fastM` to the end of the sea
+ * beyond it — which end is whichever side of `edgeM` the fast ice is on. Tower dims its
+ * aimed light over the sea toward the ice, and aims none at the fast ice.
+ */
+export interface SeaIce {
+  /** Where the pack ice begins, z in metres: the ice edge. */
+  edgeM: number
+  /** Where the fast ice begins, z in metres. */
+  fastM: number
+  /**
+   * The range Tower raised at the foot of the cap for the ice to stand on (D-093), or null:
+   * rock rising from the sea bed at `fromM` to `heightM` above the datum at the sea's end.
+   */
+  range: { fromM: number; heightM: number } | null
 }
 
 export interface River extends Base<'river'> {
@@ -385,7 +405,8 @@ 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 }, ships: 90 }
+      return { ...c, kind, levelM: 90, depthM: 420, shoreM: 4000, wind: { directionDeg: 90, speedMS: 9 }, ships: 90,
+        ice: null }
     case 'river':
       return { ...c, kind, flowM3S: 40, sourceM: 'terrain', outlet: 'auto' }
     case 'desert':
@@ -559,7 +580,8 @@ function fillParams(d: Feature, f: Record<string, unknown>): Feature {
                depthM: Math.max(1, num(f['depthM'], d.depthM)),
                shoreM: Math.max(1, num(f['shoreM'], d.shoreM)),
                wind: parseWind(f['wind'], d.wind),
-               ships: Math.max(0, Math.min(400, Math.floor(num(f['ships'], d.ships)))) }
+               ships: Math.max(0, Math.min(400, Math.floor(num(f['ships'], d.ships)))),
+               ice: parseIce(f['ice']) }
     case 'river':
       return { ...d, flowM3S: Math.max(0.1, num(f['flowM3S'], d.flowM3S)),
                sourceM: Number.isFinite(f['sourceM']) ? (f['sourceM'] as number) : 'terrain',
@@ -677,6 +699,17 @@ function parseWind(w: unknown, fallback: Wind): Wind {
   }
 }
 
+/** A sea's ice, or null where it has none or the two edges are not two different places. */
+function parseIce(i: unknown): SeaIce | null {
+  const src = i as Partial<SeaIce> | null
+  if (!src || typeof src !== 'object') return null
+  if (!Number.isFinite(src.edgeM) || !Number.isFinite(src.fastM) || src.edgeM === src.fastM) return null
+  const r = src.range as Partial<{ fromM: number; heightM: number }> | null | undefined
+  const range = r && Number.isFinite(r.fromM) && Number.isFinite(r.heightM) && r.heightM! > 0
+    ? { fromM: r.fromM!, heightM: r.heightM! } : null
+  return { edgeM: src.edgeM!, fastM: src.fastM!, range }
+}
+
 function parseGeometry(g: unknown): Geometry | null {
   const src = g as {
     type?: string; theta?: number; z?: number; points?: unknown
diff --git a/src/world/sky.ts b/src/world/sky.ts
index 26cabff..f7237ab 100644
--- a/src/world/sky.ts
+++ b/src/world/sky.ts
@@ -193,6 +193,16 @@ export function skyAt(cal: Calendar, hours: number, dial: Dial): Sky {
   }
 }
 
+/**
+ * The sky's light split by where it comes from, W/m²: the sun, the banked rod and the moon.
+ * They add up to `watts`. The split matters where Tower aims less light than elsewhere (the
+ * far end, D-091): the sun and the moon are aimed there, the rod never is.
+ */
+export function lightMix(sky: Sky): { sun: number; rod: number; moon: number } {
+  const night = sky.dial === 'dark' ? 0 : BRIGHTNESS_NIGHT_W_M2
+  return { sun: (DIALS[sky.dial] - night) * sky.power, rod: night, moon: night * sky.moonLight }
+}
+
 /**
  * The real instant of a moment on the city's clock. The offset is the one at the day's
  * noon, as in `sunTimes`: on the two nights a year the clocks change it is an hour out
diff --git a/test/far-end.test.ts b/test/far-end.test.ts
new file mode 100644
index 0000000..4afcb4d
--- /dev/null
+++ b/test/far-end.test.ts
@@ -0,0 +1,195 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import { SPIN_PERIOD_S } from '../src/canon/constants.js'
+import {
+  AIMED_AT_EDGE, ICE_FRONT_M, ICE_MANTLE_M, ROD_GLOW_BY_DAY, aimedShare, iceCover, iceMantle, iceMargin, intoIce,
+  lightShare, rangeRock,
+} from '../src/terrain/ice.js'
+import { ICE_CLEAR_M, lanesOf } from '../src/terrain/ships.js'
+import { TRIANGLE_KM } from '../src/views/cap-pattern.js'
+import { FROST_TAU_S, PANE_KM, TURN_S, frostAt, loopOrder, loopS, paneAt, paneCorners, panes } from '../src/world/frost.js'
+import { type Sea, parseWorld } from '../src/world/schema.js'
+
+const world = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8')))
+const deepward = world.features.find((f) => f.id === 'deepward-sea') as Sea
+
+describe('the Deepward Sea freezes in bands (D-090)', () => {
+  it('has the bands §1.9.6 gives: open to 512 km, pack to 525, fast to the cap', () => {
+    expect(deepward.ice).toMatchObject({ edgeM: 512_000, fastM: 525_000 })
+  })
+
+  it('is open water short of the edge, pack across the band, and solid fast ice beyond', () => {
+    const ice = deepward.ice!
+    expect(iceCover(intoIce(ice, 505_000))).toBe(0)
+    const pack = [513_000, 518_000, 524_000].map((z) => iceCover(intoIce(ice, z)))
+    expect(pack[0]).toBeGreaterThan(0.25)
+    expect(pack[1]).toBeGreaterThan(pack[0]!)
+    expect(pack[2]).toBeGreaterThan(pack[1]!)
+    expect(pack[2]).toBeLessThan(1)
+    expect(iceCover(intoIce(ice, 530_000))).toBe(1)
+  })
+
+  it('reads a frozen sea with its ice the other way round, and no ice at all without it', () => {
+    const w = parseWorld({ ...JSON.parse(readFileSync('content/world.json', 'utf8')) })
+    const sunward = w.features.find((f) => f.id === 'sunward-sea') as Sea
+    expect(sunward.ice).toBeNull()
+    expect(iceCover(intoIce({ edgeM: 30_000, fastM: 15_000, range: null }, 10_000))).toBe(1)
+    expect(iceCover(intoIce({ edgeM: 30_000, fastM: 15_000, range: null }, 35_000))).toBe(0)
+  })
+
+  it("keeps the ships on the open water, clear of the ice edge", () => {
+    const lanes = lanesOf(deepward, world.seed)
+    expect(lanes.length).toBe(deepward.ships)
+    for (const l of lanes) {
+      expect(l.z0 + l.weaveM).toBeLessThan(deepward.ice!.edgeM - ICE_CLEAR_M)
+      expect(l.z0 - l.weaveM).toBeGreaterThan(496_000)
+    }
+  })
+})
+
+describe('ice mountains at the foot of the cap (D-093)', () => {
+  const range = deepward.ice!.range!
+  const level = deepward.levelM
+  const bed = level - 420
+
+  it('stands on a range Tower raised: from the sea bed at 524 km to 2,400 m at the cap', () => {
+    expect(range).toEqual({ fromM: 524_000, heightM: 2400 })
+    expect(rangeRock(0, bed, range.heightM)).toBe(bed)
+    expect(rangeRock(1, bed, range.heightM)).toBe(range.heightM)
+    // Steepening toward the window.
+    const a = rangeRock(0.5, bed, 2400) - rangeRock(0.4, bed, 2400)
+    const b = rangeRock(1, bed, 2400) - rangeRock(0.9, bed, 2400)
+    expect(b).toBeGreaterThan(a)
+  })
+
+  it('is iced from where the rock leaves the water, in a low front', () => {
+    const t = iceMargin(level, bed, range.heightM)
+    expect(rangeRock(t, bed, range.heightM)).toBeCloseTo(level - 40, 6)
+    expect(iceMantle(0)).toBeCloseTo(ICE_FRONT_M, 6)
+  })
+
+  it('thickens as an ice sheet does, to 350 m on the heights', () => {
+    expect(iceMantle(1000)).toBeGreaterThan(150)
+    expect(iceMantle(1000)).toBeLessThan(ICE_MANTLE_M)
+    expect(iceMantle(10_000)).toBe(ICE_MANTLE_M)
+  })
+
+  it('stands kilometres up the window all round, with open water still open', async () => {
+    const { compileWorld } = await import('../src/terrain/compile.js')
+    const { prepare } = await import('../src/terrain/field.js')
+    const rt = prepare(compileWorld(world))
+    const tops: number[] = []
+    for (let i = 0; i < 24; i++) {
+      const theta = (i / 24) * 2 * Math.PI
+      const cap = rt.sample(theta, 539_980, 20)
+      expect(cap.water).toBeLessThan(cap.h)
+      tops.push(cap.h)
+      const fast = rt.sample(theta, 526_000, 20)
+      expect(fast.water).toBe(level)
+      expect(fast.ice).toBe(1)
+      const open = rt.sample(theta, 505_000, 20)
+      expect(open.glacier).toBe(0)
+      expect(open.water).toBe(level)
+    }
+    expect(Math.min(...tops)).toBeGreaterThan(1500)
+    expect(Math.max(...tops)).toBeLessThan(4000)
+    // A range, not a kerb: the heights vary round the ring.
+    expect(Math.max(...tops) - Math.min(...tops)).toBeGreaterThan(100)
+  }, 60_000)
+})
+
+describe('Tower keeps the far end dim (D-091)', () => {
+  const ice = deepward.ice!
+  const open = 496_000
+
+  it('aims its full light short of the sea, and less and less toward the ice', () => {
+    expect(aimedShare(ice, open, 480_000)).toBe(1)
+    expect(aimedShare(ice, open, 504_000)).toBeLessThan(1)
+    expect(aimedShare(ice, open, 504_000)).toBeGreaterThan(AIMED_AT_EDGE)
+    expect(aimedShare(ice, open, 512_000)).toBeCloseTo(AIMED_AT_EDGE, 6)
+    expect(aimedShare(ice, open, 520_000)).toBeLessThan(AIMED_AT_EDGE)
+    expect(aimedShare(ice, open, 530_000)).toBe(0)
+  })
+
+  it('leaves the fast ice only the rod by day: a twentieth, dusk', () => {
+    const day = { sun: 320, rod: 0.32, moon: 0 }
+    expect(lightShare(ice, open, 535_000, day)).toBeCloseTo(ROD_GLOW_BY_DAY, 2)
+    expect(lightShare(ice, open, 300_000, day)).toBe(1)
+  })
+
+  it('changes nothing by a moonless night: the rod is never aimed', () => {
+    expect(lightShare(ice, open, 535_000, { sun: 0, rod: 0.32, moon: 0 })).toBe(1)
+  })
+
+  it('takes the moon off the fast ice', () => {
+    expect(lightShare(ice, open, 535_000, { sun: 0, rod: 0.32, moon: 0.32 })).toBeCloseTo(0.5, 6)
+  })
+})
+
+describe('the far cap frosts, and Tower clears one pane per turn (D-090)', () => {
+  it('has 282 panes: 216 whole triangles and 66 cut by the rim', () => {
+    expect(panes().length).toBe(282)
+    const whole = panes().filter((p) => paneCorners(p).every(([x, y]) => Math.hypot(x, y) <= 50 + 1e-9))
+    expect(whole.length).toBe(216)
+  })
+
+  it('uses the same triangles as the mullions', () => {
+    expect(PANE_KM).toBeCloseTo(TRIANGLE_KM, 12)
+  })
+
+  it('finds each pane from a point inside it', () => {
+    for (const p of panes()) {
+      const c = paneCorners(p)
+      const x = (c[0]![0] + c[1]![0] + c[2]![0]) / 3
+      const y = (c[0]![1] + c[1]![1] + c[2]![1]) / 3
+      expect(paneAt(x, y)).toEqual(p)
+    }
+  })
+
+  it('takes one turn of the world per pane: a loop of 35.1 hours', () => {
+    expect(TURN_S).toBeCloseTo(SPIN_PERIOD_S, 9)
+    expect(loopS() / 3600).toBeCloseTo(35.1, 1)
+  })
+
+  it('clears every pane exactly once a loop, in an order reshuffled each loop', () => {
+    const a = loopOrder(world.seed, 7)
+    expect([...a].sort((x, y) => x - y)).toEqual(panes().map((_, i) => i))
+    expect(loopOrder(world.seed, 7)).toEqual(a)
+    expect(loopOrder(world.seed, 8)).not.toEqual(a)
+    expect(loopOrder(world.seed + 1, 7)).not.toEqual(a)
+  })
+
+  it('thaws the pane being cleared across its turn, and nothing else at once', () => {
+    const L = loopS()
+    const k = 12
+    const first = loopOrder(world.seed, k)[0]!
+    const out = new Float32Array(panes().length)
+    frostAt(world.seed, k * L + 0.001, out)
+    const before = out[first]!
+    frostAt(world.seed, k * L + TURN_S * 0.5, out)
+    expect(out[first]).toBeCloseTo(before * 0.5, 2)
+    frostAt(world.seed, k * L + TURN_S * 0.999, out)
+    expect(out[first]).toBeLessThan(0.01)
+    frostAt(world.seed, k * L + TURN_S * 3, out)
+    expect(out[first]).toBeCloseTo(1 - Math.exp(-(2 * TURN_S) / FROST_TAU_S), 3)
+  })
+
+  it('does not jump where one loop ends and the next begins', () => {
+    const L = loopS()
+    const a = frostAt(world.seed, 20 * L - 1e-3, new Float32Array(panes().length))
+    const b = frostAt(world.seed, 20 * L + 1e-3, new Float32Array(panes().length))
+    for (let i = 0; i < a.length; i++) expect(Math.abs(a[i]! - b[i]!)).toBeLessThan(1e-3)
+  })
+
+  it('leaves about one pane in ten nearly clear, averaged over a loop', () => {
+    const L = loopS()
+    const out = new Float32Array(panes().length)
+    let clear = 0, total = 0
+    for (let t = 30 * L; t < 31 * L; t += L / 97) {
+      frostAt(world.seed, t, out)
+      for (const f of out) { total++; if (f < 0.3) clear++ }
+    }
+    expect(clear / total).toBeGreaterThan(0.06)
+    expect(clear / total).toBeLessThan(0.16)
+  })
+})
diff --git a/test/terrain.test.ts b/test/terrain.test.ts
index 4b6b7a5..543b740 100644
--- a/test/terrain.test.ts
+++ b/test/terrain.test.ts
@@ -17,8 +17,8 @@ const rt = prepare(cw)
 describe('the world compiles', () => {
   it('ports every v1 feature, plus the ground raised under the rivers', () => {
     // Twenty-nine ported from v1, plus The Tall Wood, which Tower placed from the seed (D-071),
-    // and the two railways (D-086).
-    expect(world.features).toHaveLength(32)
+    // the two railways (D-086), and the Cap Range's name (D-093).
+    expect(world.features).toHaveLength(33)
     // Eight ranges in two groups: the Girdle with the four massifs on it, and the
     // three desert ridges, which overlap each other.
     expect(cw.mountains).toHaveLength(2)
@@ -184,7 +184,7 @@ describe('water', () => {
   it('puts water on the lakes and seas', () => {
     expect(rt.sample(0.38, 320_000, 50).water).toBe(120)       // The Deep
     expect(rt.sample(2, 10_000, 50).water).toBe(90)            // The Sunward Sea
-    expect(rt.sample(2, 530_000, 50).water).toBe(90)           // The Deepward Sea
+    expect(rt.sample(2, 520_000, 50).water).toBe(90)           // The Deepward Sea, short of its ice mountains
   })
 
   it('never lets a river flow uphill', () => {
diff --git a/world-generation.md b/world-generation.md
index 319ee4d..28a05a9 100644
--- a/world-generation.md
+++ b/world-generation.md
@@ -190,6 +190,8 @@ interface Sea {
   depthM: number
   wind: Wind
   ships: { count: number; routes: 'ring' | 'coastal' | 'crossing' }
+  ice?: { edgeM: number; fastM: number;     // D-090: open water, pack from edgeM, fast from fastM
+          range?: { fromM: number; heightM: number } }  // D-093: ice mountains at the cap
 }
 
 interface Desert {
@@ -912,7 +914,7 @@ What each concept is authored as — the parameters a change touches:
 | Mountains | polygon or band | peaks (name, position, elevation), generated-summit height and count, ruggedness, erosion, snowline |
 | Lake | polygon (the shore) | level (auto or a number), depth, basin, shore, rim, wind |
 | River | polyline, source to mouth | flow, source, outlet (by id, or auto) |
-| Sea | band or polygon | level, depth, shore, wind, ships |
+| Sea | band or polygon | level, depth, shore, wind, ships, ice (the ice edge and where the fast ice begins) |
 | Desert | polygon or band | uplift, edge, dunes, hills, plateaus, past water |
 | Forest | polygon or band | uplift, edge, density, conifer share, meadows, treeline, giants |
 | Settlement | point | size, radius; a generated one is **adopted** by writing it in |
@@ -1230,7 +1232,23 @@ draws in about 1.2 s.
   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.
+  before the wind and in toward the centreline heading into it. On a sea with ice the
+  lanes keep to the open water, 2 km short of the ice edge (D-091).
+- **Sea ice** (`terrain/ice.ts`, D-090, D-091): a sea's `ice` gives its ice edge and where
+  its fast ice begins, as z; the fast ice runs on from there to the sea's end. Each water
+  sample carries its ice cover, 0..1 — the pack from 0.3 at its edge to 0.95 at the fast
+  ice, 1 on the fast ice — with the edge wandering 1.5 km and the fast ice's 400 m, by
+  noise. The water shader breaks the pack into 110 m Voronoi floes, each ice or open by
+  its own hash against the cover, with leads between and cracks where two floes meet; the
+  fast ice is one sheet cracked in 660 m cells. Ice stills the waves under it. The map
+  whitens the water by the cover.
+- **Ice mountains** (`field.ts glacier`, D-093): a sea's `ice.range` raises rock from the sea
+  bed at `fromM` to `heightM` at the sea's end, steepening toward it (height ∝ t^1.6), ±30%
+  round the ring and ridged by up to 0.7 of its height. Ice mantles it from where it leaves
+  the water, as an ice sheet's plastic profile (τ = 150 kPa) to 350 m, draping 60% of the
+  ridges; where a ridge stands out of the ice it is bare rock. Grounded ice is white however
+  steep. The far cap's shader buries the glass's foot in ice to the height the ground stands
+  at the cap, read from the terrain round the ring into a 2,048-sample strip.
 - **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).