OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / map_core.gd

917 lines · gd ref:
extends Node3D
## Base map script — adds grapple collision layer to all geometry.
## Matches old hyper-tps_demo/game/maps/map_core.gd behavior.

func _ready() -> void:
	_add_grapple_collision_layer(self)
	_publish_required_assets()
	# DEFERRED, deliberately. Subclasses call super._ready() FIRST and configure
	# their terrain afterwards -- map_dc_erosion assigns its generator in
	# _setup_terrain() -- so binding here and now would capture a DCTerrain whose
	# generator is still null and build a panel for the wrong object.
	_register_terrain_panel.call_deferred()
	_scale_atmosphere.call_deferred()
	# `hyper-tps --debug` -> game arg --debug-collisions. The engine's own flag of
	# that name only exists in DEBUG_ENABLED builds and the shipped engine is
	# template_release, so it reaches us as a user arg and turns on Ctrl+F7's
	# overlay instead (gate_debug_collisions_arg).
	if "--debug-collisions" in OS.get_cmdline_user_args():
		_set_collision_overlay.call_deferred(true)


## EXTERNAL FILES THIS MAP NEEDS, declared once and synced for every peer.
##
## Same argument as the terrain panel below: nothing about asset syncing is
## map-specific, it just was not in the base class. Today map_dc_level.gd and
## map_dc_terrain.gd each hold their OWN copy of the same five absolute paths
## into /storage/projects/gamedev/quake/, guarded by file_exists() checks that
## degrade SILENTLY — a level renders untextured or albedo-only on every machine
## that is not the authoring one, with no error anywhere. The bytes could always
## have moved; nobody published them.
##
## Override and return [{"kind": String, "name": String, "path": String}] where
## `path` is where the AUTHORING machine keeps it. A map that declares nothing
## behaves exactly as it does today.
func required_assets() -> Array:
	return []


## Offer whatever of them this machine actually holds. Cheap and idempotent:
## hashing is a streaming read and serving hands out a locator, not bytes, so a
## machine with none of them does nothing and one with all of them spends a few
## hashes. Peers that lack a file resolve it through asset_path() instead.
func _publish_required_assets() -> void:
	var sync := get_node_or_null(^"/root/AssetSync")
	if sync == null or not sync.is_ready():
		return
	for entry in required_assets():
		if typeof(entry) != TYPE_DICTIONARY:
			continue
		var path := String(entry.get("path", ""))
		if path == "" or not FileAccess.file_exists(path):
			continue
		sync.publish_file(String(entry.get("kind", "misc")),
				String(entry.get("name", path.get_file())), path)


## Resolve a declared asset to a usable local path — this machine's own copy if
## it has one, otherwise the room's copy over the mesh, otherwise "".
##
## "" means NOT HERE YET, not failure: the fetch stays open and re-sends to every
## peer that joins later. Callers should re-resolve on AssetSync.asset_ready
## rather than treating the first miss as absence.
func asset_path(kind: String, name: String, local_fallback: String = "") -> String:
	var sync := get_node_or_null(^"/root/AssetSync")
	if sync == null:
		return local_fallback if (local_fallback != "" and FileAccess.file_exists(local_fallback)) else ""
	return String(sync.resolve_path(kind, name, local_fallback))


## The terrain panel is UNIVERSAL: any map with a DCTerrain gets it.
##
## It used to live in map_dc_world.gd's own _register_terrain_panel(), which is
## why no other map had one -- the feature was written for a single map and every
## other map would have had to copy the function. Nothing about it was
## map-specific; it just was not in the base class.
func _register_terrain_panel() -> void:
	var terrain := _find_terrain(self)
	if terrain == null:
		return                                  # headless, or a map with no terrain
	var TerrainPanel := load("res://scenes/game/terrain_panel.gd")
	if TerrainPanel == null:
		push_error("[MapCore] terrain_panel.gd failed to load — no Terrain panel")
		return
	# Looked up as a NODE, not by the BuildDock autoload identifier. This is the
	# base class for every map, so it gets compiled in contexts where autoload
	# singletons are not in scope -- a bare `"$HYPER_TPS_GODOT" --script` SceneTree run is
	# one, and it fails at COMPILE time there, taking every map down with it. A
	# node lookup degrades to "no panel" instead.
	var dock := get_node_or_null("/root/BuildDock")
	if dock == null or not dock.has_method("register"):
		return
	var panel := VBoxContainer.new()
	panel.set_script(TerrainPanel)
	panel.setup(terrain)
	dock.register("terrain", "Terrain Noise", panel, true)


## F6 — wireframe + polycount, on EVERY map.
##
## This lived in map_dc_terrain.gd's own _unhandled_input, which is why it did
## nothing on any other map: the feature was written for one map and every other
## would have had to copy it. Same defect the terrain panel had, same fix.
##
## It is the one view that separates "the geometry is wrong" from "the shading is
## wrong" — two failures that look alike on screen and need opposite work.
## CSG edit type and size, shared by every map with a DCTerrain.
## Order and defaults are map_dc_terrain.gd's, unchanged.
## Collision hull overlay (Ctrl+F7).
const COLLISION_OVERLAY_RADIUS := 14.0
const COLLISION_OVERLAY_HZ := 4.0
var _collision_overlay: MeshInstance3D = null
var _collision_overlay_on := false
var _collision_timer: Timer = null
var _collision_overlay_dbg := false
var _collision_overlay_dbg2 := false

const CSG_TYPES := ["box", "smooth sphere", "hard sphere", "voxelize brush"]
const CSG_ACTIONS: Array[String] = ["player.csg_box", "player.csg_sphere",
		"player.csg_hard", "player.csg_voxelize"]
