OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / tests / test_erosion_parity.cpp

728 lines · cpp ref:
// ============================================================================
// EROSION PARITY ORACLE — does the surface you walk on match the surface you see?
//
// WHAT IT FOUND, AND WHAT IT NOW GUARDS.
//
// map_dc_erosion meshes on the GPU (dc_terrain.cpp:931,:1329 gate on
// generator_ptr_->has_gpu_shader()) but answers sample_density, get_distance,
// get_surface_normal and get_capsule_push from a C++ path
// (dc_terrain.cpp:2841,:2855,:2863,:2869 -> ProcNodesBridge::evaluate). Those
// were two implementations of one density function with no test comparing
// them, and they had drifted. First run of this oracle:
//
//     mean |GPU surface - CPU surface|   4063.22 m
//     max                                5199.14 m
//     columns with NO collidable ground under terrain that renders   6 of 16
//
// Two independent causes, both now fixed:
//
//   1. THE WRAPPER. ProcNodesBridge::evaluate passed erosion_filter a `scale`
//      of 5.0 where ErosionNoiseNode passes 0.08333 -- 60x in BOTH gully
//      wavelength and gully amplitude, since that argument sets
//      freq = 1/(scale*cell) and strength *= scale -- and omitted the [0,1]
//      remap, the water-line gate and the height offset entirely. Fixed by
//      hoisting the node's wrapper into erosion_filter.inc as
//      erosion_node_eval and having both paths call it.
//
//   2. THE HASH. erosion_hash was iq's float trick, fract(x*y*(x+y)), whose
//      product passes 2^24 quickly. Past lattice coord ~100 more than 84% of
//      hash evaluations land on the far side of a fract() boundary under a
//      1-ULP perturbation and return an unrelated gradient, so GLSL and C++
//      disagreed by 23 m on IDENTICAL source. map_dc_erosion's detail layer
//      (scale 200) runs at lattice 100-654, squarely inside that. Replaced
//      with an integer lattice hash; the detail-layer toggle that proved it
//      is in the .inc's header note.
//
// So this file's job is now REGRESSION, not discovery. It must stay green
// through the advanced-filter swap.
//
// WHAT ITS SUBJECT ACTUALLY IS — read this before believing a number.
//
//   THE ALGORITHM: one copy, no transcription. The GPU side reads
//   proc-nodes/src/shared/erosion_filter.inc off disk at run time and compiles
//   it with glslc; the CPU side #includes THE SAME FILE through
//   erosion_cpp_shim.h. If they disagree, the shim is wrong or the driver is,
//   and that is worth knowing on its own. This half is real and permanent.
//
//   THE WRAPPERS: transcribed, and this is the oracle's soft spot. Both are
//   built by godot_cpp (DCTerrainGeneratorProcNodes::_recompile_glsl assembles
//   a string; ProcNodesBridge::evaluate is a method), so neither is reachable
//   without booting the engine. They are restated below with every constant
//   annotated by file and line, and printed at startup so they can be checked
//   against the source in one pass.
//
//   Since the fix, both wrappers call erosion_node_eval, so this half is a
//   CONSTRUCTION check: it fails if someone re-inlines one side, which is
//   exactly the regression worth catching, but it can no longer discover a
//   drift the transcription does not model. Hoisting _recompile_glsl's string
//   assembly into a godot-free function -- the way dc_shader_source.h was
//   hoisted out of dc_compute.cpp -- would close that gap. Not done.
//
// WHAT IT REPORTS. For each probe column (x,z) it bisects for the surface --
// the y where density crosses zero -- under each wrapper, and prints both
// heights and their difference in metres. A metre is the unit the bug is felt
// in; a raw density delta is not.
//
// Parameter values are map_dc_erosion.gd's, named MAP_* so a change there is a
// one-line change here.
//
// The Vulkan plumbing below is a second copy of test_gpu_stages.cpp's. That is
// deliberate and it is a different class of duplication from the algorithm:
// plumbing drift produces a broken oracle, loudly, not a wrong terrain,
// silently. Factoring both onto a shared harness is worth doing and is not
// done here, because refactoring the one working GPU instrument in the repo in
// order to land a test is how you end up with neither.
//
// Build (no engine, no godot-cpp, no scons):
//   g++ -O2 -std=c++17 -I ../proc-nodes/src/shared \
//       -o /tmp/erosion_parity tests/test_erosion_parity.cpp -lvulkan
//
// Run (from the dc-terrain root, so the default --inc path resolves):
//   /tmp/erosion_parity [--inc PATH] [--columns N]
//
// Exit status is 0 when the wrappers agree, 1 when they do not.
// ============================================================================

#include <vulkan/vulkan.h>

#include "erosion_cpp_shim.h"   // brings in erosion_filter.inc as C++
#include "erosion_wrapper_glsl.h" // the map wrapper, emitted once (see that file)

#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include <algorithm>

using dcerosion::vec2;
using dcerosion::vec3;

