OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / terrain_probe.gd

848 lines · gd ref:
class_name TerrainProbe
extends RefCounted
## TERRAIN GROUND PROBE — what is actually under the player, measured five ways.
##
## WHY THIS EXISTS. Every terrain instrument in this repo so far has been either
## headless or offline, and on a heightfield map BOTH are structurally blind:
## the baked block is bound on the GPU only (dc_terrain_generator_procnodes.cpp
## emits it into the GLSL; the C++ evaluate() never samples it), so
## sample_density() and every headless gate return the fBm world this map
## replaced. map_dc_diffusion.gd prints the size of that gap at every spawn --
## "CPU says 330.0 m, GPU expects 165.0 m". An instrument built on the CPU field
## can therefore report a clean table about a world the player never stands on.
##
## So this runs INSIDE a real render session, against the physics bodies the
## player actually collides with and the GPU density the mesher actually saw.
##
## AND IT SELF-TESTS ON EVERY RUN. #50's rule -- "an instrument must be able to
## fail" -- is enforced here rather than remembered: selftest() feeds synthetic
## flat / sloped / spiked / vertical data through the SAME pure functions the
## live report uses and checks they answer what those inputs mean. A report
## whose header says SELFTEST FAIL is not evidence about the terrain.
##
## The five measurements, and what each one distinguishes:
##
##   A  chunk       which LOD the ground here is, and whether it has collision
##                  at all. "no collision body" and "bumpy collision" look the
##                  same from inside the game and need opposite work.
##   B  walked      the collision surface as the player meets it: a downward
##                  raycast grid. Steps, roughness, holes, and how many
##                  neighbour pairs exceed floor_max_angle -- i.e. how many
##                  WALLS are in ground that looks flat.
##   C  triangles   the collision mesh's own faces, by inclination. B says the
##                  surface is bumpy; C says whether the bumps are triangles
##                  standing up or the sampling of a smooth thing.
##   D  field       the GPU density at the SAME voxel size the mesher used. If
##                  the field is smooth and B is bumpy, the MESHER makes the
##                  bumps. If the field is bumpy, the generator does. This is
##                  the one split no previous instrument could make.
##   E  lod shift   the same field at voxel v and 2v. That difference IS the
##                  step a player walks into at an LOD boundary.
##
## Sign convention: density positive = inside solid (dc_terrain.h).

const MISS := 1e30          # a raycast that hit nothing, kept in-band as a float
const DEG := 180.0 / PI


# ---------------------------------------------------------------------------
# PURE metrics. No engine state, so selftest() can drive them with data whose
# answer is known by construction.
# ---------------------------------------------------------------------------

## Height grid -> the numbers that decide whether a surface is walkable.
## h is n*n row-major (index = row*n + col); spacing is metres between samples.
static func height_metrics(h: PackedFloat32Array, n: int, spacing: float) -> Dictionary:
	var out := {
		"samples": n * n, "misses": 0, "pairs": 0,
		"max_step": 0.0, "p95_step": 0.0, "mean_step": 0.0,
		"max_slope_deg": 0.0, "steeper_than_60": 0, "steeper_than_45": 0,
		"plane_rms": 0.0, "relief": 0.0, "worst_at": Vector2i(-1, -1),
	}
	if n < 2 or spacing <= 0.0 or h.size() < n * n:
		out["error"] = "grid too small"
		return out

	var steps := PackedFloat32Array()
	var lo := INF
	var hi := -INF
	var misses := 0
	for r in range(n):
		for c in range(n):
			var v: float = h[r * n + c]
			if v >= MISS:
				misses += 1
				continue
			if v < lo: lo = v
			if v > hi: hi = v
			# 4-neighbour pairs, each counted once (right and down)
			if c + 1 < n:
				var vr: float = h[r * n + c + 1]
				if vr < MISS:
					var d: float = absf(vr - v)
					steps.append(d)
					if d > out["max_step"]:
						out["max_step"] = d
						out["worst_at"] = Vector2i(c, r)
			if r + 1 < n:
				var vd: float = h[(r + 1) * n + c]
				if vd < MISS:
					var d2: float = absf(vd - v)
					steps.append(d2)
					if d2 > out["max_step"]:
						out["max_step"] = d2
						out["worst_at"] = Vector2i(c, r)
	out["misses"] = misses
	out["pairs"] = steps.size()
	if steps.size() > 0:
		var sorted := steps.duplicate()
		sorted.sort()
		out["p95_step"] = sorted[int(floor(float(sorted.size() - 1) * 0.95))]
		var acc := 0.0
		for s in steps:
			acc += s
		out["mean_step"] = acc / float(steps.size())
		var worst: float = out["max_step"]
		out["max_slope_deg"] = atan(worst / spacing) * DEG
		for s in steps:
			var a: float = atan(s / spacing) * DEG
			if a > 60.0: out["steeper_than_60"] = int(out["steeper_than_60"]) + 1
			if a > 45.0: out["steeper_than_45"] = int(out["steeper_than_45"]) + 1
	if lo <= hi:
		out["relief"] = hi - lo

	# Least-squares plane through the hits: h = a*x + b*z + c. The residual RMS
	# is roughness at the WALKING scale with the hillside's own slope removed,
	# which is the number a slope figure cannot give you.
	var sx := 0.0; var sz := 0.0; var sh := 0.0
	var sxx := 0.0; var szz := 0.0; var sxz := 0.0; var sxh := 0.0; var szh := 0.0
	var m := 0
	for r in range(n):
		for c in range(n):
			var v2: float = h[r * n + c]
			if v2 >= MISS:
				continue
			var x := float(c) * spacing
			var z := float(r) * spacing
			sx += x; sz += z; sh += v2
			sxx += x * x; szz += z * z; sxz += x * z
			sxh += x * v2; szh += z * v2
			m += 1
	out["fit_samples"] = m
	if m < 8:
		# TOO FEW POINTS TO SAY ANYTHING. The plane fit through 5 points of a
		# 81-point grid is not "roughness 0.0000", it is no measurement -- and
		# 0.0000 reads as "perfectly smooth", which is the most misleading
		# answer available. Live report D printed exactly that from 5 columns.
		out["plane_rms"] = -1.0
		out["undersampled"] = true
	if m >= 3:
		# Solve the 3x3 normal equations by Cramer's rule.
		var fm := float(m)
		var a11 := sxx; var a12 := sxz; var a13 := sx
		var a22 := szz; var a23 := sz; var a33 := fm
		var det := (a11 * (a22 * a33 - a23 * a23)
				- a12 * (a12 * a33 - a23 * a13)
				+ a13 * (a12 * a23 - a22 * a13))
		if absf(det) > 1e-9:
			var b1 := sxh; var b2 := szh; var b3 := sh
			var da := (b1 * (a22 * a33 - a23 * a23)
					- a12 * (b2 * a33 - a23 * b3)
					+ a13 * (b2 * a23 - a22 * b3))
			var db := (a11 * (b2 * a33 - a23 * b3)
					- b1 * (a12 * a33 - a23 * a13)
					+ a13 * (a12 * b3 - b2 * a13))
			var dc := (a11 * (a22 * b3 - b2 * a23)
					- a12 * (a12 * b3 - b2 * a13)
					+ b1 * (a12 * a23 - a22 * a13))
			var pa := da / det; var pb := db / det; var pc := dc / det
			var acc2 := 0.0
			for r2 in range(n):
				for c2 in range(n):
					var v3: float = h[r2 * n + c2]
					if v3 >= MISS:
						continue
					var e: float = v3 - (pa * float(c2) * spacing + pb * float(r2) * spacing + pc)
					acc2 += e * e
			if m >= 8:
				out["plane_rms"] = sqrt(acc2 / float(m))
			out["plane_slope_deg"] = atan(sqrt(pa * pa + pb * pb)) * DEG
	return out


