File size: 19,027 Bytes
3e9ecc2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
 
3e9ecc2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
/* ============================================================================
   TERRAGEN β€” landform structure: ridges, drainage, coastlines
   ----------------------------------------------------------------------------
   The base heightfield is five octaves of value noise. Value noise makes
   BLOBS: smooth, rounded, and β€” the actual complaint β€” completely without
   structure. Real terrain is not noise, it is noise that WATER HAS RUN OVER.
   Ridgelines radiate from massifs, valleys branch upstream into dendritic
   networks, coasts fray where the sea works into soft rock. None of that is
   in a sum of octaves, at any amplitude, ever.

   Three passes add it, cheapest and highest-payoff first:

     1. RIDGED MULTIFRACTAL (Musgrave's fold: 1-|n|, squared, each octave
        weighted by the previous one's signal) gives continuous crest lines
        instead of scattered bumps. Region-masked by a very low frequency
        field so ranges belong to parts of the map rather than covering it.

     2. FLOW ACCUMULATION (D8 steepest-descent, height-ordered) computes how
        much water crosses every cell, then carves channel depth from
        log(accumulation). This is where the dendritic branching comes from,
        and it is ~100x cheaper than droplet erosion for that specific look:
        one O(N) bucket sort and two O(N) sweeps.

     3. COAST WARP domain-warps the sample position in a band around sea
        level, so shorelines fray into inlets and headlands without touching
        height anywhere inland.

   WHY IT RUNS ON A 512 GRID. The mesh is TGRID=320 (10 world units a cell)
   and terrainH box-blurs +-7 units, so nothing finer than roughly 25-30 world
   units can reach the screen as geometry no matter how fine the field is. A
   512 grid is 6.25 units a cell β€” already four times finer than the mesh can
   show. Working there costs a sixteenth of 2048 and loses nothing visible.

   WHY IT RETURNS A DELTA. The 2048 field carries a deliberate near-Nyquist
   octave that the per-pixel normal sheet displays. Upsampling absolute heights
   from 512 would erase it. Every pass writes into a delta grid instead, and
   only the delta is bilinearly upsampled and added β€” large-scale structure
   arrives, fine detail survives untouched.

   HARD RULES, both learned from what this codebase already guarantees:
     - Determinism. A private LCG, seeded from the map seed. Never the shared
       global _seed (planDistricts and friends run after us on that stream)
       and never Math.random(): context-loss rebuilds and map previews must
       reproduce the same ground, byte for byte.
     - No new water. Passability, the naval mask, water-mesh coverage and
       battlefieldNavalEnabled all key off WATER_H. A carve that drops dry
       land below sea level would silently punch holes in pathing and flood
       maps authored dry. Any cell that started at or above WATER_H is
       clamped so it can never finish below it. Combat craters follow the
       same rule via WATER_AUTH β€” punching a bowl below the water table
       must not spawn a pond; oceans/rivers/lakes stay authored. A
       shoreline crater may visually wet its lip (WATER_LIP in terrain.js)
       without rewriting this mask, PASS, or the naval grid.
   ============================================================================ */

const TERRA={
  work:512,                 // erosion grid resolution (6.25 world units a cell)
  ridgeOct:5,
  ridgeLac:2.05,            // not 2.0: exact doubling re-aligns octave harmonics
  ridgeGain:2.05,
  ridgeFreq:9.5,            // lattice cells across the map at octave 0
  regionFreq:2.4,           // range-vs-lowland mask: 2-3 blobs across the map
  regionLo:0.46, regionHi:0.78,
  streamMin:55,             // contributing cells before a channel exists
  carve:0.0132,             // height units per log-unit of accumulation
  carveMax:0.055,           // ~6.5 world units: deep enough to read, not a canyon
  coastBand:0.055,          // height units either side of sea level that warp
  coastWarp:46,             // world units a shoreline can wander
  ceiling:0.93,             // crests must not clip into flat-topped mesas
  regionBias:0.0            // dev tool: shifts how much of the map is mountainous
};

/* Private deterministic stream. Kept off the global LCG on purpose. */
function terraRng(seed){
  let s=(seed|0)||1;
  return ()=>{ s=(Math.imul(s,1664525)+1013904223)|0; return ((s>>>9)&0x7fffff)/0x800000; };
}
/* Value-noise lattice with wrap, matching the engine's existing sampler so the
   two stacks agree about what a "cell" is. */