// ---------------------------------------------------------------------------
// map_dc_erosion.gd's configuration. Names match the GDScript constants.
// ---------------------------------------------------------------------------
// The band is CENTRED ON ZERO. Godot character physics degrades with ALTITUDE
// (hyper-tps/scenes/test/slope_travel_gate.gd), so the map moved its 10600 m of
// relief from 4600..15200 down to -5300..5300. A pure translation: y_range is
// unchanged, so every wavelength and slope here is unchanged too, and this
// oracle's GPU-vs-CPU comparison is untouched. These still have to MIRROR the
// map, because the sample points below are drawn from the band.
static const float MAP_FLOOR_Y         = -10900.0f;  // map_dc_erosion.gd FLOOR_Y
static const float MAP_CEILING_Y       =  10100.0f;  // map_dc_erosion.gd CEILING_Y
static const float MAP_TERRAIN_HEIGHT  = 60000.0f;  // map_dc_erosion.gd:48
static const float MAP_MOUNTAIN_SCALE  =     1.0f;  // map_dc_erosion.gd:51
static const int   MAP_MOUNTAIN_OCT    =        8;  // map_dc_erosion.gd:53
static const float MAP_MOUNTAIN_HEIGHT =    0.55f;  // map_dc_erosion.gd:55
static const float MAP_RELIEF_SCALE    =     0.5f;  // map_dc_erosion.gd:60
static const float MAP_RELIEF_FLOOR    =    0.08f;  // map_dc_erosion.gd:62
static const float MAP_MOUNTAIN_GAIN   =    0.68f;
static const float MAP_MOUNTAIN_VARIANCE = 0.0f;  // generator "mountain_variance"

// The LOD voxel size the wrappers are compared AT. Both sides must use the same
// value or the Nyquist clamp alone would make them disagree -- the clamp is a
// function of it. 1.0 m is LOD 0, where nothing is clamped; raise it to compare
// the wrappers at a coarse LOD instead.
static float MAP_VOXEL_SIZE = 1.0f;  // map_dc_erosion.gd:69
#ifndef MAP_DETAIL_SCALE_OVERRIDE
#define MAP_DETAIL_SCALE_OVERRIDE 600.0f
#endif
static const float MAP_DETAIL_SCALE    = MAP_DETAIL_SCALE_OVERRIDE;  // map_dc_erosion.gd:79
static const float MAP_DETAIL_HEIGHT   =    30.0f;  // map_dc_erosion.gd:80
static const int   MAP_DETAIL_OCT      =        4;  // map_dc_erosion.gd:85
static const float MAP_DETAIL_GAIN     =     0.5f;  // map_dc_erosion.gd:86
static const float MAP_NOISE_LACUNARITY=    1.87f;  // map_dc_erosion.gd:92

// Graph node defaults, map_dc_erosion.gd:122-133
static const float GN_SCALE        = 3.0f;    // GradientNoise "Scale"
static const int   GN_DETAIL       = 3;       // GradientNoise "Detail"
static const float GN_GAIN         = 0.1f;    // GradientNoise "Gain"
static const float EN_HEIGHT_SCALE = 5.0f;    // ErosionNoise "HeightScale"
static const float EN_STRENGTH     = 0.15f;   // ErosionNoise "ErosionStr"
static const float EN_SLOPE_POWER  = 0.6f;    // ErosionNoise "SlopePower" (IGNORED
                                             // by the fade approach; socket kept)
static const float EN_CELL_SCALE   = 0.7f;    // ErosionNoise "CellScale"
static const int   EN_OCTAVES      = 7;       // ErosionNoise "Octaves"
static const float EN_GAIN         = 0.5f;
static const float EN_EROSION_SCALE= 0.15f;  // generator property "erosion_scale"    // ErosionNoise "Gain"

// These used to be restated here because the two wrappers each carried their
// own copy and this oracle had to model both. They now live once, inside
// erosion_node_eval in erosion_filter.inc, and are NOT duplicated here — the
// only one still needed is the base-noise amplitude, for the banner.
static const float EN_HEIGHT_AMP    = 0.25f;

// ---------------------------------------------------------------------------
// The shared terrain layers. IDENTICAL in both wrappers today
// (dc_terrain_generator_procnodes.cpp:384-402 GLSL vs :570-592 C++), so they
// are written once here: putting them in the wrappers twice would let this
// oracle manufacture a difference the engine does not have.
// ---------------------------------------------------------------------------
// The mountain's height AND gradient in gn units -- what the wrapper hands the
// erosion as pn_terrain_offset. The 8.0 is exact: gn reaches the surface as
// gn.x * HEIGHT_AMP * 0.5 = gn.x * 0.125.
static vec3 terrain_offset(float x, float z) {
	const float inv_scale = 1.0f / MAP_TERRAIN_HEIGHT;
	float mtn_lim = dcerosion::nyquist_octaves(
		MAP_TERRAIN_HEIGHT / MAP_MOUNTAIN_SCALE, MAP_VOXEL_SIZE, MAP_NOISE_LACUNARITY);
	vec3 mtn = dcerosion::fractal_multifractal_2d_lod(
		vec2(x, z) * inv_scale * MAP_MOUNTAIN_SCALE, 1.0f,
		MAP_MOUNTAIN_OCT, MAP_NOISE_LACUNARITY, MAP_MOUNTAIN_GAIN,
		MAP_MOUNTAIN_VARIANCE, mtn_lim);

	float relief_amp = 1.0f;
	if (MAP_RELIEF_SCALE > 0.0f) {
		vec3 rel = dcerosion::fractal_noise_2d_lod(
			vec2(x, z) * inv_scale * MAP_RELIEF_SCALE, 1.0f, 2, 2.0f, 0.5f,
			dcerosion::nyquist_octaves(MAP_TERRAIN_HEIGHT / MAP_RELIEF_SCALE,
				MAP_VOXEL_SIZE, 2.0f));
		float t = dcerosion::clamp(rel.x * 0.5f + 0.5f, 0.0f, 1.0f);
		relief_amp = MAP_RELIEF_FLOOR + (1.0f - MAP_RELIEF_FLOOR) * t;
	}
	float amp = MAP_MOUNTAIN_HEIGHT * relief_amp;
	return vec3(mtn.x, mtn.y * MAP_MOUNTAIN_SCALE, mtn.z * MAP_MOUNTAIN_SCALE) * (amp * 8.0f);
}