## Triangle list (flat triples) -> inclination statistics against `up`.
static func triangle_metrics(faces: PackedVector3Array, up: Vector3) -> Dictionary:
	var out := {
		"triangles": 0, "degenerate": 0, "overhang": 0,
		"over_60": 0, "over_45": 0, "max_incl_deg": 0.0,
		"area": 0.0, "area_over_60": 0.0, "mean_incl_deg": 0.0,
		"hist_deg": PackedInt32Array([0, 0, 0, 0, 0, 0]),   # 0-15,15-30,30-45,45-60,60-80,80-90+
	}
	if faces.size() < 3:
		return out
	var u := up.normalized()
	var incl_sum := 0.0
	var t := 0
	while t + 2 < faces.size():
		var a := faces[t]
		var b := faces[t + 1]
		var c := faces[t + 2]
		t += 3
		# GODOT'S WINDING, not the textbook one. Plane(p1,p2,p3) builds its
		# normal as (p1-p3).cross(p1-p2), which is the NEGATIVE of
		# (b-a).cross(c-a) -- so the obvious formula reported every up-facing
		# terrain triangle as an overhang. It did on the first three live
		# reports (152/152, 296/296) and the selftest did not catch it because
		# no case asserted the overhang field. Fixed, and case 6 below now
		# pins the sign.
		var nrm := (a - c).cross(a - b)
		var len2 := nrm.length()
		if len2 < 1e-9:
			out["degenerate"] = int(out["degenerate"]) + 1
			continue
		out["triangles"] = int(out["triangles"]) + 1
		var area := len2 * 0.5
		out["area"] = float(out["area"]) + area
		var cosang: float = clampf((nrm / len2).dot(u), -1.0, 1.0)
		if cosang < 0.0:
			out["overhang"] = int(out["overhang"]) + 1
		var incl: float = acos(absf(cosang)) * DEG
		incl_sum += incl
		if incl > float(out["max_incl_deg"]):
			out["max_incl_deg"] = incl
		if incl > 45.0: out["over_45"] = int(out["over_45"]) + 1
		if incl > 60.0:
			out["over_60"] = int(out["over_60"]) + 1
			out["area_over_60"] = float(out["area_over_60"]) + area
		var bin := 0
		if incl >= 80.0: bin = 5
		elif incl >= 60.0: bin = 4
		elif incl >= 45.0: bin = 3
		elif incl >= 30.0: bin = 2
		elif incl >= 15.0: bin = 1
		var hist: PackedInt32Array = out["hist_deg"]
		hist[bin] += 1
		out["hist_deg"] = hist
	if int(out["triangles"]) > 0:
		out["mean_incl_deg"] = incl_sum / float(out["triangles"])
	return out