function terraLattice(rand,n){
  const g=new Float32Array((n+1)*(n+1));
  for(let i=0;i<g.length;i++) g[i]=rand();
  return g;
}
function terraNoise(g,n,x,y){
  let xi=Math.floor(x), yi=Math.floor(y);
  const fx=x-xi, fy=y-yi;
  xi=((xi%n)+n)%n; yi=((yi%n)+n)%n;
  const x1=(xi+1)%n, y1=(yi+1)%n;
  const sx=fx*fx*(3-2*fx), sy=fy*fy*(3-2*fy);
  const a=g[yi*(n+1)+xi],  b=g[yi*(n+1)+x1];
  const c=g[y1*(n+1)+xi],  d=g[y1*(n+1)+x1];
  return (a+(b-a)*sx)+((c+(d-c)*sx)-(a+(b-a)*sx))*sy;
}
/* Musgrave ridged multifractal. The fold (1-|n|) puts a crest where the noise
   crosses zero, which is a LINE rather than a peak β€” that is the whole reason
   ridges come out continuous. Squaring sharpens it; weighting each octave by
   the previous signal keeps detail on the ridges and off the valley floors,
   which is what stops it reading as noise-with-creases. */
function terraRidged(g,n,x,y,oct,lac,gain){
  let sum=0, freq=1, amp=0.5, weight=1;
  for(let i=0;i<oct;i++){
    const sig0=1-Math.abs(terraNoise(g,n,x*freq,y*freq)*2-1);
    let sig=sig0*sig0*weight;
    sum+=sig*amp;
    weight=sig*gain; if(weight>1) weight=1; else if(weight<0) weight=0;
    freq*=lac; amp*=0.5;
  }
  return sum;
}

/* ---------------------------------------------------------------------------
   PLAYABILITY MASK β€” 0 where the ground must stay as authored, 1 where the
   landform passes may do as they like.

   The bumps, corridors and deposit pads are Math.max floors: they do not heal
   after something cuts into them. Attenuating DURING each pass (rather than
   re-stamping afterwards) is what makes a river route AROUND a base instead of
   being clipped flat where it crosses one β€” a re-stamp leaves a retaining-wall
   edge at every boundary, which is the same "sculpted" tell, just relocated.
   --------------------------------------------------------------------------- */
function terraPlayMask(W,bumps,corridorFns,depPts,roads,tight){
  const m=new Float32Array(W*W).fill(1);
  const s=MAP/W;
  const soften=(i,d,r,feather)=>{
    if(d>=r+feather) return;
    const v=d<=r?0:(d-r)/feather;
    const k=v*v*(3-2*v);
    if(k<m[i]) m[i]=k;
  };
  /* TWO MASKS, BECAUSE THE TWO PASSES CANNOT BREAK THE SAME THINGS.
     Carving digs DOWN: it can approach sea level, punch passability holes and
     cut through a corridor, so it respects the full authored footprint of
     every bump, lane and resource pad. Ridges only ADD height β€” they cannot
     flood anything, and nothing in this game reads slope for passability β€” so
     they need protect only enough flat ground to stand a base on. Running one
     conservative mask for both was suppressing ridges across roughly half the
     map, which is why the first pass still looked like smooth blobs. */
  const R=tight?0.42:0.82, F=tight?110:150, CR=tight?0.34:0.70, DR=tight?0.5:0.85;
  for(let y=0;y<W;y++) for(let x=0;x<W;x++){
    const i=y*W+x, wx=(x+0.5)*s, wy=(y+0.5)*s;
    for(const B of bumps) soften(i,Math.hypot(wx-B[0],wy-B[1]),B[2]*R,F);
    soften(i,corridorFns[0](wx,wy),300*CR,F);
    if(roads&&corridorFns[1]) soften(i,corridorFns[1](wx,wy),260*CR,F*0.93);
    if(depPts) for(const p of depPts) soften(i,Math.hypot(wx-p[0],wy-p[1]),120*DR,90);
  }
  return m;
}

/* ---------------------------------------------------------------------------
   FLOW ACCUMULATION (D8). Every cell drains to its steepest lower neighbour.
   Walking cells in DESCENDING height order means every contributor to a cell
   has already been processed when we reach it, so one linear sweep produces
   exact accumulation β€” no iteration, no convergence check.

   The ordering uses a 4096-bucket counting sort, not Array.sort: a comparator
   sort of a quarter-million floats in JS costs more than every other pass in
   this file combined.
   --------------------------------------------------------------------------- */
