OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / map_dc_diffusion.gd

1070 lines · gd ref:
extends "res://scenes/maps/map_core.gd"
## MAP DC DIFFUSION — the erosion map with the TERRAIN SWAPPED.
##
## A copy of map_dc_erosion.gd. Every constant below is the same, deliberately:
## the vertical band, the colour ramp, the detail layer, the erosion parameters.
## The one change is where the large forms come from. Instead of the fBm
## multifractal mountain layer, they are read from a BAKED terrain-diffusion
## block -- xandergos/terrain-diffusion-30m, seed 1234, fp32 -- and the same
## erosion filter carves that instead. hyper-games #50 section 5.3.
##
## The generator does the swap by replacing pn_terrain_offset, so the filter
## receives the model's height AND its gradient and cuts gullies down the
## ranges the model actually made, rather than decorating them.
##
## WHY GN_DETAIL IS 0 HERE AND 3 IN THE EROSION MAP. The Gradient Noise node is
## not fine detail: at gain 0.1 its amplitude reaches gn.x * HEIGHT_AMP * 0.5 =
## 0.125 of the band, which is about 2900 m -- a co-equal layer with the model's
## own 6900 m of relief. Leaving it on would bury the landscape this map exists
## to show under fBm. fractal_noise_2d_lod loops `for i < octaves`, so 0 returns
## exactly vec3(0.0); this is an off switch, not a small value.
##
## THE BLOCK IS FINITE -- 30.72 km, world x 38370..69150, z 23010..53790. The
## sampler CLAMPS outside it, so the terrain runs flat to the horizon past those
## edges. That is a property of a baked block, not a bug, and it is why the
## spawn sits at the block's centre.
## DC Erosion — mountain ranges at two scales, for judging erosion AND view distance.
##
## WHY ADDING OCTAVES DID NOT GIVE MOUNTAIN RANGES BEFORE.
##
## Octaves go UP in frequency and DOWN in amplitude (lacunarity 2.0, gain 0.5 in
## fractal_noise_2d). Octave k has wavelength base/2^k and amplitude 1/2^k. They
## add DETAIL BENEATH the base wavelength and can never add structure above it —
## so raising mountain_octaves makes terrain busier, not grander.
##
## Worse, at the old terrain_height of 1000 m the base wavelength was 2 km, so:
##
##   octave 0   2.00 km
##   octave 1   1.00 km
##   octave 2   0.50 km   <- below Nyquist at LOD 8 (voxel 256 m), INVISIBLE at range
##   octave 3   0.25 km   <- invisible at range
##
## The octaves were there. You could not see them from far away, and nothing in the
## field was range-sized to begin with. That is the whole failure.
##
## THE FIX IS THE BASE WAVELENGTH, NOT THE OCTAVE COUNT. terrain_height 20000 puts
## octave 0 at 20 km, and all four octaves (20 / 10 / 5 / 2.5 km) stay above Nyquist
## at the LODs you actually view a range from.
##
## AND MOUNTAIN DISTANCE IS ITS OWN KNOB. Peak spacing is
## terrain_height / mountain_scale, independent of height. That is what makes a
## range read as a range rather than as noise with tall spikes.
##
## TWO SCALES IN ONE SCENE NEEDED A CODE CHANGE. mountain_height is documented as
## "amplitude as fraction of [floor_y, ceiling_y]" — ONE SCALAR for the whole world,
## so every region got identical relief and an Everest-scale range could not coexist
## with a Vermont-scale one. relief_scale / relief_floor (added to
## DCTerrainGeneratorProcNodes for this map) modulate that amplitude with a much
## lower-frequency field, so "how mountainous is it here" varies across the map.
## relief_scale = 0 keeps the old uniform behaviour, so nothing else changes.

const VOXEL_SIZE := 1.0
## 14, not 12. The view distance complaint was not fog, not the far plane
## (200 km, player.tscn) and not a clamp -- octree_depth simply IS the world, and
## 12 gave 57.3 km, so from the middle the horizon was 28.6 km and there was
## barely more than one 20 km mountain to see. Measured with
## dc-terrain/tests/test_leaf_count_vs_depth.cpp: leaf count grows LINEARLY at a
## flat 224 per level, because a clipmap adds a shell rather than a volume, so
## 12 -> 14 quadruples the range for about 21% more chunks (2080 -> 2528).
## MAX_LOD_MATERIALS went 12 -> 16 alongside, or LODs 12 and 13 would share a
## material slot and lose their fade_t crossfade at exactly these distances.
const OCTREE_DEPTH := 16          # root span = 28 * 2^15 * 1.0 = 917.5 km

## Vertical range. floor/ceiling are a HARD CLIP — solid below, air above — so
## this is the total relief available, not a suggestion.
##
## THE BAND IS CENTRED ON y = 0, AND THAT IS A PHYSICS CONSTRAINT, NOT A TASTE.
## Godot's character physics degrades with ALTITUDE — the magnitude of y, not
## distance from the origin, which measures clean out to 100 km. Measured with
## scenes/test/slope_travel_gate.gd, driving the shipping capsule up a
## triangulated slope:
##
##      altitude    5deg   10deg  15deg  20deg  25deg  30deg
##      0..6000    99.6%  98.4%  96.5%  93.9%  90.6%  86.5%   clean, no stalls
##      7000       98.2%  98.4%  96.5%  21.2%  90.7%  86.6%   one cell stalls
##      8000       99.0%  98.4%  96.6%  93.9%  89.5%  86.6%   clean
##      9700       87.3%  50.2%  18.9%  12.9%  18.1%  68.6%   stalls everywhere
##
## It is not a cliff: isolated failures appear around 7 km and are pervasive by
## 9.7 km, with backward corrections up to -0.66 m in a single frame. THAT is
## "the player gets stuck and cannot travel". Raising CharacterBody3D.safe_margin
## was tried and FALSIFIED — it fixes the mid slopes and makes 5 deg worse.
##
## The surface was MEASURED at 4600..15200 m (erosion_range_gate, 576/576
## columns), 10600 m of relief. Centring that on zero puts every walkable point
## within |y| <= 5300, below the first observed failure. Centring is optimal —
## any other placement has a larger max|y|, and the relief is what it is.
##
## THE SHIFT IS A PURE TRANSLATION AND CHANGES NOTHING ELSE. The density mapping
## is y_mapped = (y - floor_y) * (height_scale / y_range) - mtn_offset * scale,
## so moving floor and ceiling together leaves y_range — and therefore every
## wavelength, every slope, MOUNTAIN_HEIGHT's fraction and DETAIL_HEIGHT's
## fraction — untouched. The terrain translates and keeps its shape. WATER_Y
## moves with them so the waterline fraction is unchanged too (0.2381 either way).
##
## Was -1000 / 20000 with the surface at 4600..15200; now -10900 / 10100 with
## the surface at -5300..5300.
##
## 9 km -> 20 km alongside the TERRAIN_HEIGHT change. Slope is amplitude over
## half-wavelength, so tripling the wavelength needs roughly triple the vertical
## room to keep the terrain from flattening. Measured at 60 km wavelength: a 10 km
## range gives 0.33 RMS slope at 2 m, a 21 km range gives 0.50 -- against 0.63
## before. Gentler underfoot AND steeper at range, which is what a bigger world
## with visible distant peaks needs.
##
## NOTE this is now the dominant term in mountain amplitude: MOUNTAIN_HEIGHT is a
## FRACTION of it, so 0.55 * 21 km = ~11.5 km peaks.
const FLOOR_Y := -10900.0
const CEILING_Y := 10100.0