var _csg_type := 2
var _csg_radius := 3.0
## Q toggles it. Was Shift+RMB, and Shift is sprint (key audit 2026-10-01).
var _carve_add := false


func _unhandled_input(event: InputEvent) -> void:
	# TYPE AND SIZE. 1-4 pick the shape, Shift+wheel sizes it -- the wheel
	# binding is map_dc_terrain.gd's, so it is one gesture everywhere rather
	# than a second way to do the same thing.
	for i in range(CSG_ACTIONS.size()):
		var act: String = CSG_ACTIONS[i]
		if Keymap.tapped(event, act):
			# REFUSE A TYPE THIS MAP CANNOT DO, AT SELECTION TIME.
			#
			# Voxelize needs a DCLevel. On a terrain-only map the first version
			# accepted the type and then printed "no DCLevel" once per click, so
			# right-click silently stopped working and the honest report of that
			# was "the number keys broke CSG". A type that cannot act here is not
			# a type here.
			if i == 3 and _find_level(self) == null:
				print("[MapCore] CSG type: voxelize brush is not available on this map (no DCLevel) — staying on %s"
						% CSG_TYPES[_csg_type])
				return
			_csg_type = i
			print("[MapCore] carver brush: %s   r=%.1f m   %s   (LMB fire, Q remove/add, - = size)"
					% [CSG_TYPES[i], _csg_radius, "ADD" if _carve_add else "REMOVE"])
			return
	# SIZE AND MODE: `-` / `=` and Q, plain keys. They were Shift+wheel and
	# Shift+RMB, and Shift is sprint -- scrolling while running resized the brush
	# instead of zooming, and carving while running ADDED instead of removing.
	if Keymap.tapped(event, "player.carve_smaller") or Keymap.tapped(event, "player.carve_bigger"):
		var step := 0.5 if Keymap.tapped(event, "player.carve_bigger") else -0.5
		_csg_radius = clampf(_csg_radius + step, 1.0, 20.0)
		print("[MapCore] carver %s r=%.1f m" % [CSG_TYPES[_csg_type], _csg_radius])
		get_viewport().set_input_as_handled()
		return
	if Keymap.tapped(event, "player.carve_mode"):
		_carve_add = not _carve_add
		print("[MapCore] carver mode: %s" % ("ADD" if _carve_add else "REMOVE"))
		get_viewport().set_input_as_handled()
		return

	# RIGHT CLICK WHILE PLAYING — CARVE THE TERRAIN. A MAP WITH A DCTerrain IS
	# ALWAYS EDITABLE, and this is the button that has always done it.
	#
	# The binding is map_dc_terrain.gd's, unchanged: RMB with the mouse
	# CAPTURED (i.e. playing), shift to add, plain to remove. It lived only in
	# that map's own input handler, so on map_dc_erosion and map_dc_diffusion
	# nothing edited the terrain at all -- which is why a carve there did
	# nothing and why the probe reported `terrain holds 0 edit(s)`.
	#
	# The captured-mouse test is what keeps it clear of the other two RMB
	# meanings: build mode holds RMB to look (game_mode.gd, cursor visible), and
	# map_dc_level cancels an active operator with it (also cursor visible).
	#
	# This is an invariant, not a per-map option. The first version had an
	# opt-out flag for maps with their own edit input, which reintroduces the
	# very condition being fixed -- editability as something each map has to
	# remember to provide. It is the third time this file has fixed that shape
	# of defect; the F6 wireframe and the terrain panel were the other two.
	#
	# LEFT CLICK NOW (key audit 2026-10-01): the carver is a weapon -- `player.fire`
	# -- and the right button is the grapple. `player` is suppressed in build mode,
	# where LMB selects and RMB-hold looks, so the captured-mouse test is now a
	# belt-and-braces check rather than the only thing keeping them apart.
	if Keymap.tapped(event, "player.fire") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
		_carve_terrain(_carve_add)
		return

	if not (event is InputEventKey) or not event.pressed or event.echo:
		return

	# Ctrl+F2 — TELEPORT 100 km AND BACK. The A/B that decides whether distance
	# from the origin is the problem, run in the GAME rather than in a harness.
	#
	# Three synthetic gates said coordinate magnitude is fine -- a capsule on a
	# trimesh delivers 100% of its motion at 100 km, and the static contact
	# normal is exact there. But all three simplified the thing they modelled:
	# one body with absolute-coordinate vertices, instead of dozens of chunk
	# bodies with small local vertices and a transform far out, inside a
	# broadphase spanning 917 km. The last of them returned an identical number
	# for every input, which is a broken ruler rather than a result.
	#
	# So: same map, same generator, same mesh, same everything that works today
	# -- move only the coordinates. Erosion walks at 97.2% near the origin. If it
	# collapses out here, position is the cause and the diffusion terrain never
	# was. If it holds, position is exonerated in-game with everything else held
	# constant, and I can stop proposing it.
	#
	# Ctrl+F2 because Ctrl+F6 is player.climb_debug and Ctrl+F8 is
	# player.camera_unstick; F2 carries nothing in this context.
	if Keymap.tapped(event, "debug.teleport_far"):
		_teleport_far()
		return

	# Ctrl+F5 — TERRAIN GROUND PROBE, on EVERY map, for the same reason F6 is
	# here rather than in one map's script.
	#
	# It answers "why can I not walk here" with numbers taken from the physics
	# bodies the player is actually colliding with and from the GPU density the
	# mesher actually saw -- not from sample_density(), which on a heightfield
	# map evaluates a DIFFERENT WORLD (the CPU generator never samples the baked
	# block; map_dc_diffusion prints the gap at every spawn).
	#
	# Ctrl+, not bare F5: map_dc_terrain.gd matches bare F6/F7/F8/F9 keycodes
	# without checking modifiers, and a bare key here would fire both handlers
	# on that map.
	# Ctrl+Shift+F5 — SETTINGS DUMP (SettingsDump): what this map's look has been
	# tuned to at runtime, against its scene file, plus the sky panel and what the
	# room saved. A developer tool: it only WRITES A FILE; turning it into new
	# defaults is a source change. Checked BEFORE Ctrl+F5, which does not look at
	# shift and would otherwise take this key too.
	if Keymap.tapped(event, "debug.settings_dump"):
		# By path, like the probe below: a class_name resolves only once the editor
		# has rescanned the global class cache, and this must work before that.
		var dump_path: String = load("res://scenes/game/settings_dump.gd").write(self)
		print("[SettingsDump] wrote %s" % dump_path if dump_path != "" else "[SettingsDump] FAILED to write")
		return
	if Keymap.tapped(event, "debug.terrain_probe"):
		_run_terrain_probe()
		return

	# Ctrl+Shift+F8 (was Ctrl+F8, which is also player.camera_unstick) — SEE THE CSG EDITS. DCTerrain has had a debug_edits overlay all
	# along (boxes as wireframe, spheres as a 3-axis cross, red = subtract,
	# green = add, drawn through the terrain), and nothing was ever bound to it.
	# It also prints the edit COUNT, which is the first thing to know when a
	# carve appears to do nothing: zero edits is a game-side failure, and a
	# nonzero count with no visible change is a shader-side one.
	#
	# NOTE it draws SPHERE and BOX only -- MESH and CONVEX edits (the level
	# shell subtract, imported brushes) are in the list but invisible here.
	if Keymap.tapped(event, "debug.csg_edits"):
		var te := _find_terrain(self)
		if te != null and "debug_edits" in te:
			te.debug_edits = not te.debug_edits
			var n: int = te.call("get_edit_count") if te.has_method("get_edit_count") else -1
			print("[MapCore] debug_edits=%s — terrain holds %d edit(s)" % [te.debug_edits, n])
		else:
			print("[MapCore] no DCTerrain with debug_edits on this map")
		return

	# Ctrl+F7 — DRAW THE COLLISION HULL.
	#
	# Everything the player actually fights is in the physics world, and until
	# now the only way to look at it was to walk into it. The visual mesh and the
	# collision mesh are supposed to be the same triangles (C2 in the probe
	# measures them as identical points) -- so anything you can SEE here that is
	# not where the terrain looks like it is, is the bug.
	#
	# Green = a face you can stand on, red = one facing away from up. The colour
	# is computed with Godot's own winding convention, measured rather than
	# assumed: trimesh_winding_gate.gd drops the shipping capsule on a floor of
	# each winding and reports which one collides. A floor's (b-a)x(c-a) points
	# DOWN.
	if Keymap.tapped(event, "debug.collision_hull"):
		_set_collision_overlay(not _collision_overlay_on)
		return

	# Ctrl+F9 — LOD colours, also on every map. Same story: it existed only in
	# map_dc_terrain.gd, and it is the one view that shows where the LOD rings
	# fall relative to where you are standing.
	if Keymap.tapped(event, "debug.lod_colors"):
		var t := _find_terrain(self)
		if t != null and "debug_lod_colors" in t:
			t.debug_lod_colors = not t.debug_lod_colors
			print("[MapCore] debug_lod_colors=%s" % t.debug_lod_colors)
		return

	if not Keymap.tapped(event, "debug.wireframe"):
		return
	RenderingServer.set_debug_generate_wireframes(true)
	var vp := get_viewport()
	vp.debug_draw = (Viewport.DEBUG_DRAW_DISABLED
			if vp.debug_draw == Viewport.DEBUG_DRAW_WIREFRAME
			else Viewport.DEBUG_DRAW_WIREFRAME)
	var on: bool = vp.debug_draw == Viewport.DEBUG_DRAW_WIREFRAME
	var tris := RenderingServer.get_rendering_info(
			RenderingServer.RENDERING_INFO_TOTAL_PRIMITIVES_IN_FRAME)
	var terrain := _find_terrain(self)
	var chunks: int = terrain.call("get_active_chunk_count") if (terrain != null
			and terrain.has_method("get_active_chunk_count")) else -1
	print("[MapCore] wireframe=%s  frame-triangles=%d  active_chunks=%d  map=%s"
			% [on, tris, chunks, get_scene_file_path().get_file()])