/* ---------------------------------------------------------------------------
   DEPRESSION FILLING (Planchon-Darboux). Without it, D8 routing is useless on
   noise: every local dimple is a sink, so flow terminates a few cells from
   where it started and accumulation comes out as scattered patches instead of
   branching networks. That is exactly what the first pass produced.

   Fill a WORKING surface only β€” start it at +infinity except on the boundary,
   then repeatedly lower each cell to max(its real height, lowest filled
   neighbour + epsilon). Sweeping in alternating directions propagates the
   boundary constraint inward from all four sides, so it converges in a handful
   of passes rather than the naive one-cell-per-pass.

   The filled surface is used ONLY to decide where water goes. Channel depth is
   still carved into the real terrain, so filling never raises the map β€” it
   just stops rivers from giving up in a puddle. */
function terraFill(H,W,passes){
  const N=W*W, F=new Float32Array(N);
  const EPS=1e-5;
  for(let i=0;i<N;i++) F[i]=1e9;
  for(let x=0;x<W;x++){ F[x]=H[x]; F[(W-1)*W+x]=H[(W-1)*W+x]; }
  for(let y=0;y<W;y++){ F[y*W]=H[y*W]; F[y*W+W-1]=H[y*W+W-1]; }
  const relax=(i,h)=>{
    let lo=1e9;
    const x=i%W, y=(i/W)|0;
    for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
      if(!dx&&!dy) continue;
      const nx=x+dx, ny=y+dy;
      if(nx<0||ny<0||nx>=W||ny>=W) continue;
      const v=F[ny*W+nx]; if(v<lo) lo=v;
    }
    const cand=lo+EPS;
    if(cand<F[i]) F[i]=h>cand?h:cand;
  };
  for(let p=0;p<passes;p++){
    if(p&1){ for(let y=W-2;y>0;y--) for(let x=W-2;x>0;x--){ const i=y*W+x; if(F[i]>H[i]) relax(i,H[i]); } }
    else   { for(let y=1;y<W-1;y++) for(let x=1;x<W-1;x++){ const i=y*W+x; if(F[i]>H[i]) relax(i,H[i]); } }
  }
  for(let i=0;i<N;i++) if(F[i]>1e8) F[i]=H[i];
  return F;
}
function terraFlow(H,W,mask,out,wet){
  const N=W*W;
  const dir=new Int32Array(N).fill(-1);
  const acc=new Float32Array(N).fill(1);
  const F=terraFill(H,W,10);          // routing surface: sinks removed
  let lo=Infinity, hi=-Infinity;
  for(let i=0;i<N;i++){ const v=F[i]; if(v<lo)lo=v; if(v>hi)hi=v; }
  const span=(hi-lo)||1;
  for(let y=1;y<W-1;y++) for(let x=1;x<W-1;x++){
    const i=y*W+x, h=F[i];
    let bestDrop=0, bi=-1;
    for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
      if(!dx&&!dy) continue;
      const j=i+dy*W+dx;
      /* Diagonal steps cover sqrt(2) more ground, so comparing raw drop would
         bias every channel onto the diagonals β€” the classic D8 staircase. */
      const drop=(F[j]-h)/((dx&&dy)?1.41421356:1);
      if(drop<bestDrop){ bestDrop=drop; bi=j; }
    }
    dir[i]=bi;
  }
  const B=4096, head=new Int32Array(B).fill(-1), next=new Int32Array(N).fill(-1);
  for(let i=0;i<N;i++){
    let b=((F[i]-lo)/span*(B-1))|0; if(b<0)b=0; else if(b>B-1)b=B-1;
    next[i]=head[b]; head[b]=i;
  }
  for(let b=B-1;b>=0;b--) for(let i=head[b];i>=0;i=next[i]){
    const d=dir[i]; if(d>=0) acc[d]+=acc[i];
  }
  /* Depth from log(accumulation): a trunk carrying a whole basin cuts a few
     times deeper than a first-order tributary, not thousands of times. */
  for(let i=0;i<N;i++){
    /* Channels stop at the waterline. Carrying the carve on underwater simply
       deepened every existing lake, and the water sheet tints by depth β€” so
       ponds turned into flat black voids instead of reading as water at all. */
    if(wet&&!wet[i]) {} else if(wet) continue;
    const a=acc[i];
    if(a<TERRA.streamMin) continue;
    let d=TERRA.carve*Math.log(1+a-TERRA.streamMin);
    if(d>TERRA.carveMax) d=TERRA.carveMax;
    out[i]-=d*mask[i];
  }
  return acc;
}