## World metres that one cycle of the noise covers. THE most important number
## here: it sets EVERY wavelength in the field simultaneously -- mountain,
## erosion and detail all evaluate at p = world / TERRAIN_HEIGHT.
##
## 20 km -> 60 km, because view distance was the binding complaint and extent was
## not what limited it. Measured: LOD cell size is 28 * 2^k and the split rule
## puts LOD k at ~56 * 2^k metres, so VOXEL SIZE IS ROUGHLY DISTANCE / 56. A
## 20 km mountain is down to ten voxels by 112 km and stops reading; a 60 km one
## survives to ~336 km. Making the features bigger buys distance for free, where
## raising lod_factor to buy the same distance costs 4x the leaves (2528 ->
## 10032, measured with test_leaf_count_vs_depth).
##
## It is a UNIFORM zoom, so DETAIL_SCALE is tripled below to hold the detail
## band at a 100 m base, and CEILING_Y is raised because tripling the wavelength
## at a fixed amplitude divides every slope by three -- measured, the whole
## profile fell from 0.63/0.38/0.27 to 0.33/0.20/0.15 and read as rolling hills.
const TERRAIN_HEIGHT := 60000.0

## Absolute waterline, in metres, converted to the colour shader's normalised
## fraction at setup. 4000 m sits below the measured surface minimum (5500 m), so
## nothing floods -- this map is about mountains, and a sea at 8.7 km, which is
## what the old 0.465 fraction became once the ceiling doubled, is not a feature.
## Moved with FLOOR_Y and CEILING_Y (was 4000 against a 4600 m surface minimum,
## now -5900 against -5300), which is what keeps the waterline FRACTION identical
## at 0.2381 — the number the colour shader actually consumes.
const WATER_Y := -5900.0

## LOOKDEV COLOUR MODE — 0 game material, 1 the lookdev's 3D material,
## 2 the lookdev's 2D hypsometric map tint.
##
## Set HERE rather than only from the terrain panel's checkbox. The checkbox
## exists too, but a control that has to be found and clicked cannot tell you
## whether it WORKED — the first run with it produced "just the normal shading"
## and there was no way, from the log, to tell a mode that never got set from a
## mode that got set and did nothing. This prints what it wrote.
const LOOKDEV_COLOUR_MODE := 1

## Peak spacing = TERRAIN_HEIGHT / MOUNTAIN_SCALE = 20 km between major peaks.
const MOUNTAIN_SCALE := 1.0
## 20 / 10 / 5 / 2.5 km. All four above Nyquist at the LODs you view a range from.
const MOUNTAIN_OCTAVES := 8
## Fraction of the 10 km vertical range at FULL relief. WAS 0.8, i.e. 8 km of
## relief at a 20 km wavelength -- steeper than the Himalaya, and measured as a
## flat 0.43 RMS slope at EVERY scale from 2 m to 100 m. That constant slope was
## the floor under the "everything is a cliff" complaint: no amount of tuning
## detail or erosion could get below it, because it is the mountain layer's own
## gradient. 0.55 -> ~5.5 km, still a very large mountain.
## ZERO. The erosion node adds pn_terrain_offset to whatever Height/Slope it is
## given, and that offset IS the mountain layer. With the heightfield supplying
## the terrain, the mountain has to be off or it is added on top of it. A map
## setting, not a code path.
const MOUNTAIN_HEIGHT := 0.0

## Relief lobes every TERRAIN_HEIGHT / RELIEF_SCALE = 40 km. The world is 57 km, so
## you get roughly one high region and one low one — which is the point: stand in
## the middle and both are in view at once.
const RELIEF_SCALE := 0.5
## Amplitude in the flattest regions. 0.08 * 8 km = ~640 m of relief: Vermont.
const RELIEF_FLOOR := 0.08

## PERSISTENCE — was hardcoded 0.5, and that is why the ground was glassy.
## Octave k has amplitude gain^k, so at 0.5 the amplitude at player-scale
## wavelengths is metres and no octave count fixes it: measured, raising
## mountain_octaves 4 -> 12 moved local roughness 0.72 -> 0.98 m per 2 m stride.
## 0.55 adds mid-scale structure without turning ridges into cliffs.
## 0.68, not 0.55. Octave k of an fBm is worth gain^k, so at 0.55 the mid
## octaves are already negligible and the field is a huge base wavelength plus
## fine detail with little between -- "no progressive mountain range buildup".
## Raising the octave COUNT does nothing about that and was measured not to:
## erosion octaves 5, 7 and 9 gave identical structure functions to two decimals.
## PERSISTENCE is the knob. Measured RMS slope at 100 m: 0.20 at gain 0.55,
## 0.32 at 0.68. Higher still (0.75) returns to the cliff-everywhere problem.
const MOUNTAIN_GAIN := 0.68

## DETAIL LAYER — its own amplitude and falloff, and NOT scaled by the relief
## field, so the low-relief lobes have rock underfoot too. That matters because
## the spawn sits in one: baseline roughness there was 0.69 m per 2 m stride
## against 9.78 m in the mountains, a 14x difference you could feel.
##
## dscale 200 over terrain_height 20000 = a 100 m base, six octaves down to
## ~3 m. dh is a NOMINAL amplitude — grad_noised's output range is about +/-0.1,
## so the effective figure is roughly a tenth of it. Measured, not derived.
## DETAIL_HEIGHT was 120 m and it was 77% of the roughness underfoot, in
## quadrature: 30 m of amplitude spent entirely between 15 and 100 m wavelength
## is a 47 degree RMS slope on its own, while contributing nothing at mid scale
## (its structure function is flat at ~30 m from 250 m outward). 30 m halves the
## 2 m slope; below 30 the returns vanish because the floor is the mountain.
## 600, not 200: DETAIL_SCALE is a divisor of TERRAIN_HEIGHT, so tripling that
## would have tripled the detail base wavelength from 100 m to 300 m and smoothed
## the ground underfoot. Tripled in step, the detail band stays where it was.
## ZERO, and that is the point. detail_offset() early-returns 0.0 when the scale
## is <= 0, so the wrapper contributes no fBm at all. The lookdev is the
## heightmap and the erosion filter and nothing else; a 600 here puts a 4-octave
## +/-30 m noise layer into the output the lookdev never had, which is what made
## "identical to lookdev" untrue the first time.
const DETAIL_SCALE := 0.0
const DETAIL_HEIGHT := 30.0
## 4, NOT 6. dscale 200 over terrain_height 20000 is a 100 m base, so six octaves
## reach 3.1 m — at or under the mesher's Nyquist for a 1.0 m voxel, which cannot
## be represented and aliases into hard glitter. Four stops at 12.5 m. Measured
## cost of dropping the two: roughness at spawn 2.45 -> 2.13 m per 2 m stride.
const DETAIL_OCTAVES := 4
const DETAIL_GAIN := 0.5

## NOT 2.0. With frequency doubling and constant gain every octave is a scaled
## copy of the last and they all land on harmonics — which is what reads as
## self-similar. A non-integer ratio stops them lining up. Applied to both the
## mountain and detail fBm.
const NOISE_LACUNARITY := 1.87