// Detail rides on top of the eroded surface and is NOT fed to the erosion.
static float detail_offset(float x, float z) {
	if (MAP_DETAIL_SCALE <= 0.0f) return 0.0f;
	const float inv_scale = 1.0f / MAP_TERRAIN_HEIGHT;
	const float y_range = MAP_CEILING_Y - MAP_FLOOR_Y;
	vec3 det = dcerosion::fractal_noise_2d_lod(
		vec2(x, z) * inv_scale * MAP_DETAIL_SCALE, 1.0f,
		MAP_DETAIL_OCT, MAP_NOISE_LACUNARITY, MAP_DETAIL_GAIN,
		dcerosion::nyquist_octaves(MAP_TERRAIN_HEIGHT / MAP_DETAIL_SCALE,
			MAP_VOXEL_SIZE, MAP_NOISE_LACUNARITY));
	return det.x * (MAP_DETAIL_HEIGHT / y_range);
}

static float y_mapped_of(float y, float det_off) {
	// height_space_sqrt is OFF (map_dc_erosion.gd:156), so this is the linear
	// branch only. dc_terrain_generator_procnodes.cpp:410 (GLSL) / :602 (C++).
	const float y_range  = MAP_CEILING_Y - MAP_FLOOR_Y;
	const float y_scale  = EN_HEIGHT_SCALE / y_range;
	return (y - MAP_FLOOR_Y) * y_scale - det_off * EN_HEIGHT_SCALE;
}

static float clip(float d, float y) {
	// dc_terrain_generator_procnodes.cpp:416-417 (GLSL) / :621-622 (C++)
	d = dcerosion::max(d, (MAP_FLOOR_Y - y) * 2.0f);
	d = dcerosion::min(d, (MAP_CEILING_Y - y) * 2.0f);
	return d;
}

// ---------------------------------------------------------------------------
// WRAPPER A — what the GPU actually meshes.
//
// Chain: DCTerrainGeneratorProcNodes::_recompile_glsl (evaluate_density)
//        -> GpuCompiler::compile_to_function  (proc_density = pos.y - query_y,
//           gpu_compiler.cpp:664-670)
//        -> GradientNoiseNode::compile_glsl   (noise_nodes.cpp:1303-1305)
//        -> ErosionNoiseNode::compile_glsl    (noise_nodes.cpp:1228-1255)
// ---------------------------------------------------------------------------
static float gpu_wrapper(float x, float y, float z) {
	const float inv_scale = 1.0f / MAP_TERRAIN_HEIGHT;
	const float y_range   = MAP_CEILING_Y - MAP_FLOOR_Y;
	const float inv_yscale= y_range / EN_HEIGHT_SCALE;

	float y_mapped = y_mapped_of(y, detail_offset(x, z));

	vec2 p = vec2(x * inv_scale, z * inv_scale);

	// GradientNoiseNode: lacunarity is hardcoded 2.0, NOT noise_lacunarity.
	vec3 gn = dcerosion::fractal_noise_2d(p, GN_SCALE, GN_DETAIL, 2.0f, GN_GAIN);

	// ErosionNoiseNode, hoisted into erosion_filter.inc. This used to be
	// inlined here, and the inlined version's numbers are what the "hoist is
	// faithful" check compared against.
	vec2 en = dcerosion::erosion_node_eval(
		p, gn + terrain_offset(x, z), EN_HEIGHT_SCALE, EN_STRENGTH,
		EN_CELL_SCALE, EN_OCTAVES, EN_GAIN, 4.8f, EN_EROSION_SCALE,
		// 999: this oracle compares the two WRAPPERS at one fixed grid, so there
		// is no LOD to taper against. Tapering here would compare the taper with
		// itself and hide a wrapper divergence behind it.
		999.0f);

	float d = en.x - y_mapped;
	d *= inv_yscale;
	return clip(d, y);
}