## First DCTerrain in the map, by capability rather than class name so this is
## safe when the extension is not loaded.
func _find_terrain(node: Node) -> Node:
	if node.has_method("sample_density") and node.has_method("get_chunk_count"):
		return node
	for child in node.get_children():
		var found := _find_terrain(child)
		if found != null:
			return found
	return null


func _add_grapple_collision_layer(node: Node) -> void:
	if node is StaticBody3D:
		# Add layer 2 (grapple hook) to existing collision layers
		node.collision_layer |= 2
	for child in node.get_children():
		_add_grapple_collision_layer(child)


## SCALE THE ATMOSPHERE TO THE MAP, not to a constant.
##
## AtmFog.gdshader normalises distance as (fog_end - d) / (fog_end - fog_start)
## with fog_end defaulting to 1000 m, and fog_density 0.0007 per metre. Both
## assume "far" is about a kilometre. A depth-16 DC clipmap is 917 km across and
## you look 100-450 km, so every distant surface sits far outside that domain:
## the gradient is saturated everywhere, there is no aerial perspective at all,
## and the world ends in a hard straight horizon line with nothing between you
## and it. On a FLAT world there is no curvature to hide that edge either.
##
## Derived from the map's own extent rather than hardcoded, so small maps keep
## their kilometre-scale fog and are not washed out, and a map with no terrain
## restores the addon defaults instead of inheriting whatever the last map set.
const _SKY_PATH := "/root/Game/Sky3DExtended/SkyDome"