## SPAWN IS NO LONGER A HEIGHT. It is an XZ column, and the Y is found from the
## terrain at load time.
##
## The hardcoded height was wrong four times in one session -- 9600, 5200, 4200,
## 9700 -- every time for the same reason: it encodes a MEASUREMENT of a field
## that keeps moving, so any change to TERRAIN_HEIGHT, CEILING_Y, MOUNTAIN_HEIGHT
## or the erosion parameters silently invalidates it. One of those values also
## came from measuring the broken CPU field and was ~5 km low. A constant that
## has to be re-derived every time the thing it describes changes is a bug with a
## slow fuse.
##
## _surface_y() bisects DCTerrain.sample_density instead. That is only
## trustworthy because sample_density now returns the SAME surface the mesher
## draws -- it used to differ by a mean of 4 km, which is precisely how the
## 5200 m figure survived -- and dc-terrain/tests/test_erosion_parity.cpp is the
## regression guard on that.
## NOT (0,0). Gradient noise is identically ZERO at every integer lattice point --
## grad_noised_2d computes f = p - floor(p), so at a lattice point f = 0 and the
## value is dot(gradient, 0) -- and the world origin is a lattice point of EVERY
## octave simultaneously. Spawning there put the player on a guaranteed zero of
## the mountain field, sitting in a bowl that only rises to real mountains 45-100
## km out: measured 0 m at the origin, -1833 m at 10 km, +1672 m at 100 km. It
## reads as a vast empty impact crater, and it is the single least representative
## column in the world.
##
## (31337, -21500) is an ordinary column: -473 m against a world mean, with 699 m
## of relief across a 4 km window. Any off-lattice point would do; this one was
## measured rather than picked.
## The baked block. Shipped as a project asset rather than load()ed from an
## absolute path: export_filter is "selected_scenes", so a file that is not a
## scene dependency never enters the PCK -- the same trap that shipped
## map_dc_erosion without its colour shader. assets/terrain/*.hblk is in
## export_presets.cfg include_filter for exactly that reason.
## THE BAKED FIELD. 16x16 lookdev tiles, 4098 px + apron, 122.88 km across,
## world x -30750..92130  z -69150..53730, elevation -4230..2782 m.
##
## WAS ONE 4x4 BLOCK, 30.72 km, and the sampler CLAMPS outside its footprint —
## so past the edge the terrain went flat. stitch_block.py --list reports 50
## SOLID 16x16 regions in the 841-tile cache, and this is the one whose centre
## has the most BIOME DIVERSITY while still containing the spawn column: 7
## classes at >=1% of area and 46.9% land, against 6 and 33.6% for the block
## centred on the spawn. Ranked across all 50 solid regions from one coarse
## survey; the extent grew 4x linear / 16x area with no
## change to the pipeline at all: same .hblk format, same loader, same mip
## builder. A streaming window is only needed beyond this, for the full
## 829 x 668 km the cache spans sparsely.
##
## Both blocks align to the same underlying tile grid (origin = tile_start - 1px
## of apron), so identical world coordinates sample identical values and
## SPAWN_SURFACE_Y did not move.
const HEIGHTFIELD := "res://assets/terrain/diffusion_123km_b.hblk"

## THE WHOLE GENERATED TILE SET, instead of one stitched block.
##
## The block above is a SNAPSHOT: 4098^2 of solid tiles, 122.88 km, fixed at
## bake time. The generated set is 841 tiles scattered over tx -30..77,
## ty -30..56 — 5.9% of the 119x119 world and only 8.9% of its own bounding
## box. It is a flight path, not a rectangle, and there is no solid region large
## enough to stitch that would show it.
##
## So the node takes a LIST and the gaps read as STUB. Absent is not an error
## and it is not a clamp: clamping smears the nearest loaded row across the
## hole, which reads as terrain, and that is the failure this exists to avoid.
const TILE_DIR := "user://tile_cache/1234_hblk"
const USE_TILE_SET := true
## ⚠ Gaps read as the hard FLOOR, deliberately. A stub near the terrain band
## would look like flat ground and hide how incomplete the world is; at the
## floor it is unmistakably a hole. 5.9% coverage should LOOK like 5.9%.
const TILE_STUB_Y := FLOOR_Y

## EROSION, over the baked field.
##
## HEIGHT_SCALE is not a taste knob: the wrapper's y_mapped is
## (y - floor) * (HEIGHT_SCALE / y_range), and the Heightfield node's YScale is
## the same quotient. Change one without the other and the world moves.
## EROSION ON OR OFF — the filter is an ENHANCEMENT, not the terrain.
##
## The heightmap IS the terrain here: the Heightfield node samples the baked
## diffusion tile and displaces the surface to it, and that is a complete map on
## its own. The erosion filter runs on a FINER GRID than the model's 30/90 m and
## synthesises the detail below the model's resolution -- hyper-games #50: "the
## erosion filter belongs on a finer grid than the model's, not beside it."
##
## With this false the graph is the Heightfield node ALONE: no erosion node, no
## filter, no noise library in the emitted shader. That is the isolation step --
## look at the tile first, then put the filter back on top and see what it adds.
## Turning it off must not change where the ground is, only how detailed it is.
const EROSION_ENABLED := true

const EROSION_HEIGHT_SCALE := 5.0
## THE GULLY BAND, IN METRES — and the reason it is in metres.
##
## erode_heightmap.cpp asks for the coarsest gully as a LENGTH and derives the
## filter's scale from it, "because a raw scale number is unreadable". Same here:
##
##     erosion_scale = GULLY_METRES / (TERRAIN_HEIGHT * CELL_SCALE)
##
## WHY 60 m. The heightmap is 30 m/px and the diffusion model resolves features
## of about two pixels, so 60 m is the finest thing the tile can carry. Anything
## coarser than that and the filter is inventing structure the model already
## has; anything at or below it is filling in what the model cannot reach, which
## is the whole point (#50: "the erosion filter belongs on a finer grid than the
## model's, not beside it").
##
## For contrast, the band this map shipped with was 60000 * 0.15 * 0.7 = 6300 m,
## halving over 7 octaves to 98 m — every octave ABOVE the model's limit, so the
## filter never once reached under it.
##
## At a 1 m voxel nyquist_octaves(60, 1, 2) = 4.9, so about five of the seven
## octaves survive at LOD 0 — which is runevision's own reference count.
const EROSION_GULLY_METRES := 60.0

## GULLY DEPTH, IN METRES — how deep the filter cuts.
##
## This was a depth:WIDTH ratio, and that was the wrong invariant. The ratio
## exists in erode_heightmap.cpp to make erosion SCALE-INVARIANT, which is
## exactly what you do not want when the band moves 105x down. Depth in metres
## is the thing being asked for, so it is the thing written down.
##
## ⚠ THE OLD DERIVATION WAS WRONG AND SILENTLY DELIVERED 1/132 OF WHAT IT SAID.
##
##     depth = 2 * strength * H      =>   strength = DEPTH_METRES / (2 * H)
##
## has no erosion_scale term. terrain-lookdev/amplitude_law.py measured the
## filter's actual amplitude against BOTH parameters, one at a time over 128x,
## and got log-log slopes of +1.000 (strength) and +1.003 (erosion_scale): the
## incision is proportional to the PRODUCT. So when #55 moved the band from
## 6300 m to 60 m, the depth fell by that same 105x and the formula did not
## notice. EROSION_DEPTH_METRES = 30 was delivering 0.227 m rms / 0.392 m peak.
##
## Three independent measurements agreed on it before this was changed: the
## standalone renderer at the map's own settings, the amplitude law above, and
## gate_dc_vs_lookdev's CONTROL — which could not tell erosion-on from
## erosion-off, because at 0.23 m they are the same field.
##
## THE CONSTANT IS CALIBRATED, NOT DERIVED. Deriving it from the filter's
## algebra is what produced the wrong formula twice; this number comes from a
## render. amplitude_law.py measured
##
##     0.227 m rms  at  strength 0.00571429, erosion_scale 0.00142857
##
## so amplitude = K * strength * erosion_scale with K = 27807. Inverting:
##
##     strength = DEPTH_METRES * TERRAIN_HEIGHT * CELL_SCALE / (K * GULLY_METRES)
##
## Verified by prediction, not by re-fitting: depth_strip.py asked for 3 / 10 /
## 30 m through this expression and measured 3.01 / 10.04 / 30.12 m.
##
## ⚠ DEPTH_METRES IS RMS OVER THE FIELD, and K was calibrated on the window at
## (50000, 34000) of the shipped 30 km block. A flatter stretch of terrain
## erodes less, so this is a good dial and not a guarantee. Peak incision runs
## about 1.72x the rms (measured 0.392/0.227, 5.17/3.01, 17.23/10.04 — stable).
##
## WHY 10 AND NOT 30. Rendered side by side at the 60 m band, 30 m is a
## depth:width of 0.86 — a slot canyon, not a gully. 10 m is 0.29, just above
## the 0.05-0.25 erode_heightmap.cpp quotes for real valleys, which is right for
## gullies at a resolution the heightmap cannot carry.
const EROSION_DEPTH_METRES := 10.0

