fuzzyworld

tower / commit

fix(world): raise the lowland, thicken the hull, and let rivers prefer to cut

14d35b7d7be2e11f66775a2a49924905a42fd9b0 · Pavel Safronov · 2026-09-16 15:02:33 -0700 · parents 376f2ce60d04

Claude Code usage: this commit ~90.5K out / ~44.3M cached · cost unknown (no per-model trailer) · session 1 tally 900 in / 723.1K out / 183.8M cached · usage by commit and session

fix(world): raise the lowland, thicken the hull, and let rivers prefer to cut

D-064, from the author's review of phase 1.

- The Uplands: a region can now raise ground, lifting it to a height
  rather than by one, and a ring of it between the Girdle and the Dry
  Reach takes the worst river embankment from ~950 m to under 200 m
- river profiles fit the 0.2-quantile of the ground, so the floor is
  mostly cut and only built up where it must be
- the exterior's skin sits at the wall's outer face, a kilometre out,
  with the wall closed at both ends: sea floors and tile skirts no
  longer show through the hull
- a #view= link no longer builds its 3D view twice
- rivers draw as continuous lines on coarse tiles, not dotted ones

Usage: 0.9K in / 723.1K out / 183.8M cached (session tally)
rtk:   3.3M saved, 37.8% over 3501 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 README.md                                      |   4 ++
 content/world.json                             |  16 +++++++
 decisions.md                                   |  46 ++++++++++++++++++
 design.md                                      |  31 +++++++------
 docs/screenshots/desktop-exterior-true.png     | Bin 524368 -> 489413 bytes
 docs/screenshots/desktop-exterior.png          | Bin 539835 -> 519903 bytes
 docs/screenshots/desktop-interior-dawn.png     | Bin 469376 -> 463879 bytes
 docs/screenshots/desktop-interior-night.png    | Bin 305425 -> 305481 bytes
 docs/screenshots/desktop-interior-overhead.png | Bin 510622 -> 510708 bytes
 docs/screenshots/desktop-interior-suncap.png   | Bin 389055 -> 384139 bytes
 docs/screenshots/desktop-interior.png          | Bin 414916 -> 410017 bytes
 docs/screenshots/desktop-map-sightline.png     | Bin 928873 -> 928303 bytes
 docs/screenshots/desktop-map.png               | Bin 901075 -> 901096 bytes
 docs/screenshots/desktop-reader.png            | Bin 885047 -> 884742 bytes
 docs/screenshots/mobile-exterior.png           | Bin 121671 -> 116101 bytes
 docs/screenshots/mobile-interior.png           | Bin 120338 -> 117806 bytes
 docs/screenshots/mobile-map.png                | Bin 239517 -> 239561 bytes
 docs/screenshots/mobile-panel.png              | Bin 81032 -> 80595 bytes
 docs/screenshots/overview.png                  | Bin 1211003 -> 1198797 bytes
 src/terrain/compile.ts                         |  62 +++++++++++++++++--------
 src/terrain/field.ts                           |  56 +++++++++++++++-------
 src/terrain/map-image.ts                       |   2 +-
 src/terrain/service.ts                         |   3 +-
 src/terrain/tile.ts                            |  28 +++++------
 src/ui/app.ts                                  |  14 +++++-
 src/views/exterior.ts                          |  41 ++++++++++++----
 src/views/interior-model.ts                    |   9 +---
 src/world/schema.ts                            |  21 +++++++--
 static/world.json                              |  16 +++++++
 test/terrain.test.ts                           |  44 ++++++++++++++----
 world-generation.md                            |  10 +++-
 31 files changed, 304 insertions(+), 99 deletions(-)

diff --git a/README.md b/README.md
index 8a2c524..9c15b74 100644
--- a/README.md
+++ b/README.md
@@ -309,6 +309,10 @@ The rest are a single click. The ground regenerates as soon as the feature exist
 Drag markers to move them, **shift-click two points** to measure a sightline, **f**
 to fit the view.
 
+A **Region** is a label, and it can also be ground Tower raised: give it an uplift and
+everything inside it is lifted to that height above the open ground, wherever that is
+higher than what is there. The Uplands are one.
+
 Changing a feature's **Kind** swaps its parameters for the new kind's defaults and
 keeps its name, notes, shape and seed; only kinds that can take its shape are offered.
 
diff --git a/content/world.json b/content/world.json
index 03f885e..a8c5fd2 100644
--- a/content/world.json
+++ b/content/world.json
@@ -599,6 +599,22 @@
       "upliftM": 780,
       "edgeM": 20000
     },