// ---------------------------------------------------------------------------
// WRAPPER B — what collision and sample_density report.
// ProcNodesBridge::evaluate, dc_terrain_generator_procnodes.cpp:606-622.
//
// Note what is missing relative to A: no HEIGHT_AMP, no [0,1] remap, no water
// gate, no height offset — and erosion_filter's `scale` argument is
// EN_HEIGHT_SCALE (5.0) where A passes EN_EROSION_SCALE (0.08333). Inside
// erosion_filter that argument sets BOTH freq = 1/(scale*cell) and
// strength *= scale, so it moves gully wavelength and gully amplitude
// together, by 60x, in opposite directions.
// ---------------------------------------------------------------------------
static float cpu_wrapper(float x, float y, float z) {
	const float inv_scale = 1.0f / MAP_TERRAIN_HEIGHT;
	const float y_range   = MAP_CEILING_Y - MAP_FLOOR_Y;
	const float inv_yscale= y_range / EN_HEIGHT_SCALE;

	float y_mapped = y_mapped_of(y, detail_offset(x, z));

	vec2 p = vec2(x * inv_scale, z * inv_scale);

	vec3 gn = dcerosion::fractal_noise_2d(p, GN_SCALE, GN_DETAIL, 2.0f, GN_GAIN);

	// FIXED. This was a reimplementation that passed EN_HEIGHT_SCALE as
	// erosion_filter's `scale` and skipped the remap, water gate and offset.
	// It now calls the same hoisted node wrapper the GPU path calls.
	vec2 en = dcerosion::erosion_node_eval(
		p, gn + terrain_offset(x, z), EN_HEIGHT_SCALE, EN_STRENGTH,
		EN_CELL_SCALE, EN_OCTAVES, EN_GAIN, 4.8f, EN_EROSION_SCALE,
		// 999: this oracle compares the two WRAPPERS at one fixed grid, so there
		// is no LOD to taper against. Tapering here would compare the taper with
		// itself and hide a wrapper divergence behind it.
		999.0f);

	float d = en.x - y_mapped;
	d *= inv_yscale;
	return clip(d, y);
}

// ---------------------------------------------------------------------------
// Surface finding. Density is positive below the surface and negative above
// (SDF convention, dc_terrain_generator_procnodes.cpp:610). Scan coarsely for
// a sign change, then bisect. Returns false if the column has no crossing.
// ---------------------------------------------------------------------------
template <typename F>
static bool find_surface(F eval, float x, float z, float& out_y) {
	const float lo = MAP_FLOOR_Y + 1.0f, hi = MAP_CEILING_Y - 1.0f;
	const int   STEPS = 512;
	float prev_y = hi, prev_d = eval(x, hi, z);
	for (int i = 1; i <= STEPS; i++) {
		float y = hi + (lo - hi) * (float)i / (float)STEPS;   // top down
		float d = eval(x, y, z);
		if ((prev_d < 0.0f) != (d < 0.0f)) {
			float a = y, b = prev_y;                          // a below, b above
			for (int k = 0; k < 60; k++) {
				float m = 0.5f * (a + b);
				if ((eval(x, m, z) < 0.0f) == (prev_d < 0.0f)) b = m; else a = m;
			}
			out_y = 0.5f * (a + b);
			return true;
		}
		prev_y = y; prev_d = d;
	}
	return false;
}

// ---------------------------------------------------------------------------
// Vulkan plumbing (see the header note: a deliberate second copy).
// ---------------------------------------------------------------------------
static void die(const char* what, VkResult r) {
	fprintf(stderr, "FATAL: %s -> VkResult %d\n", what, (int)r);
	exit(1);
}
#define VK(x) do { VkResult _r = (x); if (_r != VK_SUCCESS) die(#x, _r); } while (0)

static std::vector<uint32_t> compile_glsl(const std::string& src, const char* tag) {
	char in_path[] = "/tmp/dc_erosion_parity_XXXXXX";
	int fd = mkstemp(in_path);
	if (fd < 0) { perror("mkstemp"); exit(1); }
	FILE* f = fdopen(fd, "w");
	fwrite(src.data(), 1, src.size(), f);
	fclose(f);

	std::string spv_path = std::string(in_path) + ".spv";
	std::string cmd = "glslc -fshader-stage=comp -O -o " + spv_path + " " + in_path;
	if (system(cmd.c_str()) != 0) {
		fprintf(stderr, "FATAL: glslc failed for %s (source left at %s)\n", tag, in_path);
		exit(1);
	}
	FILE* sf = fopen(spv_path.c_str(), "rb");
	if (!sf) { fprintf(stderr, "FATAL: no SPIR-V at %s\n", spv_path.c_str()); exit(1); }
	fseek(sf, 0, SEEK_END); long n = ftell(sf); fseek(sf, 0, SEEK_SET);
	std::vector<uint32_t> spv(n / 4);
	if (fread(spv.data(), 1, n, sf) != (size_t)n) { fprintf(stderr, "FATAL: short read\n"); exit(1); }
	fclose(sf);
	remove(in_path); remove(spv_path.c_str());
	return spv;
}

struct Ctx {
	VkInstance instance = VK_NULL_HANDLE;
	VkPhysicalDevice phys = VK_NULL_HANDLE;
	VkDevice dev = VK_NULL_HANDLE;
	VkQueue queue = VK_NULL_HANDLE;
	uint32_t qfam = 0;
	VkCommandPool cmdpool = VK_NULL_HANDLE;
	VkPhysicalDeviceMemoryProperties memprops{};
	char devname[256] = {0};
};