## Measured, not derived. See the note above. terrain-lookdev/amplitude_law.py
## reproduces it; if the filter's internals change, re-run that and update this.
const EROSION_AMPLITUDE_K := 27807.0

## THE FIELD'S HALF-RELIEF, IN METRES — the SLOPE's vertical unit.
##
## Measured over all 841 cached diffusion tiles (829 x 668 km of seed 1234):
## the field spans -4829 .. 5110 m, so half-range 4970 m. The PLAYABLE band
## (CEILING_Y - FLOOR_Y = 21000 m) is 2.1x larger, and normalising the slope by
## it under-reads the terrain's steepness by that factor.
##
## This is GLOBAL on purpose. erode_heightmap.cpp normalises per tile, which is
## why it reads the terrain well AND why erosion_wrapper_glsl.h refused to copy
## it: two adjacent tiles of different relief normalise differently and the join
## steps. One constant for the whole field has the reference's magnitude and no
## seam — and it keeps working when the single block becomes many, which is why
## this and the tiling work belong together.
##
## ⚠ If the seed changes, this is wrong. Re-measure over the tile cache.
const EROSION_FIELD_HALF_RANGE := 4970.0

const EROSION_H := (CEILING_Y - FLOOR_Y) * 0.25 * 0.5

const EROSION_CELL_SCALE := 0.7
const EROSION_OCTAVES := 7
## gain = 1/lacunarity is what makes the filter self-similar across octaves;
## #45 measured slope growing 6.3x across octaves at other values.
const EROSION_GAIN := 0.5

## The block's centre. Spawning outside its footprint would put the player on
## the clamped edge, which is flat and reads as a broken generator.
const SPAWN_XZ := Vector2(53760.0, 38400.0)

## The ERODED surface at the spawn column, in metres, MEASURED rather than
## guessed -- _surface_y below cannot find it. sample_density() is the CPU
## generator path and the heightfield binding is GPU-only, so the CPU still
## evaluates the fBm mountain and would return a surface from a different world.
##
## The ERODED surface at the spawn column, measured with the harness at this
## map's own parameters (the raw field there is 165.0 m; erosion takes it down):
##   cd dc-terrain && /tmp/erosion_render4 \
##       --heightmap ../hyper-tps/assets/terrain/diffusion_gully_30km.hblk \
##       --ox 53760 --oz 38400 --n 1 --mpp 30
##   -> -615.3 m
##
## gate_dc_vs_lookdev.gd checks the whole surface against the same harness, so a
## stale number here shows up as a gate failure rather than as a long fall.
const SPAWN_SURFACE_Y := 165.0

## Metres above the found surface. Enough not to spawn inside rock on a slope
## the bisection sampled slightly off, small enough not to be a fall.
const SPAWN_CLEARANCE := 3.0

## The teleporter sits beside the spawn, on its own column -- 6 m away can be
## several metres different in height on this terrain, so it gets its own query
## rather than reusing the player's.
const TELEPORTER_OFFSET_X := 6.0
const TELEPORTER_CLEARANCE := 0.0

var _gen: DCTerrainGeneratorProcNodes
var erosion_node: Object = null


func _ready() -> void:
	super._ready()
	_setup_terrain()
	_place_player()
	# NOT here. DCCompute initialises its GPU thread ~0.5 s after the map's
	# _ready, so an oracle called from _ready measures a device that does not
	# exist yet and reports "the GPU returned NOTHING" — which is a statement
	# about the clock, not about the terrain. It cost a run to learn that.
	set_process(true)


## GPU vs CPU, on the same points, in the running engine.
##
## The CPU side is already tied to the lookdev (gate_dc_vs_lookdev: 0.028 m over
## 256 columns). So if the GPU agrees with the CPU here, the chain is closed:
## lookdev == CPU == GPU. If it does not, this says so in one line instead of
## leaving "it looks like noise" to be argued about.
##
## An EMPTY return means the GPU declined the work. Early in the map's life that
## is just the GPU thread still starting, so this RETRIES rather than concluding;
## only after the deadline is an empty answer a finding.
## ⛔ 20 s IS LOAD-BEARING. DO NOT RAISE IT.
##
## _gpu_oracle() does not observe -- it SUBMITS. debug_gpu_density() calls
## gpu_thread_->submit(), a real density fill, once per frame until it succeeds
## or this deadline passes.
##
## GPU bring-up takes ~25-29 s after map load (measured 2026-09-03: map at
## ~13.6 s, "GPU compute ready" at ~42.8 s), so at 20 s the oracle gives up
## BEFORE the GPU thread is live and never contends with it. That is why it
## reports "the GPU never accepted a density fill" -- the message is accurate,
## and the oracle is simply not able to answer on this machine.
##
## Raised to 120 s on 2026-09-03 to "fix" that, and it broke the terrain: the
## oracle's submits then interleaved with the mesh pipeline's compute lists, so
## the DENSITY uniform set (bindings 0-13, binding 4 a UniformBuffer of 176 B)
## got bound while a MESH pipeline (bindings 0-11, all StorageBuffer) was
## current. Every dispatch failed with "uniform_set is null", leaves climbed to
## 3382, and the terrain vanished.
##
## If this oracle is ever to answer, it must WAIT for the GPU thread rather than
## race it -- not simply be given a longer runway to race in.
const ORACLE_TIMEOUT_MS := 20000.0

var _oracle_done := false
var _oracle_deadline := 0.0
var _oracle_start := 0.0


func _process(_delta: float) -> void:
	if _oracle_done:
		set_process(false)
		return
	if _oracle_deadline == 0.0:
		_oracle_start = Time.get_ticks_msec()
		_oracle_deadline = _oracle_start + ORACLE_TIMEOUT_MS
	if _gpu_oracle():
		_oracle_done = true
		set_process(false)
	elif Time.get_ticks_msec() > _oracle_deadline:
		push_warning("[MapDCDiffusion] GPU ORACLE: still nothing after %.0f s. " % [
				(Time.get_ticks_msec() - _oracle_start) / 1000.0] +
				"The GPU never accepted a density fill, so everything on screen " +
				"came from the CPU fallback.")
		_oracle_done = true
		set_process(false)