# ---------------------------------------------------------------------------
# THE INSTRUMENT MUST BE ABLE TO FAIL. Synthetic inputs whose answers are known
# by construction, through the SAME functions the live report uses.
# ---------------------------------------------------------------------------
static func selftest() -> Dictionary:
	var cases: Array = []
	var ok := true
	var n := 9
	var sp := 0.25

	# 1. Flat: no steps, no roughness, no walls.
	var flat := PackedFloat32Array()
	flat.resize(n * n)
	flat.fill(100.0)
	var mf := height_metrics(flat, n, sp)
	var c1: bool = float(mf["max_step"]) < 1e-6 and float(mf["plane_rms"]) < 1e-6 and int(mf["steeper_than_60"]) == 0
	cases.append(["flat -> 0 step, 0 rms", c1, "max_step=%.6f rms=%.6f" % [mf["max_step"], mf["plane_rms"]]])
	ok = ok and c1

	# 2. A 30 degree ramp: a real slope must NOT read as roughness, and must NOT
	#    read as a wall. A metric that cannot tell a hillside from a barb is the
	#    exact failure this probe exists to avoid.
	var ramp := PackedFloat32Array()
	ramp.resize(n * n)
	var tan30 := tan(deg_to_rad(30.0))
	for r in range(n):
		for c in range(n):
			ramp[r * n + c] = float(c) * sp * tan30
	var mr := height_metrics(ramp, n, sp)
	var c2: bool = absf(float(mr["max_slope_deg"]) - 30.0) < 0.5 \
			and float(mr["plane_rms"]) < 1e-5 \
			and int(mr["steeper_than_60"]) == 0 \
			and absf(float(mr.get("plane_slope_deg", 0.0)) - 30.0) < 0.5
	cases.append(["30deg ramp -> slope 30, rms 0", c2,
			"slope=%.3f rms=%.6f plane=%.3f" % [mr["max_slope_deg"], mr["plane_rms"], mr.get("plane_slope_deg", 0.0)]])
	ok = ok and c2

	# 3. One spike of exactly 1 m on flat ground: max_step must SEE it.
	var spike := flat.duplicate()
	spike[4 * n + 4] = 101.0
	var ms := height_metrics(spike, n, sp)
	var c3: bool = absf(float(ms["max_step"]) - 1.0) < 1e-5 and int(ms["steeper_than_60"]) >= 4
	cases.append(["1 m spike -> step 1.0, walls>=4", c3,
			"max_step=%.6f walls=%d" % [ms["max_step"], ms["steeper_than_60"]]])
	ok = ok and c3

	# 4. A hole: a miss must be counted, not silently averaged away.
	var holed := flat.duplicate()
	holed[0] = MISS
	var mh := height_metrics(holed, n, sp)
	var c4: bool = int(mh["misses"]) == 1
	cases.append(["one miss -> misses 1", c4, "misses=%d" % mh["misses"]])
	ok = ok and c4

	# 5. Triangles: flat reads 0, vertical reads 90, degenerate is counted and
	#    does not pollute the statistics.
	var tris := PackedVector3Array([
		Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(0, 0, 1),          # flat
		Vector3(0, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, 1),          # vertical
		Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(2, 0, 0),          # degenerate
	])
	var mt := triangle_metrics(tris, Vector3.UP)
	var c5: bool = int(mt["triangles"]) == 2 and int(mt["degenerate"]) == 1 \
			and absf(float(mt["max_incl_deg"]) - 90.0) < 0.01 and int(mt["over_60"]) == 1
	cases.append(["flat+vertical+degenerate", c5,
			"tris=%d degen=%d max=%.2f over60=%d" % [mt["triangles"], mt["degenerate"], mt["max_incl_deg"], mt["over_60"]]])
	ok = ok and c5

	# 6. THE SIGN. A ground triangle wound the way dual contouring winds ground
	#    must NOT read as an overhang, and a ceiling must. Added because the
	#    first three live reports called every flat terrain triangle an
	#    overhang and every check above still passed.
	var ground := PackedVector3Array([Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(0, 0, 1)])
	var ceil_t := PackedVector3Array([Vector3(0, 0, 0), Vector3(0, 0, 1), Vector3(1, 0, 0)])
	var mg := triangle_metrics(ground, Vector3.UP)
	var mc3 := triangle_metrics(ceil_t, Vector3.UP)
	var c6: bool = int(mg["overhang"]) == 0 and int(mc3["overhang"]) == 1
	cases.append(["ground up-facing, reversed one is an overhang", c6,
			"ground_overhang=%d reversed_overhang=%d" % [mg["overhang"], mc3["overhang"]]])
	ok = ok and c6

	return {"pass": ok, "cases": cases}


# ---------------------------------------------------------------------------
# LIVE measurement
# ---------------------------------------------------------------------------

## B — the surface the player actually collides with, by raycast.
static func walked_surface(space: PhysicsDirectSpaceState3D, center: Vector3,
		n: int, spacing: float, up: Vector3, exclude: Array) -> PackedFloat32Array:
	var h := PackedFloat32Array()
	h.resize(n * n)
	var half := float(n - 1) * spacing * 0.5
	# A basis for the horizontal grid that does not assume up is +Y.
	var ax := up.cross(Vector3(0, 0, 1))
	if ax.length_squared() < 1e-6:
		ax = up.cross(Vector3(1, 0, 0))
	ax = ax.normalized()
	var az := up.cross(ax).normalized()
	for r in range(n):
		for c in range(n):
			var p := center + ax * (float(c) * spacing - half) + az * (float(r) * spacing - half)
			# +6 / -20 m, not +40. The ray starts above the PROBE CENTRE, not
			# above each column's own ground, so on a slope the up-hill columns
			# sit higher than the centre: over a 4 m half-width, 6 m of headroom
			# covers ground up to about 56 degrees. Starting far higher instead
			# would catch a ledge or an overhang metres above the player and
			# report it as "the ground here", which is a wrong answer rather
			# than a missing one.
			var q := PhysicsRayQueryParameters3D.create(p + up * 6.0, p - up * 20.0)
			q.exclude = exclude
			q.collide_with_areas = false
			var hit := space.intersect_ray(q)
			h[r * n + c] = (hit["position"] as Vector3).dot(up) if hit.has("position") else MISS
	return h


