// ============================================================================
// 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;
}