## THE DISTANCE THE ATMOSPHERE IS SCALED TO, in metres. 0 = the terrain's whole
## clipmap extent — right for map_dc_erosion's 917 km, far too large for a BOUNDED
## map: the mountain's clipmap is ~115 km, which put its fog at 0.5..14 km around a
## 12 km island, so fog never reached anything you look at. A bounded map sets this
## to the distance it is viewed across; every fog/scattering distance below follows.
@export var atmosphere_extent: float = 0.0
## Altitude (m) where the layered fog's HEIGHT term is densest — the map's valley
## floor; it thins upward from here (SkyDome.fog_height_falloff). Mountain: 150.
@export var fog_height_base: float = 0.0
## AUTHORED SKY DEFAULTS for this map: SkyDome property -> value, over the computed
## ones (_scale_atmosphere). How a look tuned in the Atmosphere panel becomes the map's
## default for every room: Ctrl+Shift+F5 dumps the panel, its values land here. A room
## that saved its own still wins (SkyDefaults).
@export var sky_defaults: Dictionary = {}
const _FOG_DEFAULT_END := 1000.0
const _FOG_DEFAULT_DENSITY := 0.0007
const _FOG_DEFAULT_START := 0.0

func _scale_atmosphere() -> void:
	var sky := get_node_or_null(_SKY_PATH)
	if sky == null:
		return

	var terrain := _find_terrain(self)
	if terrain == null:
		# No terrain: hand the addon's own defaults back, so this does not leak
		# a previous map's 300 km fog into a room-sized level.
		sky.set("fog_end", _FOG_DEFAULT_END)
		sky.set("fog_density", _FOG_DEFAULT_DENSITY)
		sky.set("fog_start", _FOG_DEFAULT_START)
		return

	# Root span = voxels_per_cell * 2^(depth-1) * voxel_size. 28, not 32: the
	# nominal 32 minus 2*overlap(2), per dc_terrain.cpp:281. Using 32 overstates
	# the world by 14%.
	var extent: float = atmosphere_extent
	if extent <= 0.0:
		var depth: int = int(terrain.get("octree_depth"))
		var voxel: float = float(terrain.get("voxel_size"))
		if depth <= 0 or voxel <= 0.0:
			return
		extent = float(1 << (depth - 1)) * 28.0 * voxel

	# Saturate BEFORE the world edge so the boundary is hidden rather than
	# silhouetted, and keep total optical depth across that range near 2 so the
	# far field reads as distance instead of as a white wall.
	# 0.12, not 0.35. AtmFog MULTIPLIES two distance terms -- an exponential
	# (calc_fog_exp) and a LINEAR ramp to fog_end (calc_fog_distance) -- so fog
	# only becomes visible where BOTH are substantial, i.e. well into the second
	# half of the ramp. At 0.35 (321 km) that put 50 km at 0.194 * 0.146 = 2.8%
	# fog: nothing. At 0.12 (110 km) the same 50 km gives 0.468 * 0.436 = 20%,
	# and 100 km gives 65%, which is a gradient you can actually see across the
	# range mountains are viewed at here.
	var fog_end: float = extent * 0.12
	var want := {
		"fog_end": fog_end,
		"fog_density": 2.0 / maxf(fog_end, 1.0),
		"fog_start": extent * 0.004,
		"fog_rayleigh_depth": 1.0 / maxf(extent * 0.05, 1.0),
		"fog_mie_depth": 1.0 / maxf(extent * 0.40, 1.0),
	}
	# LAYERED FOG (AtmFog fog_model 1, 2026-10-06): sized to the distance the map is
	# PLAYED at, not the clipmap's root span (Geiss: 459 km, so the old curve gave
	# < 1 % fog at 5 km — no depth cue anywhere). atmosphere_extent when the map sets
	# one (mountain 50 km -> far end 14 km); otherwise a 20 km view.
	var view_far: float = clampf(atmosphere_extent * 0.28, 4000.0, 60000.0) if atmosphere_extent > 0.0 else 20000.0
	# Ratios from A/B renders of the mountain from its spawn (14 km view): far 0.8-9 km,
	# near 6e-5 /m, height 6e-4 from 150 m read as near ridge crisp / middle distance
	# hazed / far ranges washed toward the sky.
	want["fog_far_end"] = view_far * 0.65
	want["fog_far_begin"] = view_far * 0.06
	want["fog_near_density"] = 1.0 / (view_far * 1.2)
	want["fog_height_base"] = fog_height_base
	want["fog_height_density"] = 0.0006
	for k: Variant in sky_defaults:
		want[String(k)] = sky_defaults[k]      # the map's authored look over the computed one
	# SAVED WINS (SkyDefaults, hyper-games #42 P1): a fog value this map's room has
	# saved from the Atmosphere panel is the author's, so the computed default only
	# fills the ones nobody has set. Without this, every map load overwrote the
	# sliders and a tuned fog never survived a reload.
	var saved: Array[String] = []
	if GameState:
		saved = GameState.keys_with_prefix(SkyDefaults.PREFIX)
	var props: Array[String] = []
	for k: String in want:
		props.append(k)
	var apply: Array[String] = SkyDefaults.unsaved(props, saved)
	for p: String in apply:
		sky.set(p, want[p])

	# THE TWO SCATTERING MEDIA, which is what makes aerial perspective work at
	# all. AtmFog tints by clamp(linear_depth * fog_*_depth) with depth in
	# METRES, so each value is 1/(saturation distance). The addon defaults put
	# Rayleigh at 8.7 m and Mie at 10 km -- fine for a kilometre-scale world,
	# and in a 917 km one it means every surface past nine metres receives an
	# IDENTICAL tint. That is the "fade towards a single colour" that
	# runevision's atmospheric-perspective notes identify as unable to represent
	# great distance, and it is why the far field read as a flat wash.
	#
	# Rayleigh saturates in the mid distance (blue), Mie much later (pale),
	# which is the blue-then-paler progression he observed and the two-medium
	# model his commenter describes: Rayleigh above, Mie/aerosol near the ground
	# in-scattering sky light into the path from the mountain to the eye.
	print("[MapCore] atmosphere scaled to %.1f km (%s): fog %.2f..%.1f km, " % [
			extent / 1000.0, "atmosphere_extent" if atmosphere_extent > 0.0 else "terrain extent",
			extent * 0.004 / 1000.0, fog_end / 1000.0] +
			"rayleigh saturates %.1f km, mie %.1f km — applied %d/%d (rest saved by the room)" % [
			extent * 0.05 / 1000.0, extent * 0.40 / 1000.0, apply.size(), want.size()])