## D/E — the GPU density field's own surface, at a chosen voxel size.
## Returns heights along `up` on the same n*n footprint, MISS where the column
## has no sign change. voxel is the DENSITY sample spacing, and it matters:
## the shader's octave clamping and heightfield mip both read it, so sampling
## at 0.25 m does not reproduce what a 1 m-voxel chunk was meshed from.
static func field_surface(terrain: Node, center: Vector3, n: int, spacing: float,
		voxel: float, up: Vector3, vertical_window: float = 60.0) -> Dictionary:
	var res := {"heights": PackedFloat32Array(), "n": 0, "spacing": 0.0, "voxel": voxel, "note": ""}
	if terrain == null or not terrain.has_method("debug_gpu_density"):
		res["note"] = "no debug_gpu_density on this build"
		return res
	if absf(up.dot(Vector3.UP)) < 0.99:
		res["note"] = "field probe assumes world-up columns"
		return res
	# One cubic grid covering the footprint horizontally and +/- half its span
	# vertically. cells is chosen so cells*voxel covers the footprint.
	# THE VERTICAL WINDOW IS THE MEASUREMENT, and tying it to the horizontal
	# footprint made it 8 m tall -- so a field whose surface sits a few metres
	# from the mesh reported "no sign change" for 94% of its columns instead of
	# reporting the offset, which is the finding. The grid is cubic, so the
	# footprint follows the window rather than the other way round.
	var cells := int(ceil(vertical_window / voxel))
	if cells < 4: cells = 4
	if cells > 96: cells = 96          # 97^3 floats is already 3.6 MB
	var span := float(cells) * voxel
	var origin := Vector3(center.x - span * 0.5, center.y - span * 0.5, center.z - span * 0.5)
	var data: PackedFloat32Array = terrain.call("debug_gpu_density", origin, voxel, cells)
	if data.is_empty():
		res["note"] = "the GPU declined the density request -- that IS the finding"
		return res
	var stride := cells + 1
	if data.size() < stride * stride * stride:
		res["note"] = "short grid: %d floats, expected %d" % [data.size(), stride * stride * stride]
		return res
	var out := PackedFloat32Array()
	out.resize(stride * stride)
	# THE LAYOUT IS data[z*stride*stride + y*stride + x] -- DensityGrid::fill,
	# dc_density_grid.h:53. Walking [y*stride*stride + z*stride + x] instead
	# steps along Z at the player's own height, and near the player that crosses
	# the surface right underneath him: the probe then reports approximately the
	# player's own altitude whatever the terrain does, which is an instrument
	# that cannot fail. It read -0.077 m of offset that way.
	for zi in range(stride):
		for xi in range(stride):
			var found := MISS
			# Walk DOWN from the top: the first crossing from air (negative)
			# into solid (positive) is the surface, and taking the topmost one
			# is what a downward raycast does too.
			var prev: float = data[zi * stride * stride + (stride - 1) * stride + xi]
			for yi in range(stride - 2, -1, -1):
				var d: float = data[zi * stride * stride + yi * stride + xi]
				if (prev < 0.0) != (d < 0.0):
					var y0 := origin.y + float(yi) * voxel
					var y1 := y0 + voxel
					var t := 0.5
					if absf(d - prev) > 1e-12:
						t = (0.0 - d) / (prev - d)
					found = y0 + clampf(t, 0.0, 1.0) * (y1 - y0)
					break
				prev = d
			out[zi * stride + xi] = found
	# ORIENTATION CHECK. The centre column must be AIR at the top of the window
	# and SOLID at the bottom -- that is what a vertical column through ground
	# means, and a mis-indexed walk (along Z, say) has no reason to satisfy it.
	# Reported in the report so a wrong axis shows up as a failed check rather
	# than as plausible numbers.
	var mid := stride / 2
	var top: float = data[mid * stride * stride + (stride - 1) * stride + mid]
	var bot: float = data[mid * stride * stride + 0 * stride + mid]
	res["orient_top"] = top
	res["orient_bot"] = bot
	res["orient_ok"] = top < 0.0 and bot > 0.0

	res["heights"] = out
	res["n"] = stride
	res["spacing"] = voxel
	res["origin"] = origin
	return res


# ---------------------------------------------------------------------------
# THE REPORT
# ---------------------------------------------------------------------------