static uint32_t find_mem(Ctx& c, uint32_t bits, VkMemoryPropertyFlags want) {
	for (uint32_t i = 0; i < c.memprops.memoryTypeCount; i++)
		if ((bits & (1u << i)) && (c.memprops.memoryTypes[i].propertyFlags & want) == want)
			return i;
	fprintf(stderr, "FATAL: no memory type with flags 0x%x\n", want);
	exit(1);
}

struct Buf { VkBuffer buf = VK_NULL_HANDLE; VkDeviceMemory mem = VK_NULL_HANDLE;
             void* mapped = nullptr; VkDeviceSize size = 0; };

// HOST_VISIBLE is correct HERE and wrong in test_gpu_stages.cpp, for opposite
// reasons: this oracle measures VALUES, not time, so where the memory lives
// cannot affect the answer, and mapping it directly removes the staging path.
static Buf make_host_buf(Ctx& c, VkDeviceSize size) {
	if (size == 0) size = 16;
	Buf b; b.size = size;
	VkBufferCreateInfo bi{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
	bi.size = size;
	bi.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
	bi.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
	VK(vkCreateBuffer(c.dev, &bi, nullptr, &b.buf));
	VkMemoryRequirements mr;
	vkGetBufferMemoryRequirements(c.dev, b.buf, &mr);
	VkMemoryAllocateInfo ai{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
	ai.allocationSize = mr.size;
	ai.memoryTypeIndex = find_mem(c, mr.memoryTypeBits,
		VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
	VK(vkAllocateMemory(c.dev, &ai, nullptr, &b.mem));
	VK(vkBindBufferMemory(c.dev, b.buf, b.mem, 0));
	VK(vkMapMemory(c.dev, b.mem, 0, VK_WHOLE_SIZE, 0, &b.mapped));
	return b;
}

static void init_vulkan(Ctx& c) {
	VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO};
	app.pApplicationName = "dc-terrain erosion parity oracle";
	app.apiVersion = VK_API_VERSION_1_1;
	VkInstanceCreateInfo ii{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO};
	ii.pApplicationInfo = &app;
	VK(vkCreateInstance(&ii, nullptr, &c.instance));

	uint32_t n = 0;
	VK(vkEnumeratePhysicalDevices(c.instance, &n, nullptr));
	if (!n) { fprintf(stderr, "FATAL: no Vulkan device\n"); exit(1); }
	std::vector<VkPhysicalDevice> devs(n);
	VK(vkEnumeratePhysicalDevices(c.instance, &n, devs.data()));
	c.phys = devs[0];
	for (auto d : devs) {
		VkPhysicalDeviceProperties p;
		vkGetPhysicalDeviceProperties(d, &p);
		if (p.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) { c.phys = d; break; }
	}
	VkPhysicalDeviceProperties props;
	vkGetPhysicalDeviceProperties(c.phys, &props);
	snprintf(c.devname, sizeof(c.devname), "%s", props.deviceName);
	vkGetPhysicalDeviceMemoryProperties(c.phys, &c.memprops);

	uint32_t qn = 0;
	vkGetPhysicalDeviceQueueFamilyProperties(c.phys, &qn, nullptr);
	std::vector<VkQueueFamilyProperties> qs(qn);
	vkGetPhysicalDeviceQueueFamilyProperties(c.phys, &qn, qs.data());
	bool found = false;
	for (uint32_t i = 0; i < qn; i++)
		if (qs[i].queueFlags & VK_QUEUE_COMPUTE_BIT) { c.qfam = i; found = true; break; }
	if (!found) { fprintf(stderr, "FATAL: no compute queue\n"); exit(1); }

	float prio = 1.0f;
	VkDeviceQueueCreateInfo qi{VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO};
	qi.queueFamilyIndex = c.qfam; qi.queueCount = 1; qi.pQueuePriorities = &prio;
	VkDeviceCreateInfo di{VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO};
	di.queueCreateInfoCount = 1; di.pQueueCreateInfos = &qi;
	VK(vkCreateDevice(c.phys, &di, nullptr, &c.dev));
	vkGetDeviceQueue(c.dev, c.qfam, 0, &c.queue);

	VkCommandPoolCreateInfo ci{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO};
	ci.queueFamilyIndex = c.qfam;
	ci.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
	VK(vkCreateCommandPool(c.dev, &ci, nullptr, &c.cmdpool));
}

// ---------------------------------------------------------------------------
// The GLSL side: the .inc verbatim, plus a main() that evaluates it at the
// supplied points. Everything above the .inc is scaffolding; the algorithm is
// entirely the file's.
// ---------------------------------------------------------------------------
static std::string read_file(const char* path) {
	FILE* f = fopen(path, "rb");
	if (!f) { fprintf(stderr, "FATAL: cannot open %s\n", path); exit(1); }
	fseek(f, 0, SEEK_END); long n = ftell(f); fseek(f, 0, SEEK_SET);
	std::string s(n, '\0');
	if (fread(&s[0], 1, n, f) != (size_t)n) { fprintf(stderr, "FATAL: short read\n"); exit(1); }
	fclose(f);
	return s;
}

static uint32_t checksum(const std::string& s) {
	uint32_t h = 2166136261u;
	for (unsigned char ch : s) { h ^= ch; h *= 16777619u; }
	return h;
}

// The ONE place the map's constants above become wrapper parameters. It exists
// because it was got wrong immediately: the lookdev renderer hand-copied these
// values into its own defaults, MAP_FLOOR_Y moved from -1000 to -10900 in
// 156b2e4 the same morning, and the renderer was quietly 7.3 km out until its
// verify gate caught it. A second consumer must call THIS, not retype them.
static ErosionWrapperParams erosion_wrapper_params_from_map() {
	ErosionWrapperParams wp;
	wp.floor_y           = MAP_FLOOR_Y;
	wp.ceiling_y         = MAP_CEILING_Y;
	wp.terrain_height    = MAP_TERRAIN_HEIGHT;
	wp.mountain_scale    = MAP_MOUNTAIN_SCALE;
	wp.mountain_height   = MAP_MOUNTAIN_HEIGHT;
	wp.mountain_gain     = MAP_MOUNTAIN_GAIN;
	wp.mountain_variance = MAP_MOUNTAIN_VARIANCE;
	wp.mountain_octaves  = MAP_MOUNTAIN_OCT;
	wp.voxel_size        = MAP_VOXEL_SIZE;
	wp.relief_scale      = MAP_RELIEF_SCALE;
	wp.relief_floor      = MAP_RELIEF_FLOOR;
	wp.detail_scale      = MAP_DETAIL_SCALE;
	wp.detail_height     = MAP_DETAIL_HEIGHT;
	wp.detail_gain       = MAP_DETAIL_GAIN;
	wp.detail_octaves    = MAP_DETAIL_OCT;
	wp.noise_lacunarity  = MAP_NOISE_LACUNARITY;
	wp.gn_scale          = GN_SCALE;
	wp.gn_gain           = GN_GAIN;
	wp.gn_detail         = GN_DETAIL;
	wp.en_height_scale   = EN_HEIGHT_SCALE;
	wp.en_strength       = EN_STRENGTH;
	wp.en_slope_power    = EN_SLOPE_POWER;
	wp.en_cell_scale     = EN_CELL_SCALE;
	wp.en_gain           = EN_GAIN;
	wp.en_octaves        = EN_OCTAVES;
	wp.en_erosion_scale  = EN_EROSION_SCALE;
	return wp;
}

static std::string build_shader(const std::string& inc) {
	std::string s =
		"#version 450\n"
		"layout(local_size_x = 64) in;\n"
		"layout(set=0, binding=0) readonly buffer InP  { vec4 pts[]; };\n"
		"layout(set=0, binding=1) writeonly buffer OutD { vec2 dens[]; };\n"
		"layout(push_constant) uniform PC { uint count; } pc;\n"
		"\n";
	s += inc;
	s += "\n";

	// The two wrappers used to be written out here as GLSL text, adjacent to
	// their C++ twins above. They now come from erosion_wrapper_glsl.h, which
	// emits the identical string -- hoisted the moment a SECOND GPU consumer
	// (the lookdev renderer) needed the same wrapper and would otherwise have
	// become a third copy. The C++ twins above are deliberately NOT hoisted:
	// this oracle's job is to compare a GLSL transcription against a C++ one,
	// and sharing both halves would leave it comparing a thing with itself.
	//
	// Verified byte-for-byte at the hoist: 33840 bytes, checksum 1254537005,
	// before and after.
	ErosionWrapperParams wp = erosion_wrapper_params_from_map();
	// The noise library declares pn_out_ridge (gpu_compiler.cpp) and the
	// wrapper assigns it, but a standalone shader has no library -- the same
	// contract that makes the engine prepend "int pn_max_octaves = 99;".
	// Without this, glslc fails and this harness still prints a healthy
	// CPU-vs-CPU table, which is how the omission hides.
	s += "float pn_out_ridge = 0.0;\n";
	s += "vec2 pn_out_grad = vec2(0.0);\n";
	s += erosion_wrapper_glsl(wp);

	s +=
		"void main() {\n"
		"    uint i = gl_GlobalInvocationID.x;\n"
		"    if (i >= pc.count) return;\n"
		"    vec3 q = pts[i].xyz;\n"
		"    dens[i] = vec2(gpu_wrapper(q.x,q.y,q.z), cpu_wrapper(q.x,q.y,q.z));\n"
		"}\n";
	return s;
}

// ---------------------------------------------------------------------------
int main(int argc, char** argv) {
	const char* inc_path = "../proc-nodes/src/shared/erosion_filter.inc";
	int columns = 24;
	for (int i = 1; i < argc; i++) {
		if (!strcmp(argv[i], "--inc") && i + 1 < argc) inc_path = argv[++i];
		else if (!strcmp(argv[i], "--columns") && i + 1 < argc) columns = atoi(argv[++i]);
		else if (!strcmp(argv[i], "--voxel") && i + 1 < argc) MAP_VOXEL_SIZE = (float)atof(argv[++i]);
	}

	std::string inc = read_file(inc_path);
	printf("EROSION PARITY ORACLE\n");
	printf("  algorithm : %s (%zu bytes, fnv1a %08x)\n", inc_path, inc.size(), checksum(inc));
	printf("              GPU compiles this file; CPU #included the same file at build time.\n");
	printf("              If you edited it since building, REBUILD — this is the one skew\n");
	printf("              the oracle cannot see.\n");
	printf("  wrappers  : transcribed (see header). Both call erosion_node_eval,\n");
	printf("              so this half is a CONSTRUCTION check — it fails if one side\n");
	printf("              is re-inlined, which is the regression worth catching.\n");
	printf("  filter    : advanced (phacelle / fade approach), MPL-2.0.\n");
	printf("     node args  strength=%.4f cell=%.2f octaves=%d gain=%.2f height=%.2f\n",
	       EN_STRENGTH, EN_CELL_SCALE, EN_OCTAVES, EN_GAIN, EN_HEIGHT_SCALE);
	printf("     slope_power=%.2f is accepted and IGNORED by the fade approach.\n\n",
	       EN_SLOPE_POWER);

	// --- GPU: evaluate both wrappers at a grid of probe points -------------
	Ctx c;
	init_vulkan(c);
	printf("  device    : %s\n\n", c.devname);

	// Probe points: a column of y samples per (x,z), so the GPU run covers the
	// same evaluations the host bisection will make at the found surfaces.
	std::vector<float> probe_x, probe_z;
	const float SPAN = 20000.0f;   // well inside the 57.3 km root span
	int side = 1; while (side * side < columns) side++;
	for (int i = 0; i < side; i++)
		for (int j = 0; j < side; j++) {
			probe_x.push_back(-SPAN * 0.5f + SPAN * (float)i / (float)(side - 1 ? side - 1 : 1));
			probe_z.push_back(-SPAN * 0.5f + SPAN * (float)j / (float)(side - 1 ? side - 1 : 1));
		}

	// --- CPU: bisect for the surface under each wrapper --------------------
	printf("  %-11s %-11s %13s %13s %12s\n", "x", "z", "GPU surface", "CPU surface", "delta");
	printf("  %-11s %-11s %13s %13s %12s\n", "-----------", "-----------",
	       "-------------", "-------------", "------------");

	int found_both = 0, disagree = 0;
	double sum_abs = 0.0, max_abs = 0.0;
	for (size_t k = 0; k < probe_x.size(); k++) {
		float x = probe_x[k], z = probe_z[k];
		float ygpu = 0.0f, ycpu = 0.0f;
		bool g = find_surface(gpu_wrapper, x, z, ygpu);
		bool p = find_surface(cpu_wrapper, x, z, ycpu);
		if (!g || !p) {
			printf("  %-11.1f %-11.1f %13s %13s %12s\n", x, z,
			       g ? "(found)" : "NO SURFACE", p ? "(found)" : "NO SURFACE", "-");
			continue;
		}
		found_both++;
		double d = (double)ygpu - (double)ycpu;
		sum_abs += d < 0 ? -d : d;
		if ((d < 0 ? -d : d) > max_abs) max_abs = (d < 0 ? -d : d);
		if ((d < 0 ? -d : d) > 0.5) disagree++;
		printf("  %-11.1f %-11.1f %13.2f %13.2f %12.2f\n", x, z, ygpu, ycpu, d);
	}

	printf("\n  columns with a surface under both wrappers : %d / %zu\n",
	       found_both, probe_x.size());
	printf("  columns disagreeing by more than 0.5 m     : %d\n", disagree);
	if (found_both) {
		printf("  mean |GPU surface - CPU surface|           : %.2f m\n", sum_abs / found_both);
		printf("  max  |GPU surface - CPU surface|           : %.2f m\n", max_abs);
	}

	// --- The GLSL-vs-C++ half: same wrapper, both languages ----------------
	// This is the invariant erosion_filter.inc exists to hold, and it must stay
	// green through the algorithm swap. It is separate from the wrapper
	// question above and must not be conflated with it.
	{
		std::vector<float> pts;
		for (size_t k = 0; k < probe_x.size(); k++)
			for (int s = 0; s < 8; s++) {
				float y = MAP_FLOOR_Y + (MAP_CEILING_Y - MAP_FLOOR_Y) * (s + 0.5f) / 8.0f;
				pts.push_back(probe_x[k]); pts.push_back(y);
				pts.push_back(probe_z[k]); pts.push_back(0.0f);
			}
		uint32_t n = (uint32_t)(pts.size() / 4);

		Buf in  = make_host_buf(c, pts.size() * sizeof(float));
		Buf out = make_host_buf(c, n * 2 * sizeof(float));
		memcpy(in.mapped, pts.data(), pts.size() * sizeof(float));
		memset(out.mapped, 0, n * 2 * sizeof(float));

		std::string src = build_shader(inc);
		std::vector<uint32_t> spv = compile_glsl(src, "erosion parity");

		VkShaderModuleCreateInfo smi{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
		smi.codeSize = spv.size() * 4; smi.pCode = spv.data();
		VkShaderModule mod;
		VK(vkCreateShaderModule(c.dev, &smi, nullptr, &mod));

		VkDescriptorSetLayoutBinding binds[2]{};
		for (int i = 0; i < 2; i++) {
			binds[i].binding = i;
			binds[i].descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
			binds[i].descriptorCount = 1;
			binds[i].stageFlags = VK_SHADER_STAGE_COMPUTE_BIT;
		}
		VkDescriptorSetLayoutCreateInfo dli{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO};
		dli.bindingCount = 2; dli.pBindings = binds;
		VkDescriptorSetLayout dsl;
		VK(vkCreateDescriptorSetLayout(c.dev, &dli, nullptr, &dsl));

		VkPushConstantRange pcr{VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(uint32_t)};
		VkPipelineLayoutCreateInfo pli{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO};
		pli.setLayoutCount = 1; pli.pSetLayouts = &dsl;
		pli.pushConstantRangeCount = 1; pli.pPushConstantRanges = &pcr;
		VkPipelineLayout playout;
		VK(vkCreatePipelineLayout(c.dev, &pli, nullptr, &playout));

		VkComputePipelineCreateInfo cpi{VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO};
		cpi.stage.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
		cpi.stage.stage = VK_SHADER_STAGE_COMPUTE_BIT;
		cpi.stage.module = mod; cpi.stage.pName = "main";
		cpi.layout = playout;
		VkPipeline pipe;
		VK(vkCreateComputePipelines(c.dev, VK_NULL_HANDLE, 1, &cpi, nullptr, &pipe));

		VkDescriptorPoolSize ps{VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 2};
		VkDescriptorPoolCreateInfo dpi{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};
		dpi.maxSets = 1; dpi.poolSizeCount = 1; dpi.pPoolSizes = &ps;
		VkDescriptorPool dpool;
		VK(vkCreateDescriptorPool(c.dev, &dpi, nullptr, &dpool));
		VkDescriptorSetAllocateInfo dai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
		dai.descriptorPool = dpool; dai.descriptorSetCount = 1; dai.pSetLayouts = &dsl;
		VkDescriptorSet dset;
		VK(vkAllocateDescriptorSets(c.dev, &dai, &dset));

		VkDescriptorBufferInfo bi[2] = {{in.buf, 0, VK_WHOLE_SIZE}, {out.buf, 0, VK_WHOLE_SIZE}};
		VkWriteDescriptorSet w[2]{};
		for (int i = 0; i < 2; i++) {
			w[i].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
			w[i].dstSet = dset; w[i].dstBinding = i; w[i].descriptorCount = 1;
			w[i].descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
			w[i].pBufferInfo = &bi[i];
		}
		vkUpdateDescriptorSets(c.dev, 2, w, 0, nullptr);

		VkCommandBufferAllocateInfo cbi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
		cbi.commandPool = c.cmdpool; cbi.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
		cbi.commandBufferCount = 1;
		VkCommandBuffer cmd;
		VK(vkAllocateCommandBuffers(c.dev, &cbi, &cmd));
		VkCommandBufferBeginInfo cbb{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
		cbb.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
		VK(vkBeginCommandBuffer(cmd, &cbb));
		vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pipe);
		vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, playout, 0, 1, &dset, 0, nullptr);
		vkCmdPushConstants(cmd, playout, VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(uint32_t), &n);
		vkCmdDispatch(cmd, (n + 63) / 64, 1, 1);
		VK(vkEndCommandBuffer(cmd));
		VkSubmitInfo si{VK_STRUCTURE_TYPE_SUBMIT_INFO};
		si.commandBufferCount = 1; si.pCommandBuffers = &cmd;
		VK(vkQueueSubmit(c.queue, 1, &si, VK_NULL_HANDLE));
		VK(vkQueueWaitIdle(c.queue));

		const float* got = (const float*)out.mapped;
		double worst_gpu = 0.0, worst_cpu = 0.0;
		for (uint32_t i = 0; i < n; i++) {
			float x = pts[i * 4 + 0], y = pts[i * 4 + 1], z = pts[i * 4 + 2];
			double dg = (double)got[i * 2 + 0] - (double)gpu_wrapper(x, y, z);
			double dc = (double)got[i * 2 + 1] - (double)cpu_wrapper(x, y, z);
			if (dg < 0) dg = -dg;
			if (dc < 0) dc = -dc;
			if (dg > worst_gpu) worst_gpu = dg;
			if (dc > worst_cpu) worst_cpu = dc;
		}
		printf("\n  GLSL vs C++ on the SAME .inc (%u points):\n", n);
		printf("    worst |glsl - c++| , GPU wrapper : %.6g\n", worst_gpu);
		printf("    worst |glsl - c++| , CPU wrapper : %.6g\n", worst_cpu);
		printf("    (float32 either side; anything above ~1e-2 means the shim and the\n");
		printf("     GLSL builtins disagree, which is a different bug from the one above.)\n");
	}

	printf("\n  VERDICT: ");
	if (disagree == 0 && found_both > 0) printf("wrappers AGREE.\n");
	else printf("wrappers DISAGREE on %d of %d columns. The meshed surface and the\n"
	            "           collidable surface are different functions.\n", disagree, found_both);
	return disagree == 0 ? 0 : 1;
}