## Ctrl+F5 — run the ground probe at the player (or the camera if there is no
## player) and write the report to a file. The path is printed; the FILE is the
## record, because print() does not survive dev-run's pipeline.
func _run_terrain_probe() -> void:
	var Probe := load("res://scenes/test/terrain_probe.gd")
	if Probe == null:
		push_error("[MapCore] terrain_probe.gd failed to load")
		return
	var world := get_world_3d()
	if world == null:
		push_error("[MapCore] no World3D — cannot probe")
		return
	var space := world.direct_space_state
	if space == null:
		push_error("[MapCore] no space state — cannot probe")
		return

	# The player if there is one, else the camera: the probe is about the ground
	# under the thing that is having trouble.
	var at := Vector3.ZERO
	var up := Vector3.UP
	var exclude: Array = []
	# The LOCAL player, found the way the rest of the game finds it: the map is
	# not its parent (game.gd parents players under Game/Players), so a child
	# lookup here returns null on every map and the probe would silently measure
	# the camera instead -- reporting the ground under a free-flying viewpoint as
	# if it were the ground under the character.
	var player: Node = null
	for p in get_tree().get_nodes_in_group("player"):
		if p is Node3D and (p as Node).is_multiplayer_authority():
			player = p
			break
	if player is Node3D:
		at = (player as Node3D).global_position
		if player is CharacterBody3D:
			up = (player as CharacterBody3D).up_direction
			exclude = [(player as CharacterBody3D).get_rid()]
	else:
		var cam := get_viewport().get_camera_3d()
		if cam != null:
			at = cam.global_position
		else:
			push_warning("[MapCore] no player and no camera — probing the origin")

	var text: String = Probe.run(_find_terrain(self), space, at, up, exclude, 8.0, 0.25, player)
	var path: String = Probe.write_report(text)
	# The RAW trace beside the report. A summary hides shapes -- a stutter and a
	# drag can share an average -- and the per-frame rows are what make the two
	# distinguishable without another session of walking around.
	if player != null and player.has_method("motion_trace_csv"):
		var csv: String = player.call("motion_trace_csv", 15000)
		var cf := FileAccess.open(path.get_base_dir() + "/" + path.get_file().get_basename() + ".csv",
				FileAccess.WRITE)
		if cf != null:
			cf.store_string(csv)
			cf.flush()
			cf.close()
	print(text)
	print("[TerrainProbe] report written to %s" % path)