## Returns false while the GPU is not ready yet, true once it has answered.
func _gpu_oracle() -> bool:
	var terrain := get_node_or_null("DCTerrain")
	if terrain == null or not terrain.has_method("debug_gpu_density"):
		push_warning("[MapDCDiffusion] no debug_gpu_density — extension is older than this map")
		return true

	var cells := 8
	var vs := 30.0
	var org := Vector3(SPAWN_XZ.x - 120.0, SPAWN_SURFACE_Y - 120.0, SPAWN_XZ.y - 120.0)
	var g: PackedFloat32Array = terrain.debug_gpu_density(org, vs, cells)
	if g.is_empty():
		return false   # GPU thread not up yet — _process will try again

	var stride := cells + 1
	var worst := 0.0
	var sum_sq := 0.0
	var count := 0
	var gpu_lo := 1e30
	var gpu_hi := -1e30
	for iz in range(stride):
		for iy in range(stride):
			for ix in range(stride):
				var gv: float = g[iz * stride * stride + iy * stride + ix]
				gpu_lo = minf(gpu_lo, gv)
				gpu_hi = maxf(gpu_hi, gv)
				var p := Vector3(org.x + ix * vs, org.y + iy * vs, org.z + iz * vs)
				var cv: float = terrain.sample_density(p)
				var d: float = absf(gv - cv)
				worst = maxf(worst, d)
				sum_sq += d * d
				count += 1
	# DOES THE SHADER READ THE FIELD AT ALL?
	#
	# The check above says gpu != cpu but not why. This one is single-purpose:
	# sample at a FIXED y across 2.4 km of x/z, where the block's own relief is
	# hundreds of metres. If the shader reads hblk_data the density must vary by
	# that much. If it reads zeros, heightmap_gn returns a constant and the only
	# variation left is the 30 m detail layer -- a flat plane with low noise,
	# which is exactly what is on screen.
	var wide_vs := 300.0
	var wide_org := Vector3(SPAWN_XZ.x - 1200.0, SPAWN_SURFACE_Y, SPAWN_XZ.y - 1200.0)
	var w: PackedFloat32Array = terrain.debug_gpu_density(wide_org, wide_vs, 8)
	if not w.is_empty():
		var ws := 9
		var w_lo := 1e30
		var w_hi := -1e30
		var c_lo := 1e30
		var c_hi := -1e30
		for iz in range(ws):
			for ix in range(ws):
				# y index 0 == the fixed plane at wide_org.y
				var wv: float = w[iz * ws * ws + 0 * ws + ix]
				w_lo = minf(w_lo, wv); w_hi = maxf(w_hi, wv)
				var cv2: float = terrain.sample_density(
						Vector3(wide_org.x + ix * wide_vs, wide_org.y, wide_org.z + iz * wide_vs))
				c_lo = minf(c_lo, cv2); c_hi = maxf(c_hi, cv2)
		print("[MapDCDiffusion] FIELD-READ PROBE over 2.4 km at fixed y:")
		print("    GPU density spread %.1f m   (%.1f .. %.1f)" % [w_hi - w_lo, w_lo, w_hi])
		print("    CPU density spread %.1f m   (%.1f .. %.1f)" % [c_hi - c_lo, c_lo, c_hi])
		if w_hi - w_lo < 0.25 * (c_hi - c_lo):
			push_warning("[MapDCDiffusion] the GPU field barely varies across 2.4 km " +
					"while the CPU field varies by %.0f m. The shader is NOT reading " % (c_hi - c_lo) +
					"hblk_data — it is bound to something empty.")
		else:
			print("    both vary — the shader IS reading the heightfield")

	# WRITE THE TERRAIN AS AN IMAGE, from the GPU.
	#
	# One debug_gpu_density dispatch over a 64^3 block spanning the vertical
	# range, then the first sign change down each column. That is a top-down
	# heightmap of what the ENGINE actually generates, which can be laid beside
	# the lookdev's picture of the same ground. Every other check tonight has
	# been a number; a number has not once told anybody what they were looking at.
	var img_n := 63
	var img_span := 7680.0
	var img_vs := img_span / float(img_n)
	var img_org := Vector3(SPAWN_XZ.x - img_span * 0.5,
			SPAWN_SURFACE_Y - img_span * 0.5, SPAWN_XZ.y - img_span * 0.5)
	var blk: PackedFloat32Array = terrain.debug_gpu_density(img_org, img_vs, img_n)
	if not blk.is_empty():
		var st := img_n + 1
		var out := FileAccess.open("/tmp/engine_gpu_surface.f32", FileAccess.WRITE)
		var hits := 0
		for iz in range(st):
			for ix in range(st):
				var surf := -99999.0
				# scan DOWN: density is positive below the surface
				for iy in range(st - 1, 0, -1):
					var a: float = blk[iz * st * st + iy * st + ix]
					var b: float = blk[iz * st * st + (iy - 1) * st + ix]
					if (a >= 0.0) != (b >= 0.0):
						var t: float = a / (a - b)
						surf = img_org.y + (float(iy) - t) * img_vs
						hits += 1
						break
				out.store_float(surf)
		out.close()
		print("[MapDCDiffusion] wrote /tmp/engine_gpu_surface.f32  %dx%d over %.2f km "
				% [st, st, img_span / 1000.0] +
				"from (%.0f, %.0f), %d/%d columns hit" % [img_org.x, img_org.z, hits, st * st])

	# ISOLATE THE BUFFER READ.
	#
	# heightfield_only makes the shader "hblk_height_m(x,z) - y" and nothing
	# else -- no erosion, no graph, no detail. Flipping it at runtime recompiles
	# the GLSL, so one probe in that mode answers the only question left: can
	# the shader see hblk_data? If the density there is constant across 2.4 km,
	# the buffer is empty or unbound, and no amount of correctness in the
	# erosion path can matter.
	if _gen != null:
		_gen.set_heightfield_only(true)
		var iso: PackedFloat32Array = terrain.debug_gpu_density(wide_org, 300.0, 8)
		if not iso.is_empty():
			var ws2 := 9
			var lo2 := 1e30
			var hi2 := -1e30
			for iz in range(ws2):
				for ix in range(ws2):
					var v2: float = iso[iz * ws2 * ws2 + 0 * ws2 + ix]
					lo2 = minf(lo2, v2); hi2 = maxf(hi2, v2)
			print("[MapDCDiffusion] BUFFER-READ ISOLATION (heightfield_only, 2.4 km):")
			print("    GPU spread %.1f m  (%.1f .. %.1f)" % [hi2 - lo2, lo2, hi2])
			if hi2 - lo2 < 10.0:
				push_warning("[MapDCDiffusion] the shader reads a CONSTANT from " +
						"hblk_data — the buffer is empty or not bound at binding 13. " +
						"That is the whole bug; everything else is downstream of it.")
			else:
				print("    the shader CAN read hblk_data — the bug is in the erosion path")
		_gen.set_heightfield_only(false)

	var rms: float = sqrt(sum_sq / float(count))
	print("[MapDCDiffusion] GPU ORACLE over %d points: max |gpu-cpu| = %.3f, rms = %.3f"
			% [count, worst, rms])
	print("[MapDCDiffusion] GPU density range %.1f .. %.1f" % [gpu_lo, gpu_hi])
	if gpu_hi - gpu_lo < 1.0:
		push_warning("[MapDCDiffusion] the GPU field is CONSTANT across the probe — " +
				"that is what an unbound or zero-filled heightfield buffer looks like.")
	elif worst > 1.0:
		push_warning("[MapDCDiffusion] GPU and CPU DISAGREE by %.1f — the shader is " % worst +
				"not computing what sample_density computes.")
	else:
		print("[MapDCDiffusion] GPU agrees with CPU, and CPU is tied to the lookdev. " +
				"Chain closed: lookdev == CPU == GPU.")
	return true


## Load every generated tile into the node's grid.
##
## The index table is sized to the WORLD, not to these tiles, so a tile fetched
## later lands without changing the emitted GLSL — see
## spec/tile-residency-and-generation.md and HeightfieldNode::_rebuild_grid.
## The span formula is the octree's own, kept here rather than defaulted so the
## two cannot drift.
func _load_tile_set(hf: Object) -> int:
	hf.set_stub_height(TILE_STUB_Y)
	hf.set_world_span(28.0 * float(1 << (OCTREE_DEPTH - 1)) * VOXEL_SIZE)
	var d := DirAccess.open(TILE_DIR)
	if d == null:
		push_warning("[MapDCDiffusion] no tile cache at %s — falling back to the block" % TILE_DIR)
		hf.set("tile_path", HEIGHTFIELD)
		return 0
	var t0 := Time.get_ticks_msec()
	var n := 0
	for f in d.get_files():
		if not f.ends_with(".hblk"):
			continue
		if hf.add_tile(TILE_DIR.path_join(f)):
			n += 1
	var info: Dictionary = hf.get_grid_info()
	var mb := float(int(info["table"]) + n * int(info["n"]) * int(info["n"])) * 4.0 / 1048576.0
	print("[MapDCDiffusion] tile set: %d tiles in %d ms, %dx%d world table, %.1f MiB"
			% [n, Time.get_ticks_msec() - t0, int(info["nx"]), int(info["ny"]), mb])
	if n == 0:
		push_warning("[MapDCDiffusion] tile cache is EMPTY — falling back to the block")
		hf.set("tile_path", HEIGHTFIELD)
	return n