/* Separable box blur on a grid β€” used to widen carved channels so they survive
   the mesh's own +-7 unit smoothing, and to soften the ridge mask. */
function terraBlur(src,W,r,tmp){
  const inv=1/(r*2+1);
  for(let y=0;y<W;y++){
    let s=0;
    for(let k=-r;k<=r;k++) s+=src[y*W+Math.min(W-1,Math.max(0,k))];
    for(let x=0;x<W;x++){
      tmp[y*W+x]=s*inv;
      s-=src[y*W+Math.min(W-1,Math.max(0,x-r))];
      s+=src[y*W+Math.min(W-1,Math.max(0,x+r+1))];
    }
  }
  for(let x=0;x<W;x++){
    let s=0;
    for(let k=-r;k<=r;k++) s+=tmp[Math.min(W-1,Math.max(0,k))*W+x];
    for(let y=0;y<W;y++){
      src[y*W+x]=s*inv;
      s-=tmp[Math.min(W-1,Math.max(0,y-r))*W+x];
      s+=tmp[Math.min(W-1,Math.max(0,y+r+1))*W+x];
    }
  }
}

/* ---------------------------------------------------------------------------
   THE PASS. Called from buildTerrain after every authored feature is stamped
   and before anything reads the field back β€” passability, district planning,
   the painted macro map, the mesh and the normal sheet all see eroded ground.
   --------------------------------------------------------------------------- */