## Run every measurement at `center` and return the formatted report.
## `terrain` may be null (B and C still work; D and E say so).
static func run(terrain: Node, space: PhysicsDirectSpaceState3D, center: Vector3,
		up: Vector3, exclude: Array, footprint: float = 8.0, spacing: float = 0.25,
		player: Node = null) -> String:
	var L: Array[String] = []
	var n := int(round(footprint / spacing)) + 1
	if n < 3: n = 3
	if n > 65: n = 65

	L.append("=== TERRAIN GROUND PROBE ===")
	L.append("at %s   up %s   footprint %.1f m @ %.2f m (%dx%d)"
			% [center, up, footprint, spacing, n, n])
	# NAME THE ARTIFACT. A report that does not say which binary produced it can
	# be a report about code that was never loaded.
	if terrain != null and terrain.has_method("build_stamp"):
		L.append("binary: %s" % terrain.call("build_stamp"))
	else:
		L.append("binary: OLDER THAN THIS PROBE -- no build_stamp(); rebuild dc-terrain")

	# --- the instrument, first ---
	var st := selftest()
	L.append("")
	L.append("SELFTEST: %s" % ("PASS" if st["pass"] else "*** FAIL -- NOTHING BELOW IS EVIDENCE ***"))
	for c in st["cases"]:
		L.append("   %s  %s   (%s)" % ["ok " if c[1] else "FAIL", c[0], c[2]])

	# --- A: the chunk ---
	L.append("")
	L.append("A. CHUNK UNDER THE POINT")
	if terrain != null and terrain.has_method("debug_chunk_info"):
		var info: Dictionary = terrain.call("debug_chunk_info", center)
		if info.is_empty():
			L.append("   no leaf contains this point (not 'LOD 0' -- no chunk at all)")
		else:
			L.append("   lod=%d  voxel=%.3f m  chunk=%.1f m  tris=%d  state=%d"
					% [info["lod"], info["voxel_size"], info["chunk_size"], info["triangles"], info["state"]])
			L.append("   collision: body=%s  eligible=%s  parent_collision_stale=%s"
					% [info["has_collision"], info["collision_eligible"], info["parent_collision_stale"]])
	if terrain != null and terrain.has_method("get_edit_count"):
		var ec: int = terrain.call("get_edit_count")
		var ms := -1
		var ci3: Dictionary = terrain.call("debug_chunk_info", center) if terrain.has_method("debug_chunk_info") else {}
		if ci3.has("mesh_state"):
			ms = int(ci3["mesh_state"])
		L.append("   CSG: terrain holds %d edit(s); this chunk's mesh_state=%d (0 never, 1 pending, 2 generating, 3 up-to-date, 4 NEEDS UPDATE)"
				% [ec, ms])
		if ec == 0:
			L.append("        zero edits means the carve never reached DCTerrain at all -- game side, not shader side")
	if terrain != null and "collision_distance" in terrain:
		L.append("   collision is built within %.0f m of the camera, lod <= %s"
				% [float(terrain.collision_distance),
				   str(terrain.collision_lod_max) if "collision_lod_max" in terrain else "?"])
		# Neighbours, to catch an LOD boundary within walking distance.
		var seen := {}
		for d in [Vector3(32, 0, 0), Vector3(-32, 0, 0), Vector3(0, 0, 32), Vector3(0, 0, -32),
				Vector3(96, 0, 0), Vector3(-96, 0, 0), Vector3(0, 0, 96), Vector3(0, 0, -96)]:
			var ni: Dictionary = terrain.call("debug_chunk_info", center + d)
			var k: String = "none" if ni.is_empty() else "lod%d/col=%s" % [ni["lod"], ni["has_collision"]]
			seen[k] = int(seen.get(k, 0)) + 1
		L.append("   within 96 m: %s" % str(seen))
	else:
		L.append("   (this build has no debug_chunk_info -- rebuild dc-terrain)")

	# --- B: the walked surface ---
	L.append("")
	L.append("B. THE SURFACE THE PLAYER COLLIDES WITH (raycast grid)")
	var hb := walked_surface(space, center, n, spacing, up, exclude)
	var mb := height_metrics(hb, n, spacing)
	L.append("   hits %d/%d   misses %d (holes in collision)"
			% [int(mb["samples"]) - int(mb["misses"]), mb["samples"], mb["misses"]])
	L.append("   relief %.3f m   plane slope %.2f deg   ROUGHNESS (rms off best-fit plane) %.4f m"
			% [mb["relief"], mb.get("plane_slope_deg", 0.0), mb["plane_rms"]])
	L.append("   step between %.2f m samples: max %.4f m  p95 %.4f m  mean %.4f m  (worst at %s)"
			% [spacing, mb["max_step"], mb["p95_step"], mb["mean_step"], mb["worst_at"]])
	L.append("   WALLS: %d/%d neighbour pairs steeper than 60 deg (floor_max_angle), %d over 45"
			% [mb["steeper_than_60"], mb["pairs"], mb["steeper_than_45"]])

	# --- C: the collision triangles ---
	L.append("")
	L.append("C. THE COLLISION TRIANGLES THEMSELVES")
	if terrain != null and terrain.has_method("debug_collision_faces"):
		var faces: PackedVector3Array = terrain.call("debug_collision_faces", center, footprint * 0.5)
		if faces.is_empty():
			L.append("   no collision faces within %.1f m -- the ground here has no trimesh body"
					% (footprint * 0.5))
		else:
			var mc := triangle_metrics(faces, up)
			L.append("   %d triangles (%d degenerate, %d overhanging), area %.1f m2"
					% [mc["triangles"], mc["degenerate"], mc["overhang"], mc["area"]])
			L.append("   inclination: mean %.1f deg  max %.1f deg   over45 %d   over60 %d (%.1f%% by area)"
					% [mc["mean_incl_deg"], mc["max_incl_deg"], mc["over_45"], mc["over_60"],
					   100.0 * float(mc["area_over_60"]) / maxf(float(mc["area"]), 1e-9)])
			var hist: PackedInt32Array = mc["hist_deg"]
			L.append("   histogram  0-15:%d  15-30:%d  30-45:%d  45-60:%d  60-80:%d  80+:%d"
					% [hist[0], hist[1], hist[2], hist[3], hist[4], hist[5]])
	else:
		L.append("   (this build has no debug_collision_faces -- rebuild dc-terrain)")

	# --- C2: is the collision where the terrain is DRAWN? ---
	L.append("")
	L.append("C2. VISUAL MESH vs COLLISION MESH")
	if terrain != null and terrain.has_method("debug_visual_faces"):
		var vfaces: PackedVector3Array = terrain.call("debug_visual_faces", center, footprint * 0.5)
		var cfaces: PackedVector3Array = terrain.call("debug_collision_faces", center, footprint * 0.5)
		if vfaces.is_empty():
			L.append("   no visual triangles here")
		else:
			var mv := triangle_metrics(vfaces, up)
			L.append("   visual %d triangles (mean %.1f deg), collision %d triangles"
					% [mv["triangles"], mv["mean_incl_deg"], cfaces.size() / 3])
			# The strong test: every collision vertex should BE a visual vertex.
			# They are built from the same array, so a mismatch is either a
			# different transform or a different mesh generation.
			var worst := 0.0
			var checked := 0
			var step: int = maxi(1, cfaces.size() / 90)
			for i in range(0, cfaces.size(), step):
				var cp := cfaces[i]
				var best := 1e30
				for j in range(vfaces.size()):
					var dd: float = cp.distance_squared_to(vfaces[j])
					if dd < best:
						best = dd
						if best < 1e-12:
							break
				checked += 1
				var dist: float = sqrt(best)
				if dist > worst:
					worst = dist
			L.append("   %d collision vertices sampled: worst distance to the nearest VISUAL vertex = %.6f m"
					% [checked, worst])
			L.append("   0 means they are literally the same points. Anything else is a real offset.")
	else:
		L.append("   (this build has no debug_visual_faces -- rebuild dc-terrain)")

	# --- D/E: the field the mesher saw, and how it moves with LOD ---
	L.append("")
	L.append("D. THE GPU DENSITY FIELD, at the chunk's own voxel size")
	var voxel := 1.0
	if terrain != null and terrain.has_method("debug_chunk_info"):
		var ci: Dictionary = terrain.call("debug_chunk_info", center)
		if not ci.is_empty():
			voxel = float(ci["voxel_size"])
	var fd := field_surface(terrain, center, n, spacing, voxel, up)
	if fd["n"] == 0:
		L.append("   %s" % fd["note"])
	else:
		var md := height_metrics(fd["heights"], int(fd["n"]), float(fd["spacing"]))
		var fhits: int = int(md["samples"]) - int(md["misses"])
		L.append("   column orientation: top of window %.2f (want < 0 = air), bottom %.2f (want > 0 = solid) -> %s"
				% [fd.get("orient_top", 0.0), fd.get("orient_bot", 0.0),
				   "OK" if bool(fd.get("orient_ok", false)) else "*** WRONG AXIS OR NO GROUND HERE ***"])
		L.append("   voxel %.3f m, %dx%d columns over a %.0f m vertical window: %d found a surface, %d did not"
				% [fd["spacing"], fd["n"], fd["n"], float(fd["spacing"]) * float(int(fd["n"]) - 1),
				   fhits, md["misses"]])
		if bool(md.get("undersampled", false)):
			L.append("   TOO FEW COLUMNS CROSSED ZERO TO CHARACTERISE THE FIELD (%d of %d)."
					% [fhits, md["samples"]])
			L.append("   That is itself the finding: the density this build evaluates has no")
			L.append("   surface near the ground the player is standing on. Check the offset below.")
		else:
			L.append("   relief %.3f m   plane slope %.2f deg   ROUGHNESS %.4f m   max step %.4f m"
					% [md["relief"], md.get("plane_slope_deg", 0.0), md["plane_rms"], md["max_step"]])
			L.append("   READ THIS AGAINST B: field smooth + B rough => the MESHER makes the bumps.")
			L.append("                        field rough            => the GENERATOR makes them.")
		# THE NUMBER THAT MATTERS: is the field where the collision is? The mesh
		# is built FROM this field, so anything but ~0 means the two halves of
		# the pipeline disagree about where the ground is.
		var fc0 := _centre_value(fd["heights"], int(fd["n"]))
		var rc0 := _centre_value(hb, n)
		if fc0 < MISS and rc0 < MISS:
			L.append("   FIELD vs COLLISION at the centre: field %.3f m, collision %.3f m -> OFFSET %+.3f m"
					% [fc0, rc0, fc0 - rc0])
		elif rc0 >= MISS:
			L.append("   FIELD vs COLLISION: no collision under the centre sample")
		else:
			L.append("   FIELD vs COLLISION: the field has no surface in the centre column at all")

		L.append("")
		L.append("E. THE SAME FIELD ONE LOD COARSER (the step at an LOD boundary)")
		var fc := field_surface(terrain, center, n, spacing, voxel * 2.0, up)
		if fc["n"] == 0:
			L.append("   %s" % fc["note"])
		else:
			var mc2 := height_metrics(fc["heights"], int(fc["n"]), float(fc["spacing"]))
			L.append("   voxel %.3f m: relief %.3f m  roughness %.4f m  max step %.4f m"
					% [fc["spacing"], mc2["relief"], mc2["plane_rms"], mc2["max_step"]])
			# Compare the two at their common centre column.
			var af := _centre_value(fd["heights"], int(fd["n"]))
			var ac := _centre_value(fc["heights"], int(fc["n"]))
			if af < MISS and ac < MISS:
				L.append("   surface at the centre: %.4f m at voxel %.2f vs %.4f m at voxel %.2f -> SHIFT %.4f m"
						% [af, fd["spacing"], ac, fc["spacing"], ac - af])
				L.append("   That shift is what a player walks into where those two LODs meet.")
	# --- G: holes. Chunks that are finished and empty where ground should be ---
	L.append("")
	L.append("G. HOLES — a coarse ring scan for ground that is not there")
	var holes := 0
	var probed := 0
	var hole_states := {}
	# INSIDE THE COLLISION RADIUS ONLY. Collision is built for chunks within
	# collision_distance of the camera (100 m by default) and for lod <=
	# collision_lod_max, so a ring at 256 m finds no ground BY DESIGN and
	# reporting that as a hole is the instrument inventing a defect -- it
	# reported 22 of 60 on a map with no visible holes at all.
	var col_dist := 100.0
	if terrain != null and "collision_distance" in terrain:
		col_dist = float(terrain.collision_distance)
	var radii := [col_dist * 0.15, col_dist * 0.3, col_dist * 0.5, col_dist * 0.75]
	L.append("   ring radii inside collision_distance %.0f m: %s" % [col_dist, str(radii)])
	for rad in radii:
		for k in range(12):
			var ang := TAU * float(k) / 12.0
			var pw := center + Vector3(cos(ang), 0.0, sin(ang)) * float(rad)
			probed += 1
			# Straight down from well above: 400 m covers the relief a ring of
			# this size can climb, so a miss is a MISSING SURFACE and not a ray
			# that started underground.
			var qq := PhysicsRayQueryParameters3D.create(pw + up * 400.0, pw - up * 400.0)
			qq.exclude = exclude
			qq.collide_with_areas = false
			var hh := space.intersect_ray(qq)
			if hh.has("position"):
				continue
			holes += 1
			# WHAT the streamer thinks is there. A leaf that is ACTIVE (state 4)
			# with zero triangles is a chunk that finished and produced nothing --
			# a hole the mesher believes in. PENDING/GENERATING is just streaming
			# and will fill in. No leaf at all is a third thing again.
			var ci2: Dictionary = {}
			if terrain != null and terrain.has_method("debug_chunk_info"):
				ci2 = terrain.call("debug_chunk_info", pw)
			# AT THE PLAYER'S ALTITUDE, which on a slope is AIR a hundred metres
			# away -- an air chunk is legitimately ACTIVE with zero triangles and
			# must not be read as "meshed to nothing". So walk the column and ask
			# whether ANY leaf above or below carries triangles.
			var column_has_geometry := false
			for dy in [-64.0, -32.0, -16.0, 0.0, 16.0, 32.0, 64.0]:
				var ck: Dictionary = {}
				if terrain != null and terrain.has_method("debug_chunk_info"):
					ck = terrain.call("debug_chunk_info", pw + up * dy)
				if not ck.is_empty() and int(ck["triangles"]) > 0:
					column_has_geometry = true
					break
			var key: String = "no leaf"
			if not ci2.is_empty():
				key = "lod%d state=%d tris=%d %s" % [ci2["lod"], ci2["state"], ci2["triangles"],
						("column has geometry elsewhere" if column_has_geometry
						 else "COLUMN EMPTY TOP TO BOTTOM")]
			hole_states[key] = int(hole_states.get(key, 0)) + 1
	L.append("   %d of %d ring samples (16-256 m) found NO ground under them" % [holes, probed])
	if holes > 0:
		L.append("   what the streamer says is there: %s" % str(hole_states))
		L.append("   state 4 = ACTIVE (finished). ACTIVE with tris=0 is a chunk that meshed to nothing.")
	else:
		L.append("   (this only sees holes a vertical ray can find; a hole beside you may need a wider ring)")

	# --- I: every collision body actually here, not just the ones I can name ---
	L.append("")
	L.append("I. THE COLLISION BODIES ACTUALLY OVERLAPPING YOU")
	L.append("   debug_collision_faces walks get_leaves(). A chunk that has SPLIT is no")
	L.append("   longer a leaf, but _on_mesh_free keeps a parent's collision body alive")
	L.append("   while its children mesh -- so a stale coarse surface can sit here and be")
	L.append("   invisible to section C. This asks the physics server instead.")
	var shape := SphereShape3D.new()
	shape.radius = 2.0
	var sq := PhysicsShapeQueryParameters3D.new()
	sq.shape = shape
	sq.transform = Transform3D(Basis(), center)
	sq.collide_with_areas = false
	sq.collide_with_bodies = true
	sq.exclude = exclude
	var hits: Array = space.intersect_shape(sq, 32)
	var bodies := {}
	for h in hits:
		var rid_s: String = str(h.get("rid", RID()))
		bodies[rid_s] = int(bodies.get(rid_s, 0)) + 1
	L.append("   %d shape overlaps within 2.0 m, across %d DISTINCT bodies"
			% [hits.size(), bodies.size()])
	if bodies.size() > 1:
		L.append("   *** MORE THAN ONE BODY OVERLAPS YOU. Terrain collision is one body per")
		L.append("   *** chunk, so overlapping bodies here means two surfaces at this spot.")

	# Is there terrain ABOVE you? On ground with no overhanging triangle there
	# should be nothing, and a hit is a stale surface by construction.
	var upq := PhysicsRayQueryParameters3D.create(center + up * 0.1, center + up * 6.0)
	upq.exclude = exclude
	upq.collide_with_areas = false
	var uphit := space.intersect_ray(upq)
	if uphit.has("position"):
		var uh: Vector3 = uphit["position"]
		var un: Vector3 = uphit.get("normal", Vector3.ZERO)
		L.append("   CEILING FOUND %.3f m above you, normal %.1f deg from up"
				% [(uh - center).dot(up), rad_to_deg(acos(clampf(un.dot(up), -1.0, 1.0)))])
		L.append("   *** On ground whose triangles are all shallow, a surface overhead is a")
		L.append("   *** collision body that should have been freed.")
	else:
		L.append("   nothing overhead within 6 m (so no stale surface directly above)")

	# --- H: how well did you move, with no definition of "badly" ---
	L.append("")
	L.append("H. HOW WELL YOU ACTUALLY MOVED (last 10 s, every frame, no threshold)")
	if player != null and player.has_method("motion_summary"):
		var mo: Dictionary = player.call("motion_summary", 10000)
		if int(mo["frames"]) == 0:
			L.append("   no frames with movement input in the window -- walk, THEN press")
		else:
			L.append("   TRAVEL EFFICIENCY %.1f%%  (%.2f m delivered of %.2f m asked, over %d frames)"
					% [100.0 * float(mo["efficiency"]), mo["got_total"], mo["want_total"], mo["frames"]])
			L.append("   per-frame delivery: p10 %.2f  median %.2f  p90 %.2f" % [mo["p10"], mo["p50"], mo["p90"]])
			L.append("        a low p10 with a healthy median is STUTTER; a uniformly low median is DRAG")
			L.append("   on the floor %.1f%% of frames, losing/regaining it %d times   <- flapping is bouncing"
					% [mo["on_floor_pct"], mo["floor_transitions"]])
			L.append("   mean contacts %.2f   vertical velocity range %.2f .. %.2f m/s   mean ground angle %.1f deg"
					% [mo["mean_contacts"], mo["min_vy"], mo["max_vy"], mo["mean_ground_deg"]])
			L.append("   THE CONTROLLER'S OWN TERMS: input %.2f  desired %.2f m/s  traction mean %.3f min %.3f"
					% [mo.get("mean_input", 0.0), mo.get("mean_desired", 0.0),
					   mo.get("mean_traction", 0.0), mo.get("min_traction", 0.0)])
			L.append("        surface_friction min %.2f   ski held on %d frames"
					% [mo.get("min_friction", 0.0), mo.get("ski_frames", 0)])
			L.append("        _update_movement did NOT run on %d frames (flipping on %d) —"
					% [mo.get("stale_frames", 0), mo.get("flip_frames", 0)])
			L.append("        on those, every term above is a STALE value from an earlier frame")
			L.append("        traction near 0 means the acceleration BUDGET is zero: the character")
			L.append("        cannot push, whatever the ground is doing. input 0 means no key was down.")
			L.append("   WITH A KEY ACTUALLY DOWN: %d frames, efficiency %.1f%%, mean contacts %.2f,"
					% [mo.get("trying_frames", 0), 100.0 * float(mo.get("trying_eff", 0.0)),
					   mo.get("trying_contacts", 0.0)])
			L.append("        deepest penetration %.4f m, steepest contact %.1f deg"
					% [mo.get("trying_depth", 0.0), mo.get("trying_worst_deg", -1.0)])
			L.append("        THIS is the number that separates being pinned from having let go:")
			L.append("        want comes from velocity, so a released key and a stuck character")
			L.append("        look identical without it.")
			var wf: Dictionary = mo.get("worst_frame", {})
			if not wf.is_empty():
				L.append("   THE WORST SINGLE FRAME (wanted %.4f m, got %.4f m, %d contacts):"
						% [wf.get("want", 0.0), wf.get("got", 0.0), wf.get("contacts", 0)])
				var gn: Vector3 = wf.get("gnormal", Vector3.UP)
				L.append("        ground_normal at that moment: %s" % str(gn))
				for ci in (wf.get("contact_info", []) as Array):
					var cn: Vector3 = ci.get("n", Vector3.ZERO)
					# A contact normal that is the NEGATIVE of the floor normal is
					# the same triangle hit from behind -- a backface, not a
					# surface. Distinguishing the two is the whole question: 163
					# to 168 degrees on 15 degree ground is 180 minus the slope,
					# which is what an inverted normal looks like and what a real
					# overhang does not.
					var inv: float = cn.dot(-gn)
					L.append("        %6.1f deg  depth %.5f m  at %s  n=%s  dot(-ground)=%+.3f%s  <- %s"
							% [ci.get("deg", -1.0), ci.get("depth", 0.0), str(ci.get("at", Vector3.ZERO)),
							   str(cn), inv, "  BACKFACE" if inv > 0.98 else "", ci.get("who", "?")])
				if (wf.get("contact_info", []) as Array).is_empty():
					L.append("        no contacts recorded on that frame — it was not collision")
			L.append("   COMPARE THIS NUMBER BETWEEN MAPS. It does not care whether the loss is")
			L.append("   stopping, stuttering, sliding or bouncing, which is why it replaced F.")
	else:
		L.append("   this player has no motion trace")

	# --- F: what actually stopped you, from the player's own recorder ---
	L.append("")
	L.append("F. FRAMES WHERE YOU ASKED TO MOVE AND DID NOT (last 10 s)")
	if player != null and player.has_method("block_summary"):
		var bs: Dictionary = player.call("block_summary", 10000)
		L.append("   %d blocked frames in the window (%d recorded in total)"
				% [bs["events_window"], bs["events_total"]])
		if int(bs["events_window"]) > 0:
			L.append("   mean progress %.1f%% of what was asked   full stops %d   blocked by a wall-angle face %d"
					% [100.0 * float(bs["mean_frac"]), bs["full_stops"], bs["wall_blocked"]])
			L.append("   steepest contact normal %.1f deg  (floor_max_angle is %.1f deg -- above it Godot calls it a WALL)"
					% [bs["worst_deg"], bs["floor_max_angle_deg"]])
			var last: Dictionary = bs["last"]
			L.append("   deepest penetration in the window: %.4f m  (a slope you cannot climb has ~0; being pushed out of geometry does not)"
					% float(bs.get("max_depth", 0.0)))
			L.append("   most recent: at %s  wanted %.3f m, got %.3f m, %d contacts, worst %.1f deg, depth %.4f m, on_floor=%s"
					% [last["pos"], last["want"], last["got"], last["collisions"], last["worst_deg"],
					   float(last.get("depth", 0.0)), last["on_floor"]])
			L.append("   touching: %s" % str(last.get("colliders", [])))
			if terrain != null and terrain.has_method("debug_chunk_info"):
				var li: Dictionary = terrain.call("debug_chunk_info", last["pos"])
				if not li.is_empty():
					L.append("   that spot was chunk lod=%d voxel=%.3f m collision=%s"
							% [li["lod"], li["voxel_size"], li["has_collision"]])
	elif player == null:
		L.append("   no local player (probing a camera position)")
	else:
		L.append("   this player script has no block recorder")

	L.append("")
	L.append("=== end ===")
	return "\n".join(PackedStringArray(L))


static func _centre_value(h: PackedFloat32Array, n: int) -> float:
	if n <= 0 or h.size() < n * n:
		return MISS
	var m := n / 2
	return h[m * n + m]


## Write the report where it will survive: print() is dropped by dev-run's
## pipeline (hyper-games #51 section 2.2) and Godot rotates its own logs after
## five runs, so the file is the record and the print is only a pointer to it.
static func write_report(text: String) -> String:
	var dir := "user://probe"
	DirAccess.make_dir_recursive_absolute(dir)
	var path := "%s/terrain_probe_%s.txt" % [dir, Time.get_datetime_string_from_system().replace(":", "-")]
	var f := FileAccess.open(path, FileAccess.WRITE)
	if f == null:
		push_warning("[TerrainProbe] cannot write %s" % path)
		return ""
	f.store_string(text)
	f.flush()
	f.close()
	return ProjectSettings.globalize_path(path)