func _setup_terrain() -> void:
	var terrain := get_node_or_null("DCTerrain")
	if terrain == null or not is_instance_valid(terrain):
		push_warning("[MapDCDiffusion] no DCTerrain child — nothing to generate")
		return

	# The erosion graph, built in code: the graph IS the parameter set, and six
	# literals in a script diff better than a .tres full of node ids. Socket indices
	# follow scenes/test/proc_erosion_sunshine_test.gd, the working reference.
	var g := ProcNodeGraph.new()

	# THE TERRAIN IS A NODE. Heightfield replaces GradientNoise in the same two
	# sockets; everything downstream is untouched. Swap this node and the level
	# gets a different terrain -- which is what a composed density graph is for,
	# and why this is not a flag on the generator.
	# ONE NODE. The heightmap and nothing else -- no erosion, no base noise, no
	# second stage. If this does not show the tile then nothing more elaborate
	# can, and every layer added before proving it just hides where the fault is.
	# The erosion node goes back on top once this is confirmed.
	var noise := g.add_node(HeightfieldNode.new())
	if USE_TILE_SET:
		_load_tile_set(g.get_node(noise))
	else:
		g.get_node(noise).set("tile_path", HEIGHTFIELD)
	g.get_node(noise).set_input_default(1, TERRAIN_HEIGHT)                      # normalised pos -> world
	g.get_node(noise).set_input_default(2, (CEILING_Y - FLOOR_Y) * 0.25 * 0.5)  # metres per gn unit
	g.get_node(noise).set_input_default(3, (FLOOR_Y + CEILING_Y) * 0.5)         # band centre
	g.get_node(noise).set_input_default(4, FLOOR_Y)                             # floor, for the y-map
	g.get_node(noise).set_input_default(5, 5.0 / (CEILING_Y - FLOOR_Y))         # height_scale / y_range

	# THE EROSION NODE, PUT BACK. The comment above described the isolation step
	# -- one node, the heightmap alone, to prove the tiles reach the engine --
	# and said "the erosion node goes back on top once this is confirmed". It was
	# confirmed and this never went back, so the map has been shipping the bare
	# 30 m diffusion field: no gullies, no ridges, just Catmull-Rom between
	# texels. The compiled shader showed it plainly -- the Heightfield node
	# computed gn_height and gn_slope and NOTHING consumed them.
	#
	# Wired exactly as scenes/test/proc_erosion_sunshine_test.gd wires it, which
	# is the reference the parameter comments already point at: outputs 0 and 1
	# (Height, Slope) into the erosion node's EXTERNAL inputs 7 and 8. That is
	# the use_external path in noise_nodes.cpp, so the filter reads the model's
	# height AND its gradient rather than re-deriving a gradient by finite
	# difference off the sampled field.
	# THE FILTER, ON TOP OF THE HEIGHTMAP. Always in the graph; whether it is
	# EMITTED is the generator's erosion_enabled property, so it can be toggled
	# in game from the terrain panel rather than by editing this file.
	#
	# Not in the graph means not in the shader: no erosion node, so
	# compile_to_function emits no noise library either, and evaluate_density is
	# the heightmap displacement, the y-mapping and the clip. The Heightfield
	# node already writes pos.y from the tile, so it is a complete terrain by
	# itself and proc_density returns (tile height - query height) directly.
	var erosion := g.add_node(ErosionNoiseNode.new())
	# HeightScale MUST be 5.0: the wrapper's y_mapped divides by exactly this
	# (EN_HS / y_range, and YScale above is 5.0/(CEILING-FLOOR)). They are two
	# halves of one mapping and a mismatch shifts the whole world vertically --
	# which is precisely the 2-6.5 m offset the six-digit y-scale literal caused.
	g.get_node(erosion).set_input_default(1, EROSION_HEIGHT_SCALE)
	# DERIVED, not typed. See EROSION_GULLY_METRES / EROSION_DEPTH_METRES.
	g.get_node(erosion).set_input_default(2,
			EROSION_DEPTH_METRES * TERRAIN_HEIGHT * EROSION_CELL_SCALE
			/ (EROSION_AMPLITUDE_K * EROSION_GULLY_METRES))
	g.get_node(erosion).set_input_default(3, 0.6)    # SlopePower: dead, kept so old graphs load
	g.get_node(erosion).set_input_default(4, EROSION_CELL_SCALE)
	g.get_node(erosion).set_input_default(5, EROSION_OCTAVES)
	g.get_node(erosion).set_input_default(6, EROSION_GAIN)
	g.connect_sockets(noise, 0, erosion, 7)   # Height -> external height
	g.connect_sockets(noise, 1, erosion, 8)   # Slope  -> external slope


	_gen = DCTerrainGeneratorProcNodes.new()
	_gen.set_terrain_height(TERRAIN_HEIGHT)
	_gen.set_floor_y(FLOOR_Y)
	_gen.set_ceiling_y(CEILING_Y)
	_gen.set_mountain_scale(MOUNTAIN_SCALE)
	_gen.set_mountain_height(MOUNTAIN_HEIGHT)
	_gen.set_mountain_octaves(MOUNTAIN_OCTAVES)
	_gen.set_relief_scale(RELIEF_SCALE)
	_gen.set_relief_floor(RELIEF_FLOOR)
	_gen.set_mountain_gain(MOUNTAIN_GAIN)
	_gen.set_detail_scale(DETAIL_SCALE)
	_gen.set_detail_height(DETAIL_HEIGHT)
	_gen.set_detail_octaves(DETAIL_OCTAVES)
	_gen.set_detail_gain(DETAIL_GAIN)
	_gen.set_noise_lacunarity(NOISE_LACUNARITY)
	# height_space_sqrt stays OFF. The transform is implemented and correct, but it
	# pivots on floor_y, so squaring compresses the whole world onto the floor —
	# measured elevations collapsed to about -650..-990 m against a -1000 m floor,
	# and the roughness-follows-elevation effect it exists for did NOT appear
	# (highest transect was the SMOOTHEST). It needs the vertical mapping
	# re-derived around a real sea level before it is worth enabling.
	# BEFORE set_graph: set_heightfield_path recompiles the GLSL, and so does
	# set_graph. Setting the field first means one compile with the field in it
	# rather than one without followed by another with.
	# HEIGHTFIELD ONLY. No erosion, no graph, no noise -- density is the field
	# and nothing else. This is the isolation step: bring the tiles in, look at
	# them, and only then put the filter back on top. Everything that made the
	# first attempt hard lives on the other side of this switch.
	# The generator no longer owns the tile — the node does, and the generator
	# just collects its floats as opaque custom data. Left set to "" rather than
	# removed so the older path stays available.
	_gen.set_heightfield_path("")
	# EROSION BACK ON. heightfield_only was the isolation step -- it proved the
	# tiles reach the engine. The erosion parameters below are the map's own,
	# which are also what erosion_wrapper_params_from_map() hands the lookdev
	# harness, so "identical to lookdev" is a property of both reading the same
	# constants rather than of two lists agreeing today.
	#
	# The one that must match and is easy to miss: Gradient Noise Detail is 0,
	# so gn is exactly vec3(0) and the filter sees the heightfield ALONE -- which
	# is what the harness does in heightmap mode. Any other value adds an fBm
	# layer the lookdev never had.
	_gen.set_heightfield_only(false)
	# THE LOOKDEV'S OWN WRAPPER, not a transcription of it. The generator stops
	# assembling its 47 KB engine wrapper and emits erosion_wrapper_glsl instead,
	# so what runs is gpu_wrapper() -- the same function erosion_render compiles
	# and test_erosion_parity guards. With DETAIL_SCALE 0 that is exactly
	# heightmap + erosion, plus the y-mapping and the floor/ceiling clip.
	_gen.set_lookdev_wrapper(false)
	# EROSION ON OR OFF. The generator skips the ErosionNoise node when this is
	# false, so the filter and the whole noise library leave the emitted shader.
	# Set from the map's constant here, and toggleable at runtime from the
	# terrain panel's erosion_enabled checkbox.
	# THE GULLY BAND, derived from the length we actually want.
	_gen.set_erosion_scale(EROSION_GULLY_METRES / (TERRAIN_HEIGHT * EROSION_CELL_SCALE))
	_gen.set_field_half_range(EROSION_FIELD_HALF_RANGE)
	_gen.set_erosion_enabled(EROSION_ENABLED)
	# THE EROSION-MINIMAL WRAPPER. This map's terrain is the baked tile and the
	# filter on top of it; the general wrapper's mountain, relief and detail
	# layers contribute nothing here but branches. Minimal emits the heightfield,
	# the filter, the y-mapping and the clip, and nothing else.
	_gen.set_erosion_minimal(true)
	_gen.set_graph(g)
	# OUR GEISS-STYLE UNDERSEA TERM (§4 P3), below the coast band (the scene's
	# coast_start / coast_full knobs, 30 / 50 m below WATER_Y by default), capped at half
	# the water depth; cut out of blender/undersea_geiss.tscn by
	# UnderseaGeissGraph.install_detail. The minimal wrapper had no submarine term, so
	# the water level it masks against is set here.
	_gen.set_submarine_water_y(WATER_Y)
	var undersea_why := UnderseaGeissGraph.install_detail(_gen)
	if undersea_why != "":
		push_warning("[MapDCDiffusion] undersea graph not used: %s" % undersea_why)

	# Assert the graph compiled to the erosion function rather than silently
	# producing a bare noise field. A generator that fails to compile still renders
	# something plausible, which is the worst outcome for a map whose only purpose
	# is judging what the erosion looks like.
	#
	# CHECK THE CALL, NOT THE NAME. This used to test for "erosion_filter", which
	# was a real check while that function was emitted only when the erosion node
	# was in the graph. It is not one any more: erosion_filter now lives in the
	# shared noise LIBRARY, which GpuCompiler attaches whenever any node mentions
	# noise at all — so a graph containing nothing but GradientNoise would have
	# passed. The string below appears only in ErosionNoiseNode's own emitted
	# snippet, so it is present exactly when the node actually ran.
	# The parent map asserts the compiled GLSL calls erosion_node_eval. This graph
	# deliberately has no erosion node, so that check is inherited noise -- it
	# fired every run and said the generator had failed when it had not. What
	# matters here is that the heightfield node emitted its sampler.
	var glsl: String = g.compile_to_glsl_function("terrain_density", true)
	# ⛔ CHECK THE CALL SITE, NOT THE DEFINITION.
	#
	# `glsl.contains("hblk_sample(")` CANNOT FAIL: the Heightfield node's prelude
	# DEFINES `vec3 hblk_sample(float x, float z)` whether or not one line calls
	# it. On 2026-09-03 the tile grid loaded, 839 tiles went resident, 213 MiB
	# uploaded — and HeightfieldNode::compile_glsl returned its flat-field
	# fallback (`gn_height = 0.0`) because it gated on the SINGLE tile's n_.
	# The world was flat and this check passed throughout.
	#
	# The node emits exactly one call site, `vec3 hf_s = hblk_sample(...)`, so
	# that string is the presence of the heightfield IN the density function.
	if not glsl.contains("hf_s = hblk_sample("):
		push_warning("[MapDCDiffusion] the density GLSL never CALLS the heightfield " +
				"sampler — the tiles are loaded and uploaded but the graph emitted " +
				"a flat field. You are not looking at the terrain.")


	terrain.set("generator", _gen)

	# THE COLOUR SHADER NORMALISES HEIGHT BY THE SAME VERTICAL RANGE, so it has to
	# be told what that range is. It was authored in the .tscn as (-1000, 9000)
	# and stayed there when CEILING_Y moved to 20000, which silently broke every
	# material band: h = (y - floor)/(ceiling - floor) read 0.978 at the spawn
	# instead of 0.466, putting the whole world past the snow threshold (0.70)
	# so it rendered as featureless white with no rock, no cliffs and no
	# variation. It looked like missing terrain rather than a broken ramp.
	#
	# Set from the map's own constants rather than authored in the scene: a
	# duplicated constant is a constant that goes stale, which is the same defect
	# as the hardcoded spawn height above.
	var mat: Variant = terrain.get("custom_material")
	if mat != null and mat is ShaderMaterial:
		var water_frac: float = (WATER_Y - FLOOR_Y) / (CEILING_Y - FLOOR_Y)
		(mat as ShaderMaterial).set_shader_parameter(
				"terrain_params", Vector3(FLOOR_Y, CEILING_Y, water_frac))
		print("[MapDCDiffusion] colour ramp: floor %.0f ceiling %.0f water %.0f m (h=%.3f)"
				% [FLOOR_Y, CEILING_Y, WATER_Y, water_frac])
		# THE MODE, and proof it was written. Reading it back means a silent
		# failure (wrong name, wrong type, material not the one being drawn)
		# shows up here rather than as "the colours did not change".
		(mat as ShaderMaterial).set_shader_parameter("color_mode", LOOKDEV_COLOUR_MODE)
		var read_back: Variant = (mat as ShaderMaterial).get_shader_parameter("color_mode")
		print("[MapDCDiffusion] color_mode set to %d, reads back as %s (%s)"
				% [LOOKDEV_COLOUR_MODE, str(read_back),
				   "MATCH" if int(read_back) == LOOKDEV_COLOUR_MODE else "MISMATCH"])
		var shader_res: Variant = (mat as ShaderMaterial).shader
		print("[MapDCDiffusion] shader is %s" % [
				shader_res.resource_path if shader_res != null else "<null>"])
	else:
		push_warning("[MapDCDiffusion] no ShaderMaterial on DCTerrain — colour ramp " +
				"still on whatever the scene authored, which will not match " +
				"floor_y/ceiling_y.")

	# The colour material is declared in map_dc_erosion.tscn as an ext_resource, not
	# load()ed here. export_filter is "selected_scenes", so only scene DEPENDENCIES
	# are exported and a runtime load() is not one — the exported build logged
	# "Cannot load shader: res://scenes/maps/dc_erosion_color.gdshader" because the file
	# had never entered the PCK.

	# ROOT SPAN, not half of it. OctreeClipmap::initialize centres the root and
	# offsets by root_span/2, so voxels_per_cell * 2^(depth-1) IS the full extent.
	# proc_erosion_sunshine_test.gd calls the same expression `half_ext` and prints
	# double it — that print is wrong; do not copy it.
	var span: float = float(1 << (OCTREE_DEPTH - 1)) * 28.0 * VOXEL_SIZE
	print("[MapDCDiffusion] voxel %.2f m depth %d -> span %.1f km (+/- %.1f km)" %
			[VOXEL_SIZE, OCTREE_DEPTH, span / 1000.0, span / 2000.0])
	print("[MapDCDiffusion] peak spacing %.1f km, octaves %d (%.1f/%.1f/%.1f/%.1f km), " %
			[TERRAIN_HEIGHT / MOUNTAIN_SCALE / 1000.0, MOUNTAIN_OCTAVES,
			 TERRAIN_HEIGHT / MOUNTAIN_SCALE / 1000.0,
			 TERRAIN_HEIGHT / MOUNTAIN_SCALE / 2000.0,
			 TERRAIN_HEIGHT / MOUNTAIN_SCALE / 4000.0,
			 TERRAIN_HEIGHT / MOUNTAIN_SCALE / 8000.0] +
			"relief lobes %.1f km, relief %.0f m .. %.0f m" %
			[TERRAIN_HEIGHT / RELIEF_SCALE / 1000.0,
			 (CEILING_Y - FLOOR_Y) * MOUNTAIN_HEIGHT * RELIEF_FLOOR,
			 (CEILING_Y - FLOOR_Y) * MOUNTAIN_HEIGHT])


