OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / src / dc_terrain_generator_procnodes.cpp

1992 lines · cpp ref:
#include "dc_terrain_generator_procnodes.h"
#include <godot_cpp/variant/utility_functions.hpp>

// THE erosion filter, shared with proc-nodes' GLSL. This file used to carry
// its own transcription of it, and the transcription had drifted: it called
// erosion_filter with scale=5.0 where the shader passes 0.08333, which is 60x
// in both gully wavelength and gully amplitude, and it omitted the [0,1]
// remap, the water-line gate and the height offset entirely. The result was
// that DCTerrain::sample_density, get_distance, get_surface_normal and
// get_capsule_push -- collision -- reported a surface the mesher never drew,
// by a mean of 4 km on map_dc_erosion, with 6 of 16 probe columns having no
// collidable ground at all under terrain that renders fine.
//
// The header is godot-free, so this does NOT reintroduce a compile-time
// dependency on proc-nodes' classes -- the ClassDB reflection in the header
// comment still stands. It is a shared math header and nothing more.
#include "erosion_cpp_shim.h"
// The curve as C++, inside the shim's namespace so it sees the GLSL builtins
// -- the same arrangement the shim uses for erosion_filter.inc. The shader
// compiles this identical text via HYPSO_CURVE_GLSL, so get_waterline_norm()
// below cannot drift from what the GPU computes.
namespace dcerosion {
#include "hypso_curve.inc"
}
#include "erosion_wrapper_glsl.h"
#include <godot_cpp/classes/file_access.hpp>

namespace godot {

void DCTerrainGeneratorProcNodes::_bind_methods() {
	// Bound so a gate can ASK whether the graph reached the GPU, instead of a
	// human having to spot an error line. hyper-games #64 NEXT 4.
	ClassDB::bind_method(D_METHOD("get_last_compile_error"), &DCTerrainGeneratorProcNodes::get_last_compile_error);
	ClassDB::bind_method(D_METHOD("has_compiled_shader"), &DCTerrainGeneratorProcNodes::has_compiled_shader);
	ClassDB::bind_method(D_METHOD("set_erosion_minimal", "value"), &DCTerrainGeneratorProcNodes::set_erosion_minimal);
	ClassDB::bind_method(D_METHOD("get_erosion_minimal"), &DCTerrainGeneratorProcNodes::get_erosion_minimal);
	ADD_PROPERTY(PropertyInfo(Variant::BOOL, "erosion_minimal"), "set_erosion_minimal", "get_erosion_minimal");
	ClassDB::bind_method(D_METHOD("set_erosion_enabled", "value"), &DCTerrainGeneratorProcNodes::set_erosion_enabled);
	ClassDB::bind_method(D_METHOD("get_erosion_enabled"), &DCTerrainGeneratorProcNodes::get_erosion_enabled);
	ADD_PROPERTY(PropertyInfo(Variant::BOOL, "erosion_enabled"), "set_erosion_enabled", "get_erosion_enabled");
	ClassDB::bind_method(D_METHOD("set_mountain_scale", "value"), &DCTerrainGeneratorProcNodes::set_mountain_scale);
	ClassDB::bind_method(D_METHOD("get_mountain_scale"), &DCTerrainGeneratorProcNodes::get_mountain_scale);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "mountain_scale"), "set_mountain_scale", "get_mountain_scale");
	ClassDB::bind_method(D_METHOD("set_mountain_height", "value"), &DCTerrainGeneratorProcNodes::set_mountain_height);
	ClassDB::bind_method(D_METHOD("get_mountain_height"), &DCTerrainGeneratorProcNodes::get_mountain_height);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "mountain_height"), "set_mountain_height", "get_mountain_height");
	ClassDB::bind_method(D_METHOD("set_mountain_gain", "value"), &DCTerrainGeneratorProcNodes::set_mountain_gain);
	ClassDB::bind_method(D_METHOD("get_mountain_gain"), &DCTerrainGeneratorProcNodes::get_mountain_gain);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "mountain_gain"), "set_mountain_gain", "get_mountain_gain");
	ClassDB::bind_method(D_METHOD("set_mountain_variance", "value"), &DCTerrainGeneratorProcNodes::set_mountain_variance);
	ClassDB::bind_method(D_METHOD("get_mountain_variance"), &DCTerrainGeneratorProcNodes::get_mountain_variance);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "mountain_variance"), "set_mountain_variance", "get_mountain_variance");
	ClassDB::bind_method(D_METHOD("set_relief_scale", "value"), &DCTerrainGeneratorProcNodes::set_relief_scale);
	ClassDB::bind_method(D_METHOD("get_relief_scale"), &DCTerrainGeneratorProcNodes::get_relief_scale);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "relief_scale"), "set_relief_scale", "get_relief_scale");
	ClassDB::bind_method(D_METHOD("set_relief_floor", "value"), &DCTerrainGeneratorProcNodes::set_relief_floor);
	ClassDB::bind_method(D_METHOD("get_relief_floor"), &DCTerrainGeneratorProcNodes::get_relief_floor);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "relief_floor"), "set_relief_floor", "get_relief_floor");
	ClassDB::bind_method(D_METHOD("set_detail_scale", "value"), &DCTerrainGeneratorProcNodes::set_detail_scale);
	ClassDB::bind_method(D_METHOD("get_detail_scale"), &DCTerrainGeneratorProcNodes::get_detail_scale);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "detail_scale"), "set_detail_scale", "get_detail_scale");
	ClassDB::bind_method(D_METHOD("set_detail_height", "value"), &DCTerrainGeneratorProcNodes::set_detail_height);
	ClassDB::bind_method(D_METHOD("get_detail_height"), &DCTerrainGeneratorProcNodes::get_detail_height);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "detail_height"), "set_detail_height", "get_detail_height");
	ClassDB::bind_method(D_METHOD("set_detail_gain", "value"), &DCTerrainGeneratorProcNodes::set_detail_gain);
	ClassDB::bind_method(D_METHOD("get_detail_gain"), &DCTerrainGeneratorProcNodes::get_detail_gain);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "detail_gain"), "set_detail_gain", "get_detail_gain");
	ClassDB::bind_method(D_METHOD("set_noise_lacunarity", "value"), &DCTerrainGeneratorProcNodes::set_noise_lacunarity);
	ClassDB::bind_method(D_METHOD("get_noise_lacunarity"), &DCTerrainGeneratorProcNodes::get_noise_lacunarity);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "noise_lacunarity"), "set_noise_lacunarity", "get_noise_lacunarity");
	ClassDB::bind_method(D_METHOD("set_erosion_scale", "value"), &DCTerrainGeneratorProcNodes::set_erosion_scale);
	ClassDB::bind_method(D_METHOD("get_erosion_scale"), &DCTerrainGeneratorProcNodes::get_erosion_scale);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "erosion_scale"), "set_erosion_scale", "get_erosion_scale");
	ClassDB::bind_method(D_METHOD("set_field_half_range", "value"), &DCTerrainGeneratorProcNodes::set_field_half_range);
	ClassDB::bind_method(D_METHOD("get_field_half_range"), &DCTerrainGeneratorProcNodes::get_field_half_range);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "field_half_range"), "set_field_half_range", "get_field_half_range");
	ClassDB::bind_method(D_METHOD("set_input_height_amplitude", "value"), &DCTerrainGeneratorProcNodes::set_input_height_amplitude);
	ClassDB::bind_method(D_METHOD("get_input_height_amplitude"), &DCTerrainGeneratorProcNodes::get_input_height_amplitude);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "input_height_amplitude"), "set_input_height_amplitude", "get_input_height_amplitude");
	ClassDB::bind_method(D_METHOD("set_octave_lacunarity", "value"), &DCTerrainGeneratorProcNodes::set_octave_lacunarity);
	ClassDB::bind_method(D_METHOD("get_octave_lacunarity"), &DCTerrainGeneratorProcNodes::get_octave_lacunarity);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "octave_lacunarity"), "set_octave_lacunarity", "get_octave_lacunarity");
	ClassDB::bind_method(D_METHOD("set_gully_depth", "value"), &DCTerrainGeneratorProcNodes::set_gully_depth);
	ClassDB::bind_method(D_METHOD("get_gully_depth"), &DCTerrainGeneratorProcNodes::get_gully_depth);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "gully_depth"), "set_gully_depth", "get_gully_depth");
	ClassDB::bind_method(D_METHOD("set_fine_gully_slope_restriction", "value"), &DCTerrainGeneratorProcNodes::set_fine_gully_slope_restriction);
	ClassDB::bind_method(D_METHOD("get_fine_gully_slope_restriction"), &DCTerrainGeneratorProcNodes::get_fine_gully_slope_restriction);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "fine_gully_slope_restriction"), "set_fine_gully_slope_restriction", "get_fine_gully_slope_restriction");
	ClassDB::bind_method(D_METHOD("set_gully_normalize_threshold", "value"), &DCTerrainGeneratorProcNodes::set_gully_normalize_threshold);
	ClassDB::bind_method(D_METHOD("get_gully_normalize_threshold"), &DCTerrainGeneratorProcNodes::get_gully_normalize_threshold);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "gully_normalize_threshold"), "set_gully_normalize_threshold", "get_gully_normalize_threshold");
	ClassDB::bind_method(D_METHOD("set_height_offset", "value"), &DCTerrainGeneratorProcNodes::set_height_offset);
	ClassDB::bind_method(D_METHOD("get_height_offset"), &DCTerrainGeneratorProcNodes::get_height_offset);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "height_offset"), "set_height_offset", "get_height_offset");
	ClassDB::bind_method(D_METHOD("set_height_offset_follows_altitude", "value"), &DCTerrainGeneratorProcNodes::set_height_offset_follows_altitude);
	ClassDB::bind_method(D_METHOD("get_height_offset_follows_altitude"), &DCTerrainGeneratorProcNodes::get_height_offset_follows_altitude);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "height_offset_follows_altitude"), "set_height_offset_follows_altitude", "get_height_offset_follows_altitude");
	ClassDB::bind_method(D_METHOD("set_crease_rounding", "value"), &DCTerrainGeneratorProcNodes::set_crease_rounding);
	ClassDB::bind_method(D_METHOD("get_crease_rounding"), &DCTerrainGeneratorProcNodes::get_crease_rounding);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "crease_rounding"), "set_crease_rounding", "get_crease_rounding");
	ClassDB::bind_method(D_METHOD("set_submarine_amp", "v"), &DCTerrainGeneratorProcNodes::set_submarine_amp);
	ClassDB::bind_method(D_METHOD("get_submarine_amp"), &DCTerrainGeneratorProcNodes::get_submarine_amp);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "submarine_amp"), "set_submarine_amp", "get_submarine_amp");
	ClassDB::bind_method(D_METHOD("set_submarine_wavelength", "v"), &DCTerrainGeneratorProcNodes::set_submarine_wavelength);
	ClassDB::bind_method(D_METHOD("get_submarine_wavelength"), &DCTerrainGeneratorProcNodes::get_submarine_wavelength);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "submarine_wavelength"), "set_submarine_wavelength", "get_submarine_wavelength");
	ClassDB::bind_method(D_METHOD("set_submarine_octaves", "v"), &DCTerrainGeneratorProcNodes::set_submarine_octaves);
	ClassDB::bind_method(D_METHOD("get_submarine_octaves"), &DCTerrainGeneratorProcNodes::get_submarine_octaves);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "submarine_octaves"), "set_submarine_octaves", "get_submarine_octaves");
	ClassDB::bind_method(D_METHOD("set_submarine_buffer", "a", "b"), &DCTerrainGeneratorProcNodes::set_submarine_buffer);
	ClassDB::bind_method(D_METHOD("set_erosion_mask_depth", "v"), &DCTerrainGeneratorProcNodes::set_erosion_mask_depth);
	ClassDB::bind_method(D_METHOD("get_erosion_mask_depth"), &DCTerrainGeneratorProcNodes::get_erosion_mask_depth);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "erosion_mask_depth"), "set_erosion_mask_depth", "get_erosion_mask_depth");
	ClassDB::bind_method(D_METHOD("set_submarine_water_y", "v"), &DCTerrainGeneratorProcNodes::set_submarine_water_y);
	ClassDB::bind_method(D_METHOD("get_submarine_water_y"), &DCTerrainGeneratorProcNodes::get_submarine_water_y);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "submarine_water_y"), "set_submarine_water_y", "get_submarine_water_y");
	ClassDB::bind_method(D_METHOD("set_detail_slope_lo", "value"), &DCTerrainGeneratorProcNodes::set_detail_slope_lo);
	ClassDB::bind_method(D_METHOD("get_detail_slope_lo"), &DCTerrainGeneratorProcNodes::get_detail_slope_lo);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "detail_slope_lo"), "set_detail_slope_lo", "get_detail_slope_lo");
	ClassDB::bind_method(D_METHOD("set_detail_slope_hi", "value"), &DCTerrainGeneratorProcNodes::set_detail_slope_hi);
	ClassDB::bind_method(D_METHOD("get_detail_slope_hi"), &DCTerrainGeneratorProcNodes::get_detail_slope_hi);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "detail_slope_hi"), "set_detail_slope_hi", "get_detail_slope_hi");
	ClassDB::bind_method(D_METHOD("set_hypso_enabled", "value"), &DCTerrainGeneratorProcNodes::set_hypso_enabled);
	ClassDB::bind_method(D_METHOD("get_hypso_enabled"), &DCTerrainGeneratorProcNodes::get_hypso_enabled);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "hypso_enabled"), "set_hypso_enabled", "get_hypso_enabled");
	ClassDB::bind_method(D_METHOD("set_hypso_sea", "value"), &DCTerrainGeneratorProcNodes::set_hypso_sea);
	ClassDB::bind_method(D_METHOD("get_hypso_sea"), &DCTerrainGeneratorProcNodes::get_hypso_sea);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "hypso_sea"), "set_hypso_sea", "get_hypso_sea");
	ClassDB::bind_method(D_METHOD("set_hypso_shelf_width", "value"), &DCTerrainGeneratorProcNodes::set_hypso_shelf_width);
	ClassDB::bind_method(D_METHOD("get_hypso_shelf_width"), &DCTerrainGeneratorProcNodes::get_hypso_shelf_width);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "hypso_shelf_width"), "set_hypso_shelf_width", "get_hypso_shelf_width");
	ClassDB::bind_method(D_METHOD("set_hypso_slope_width", "value"), &DCTerrainGeneratorProcNodes::set_hypso_slope_width);
	ClassDB::bind_method(D_METHOD("get_hypso_slope_width"), &DCTerrainGeneratorProcNodes::get_hypso_slope_width);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "hypso_slope_width"), "set_hypso_slope_width", "get_hypso_slope_width");
	ClassDB::bind_method(D_METHOD("set_hypso_abyss", "value"), &DCTerrainGeneratorProcNodes::set_hypso_abyss);
	ClassDB::bind_method(D_METHOD("get_hypso_abyss"), &DCTerrainGeneratorProcNodes::get_hypso_abyss);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "hypso_abyss"), "set_hypso_abyss", "get_hypso_abyss");
	ClassDB::bind_method(D_METHOD("set_hypso_plat_slope", "value"), &DCTerrainGeneratorProcNodes::set_hypso_plat_slope);
	ClassDB::bind_method(D_METHOD("get_hypso_plat_slope"), &DCTerrainGeneratorProcNodes::get_hypso_plat_slope);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "hypso_plat_slope"), "set_hypso_plat_slope", "get_hypso_plat_slope");
	ClassDB::bind_method(D_METHOD("set_hypso_mtn_onset", "value"), &DCTerrainGeneratorProcNodes::set_hypso_mtn_onset);
	ClassDB::bind_method(D_METHOD("get_hypso_mtn_onset"), &DCTerrainGeneratorProcNodes::get_hypso_mtn_onset);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "hypso_mtn_onset"), "set_hypso_mtn_onset", "get_hypso_mtn_onset");
	ClassDB::bind_method(D_METHOD("set_hypso_mtn_width", "value"), &DCTerrainGeneratorProcNodes::set_hypso_mtn_width);
	ClassDB::bind_method(D_METHOD("get_hypso_mtn_width"), &DCTerrainGeneratorProcNodes::get_hypso_mtn_width);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "hypso_mtn_width"), "set_hypso_mtn_width", "get_hypso_mtn_width");
	ClassDB::bind_method(D_METHOD("set_hypso_mtn_slope", "value"), &DCTerrainGeneratorProcNodes::set_hypso_mtn_slope);
	ClassDB::bind_method(D_METHOD("get_hypso_mtn_slope"), &DCTerrainGeneratorProcNodes::get_hypso_mtn_slope);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "hypso_mtn_slope"), "set_hypso_mtn_slope", "get_hypso_mtn_slope");
	ClassDB::bind_method(D_METHOD("set_hypso_peak_onset", "value"), &DCTerrainGeneratorProcNodes::set_hypso_peak_onset);
	ClassDB::bind_method(D_METHOD("get_hypso_peak_onset"), &DCTerrainGeneratorProcNodes::get_hypso_peak_onset);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "hypso_peak_onset"), "set_hypso_peak_onset", "get_hypso_peak_onset");
	ClassDB::bind_method(D_METHOD("set_hypso_peak_gain", "value"), &DCTerrainGeneratorProcNodes::set_hypso_peak_gain);
	ClassDB::bind_method(D_METHOD("get_hypso_peak_gain"), &DCTerrainGeneratorProcNodes::get_hypso_peak_gain);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "hypso_peak_gain"), "set_hypso_peak_gain", "get_hypso_peak_gain");
	ClassDB::bind_method(D_METHOD("get_waterline_norm"), &DCTerrainGeneratorProcNodes::get_waterline_norm);
	ClassDB::bind_method(D_METHOD("set_ridge_rounding", "value"), &DCTerrainGeneratorProcNodes::set_ridge_rounding);
	ClassDB::bind_method(D_METHOD("get_ridge_rounding"), &DCTerrainGeneratorProcNodes::get_ridge_rounding);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "ridge_rounding"), "set_ridge_rounding", "get_ridge_rounding");
	ClassDB::bind_method(D_METHOD("set_input_feature_rounding", "value"), &DCTerrainGeneratorProcNodes::set_input_feature_rounding);
	ClassDB::bind_method(D_METHOD("get_input_feature_rounding"), &DCTerrainGeneratorProcNodes::get_input_feature_rounding);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "input_feature_rounding"), "set_input_feature_rounding", "get_input_feature_rounding");
	ClassDB::bind_method(D_METHOD("set_rounding_per_octave", "value"), &DCTerrainGeneratorProcNodes::set_rounding_per_octave);
	ClassDB::bind_method(D_METHOD("get_rounding_per_octave"), &DCTerrainGeneratorProcNodes::get_rounding_per_octave);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "rounding_per_octave"), "set_rounding_per_octave", "get_rounding_per_octave");
	ClassDB::bind_method(D_METHOD("set_gully_onset_slope", "value"), &DCTerrainGeneratorProcNodes::set_gully_onset_slope);
	ClassDB::bind_method(D_METHOD("get_gully_onset_slope"), &DCTerrainGeneratorProcNodes::get_gully_onset_slope);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "gully_onset_slope"), "set_gully_onset_slope", "get_gully_onset_slope");
	ClassDB::bind_method(D_METHOD("set_octave_onset_slope", "value"), &DCTerrainGeneratorProcNodes::set_octave_onset_slope);
	ClassDB::bind_method(D_METHOD("get_octave_onset_slope"), &DCTerrainGeneratorProcNodes::get_octave_onset_slope);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "octave_onset_slope"), "set_octave_onset_slope", "get_octave_onset_slope");
	ClassDB::bind_method(D_METHOD("set_ridge_onset_slope", "value"), &DCTerrainGeneratorProcNodes::set_ridge_onset_slope);
	ClassDB::bind_method(D_METHOD("get_ridge_onset_slope"), &DCTerrainGeneratorProcNodes::get_ridge_onset_slope);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "ridge_onset_slope"), "set_ridge_onset_slope", "get_ridge_onset_slope");
	ClassDB::bind_method(D_METHOD("set_ridge_octave_onset", "value"), &DCTerrainGeneratorProcNodes::set_ridge_octave_onset);
	ClassDB::bind_method(D_METHOD("get_ridge_octave_onset"), &DCTerrainGeneratorProcNodes::get_ridge_octave_onset);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "ridge_octave_onset"), "set_ridge_octave_onset", "get_ridge_octave_onset");
	ClassDB::bind_method(D_METHOD("set_assumed_slope", "value"), &DCTerrainGeneratorProcNodes::set_assumed_slope);
	ClassDB::bind_method(D_METHOD("get_assumed_slope"), &DCTerrainGeneratorProcNodes::get_assumed_slope);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "assumed_slope"), "set_assumed_slope", "get_assumed_slope");
	ClassDB::bind_method(D_METHOD("set_assumed_slope_mix", "value"), &DCTerrainGeneratorProcNodes::set_assumed_slope_mix);
	ClassDB::bind_method(D_METHOD("get_assumed_slope_mix"), &DCTerrainGeneratorProcNodes::get_assumed_slope_mix);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "assumed_slope_mix"), "set_assumed_slope_mix", "get_assumed_slope_mix");
	ClassDB::bind_method(D_METHOD("set_mountain_octaves", "value"), &DCTerrainGeneratorProcNodes::set_mountain_octaves);
	ClassDB::bind_method(D_METHOD("get_mountain_octaves"), &DCTerrainGeneratorProcNodes::get_mountain_octaves);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "mountain_octaves"), "set_mountain_octaves", "get_mountain_octaves");
	ClassDB::bind_method(D_METHOD("set_detail_octaves", "value"), &DCTerrainGeneratorProcNodes::set_detail_octaves);
	ClassDB::bind_method(D_METHOD("get_detail_octaves"), &DCTerrainGeneratorProcNodes::get_detail_octaves);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "detail_octaves"), "set_detail_octaves", "get_detail_octaves");
	ClassDB::bind_method(D_METHOD("set_lookdev_wrapper", "value"), &DCTerrainGeneratorProcNodes::set_lookdev_wrapper);
	ClassDB::bind_method(D_METHOD("get_lookdev_wrapper"), &DCTerrainGeneratorProcNodes::get_lookdev_wrapper);
	ADD_PROPERTY(PropertyInfo(Variant::BOOL, "lookdev_wrapper"), "set_lookdev_wrapper", "get_lookdev_wrapper");
	ClassDB::bind_method(D_METHOD("set_graph", "graph"), &DCTerrainGeneratorProcNodes::set_graph);
	ClassDB::bind_method(D_METHOD("get_graph"), &DCTerrainGeneratorProcNodes::get_graph);
	ClassDB::bind_method(D_METHOD("set_terrain_height", "height"), &DCTerrainGeneratorProcNodes::set_terrain_height);
	ClassDB::bind_method(D_METHOD("get_terrain_height"), &DCTerrainGeneratorProcNodes::get_terrain_height);
	ClassDB::bind_method(D_METHOD("recompile"), &DCTerrainGeneratorProcNodes::recompile);
	ClassDB::bind_method(D_METHOD("get_compiled_glsl"), &DCTerrainGeneratorProcNodes::get_compiled_glsl);

	ADD_PROPERTY(PropertyInfo(Variant::OBJECT, "graph", PROPERTY_HINT_RESOURCE_TYPE, "ProcNodeGraph"),
		"set_graph", "get_graph");
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "terrain_height", PROPERTY_HINT_RANGE, "1.0,10000.0,0.1"),
		"set_terrain_height", "get_terrain_height");

	ClassDB::bind_method(D_METHOD("set_floor_y", "value"), &DCTerrainGeneratorProcNodes::set_floor_y);
	ClassDB::bind_method(D_METHOD("get_floor_y"), &DCTerrainGeneratorProcNodes::get_floor_y);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "floor_y", PROPERTY_HINT_RANGE, "-1000.0,1000.0,0.5"),
		"set_floor_y", "get_floor_y");

	ClassDB::bind_method(D_METHOD("set_ceiling_y", "value"), &DCTerrainGeneratorProcNodes::set_ceiling_y);
	ClassDB::bind_method(D_METHOD("get_ceiling_y"), &DCTerrainGeneratorProcNodes::get_ceiling_y);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "ceiling_y", PROPERTY_HINT_RANGE, "-1000.0,1000.0,0.5"),
		"set_ceiling_y", "get_ceiling_y");






	ClassDB::bind_method(D_METHOD("set_heightfield_path", "path"), &DCTerrainGeneratorProcNodes::set_heightfield_path);
	ClassDB::bind_method(D_METHOD("get_heightfield_path"), &DCTerrainGeneratorProcNodes::get_heightfield_path);
	ADD_PROPERTY(PropertyInfo(Variant::STRING, "heightfield_path", PROPERTY_HINT_FILE, "*.hblk"),
		"set_heightfield_path", "get_heightfield_path");
	ClassDB::bind_method(D_METHOD("set_heightfield_only", "value"), &DCTerrainGeneratorProcNodes::set_heightfield_only);
	ClassDB::bind_method(D_METHOD("get_heightfield_only"), &DCTerrainGeneratorProcNodes::get_heightfield_only);
	ADD_PROPERTY(PropertyInfo(Variant::BOOL, "heightfield_only"),
		"set_heightfield_only", "get_heightfield_only");








	ClassDB::bind_method(D_METHOD("set_height_space_sqrt", "value"), &DCTerrainGeneratorProcNodes::set_height_space_sqrt);
	ClassDB::bind_method(D_METHOD("get_height_space_sqrt"), &DCTerrainGeneratorProcNodes::get_height_space_sqrt);
	ADD_PROPERTY(PropertyInfo(Variant::BOOL, "height_space_sqrt"),
		"set_height_space_sqrt", "get_height_space_sqrt");
}

