#!/usr/bin/env python3
"""
Compare dc-terrain's surface (from Godot) against the lookdev harness.
The Godot half is scenes/test/gate_dc_vs_lookdev.gd, which walks
DCTerrain.sample_density and writes /tmp/dc_surface.txt. This renders the same
columns with erosion_render and compares. Two independent implementations --
GDScript-driven C++ inside the engine, and a standalone Vulkan compute shader --
so agreement is evidence rather than a tautology.
THE CONTROL renders the same columns with erosion turned OFF and requires the
comparison to REJECT it. Without that, a comparison that passed everything
would read as success.
python3 gate_dc_vs_lookdev.py [--bin /tmp/erosion_render4]
"""
import argparse, os, re, subprocess, sys
import numpy as np
DC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "dc-terrain"))
HBLK = os.path.abspath(os.path.join(os.path.dirname(__file__), "..",
"hyper-tps", "assets", "terrain",
"diffusion_gully_30km.hblk"))
MAP_GD = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "hyper-tps",
"scenes", "maps", "map_dc_diffusion.gd"))
def map_params():
"""The MAP's parameters, READ FROM THE MAP.
The first version of this gate rendered the lookdev half at erosion_render's
OWN defaults -- strength 0.22, erosion_scale 0.15 -- while the Godot half ran
the map's 0.0057 / 0.0014. Two different terrains, reported as a 904 m
disagreement between engine and lookdev. It was neither.
So these are PARSED from map_dc_diffusion.gd rather than copied. A gate that
keeps its own copy of the numbers it is checking drifts from them silently,
which is the whole failure being fixed here.
"""
src = open(MAP_GD, encoding="utf-8").read()
def const(name):
m = re.search(r"^const\s+" + name + r"\s*:=\s*(-?[\d.]+)", src, re.M)
if not m:
raise SystemExit(f"cannot read {name} from {MAP_GD}")
return float(m.group(1))
# The BLOCK, read from the map too. Hardcoding it here is how the two
# halves end up on different terrain — the same class of bug as the
# parameters this function exists to read.
m = re.search(r'^const HEIGHTFIELD\s*:=\s*"res://([^"]+)"', src, re.M)
if not m:
raise SystemExit("cannot read HEIGHTFIELD from " + MAP_GD)
global HBLK
HBLK = os.path.abspath(os.path.join(os.path.dirname(MAP_GD), "..", "..", m.group(1)))
if not os.path.exists(HBLK):
raise SystemExit("map's heightfield not on disk: " + HBLK)
floor_y, ceil_y = const("FLOOR_Y"), const("CEILING_Y")
th, cell = const("TERRAIN_HEIGHT"), const("EROSION_CELL_SCALE")
gully, depth = const("EROSION_GULLY_METRES"), const("EROSION_DEPTH_METRES")
k = const("EROSION_AMPLITUDE_K")
hs, gain = const("EROSION_HEIGHT_SCALE"), const("EROSION_GAIN")
fhr = const("EROSION_FIELD_HALF_RANGE")
oct_ = const("EROSION_OCTAVES")
# The map's own two derivations, mirrored (map :602 and the erosion_scale line).
strength = depth * th * cell / (k * gully)
escale = gully / (th * cell)
vals = [("floor_y", floor_y), ("ceiling_y", ceil_y), ("terrain_height", th),
("en_height_scale", hs), ("en_strength", strength),
("en_erosion_scale", escale), ("en_cell_scale", cell),
("en_octaves", int(oct_)), ("en_gain", gain),
("field_half_range", fhr)]
out = []
for kk, vv in vals:
out += ["--set", kk, str(vv)]
return out, depth, strength
def render(binpath, ox, oz, n, mpp, out, extra=()):
cmd = [binpath, "--heightmap", HBLK, "--ox", str(ox), "--oz", str(oz),
"--n", str(n), "--mpp", str(mpp), "--out", out] + list(extra)
r = subprocess.run(cmd, cwd=DC, capture_output=True, text=True)
if r.returncode != 0:
print(r.stdout, r.stderr); raise SystemExit("renderer failed")
return np.fromfile(out, dtype="<f4", count=n * n).astype(np.float64).reshape(n, n)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--bin", default="/tmp/erosion_render4")
ap.add_argument("--dump", default="/tmp/dc_surface.txt")
ap.add_argument("--tol", type=float, default=2.0, help="metres")
a = ap.parse_args()
rows = []
for line in open(a.dump):
if line.startswith("#"):
continue
x, z, y = line.split()
rows.append((float(x), float(z), float(y)))
xs = sorted(set(r[0] for r in rows)); zs = sorted(set(r[1] for r in rows))
n = len(xs)
mpp = xs[1] - xs[0]
godot = np.zeros((len(zs), n))
for x, z, y in rows:
godot[zs.index(z)][xs.index(x)] = y
print(f"Godot surface: {n}x{len(zs)} columns from ({xs[0]:.0f}, {zs[0]:.0f}) "
f"at {mpp:.0f} m spacing")
mp, depth, strength = map_params()
print(f"lookdev half at the MAP's parameters: depth {depth:g} m rms, "
f"strength {strength:.6g}")
look = render(a.bin, xs[0], zs[0], n, mpp, "/tmp/lk_surface.f32", mp)
d = np.abs(godot - look)
ok = d.max() <= a.tol
print(f"\n MATCH max |godot - lookdev| = {d.max():8.3f} m "
f"rms {np.sqrt((d**2).mean()):7.3f} m {'PASS' if ok else 'FAIL'}")
zero = render(a.bin, xs[0], zs[0], n, mpp, "/tmp/lk_zero.f32",
mp + ["--set", "en_strength", "0"])
dz = np.abs(godot - zero)
ctl = dz.max() > a.tol
print(f" CONTROL erosion off: max diff {dz.max():8.1f} m "
f"{'REJECTED (gate discriminates)' if ctl else 'ACCEPTED — GATE IS BLIND'}")
print()
if not ctl:
print("VERDICT: the comparison accepts a different field, so MATCH means nothing.")
return 2
if not ok:
print("VERDICT: dc-terrain and the lookdev DISAGREE about the terrain.")
return 1
print("VERDICT: dc-terrain's field is the lookdev's field.")
return 0
if __name__ == "__main__":
sys.exit(main())