## Surface height in a column, or null_h if the column never crosses zero.
##
## Coarse scan to bracket, then bisect. The coarse step must be smaller than the
## thinnest feature worth landing on; 50 m over a 21 km band is 420 samples,
## which is instant and cannot step over a mountain.
const _NO_SURFACE := -999999.0

func _surface_y(terrain, x: float, z: float) -> float:
	var lo := FLOOR_Y + 1.0
	var hi := CEILING_Y - 1.0
	var step := 50.0
	# Scan DOWNWARD from the ceiling so we find the TOP surface. Scanning up from
	# the floor finds the bottom of the first overhang instead, and while this
	# generator is a heightfield today it will not be once caves land.
	var prev_y := hi
	var prev: float = terrain.sample_density(Vector3(x, hi, z))
	var y := hi - step
	while y >= lo:
		var d: float = terrain.sample_density(Vector3(x, y, z))
		if (prev >= 0.0) != (d >= 0.0):
			var a := y        # inside the sign change
			var b := prev_y   # outside
			for _i in range(28):
				var m: float = (a + b) * 0.5
				if (terrain.sample_density(Vector3(x, m, z)) >= 0.0) == (d >= 0.0):
					a = m
				else:
					b = m
			return (a + b) * 0.5
		prev = d
		prev_y = y
		y -= step
	return _NO_SURFACE