## The universal carve. Raycast the crosshair, apply a sphere, and SAY what
## happened -- a carve that hits nothing, or lands on something that is not the
## terrain, is otherwise indistinguishable from a broken shader, which is
## exactly the confusion this cost.
func _carve_terrain(additive: bool) -> void:
	var terrain := _find_terrain(self)
	if terrain == null:
		print("[MapCore] carve: this map has no DCTerrain")
		return
	var cam := get_viewport().get_camera_3d()
	if cam == null:
		print("[MapCore] carve: no camera")
		return
	var mid := get_viewport().get_visible_rect().size * 0.5
	var from := cam.project_ray_origin(mid)
	var dir := cam.project_ray_normal(mid)
	var space := get_world_3d().direct_space_state
	var q := PhysicsRayQueryParameters3D.create(from, from + dir * 200.0)
	var exclude: Array = []
	for pl in get_tree().get_nodes_in_group("player"):
		if pl is CollisionObject3D and (pl as Node).is_multiplayer_authority():
			exclude.append((pl as CollisionObject3D).get_rid())
	q.exclude = exclude
	var hit := space.intersect_ray(q)
	if hit.is_empty():
		print("[MapCore] carve: nothing under the crosshair within 200 m")
		return

	var pos: Vector3 = hit["position"]
	var who: Object = hit.get("collider")
	# The terrain's chunks are bare PhysicsServer bodies with no node, so a hit
	# on them reports a null collider. That is the NORMAL case here and must not
	# read as a failure.
	var who_name: String = "terrain (bodiless chunk)" if who == null else str(who)
	var radius := _csg_radius
	var strength := 3.0 if additive else -3.0
	var before: int = terrain.call("get_edit_count") if terrain.has_method("get_edit_count") else -1

	var mgr := get_tree().get_first_node_in_group("terrain_edit_manager")
	var route := "direct"
	match _csg_type:
		3:
			if not _voxelize_brush_at(terrain, from, dir):
				return
			route = "convex stamp (NOT durable — TerrainEditManager has no convex route)"
		0:
			var half := Vector3(radius, radius, radius)
			if mgr != null and mgr.has_method("apply_edit"):
				mgr.apply_edit(pos, half, strength)
				route = "TerrainEditManager (durable)"
			elif terrain.has_method("edit_terrain_box"):
				terrain.call("edit_terrain_box", pos, half, strength)
			else:
				print("[MapCore] carve: DCTerrain has no edit_terrain_box")
				return
		_:
			# 1 = smooth sphere (op 0, the metaball blend), 2 = hard sphere
			# (op 1, a sharp SDF union/difference).
			var op := 0 if _csg_type == 1 else 1
			if mgr != null and mgr.has_method("apply_sphere"):
				mgr.apply_sphere(pos, radius, strength, op)
				route = "TerrainEditManager (durable)"
			elif terrain.has_method("edit_terrain"):
				terrain.call("edit_terrain", pos, radius, strength, op)
			else:
				print("[MapCore] carve: DCTerrain has no edit_terrain method")
				return
	var after: int = terrain.call("get_edit_count") if terrain.has_method("get_edit_count") else -1
	print("[MapCore] %s %s r=%.1f at %s via %s — hit %s — edits %d -> %d"
			% [CSG_TYPES[_csg_type], "ADD" if additive else "SUBTRACT", radius,
			   str(pos), route, who_name, before, after])
	if before == after:
		push_warning("[MapCore] carve did not change the edit count (%d). The edit never reached DCTerrain." % after)


## VOXELIZE THE BRUSH UNDER THE CROSSHAIR — an exact level brush becomes part of
## the terrain's SDF, and stops being a brush.
##
## The pieces all existed: DCLevel.raycast finds the brush, get_brush returns its
## planes with the centre and bounding radius "in the shape edit_terrain_convex
## wants", and map_dc_terrain has stamped whole Quake levels through that call
## for months. What was missing was a way to ask for one brush at a time.
##
## DURABLE ON BOTH SIDES. The terrain stamp goes through TerrainEditManager's
## convex route into world:terrain:edit:*, and the brush's removal through
## LevelPersistence as a `brush.remove` record -- the same one the delete key
## writes. Either half missing is reported rather than assumed.
##
## get_brush omits centre and radius entirely when a brush has no valid bounds,
## and the comment there records what a default costs: map_dc_world's wall feed
## read radius with a 0.0 fallback, silently skipped all 592 brushes, while its
## gate used 1.0 and reported 592/592. So a missing bound is refused here, never
## defaulted.
func _voxelize_brush_at(terrain: Node, from: Vector3, dir: Vector3) -> bool:
	var level := _find_level(self)
	if level == null:
		print("[MapCore] voxelize: this map has no DCLevel — nothing to voxelize")
		return false
	var hit: Dictionary = level.call("raycast", from, from + dir * 200.0, false)
	if hit.is_empty():
		print("[MapCore] voxelize: no brush under the crosshair")
		return false
	var bid: int = int(hit["brush"])
	var b: Dictionary = level.call("get_brush", bid)
	if b.is_empty():
		print("[MapCore] voxelize: brush %d could not be read" % bid)
		return false
	if not (b.has("center") and b.has("radius")):
		push_warning("[MapCore] voxelize: brush %d has no valid bounds — refusing rather than defaulting" % bid)
		return false
	var planes: PackedFloat32Array = b["planes"]
	if planes.is_empty():
		print("[MapCore] voxelize: brush %d has no planes" % bid)
		return false
	# The brush's durable name, read BEFORE it is removed.
	var bkey: String = ""
	if level.has_method("get_brush_key"):
		bkey = str(level.call("get_brush_key", bid))

	# TERRAIN FIRST, BRUSH SECOND, and the order is the whole safety argument.
	#
	# This one action spans two journals: the convex edit lands in
	# world:terrain:edit:* via TerrainEditManager, and the brush's removal lands
	# in world:level:<author>:op:<seq> via LevelPersistence. They are replayed
	# independently, so a replay CAN land one and not the other.
	#
	# Adding the terrain first means the worst case is geometry that exists
	# twice -- visible, and fixable by deleting one. The other order's worst
	# case is a brush that is gone with nothing where it was: an invisible hole
	# you fall through, discovered by falling through it.
	#
	# POSITIVE strength: the brush is solid, so it ADDS material. Same sign
	# map_dc_terrain stamps a whole level with.
	var mgr := get_tree().get_first_node_in_group("terrain_edit_manager")
	var durable := false
	if mgr != null and mgr.has_method("apply_convex"):
		mgr.apply_convex(planes, b["center"], float(b["radius"]), 1.9,
				int(b.get("material_id", 0)), b.get("proj", PackedInt32Array()), bkey)
		durable = true
	else:
		terrain.call("edit_terrain_convex", planes, b["center"], float(b["radius"]),
				1.9, true, int(b.get("material_id", 0)), b.get("proj", PackedInt32Array()))

	if not level.call("remove_brush", bid):
		push_warning("[MapCore] voxelize: the terrain stamp landed but brush %d could not be removed — the geometry now exists twice" % bid)
		return true
	if level.has_method("rebuild"):
		level.call("rebuild")
	# The level half, through the same record kind the delete key already uses.
	var persist = get("_persist")
	var level_durable := false
	if persist != null and persist.has_method("record") and bkey != "":
		persist.record("brush.remove", {"brush_key": bkey})
		level_durable = true

	print("[MapCore] voxelized brush %d (%d faces, r=%.2f) into the terrain — terrain %s, brush removal %s"
			% [bid, planes.size() / 4, float(b["radius"]),
			   "durable" if durable else "LOCAL ONLY",
			   "durable" if level_durable else "LOCAL ONLY"])
	if not (durable and level_durable):
		push_warning("[MapCore] voxelize was not fully durable — it will not survive a reload intact")
	return true