function terraShape(heightF,TS,MD,bumps,corridorFns,depPts){
  if(typeof TERRA_ENABLED!=='undefined'&&!TERRA_ENABLED) return null;
  const t0=(typeof performance!=='undefined')?performance.now():0;
  const W=TERRA.work, N=W*W, step=TS/W;
  const rand=terraRng((MD.seed^0xE20D10)|1);
  const LN=64;
  const gr=terraLattice(rand,LN), gm=terraLattice(rand,LN), gw1=terraLattice(rand,LN), gw2=terraLattice(rand,LN);

  /* Downsample by point-sampling the cell centre. A box average here would
     pre-blur the field we are about to measure slopes on, and the delta we
     return is added back to the untouched 2048 heights anyway. */
  const H=new Float32Array(N);
  for(let y=0;y<W;y++) for(let x=0;x<W;x++)
    H[y*W+x]=heightF[((y*step+step*0.5)|0)*TS+((x*step+step*0.5)|0)];

  const wet=new Uint8Array(N);
  for(let i=0;i<N;i++) wet[i]=H[i]>=WATER_H?1:0;      // remembered before anything moves
  const mask=terraPlayMask(W,bumps,corridorFns,depPts,MD.roads,false);   // carving: full protection
  const maskR=terraPlayMask(W,bumps,corridorFns,depPts,MD.roads,true);   // ridges: build pads only
  const delta=new Float32Array(N);
  const rel=MD.relief||1;

  /* ---- 1. RIDGES ---------------------------------------------------------
     Amplitude scales with the map's own relief setting, so a "flat highway
     country" map gets hills and a "cliff arcology" gets a range. The region
     mask decides WHERE, and is blurred so ranges have shoulders instead of
     edges. Squaring the mask keeps foothills low and cores high. */
  const ridgeAmp=(0.23+0.26*Math.max(0,Math.min(1.6,rel)-0.85))*(MD.crater?0.66:1);
  const region=new Float32Array(N), tmp=new Float32Array(N);
  for(let y=0;y<W;y++) for(let x=0;x<W;x++){
    const u=x/W*TERRA.regionFreq, v=y/W*TERRA.regionFreq;
    let m=terraNoise(gm,LN,u,v);
    m=(m-(TERRA.regionLo-TERRA.regionBias))/(TERRA.regionHi-TERRA.regionLo);
    region[y*W+x]=m<0?0:m>1?1:m*m*(3-2*m);
  }
  terraBlur(region,W,3,tmp);
  for(let y=0;y<W;y++) for(let x=0;x<W;x++){
    const i=y*W+x, m=region[i]*region[i]*maskR[i];
    if(m<=0.002) continue;
    const u=x/W*TERRA.ridgeFreq, v=y/W*TERRA.ridgeFreq;
    delta[i]+=terraRidged(gr,LN,u,v,TERRA.ridgeOct,TERRA.ridgeLac,TERRA.ridgeGain)*ridgeAmp*m;
  }

  /* ---- 2. DRAINAGE -------------------------------------------------------
     Flow is routed over base + ridges, so the new ranges are what the new
     rivers come off β€” the two passes have to see each other or the valleys
     land in the wrong places. */
  const flowH=new Float32Array(N);
  for(let i=0;i<N;i++) flowH[i]=H[i]+delta[i];
  const carve=new Float32Array(N);
  terraFlow(flowH,W,mask,carve,wet);
  /* Widen: a one-cell channel is 6.25 world units and the mesh blur would eat
     it whole. Two blur passes spread it to roughly 30, which is the narrowest
     thing this terrain can actually show. */
  terraBlur(carve,W,2,tmp);
  terraBlur(carve,W,1,tmp);
  for(let i=0;i<N;i++) delta[i]+=carve[i];

  /* ---- 3. COASTS ---------------------------------------------------------
     Domain-warp the sampled position, but only for cells near sea level, and
     write the difference as delta. Inland is untouched, so this costs almost
     nothing and cannot disturb a base sitting on a plateau. */
  for(let y=1;y<W-1;y++) for(let x=1;x<W-1;x++){
    const i=y*W+x, h=H[i]+delta[i];
    const near=1-Math.min(1,Math.abs(h-WATER_H)/TERRA.coastBand);
    if(near<=0.02) continue;
    const u=x/W*7.3, v=y/W*7.3;
    const wx=(terraNoise(gw1,LN,u,v)*2-1)*TERRA.coastWarp/MAP*W;
    const wy=(terraNoise(gw2,LN,u,v)*2-1)*TERRA.coastWarp/MAP*W;
    const sx=Math.max(0,Math.min(W-1,x+wx))|0, sy=Math.max(0,Math.min(W-1,y+wy))|0;
    delta[i]+=(H[sy*W+sx]-H[i])*near*0.55*mask[i];
  }

  /* ---- 4. GUARD ----------------------------------------------------------
     No cell that started dry may finish wet. Everything downstream that
     matters β€” passability, the naval connectivity flood fill, water mesh
     coverage, whether the map even offers naval units β€” keys off WATER_H, so
     a carve that crossed it would quietly redesign the map. */
  for(let i=0;i<N;i++){
    let v=H[i]+delta[i];
    if(wet[i]){ if(v<WATER_H+0.004) v=WATER_H+0.004; }      // dry land stays dry
    else if(v<H[i]) v=H[i];                                  // and lakes never get deeper
    if(v>TERRA.ceiling) v=TERRA.ceiling;
    delta[i]=v-H[i];
  }

  /* ---- 5. UPSAMPLE THE DELTA --------------------------------------------
     Bilinear, so the added structure is smooth, while the 2048 field keeps
     every octave it already had underneath. */
  let lo=0, hiV=0;
  for(let y=0;y<TS;y++){
    const gy=(y/step)-0.5, y0=Math.floor(gy), fy=gy-y0;
    const ya=Math.max(0,Math.min(W-1,y0)), yb=Math.max(0,Math.min(W-1,y0+1));
    for(let x=0;x<TS;x++){
      const gx=(x/step)-0.5, x0=Math.floor(gx), fx=gx-x0;
      const xa=Math.max(0,Math.min(W-1,x0)), xb=Math.max(0,Math.min(W-1,x0+1));
      const d=(delta[ya*W+xa]*(1-fx)+delta[ya*W+xb]*fx)*(1-fy)
             +(delta[yb*W+xa]*(1-fx)+delta[yb*W+xb]*fx)*fy;
      const i=y*TS+x;
      const was=heightF[i];
      let v=was+d;
      if(was>=WATER_H){ if(v<WATER_H+0.002) v=WATER_H+0.002; }   // same guard at full res
      else if(v<was) v=was;                                       // no deepening underwater
      if(v>TERRA.ceiling) v=TERRA.ceiling;
      heightF[i]=v;
      if(d<lo) lo=d; if(d>hiV) hiV=d;
    }
  }
  const ms=((typeof performance!=='undefined')?performance.now():0)-t0;
  return {ms:Math.round(ms),work:W,ridgeAmp:+ridgeAmp.toFixed(3),
          deltaLo:+lo.toFixed(3),deltaHi:+hiV.toFixed(3)};
}
let TERRA_ENABLED=true;
let terraLastStats=null;