OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev / gate_dc_vs_lookdev.py

142 lines · python ref:
#!/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())