## First DCLevel in the map, by capability rather than class name, matching
## _find_terrain's reasoning.
func _find_level(node: Node) -> Node:
	if node.has_method("get_brush") and node.has_method("raycast"):
		return node
	for child in node.get_children():
		var found := _find_level(child)
		if found != null:
			return found
	return null


## A TIMER, NOT _process.
##
## map_dc_diffusion calls set_process(false) as soon as its startup oracle
## finishes, so nothing in _process runs on that map at all -- with or without a
## super call. An overlay that depends on the base class being processed is an
## overlay that silently stops on exactly the map being debugged. A Timer is
## owned by this node and does not care.
func _overlay_timer_tick() -> void:
	if _collision_overlay_on:
		_update_collision_overlay()


## Rebuild the wireframe from the collision shapes the physics server holds.
##
## Read back through debug_collision_faces, so what is drawn is what the solver
## has -- not a re-derivation of what it ought to have. Depth test is disabled
## so a surface hidden INSIDE the terrain still shows, which is the case that
## cannot be seen any other way.
func _update_collision_overlay() -> void:
	var terrain := _find_terrain(self)
	if terrain == null or not terrain.has_method("debug_collision_faces"):
		return
	var at := Vector3.ZERO
	for p in get_tree().get_nodes_in_group("player"):
		if p is Node3D and (p as Node).is_multiplayer_authority():
			at = (p as Node3D).global_position
			break
	if at == Vector3.ZERO:
		var cam := get_viewport().get_camera_3d()
		if cam != null:
			at = cam.global_position

	var faces: PackedVector3Array = terrain.call("debug_collision_faces", at, COLLISION_OVERLAY_RADIUS)
	# SAY WHAT WAS DRAWN. "I toggled it and see nothing" has at least six causes
	# -- no player found so the centre is the origin, no faces returned, a mesh
	# with no AABB, a node that never entered the tree -- and they are
	# indistinguishable from outside. One line separates them.
	if _collision_overlay_dbg:
		_collision_overlay_dbg = false
		print("[MapCore] collision hull: centre %s, %d triangles from debug_collision_faces"
				% [str(at), faces.size() / 3])
	if _collision_overlay == null:
		_collision_overlay = MeshInstance3D.new()
		_collision_overlay.name = "_CollisionHull"
		_collision_overlay.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
		_collision_overlay.top_level = true
		add_child(_collision_overlay)
	if faces.size() < 3:
		_collision_overlay.mesh = null
		return

	var im := ImmediateMesh.new()
	im.surface_begin(Mesh.PRIMITIVE_LINES)
	var t := 0
	var up := Vector3.UP
	while t + 2 < faces.size():
		var a := faces[t]
		var b := faces[t + 1]
		var c := faces[t + 2]
		t += 3
		# Godot's own convention, per trimesh_winding_gate: a standable floor has
		# (b-a)x(c-a) pointing DOWN, so the outward normal is its negative.
		var outward := -(b - a).cross(c - a)
		var col := Color(0.2, 1.0, 0.3, 1.0)
		if outward.length_squared() < 1e-12:
			col = Color(1.0, 0.0, 1.0, 1.0)          # degenerate — magenta
		elif outward.normalized().dot(up) < 0.0:
			col = Color(1.0, 0.2, 0.2, 1.0)          # faces away from up — red
		for e in [[a, b], [b, c], [c, a]]:
			im.surface_set_color(col)
			im.surface_add_vertex(e[0])
			im.surface_set_color(col)
			im.surface_add_vertex(e[1])
	im.surface_end()

	var mat := StandardMaterial3D.new()
	mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	mat.vertex_color_use_as_albedo = true
	mat.no_depth_test = true
	im.surface_set_material(0, mat)
	_collision_overlay.mesh = im
	if _collision_overlay_dbg2:
		_collision_overlay_dbg2 = false
		print("[MapCore] collision hull mesh: %d surfaces, aabb %s, node in tree=%s visible=%s"
				% [im.get_surface_count(), str(_collision_overlay.get_aabb()),
				   _collision_overlay.is_inside_tree(), _collision_overlay.visible])


## Teleport the player 100 km out along +X, or back if already out there.
##
## THE FIRST VERSION FELL FOR MINUTES, and the reason is worth keeping: it
## parked the player 400 m up and raycast 400 m down for collision. On a map
## with 10 km of relief the ground at the destination was 2.6 km below that ray,
## so it never hit -- AND collision is only built within collision_distance
## (100 m) of the camera, so hovering 3 km above the surface, no amount of
## waiting could ever produce a collision body to find. It timed out and left
## the player to fall.
##
## So the surface has to be found WITHOUT collision, and the only field that is
## right on both maps is the GPU one: on the diffusion map the CPU generator
## evaluates a different world entirely (its own log prints the gap at every
## spawn), so sample_density would place the player confidently and wrongly.
## debug_gpu_density runs the real shader, so it is the same surface the mesher
## will build when the player gets there.
##
## Coarse pass then fine pass, because the query grid is cubic and capped: 64 m
## voxels to find the surface within a few kilometres, then 2 m voxels to land
## on it.
const TELEPORT_DISTANCE := 100000.0
const TELEPORT_COARSE_VOXEL := 64.0
const TELEPORT_FINE_VOXEL := 2.0
const TELEPORT_WAIT_S := 20.0
var _teleport_home = null