func _place_player() -> void:
	var terrain := get_node_or_null("DCTerrain")
	if terrain == null or not is_instance_valid(terrain):
		push_error("[MapDCDiffusion] no DCTerrain — cannot place spawn on the surface")
		return

	# THE MEASURED SURFACE, not a queried one. _surface_y walks sample_density(),
	# which is the CPU generator; the heightfield is bound on the GPU only, so
	# that walk would return the surface of the fBm world this map replaced.
	# Using it here would place the spawn confidently and wrongly, which is worse
	# than not placing it. SPAWN_SURFACE_Y is measured — see its comment.
	var surface := SPAWN_SURFACE_Y
	# Kept as a DIAGNOSTIC: how far the CPU field is from the GPU one is exactly
	# the size of the gap left open, and printing it means nobody has to rediscover
	# that the two paths disagree.
	var cpu_surface := _surface_y(terrain, SPAWN_XZ.x, SPAWN_XZ.y)
	if cpu_surface != _NO_SURFACE:
		var gap: float = absf(cpu_surface - surface)
		if gap < 1.0:
			print("[MapDCDiffusion] CPU surface agrees with the measured one: %.1f m." % surface)
		else:
			push_warning("[MapDCDiffusion] CPU says %.1f m, GPU expects %.1f m (%.0f m apart). "
					% [cpu_surface, surface, gap] +
					"Under heightfield_only these must match; they do not.")

	# The parent map's "no surface in this column" error is gone with the query
	# that raised it. The equivalent failure here is a STALE SPAWN_SURFACE_Y, and
	# it is caught differently: the constant carries the command that produced it,
	# and the band check below is what notices if it drifts out of the world.
	if surface <= FLOOR_Y or surface >= CEILING_Y:
		push_error("[MapDCDiffusion] SPAWN_SURFACE_Y %.1f is outside the band " % surface +
				"(%.0f .. %.0f) — re-measure it; see its comment." % [FLOOR_Y, CEILING_Y])
		return

	var spawn_pos := Vector3(SPAWN_XZ.x, surface + SPAWN_CLEARANCE, SPAWN_XZ.y)
	print("[MapDCDiffusion] surface at spawn column = %.1f m -> spawn %.1f m" %
			[surface, spawn_pos.y])

	var player := get_node_or_null("Player")
	if player != null and player is Node3D:
		(player as Node3D).position = spawn_pos

	# Keep the authored SpawnPoint marker in step, or respawn puts you back at
	# whatever height the .tscn happens to still carry.
	# The player's fall-through-the-world floor is an ABSOLUTE Y, and the band just
	# moved past its old default of -200: with the band centred a valley floor
	# reaches -5300, and every player standing in one would be auto-respawned where
	# they stood. player_procedural.gd resolves it from the terrain's own floor_y
	# instead, so there is nothing to set here — this note exists because the
	# coupling is invisible from either file alone.

	var marker := get_node_or_null("SpawnPoint")
	if marker != null and marker is Node3D:
		(marker as Node3D).position = spawn_pos

	# The teleporter gets its OWN column query — 6 m away is a different height.
	var tp := get_node_or_null("TeleporterToHome")
	if tp != null and tp is Node3D:
		var tx: float = SPAWN_XZ.x + TELEPORTER_OFFSET_X
		# Same reason as the spawn: no CPU query here either. 6 m along the
		# surface is well inside one 30 m heightfield texel, so the spawn's own
		# measured height is the right value rather than an approximation.
		var ts := surface
		(tp as Node3D).position = Vector3(tx, ts + TELEPORTER_CLEARANCE, SPAWN_XZ.y)
		print("[MapDCDiffusion] teleporter placed at %.1f m" % (ts + TELEPORTER_CLEARANCE))


# ---- caves: what it would take, and why they are not here ------------------
#
# This generator is a HEIGHTFIELD by construction. The assembled GLSL in
# dc_terrain_generator_procnodes.cpp ends in
#
#     float d = (has.x + erosion_delta.x) - y_mapped;
#
# so Y enters once, linearly, and every (x,z) column has exactly ONE surface. No
# parameter produces a cave, an overhang, or anything above your head — it is the
# functional form, not a setting.
#
# THE PIECES FOR CAVES ALREADY EXIST, in the OTHER generator. The built-in one does
# it at shaders/generators/noise_sdf.glsl:307-328 — two 3D noise fields, carve where
# their combined distance falls inside a radius:
#
#     tube_carve = max(1 - (ta*ta + tb*tb) / radius^2, 0)
#     d -= tube_carve * tunnel_strength
#
# which gives TUBE-shaped voids rather than the swiss cheese a single threshold
# produces. Porting that into this wrapper is a contained change to one string plus
# three properties, exactly like relief_scale/relief_floor were.
#
# THE ONE REAL OBSTACLE: noise3/fbm3 live in DENSITY_GLSL_BODY, and a custom
# generator REPLACES that body — so this shader has no 3D noise to call. It does
# have perm_tables[] (declared in DENSITY_GLSL_HEADER, shared by every body), so the
# fix is to give the wrapper its own noise3, not to plumb a new buffer.
#
# And it is not a free addition: tunnels read `d` to decide where to carve
# (noise_sdf.glsl:311 gates on a crust band), so cave placement and the erosion
# surface are coupled through that test.