void DCTerrainGeneratorProcNodes::set_mountain_scale(float p_val) {
	if (mountain_scale_ == p_val) return;
	mountain_scale_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_mountain_height(float p_val) {
	if (mountain_height_ == p_val) return;
	mountain_height_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_mountain_gain(float p_val) {
	if (mountain_gain_ == p_val) return;
	mountain_gain_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_mountain_variance(float p_val) {
	if (mountain_variance_ == p_val) return;
	mountain_variance_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_relief_scale(float p_val) {
	if (relief_scale_ == p_val) return;
	relief_scale_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_relief_floor(float p_val) {
	if (relief_floor_ == p_val) return;
	relief_floor_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_detail_scale(float p_val) {
	if (detail_scale_ == p_val) return;
	detail_scale_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_detail_height(float p_val) {
	if (detail_height_ == p_val) return;
	detail_height_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_detail_gain(float p_val) {
	if (detail_gain_ == p_val) return;
	detail_gain_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_noise_lacunarity(float p_val) {
	if (noise_lacunarity_ == p_val) return;
	noise_lacunarity_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_erosion_scale(float p_val) {
	if (erosion_scale_ == p_val) return;
	erosion_scale_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_field_half_range(float p_val) {
	if (field_half_range_ == p_val) return;
	field_half_range_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_input_height_amplitude(float p_val) {
	if (input_height_amplitude_ == p_val) return;
	input_height_amplitude_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_octave_lacunarity(float p_val) {
	if (octave_lacunarity_ == p_val) return;
	octave_lacunarity_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_gully_depth(float p_val) {
	if (gully_depth_ == p_val) return;
	gully_depth_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_fine_gully_slope_restriction(float p_val) {
	if (fine_gully_slope_restriction_ == p_val) return;
	fine_gully_slope_restriction_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_gully_normalize_threshold(float p_val) {
	if (gully_normalize_threshold_ == p_val) return;
	gully_normalize_threshold_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_height_offset(float p_val) {
	if (height_offset_ == p_val) return;
	height_offset_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_height_offset_follows_altitude(float p_val) {
	if (height_offset_follows_altitude_ == p_val) return;
	height_offset_follows_altitude_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_crease_rounding(float p_val) {
	if (crease_rounding_ == p_val) return;
	crease_rounding_ = p_val;
	_recompile_glsl();
	emit_changed();
}

float DCTerrainGeneratorProcNodes::get_waterline_norm() const {
	// Sea level in the curve's OUTPUT units. The curve is monotonic, so the
	// waterline is simply its value at `sea` -- no bisection, no second
	// constant to fall out of step with the first.
	return dcerosion::hypso_curve(hypso_sea_, hypso_sea_, hypso_shelf_width_,
			hypso_slope_width_, hypso_abyss_, hypso_plat_slope_, hypso_mtn_onset_,
			hypso_mtn_width_, hypso_mtn_slope_, hypso_peak_onset_,
			hypso_peak_gain_).x;
}

void DCTerrainGeneratorProcNodes::set_submarine_amp(float v) {
	if (submarine_amp_ == v) return;
	submarine_amp_ = v; _recompile_glsl(); emit_changed();
}
void DCTerrainGeneratorProcNodes::set_submarine_wavelength(float v) {
	if (submarine_wavelength_ == v) return;
	submarine_wavelength_ = v; _recompile_glsl(); emit_changed();
}
void DCTerrainGeneratorProcNodes::set_submarine_octaves(int v) {
	if (submarine_octaves_ == v) return;
	submarine_octaves_ = v; _recompile_glsl(); emit_changed();
}
void DCTerrainGeneratorProcNodes::set_submarine_buffer(float a, float b) {
	submarine_buffer_start_ = a; submarine_buffer_end_ = b;
	_recompile_glsl(); emit_changed();
}
void DCTerrainGeneratorProcNodes::set_erosion_mask_depth(float v) {
	if (erosion_mask_depth_ == v) return;
	erosion_mask_depth_ = v; _recompile_glsl(); emit_changed();
}
void DCTerrainGeneratorProcNodes::set_submarine_water_y(float v) {
	if (submarine_water_y_ == v) return;
	submarine_water_y_ = v; _recompile_glsl(); emit_changed();
}

void DCTerrainGeneratorProcNodes::set_detail_slope_lo(float p_val) {
	if (detail_slope_lo_ == p_val) return;
	detail_slope_lo_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_detail_slope_hi(float p_val) {
	if (detail_slope_hi_ == p_val) return;
	detail_slope_hi_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_hypso_enabled(float p_val) {
	if (hypso_enabled_ == p_val) return;
	hypso_enabled_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_hypso_sea(float p_val) {
	if (hypso_sea_ == p_val) return;
	hypso_sea_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_hypso_shelf_width(float p_val) {
	if (hypso_shelf_width_ == p_val) return;
	hypso_shelf_width_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_hypso_slope_width(float p_val) {
	if (hypso_slope_width_ == p_val) return;
	hypso_slope_width_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_hypso_abyss(float p_val) {
	if (hypso_abyss_ == p_val) return;
	hypso_abyss_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_hypso_plat_slope(float p_val) {
	if (hypso_plat_slope_ == p_val) return;
	hypso_plat_slope_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_hypso_mtn_onset(float p_val) {
	if (hypso_mtn_onset_ == p_val) return;
	hypso_mtn_onset_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_hypso_mtn_width(float p_val) {
	if (hypso_mtn_width_ == p_val) return;
	hypso_mtn_width_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_hypso_mtn_slope(float p_val) {
	if (hypso_mtn_slope_ == p_val) return;
	hypso_mtn_slope_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_hypso_peak_onset(float p_val) {
	if (hypso_peak_onset_ == p_val) return;
	hypso_peak_onset_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_hypso_peak_gain(float p_val) {
	if (hypso_peak_gain_ == p_val) return;
	hypso_peak_gain_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_ridge_rounding(float p_val) {
	if (ridge_rounding_ == p_val) return;
	ridge_rounding_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_input_feature_rounding(float p_val) {
	if (input_feature_rounding_ == p_val) return;
	input_feature_rounding_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_rounding_per_octave(float p_val) {
	if (rounding_per_octave_ == p_val) return;
	rounding_per_octave_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_gully_onset_slope(float p_val) {
	if (gully_onset_slope_ == p_val) return;
	gully_onset_slope_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_octave_onset_slope(float p_val) {
	if (octave_onset_slope_ == p_val) return;
	octave_onset_slope_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_ridge_onset_slope(float p_val) {
	if (ridge_onset_slope_ == p_val) return;
	ridge_onset_slope_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_ridge_octave_onset(float p_val) {
	if (ridge_octave_onset_ == p_val) return;
	ridge_octave_onset_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_assumed_slope(float p_val) {
	if (assumed_slope_ == p_val) return;
	assumed_slope_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_assumed_slope_mix(float p_val) {
	if (assumed_slope_mix_ == p_val) return;
	assumed_slope_mix_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_mountain_octaves(int p_val) {
	if (mountain_octaves_ == p_val) return;
	mountain_octaves_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_detail_octaves(int p_val) {
	if (detail_octaves_ == p_val) return;
	detail_octaves_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_lookdev_wrapper(bool p_val) {
	if (lookdev_wrapper_ == p_val) return;
	lookdev_wrapper_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_erosion_minimal(bool p_val) {
	if (erosion_minimal_ == p_val) return;
	erosion_minimal_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_erosion_enabled(bool p_val) {
	if (erosion_enabled_ == p_val) return;
	erosion_enabled_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_terrain_height(float p_height) {
	if (terrain_height_ == p_height) return;
	terrain_height_ = p_height;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_floor_y(float p_val) {
	if (floor_y_ == p_val) return;
	floor_y_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_ceiling_y(float p_val) {
	if (ceiling_y_ == p_val) return;
	ceiling_y_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_height_space_sqrt(bool p_val) {
	if (height_space_sqrt_ == p_val) return;
	height_space_sqrt_ = p_val;
	_recompile_glsl();
	emit_changed();
}
// Load a .hblk written by terrain-lookdev/stitch_block.py. The format is
// deliberately trivial -- a 32-byte header and int16 metres -- because the
// alternative was a Godot Image import path, and the field has to be readable
// identically by the standalone harness that gates it, which has no engine.
bool DCTerrainGeneratorProcNodes::_load_heightfield(const String &p_path) {
	hblk_.clear(); hblk_n_ = 0; hblk_version_++;
	if (p_path.is_empty()) return true;
	Ref<FileAccess> f = FileAccess::open(p_path, FileAccess::READ);
	if (f.is_null()) {
		UtilityFunctions::push_warning("DCTerrainGeneratorProcNodes: cannot open heightfield ", p_path);
		return false;
	}
	PackedByteArray hdr = f->get_buffer(32);
	if (hdr.size() != 32 || hdr[0] != 'H' || hdr[1] != 'B' || hdr[2] != 'L' || hdr[3] != 'K') {
		UtilityFunctions::push_warning("DCTerrainGeneratorProcNodes: not an .hblk: ", p_path);
		return false;
	}
	const uint8_t *p = hdr.ptr();
	uint32_t ver, n; float mpp, ox, oz;
	memcpy(&ver, p + 4, 4); memcpy(&n, p + 8, 4); memcpy(&mpp, p + 12, 4);
	memcpy(&ox, p + 16, 4); memcpy(&oz, p + 20, 4);
	if (ver != 1u || n == 0u) {
		UtilityFunctions::push_warning("DCTerrainGeneratorProcNodes: unsupported .hblk version/size");
		return false;
	}
	PackedByteArray body = f->get_buffer((int64_t)n * (int64_t)n * 2);
	if (body.size() != (int64_t)n * (int64_t)n * 2) {
		UtilityFunctions::push_warning("DCTerrainGeneratorProcNodes: short .hblk data");
		return false;
	}
	hblk_.resize((size_t)n * (size_t)n);
	const uint8_t *b = body.ptr();
	for (size_t i = 0; i < hblk_.size(); i++) {
		int16_t v; memcpy(&v, b + i * 2, 2);
		hblk_[i] = (float)v;
	}
	hblk_n_ = (int)n; hblk_mpp_ = mpp; hblk_ox_ = ox; hblk_oz_ = oz;
	hblk_version_++;
	UtilityFunctions::print("[DCTerrainGeneratorProcNodes] heightfield ", p_path,
		": ", (int)n, "x", (int)n, " @ ", mpp, " m/px, origin x ", ox, " z ", oz);
	return true;
}


void DCTerrainGeneratorProcNodes::set_heightfield_only(bool p_val) {
	if (heightfield_only_ == p_val) return;
	heightfield_only_ = p_val;
	_recompile_glsl();
	emit_changed();
}

void DCTerrainGeneratorProcNodes::set_heightfield_path(const String &p_path) {
	if (heightfield_path_ == p_path) return;
	heightfield_path_ = p_path;
	_load_heightfield(p_path);
	_recompile_glsl();
	emit_changed();
}


void DCTerrainGeneratorProcNodes::set_graph(const Ref<Resource> &p_graph) {
	if (p_graph.is_valid()) {
		// Validate that the resource is a ProcNodeGraph (from proc-nodes GDExtension)
		if (!p_graph->has_method("get_node_count") || !p_graph->has_method("compile_to_glsl_function")) {
			UtilityFunctions::push_error(
				"DCTerrainGeneratorProcNodes: assigned resource is not a ProcNodeGraph "
				"(missing get_node_count/compile_to_glsl_function). "
				"Is the proc-nodes GDExtension loaded?");
			return;
		}
	}
	graph_ = p_graph;
	_discover_params();
	_collect_node_data();
	_recompile_glsl(); // Eager — must happen on main thread before worker sees this generator
	notify_property_list_changed();
	emit_changed();
}

// ── Parameter Discovery ───────────────────────────────────────────────────────

// Collect opaque GPU data from the graph's nodes.
//
// dc-terrain transports floats and knows nothing about them; a NODE decides what
// they mean. So the generator asks each node "do you have data?" and hands the
// first answer up as custom data. This is what lets a heightfield live in the
// graph rather than as another property on this class.
//
// One producer for now, which matches one bound buffer. More than one wants an
// offset table emitted alongside, which is the same change a grid of tiles needs.
void DCTerrainGeneratorProcNodes::_collect_node_data() {
	if (graph_.is_null()) return;
	Variant nc_var = graph_->call("get_node_count");
	if (nc_var.get_type() != Variant::INT) return;
	int node_count = nc_var;
	for (int ni = 0; ni < node_count; ni++) {
		Variant node_var = graph_->call("get_node", ni);
		if (node_var.get_type() != Variant::OBJECT) continue;
		Object *node_obj = node_var.operator Object *();
		if (!node_obj || !node_obj->has_method("get_gpu_data")) continue;
		PackedFloat32Array d = node_obj->call("get_gpu_data");
		if (d.is_empty()) continue;
		hblk_.assign(d.ptr(), d.ptr() + d.size());
		hblk_version_++;
		// THE PACKED TILE GRID, if the node supplied one. Without this the CPU
		// twin would keep reading the buffer as a single hblk_n_ square -- which
		// is now the INDEX TABLE followed by tile rects, so it would return
		// table entries as elevations and the collision surface would be
		// somewhere else entirely from the rendered one.
		hgrid_ = HeightGridDesc();
		if (node_obj->has_method("get_grid_info")) {
			Dictionary gi = node_obj->call("get_grid_info");
			if ((bool)gi.get("active", false)) {
				hgrid_.nx      = (int)gi.get("nx", 0);
				hgrid_.ny      = (int)gi.get("ny", 0);
				hgrid_.tx0     = (int)gi.get("tx0", 0);
				hgrid_.ty0     = (int)gi.get("ty0", 0);
				hgrid_.payload = (int)gi.get("payload", 0);
				hgrid_.apron   = (int)gi.get("apron", 0);
				hgrid_.n       = (int)gi.get("n", 0);
				hgrid_.table   = (int)gi.get("table", 0);
				hgrid_.mpp     = (float)(double)gi.get("mpp", 30.0);
				hgrid_.stub    = (float)(double)gi.get("stub", 0.0);
				// ⛔ AND THE GEOMETRY, WHICH IS THE WHOLE POINT.
				//
				// hblk_n_/ox_/oz_ were taken from get_tile_size()/get_tile_origin()
				// above -- the SINGLE tile's, i.e. the node's n_, which is 0 when
				// only the grid is loaded because set_tile_path() was never called.
				//
				// And `hblk_n_ > 0` gates BOTH the four CPU heightfield sites AND
				// the emission at :1117 that substitutes heightmap_gn(x,z) for the
				// mountain-noise layer. Left at 0, 213 MiB of tiles upload
				// successfully and NOTHING EVER SAMPLES THEM: the CPU meshes the
				// base noise, the GLSL emits the noise branch, and the only symptom
				// is a CPU/GPU spawn-height disagreement.
				hblk_n_   = (int)gi.get("side", 0);
				hblk_ox_  = (float)(double)gi.get("ox", 0.0);
				hblk_oz_  = (float)(double)gi.get("oz", 0.0);
				hblk_mpp_ = hgrid_.mpp;
				UtilityFunctions::print("[DCTerrainGeneratorProcNodes] tile grid ",
					hgrid_.nx, "x", hgrid_.ny, " cells, ", (int)gi.get("tiles", 0),
					" tiles resident, ", hblk_n_, " texels/side from (",
					hblk_ox_, ",", hblk_oz_, ")");
			}
		}
		// ASK THE NODE FOR THE SIDE LENGTH; DO NOT DERIVE IT FROM THE ARRAY.
		//
		// get_gpu_data() returns the MIP PYRAMID, not one square: 1,403,226
		// floats for a 1026 tile. sqrt of that is 1184.6, so the old derivation
		// produced 1185 and every consumer of this copy -- the lookdev
		// wrapper's sampler and the CPU twin -- would have read the block with
		// a stride 159 texels too wide, which shears the terrain rather than
		// failing. It has been latent because nothing used hblk_n_ on the
		// node-supplied path yet.
		if (node_obj->has_method("get_tile_size")) {
			hblk_n_ = (int)(int64_t)node_obj->call("get_tile_size");
		} else {
			hblk_n_ = (int)Math::round(Math::sqrt((double)d.size()));
		}
		// AND ITS GEOMETRY. Copying the floats without the origin and pitch left
		// the CPU sampling at world/30 from (0,0) -- off the end of the tile, so
		// every query clamped to a corner and reported the block's minimum. The
		// data means nothing without where it sits.
		if (node_obj->has_method("get_tile_mpp"))
			hblk_mpp_ = (float)(double)node_obj->call("get_tile_mpp");
		if (node_obj->has_method("get_tile_origin")) {
			Vector2 o = node_obj->call("get_tile_origin");
			hblk_ox_ = o.x;
			hblk_oz_ = o.y;
		}
		UtilityFunctions::print("[DCTerrainGeneratorProcNodes] node ", ni,
				" supplied ", (int64_t)d.size(), " floats of custom data");
		return;
	}
}

// ONE CPU heightfield sampler, for the four sites that used to each carry their
// own bilinear. Paged when the node supplied a tile grid, a plain square
// otherwise -- and the paged half is erosion_wrapper_glsl.h's, the same file
// that emits the GLSL, so the twin cannot drift.
float DCTerrainGeneratorProcNodes::_hblk_sample_cpu(float x, float z) const {
	if (hblk_.empty()) return 0.0f;
	if (hgrid_.active())
		return erosion_heightmap_grid_sample(hblk_.data(), hgrid_, x, z);
	const int   hn = hblk_n_;
	const float u0 = dcerosion::clamp((x - hblk_ox_) / hblk_mpp_, 0.0f, (float)(hn - 1));
	const float v0 = dcerosion::clamp((z - hblk_oz_) / hblk_mpp_, 0.0f, (float)(hn - 1));
	const int i0 = (int)floorf(u0), j0 = (int)floorf(v0);
	const int i1 = MIN(i0 + 1, hn - 1), j1 = MIN(j0 + 1, hn - 1);
	const float fu = u0 - (float)i0, fv = v0 - (float)j0;
	const float h00 = hblk_[(size_t)j0*hn + i0], h10 = hblk_[(size_t)j0*hn + i1];
	const float h01 = hblk_[(size_t)j1*hn + i0], h11 = hblk_[(size_t)j1*hn + i1];
	const float a = h00 + (h10 - h00) * fu, b = h01 + (h11 - h01) * fu;
	return a + (b - a) * fv;
}

void DCTerrainGeneratorProcNodes::_discover_params() {
	discovered_params_.clear();
	if (graph_.is_null()) return;

	Variant nc_var = graph_->call("get_node_count");
	if (nc_var.get_type() != Variant::INT) return;
	int node_count = nc_var;

	for (int ni = 0; ni < node_count; ni++) {
		Variant node_var = graph_->call("get_node", ni);
		if (node_var.get_type() != Variant::OBJECT) continue;
		Object *node_obj = node_var.operator Object *();
		if (!node_obj) continue;

		// Only include GPU-capable nodes (these are the ones that compile to GLSL)
		Variant gpu_var = node_obj->call("supports_gpu");
		if (gpu_var.get_type() != Variant::BOOL || !(bool)gpu_var) continue;

		String title = node_obj->call("get_node_title");
		int input_count = (int)(int64_t)node_obj->call("get_input_count");

		for (int si = 0; si < input_count; si++) {
			Variant val = node_obj->call("get_input_default", si);
			// Only expose Float and Int parameters (not Geometry, Vector, etc.)
			if (val.get_type() != Variant::FLOAT && val.get_type() != Variant::INT)
				continue;

			String name = node_obj->call("get_input_name", si);
			if (name.is_empty()) name = "param_" + itos(si);

			GraphParam gp;
			gp.node_idx = ni;
			gp.socket_idx = si;
			gp.node_title = title;
			gp.socket_name = name;
			gp.type = val.get_type();
			gp.default_value = val;
			discovered_params_.push_back(gp);
		}
	}

	UtilityFunctions::print("DCTerrainGeneratorProcNodes: Discovered ",
		discovered_params_.size(), " parameters from graph");
}

// ── Dynamic Inspector Properties ──────────────────────────────────────────────

bool DCTerrainGeneratorProcNodes::_set(const StringName &p_name, const Variant &p_value) {
	String name_str = p_name;
	for (int i = 0; i < discovered_params_.size(); i++) {
		GraphParam &gp = discovered_params_.ptrw()[i];
		String prop_name = gp.node_title + String("/") + gp.socket_name;
		if (prop_name == name_str) {
			// Apply to the proc-nodes graph node
			if (!graph_.is_null()) {
				Variant node_var = graph_->call("get_node", gp.node_idx);
				if (node_var.get_type() == Variant::OBJECT) {
					Object *node_obj = node_var.operator Object *();
					if (node_obj) {
						node_obj->call("set_input_default", gp.socket_idx, p_value);
					}
				}
			}
			gp.default_value = p_value;
			_recompile_glsl(); // Eager — rebake params into GLSL on main thread
			emit_changed(); // Triggers DC terrain rebuild
			return true;
		}
	}
	return false;
}

bool DCTerrainGeneratorProcNodes::_get(const StringName &p_name, Variant &r_ret) const {
	String name_str = p_name;
	for (int i = 0; i < discovered_params_.size(); i++) {
		const GraphParam &gp = discovered_params_[i];
		String prop_name = gp.node_title + String("/") + gp.socket_name;
		if (prop_name == name_str) {
			r_ret = gp.default_value;
			return true;
		}
	}
	return false;
}

void DCTerrainGeneratorProcNodes::_get_property_list(List<PropertyInfo> *p_list) const {
	for (int i = 0; i < discovered_params_.size(); i++) {
		const GraphParam &gp = discovered_params_[i];
		String prop_name = gp.node_title + String("/") + gp.socket_name;

		Variant::Type vtype = (gp.type == Variant::INT) ? Variant::INT : Variant::FLOAT;
		p_list->push_back(PropertyInfo(vtype, prop_name));
	}
}

// ── GLSL Compilation ──────────────────────────────────────────────────────────

// ============================================================================
// A COMPILE FAILURE HERE IS SILENT, AND SILENCE IS THE BUG.
//
// Every early return below leaves cached_glsl_ empty. has_gpu_shader() then
// answers false, dc_worker_cpu takes the CPU path, and DCTerrain may swap in
// the BUILT-IN noise generator (dc_terrain.cpp, _propagate_config and
// set_generator) -- so the map renders a plausible landscape that is not the
// one the graph describes, with nothing in the log.
//
// proc-nodes' compile_to_glsl_function REFUSES on purpose: a node that claims
// GPU support and emits nothing would leave a variable nothing declares, so it
// returns "" and pushes its own error. That refusal used to arrive here and
// vanish. It does not any more.
//
// last_compile_error_ is bound so a headless gate can ASK, rather than a human
// having to notice a line scroll past. See hyper-games #64 NEXT 4.
// ============================================================================
void DCTerrainGeneratorProcNodes::_recompile_glsl() {
	cached_glsl_.clear();
	last_compile_error_ = String();

	if (graph_.is_null()) {
		last_compile_error_ = "no graph assigned";
		// Not an error: an unconfigured generator is a legitimate state, and a
		// scene that has not been wired up yet would scream on every load. It
		// is recorded so a gate can still tell this apart from a real failure.
		return;
	}

	// Extract noise parameters from discovered graph parameters.
	float base_freq = 3.0f;
	float height_scale = 5.0f;
	for (int i = 0; i < discovered_params_.size(); i++) {
		const GraphParam &gp = discovered_params_[i];
		if (gp.node_title == "Gradient Noise" && gp.socket_name == "Scale") {
			base_freq = (float)(double)gp.default_value;
		}
		if (gp.node_title == "Erosion Noise" && gp.socket_name == "HeightScale") {
			height_scale = (float)(double)gp.default_value;
		}
	}

	// Cross-extension call with octave clamping enabled.
	// proc-nodes will emit min(N, pn_max_octaves) for all integer (octave) params.
	// SKIP THE EROSION NODE when the toggle is off. Not in the graph's emission
	// means not in the shader at all: no filter, and no noise library either,
	// because nothing left in a heightfield graph mentions noise.
	PackedStringArray skip;
	if (!erosion_enabled_) {
		skip.push_back("ErosionNoise");
	}
	Variant glsl_var = graph_->call("compile_to_glsl_function", "proc_density", true, skip);
	if (glsl_var.get_type() != Variant::STRING) {
		last_compile_error_ = "compile_to_glsl_function returned non-string "
				"(is the proc-nodes extension loaded?)";
		UtilityFunctions::push_error(
				"DCTerrainGeneratorProcNodes: ", last_compile_error_,
				" -- no GPU shader. The terrain will not be the graph's.");
		return;
	}

	String proc_glsl = glsl_var;
	if (proc_glsl.is_empty()) {
		// THE REFUSAL. proc-nodes returns "" when a node claims GPU support and
		// emits no GLSL, and it has already said which node. What it cannot know
		// is that the caller was a terrain generator, so say what the refusal
		// COSTS -- that is the half that was missing.
		last_compile_error_ = "compile_to_glsl_function REFUSED (a node claims "
				"GPU support and emitted no GLSL -- see the proc-nodes error "
				"above it for which)";
		UtilityFunctions::push_error(
				"DCTerrainGeneratorProcNodes: ", last_compile_error_,
				". No GPU shader: this terrain will mesh from the CPU wrapper, "
				"which is a hardcoded gradient-noise -> erosion pipeline and NOT "
				"your graph.");
		return;
	}

	// Bridge proc-nodes UV-space noise to dc-terrain world-space SDF.
	//
	// proc-nodes noise (GradientNoise Scale=3.0) is designed for UV [0,1] evaluation,
	// same as erosion_terrain.gdshader. dc-terrain evaluates at world meters.
	//
	// terrain_height controls the coordinate scale: XZ world coords are divided by it
	// to convert to UV-like space. E.g., terrain_height=10 means the noise pattern
	// that fills [0,1] in the shader now fills [0,10] meters.
	//
	// Height is controlled DIRECTLY by ErosionNoise HeightScale — no output multiplier.
	// HeightScale=5 means ~2m of surface variation (same as the erosion shader test).
	//
	// pn_max_octaves must be declared BEFORE proc_density (which reads it).
	String th_str = String::num(terrain_height_, 4);
	String floor_str = String::num(floor_y_, 4);
	String ceil_str_early = String::num(ceiling_y_, 4);

	// ── HEIGHTFIELD ONLY ───────────────────────────────────────────────────
	// Everything below this point -- the graph, the noise library, the mountain
	// and relief and detail layers, the filter-space mapping -- is skipped. The
	// density IS the field. If this does not show the block, nothing downstream
	// of it can, and the fault is in getting the bytes to the GPU rather than in
	// anything about terrain.
	// NOT over a packed grid: the sampler emitted below is single-tile
	// addressing, custom_data[j*HBLK_N + i], and with HBLK_N = the grid's texel
	// extent that indexes 928M entries into a 55.8M buffer. The node's own
	// prelude already emits the PAGED sampler.
	if (heightfield_only_ && hblk_n_ > 0 && !hgrid_.active()) {
		String hb =
			String("const int   HBLK_N  = ") + itos(hblk_n_) + ";\n"
			"const float HBLK_OX = " + String::num(hblk_ox_, 4) + ";\n"
			"const float HBLK_OZ = " + String::num(hblk_oz_, 4) + ";\n"
			"const float HBLK_MPP = " + String::num(hblk_mpp_, 6) + ";\n"
			"float hblk_height_m(float x, float z) {\n"
			"    float u = (x - HBLK_OX) / HBLK_MPP;\n"
			"    float v = (z - HBLK_OZ) / HBLK_MPP;\n"
			"    u = clamp(u, 0.0, float(HBLK_N - 1));\n"
			"    v = clamp(v, 0.0, float(HBLK_N - 1));\n"
			"    int i0 = int(floor(u)); int j0 = int(floor(v));\n"
			"    int i1 = min(i0 + 1, HBLK_N - 1); int j1 = min(j0 + 1, HBLK_N - 1);\n"
			"    float fu = u - float(i0); float fv = v - float(j0);\n"
			"    float h00 = custom_data[j0*HBLK_N + i0]; float h10 = custom_data[j0*HBLK_N + i1];\n"
			"    float h01 = custom_data[j1*HBLK_N + i0]; float h11 = custom_data[j1*HBLK_N + i1];\n"
			"    return mix(mix(h00, h10, fu), mix(h01, h11, fu), fv);\n"
			"}\n"
			"float evaluate_density(float x, float y, float z) {\n"
			"    float d = hblk_height_m(x, z) - y;\n"
			"    d = max(d, (" + floor_str + " - y) * 2.0);\n"
			"    d = min(d, (" + ceil_str_early + " - y) * 2.0);\n"
			"    return d;\n"
			"}\n";
		CharString u8 = hb.utf8();
		cached_glsl_ = std::string(u8.get_data(), u8.length());
		UtilityFunctions::print("[DCTerrainGeneratorProcNodes] HEIGHTFIELD ONLY: ",
			String::num_int64(cached_glsl_.size()), " bytes of GLSL, no graph, no erosion");
		return;
	}

	String ceil_str = String::num(ceiling_y_, 4);
	// y_scale maps [floor_y, ceiling_y] to proc_density's native [0, HeightScale]
	float y_range = ceiling_y_ - floor_y_;
	if (y_range < 0.01f) y_range = 0.01f;
	// NINE DECIMALS, NOT SIX -- the same bug proc-nodes' gpu_compiler.cpp fixed
	// in its own literal emission, still live here.
	//
	// String::num's second argument is DECIMAL PLACES, so for a value of about
	// 2.4e-4 six of them leave THREE significant figures: 5/21000 =
	// 0.000238095238 was emitted as 0.000238. The HeightfieldNode's matching
	// constant is emitted by gpu_compiler at nine, so the two halves of the same
	// mapping disagreed by 4.0e-4 relative -- and since the density is their
	// difference, the surface came out where they balance rather than where the
	// data says:
	//
	//   height  -5300 m -> density zero at -5306.466 m   (-6.466 m)
	//   height      0 m ->                    -4.351 m   (-4.351 m)
	//   height   +165 m ->                  +160.715 m   (-4.285 m)
	//   height  +5300 m ->                 +5297.765 m   (-2.235 m)
	//
	// (tools/oracles/density_precision.py). A smooth error, so it does not make
	// the ground rough -- it just puts the world several metres from its own
	// heightfield, which is why this map's spawn height had to be measured by
	// hand instead of read from the block.
	//
	// Nine also makes the two literals IDENTICAL when the map derives the node's
	// YScale from the same height_scale/y_range, so the difference is exact
	// rather than merely closer.
	// Cell size from the graph's own Erosion Noise node -- the same place the
	// CPU path and the lookdev read it, so the three cannot drift.
	float emit_cell_scale = 1.0f;
	for (int i = 0; i < discovered_params_.size(); i++) {
		const GraphParam &gp = discovered_params_[i];
		if (gp.node_title == "Erosion Noise" && gp.socket_name == "CellScale")
			emit_cell_scale = (float)(double)gp.default_value;
	}

	// HEIGHT SCALE CANNOT BE ZERO.
	//
	// It is the erosion node's HeightScale socket and the divisor of the whole
	// vertical mapping, and the terrain panel gives it a slider that starts at
	// 0. At zero, y_range / height_scale is infinite and String::num emits the
	// literal "inf" -- which GLSL has no identifier for:
	//
	//     ERROR: 0:1751: 'inf' : undeclared identifier
	//
	// The density shader then never compiles, EVERY chunk falls back, and the
	// world has no terrain. It is sticky, too: the value came from a socket the
	// panel writes and GameState persists durably, so it survives a restart and
	// every later rebuild re-emits "inf".
	//
	// Emission must not be able to produce a shader that cannot compile.
	if (!(height_scale > 1e-6f)) {
		UtilityFunctions::push_warning(
			"DCTerrainGeneratorProcNodes: Erosion Noise/HeightScale is ", height_scale,
			" — clamping to 1e-6 so the shader still compiles.");
		height_scale = 1e-6f;
	}
	String yscale_str = String::num(height_scale / y_range, 9);
	String inv_yscale_str = String::num(y_range / height_scale, 9);
	// detail_height_ is in METRES; mtn_offset is in units where 1.0 == y_range
	// metres (y_mapped divides by y_scale = height_scale / y_range). Convert once
	// here so the shader carries a plain fraction.
	float detail_frac = (y_range > 0.01f) ? (detail_height_ / y_range) : 0.0f;
	String mgain_str  = String::num(mountain_gain_, 4);
	String lac_str    = String::num(noise_lacunarity_, 4);
	String dscale_str = String::num(detail_scale_, 4);
	String dfrac_str  = String::num(detail_frac, 8);
	String doct_str   = itos(detail_octaves_);
	String dgain_str  = String::num(detail_gain_, 4);
	String rscale_str = String::num(relief_scale_, 4);
	String rfloor_str = String::num(relief_floor_, 4);
	String mscale_str = String::num(mountain_scale_, 4);
	String mheight_str = String::num(mountain_height_, 4);
	String moct_str = itos(mountain_octaves_);

	// ── Baked heightfield, if one is loaded ────────────────────────────────
	// The SAMPLER is ours (storage is per-consumer); the metres -> filter-space
	// CONVERSION comes from erosion_wrapper_glsl.h, the same text the standalone
	// harness and its identity gate compile. That is the whole point of the
	// split: a unit error here would be invisible in-engine and is exactly the
	// class of bug that cost #45 a session.
	String hblk_glsl_sampler;
	String hblk_glsl;
	// ONLY for the generator's OWN heightfield_path. When the data came from a
	// NODE, that node emits its own sampler in its prelude -- emitting a second
	// one here is a redefinition, and this older copy still names the buffer
	// custom_data. FIXED: it named the buffer hblk_data, which stopped existing
	// when dc-terrain moved to opaque custom_data. #55 section 2.3 recorded this
	// as fixed; it was not, and the heightfield_only isolation oracle failed to
	// compile on EVERY run -- "Custom shader failed, falling back" -- which is
	// what made the map's own GPU/CPU oracle report a 9.1 m disagreement.
	if (hblk_n_ > 0 && !heightfield_path_.is_empty() && !hgrid_.active()) {
		// The sampler alone — HBLK_* consts plus hblk_height_m. Split out because
		// the lookdev path needs ONLY this: erosion_wrapper_glsl emits its own
		// M_* consts and its own heightmap_gn, and emitting ours as well would be
		// a redefinition, which is precisely how this file once shipped a shader
		// that never compiled and a world with no terrain.
		hblk_glsl_sampler =
			String("const int   HBLK_N  = ") + itos(hblk_n_) + ";\n"
			"const float HBLK_OX = " + String::num(hblk_ox_, 4) + ";\n"
			"const float HBLK_OZ = " + String::num(hblk_oz_, 4) + ";\n"
			"const float HBLK_MPP = " + String::num(hblk_mpp_, 6) + ";\n"
			"float hblk_height_m(float x, float z) {\n"
			"    float u = (x - HBLK_OX) / HBLK_MPP;\n"
			"    float v = (z - HBLK_OZ) / HBLK_MPP;\n"
			"    u = clamp(u, 0.0, float(HBLK_N - 1));\n"
			"    v = clamp(v, 0.0, float(HBLK_N - 1));\n"
			"    int i0 = int(floor(u)); int j0 = int(floor(v));\n"
			"    int i1 = min(i0 + 1, HBLK_N - 1); int j1 = min(j0 + 1, HBLK_N - 1);\n"
			"    float fu = u - float(i0); float fv = v - float(j0);\n"
			"    float h00 = custom_data[j0*HBLK_N + i0]; float h10 = custom_data[j0*HBLK_N + i1];\n"
			"    float h01 = custom_data[j1*HBLK_N + i0]; float h11 = custom_data[j1*HBLK_N + i1];\n"
			"    return mix(mix(h00, h10, fu), mix(h01, h11, fu), fv);\n"
			"}\n"
			// The five names erosion_heightmap_gn_glsl() reads. EN_HAMP is the
			// filter's height_amp, which erosion_node_eval BAKES at 0.25 on the
			// engine path -- the same 0.125 = HAMP*0.5 the mountain layer's
			// "* 8.0" below is the inverse of.
			;
		hblk_glsl = hblk_glsl_sampler
			+ "const float M_FLOOR_Y = " + floor_str + ";\n"
			  "const float M_CEIL_Y  = " + ceil_str + ";\n"
			  "const float M_TH      = " + th_str + ";\n"
			  "const float EN_HAMP   = 0.25;\n"
			  "const float HBLK_STEP = " + String::num(hblk_mpp_, 6) + ";\n"
			+ String(erosion_heightmap_gn_glsl().c_str());
	}

	// ── LOOKDEV WRAPPER ────────────────────────────────────────────────────
	if (lookdev_wrapper_ && hblk_n_ > 0) {
		ErosionWrapperParams wp;
		wp.floor_y          = floor_y_;
		wp.ceiling_y        = ceiling_y_;
		wp.terrain_height   = terrain_height_;
		wp.noise_lacunarity = noise_lacunarity_;
		wp.detail_scale     = detail_scale_;
		wp.detail_height    = detail_height_;
		wp.detail_gain      = detail_gain_;
		wp.detail_octaves   = detail_octaves_;
		wp.en_erosion_scale = erosion_scale_;
		wp.field_half_range = field_half_range_;
		wp.voxel_size       = 1.0f;
		wp.heightmap_source = true;
		wp.heightmap_step   = hblk_mpp_;
		for (int i = 0; i < discovered_params_.size(); i++) {
			const GraphParam &gp = discovered_params_[i];
			if (gp.node_title != "Erosion Noise") continue;
			if      (gp.socket_name == "HeightScale") wp.en_height_scale = (float)(double)gp.default_value;
			else if (gp.socket_name == "ErosionStr")  wp.en_strength     = (float)(double)gp.default_value;
			else if (gp.socket_name == "SlopePower")  wp.en_slope_power  = (float)(double)gp.default_value;
			else if (gp.socket_name == "CellScale")   wp.en_cell_scale   = (float)(double)gp.default_value;
			else if (gp.socket_name == "Octaves")     wp.en_octaves      = (int)gp.default_value;
			else if (gp.socket_name == "Gain")        wp.en_gain         = (float)(double)gp.default_value;
		}
		String lk = String("int pn_max_octaves = 99;\n") + proc_glsl + "\n" + hblk_glsl_sampler
			+ String(erosion_wrapper_glsl(wp).c_str())
			+ "float evaluate_density(float x, float y, float z) {\n"
			  "    return gpu_wrapper(x, y, z);\n"
			  "}\n";
		CharString u8 = lk.utf8();
		cached_glsl_ = std::string(u8.get_data(), u8.length());
		UtilityFunctions::print("[DCTerrainGeneratorProcNodes] LOOKDEV WRAPPER: ",
			String::num_int64(cached_glsl_.size()), " bytes; detail_scale=",
			detail_scale_, " (0 = heightmap + erosion ONLY)");
		return;
	}

	// DOES THE GRAPH BRING THE NOISE LIBRARY?
	//
	// compile_to_function emits it only when a node mentions noise, and this
	// wrapper calls fractal_* and assigns pn_terrain_offset / pn_fade_div /
	// pn_erosion_scale -- all of which the LIBRARY declares. A graph that needs
	// no noise (a heightfield node on its own) leaves every one of those
	// undeclared and the shader does not compile, which the map experiences as
	// a silent fallback. So the wrapper's noise layers are emitted only when
	// there is a library to support them.
	// ── EROSION-MINIMAL WRAPPER ────────────────────────────────────────────
	//
	// The heightmap is the terrain and the filter is the enhancement, so this
	// emits exactly those two and the mapping between them:
	//
	//     proc_density  =  Heightfield -> Erosion   (the graph, unchanged)
	//     evaluate_density = y-map, call, rescale, clip
	//
	// No mountain, no relief, no detail, no det_offset. Compare with the
	// general path below, which carries all three because it was written when
	// the mountain was the terrain -- on this map they contribute nothing but
	// branches, and a branch that can be mis-gated is how the filter's own
	// parameters went unset for weeks (hyper-games #53 section 2).
	//
	// The erosion knobs are still emitted, and still gated on the noise library
	// that DECLARES them: with erosion_enabled false there is no library, and
	// assigning pn_ero_* would name identifiers that do not exist.
	if (erosion_minimal_) {
		const bool lib = proc_glsl.find("fractal_noise_2d_lod") != -1;
		String mm =
			"// EROSION-MINIMAL: heightfield + erosion filter, and nothing else.\n"
			"int pn_max_octaves;\n\n"
			+ proc_glsl + "\n"
			+ hblk_glsl +
			"float evaluate_density(float x, float y, float z) {\n"
			"    float inv_scale = 1.0 / " + th_str + ";\n"
			"    pn_max_octaves = 99;\n"
			+ (lib ? String(
			"    pn_fade_div = 4.8;\n"
			"    pn_erosion_scale = " + String::num(erosion_scale_, 6) + ";\n"
			"    pn_ero_input_height_amplitude = " + String::num(input_height_amplitude_, 9) + ";\n"
			"    pn_ero_octave_lacunarity = " + String::num(octave_lacunarity_, 9) + ";\n"
			"    pn_ero_gully_depth = " + String::num(gully_depth_, 9) + ";\n"
			"    pn_ero_fine_gully_slope_restriction = " + String::num(fine_gully_slope_restriction_, 9) + ";\n"
			"    pn_ero_gully_normalize_threshold = " + String::num(gully_normalize_threshold_, 9) + ";\n"
			"    pn_ero_height_offset = " + String::num(height_offset_, 9) + ";\n"
			"    pn_ero_height_offset_follows_altitude = " + String::num(height_offset_follows_altitude_, 9) + ";\n"
			"    pn_ero_crease_rounding = " + String::num(crease_rounding_, 9) + ";\n"
			"    pn_ero_ridge_rounding = " + String::num(ridge_rounding_, 9) + ";\n"
			"    pn_ero_input_feature_rounding = " + String::num(input_feature_rounding_, 9) + ";\n"
			"    pn_ero_rounding_per_octave = " + String::num(rounding_per_octave_, 9) + ";\n"
			"    pn_ero_gully_onset_slope = " + String::num(gully_onset_slope_, 9) + ";\n"
			"    pn_ero_octave_onset_slope = " + String::num(octave_onset_slope_, 9) + ";\n"
			"    pn_ero_ridge_onset_slope = " + String::num(ridge_onset_slope_, 9) + ";\n"
			"    pn_ero_ridge_octave_onset = " + String::num(ridge_octave_onset_, 9) + ";\n"
			"    pn_ero_assumed_slope = " + String::num(assumed_slope_, 9) + ";\n"
			"    pn_ero_assumed_slope_mix = " + String::num(assumed_slope_mix_, 9) + ";\n"
			// The coarsest gully spans terrain_height * gully_band_scale *
			// cell_size in world metres and halves per octave; nyquist_octaves
			// of that against this chunk's voxel is how many octaves it can
			// actually resolve.
			"    pn_ero_max_octaves = nyquist_octaves(" +
				String::num(terrain_height_ * erosion_scale_ * emit_cell_scale, 6) +
				", voxel_scale, 2.0);\n") : String())
			+ "    float y_mapped = (y - " + floor_str + ") * " + yscale_str + ";\n"
			"    float d = proc_density(vec3(x * inv_scale, y_mapped, z * inv_scale));\n"
			"    d *= " + inv_yscale_str + ";\n"
			"    d = max(d, (" + floor_str + " - y) * 2.0);\n"
			"    d = min(d, (" + ceil_str + " - y) * 2.0);\n"
			"    return d;\n"
			"}\n";
		CharString u8 = mm.utf8();
		cached_glsl_ = std::string(u8.get_data(), u8.length());
		UtilityFunctions::print("[DCTerrainGeneratorProcNodes] EROSION-MINIMAL: ",
			String::num_int64(cached_glsl_.size()), " bytes (erosion ",
			erosion_enabled_ ? "ON" : "OFF", ")");
		return;
	}

	const bool has_noise_lib = proc_glsl.find("fractal_noise_2d_lod") != -1;
	const bool want_mountain = has_noise_lib && mountain_height_ != 0.0f;
	const bool want_detail   = has_noise_lib && detail_scale_ > 0.0f;

	// THE MOUNTAIN ASSIGNMENT, with the land/sea curve folded in.
	//
	// The curve goes HERE -- on the mountain layer, BEFORE the erosion filter
	// -- because that is the order the landscape happens in: continents and
	// ocean basins are the shape, erosion is what runs over it. Applying it
	// after erosion would stretch gullies wherever the curve is steep.
	//
	// ⚠ THE GRADIENT MUST BE REMAPPED TOO. The filter is slope-aware and
	// steers gullies by the gradient the wrapper hands it, so a height that
	// has been remapped and a gradient that has not describes a surface that
	// does not exist. hypso_curve returns dout/dh for exactly this.
	//
	// Disabled emits the ORIGINAL line, byte for byte, so `hypso_enabled 0`
	// is not "the curve with neutral parameters" -- it is the previous shader.
	String mtn_assign = (hypso_enabled_ != 0.0f)
		? String(
			"    vec2 hc = hypso_curve(mtn.x, ")
			+ String::num(hypso_sea_, 6) + ", " + String::num(hypso_shelf_width_, 6) + ", "
			+ String::num(hypso_slope_width_, 6) + ", "
			+ String::num(hypso_abyss_, 6) + ", " + String::num(hypso_plat_slope_, 6) + ",\n"
			"                          "
			+ String::num(hypso_mtn_onset_, 6) + ", " + String::num(hypso_mtn_width_, 6) + ", "
			+ String::num(hypso_mtn_slope_, 6) + ", " + String::num(hypso_peak_onset_, 6) + ", "
			+ String::num(hypso_peak_gain_, 6) + ");\n"
			// ⚠ mtn_amp IS NOT USED HERE, and that is deliberate.
			//
			// mtn_amp = mountain_height * relief_amp, and relief_amp varies
			// SPATIALLY from relief_floor (0.08) to 1.0 -- a 12.5x swing.
			// Scaling the curve's output by it would put sea level at a
			// different world Y in every region, and a single water plane
			// then cannot match the shoreline anywhere. The curve defines
			// where the sea is; that cannot be a function of position.
			//
			// So the hypso path uses mountain_height ALONE. The cost is that
			// relief_scale/relief_floor stop modulating how mountainous a
			// region is while the curve is on. Re-coupling them belongs on
			// the curve's INPUT (or on mtn_slope/peak_gain), not on its
			// output, and is deliberately not done here.
			"    pn_terrain_offset = vec3(hc.x, mtn.y * " + mscale_str + " * hc.y, mtn.z * "
			+ mscale_str + " * hc.y) * " + mheight_str + " * 8.0;\n"
		// EROSION IS SUBAERIAL. hc.x is the elevation BEFORE the filter runs,
		// so the column's depth is known here and the mask can be set before
		// proc_density is entered. Metres per curve unit is mountain_height *
		// inv_yscale: pn_terrain_offset carries hc.x * mheight * 8 and reaches
		// the surface through a factor of 0.125, so the base surface in mapped
		// METRES PER CURVE UNIT = mountain_height * erosion height_scale *
		// inv_yscale. The full chain: pn_terrain_offset carries hc.x * mheight
		// * 8, gn reaches the surface through input_height_amplitude * 0.5 =
		// 0.125, and erosion_node_eval scales the result by height_scale. So
		// 8 * 0.125 * height_scale = height_scale.
		//
		// ⚠ The height_scale factor was MISSING at first, giving 0.55 * 4200 =
		// 2310 m/unit against a world whose own relief readout says 11550 --
		// exactly 5x out, which is height_scale. Caught by comparing the
		// emitted constant against a number the map already prints.
		+ (erosion_mask_depth_ > 0.0f ? String(
			"    pn_erosion_mask = 1.0 - smoothstep(0.0, ")
			+ String::num(erosion_mask_depth_, 3) + ", ("
			+ String::num(get_waterline_norm(), 8) + " - hc.x) * "
			+ String::num(mountain_height_ * erosion_height_scale_, 6)
			+ " * " + inv_yscale_str + ");\n"
			: String())
		: String("    pn_terrain_offset = vec3(mtn.x, mtn.y * ") + mscale_str
			+ ", mtn.z * " + mscale_str + ") * mtn_amp * 8.0;\n";

	String adapted =
		"// Octave limit — fixed high value for LOD-consistent surface position.\n"
		"// Nyquist clamping shifts the erosion surface between LOD levels, causing\n"
		"// chunks to disappear during refinement. Consistent octaves = consistent surface.\n"
		"int pn_max_octaves;\n\n"
		// pn_terrain_offset / pn_fade_div are NOT declared here. The noise
		// library (gpu_compiler.cpp) already declares them with defaults so a
		// shader with no wrapper still compiles, and the library is emitted
		// INSIDE proc_glsl below -- i.e. before evaluate_density -- so this
		// function can just assign to them.
		//
		// Declaring them here as well is what shipped a dead map: two
		// declarations in one translation unit is
		//     ERROR: 0:1272: 'pn_terrain_offset' : redefinition
		// the density shader never compiles, every chunk comes back empty, and
		// the symptom is a world with no terrain and a perfectly healthy log
		// right up to the shader compile. pn_max_octaves is different: nothing
		// else declares it, so it stays here.
		+ proc_glsl + "\n"
		+ hblk_glsl +
		"float evaluate_density(float x, float y, float z) {\n"
		"    float inv_scale = 1.0 / " + th_str + ";\n"
		"    pn_max_octaves = 99;\n"
		"    // MOUNTAIN LAYER, height AND gradient. The gradient used to be computed and\n"
		"    // thrown away: mtn_offset was added to y_mapped AFTER the erosion, so the\n"
		"    // filter never saw the range it was carving and aligned its gullies to gn\n"
		"    // alone -- three octaves at gain 0.1, i.e. one smooth 6.7 km blob. It read\n"
		"    // as noise laid over a mountain rather than erosion of one.\n"
		"    // MULTIFRACTAL. variance 0 is bit-identical to fractal_noise_2d, so the\n"
		"    // default costs nothing; above 0 each octave is weighted by the elevation\n"
		"    // accumulated so far, putting the roughness on high ground. Plain fBm is\n"
		"    // statistically homogeneous -- equally rugged in the basins and on the\n"
		"    // peaks -- which is what reads as every range looking the same.\n"
		"    // NYQUIST LIMITS. voxel_scale is the LOD's voxel size in metres and is\n"
		"    // declared by BOTH shaders this body is injected into (density_fill.glsl\n"
		"    // and edge_intersect.glsl), above the injection point. One cycle of a\n"
		"    // layer spans terrain_height/scale world metres, so that is its base\n"
		"    // wavelength. Fractional, so an octave fades in as a chunk refines\n"
		"    // rather than snapping on and moving the surface underfoot.\n"
		+ (want_mountain ? String(
			"    float mtn_lim = nyquist_octaves(" + th_str + " / max(" + mscale_str + ", 1e-6), voxel_scale, " + lac_str + ");\n"
			"    float rel_lim = nyquist_octaves(" + th_str + " / max(" + rscale_str + ", 1e-6), voxel_scale, 2.0);\n"
			"    float det_lim = nyquist_octaves(" + th_str + " / max(" + dscale_str + ", 1e-6), voxel_scale, " + lac_str + ");\n"
			"    vec3 mtn = fractal_multifractal_2d_lod(vec2(x, z) * inv_scale * " + mscale_str + ", 1.0, " + moct_str + ", " + lac_str + ", " + mgain_str + ", " + String::num(mountain_variance_, 4) + ", mtn_lim);\n"
			"    float relief_amp = 1.0;\n"
			"    if (" + rscale_str + " > 0.0) {\n"
			"        vec3 rel = fractal_noise_2d_lod(vec2(x, z) * inv_scale * " + rscale_str + ", 1.0, 2, 2.0, 0.5, rel_lim);\n"
			"        float t = clamp(rel.x * 0.5 + 0.5, 0.0, 1.0);\n"
			"        relief_amp = " + rfloor_str + " + (1.0 - " + rfloor_str + ") * t;\n"
			"    }\n"
			"    float mtn_amp = " + mheight_str + " * relief_amp;\n"
			"    // mtn is evaluated at p * mountain_scale, so d/dp = mtn.yz * mountain_scale.\n"
			"    // d(relief_amp)/dp is dropped: relief is a 40 km envelope, orders coarser\n"
			"    // than anything gully direction responds to. The 8.0 is exact, not fitted --\n"
			"    // gn reaches the surface as gn.x * HEIGHT_AMP * 0.5 = gn.x * 0.125, so\n"
			"    // dividing the offset by 0.125 reproduces the identical base surface at\n"
			"    // zero erosion strength.\n"
		+ (hblk_n_ > 0
			? String("    pn_terrain_offset = heightmap_gn(x, z);\n")
			: mtn_assign)
		+
			"    // DETAIL rides ON TOP of the eroded surface and is deliberately NOT folded\n"
			"    // into pn_terrain_offset: its gradient carries a factor of detail_scale, so\n"
			"    // inside one gully cell the direction flips several times and the stripes go\n"
			"    // chaotic -- measured 77.6 m of relief across a 2 m step, i.e. vertical\n"
			"    // noise. Detail is what water runs over, not what steers it.\n"
		) : String())
		// GATED ON THE NOISE LIBRARY, because that is what DECLARES every name
		// below. A graph with no erosion node -- a heightfield on its own, which
		// is what the map's EROSION_ENABLED toggle produces -- brings no library,
		// and assigning to pn_fade_div / pn_ero_* / calling nyquist_octaves then
		// references identifiers that do not exist and the shader does not
		// compile. Same reason want_mountain and want_detail are gated.
		+ (has_noise_lib ? String(
"    pn_fade_div = 4.8;\n"
			"    pn_erosion_scale = " + String::num(erosion_scale_, 6) + ";\n"
			"    pn_ero_input_height_amplitude = " + String::num(input_height_amplitude_, 9) + ";\n"
			"    pn_ero_octave_lacunarity = " + String::num(octave_lacunarity_, 9) + ";\n"
			"    pn_ero_gully_depth = " + String::num(gully_depth_, 9) + ";\n"
			"    pn_ero_fine_gully_slope_restriction = " + String::num(fine_gully_slope_restriction_, 9) + ";\n"
			"    pn_ero_gully_normalize_threshold = " + String::num(gully_normalize_threshold_, 9) + ";\n"
			"    pn_ero_height_offset = " + String::num(height_offset_, 9) + ";\n"
			"    pn_ero_height_offset_follows_altitude = " + String::num(height_offset_follows_altitude_, 9) + ";\n"
			"    pn_ero_crease_rounding = " + String::num(crease_rounding_, 9) + ";\n"
			"    pn_ero_ridge_rounding = " + String::num(ridge_rounding_, 9) + ";\n"
			"    pn_ero_input_feature_rounding = " + String::num(input_feature_rounding_, 9) + ";\n"
			"    pn_ero_rounding_per_octave = " + String::num(rounding_per_octave_, 9) + ";\n"
			"    pn_ero_gully_onset_slope = " + String::num(gully_onset_slope_, 9) + ";\n"
			"    pn_ero_octave_onset_slope = " + String::num(octave_onset_slope_, 9) + ";\n"
			"    pn_ero_ridge_onset_slope = " + String::num(ridge_onset_slope_, 9) + ";\n"
			"    pn_ero_ridge_octave_onset = " + String::num(ridge_octave_onset_, 9) + ";\n"
			"    pn_ero_assumed_slope = " + String::num(assumed_slope_, 9) + ";\n"
			"    pn_ero_assumed_slope_mix = " + String::num(assumed_slope_mix_, 9) + ";\n"
			"    pn_ero_max_octaves = nyquist_octaves(" +
				String::num(terrain_height_ * erosion_scale_ * emit_cell_scale, 6) +
				", voxel_scale, 2.0);\n"
					) : String())
		+ String("    float det_offset = 0.0;\n")
		+ (want_detail ? String(
			"    if (" + dscale_str + " > 0.0) {\n"
			"        vec3 det = fractal_noise_2d_lod(vec2(x, z) * inv_scale * " + dscale_str + ", 1.0, " + doct_str + ", " + lac_str + ", " + dgain_str + ", det_lim);\n"
			// DETAIL BELONGS ON GENTLE GROUND, NOT EVERYWHERE.
			//
			// It was applied flat, so the same 100 m mottling sat on flats,
			// cliff faces, ridges and gully floors alike -- and because the
			// material reads the NORMAL, that mottling propagated straight
			// into the colour, which is why the terrain read as crumpled foil
			// rather than as landforms.
			//
			// Physically it is soil, scree and regolith: it ACCUMULATES on low
			// slopes and is stripped from steep faces. Masking by slope is the
			// same statement.
			//
			// The gradient is free -- pn_terrain_offset.yz is the mountain
			// slope computed a few lines above, in gn units per unit p. No
			// extra evaluation, no new field.
			"        float det_slope = length(vec2(pn_terrain_offset.y, pn_terrain_offset.z));\n"
			"        float det_mask = 1.0 - smoothstep(" + String::num(detail_slope_lo_, 6)
			+ ", " + String::num(detail_slope_hi_, 6) + ", det_slope);\n"
			"        det_offset = det.x * " + dfrac_str + " * det_mask;\n"
			"    }\n"
		) : String())
		+ (height_space_sqrt_
			? String("    float un = (y - ") + floor_str + ") / " + String::num(y_range, 4) + ";\n"
			         "    float y_mapped = sign(un) * sqrt(abs(un)) * " + String::num(height_scale, 4) +
			         " - det_offset * " + String::num(height_scale, 4) + ";\n"
			: String("    float y_mapped = (y - ") + floor_str + ") * " + yscale_str +
			         " - det_offset * " + String::num(height_scale, 4) + ";\n")
		+ ""
		+ ""
		"    float d = proc_density(vec3(x * inv_scale, y_mapped, z * inv_scale));\n"
		"    d *= " + inv_yscale_str + ";\n"
		// pn_out_grad LEAVES proc_density IN NODE SPACE: d(mapped height)/d(p),
		// with p = world / terrain_height. A consumer that wants a surface
		// normal needs d(world y)/d(world x), and the two constants that close
		// that gap are emitted right here as literals. Converting in the
		// consumer would mean copying 1/60000 and 4200 into a shader by hand --
		// exactly the multi-stage rescale this file has already got wrong once
		// (the waterline, off by 3.4x). Do it where the constants live.
		//
		// ⚠ det_offset's slope is NOT in here. Detail is subtracted from
		// y_mapped and its gradient is deliberately not folded into
		// pn_terrain_offset (see the DETAIL comment above: the direction flips
		// several times inside one gully cell). The normal this yields is the
		// ERODED MOUNTAIN's, which is the structure that survives to distance.
		+ (height_space_sqrt_
			? String(
		"    // sqrt height space: the mapped->world conversion is not linear and\n"
		"    // the inverse is not derived here. Zeroed rather than scaled by a\n"
		"    // constant that would be quietly wrong -- a consumer sees FLAT, which\n"
		"    // is visible, instead of a normal tilted by an unaudited factor.\n"
		"    pn_out_grad = vec2(0.0);\n")
			: String(
		"    pn_out_grad *= inv_scale * ") + inv_yscale_str + ";\n")
		// ── SUBMARINE TERRAIN ──────────────────────────────────────────
		// Geiss (GPU Gems 3 ch.1): add a 3D fBm to a heightfield density and
		// the surface stops being single-valued -- caves, arches and overhangs
		// appear wherever the 3D term outruns the vertical gradient. A
		// heightfield seabed cannot have any of those, and the whole world is
		// meant to be reachable.
		//
		// GATED ON DEPTH, not applied globally. sea_depth uses the UNPERTURBED
		// surface (y + d before this line), so the weight is a property of the
		// column and cannot feed back on itself. The first tens of metres stay
		// a pure heightfield because beaches and the waterline need a
		// single-valued surface -- a cave mouth at the shoreline has no clean
		// coastline, and the ocean's depth query assumes d = H - y holds.
		+ (submarine_amp_ > 0.0f ? String(
		"    // Submarine volumetric seabed, below a smooth coastal buffer.\n"
		"    float sea_depth = ") + String::num(submarine_water_y_, 3) + " - (y + d);\n"
		"    float sub_w = smoothstep(" + String::num(submarine_buffer_start_, 3) + ", "
		+ String::num(submarine_buffer_end_, 3) + ", sea_depth);\n"
		"    if (sub_w > 0.0) {\n"
		"        float sub_f = 1.0 / " + String::num(submarine_wavelength_, 4) + ";\n"
		// AMPLITUDE IS CAPPED BY THE WATER OVER IT. A 1500 m feature in 400 m
		// of water pushes the seabed straight through the surface and puts an
		// island where there should be a reef. Half the local depth is the
		// most that can be added without breaching, and it makes the caves
		// scale with the basin they are in -- big ones offshore, gentle relief
		// in the shallows, which is what a real seabed does.
		"        float sub_a = min(" + String::num(submarine_amp_, 4)
		+ ", max(sea_depth, 0.0) * 0.5);\n"
		"        d += ps_fbm_noise_3d(x * sub_f, y * sub_f, z * sub_f, "
		+ String::num_int64(submarine_octaves_) + ", "
		+ String::num(submarine_roughness_, 4) + ", 2.0) * sub_a * sub_w;\n"
		"    }\n" : String())
		+ String("    // Clip: solid below floor_y, air above ceiling_y.\n"
		"    d = max(d, (") + floor_str + " - y) * 2.0);\n"
		"    d = min(d, (" + ceil_str + " - y) * 2.0);\n"
		"    return d;\n"
		"}\n";

	CharString utf8 = adapted.utf8();
	cached_glsl_ = std::string(utf8.get_data(), utf8.length());

	UtilityFunctions::print("DCTerrainGeneratorProcNodes: Compiled GLSL (",
		String::num_int64(cached_glsl_.size()), " bytes, base_freq=",
		String::num(base_freq, 4), ", terrain_height=",
		String::num(terrain_height_, 1), ")");
}

// ── ProcNodesBridge ────────────────────────────────────────────────────────────

// ============================================================================
// THE DENSITY WRAPPER, SPLIT IN TWO.
//
// It used to be one function, and every line of it ran PER VOXEL -- including
// two linear scans over discovered_params_ comparing Godot Strings, to answer
// questions ("what is Erosion Noise's Gain", "is there an Erosion Noise node
// at all") whose answers cannot change between two points of the same grid.
// DensityGrid::fill asks 33^3 = 35937 of those per 32^3 chunk.
//
// So: _resolve_eval_params does the part that depends on the GRAPH and the LOD,
// once; _eval_at does the part that depends on the POINT. evaluate() is the two
// in sequence and is unchanged in behaviour; evaluate_batch() resolves once and
// loops, which is what dc_worker_cpu's fill now calls.
//
// See hyper-games #64 NEXT 2. ⚠ test_erosion_parity.cpp, which that item names
// as the gate, CANNOT SEE THIS FUNCTION -- it restates both wrappers itself and
// its own header says so. demo/procnodes_eval_probe.gd is the instrument that
// can: it drives sample_density through the real bridge, and this split was
// verified bit-identical across 2601 samples with it.
// ============================================================================
DCTerrainGeneratorProcNodes::EvalParams
DCTerrainGeneratorProcNodes::_resolve_eval_params(float lod_voxel_size) const {
	EvalParams p;
	p.lod_voxel_size = lod_voxel_size;
	// lod_voxel_size USED to be discarded here, which is why this generator had
	// no Nyquist protection on any layer: the wrapper passed literal octave
	// counts and the graph nodes' min(N, pn_max_octaves) is disarmed by
	// pn_max_octaves = 99. DCTerrain::sample_density passes 0 on purpose
	// (dc_terrain.cpp:2841) and nyquist_octaves reads that as "no limit", so
	// collision keeps evaluating the full field.

	// Read graph parameters (same values as the GLSL adapter uses)
	float terrain_height = terrain_height_;
	float floor_y = floor_y_;
	float ceiling_y = ceiling_y_;
	float mtn_scale = mountain_scale_;
	float mtn_height = mountain_height_;
	int mtn_octaves = mountain_octaves_;

	// Extract noise params from discovered graph parameters
	// gn_detail defaults to ZERO, not 3. These defaults stand in for a node that
	// is not in the graph, and a graph with no Gradient Noise node has no base
	// fBm -- defaulting to 3 invented three octaves the GPU never evaluates, and
	// the CPU and GPU then disagree by exactly that. The loop below overwrites
	// this when the node IS present.
	float base_freq = 3.0f, gn_detail = 0, gn_gain = 0.1f;
	float en_height_scale = 5.0f, en_erosion_str = 0.16f, en_slope_power = 0.6f;
	float en_cell_scale = 1.0f; int en_octaves = 5; float en_gain = 0.5f;
	// MountainNode's sockets. Height 0 means "no mountain node in this graph",
	// which is the honest default: a layer that is not in the graph contributes
	// nothing. The generator's own mountain_* properties are not read here any
	// more -- they are what made the CPU compute a layer the shader had stopped
	// emitting, and vice versa.
	for (int i = 0; i < discovered_params_.size(); i++) {
		const auto &gp = discovered_params_[i];
		if (gp.node_title == "Gradient Noise" && gp.socket_name == "Scale")
			base_freq = (float)(double)gp.default_value;
		if (gp.node_title == "Gradient Noise" && gp.socket_name == "Detail")
			gn_detail = (int)gp.default_value;
		if (gp.node_title == "Gradient Noise" && gp.socket_name == "Gain")
			gn_gain = (float)(double)gp.default_value;
		if (gp.node_title == "Erosion Noise" && gp.socket_name == "HeightScale")
			en_height_scale = (float)(double)gp.default_value;
		if (gp.node_title == "Erosion Noise" && gp.socket_name == "ErosionStr")
			en_erosion_str = (float)(double)gp.default_value;
		if (gp.node_title == "Erosion Noise" && gp.socket_name == "SlopePower")
			en_slope_power = (float)(double)gp.default_value;
		if (gp.node_title == "Erosion Noise" && gp.socket_name == "CellScale")
			en_cell_scale = (float)(double)gp.default_value;
		if (gp.node_title == "Erosion Noise" && gp.socket_name == "Octaves")
			en_octaves = (int)gp.default_value;
		if (gp.node_title == "Erosion Noise" && gp.socket_name == "Gain")
			en_gain = (float)(double)gp.default_value;
	}

	float inv_scale = 1.0f / terrain_height;
	float y_range = ceiling_y - floor_y;
	if (y_range < 0.01f) y_range = 0.01f;
	float y_scale = en_height_scale / y_range;
	float inv_y_scale = y_range / en_height_scale;

	// DOES THE GRAPH ACTUALLY HAVE AN EROSION NODE?
	//
	// This function is a hardcoded pipeline -- gn, then terrain_offset, then
	// erosion_node_eval -- written when the graph was always GradientNoise ->
	// ErosionNoise. A graph of one Heightfield node runs none of that on the
	// GPU, where the node sets pos.y itself, so the CPU answered from a pipeline
	// the shader no longer runs and reported a surface 5.3 km out. sample_density
	// drives the spawn, _lift_out_of_terrain and the fall-respawn, so that is a
	// player floating kilometres off the ground.
	//
	// The honest fix is a CPU evaluator that walks the graph the way the GPU
	// compiler does. Until then, match the two shapes that exist.
	bool graph_has_erosion = false;
	for (int i = 0; i < discovered_params_.size(); i++) {
		if (discovered_params_[i].node_title == "Erosion Noise") {
			graph_has_erosion = true;
			break;
		}
	}
	p.terrain_height = terrain_height;
	p.floor_y = floor_y;
	p.ceiling_y = ceiling_y;
	p.mtn_scale = mtn_scale;
	p.mtn_height = mtn_height;
	p.mtn_octaves = mtn_octaves;
	p.base_freq = base_freq;
	p.gn_detail = gn_detail;
	p.gn_gain = gn_gain;
	p.en_height_scale = en_height_scale;
	p.en_erosion_str = en_erosion_str;
	p.en_slope_power = en_slope_power;
	p.en_cell_scale = en_cell_scale;
	p.en_octaves = en_octaves;
	p.en_gain = en_gain;
	p.inv_scale = inv_scale;
	p.y_range = y_range;
	p.y_scale = y_scale;
	p.inv_y_scale = inv_y_scale;
	p.graph_has_erosion = graph_has_erosion;
	return p;
}

float DCTerrainGeneratorProcNodes::_eval_at(const EvalParams &p,
		float x, float y, float z) const {
	// The resolved constants, under the names they had when this was one
	// function, so every line of arithmetic below is UNCHANGED TEXT rather than
	// a rename. That was the whole point of the split: the wrapper this lives
	// in once passed erosion_filter a `scale` 60x out and put the surface you
	// walk on 4 km from the one you see, and a refactor of it has to be
	// provably the same function, not carefully reviewed.
	const float terrain_height = p.terrain_height;
	const float floor_y = p.floor_y;
	const float ceiling_y = p.ceiling_y;
	const float mtn_scale = p.mtn_scale;
	const float mtn_height = p.mtn_height;
	const int mtn_octaves = p.mtn_octaves;
	const float base_freq = p.base_freq;
	const float gn_detail = p.gn_detail;
	const float gn_gain = p.gn_gain;
	const float en_height_scale = p.en_height_scale;
	const float en_erosion_str = p.en_erosion_str;
	const float en_slope_power = p.en_slope_power;
	const float en_cell_scale = p.en_cell_scale;
	const int en_octaves = p.en_octaves;
	const float en_gain = p.en_gain;
	const float inv_scale = p.inv_scale;
	const float y_range = p.y_range;
	const float y_scale = p.y_scale;
	const float inv_y_scale = p.inv_y_scale;
	const bool graph_has_erosion = p.graph_has_erosion;
	const float lod_voxel_size = p.lod_voxel_size;

	// Mountain layer. dcerosion::fractal_noise_2d is THE one from
	// erosion_filter.inc — the same text the shader compiles — so this layer
	// and its GLSL twin above can no longer disagree about the noise itself.
	// What they can still disagree about is the ALGEBRA around it, which is
	// why the "MUST stay identical" notes below are still here and still mean
	// it. Hoisting this wrapper the way erosion_node_eval was hoisted is the
	// remaining piece.
	using dcerosion::vec2;
	using dcerosion::vec3;

	if (!graph_has_erosion && hblk_n_ > 0) {
		// Heightfield alone: density IS height minus query, exactly what the
		// node emits. No filter space, no offsets, nothing to get wrong.
		const float hmpp = hblk_mpp_;
		float d = _hblk_sample_cpu(x, z) - y;
		d = MAX(d, (floor_y - y) * 2.0f);
		d = MIN(d, (ceiling_y - y) * 2.0f);
		return d;
	}

	// HEIGHTFIELD ONLY — the CPU twin, and deliberately trivial. There is no
	// algebra to get wrong here, so if the world still looks like noise with
	// this on, the field is not reaching the GPU and this is what you are
	// standing on instead.
	if (heightfield_only_ && hblk_n_ > 0) {
		const float hmpp = hblk_mpp_;
		float d = _hblk_sample_cpu(x, z) - y;
		d = MAX(d, (floor_y - y) * 2.0f);
		d = MIN(d, (ceiling_y - y) * 2.0f);
		return d;
	}

	// MUST match the GLSL above: same base wavelengths, same lacunarities.
	const float lac = noise_lacunarity_;
	float mtn_lim = dcerosion::nyquist_octaves(terrain_height / MAX(mtn_scale, 1e-6f),
		lod_voxel_size, lac);
	float rel_lim = dcerosion::nyquist_octaves(
		terrain_height / MAX(relief_scale_, 1e-6f), lod_voxel_size, 2.0f);
	float det_lim = dcerosion::nyquist_octaves(
		terrain_height / MAX(detail_scale_, 1e-6f), lod_voxel_size, lac);

	vec3 mtn = dcerosion::fractal_multifractal_2d_lod(vec2(x, z) * inv_scale * mtn_scale, 1.0f,
		mtn_octaves, lac, mountain_gain_,
		mountain_variance_, mtn_lim);
	// Relief field — MUST stay identical to the GLSL above.
	float relief_amp = 1.0f;
	if (relief_scale_ > 0.0f) {
		vec3 rel = dcerosion::fractal_noise_2d_lod(vec2(x, z) * inv_scale * relief_scale_,
			1.0f, 2, 2.0f, 0.5f, rel_lim);
		float t = dcerosion::clamp(rel.x * 0.5f + 0.5f, 0.0f, 1.0f);
		relief_amp = relief_floor_ + (1.0f - relief_floor_) * t;
	}
	float mtn_amp = mtn_height * relief_amp;
	// The mountain's HEIGHT AND GRADIENT, in gn units, to hand to the erosion —
	// the pn_terrain_offset the GLSL sets.
	// THE LAND/SEA CURVE, mirroring the GLSL emission exactly. Without this
	// the CPU answers from the un-remapped fBm while the GPU meshes the
	// remapped one -- the meshed surface and the collidable surface differ,
	// which is the failure this function's own note below was written about.
	//
	// mountain_height alone, NOT mtn_amp: relief_amp varies spatially and
	// scaling the curve by it would move sea level per region. Same reason,
	// same line, as the emission site.
	vec3 terrain_offset;
	// Hoisted: the subaerial erosion mask below needs the pre-erosion elevation,
	// and hc is otherwise scoped to the branch that computes it.
	float pre_erosion_h = mtn.x;
	if (hypso_enabled_ != 0.0f) {
		dcerosion::vec2 hc = dcerosion::hypso_curve(mtn.x,
			hypso_sea_, hypso_shelf_width_, hypso_slope_width_, hypso_abyss_, hypso_plat_slope_,
			hypso_mtn_onset_, hypso_mtn_width_, hypso_mtn_slope_,
			hypso_peak_onset_, hypso_peak_gain_);
		terrain_offset = vec3(hc.x, mtn.y * mtn_scale * hc.y,
			mtn.z * mtn_scale * hc.y) * (mtn_height * 8.0f);
		pre_erosion_h = hc.x;
	} else {
		terrain_offset = vec3(mtn.x, mtn.y * mtn_scale, mtn.z * mtn_scale) * (mtn_amp * 8.0f);
	}

	// THE SAME SUBSTITUTION THE GLSL MAKES.
	//
	// Not for collision -- that is COLLISION_TRIMESH, built from the GPU mesh.
	// For two other reasons, both of which cost real time when this was missing:
	//   1. sample_density() is this path, so without it every headless gate and
	//      every map script asking "where is the ground" answers from the fBm
	//      world the heightfield replaced -- and a gate that cannot see the
	//      field cannot tell a correct shader from one that never ran.
	//   2. The !gpu_filled fallback in dc_worker_cpu.cpp fills chunks from here.
	//      Left unwired it generates DIFFERENT TERRAIN from its neighbours.
	//
	// The algebra is erosion_wrapper_glsl.h's erosion_heightmap_gn_cpu, sitting
	// beside the GLSL it mirrors, so the two cannot drift apart quietly.
	if (hblk_n_ > 0) {
		const float hmpp = hblk_mpp_;
		auto sample = [&](float sx, float sz) -> float {
			return _hblk_sample_cpu(sx, sz);
		};
		float oh, odx, odz;
		erosion_heightmap_gn_cpu(x, z, floor_y, ceiling_y, terrain_height,
			/* height_amp */ 0.25f, erosion_scale_,
			field_half_range_, hmpp, sample, oh, odx, odz);
		terrain_offset = vec3(oh, odx, odz);
	}
	// Detail rides on TOP and is NOT folded in — MUST stay identical to the GLSL.
	float det_offset = 0.0f;
	if (detail_scale_ > 0.0f) {
		float detail_frac = (y_range > 0.01f) ? (detail_height_ / y_range) : 0.0f;
		vec3 det = dcerosion::fractal_noise_2d_lod(vec2(x, z) * inv_scale * detail_scale_,
			1.0f, detail_octaves_, lac, detail_gain_, det_lim);
		// MIRRORS THE GLSL. See the note at the emission site: detail is
		// masked by slope so it accumulates on gentle ground and is stripped
		// from steep faces. terrain_offset is pn_terrain_offset's twin.
		float det_slope = std::sqrt(terrain_offset.y * terrain_offset.y +
		                            terrain_offset.z * terrain_offset.z);
		float dm_t = (detail_slope_hi_ > detail_slope_lo_)
			? (det_slope - detail_slope_lo_) / (detail_slope_hi_ - detail_slope_lo_)
			: 0.0f;
		dm_t = dm_t < 0.0f ? 0.0f : (dm_t > 1.0f ? 1.0f : dm_t);
		float det_mask = 1.0f - (dm_t * dm_t * (3.0f - 2.0f * dm_t));
		det_offset = det.x * detail_frac * det_mask;
	}

	// Map world Y to proc_density space
	// MUST mirror the GLSL above, both branches.
	float y_mapped;
	if (height_space_sqrt_) {
		float un = (y - floor_y) / y_range;
		float su = (un < 0.0f) ? -sqrtf(-un) : sqrtf(un);
		y_mapped = su * en_height_scale - det_offset * en_height_scale;
	} else {
		y_mapped = (y - floor_y) * y_scale - det_offset * en_height_scale;
	}

	// GradientNoiseNode. Lacunarity is hardcoded 2.0 in the node
	// (noise_nodes.cpp:1303) and is NOT noise_lacunarity — do not "fix" that
	// here without fixing the node, or these two diverge again.
	vec2 uv = vec2(x * inv_scale, z * inv_scale);
	vec3 gn = dcerosion::fractal_noise_2d(uv, base_freq, (int)gn_detail, 2.0f, gn_gain);

	// ErosionNoiseNode. THIS LINE IS THE FIX. What stood here was a
	// reimplementation that passed en_height_scale (5.0) as erosion_filter's
	// `scale` where the node passes 0.08333, and skipped the [0,1] remap, the
	// water gate and the height offset. erosion_node_eval is the node's own
	// wrapper, shared with the GLSL, so there is nothing left to get wrong.
	// It returns vec2(height * height_scale, erosion); .y is the ridge/crease
	// value, unused on the density path.
	// slope_power is gone from the signature -- it was never read. Parsed off
	// the graph above and deliberately unused, so old graphs still load.
	(void)en_slope_power;
	// THE FRACTIONAL OCTAVE LIMIT, computed here exactly as the shader computes
	// it. If these two expressions ever differ the surface you walk on and the
	// surface you see part company by however much an octave is worth, which is
	// the failure test_erosion_parity exists to catch -- it caught a 60x version
	// of it once already.
	//
	// The coarsest gully spans terrain_height * gully_band_scale * cell_size in
	// world metres and halves per octave, so that is the base wavelength.
	float ero_max_oct = 999.0f;
	if (lod_voxel_size > 0.0f) {
		ero_max_oct = dcerosion::nyquist_octaves(
			terrain_height * erosion_scale_ * en_cell_scale,
			lod_voxel_size, 2.0f);
	}
	// EROSION IS SUBAERIAL -- mirrors pn_erosion_mask in the emitted GLSL.
	// Written out rather than shared because the CPU path calls the node's
	// wrapper directly and has no globals to set. If these two expressions
	// differ, the surface you collide with and the surface you see part
	// company, which is what test_erosion_parity exists to catch.
	float ero_mask = 1.0f;
	if (erosion_mask_depth_ > 0.0f) {
		float depth_m = (get_waterline_norm() - pre_erosion_h)
				* mountain_height_ * erosion_height_scale_ * inv_y_scale;
		float t = depth_m / erosion_mask_depth_;
		t = t < 0.0f ? 0.0f : (t > 1.0f ? 1.0f : t);
		ero_mask = 1.0f - (t * t * (3.0f - 2.0f * t));
	}
	vec2 en = dcerosion::erosion_node_eval(
		uv, gn + terrain_offset, en_height_scale, en_erosion_str * ero_mask,
		en_cell_scale, en_octaves, en_gain, 4.8f,
		erosion_scale_, ero_max_oct);

	// SDF convention: negative = inside, positive = outside.
	float d = en.x - y_mapped;
	d *= inv_y_scale;

	// ⚠ SUBMARINE TERRAIN IS NOT MIRRORED HERE, AND THAT IS A KNOWN GAP.
	// The GPU density adds a 3D fBm below the coastal buffer; this CPU twin
	// does not, so the meshed seabed has caves and arches that the CPU path
	// does not know about. Everything meshes on the GPU today (GPUPATH reports
	// gpu_mesh 100%), so the visible surface is right -- but any consumer of
	// THIS function below the buffer is wrong by up to submarine_amp metres.
	//
	// The blocker is erosion_cpp_shim.h: it implements the 2D subset the
	// erosion filter needs, and proc_simplex.inc wants vec4, floor(vec3) and
	// scalar-vector operators it does not have. Extending the shim is the fix;
	// faking the noise here would recreate the exact divergence
	// test_erosion_parity exists to catch.

	// Clip: solid below floor_y, air above ceiling_y
	d = MAX(d, (floor_y - y) * 2.0f);
	d = MIN(d, (ceiling_y - y) * 2.0f);

	return d;
}

float DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate(
		float x, float y, float z, float lod_voxel_size) {
	return owner_->_eval_at(owner_->_resolve_eval_params(lod_voxel_size), x, y, z);
}

// THE POINT OF THE SPLIT. One resolve for the whole grid instead of 35937.
void DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate_batch(
		const float *p_xyz, int p_count, float p_lod_voxel_size, float *r_out) {
	const EvalParams ep = owner_->_resolve_eval_params(p_lod_voxel_size);
	for (int i = 0; i < p_count; i++) {
		r_out[i] = owner_->_eval_at(ep, p_xyz[i * 3], p_xyz[i * 3 + 1], p_xyz[i * 3 + 2]);
	}
}

bool DCTerrainGeneratorProcNodes::ProcNodesBridge::has_gpu_shader() const {
	// Read-only cache check — safe to call from worker thread.
	// GLSL is compiled eagerly on main thread in set_graph() / _set().
	return !owner_->cached_glsl_.empty();
}

const char *DCTerrainGeneratorProcNodes::ProcNodesBridge::gpu_shader_source() const {
	// Read-only cache access — safe to call from worker thread.
	return owner_->cached_glsl_.empty() ? nullptr : owner_->cached_glsl_.c_str();
}

// The CONTENT version. hblk_ is written by two producers and, once tiles can
// arrive at runtime, by growth in place -- and std::vector::assign into spare
// capacity keeps data() unchanged, so the pointer cannot be the signal. Every
// site that touches hblk_ bumps this.
uint32_t DCTerrainGeneratorProcNodes::ProcNodesBridge::gpu_custom_data_version() const {
	return owner_->hblk_version_;
}

const float *DCTerrainGeneratorProcNodes::ProcNodesBridge::gpu_custom_data() const {
	return owner_->hblk_.empty() ? nullptr : owner_->hblk_.data();
}

int DCTerrainGeneratorProcNodes::ProcNodesBridge::gpu_custom_data_count() const {
	// THE NUMBER OF FLOATS, not the tile's side.
	//
	// hblk_ has two producers and they store different shapes: _load_heightfield
	// stores a plain n*n square, while _collect_node_data stores the node's MIP
	// PYRAMID -- 1,403,226 floats for a 1026 tile, because hblk_sample needs the
	// coarser levels to band-limit itself per LOD. Returning the side made
	// DCCompute upload side*side, which is exactly level 0 and nothing else, so
	// HBLK_OFF[1] = 1052676 pointed one float past the end of the buffer.
	//
	// Every read at LOD >= 1 then came back zero and the terrain was flat
	// wherever the voxel exceeded the block's 30 m pitch -- which is most of a
	// 917 km world.
	return (int)owner_->hblk_.size();
}

} // namespace godot