func _teleport_far() -> void:
	var player: Node3D = null
	for p in get_tree().get_nodes_in_group("player"):
		if p is Node3D and (p as Node).is_multiplayer_authority():
			player = p
			break
	if player == null:
		print("[MapCore] teleport: no local player")
		return
	var terrain := _find_terrain(self)
	if terrain == null or not terrain.has_method("debug_gpu_density"):
		print("[MapCore] teleport: this build cannot query the GPU density — refusing rather than dropping you into the sky")
		return

	var dest: Vector3
	if _teleport_home != null:
		dest = _teleport_home
		_teleport_home = null
		print("[MapCore] teleport: returning to %s" % str(dest))
		player.global_position = dest
		if player is CharacterBody3D:
			(player as CharacterBody3D).velocity = Vector3.ZERO
		return

	_teleport_home = player.global_position
	dest = player.global_position + Vector3(TELEPORT_DISTANCE, 0.0, 0.0)
	var ulp: float = (absf(dest.x) * 1.0000001192092896) - absf(dest.x)
	print("[MapCore] teleport: %s -> x+%.0f km   (one float32 ULP there = %.5f m)"
			% [str(player.global_position), TELEPORT_DISTANCE / 1000.0, ulp])

	var Probe := load("res://scenes/test/terrain_probe.gd")
	var surface = _find_surface_gpu(Probe, terrain, dest, TELEPORT_COARSE_VOXEL)
	if surface == null:
		print("[MapCore] teleport: no surface found in a %.0f km column at the destination — NOT moving you"
				% (TELEPORT_COARSE_VOXEL * 96.0 / 1000.0))
		_teleport_home = null
		return
	var refined = _find_surface_gpu(Probe, terrain, Vector3(dest.x, float(surface), dest.z),
			TELEPORT_FINE_VOXEL)
	var y: float = float(refined) if refined != null else float(surface)

	player.global_position = Vector3(dest.x, y + 2.0, dest.z)
	if player is CharacterBody3D:
		(player as CharacterBody3D).velocity = Vector3.ZERO
	print("[MapCore] teleport: surface at %.1f m — placed at %s, waiting for collision to build"
			% [y, str(player.global_position)])

	# Now the player IS within collision_distance of the ground, so the queue
	# will build a body under them. Hold them in place until it does, or the
	# first frame of gravity drops them through terrain that does not exist yet.
	var space := get_world_3d().direct_space_state
	var t := 0.0
	while t < TELEPORT_WAIT_S:
		await get_tree().physics_frame
		t += get_physics_process_delta_time()
		var q := PhysicsRayQueryParameters3D.create(
				Vector3(dest.x, y + 30.0, dest.z), Vector3(dest.x, y - 30.0, dest.z))
		if player is CollisionObject3D:
			q.exclude = [(player as CollisionObject3D).get_rid()]
		var hit := space.intersect_ray(q)
		if hit.has("position"):
			player.global_position = (hit["position"] as Vector3) + Vector3(0.0, 1.2, 0.0)
			if player is CharacterBody3D:
				(player as CharacterBody3D).velocity = Vector3.ZERO
			print("[MapCore] teleport: landed on real collision at %s after %.1f s — walk, then Ctrl+F5"
					% [str(player.global_position), t])
			return
		player.global_position = Vector3(dest.x, y + 2.0, dest.z)
		if player is CharacterBody3D:
			(player as CharacterBody3D).velocity = Vector3.ZERO
	print("[MapCore] teleport: collision never built under %s in %.0f s — you are held at the"
			% [str(dest), TELEPORT_WAIT_S])
	print("          density surface; the terrain there may still be streaming.")


## Topmost surface in a column at `at`, from the GPU density. null if the column
## has no sign change at all, which is a finding rather than a reason to guess.
func _find_surface_gpu(Probe, terrain: Node, at: Vector3, voxel: float):
	if Probe == null:
		return null
	var window: float = voxel * 90.0
	var fd: Dictionary = Probe.field_surface(terrain, at, 3, voxel, voxel, Vector3.UP, window)
	if int(fd.get("n", 0)) == 0:
		return null
	var h: PackedFloat32Array = fd["heights"]
	var n: int = int(fd["n"])
	var mid := n / 2
	var v: float = h[mid * n + mid]
	if v >= Probe.MISS:
		return null
	return v


## Ctrl+F7's overlay, on or off (also --debug-collisions at map start).
func _set_collision_overlay(on: bool) -> void:
	_collision_overlay_on = on
	_collision_overlay_dbg = true
	_collision_overlay_dbg2 = true
	if not _collision_overlay_on:
		if _collision_overlay != null:
			_collision_overlay.queue_free()
			_collision_overlay = null
		if _collision_timer != null:
			_collision_timer.queue_free()
			_collision_timer = null
	else:
		if _collision_timer == null:
			_collision_timer = Timer.new()
			_collision_timer.wait_time = 1.0 / COLLISION_OVERLAY_HZ
			_collision_timer.autostart = true
			_collision_timer.timeout.connect(_overlay_timer_tick)
			add_child(_collision_timer)
		_update_collision_overlay()
	print("[MapCore] collision hull overlay=%s (radius %.0f m)"
			% [_collision_overlay_on, COLLISION_OVERLAY_RADIUS])