+    {
+      "id": "the-uplands",
+      "name": "The Uplands",
+      "blurb": "",
+      "notes": "Provisional name. Ground Tower raised between the Girdle and the Dry Reach, so the four rivers off the range can run down to The Narrows instead of into a hole. v1 left this as a lowland 60 m high below an 800 m plateau and painted the rivers across it a kilometre up; generated honestly they needed embankments up to 950 m high (D-063). Raised to about 1,000 m, the rivers need at most ~200 m of levee (D-064).",
+      "public": true,
+      "seed": 1990705996,
+      "kind": "region",
+      "geometry": {
+        "type": "band",
+        "zFrom": 105000,
+        "zTo": 150000
+      },
+      "upliftM": 1000,
+      "edgeM": 8000
+    },
     {
       "id": "first-ridge",
       "name": "First Ridge",
diff --git a/decisions.md b/decisions.md
index 2686df4..6c70078 100644
--- a/decisions.md
+++ b/decisions.md
@@ -2039,3 +2039,49 @@ since the picture on the hull changed anyway.
 1.2 s to draw the map, so the status dialog rarely appears on the author's laptop; it
 will on slower machines, and it will once erosion arrives in phase 2. A production
 build reaches a fully detailed interior 4.3 s after a cold load.
+
+---
+
+## D-064 — The Uplands, a hull a kilometre thick, and rivers that prefer to cut
+
+**The author's call on D-063's open question: raise the lowland.** A region can now
+carry an uplift, and **The Uplands** (provisional name) lift the ring of ground between
+the Girdle and the Dry Reach — z 105 to 150 km — to about 1,000 m. A raised region lifts
+the ground *to* a height rather than *by* one: it takes the higher of itself and what is
+already there. Added, it stacked onto the plateau's rising edge and moved the river's
+problem ten kilometres deepward instead of solving it; taken as a maximum, it meets the
+plateau and stops.
+
+Measured before choosing, as each river's worst embankment:
+
+| | worst fill | The Narrows cut |
+|---|---|---|
+| v1's lowland | 895–980 m | ~670 m |
+| Uplands at 1,000 m, least-squares profile | 240–350 m | ~390 m |
+| Uplands at 1,000 m, **0.2-quantile profile** | **93–199 m** | ~540 m |
+
+**The profile now prefers cutting to filling.** Least squares treats a metre of
+embankment like a metre of cutting, and so balances the two; but a cut through high
+ground reads as a valley and a 300 m bank across a plain reads as a dam. Fitting the
+0.2-quantile instead keeps the water below four-fifths of the ground in any reach. The
+Narrows comes out a little past design.md's "up to 480 m" at about 540 m, which is
+recorded rather than tuned away.
+
+**The hull is a kilometre thick all the way round.** D-044 gave Tower a 1 km wall and
+drew it only at the caps, reasoning that a closed can shows no thickness from outside.
+That held while the ground never went far below the floor. Generated sea floors reach
+330 m down and the tiles hang skirts below that, and with the outer skin at the floor's
+own radius they came through the hull as a lattice of lines (the author's screenshot).
+The skin now sits a kilometre out, rings close the wall at both ends, and nothing
+generated may go deeper than 950 m below the datum.
+
+**Two smaller faults found on the way.**
+
+- *A `#view=` link built its 3D view twice.* Reading the hash and choosing the initial
+  view each started the deferred import before either had finished, so the exterior
+  drew every caption twice and the interior would have run two renderers and two sets
+  of tiles. The import is now shared.
+- *Rivers drew as dotted lines at a distance.* D-063 weighted water by the channel's
+  width over the vertex spacing, but only at vertices that landed in the channel. It
+  now weighs every vertex by the share of its own cell the channel covers, so a coarse
+  tile draws a river as a faint continuous line.
diff --git a/design.md b/design.md
index 197a786..9df4b33 100644
--- a/design.md
+++ b/design.md
@@ -546,6 +546,7 @@ along the axis**, which is the natural grain of an engineered climate (§1.9.4):
 |---|---|---|
 | 0–42 km | **The Sunward Sea** | ring ocean at the foot of the sun cap |
 | 55–140 km | **the range** | Westwall, The Sisters, **Snowcrown** (4,300 m), Eastwall — the named high points of **The Girdle**, which rings the world at z 90 km (D-051) |
+| 105–150 km | **The Uplands** | ground Tower raised to about 1,000 m, ringing the world, so the rivers off the range can run down to The Narrows (D-064) |
 | 148–218 km | **The Dry Reach** | desert **ringing the world**, 22,000 km², 13% of the surface |
 | 176/190/203 km | **three ridges** | ringing, concentric — and **The Narrows** is the only breach in all three |
 | 214–430 km | **Greatwood** | forest belt |
@@ -574,16 +575,17 @@ gathering each other, and leave with the fourth through the single gap.
   where Coriolis is exactly zero.
 - Whoever holds The Narrows holds the axis of the world. The Capital sits behind it.
 
-**The drainage needs the floor built up in one place, and generating it honestly is
-what showed it** (D-063). Between the Girdle's foot and the Dry Reach's rise — z 115
-to 135 km — the ground authored in v1 is a lowland barely 60 m high, while the plateau
-the rivers must cross beyond it stands at 800–1,100 m. v1 drew its rivers across that
-lowland at about 1,000 m, painted over ground a kilometre below them. A river cannot
-climb, so the generator gives each river the descending profile nearest the ground it
-crosses: it cuts The Narrows about 300–500 m into the ridges — the depth this section
-has always given — and **builds the lowland up by up to 880 m under each river** to
-carry it there. Tower shapes its floor so its rivers run; here that means four
-engineered embankments across a plain. See the open question below.
+**The drainage needed the floor raised, and generating it honestly is what showed it**
+(D-063, D-064). As v1 authored it, the ground between the Girdle's foot and the Dry
+Reach — z 115 to 135 km — was a lowland barely 60 m high, below a plateau of
+800–1,100 m, and v1 painted its rivers across it a kilometre up. A river cannot climb.
+Generated faithfully, each needed an embankment up to 950 m high to reach The Narrows.
+
+So Tower raised the lowland: **The Uplands**, a ring of ground lifted to about 1,000 m,
+meeting the Girdle's skirt on one side and the plateau's edge on the other. The rivers
+now fall steadily off the range, across the Uplands and onto the plateau, and the floor
+under them is mostly cut — The Narrows about 500 m deep — and built up by no more than
+~200 m anywhere, the height of a levee rather than a dam.
 
 #### The Deep is oriented for the view
 
@@ -620,10 +622,6 @@ Hollow Station — around 2,100 m, with the runs above them.
 
 #### Open questions
 
-- **The lowland under the rivers.** The four embankments above follow from the ground
-  as authored. The alternatives are to raise the lowland itself — the Girdle's skirt
-  reaching out to meet the Dry Reach — or to let the rivers pool there as a lake that
-  spills over the plateau's lip. Either is a change to the world, not to the generator.
 - **The Spindle's height.** 3.2 km is a placeholder; an object dropped from its top
   lands ~760 m antispinward of its base.
 - **z 430–496 km** is unauthored. The circumference outside the named peaks is no
@@ -784,7 +782,10 @@ collector and the stars beyond the far one. The shaders work in the tower's fram
 and the exterior hands them the world-to-tower transform each frame, because the
 hull is turning and the air turns with it.
 
-The outer skin keeps its own mesh and its own two modes. Three's cylinder runs u
+The outer skin keeps its own mesh and its own two modes. It sits **a kilometre out from
+the floor**, where D-044 puts the wall's outer face, with a ring closing the wall at each
+end — generated ground goes below the datum, and a skin at the floor's own radius let
+sea floors and tile skirts show through the hull (D-064). Three's cylinder runs u
 around and v along, while the image is the other way round, so the UVs are rewritten
 from the original attribute rather than derived from vertex positions — the seam
 vertices are duplicated, and deriving an angle from them smears one column of
diff --git a/docs/screenshots/desktop-exterior-true.png b/docs/screenshots/desktop-exterior-true.png
index 7ef525d..177726b 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 4635b8a..a297bfa 100644
Binary files a/docs/screenshots/desktop-exterior.png and b/docs/screenshots/desktop-exterior.png differ
diff --git a/docs/screenshots/desktop-interior-dawn.png b/docs/screenshots/desktop-interior-dawn.png
index 71fac24..75e2e9b 100644
Binary files a/docs/screenshots/desktop-interior-dawn.png and b/docs/screenshots/desktop-interior-dawn.png differ
diff --git a/docs/screenshots/desktop-interior-night.png b/docs/screenshots/desktop-interior-night.png
index 8e2fb63..5b7b3d3 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 7168884..db2e892 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 0981048..8ee131e 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 62b49a1..41d3b9b 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 efb751d..dedc82a 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 f6f8f95..2f0ec03 100644
Binary files a/docs/screenshots/desktop-map.png and b/docs/screenshots/desktop-map.png differ
diff --git a/docs/screenshots/desktop-reader.png b/docs/screenshots/desktop-reader.png
index da6bd35..8310124 100644
Binary files a/docs/screenshots/desktop-reader.png and b/docs/screenshots/desktop-reader.png differ
diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png
index bca5379..0485ef0 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 ce788ec..8c8d284 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 20977b8..db21807 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 3359577..8497ed6 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 ff14d4d..9b29edf 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 56964e6..a13761a 100644
--- a/src/terrain/compile.ts
+++ b/src/terrain/compile.ts
@@ -22,7 +22,8 @@ import { elevationStage, prepare, Stage } from './field.js'
 
 export interface CUplift {
   id: string
-  kind: 'desert' | 'forest'
+  /** A region raises ground and claims no cover; a desert or forest does both. */
+  kind: 'desert' | 'forest' | 'region'
   shape: Shape
   upliftM: number
   edgeM: number
@@ -124,8 +125,9 @@ export function compileWorld(world: World, onStep: StepListener = () => {}): Com
   // Order is the order of the elevation stack (field.ts): each stage may sample
   // everything compiled before it, and nothing after.
   for (const f of world.features) {
-    if (f.kind !== 'desert' && f.kind !== 'forest') continue
-    onStep(f.kind === 'desert' ? `Quoting the deserts of Earth in ${f.name}` : `Planting ${f.name}`)
+    if (f.kind !== 'desert' && f.kind !== 'forest' && !(f.kind === 'region' && f.upliftM)) continue
+    onStep(f.kind === 'desert' ? `Quoting the deserts of Earth in ${f.name}`
+      : f.kind === 'forest' ? `Planting ${f.name}` : `Raising ${f.name}`)
     const shape = shapeOf(f.geometry)
     if (!shape) continue
     cw.uplifts.push({
@@ -282,6 +284,13 @@ const RIVER_STEP_M = 200
  * reaches. Anything flatter is a canal, and a canal has to be pumped.
  */
 const MIN_GRADE = 0.002
+/**
+ * How strongly a river's profile prefers cutting to filling: the quantile of the ground
+ * it sits at. 0.5 would treat a metre of embankment like a metre of cutting; 0.2 lets
+ * the water run below four-fifths of the ground in any pooled reach, so the floor is
+ * mostly dug and only built up where it has to be.
+ */
+const FILL_QUANTILE = 0.2
 /** Rise per metre of run on the flanks of ground built up to carry a river. */
 export const EMBANKMENT_SLOPE = 0.15
 /** How far past its end a river may be from the water it flows into. */
@@ -415,7 +424,7 @@ function compileRivers(rivers: River[], cw: CompiledWorld, onStep: StepListener)
     // An authored source height is only a ceiling: pinning the water to it where the
     // ground is far lower would stand the river's first kilometre on a tower of fill.
     if (lake) { target[0] = lake.levelM; weight[0] = n }
-    const fitted = nonIncreasing(target.map((v, i) => v + grade * line.s[i]!), weight)
+    const fitted = nonIncreasing(target.map((v, i) => v + grade * line.s[i]!), weight, FILL_QUANTILE)
     /**
      * Never below the water it drains into plus the grade still to fall. Where the whole
      * course lies lower than the river it joins — a tributary drawn across a lowland to
@@ -439,27 +448,40 @@ function compileRivers(rivers: River[], cw: CompiledWorld, onStep: StepListener)
 }
 
 /**
- * The weighted least-squares non-increasing sequence nearest `y`: pool adjacent
- * violators. Linear time. Each block holds the weighted mean of the values pooled into it.
+ * The non-increasing sequence nearest `y`, by pool adjacent violators.
+ *
+ * `tau` sets what "nearest" means. At 0.5 each pooled block takes the weighted median
+ * of its values — least absolute error, cut and fill alike. Below 0.5 it takes a lower
+ * quantile, so the sequence prefers to sit *under* the data: for a river, cutting the
+ * ground is cheaper than building it up, which is the right way round (D-064). The
+ * blocks keep their values, so this is quadratic in the worst case; a river is a few
+ * hundred points.
  */
-export function nonIncreasing(y: readonly number[], w: readonly number[] = y.map(() => 1)): number[] {
-  const mean: number[] = []
-  const weight: number[] = []
-  const count: number[] = []
+export function nonIncreasing(
+  y: readonly number[], w: readonly number[] = y.map(() => 1), tau = 0.5,
+): number[] {
+  type Block = { values: [number, number][]; stat: number; count: number }
+  const quantile = (values: [number, number][]): number => {
+    values.sort((a, b) => a[0] - b[0])
+    const total = values.reduce((t, v) => t + v[1], 0)
+    let run = 0
+    for (const [v, wt] of values) {
+      run += wt
+      if (run >= tau * total) return v
+    }
+    return values[values.length - 1]![0]
+  }
+  const blocks: Block[] = []
   y.forEach((v, i) => {
-    mean.push(v)
-    weight.push(w[i]!)
-    count.push(1)
+    blocks.push({ values: [[v, w[i]!]], stat: v, count: 1 })
     // A later block higher than the one before it is a violation: merge them.
-    while (mean.length > 1 && mean[mean.length - 1]! > mean[mean.length - 2]!) {
-      const m1 = mean.pop()!, w1 = weight.pop()!, c1 = count.pop()!
-      const m0 = mean.pop()!, w0 = weight.pop()!, c0 = count.pop()!
-      mean.push((m0 * w0 + m1 * w1) / (w0 + w1))
-      weight.push(w0 + w1)
-      count.push(c0 + c1)
+    while (blocks.length > 1 && blocks[blocks.length - 1]!.stat > blocks[blocks.length - 2]!.stat) {
+      const b1 = blocks.pop()!, b0 = blocks.pop()!
+      const values = b0.values.concat(b1.values)
+      blocks.push({ values, stat: quantile(values), count: b0.count + b1.count })
     }
   })
   const out: number[] = []
-  mean.forEach((m, k) => { for (let i = 0; i < count[k]!; i++) out.push(m) })
+  for (const b of blocks) for (let i = 0; i < b.count; i++) out.push(b.stat)
   return out
 }
diff --git a/src/terrain/field.ts b/src/terrain/field.ts
index 90fb628..30d44c8 100644
--- a/src/terrain/field.ts
+++ b/src/terrain/field.ts
@@ -39,10 +39,13 @@ export interface Sample {
   /** Water surface, or -Infinity where there is none. */
   water: number
   /**
-   * How wide the water here is, metres: Infinity for a lake or sea, the channel for a
-   * river. A tile whose vertices are further apart than this cannot show it.
+   * How much of the ground this sample stands for is water, 0..1, at this level of
+   * detail. Lakes and seas cover all of it. A river 80 m wide covers a fraction of a
+   * 500 m cell — and covers it whether or not the sample point itself is in the
+   * channel, so a coarse tile draws a river as a faint continuous line rather than as
+   * dots where its vertices happened to land in the water.
    */
-  waterWidthM: number
+  waterCover: number
   /** 0..1 claims on the surface. The shader turns these into colour. */
   desert: number
   forest: number
@@ -88,7 +91,7 @@ function warp(seed: number, p: CylPoint, z: number, a: number, ampM: number, sca
  */
 export class Runtime {
   private indexes: LineIndex[]
-  private out: Sample = { h: 0, water: -Infinity, waterWidthM: 0, desert: 0, forest: 0, relief: 0, snowlineM: 3000 }
+  private out: Sample = { h: 0, water: -Infinity, waterCover: 0, desert: 0, forest: 0, relief: 0, snowlineM: 3000 }
   private nearest: Nearest = { d: 0, seg: 0, t: 0 }
 
   constructor(readonly world: CompiledWorld) {
@@ -105,7 +108,7 @@ export class Runtime {
     const a = theta * RADIUS_M
     const p = cylPoint(theta, z)
     o.water = -Infinity
-    o.waterWidthM = 0
+    o.waterCover = 0
     o.desert = 0
     o.forest = 0
     o.relief = 0
@@ -117,8 +120,15 @@ export class Runtime {
       return o
     }
 
-    o.h = ground(w, p, detailM)
-    for (const u of w.uplifts) if (inBox(u.box, z, a)) uplift(u, p, z, a, detailM, o)
+    const g0 = ground(w, p, detailM)
+    o.h = g0
+    for (const u of w.uplifts) {
+      if (u.kind !== 'region' && inBox(u.box, z, a)) uplift(u, p, z, a, detailM, o)
+    }
+    // Raised regions last: they lift the ground *to* a height, not by one (D-064).
+    for (const u of w.uplifts) {
+      if (u.kind === 'region' && inBox(u.box, z, a)) raise(u, p, z, a, detailM, g0, o)
+    }
     let relief = 0
     for (const m of w.mountains) {
       if (!inBox(m.box, z, a)) continue
@@ -130,7 +140,7 @@ export class Runtime {
     for (const b of w.basins) if (inBox(b.box, z, a)) basin(b, p, z, a, detailM, o)
     if (upTo === Stage.Basins) return o
     w.rivers.forEach((r, i) => {
-      if (inBox(r.box, z, a)) river(r, this.indexes[i]!, z, a, o, this.nearest)
+      if (inBox(r.box, z, a)) river(r, this.indexes[i]!, z, a, detailM, o, this.nearest)
     })
     return o
   }
@@ -181,7 +191,21 @@ function uplift(u: CUplift, p: CylPoint, z: number, a: number, detailM: number,
   // line rather than a ruled one.
   const claim = smoothstep(0.25, 0.4, m + n * 0.45)
   if (u.kind === 'desert') o.desert = Math.max(o.desert, claim)
-  else o.forest = Math.max(o.forest, claim)
+  else if (u.kind === 'forest') o.forest = Math.max(o.forest, claim)
+}
+
+/**
+ * Ground Tower raised: the open ground lifted by the region's uplift, wherever that is
+ * higher than what is already here. Taking the higher rather than adding is what lets
+ * a raised region run up to a desert plateau without the two stacking into a wall at
+ * the join — the plateau stands where it is higher, the region where the land was low.
+ */
+function raise(u: CUplift, p: CylPoint, z: number, a: number, detailM: number, g0: number, o: Sample): void {
+  const [wz, wa] = warp(u.seed, p, z, a, u.warpM, u.edgeM * 2, detailM)
+  const m = smoothstep(-u.edgeM, 0, signedDistance(u.shape, wz, wa))
+  if (m <= 0) return
+  const n = fbm(layer(u.seed, 1), p, u.noiseScaleM, 4, detailM)
+  o.h = Math.max(o.h, g0 + m * (u.upliftM + n * u.noiseAmpM))
 }
 
 /** How high a range stands here, above the land under it. */
@@ -248,7 +272,7 @@ 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) { o.water = Math.max(o.water, b.levelM); o.waterWidthM = Infinity }
+    if (m > 0.05 && o.h < b.levelM) { o.water = Math.max(o.water, b.levelM); o.waterCover = 1 }
     return
   }
   const sd = signedDistance(b.shape, z, a)
@@ -256,7 +280,7 @@ function basin(b: CBasin, p: CylPoint, z: number, a: number, detailM: number, o:
   const d = 1 - Math.min(1, sd / b.maxInsideM)
   o.h -= b.depthM * Math.cos((d * Math.PI) / 2) ** 1.2
   if (d > 0.68) o.h += b.rimM * Math.sin(((d - 0.68) / 0.32) * Math.PI)
-  if (d < 0.85 && o.h < b.levelM) { o.water = Math.max(o.water, b.levelM); o.waterWidthM = Infinity }
+  if (d < 0.85 && o.h < b.levelM) { o.water = Math.max(o.water, b.levelM); o.waterCover = 1 }
 }
 
 /**
@@ -268,7 +292,7 @@ function basin(b: CBasin, p: CylPoint, z: number, a: number, detailM: number, o:
  * and a canyon where it crosses high ground. An embankment is the same idea the other
  * way up — flanks falling away at a fixed slope until they meet the ground.
  */
-function river(r: CRiver, index: LineIndex, z: number, a: number, o: Sample, near: Nearest): void {
+function river(r: CRiver, index: LineIndex, z: number, a: number, detailM: number, o: Sample, near: Nearest): void {
   const segs = index.near(z, a)
   if (!segs.length) return
   nearestOnSegments(r.line, z, a, segs, near)
@@ -285,8 +309,8 @@ function river(r: CRiver, index: LineIndex, z: number, a: number, o: Sample, nea
   // with a few metres of level top before the flanks fall away.
   const fill = x <= 0 ? floor : level + 1 - Math.max(0, x - 25) * EMBANKMENT_SLOPE
   o.h = smin(-smin(-o.h, -fill, 12), cut, 12)
-  if (x <= 0 && level > o.h) {
-    o.water = Math.max(o.water, level)
-    o.waterWidthM = Math.max(o.waterWidthM, 2 * hw)
-  }
+  if (x <= 0 && level > o.h) o.water = Math.max(o.water, level)
+  // A sample stands for a cell about half the detail wavelength across (tile.ts).
+  const cell = Math.max(1, detailM / 2)
+  if (near.d < hw + cell / 2) o.waterCover = Math.max(o.waterCover, Math.min(1, (2 * hw) / cell))
 }
diff --git a/src/terrain/map-image.ts b/src/terrain/map-image.ts
index 18651f8..1df5b6a 100644
--- a/src/terrain/map-image.ts
+++ b/src/terrain/map-image.ts
@@ -37,7 +37,7 @@ export function renderMapRows(
     for (let x = 0; x < width; x++) {
       const s = rt.sample(theta, (x + 0.5) * mppX, detail)
       shown[r * width + x] = Math.max(s.h, s.water)
-      if (r >= 1 && r <= y1 - y0) materialsAt(s, 0, mats, ((r - 1) * width + x) * MATERIALS.length, Math.max(mppX, mppY))
+      if (r >= 1 && r <= y1 - y0) materialsAt(s, 0, mats, ((r - 1) * width + x) * MATERIALS.length)
     }
   }
 
diff --git a/src/terrain/service.ts b/src/terrain/service.ts
index 38d1c45..256cf26 100644
--- a/src/terrain/service.ts
+++ b/src/terrain/service.ts
@@ -101,7 +101,8 @@ export class TerrainService {
 
     // Every compile step, plus drawing the map.
     const counted = world.features.filter((f) =>
-      ['desert', 'forest', 'mountains', 'lake', 'sea', 'river'].includes(f.kind)).length
+      ['desert', 'forest', 'mountains', 'lake', 'sea', 'river'].includes(f.kind)
+      || (f.kind === 'region' && f.upliftM)).length
     const total = 1 + counted + 1
     let done = 0
     this.setProgress({ busy: true, label: 'Reading the plans', done: 0, total, startedAt: performance.now() })
diff --git a/src/terrain/tile.ts b/src/terrain/tile.ts
index 37739b1..4fb1853 100644
--- a/src/terrain/tile.ts
+++ b/src/terrain/tile.ts
@@ -16,6 +16,14 @@
 import { CIRCUMFERENCE_M, LENGTH_M, RADIUS_M } from '../canon/constants.js'
 import type { Runtime, Sample } from './field.js'
 
+/**
+ * The deepest anything generated may reach, metres below the datum. Tower's wall is a
+ * kilometre thick (D-044), and its outer skin sits at that depth: sea floors are cut to
+ * about -330 m, and nothing — not a basin, not a tile's skirt — may reach the skin, or
+ * it shows through the hull from outside (D-064).
+ */
+export const LOWEST_GROUND_M = -950
+
 export const ROOTS_ALONG = 16
 export const ROOTS_AROUND = 10
 export const MAX_LEVEL = 10
@@ -73,16 +81,9 @@ const smoothstep = (e0: number, e1: number, x: number): number => {
  *
  * `out` receives MATERIALS.length values in 0..1.
  */
-export function materialsAt(
-  s: Sample, slope: number, out: Float32Array | number[], offset = 0, spacingM = 0,
-): void {
+export function materialsAt(s: Sample, slope: number, out: Float32Array | number[], offset = 0): void {
   const shown = Math.max(s.h, s.water)
-  /**
-   * Water covers a vertex only as far as it covers the ground that vertex stands for.
-   * An 80 m river under a vertex of a 500 m tile would otherwise paint a 500 m blue
-   * diamond, and a line of them down every river on the far side of the world.
-   */
-  const water = s.water > s.h ? Math.min(1, s.waterWidthM / Math.max(1, spacingM)) : 0
+  const water = s.waterCover
   const rock = smoothstep(1300, 1900, s.relief)
   const desert = s.desert * (1 - rock)
   const forest = s.forest * (1 - rock) * (1 - desert)
@@ -90,7 +91,7 @@ export function materialsAt(
   // 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 = water ? 0 : smoothstep(s.snowlineM, s.snowlineM + 550, shown) * holds
+  const snow = (1 - water) * smoothstep(s.snowlineM, s.snowlineM + 550, shown) * holds
   out[offset] = plain
   out[offset + 1] = desert
   out[offset + 2] = forest
@@ -98,7 +99,7 @@ export function materialsAt(
   out[offset + 4] = snow
   out[offset + 5] = water
   // Water deepens to its darkest over the first 90 m, as the v1 map did.
-  out[offset + 6] = water ? Math.min(1, (s.water - s.h) / 90) : 0
+  out[offset + 6] = s.water > s.h ? Math.min(1, (s.water - s.h) / 90) : 0
   out[offset + 7] = 0
 }
 
@@ -129,7 +130,7 @@ export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData {
       const k = v * W + u
       shown[k] = Math.max(s.h, s.water)
       // Slope is not known until the neighbours are, so snow is thinned by it below.
-      materialsAt(s, 0, mats, k * MATERIALS.length, Math.max(dz, da))
+      materialsAt(s, 0, mats, k * MATERIALS.length)
     }
   }
 
@@ -146,7 +147,8 @@ export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData {
     const theta = (a0 + v * da) / RADIUS_M
     const cos = Math.cos(theta), sin = Math.sin(theta)
     const h = shown[k]!
-    const r = (RADIUS_M - h + drop) / 1000
+    // A skirt hangs down past any crack, but never through the wall.
+    const r = (RADIUS_M - Math.max(LOWEST_GROUND_M, h - drop)) / 1000
     positions[3 * index] = r * cos - cx
     positions[3 * index + 1] = r * sin - cy
     positions[3 * index + 2] = z / 1000 - cz
diff --git a/src/ui/app.ts b/src/ui/app.ts
index 73b71bc..e06fbe3 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -77,6 +77,14 @@ export class App {
   private map: TowerMap
   private exterior: ExteriorView | null = null
   private interior: InteriorView | null = null
+  /**
+   * The deferred imports in flight. Opening on a `#view=` link asks for a view twice
+   * before the first import has resolved — once reading the hash, once choosing the
+   * initial view — and without these each built its own renderer: two WebGL contexts,
+   * two sets of terrain tiles, and every exterior caption drawn twice.
+   */
+  private exteriorModule: Promise<typeof import('../views/exterior.js')> | null = null
+  private interiorModule: Promise<typeof import('../views/interior.js')> | null = null
   private view: ViewName = 'map'
   /**
    * Writer mode is a UI mode, not a security boundary (D-011). It is guessable
@@ -334,7 +342,8 @@ export class App {
       // Genuinely deferred: Three.js is by far the largest dependency, and a static
       // import would bundle it into the entry chunk whether or not this view is
       // ever opened. Vite splits it into its own chunk on this await.
-      const { ExteriorView } = await import('../views/exterior.js')
+      const { ExteriorView } = await (this.exteriorModule ??= import('../views/exterior.js'))
+      if (this.exterior) return // the other request built it, and is starting it
       this.exterior = new ExteriorView($<HTMLCanvasElement>('exterior'), {
         onPickPoint: (p) => {
           this.map.probe = { from: p, to: null }
@@ -356,7 +365,8 @@ export class App {
   /** Same deferred-import bargain as the exterior: Three.js is not the map's cost. */
   private async openInterior(): Promise<void> {
     if (!this.interior) {
-      const { InteriorView } = await import('../views/interior.js')
+      const { InteriorView } = await (this.interiorModule ??= import('../views/interior.js'))
+      if (this.interior) return // the other request built it, and is starting it
       this.interior = new InteriorView($<HTMLCanvasElement>('interior'), {
         onPickPlace: (id) => {
           this.selectedId = id
diff --git a/src/views/exterior.ts b/src/views/exterior.ts
index 5b5ea4b..acd5307 100644
--- a/src/views/exterior.ts
+++ b/src/views/exterior.ts
@@ -200,7 +200,14 @@ export class ExteriorView {
    * surface projected onto it, which is a diagram and not a view (D-047).
    */
   private buildHull(): Mesh {
-    const g = new CylinderGeometry(R, R, L, 160, 1, true)
+    /**
+     * The skin is the wall's outer face, a kilometre out from the floor (D-044), not
+     * the floor itself. While the ground was a raster that never went below the datum
+     * by more than a lake, the difference could be ignored; generated ground has sea
+     * floors 330 m down and tile skirts below that, and at radius R they came through
+     * the hull as a lattice of lines (D-064).
+     */
+    const g = new CylinderGeometry(R + HULL_D, R + HULL_D, L, 160, 1, true)
     g.rotateX(Math.PI / 2)
     g.translate(0, 0, L / 2)
     this.remapHullUVs(g)
@@ -299,19 +306,37 @@ export class ExteriorView {
    * hazed like everything else in there, and drawing both would put two surfaces at
    * one depth. The sun collar has no bore behind it — the sun cap window sits at the
    * inner face, z = 0 — so it keeps both faces, as the lip you look past to see in.
+   *
+   * Since D-064 the wall is a kilometre thick around the whole circumference too, so
+   * each end also gets its end face: a ring from the window's edge out to the skin.
    */
   private buildCollars(): void {
+    const metal = (side: typeof FrontSide | typeof DoubleSide) => new MeshStandardMaterial({
+      color: 0x6f7787, roughness: 0.55, metalness: 0.42, side,
+    })
     for (const z0 of [-HULL_D, L]) {
-      const g = new CylinderGeometry(R, R, HULL_D, 160, 1, true)
-      g.rotateX(Math.PI / 2)
-      g.translate(0, 0, z0 + HULL_D / 2)
-      this.tower.add(new Mesh(g, new MeshStandardMaterial({
-        color: 0x6f7787, roughness: 0.55, metalness: 0.42,
-        side: z0 < 0 ? DoubleSide : FrontSide,
-      })))
+      // The outer skin carried on past each end of the world, to the caps.
+      const outer = new CylinderGeometry(R + HULL_D, R + HULL_D, HULL_D, 160, 1, true)
+      outer.rotateX(Math.PI / 2)
+      outer.translate(0, 0, z0 + HULL_D / 2)
+      this.tower.add(new Mesh(outer, metal(FrontSide)))
+      // The end face of the wall: the kilometre between the window's edge and the skin.
+      const face = new RingGeometry(R, R + HULL_D, 160)
+      face.translate(0, 0, z0 < 0 ? z0 : L + HULL_D)
+      this.tower.add(new Mesh(face, metal(DoubleSide)))
     }
+    /**
+     * The sun end's inner face has no bore behind it — the sun cap window sits at the
+     * floor's end, z = 0 — so it is drawn here, as the lip you look past to see in. The
+     * far end's inner face is the interior model's bore, hazed like everything in there.
+     */
+    const lip = new CylinderGeometry(R, R, HULL_D, 160, 1, true)
+    lip.rotateX(Math.PI / 2)
+    lip.translate(0, 0, -HULL_D / 2)
+    this.tower.add(new Mesh(lip, metal(DoubleSide)))
   }
 
+
   /**
    * The beam's way in: the hole in the middle of the sun cap's annulus, where the
    * converging rays arrive. The caps themselves are the interior model's windows
diff --git a/src/views/interior-model.ts b/src/views/interior-model.ts
index c310b96..6d3f6e2 100644
--- a/src/views/interior-model.ts
+++ b/src/views/interior-model.ts
@@ -23,6 +23,7 @@ import {
   SPECIFIC_GAS_CONSTANT, TEMPERATURE_K,
 } from '../canon/constants.js'
 import { PALETTE_GLSL } from '../terrain/palette.js'
+import { LOWEST_GROUND_M } from '../terrain/tile.js'
 import { terrain } from '../terrain/service.js'
 import { MIRROR_UV_RADIUS, capGlazingTexture, sunCapGlazingTexture } from './cap-pattern.js'
 import { TerrainLOD, type TerrainLODOptions } from './terrain-lod.js'
@@ -66,13 +67,6 @@ const DENSITY_A = ((OMEGA_RAD_S ** 2 / (2 * SPECIFIC_GAS_CONSTANT * TEMPERATURE_
 export const HAZE_DAY = 0x9aa3ad
 export const HAZE_DUSK = 0x8d8792
 
-/**
- * The lowest the ground is expected to go, metres. Sea and lake floors are cut below
- * the datum, and the haze integral's air has to reach down past them. Generated
- * terrain has no hard floor, so this is a margin rather than a measurement: the
- * deepest basin today is about 330 m under The Deepward Sea's 90 m surface.
- */
-const LOWEST_GROUND_M = -1000
 
 // ---------------------------------------------------------------------- shaders
 
@@ -100,6 +94,7 @@ uniform vec3 uHaze;
 uniform float uExposure;
 const float A = ${DENSITY_A.toExponential(8)};
 const float RR = ${(R * R).toFixed(4)};
+// The air reaches down past the deepest ground anything generates (tile.ts).
 const float RR_AIR = ${((R - LOWEST_GROUND_M / KM + 0.05) ** 2).toFixed(4)};
 const float Z_AIR0 = 0.0;
 const float Z_AIR1 = ${(L + HULL_D).toFixed(4)};
diff --git a/src/world/schema.ts b/src/world/schema.ts
index 21ca3e4..127ae00 100644
--- a/src/world/schema.ts
+++ b/src/world/schema.ts
@@ -23,11 +23,11 @@ export const WORLD_VERSION = 2
 
 // ------------------------------------------------------------------------ kinds
 
-/** Features that shape the ground. */
+/** Features that shape the ground. A region can too, when it is given an uplift. */
 export const TERRAIN_KINDS = ['mountains', 'lake', 'sea', 'river', 'desert', 'forest'] as const
 /** Built things. Generated from in later phases (world-generation.md §4.7, §4.8). */
 export const BUILT_KINDS = ['settlement', 'road', 'path'] as const
-/** Labels on the world that generate nothing. */
+/** Labels on the world. They generate nothing, except a region with an uplift. */
 export const MARKER_KINDS = ['embassy', 'tower', 'landmark', 'note', 'region'] as const
 
 export const FEATURE_KINDS = [...TERRAIN_KINDS, ...BUILT_KINDS, ...MARKER_KINDS] as const
@@ -143,13 +143,23 @@ export interface Route extends Base<'road' | 'path'> {
   widthM: number
 }
 
-export interface Marker extends Base<'embassy' | 'tower' | 'landmark' | 'note' | 'region'> {
+export interface Marker extends Base<'embassy' | 'tower' | 'landmark' | 'note'> {
   /** Height of a tower, for when towers are drawn. */
   heightM?: number
 }
 
+/**
+ * A named area. It is a label, and it can also be ground Tower raised: `upliftM` lifts
+ * everything inside it, with a soft edge `edgeM` wide, and paints nothing — the land
+ * keeps whatever cover it has. v1's world needed one under its rivers (D-064).
+ */
+export interface Region extends Base<'region'> {
+  upliftM: number
+  edgeM: number
+}
+
 export type Feature =
-  | Mountains | Lake | Sea | River | Desert | Forest | Settlement | Route | Marker
+  | Mountains | Lake | Sea | River | Desert | Forest | Settlement | Route | Marker | Region
 
 /** The gentle relief everywhere nothing else is (today's "open ground"). */
 export interface Ground {
@@ -224,6 +234,8 @@ export function withKind(common: Common, kind: FeatureKind): Feature {
     case 'road':
     case 'path':
       return { ...c, kind, widthM: kind === 'road' ? 8 : 2 }
+    case 'region':
+      return { ...c, kind, upliftM: 0, edgeM: 4000 }
     default:
       return { ...c, kind }
   }
@@ -346,6 +358,7 @@ function fillParams(d: Feature, f: Record<string, unknown>): Feature {
                outlet: typeof f['outlet'] === 'string' && f['outlet'] ? f['outlet'] : 'auto' }
     case 'desert':
     case 'forest':
+    case 'region':
       return { ...d, upliftM: num(f['upliftM'], d.upliftM), edgeM: Math.max(1, num(f['edgeM'], d.edgeM)) }
     case 'settlement': {
       const sizes = ['hamlet', 'village', 'town', 'station', 'city'] as const
diff --git a/static/world.json b/static/world.json
index 964a4bb..c377a1b 100644
--- a/static/world.json
+++ b/static/world.json
@@ -599,6 +599,22 @@
       "upliftM": 780,
       "edgeM": 20000
     },
+    {
+      "id": "the-uplands",
+      "name": "The Uplands",
+      "blurb": "",
+      "notes": "",
+      "public": true,
+      "seed": 1990705996,
+      "kind": "region",
+      "geometry": {
+        "type": "band",
+        "zFrom": 105000,
+        "zTo": 150000
+      },
+      "upliftM": 1000,
+      "edgeM": 8000
+    },
     {
       "id": "first-ridge",
       "name": "First Ridge",
diff --git a/test/terrain.test.ts b/test/terrain.test.ts
index 08ca16f..e72121b 100644
--- a/test/terrain.test.ts
+++ b/test/terrain.test.ts
@@ -13,12 +13,13 @@ const compileMs = performance.now() - t0
 const rt = prepare(cw)
 
 describe('the world compiles', () => {
-  it('ports every v1 feature', () => {
-    expect(world.features).toHaveLength(28)
+  it('ports every v1 feature, plus the ground raised under the rivers', () => {
+    expect(world.features).toHaveLength(29)
     expect(cw.mountains).toHaveLength(8)
     expect(cw.basins).toHaveLength(7)
     expect(cw.rivers).toHaveLength(4)
-    expect(cw.uplifts).toHaveLength(2)
+    // The Dry Reach, Greatwood, and The Uplands (D-064).
+    expect(cw.uplifts).toHaveLength(3)
   })
 
   it('names every step it takes, in the order it takes them', () => {
@@ -151,14 +152,37 @@ describe('water', () => {
   })
 
   it('cuts a canyon where the river crosses the ridges', () => {
-    // First Ridge is at z 176 km; The Narrows is the only breach (D-020).
+    // The ridges are at z 176, 190 and 203 km; The Narrows is the only breach in all
+    // three (D-020), and design.md §1.11 puts it at up to 480 m deep.
     const trunk = cw.rivers.find((r) => r.id === 'the-long-water')!
-    const i = trunk.line.pts.findIndex((v, k) => k % 2 === 0 && v > 176_000) / 2
-    const z = trunk.line.pts[2 * i]!, theta = trunk.line.pts[2 * i + 1]! / RADIUS_M
-    const floor = rt.sample(theta, z, 5).h
-    const rim = Math.max(rt.sample(theta + 2500 / RADIUS_M, z, 5).h,
-      rt.sample(theta - 2500 / RADIUS_M, z, 5).h)
-    expect(rim - floor).toBeGreaterThan(150)
+    let deepest = 0
+    for (let i = 0; i < trunk.level.length; i++) {
+      const z = trunk.line.pts[2 * i]!
+      if (z < 170_000 || z > 210_000) continue
+      const theta = trunk.line.pts[2 * i + 1]! / RADIUS_M
+      const floor = rt.sample(theta, z, 5).h
+      let rim = -Infinity
+      for (const off of [2000, 3000, 4000, 5000]) {
+        rim = Math.max(rim, rt.sample(theta + off / RADIUS_M, z, 5).h, rt.sample(theta - off / RADIUS_M, z, 5).h)
+      }
+      deepest = Math.max(deepest, rim - floor)
+    }
+    expect(deepest).toBeGreaterThan(300)
+    expect(deepest).toBeLessThan(700)
+  })
+
+  it('builds the floor up under a river only a little, now the lowland is raised', () => {
+    // v1's lowland needed embankments of up to 950 m; The Uplands bring that under
+    // 250 m (D-064). Measured against the ground as it is before the rivers shape it.
+    const before = prepare({ ...cw, rivers: [] })
+    for (const r of cw.rivers) {
+      let fill = 0
+      for (let i = 0; i < r.level.length; i++) {
+        const s = before.sample(r.line.pts[2 * i + 1]! / RADIUS_M, r.line.pts[2 * i]!, 60, Stage.Basins)
+        fill = Math.max(fill, r.level[i]! - Math.max(s.h, s.water))
+      }
+      expect(fill).toBeLessThan(250)
+    }
   })
 })
 
diff --git a/world-generation.md b/world-generation.md
index 137c1f9..2a9f5e2 100644
--- a/world-generation.md
+++ b/world-generation.md
@@ -1036,8 +1036,14 @@ What the plan did not know until the world was generated honestly:
   900 m deep through the whole desert; the second — a graded curve — needed a
   1,300 m embankment. The least-earthwork profile of §4.3 cuts The Narrows 300–500 m
   deep, which is what design.md §1.11 always said, and builds the lowland up by up to
-  880 m under each river. That is the world as authored, generated faithfully; whether
-  to change the world is an open question in design.md §1.11.
+  880 m under each river. That is the world as authored, generated faithfully.
+  **Resolved by the author (D-064):** raise the lowland. The Uplands, a region lifting
+  the ground to ~1,000 m, bring the worst embankment under 200 m; and the profile now
+  prefers cutting to filling (a 0.2-quantile isotonic fit rather than least squares),
+  since a cut reads as a valley and an embankment as a dam.
+- **The hull was at the floor's radius.** Seen from outside, generated sea floors and
+  tile skirts came through it as a lattice. The skin now sits at the wall's outer face,
+  a kilometre out (D-044, D-064), and nothing generated may reach it.
 - **Tiles were measured against the tallest mountain.** A fixed 4.5 km relief allowance
   made every tile within 5 km of the eye split to the finest level: 11,000 tiles on
   screen. Each tile is now bounded by its own measured relief.