OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / sky_debug_panel.gd

1407 lines · gd ref:
extends PanelContainer

## In-game sky debug overlay toggled with F2.
## Auto-discovers cloud layers and builds sliders dynamically.
## Sections are collapsible — click header to expand/collapse.

var sky3d: Sky3DExtended
var _sliders: Dictionary = {}  # name → HSlider
var _labels: Dictionary = {}   # name → LineEdit (value display, editable)
var _min_edits: Dictionary = {}  # name → LineEdit (range min)
var _max_edits: Dictionary = {}  # name → LineEdit (range max)
var _time_paused: bool = false
var _layer_toggles: Dictionary = {}  # layer_name → CheckButton
var _vbox: VBoxContainer

## Where atmosphere settings live in GameState.
##
## PER ROOM **AND** PER MAP. Room isolation comes free — the StateBase is per
## room — but map isolation does not: `world:sky:` is listed in
## GameState.MAP_SCOPED_PREFIXES, which stores it as `map:<map_id>:world:sky:*`.
##
## I had this room-global at first, reasoning that the Sky3DExtended node hangs
## off Game rather than off a map so there is only one sky object. That confuses
## the NODE with the SETTING: a map is a contained world and its sky is part of
## what makes it one. A Quake level lit at noon and a procedural overworld at
## dusk are two worlds, not one world being re-graded. Room-global also meant
## setting dusk in one map silently re-lit every other map in the room.
##
## Consumers still use the plain key: _durable holds unscoped keys for the
## CURRENT map, _scoped() adds the namespace only when writing to the StateBase,
## and _unscope() drops other maps' keys on load.
##
## `world:sky:*` is routed DURABLE/"peer" in game.gd. THAT ROUTE IS LOAD-BEARING
## and had to land in the same change as the first write: GameState._resolve
## falls through to _DEFAULT_ROUTE {REALTIME, "peer"} for an unrouted key, which
## is never persisted and never replicated — the silent no-op that lost every
## world:level:* carve until 0.843.0 (hyper-games #27 §2).
##
## No author segment, so can_write returns true early: this is shared
## last-write-wins config, like terrain noise, not per-author content.
const KEY_PREFIX := "world:sky:"
## Sky params are shader uniforms — cheap to apply, unlike terrain noise which
## rebuilds the world. This debounce is only to keep a drag from writing a
## durable record per frame.
const WRITE_DELAY := 0.25

## How long after the last restore to re-assert it.
##
## RESTORES ARRIVE TOO EARLY TO STICK. Room state loads well before the scene has
## finished assembling, and things that run afterwards put the sky back: SkyDome
## finishes building and brings its authored current_time (19.6, dusk) with it, a
## map may force its own daylight, and AdminFacade re-indexes /world/sky. The
## restore was applied correctly and then overwritten — which is exactly why
## nudging the slider by hand a moment later shows the right sky for the SAME
## value. Applying later is the whole fix.
##
## A re-assert rather than a retry loop: it reads the saved values back out of
## GameState and applies them once, so it is idempotent and cannot fight a change
## the player made in the meantime (their change IS the saved value by then).
const SETTLE_DELAY := 1.5

## A LOCAL MIRROR OF THE SAVED SKY, so the first frame is already right.
##
## The authoritative value lives in the room's StateBase and cannot arrive until
## that has loaded — measured at ~5 s here, after DHT bootstrap and the room
## watch. Until then the sky shows whatever the scene authored (19.6, dusk), then
## snaps. That window is not a bug in persistence; it is the cost of the value
## being remote.
##
## So keep a local copy, keyed by room AND map exactly as the durable keys are,
## and apply it the moment the map is known. The room's value still wins when it
## lands — normally it is identical, so nothing visibly changes; when it differs
## (another peer edited it) the correction is the point.
##
## user:// rather than GameState: this is a rendering convenience for THIS
## install, not world state. It must never be the source of truth.
const CACHE_PATH := "user://sky_cache.cfg"

## HTPS_SKY_DEBUG=1 brings back the per-restore celestial dump.
##
## KEPT, NOT DELETED. That dump is what actually cracked this: printing every
## celestial input beside the resolved sun/moon angles showed identical inputs
## producing four different sun altitudes, which located the SkyDome's
## interpolation. Reasoning about the code had failed three times before it.
##
## But it prints on every re-emit of every key, so it is noise once the hunt is
## over. Off by default, one env var away. Mirrors GameState's HTPS_VERBOSE.
static var _verbose: bool = OS.has_environment("HTPS_SKY_DEBUG")

var _pending: Dictionary = {}
var _write_timer: Timer = null
var _applying: bool = false   # our own writes come back via state_changed
## Scene-authored values, captured before any saved state is applied.
var _defaults: Dictionary = {}
## Re-assert saved values once the scene has stopped moving. See _settle().
var _settle_timer: Timer = null
## Sunshine Clouds section: its content box, and the effect it is currently
## showing. See _rebuild_sunshine().
var _sc_box: VBoxContainer = null
var _sc_fx: CompositorEffect = null
## Live GPU cost readout (the addon's own per-pass timestamps).
var _sc_timing: Label = null
var _sc_timing_t: float = 0.0


func setup(p_sky3d: Sky3DExtended) -> void:
	sky3d = p_sky3d
	_build_ui()
	visibility_changed.connect(func() -> void:
		if visible:
			_resync_from_sky())
	_write_timer = Timer.new()
	_write_timer.one_shot = true
	_write_timer.wait_time = WRITE_DELAY
	_write_timer.timeout.connect(_flush_writes)
	add_child(_write_timer)
	_settle_timer = Timer.new()
	_settle_timer.one_shot = true
	_settle_timer.wait_time = SETTLE_DELAY
	_settle_timer.timeout.connect(_settle)
	add_child(_settle_timer)
	# Snapshot the scene's own values BEFORE any saved state lands. This is the
	# baseline a room with nothing saved must fall back to — see _on_map_changing.
	for prop: String in _sliders:
		_defaults[prop] = (_sliders[prop] as HSlider).value
	if GameState:
		GameState.state_changed.connect(_on_state_changed)
		GameState.map_changing.connect(_on_map_changing)
		# Local copy first so the first rendered frame is right, then whatever the
		# room already has in memory.
		_cache_apply()
		_load_persisted()


## ROOM ISOLATION. Two ways this panel could leak one room's sky into another,
## and both are live without this.
##
## 1. A PENDING WRITE. set_state resolves the destination room at FLUSH time, not
##    at drag time, so a value staged in room A and flushed 0.25 s later — after
##    a switch — is written into room B. Dropping the queue is the whole fix.
##
## 2. VALUES LEFT APPLIED. GameState.reset_for_room_switch clears its mirrors and
##    the new room re-emits what IT has saved, but a param the new room never
##    saved is never re-emitted — so room A's value stays on the sky node and
##    reads as room B's own setting. Restoring the scene baseline first means the
##    new room's state lands on defaults, not on the last room's.
func _on_map_changing(map_id: String) -> void:
	if _write_timer:
		_write_timer.stop()
	# A settle staged for the OUTGOING map must not fire against the incoming one.
	if _settle_timer:
		_settle_timer.stop()
	_pending.clear()
	for prop: String in _defaults:
		_apply_external(prop, float(_defaults[prop]))
	if _verbose:
		print("[SkyPanel] map -> %s : dropped pending writes, reset %d setting(s) to scene defaults"
				% [map_id, _defaults.size()])
		print(_binding_line("MAPCHANGE " + map_id))
	# Deferred: current_map_id is set before map_changing is emitted, but the
	# cache section also needs the room, and this keeps the reset-then-apply
	# ordering obvious rather than interleaved.
	call_deferred("_cache_apply")
	# The incoming map's state arrives asynchronously (load_room_state watches),
	# so arm the settle here too: if this map has nothing saved the re-assert is a
	# no-op, and if state arrives it pushes the timer out anyway.
	if _settle_timer:
		_settle_timer.start()


func _build_ui() -> void:
	# NO CHROME AND NO ANCHORS — this is a DOCK PANEL now, not a floating overlay.
	# EditorDock owns where it sits, what it is framed in and its collapsible
	# header; a panel that also drew a background and anchored itself top-left
	# would be a second answer to both, and it is what kept this thing from
	# sitting next to any other panel.
	add_theme_stylebox_override("panel", StyleBoxEmpty.new())

	# NO INTERNAL SCROLL either. It used to force a 370x500 viewport and scroll
	# its own contents, which inside a scrolling dock is two scrollbars racing for
	# the same wheel event. The dock caps the column against the window; this just
	# declares how tall it wants to be.
	_vbox = VBoxContainer.new()
	_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	add_child(_vbox)

	# --- Time ---
	var sec: VBoxContainer
	sec = _add_section(_vbox, "Time")
	_add_slider(sec, "current_time", 0.0, 24.0, 0.1, sky3d.current_time)
	_add_slider(sec, "minutes_per_day", 1.0, 60.0, 0.5, sky3d.minutes_per_day)
	_add_toggle(sec, "pause_time", _time_paused)

	# --- Date & Location -------------------------------------------------------
	# WHERE THE MOON ACTUALLY COMES FROM. TimeOfDay derives sun AND moon
	# coordinates from (current_time, date, latitude, longitude, utc) — see
	# _update_celestial_coords. Only current_time was reachable from this panel,
	# so on a reconnect the time restored and the celestial maths did not, and the
	# moon came back in a position belonging to a different date.
	#
	# These are world-authoring rather than atmosphere, which is why they are their
	# own section: you set them once for a world, not while grading a sky.
	sec = _add_section(_vbox, "Date & Location")
	_add_slider(sec, "day", 1.0, 31.0, 1.0, float(_tod_get("day", 1)))
	_add_slider(sec, "month", 1.0, 12.0, 1.0, float(_tod_get("month", 1)))
	_add_slider(sec, "year", 1900.0, 2200.0, 1.0, float(_tod_get("year", 2025)))
	# DEGREES ON THE SLIDER, RADIANS ON THE NODE. TimeOfDay stores these in
	# radians (its defaults are 0.279 and 1.885 — 16 N, 108 E), so a raw -90..90
	# slider would show 0.279 as "nearly zero" and, worse, write 16 DEGREES into a
	# field read as 16 RADIANS the moment you dragged it. _tod_get/_tod_set convert
	# for exactly these two.
	_add_slider(sec, "latitude", -90.0, 90.0, 0.5, float(_tod_get("latitude", 0.0)))
	_add_slider(sec, "longitude", -180.0, 180.0, 0.5, float(_tod_get("longitude", 0.0)))
	_add_slider(sec, "utc", -12.0, 14.0, 1.0, float(_tod_get("utc", 0.0)))

	# --- Sun ---
	sec = _add_section(_vbox, "Sun")
	_add_slider(sec, "sun_disk_size", 0.0, 0.2, 0.001, sky3d.sky.sun_disk_size)
	_add_slider(sec, "sun_disk_intensity", 0.0, 100.0, 0.5, sky3d.sky.sun_disk_intensity)
	_add_slider(sec, "sun_energy", 0.0, 4.0, 0.05, sky3d.sun_energy)
	_add_slider(sec, "atm_sun_mie_intensity", 0.0, 5.0, 0.01, sky3d.sky.atm_sun_mie_intensity)

	# --- Moon ---
	sec = _add_section(_vbox, "Moon")
	_add_slider(sec, "moon_phase", -1.0, 1.0, 0.01, sky3d.sky.moon_phase)
	_add_slider(sec, "moon_size", 0.01, 0.3, 0.005, sky3d.sky.moon_size)
	_add_slider(sec, "moon_intensity", 0.0, 10.0, 0.1, 2.0)
	_add_slider(sec, "atm_moon_mie_intensity", 0.0, 2.0, 0.01, sky3d.sky.atm_moon_mie_intensity)

	# --- Sky Layers (toggle each rendering layer independently) ---
	_build_sky_layer_ui()

	# --- Volumetric Clouds (sky shader) ---
	_build_vol_cloud_ui()

	# --- Cloud Shadow Layer ---
	_build_cloud_layer_ui()

	# --- Atmosphere ---
	sec = _add_section(_vbox, "Atmosphere")
	_add_slider(sec, "atm_thickness", 0.0, 2.0, 0.01, sky3d.sky.atm_thickness)
	_add_slider(sec, "atm_mie", 0.0, 0.5, 0.001, sky3d.sky.atm_mie)
	_add_slider(sec, "camera_exposure", 0.0, 4.0, 0.01, sky3d.camera_exposure)
	_add_slider(sec, "tonemap_exposure", 0.0, 4.0, 0.01, sky3d.tonemap_exposure)

	# AERIAL PERSPECTIVE — the two scattering media, exposed so the blue-then-pale
	# behaviour can actually be dialled against the view.
	#
	# AtmFog.gdshader tints by clamp(linear_depth * fog_rayleigh_depth) and
	# clamp(linear_depth * fog_mie_depth), with linear_depth in METRES, so each
	# value is 1/(distance at which that medium saturates). The addon defaults --
	# 0.115 and 0.0001 -- put Rayleigh at 8.7 m and Mie at 10 km, which is a
	# sensible ratio for a kilometre-scale world and meaningless in a 917 km one:
	# every surface past ~9 m gets an identical tint, i.e. the single-colour fade
	# runevision's atmospheric-perspective post says cannot represent distance.
	#
	# Rayleigh saturating FIRST and Mie much later is what produces blue in the
	# mid distance and pale at the far distance. map_core sets both from the map's
	# extent; these sliders are for finding the ratio that reads correctly.
	_add_slider(sec, "fog_rayleigh_depth", 0.0000005, 0.005, 0.0000005,
			sky3d.sky.fog_rayleigh_depth)
	_add_slider(sec, "fog_mie_depth", 0.0000005, 0.005, 0.0000005,
			sky3d.sky.fog_mie_depth)

	# HOW MUCH fog, as opposed to what colour it is. Exposed because the two
	# distance terms MULTIPLY -- an exponential in fog_density and a linear ramp
	# to fog_end -- so the visible result is not something you can reason your
	# way to from either value alone. map_core sets both from the map's extent;
	# these are for correcting it against the actual view.
	#
	# fog_falloff is a height term on the normalised view direction, not on
	# altitude: it fades fog OUT as you look up, so the sky is not double-tinted.
	_add_slider(sec, "fog_end", 1000.0, 500000.0, 1000.0, sky3d.sky.fog_end)
	_add_slider(sec, "fog_density", 0.0000005, 0.001, 0.0000005, sky3d.sky.fog_density)
	_add_slider(sec, "fog_start", 0.0, 100000.0, 100.0, sky3d.sky.fog_start)
	_add_slider(sec, "fog_falloff", 0.0, 10.0, 0.05, sky3d.sky.fog_falloff)

	# --- LAYERED FOG (AtmFog fog_model 1, 2026-10-06) ---
	# Near / far / height as INDEPENDENT terms — the four controls above are the old
	# multiplied model (fog_model 0), where one density moved both ends. map_core sizes
	# these to the distance a map is played at; a saved value here wins.
	if "fog_model" in sky3d.sky:
		var lf := _add_section(_vbox, "Layered fog (near / far / height)")
		_add_slider(lf, "fog_model", 0.0, 1.0, 1.0, float(sky3d.sky.fog_model))
		_add_slider(lf, "fog_near_density", 0.000001, 0.001, 0.0, sky3d.sky.fog_near_density)
		_add_slider(lf, "fog_far_begin", 10.0, 100000.0, 0.0, sky3d.sky.fog_far_begin)
		_add_slider(lf, "fog_far_end", 100.0, 500000.0, 0.0, sky3d.sky.fog_far_end)
		_add_slider(lf, "fog_far_curve", 0.25, 4.0, 0.01, sky3d.sky.fog_far_curve)
		_add_slider(lf, "fog_far_max", 0.0, 1.0, 0.01, sky3d.sky.fog_far_max)
		_add_slider(lf, "fog_height_base", -500.0, 3000.0, 1.0, sky3d.sky.fog_height_base)
		_add_slider(lf, "fog_height_falloff", 10.0, 3000.0, 0.0, sky3d.sky.fog_height_falloff)
		_add_slider(lf, "fog_height_density", 0.000001, 0.01, 0.0, sky3d.sky.fog_height_density)
		_add_slider(lf, "fog_haze_color_depth", 10.0, 50000.0, 0.0, sky3d.sky.fog_haze_color_depth)

	mouse_filter = Control.MOUSE_FILTER_STOP


## Build volumetric cloud controls (sky shader parameters)
func _build_vol_cloud_ui() -> void:
	if not sky3d.sky:
		return
	# PROBE THE PROPERTY, not just the node. Sky3D used to render clouds itself and
	# these vol_cloud_* properties lived on SkyDome; cloud rendering moved to the
	# nubis_clouds addon and they were removed (SkyDome.gd has none). The old node
	# check still passed, so this section built until the first access and threw
	# "Invalid access to property 'vol_clouds_enabled'" — which aborted _build_ui
	# half-way and cost the whole Atmosphere panel every section after it.
	if not ("vol_clouds_enabled" in sky3d.sky):
		# The clouds that DO render now are SunshineClouds2, a CompositorEffect a map
		# appends to this sky's compositor (map_mountain.gd). The section is filled
		# in _process when one appears — the panel is built before any map loads.
		_sc_box = _add_section(_vbox, "Sunshine Clouds")
		_rebuild_sunshine()
		return
	var sec := _add_section(_vbox, "Volumetric Clouds")
	_add_toggle(sec, "vol_clouds_enabled", sky3d.sky.vol_clouds_enabled)
	_add_slider(sec, "vol_cloud_coverage", 0.0, 1.0, 0.01, sky3d.sky.vol_cloud_coverage)
	_add_slider(sec, "vol_cloud_density", 0.1, 5.0, 0.05, sky3d.sky.vol_cloud_density)
	_add_slider(sec, "vol_cloud_scale", 0.1, 5.0, 0.05, sky3d.sky.vol_cloud_scale)
	_add_slider(sec, "vol_cloud_base_altitude", 500.0, 5000.0, 50.0, sky3d.sky.vol_cloud_base_altitude)
	_add_slider(sec, "vol_cloud_top_altitude", 1000.0, 8000.0, 50.0, sky3d.sky.vol_cloud_top_altitude)
	_add_slider(sec, "vol_cloud_march_steps", 8.0, 64.0, 1.0, float(sky3d.sky.vol_cloud_march_steps))
	_add_slider(sec, "vol_cloud_light_steps", 1.0, 8.0, 1.0, float(sky3d.sky.vol_cloud_light_steps))
	_add_slider(sec, "vol_cloud_absorption", 0.5, 4.0, 0.05, sky3d.sky.vol_cloud_absorption)
	_add_slider(sec, "vol_cloud_silver_intensity", 0.0, 3.0, 0.05, sky3d.sky.vol_cloud_silver_intensity)
	_add_slider(sec, "vol_cloud_silver_spread", 1.0, 8.0, 0.1, sky3d.sky.vol_cloud_silver_spread)


## Build cloud shadow layer controls
func _build_cloud_layer_ui() -> void:
	# `in` FIRST: cloud_system was removed from Sky3DExtended, and reading a
	# nonexistent property throws rather than returning null — so the old
	# `if not sky3d.cloud_system` guard was itself the error.
	if not ("cloud_system" in sky3d) or not sky3d.cloud_system:
		return

	var sec := _add_section(_vbox, "Cloud Shadows")
	_add_shadow_mode_dropdown(sec)
	_add_slider(sec, "wind_speed", 0.0, 0.2, 0.005, sky3d.cloud_system.wind_speed)

	# Shadow layer params only
	# EXPLICIT Array, not `:=`. Sky3D.cloud_system is an untyped var, so
	# `sky3d.cloud_system.get_layers()` is Variant to the analyser even though
	# get_layers() itself declares `-> Array[CloudLayer]`, and an inferred
	# declaration off a Variant is a PARSE error — which failed the whole script
	# to load, so this panel never appeared at all. Same for get_params() below.
	var layers: Array = sky3d.cloud_system.get_layers()
	for layer in layers:
		var layer_sec := _add_section(_vbox, layer.name)
		_add_layer_toggle(layer_sec, layer)
		var params: Array = layer.get_params()
		for param in params:
			var slider_key: String = layer.name + "/" + str(param["name"])
			_add_slider(layer_sec, slider_key,
				param["min"], param["max"], param["step"], param["value"])


## Build sky layer toggles and per-layer parameters
func _build_sky_layer_ui() -> void:
	# --- Deep Space (milky way cubemap in sky shader) ---
	var sec := _add_section(_vbox, "Deep Space")
	_add_toggle(sec, "milky_way", sky3d.sky.mw_enabled)
	_add_slider(sec, "mw_brightness", 0.0, 5.0, 0.01, sky3d.sky.mw_brightness)


## Create a collapsible section. Returns the content VBoxContainer to add controls to.
func _add_section(parent: VBoxContainer, title: String) -> VBoxContainer:
	var sep := HSeparator.new()
	parent.add_child(sep)

	# Clickable header button
	var header := Button.new()
	header.text = "[-] " + title
	header.flat = true
	header.alignment = HORIZONTAL_ALIGNMENT_LEFT
	header.add_theme_font_size_override("font_size", 13)
	header.add_theme_color_override("font_color", Color(1.0, 0.85, 0.4))
	header.add_theme_color_override("font_hover_color", Color(1.0, 0.95, 0.6))
	header.add_theme_color_override("font_pressed_color", Color(1.0, 0.85, 0.4))
	parent.add_child(header)

	# Content container (starts expanded)
	var content := VBoxContainer.new()
	content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	parent.add_child(content)

	# Toggle visibility on click
	header.pressed.connect(_on_section_toggle.bind(header, content, title))

	return content


func _on_section_toggle(header: Button, content: VBoxContainer, title: String) -> void:
	content.visible = not content.visible
	if content.visible:
		header.text = "[-] " + title
	else:
		header.text = "[+] " + title


func _add_slider(parent: VBoxContainer, param_name: String,
		min_val: float, max_val: float, step: float, initial: float) -> void:
	var hbox := HBoxContainer.new()
	hbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL

	# Display just the param part (after "/") for layer params
	var display_name := param_name
	if "/" in param_name:
		display_name = param_name.get_slice("/", 1)

	var name_lbl := Label.new()
	name_lbl.text = display_name
	name_lbl.custom_minimum_size.x = 100
	name_lbl.add_theme_font_size_override("font_size", 12)
	hbox.add_child(name_lbl)

	# Editable min range
	var min_edit := _make_range_edit(min_val)
	hbox.add_child(min_edit)

	var slider := HSlider.new()
	slider.min_value = min_val
	slider.max_value = max_val
	slider.step = step
	# LOG SLIDER across three or more decades: linear, the mountain's 6 km fog end sat at
	# 1 % of a 1-500 km slider's travel (atmosphere audit 2026-10-06).
	if min_val > 0.0 and max_val / min_val >= 100.0:
		slider.exp_edit = true
	slider.value = initial
	slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	slider.custom_minimum_size.x = 60
	hbox.add_child(slider)

	# Editable max range
	var max_edit := _make_range_edit(max_val)
	hbox.add_child(max_edit)

	# Editable value display (highlighted border to distinguish from range edits)
	var val_edit := _make_value_edit(initial)
	hbox.add_child(val_edit)

	parent.add_child(hbox)
	_sliders[param_name] = slider
	_labels[param_name] = val_edit
	_min_edits[param_name] = min_edit
	_max_edits[param_name] = max_edit

	slider.value_changed.connect(_on_slider_changed.bind(param_name))
	min_edit.text_submitted.connect(_on_range_min_submitted.bind(param_name))
	max_edit.text_submitted.connect(_on_range_max_submitted.bind(param_name))
	val_edit.text_submitted.connect(_on_value_submitted.bind(param_name))


## Create a small styled LineEdit for range min/max input.
func _make_range_edit(value: float) -> LineEdit:
	var edit := LineEdit.new()
	edit.text = _format_range(value)
	edit.custom_minimum_size = Vector2(44, 22)
	edit.add_theme_font_size_override("font_size", 11)
	edit.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
	edit.add_theme_color_override("font_selected_color", Color(1.0, 1.0, 1.0))
	edit.add_theme_color_override("caret_color", Color(1.0, 1.0, 1.0))
	edit.alignment = HORIZONTAL_ALIGNMENT_CENTER
	edit.mouse_filter = Control.MOUSE_FILTER_STOP
	edit.select_all_on_focus = true
	var style := StyleBoxFlat.new()
	style.bg_color = Color(0.12, 0.12, 0.12, 0.9)
	style.border_color = Color(0.4, 0.4, 0.4, 0.6)
	style.set_border_width_all(1)
	style.set_content_margin_all(2)
	edit.add_theme_stylebox_override("normal", style)
	var focus_style := StyleBoxFlat.new()
	focus_style.bg_color = Color(0.18, 0.18, 0.25, 0.95)
	focus_style.border_color = Color(0.5, 0.7, 1.0, 0.8)
	focus_style.set_border_width_all(1)
	focus_style.set_content_margin_all(2)
	edit.add_theme_stylebox_override("focus", focus_style)
	return edit


## Create a styled LineEdit for the current value display.
func _make_value_edit(value: float) -> LineEdit:
	var edit := LineEdit.new()
	edit.text = _fmt(value)
	edit.custom_minimum_size = Vector2(52, 22)
	edit.add_theme_font_size_override("font_size", 11)
	edit.add_theme_color_override("font_color", Color(0.9, 0.9, 0.9))
	edit.add_theme_color_override("font_selected_color", Color(1.0, 1.0, 1.0))
	edit.add_theme_color_override("caret_color", Color(1.0, 1.0, 1.0))
	edit.alignment = HORIZONTAL_ALIGNMENT_CENTER
	edit.mouse_filter = Control.MOUSE_FILTER_STOP
	edit.select_all_on_focus = true
	var style := StyleBoxFlat.new()
	style.bg_color = Color(0.15, 0.15, 0.2, 0.9)
	style.border_color = Color(0.3, 0.5, 0.8, 0.7)
	style.set_border_width_all(1)
	style.set_content_margin_all(2)
	edit.add_theme_stylebox_override("normal", style)
	var focus_style := StyleBoxFlat.new()
	focus_style.bg_color = Color(0.2, 0.2, 0.3, 0.95)
	focus_style.border_color = Color(0.4, 0.7, 1.0, 0.9)
	focus_style.set_border_width_all(1)
	focus_style.set_content_margin_all(2)
	edit.add_theme_stylebox_override("focus", focus_style)
	return edit


## Format range values concisely: drop trailing zeros, use int form when possible.
func _format_range(value: float) -> String:
	if is_equal_approx(value, roundf(value)):
		return str(int(value))
	return _fmt(value)


## THE SLIDERS SHOW WHAT THE SKY IS DOING. map_core writes the sky directly when a map
## loads (_scale_atmosphere); the sliders were read once at setup, so they showed the
## scene defaults while the shader ran the map's values, and the first drag jumped
## (atmosphere audit 2026-10-06). Re-read on every open. _applying: a re-read is not
## an edit, so nothing is written to the room.
func _resync_from_sky() -> void:
	if sky3d == null or sky3d.sky == null:
		return
	_applying = true
	for p: String in _sliders.keys():
		if (p.begins_with("fog_") or p.begins_with("atm_")) and p in sky3d.sky:
			var s: HSlider = _sliders[p]
			var v := float(sky3d.sky.get(p))
			if v < s.min_value: s.min_value = v
			if v > s.max_value: s.max_value = v
			s.value = v
	_applying = false


## A value that READS at every magnitude (atmosphere audit 2026-10-06: "%.3f" showed a
## fog density of 3.3e-4 or a Rayleigh depth of 4e-4 as 0.000): scientific below 0.01,
## whole metres from 1000 up, three decimals between.
func _fmt(v: float) -> String:
	if v == 0.0:
		return "0"
	if absf(v) < 0.01:
		return String.num_scientific(v)
	if absf(v) >= 1000.0:
		return "%d" % int(roundf(v))
	return "%.3f" % v


func _on_range_min_submitted(new_text: String, param_name: String) -> void:
	var val: float = new_text.to_float()
	if param_name in _sliders:
		var slider: HSlider = _sliders[param_name]
		if val < slider.max_value:
			slider.min_value = val
			if slider.value < val:
				slider.value = val
	# Unfocus so keyboard returns to game
	_min_edits[param_name].release_focus()


func _on_range_max_submitted(new_text: String, param_name: String) -> void:
	var val: float = new_text.to_float()
	if param_name in _sliders:
		var slider: HSlider = _sliders[param_name]
		if val > slider.min_value:
			slider.max_value = val
			if slider.value > val:
				slider.value = val
	_max_edits[param_name].release_focus()


func _on_value_submitted(new_text: String, param_name: String) -> void:
	var val: float = new_text.to_float()
	if param_name in _sliders:
		var slider: HSlider = _sliders[param_name]
		# Expand range if typed value is outside current range
		if val < slider.min_value:
			slider.min_value = val
			_min_edits[param_name].text = _format_range(val)
		if val > slider.max_value:
			slider.max_value = val
			_max_edits[param_name].text = _format_range(val)
		slider.value = val
	_labels[param_name].release_focus()


func _add_toggle(parent: VBoxContainer, param_name: String, initial: bool) -> void:
	var hbox := HBoxContainer.new()
	var name_lbl := Label.new()
	name_lbl.text = param_name
	name_lbl.custom_minimum_size.x = 130
	name_lbl.add_theme_font_size_override("font_size", 12)
	hbox.add_child(name_lbl)

	var btn := CheckButton.new()
	btn.button_pressed = initial
	btn.toggled.connect(_on_toggle_changed.bind(param_name))
	hbox.add_child(btn)

	parent.add_child(hbox)


func _add_layer_toggle(parent: VBoxContainer, layer: CloudLayer) -> void:
	var hbox := HBoxContainer.new()
	var name_lbl := Label.new()
	name_lbl.text = "enabled"
	name_lbl.custom_minimum_size.x = 130
	name_lbl.add_theme_font_size_override("font_size", 12)
	hbox.add_child(name_lbl)

	var btn := CheckButton.new()
	btn.button_pressed = layer.enabled
	btn.toggled.connect(_on_layer_toggle.bind(layer))
	hbox.add_child(btn)
	_layer_toggles[layer.name] = btn

	parent.add_child(hbox)


func _add_shadow_mode_dropdown(parent: VBoxContainer) -> void:
	var hbox := HBoxContainer.new()
	var name_lbl := Label.new()
	name_lbl.text = "shadow_mode"
	name_lbl.custom_minimum_size.x = 130
	name_lbl.add_theme_font_size_override("font_size", 12)
	hbox.add_child(name_lbl)

	var dropdown := OptionButton.new()
	# Order matches CloudSystem.ShadowMode enum: ANALYTICAL=0, DECAL=1, BOTH=2, NONE=3
	dropdown.add_item("Analytical", 0)
	dropdown.add_item("Ground Plane", 1)
	dropdown.add_item("Both", 2)
	dropdown.add_item("None", 3)
	dropdown.selected = sky3d.cloud_system.shadow_mode as int
	dropdown.item_selected.connect(_on_shadow_mode_changed)
	hbox.add_child(dropdown)

	parent.add_child(hbox)


## Read current param values from all layers and update sliders to match.
func _sync_layer_sliders() -> void:
	if not sky3d.cloud_system:
		return
	for layer in sky3d.cloud_system.get_layers():
		var params: Array = layer.get_params()
		for param in params:
			var slider_key: String = layer.name + "/" + str(param["name"])
			if slider_key in _sliders:
				var slider: HSlider = _sliders[slider_key]
				var current_val: float = layer.get_param(str(param["name"]))
				slider.set_value_no_signal(current_val)
				if slider_key in _labels:
					var val_edit: LineEdit = _labels[slider_key]
					if not val_edit.has_focus():
						val_edit.text = _fmt(current_val)


func _on_slider_changed(value: float, param_name: String) -> void:
	if param_name in _labels:
		var val_edit: LineEdit = _labels[param_name]
		if not val_edit.has_focus():
			val_edit.text = _fmt(value)

	# Persist. Queued rather than written here so a drag is one durable record,
	# not one per frame. _applying suppresses the write when the change ARRIVED
	# from GameState — otherwise every remote update would echo straight back.
	if not _applying:
		_pending[param_name] = value
		if _write_timer:
			_write_timer.start()

	# Check if this is a layer parameter (format: "LayerName/param_name")
	if "/" in param_name:
		var layer_name: String = param_name.get_slice("/", 0)
		var p_name: String = param_name.get_slice("/", 1)
		# `in` guard: cloud_system was removed from Sky3DExtended when rendering
		# moved to nubis_clouds, and reading a missing property throws. Reachable
		# only if layer sliders were built, which they no longer are — but an
		# unreachable branch that would crash if reached is worth closing.
		if not ("cloud_system" in sky3d) or not sky3d.cloud_system:
			return
		for layer in sky3d.cloud_system.get_layers():
			if layer.name == layer_name:
				layer.set_param(p_name, value)
				# If weather changed, sync dependent sliders
				if p_name == "weather":
					_sync_layer_sliders()
				return
		return

	# Built-in parameters
	match param_name:
		# Date & location — every one of these moves the sun and the moon, so each
		# has to recompute. TimeOfDay's setters do not (game.gd documents the same
		# gap for current_time), which is why _tod_set does it.
		"day", "month", "year", "latitude", "longitude", "utc":
			_tod_set(param_name, value)
		# Time
		"current_time":
			if _verbose:
				print(_binding_line("SLIDER current_time=%.2f" % value))
			sky3d.current_time = value
			# EXPLICITLY recompute, as _force_daylight and game.gd both do and both
			# document: Sky3D's current_time setter does not move the sun or moon,
			# and with game_time_enabled false _process never does either. Without
			# this a restored time sets the NUMBER and nothing in the sky moves —
			# which reads exactly like "it did not persist".
			var _t := _tod()
			if _t and _t.has_method("_update_celestial_coords"):
				_t._update_celestial_coords()
		"minutes_per_day":
			sky3d.minutes_per_day = value
		# Sun
		"sun_disk_size":
			sky3d.sky.sun_disk_size = value
		"sun_disk_intensity":
			sky3d.sky.sun_disk_intensity = value
		"sun_energy":
			sky3d.sun_energy = value
		"atm_sun_mie_intensity":
			sky3d.sky.atm_sun_mie_intensity = value
		# Moon
		"moon_phase":
			sky3d.sky.moon_phase = value
		"moon_size":
			sky3d.sky.moon_size = value
		"moon_intensity":
			if sky3d.sky.sky_material:
				sky3d.sky.sky_material.set_shader_parameter("moon_intensity", value)
		"atm_moon_mie_intensity":
			sky3d.sky.atm_moon_mie_intensity = value
		# Atmosphere
		"atm_thickness":
			sky3d.sky.atm_thickness = value
		"atm_mie":
			sky3d.sky.atm_mie = value
		"fog_rayleigh_depth":
			sky3d.sky.fog_rayleigh_depth = value
		"fog_mie_depth":
			sky3d.sky.fog_mie_depth = value
		"fog_end":
			sky3d.sky.fog_end = value
		"fog_density":
			sky3d.sky.fog_density = value
		"fog_start":
			sky3d.sky.fog_start = value
		"fog_falloff":
			sky3d.sky.fog_falloff = value
		"fog_model":
			sky3d.sky.fog_model = int(roundf(value))
		"fog_near_density", "fog_far_begin", "fog_far_end", "fog_far_curve", "fog_far_max", \
				"fog_height_base", "fog_height_falloff", "fog_height_density", "fog_haze_color_depth":
			sky3d.sky.set(param_name, value)
		"camera_exposure":
			sky3d.camera_exposure = value
		"tonemap_exposure":
			sky3d.tonemap_exposure = value
		"mw_brightness":
			sky3d.sky.mw_brightness = value
		# Volumetric clouds
		"vol_cloud_coverage":
			sky3d.sky.vol_cloud_coverage = value
		"vol_cloud_density":
			sky3d.sky.vol_cloud_density = value
		"vol_cloud_scale":
			sky3d.sky.vol_cloud_scale = value
		"vol_cloud_base_altitude":
			sky3d.sky.vol_cloud_base_altitude = value
		"vol_cloud_top_altitude":
			sky3d.sky.vol_cloud_top_altitude = value
		"vol_cloud_march_steps":
			sky3d.sky.vol_cloud_march_steps = int(value)
		"vol_cloud_light_steps":
			sky3d.sky.vol_cloud_light_steps = int(value)
		"vol_cloud_absorption":
			sky3d.sky.vol_cloud_absorption = value
		"vol_cloud_silver_intensity":
			sky3d.sky.vol_cloud_silver_intensity = value
		"vol_cloud_silver_spread":
			sky3d.sky.vol_cloud_silver_spread = value
		# Cloud system
		"wind_speed":
			if sky3d.cloud_system:
				sky3d.cloud_system.wind_speed = value


func _on_toggle_changed(pressed: bool, param_name: String) -> void:
	match param_name:
		"pause_time":
			_time_paused = pressed
			if pressed:
				sky3d.pause()
			else:
				sky3d.resume()
		"milky_way":
			sky3d.sky.mw_enabled = pressed
		"vol_clouds_enabled":
			sky3d.sky.vol_clouds_enabled = pressed


func _on_layer_toggle(pressed: bool, layer: CloudLayer) -> void:
	layer.enabled = pressed


func _on_shadow_mode_changed(index: int) -> void:
	# Dropdown indices match CloudSystem.ShadowMode enum directly
	sky3d.cloud_system.shadow_mode = index as CloudSystem.ShadowMode


func _process(_delta: float) -> void:
	# A map adds/removes its SunshineClouds effect on enter/exit; follow it.
	if _sc_box != null:
		var fx := _find_sunshine()
		if fx != _sc_fx:
			_sc_fx = fx
			_rebuild_sunshine()
		_sc_timing_t += _delta
		if _sc_timing_t > 0.25 and _sc_fx != null and is_instance_valid(_sc_timing):
			_sc_timing_t = 0.0
			_sc_timing.text = _sc_timing_text()
	# Keep current_time slider in sync with live time progression
	if not _time_paused and "current_time" in _sliders:
		var slider: HSlider = _sliders["current_time"]
		if not slider.has_focus():
			slider.set_value_no_signal(sky3d.current_time)
			var val_edit: LineEdit = _labels["current_time"]
			if not val_edit.has_focus():
				val_edit.text = "%.3f" % sky3d.current_time


## The full celestial input tuple plus what it RESOLVED to, in one line.
##
## "I set something that looks like noon and come back to a different sky" cannot
## be diagnosed from current_time alone: TimeOfDay derives sun and moon angles
## from (time, date, latitude, longitude, utc, mode), and Sky3D carries
## current_time on BOTH the wrapper and its TimeOfDay child. Printing the inputs
## AND the outputs at save and at restore makes the two comparable directly,
## instead of inferring which of them moved.
func _celestial_line(tag: String) -> String:
	var t := _tod()
	var dome := sky3d.get_node_or_null("SkyDome") if sky3d else null
	return ("[SkyPanel] %s map=%s wrapper=%.2f tod=%s date=%s/%s/%s lat=%s lon=%s utc=%s " +
			"mode=%s sun(alt=%s az=%s) moon(alt=%s az=%s)") % [
		tag,
		GameState.current_map_id if GameState else "?",
		sky3d.current_time if sky3d else -1.0,
		str(_tod_get("current_time", "?")),
		str(_tod_get("day", "?")), str(_tod_get("month", "?")), str(_tod_get("year", "?")),
		str(_tod_get("latitude", "?")), str(_tod_get("longitude", "?")), str(_tod_get("utc", "?")),
		str(_tod_get("celestials_calculations", "?")),
		str(dome.sun_altitude) if dome and ("sun_altitude" in dome) else "?",
		str(dome.sun_azimuth) if dome and ("sun_azimuth" in dome) else "?",
		str(dome.moon_altitude) if dome and ("moon_altitude" in dome) else "?",
		str(dome.moon_azimuth) if dome and ("moon_azimuth" in dome) else "?"]


## TimeOfDay lives under the Sky3D node and is not the same object as sky3d.sky.
## Both accessors degrade rather than throw: a Sky3D build without one of these
## properties should cost that slider, not the whole panel — which is exactly the
## failure the cloud sections caused.
func _tod() -> Node:
	return sky3d.get_node_or_null("TimeOfDay") if sky3d else null


## Properties TimeOfDay keeps in radians but a person reads in degrees.
const ANGLE_PROPS: PackedStringArray = ["latitude", "longitude"]


func _tod_get(prop: String, fallback: Variant) -> Variant:
	var t := _tod()
	if t == null or not (prop in t):
		return fallback
	var v: Variant = t.get(prop)
	return rad_to_deg(float(v)) if prop in ANGLE_PROPS else v


func _tod_set(prop: String, value: float) -> void:
	var t := _tod()
	if t == null or not (prop in t):
		return
	var out: Variant = deg_to_rad(value) if prop in ANGLE_PROPS else value
	# int-typed on TimeOfDay (day/month/year); a float would round unpredictably.
	t.set(prop, int(round(value)) if typeof(t.get(prop)) == TYPE_INT else out)
	# Sky3D's setters do not recompute celestial coordinates, and with
	# game_time_enabled false _process never does either — so without this the sun
	# and moon simply do not move when the date changes.
	if t.has_method("_update_celestial_coords"):
		t._update_celestial_coords()


## Write the staged values. One durable record per changed param per drag.
func _flush_writes() -> void:
	if GameState == null:
		return
	for prop: String in _pending:
		GameState.set_state(KEY_PREFIX + prop, float(_pending[prop]))
	_cache_write()
	if _verbose and not _pending.is_empty():
		print("[SkyPanel] persisted %d atmosphere setting(s)" % _pending.size())
		print(_celestial_line("SAVE   "))
	_pending.clear()


## A value arriving from GameState — a peer's change, or this room's saved state
## on load. Driven through the SLIDER rather than applied directly, so the readout
## and the sky node cannot disagree: setting slider.value re-enters
## _on_slider_changed, which is the one place a param is applied.
func _apply_external(prop: String, v: float) -> void:
	if not _sliders.has(prop):
		return
	_applying = true
	# SNAP, DO NOT LERP. THIS is what was writing the sky after us.
	#
	# SkyDome INTERPOLATES its celestial positions. Its sun_altitude setter only
	# stores a TARGET while _celestial_interpolation_active is true, and
	# _interpolate_celestial_positions lerps toward it in _process. So a restore
	# never moved the sun — it started it drifting from wherever it already was
	# (the scene's authored 19.6, dusk), which is why the slider read 10.5 over a
	# sunset sky, and why nudging the slider by hand a moment later looked right:
	# that re-targeted a lerp which had by then had time to arrive.
	#
	# It is also what made the same inputs report four different sun altitudes —
	# those samples were taken mid-interpolation, not from a drifting calculation.
	#
	# A restore is not an animation. Disabling interpolation makes the setter take
	# the immediate branch (assign + _update_sun_coords), so the sky is correct on
	# the frame it arrives.
	var dome: Node = sky3d.get_node_or_null("SkyDome") if sky3d else null
	var had_interp: bool = false
	if dome and ("_celestial_interpolation_active" in dome):
		had_interp = bool(dome.get("_celestial_interpolation_active"))
		dome.set("_celestial_interpolation_active", false)
	(_sliders[prop] as HSlider).value = v
	if dome and had_interp:
		dome.set("_celestial_interpolation_active", true)
	_applying = false


func _on_state_changed(key: String, value: Variant, _channel: int) -> void:
	if not key.begins_with(KEY_PREFIX) or value == null:
		return
	var prop := key.substr(KEY_PREFIX.length())
	# IGNORE A RE-EMIT OF THE SAME VALUE. GameState re-emits every durable key on
	# its poll (~3 s), so without this every key re-applied and re-armed the
	# settle forever: measured as a "<- 19.4 / re-asserted" pair every three
	# seconds for the whole session. Nothing was wrong with the value; it was
	# being re-delivered, and we treated each delivery as news.
	if _sliders.has(prop) and is_equal_approx((_sliders[prop] as HSlider).value, float(value)):
		return
	# LOUD, because the failure mode is silence. Room state arrives well after
	# setup() — _durable is empty at _ready — so almost every restore comes
	# through here rather than through _load_persisted, and "nothing happened"
	# has to be distinguishable from "nothing arrived".
	# A dropped value is a real defect and always reports; a normal arrival is
	# routine and does not.
	if not _sliders.has(prop):
		push_warning("[SkyPanel] %s = %s arrived with NO SLIDER — value dropped" % [prop, str(value)])
	elif _verbose:
		print("[SkyPanel] <- %s = %s" % [prop, str(value)])
	_apply_external(prop, float(value))
	if _verbose and prop == "current_time":
		print(_celestial_line("RESTORE"))
	# Every arriving restore pushes the settle out, so it fires once after the
	# last one rather than once per key.
	if _settle_timer:
		_settle_timer.start()


## room/map section for the local cache. Empty when either is unknown, in which
## case the cache is skipped rather than written under a wrong name.
func _cache_section() -> String:
	var room: String = ""
	if KeetService and KeetService.has_method("get_current_room"):
		room = String(KeetService.get_current_room())
	var map: String = GameState.current_map_id if GameState else ""
	return "%s/%s" % [room, map] if room != "" and map != "" else ""


func _cache_write() -> void:
	var sec := _cache_section()
	if sec == "":
		return
	var cf := ConfigFile.new()
	cf.load(CACHE_PATH)                      # keep other rooms/maps
	for prop: String in _pending:
		cf.set_value(sec, prop, float(_pending[prop]))
	cf.save(CACHE_PATH)


## Apply the local copy immediately. Returns how many it applied.
func _cache_apply() -> int:
	var sec := _cache_section()
	if sec == "":
		return 0
	var cf := ConfigFile.new()
	if cf.load(CACHE_PATH) != OK or not cf.has_section(sec):
		return 0
	var n := 0
	for prop: String in cf.get_section_keys(sec):
		_apply_external(prop, float(cf.get_value(sec, prop, 0.0)))
		n += 1
	if _verbose and n > 0:
		print("[SkyPanel] pre-applied %d setting(s) from local cache [%s]" % [n, sec])
	return n


## Re-apply everything saved, after the scene has stopped overwriting it.
func _settle() -> void:
	if GameState == null:
		return
	var n := _load_persisted()
	if _verbose and n > 0:
		print("[SkyPanel] re-asserted %d setting(s) after settle" % n)
		print(_celestial_line("SETTLE "))


func _load_persisted() -> int:
	var n := 0
	for key: String in GameState.keys_with_prefix(KEY_PREFIX):
		var v: Variant = GameState.get_state(key)
		if v != null:
			_apply_external(key.substr(KEY_PREFIX.length()), float(v))
			n += 1
	return n

## WHICH SKY AM I ACTUALLY DRIVING?
##
## The panel is handed a sky ONCE (game.gd:291) and never re-acquires it, while
## game.gd:637 DETACHES that very node whenever a map owns its own
## WorldEnvironment. So "the sliders do nothing on map X" has a specific,
## checkable shape: the bound node is out of the tree, or a DIFFERENT
## WorldEnvironment is the one being rendered. This prints both.
##
## HTPS_SKY_DEBUG=1 to enable.
func _binding_line(tag: String) -> String:
	var envs: Array = []
	var root := get_tree().root if get_tree() else null
	if root != null:
		var stack: Array[Node] = [root]
		while not stack.is_empty():
			var n: Node = stack.pop_back()
			if n is WorldEnvironment:
				envs.append(String(n.get_path()))
			for c in n.get_children():
				stack.append(c)
	var bound := "<null>"
	var in_tree := false
	if sky3d != null:
		in_tree = sky3d.is_inside_tree()
		bound = String(sky3d.get_path()) if in_tree else "(DETACHED) " + sky3d.name
	return "[SkyPanel] %s bound=%s in_tree=%s tod=%s live_WorldEnvironments=%s" % [
		tag, bound, str(in_tree), str(_tod() != null), str(envs)]


# ---- Sunshine Clouds ---------------------------------------------------------
#
# LIVE, NOT PERSISTED. These controls write straight onto the objects the clouds
# are made of — the SunshineCloudsGD resource in the compositor, the
# SunshineCloudsDriverGD node feeding it, and each SunshineCloudsEffector. The
# addon re-reads all of them every frame (update_matrices from _render_callback;
# the driver's _process), so a drag shows at once. They deliberately bypass
# _sliders/_pending: world:sky:* is room state, while a cloud look belongs to the
# map's .tres. "Save to .tres" writes the RESOURCE only; driver/effector values
# come from the map's exports (map_mountain.gd, "Cloud bank").
#
# Built from each object's own export list, so every setting the addon exposes
# appears here, grouped as the author grouped them — no hand-kept copy to drift.

## Groups that are wiring, not look: shader files, and the per-frame positions /
## light data the driver WRITES every frame (editing them would be overwritten).
const _SC_SKIP_GROUPS: PackedStringArray = ["Compute Shaders", "Internal Use"]
## Sections open on build; the rest start collapsed (there are ~70 controls).
const _SC_OPEN: PackedStringArray = ["Render", "Basic Settings"]


func _find_sunshine() -> CompositorEffect:
	if sky3d == null or sky3d.compositor == null:
		return null
	for e in sky3d.compositor.compositor_effects:
		if e is SunshineCloudsGD:
			return e
	return null


## The driver node feeding this effect (it lives under the map, not the sky).
func _find_sunshine_driver() -> Node:
	for n in get_tree().root.find_children("*", "Node", true, false):
		if n is SunshineCloudsDriverGD and n.clouds_resource == _sc_fx:
			return n
	return null


func _sc_section(title: String) -> VBoxContainer:
	var content := _add_section(_sc_box, title)
	if not (title in _SC_OPEN):
		var header := _sc_box.get_child(content.get_index() - 1) as Button
		_on_section_toggle(header, content, title)
	return content


func _rebuild_sunshine() -> void:
	for c in _sc_box.get_children():
		c.queue_free()
	if _sc_fx == null:
		var note := Label.new()
		note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
		note.add_theme_color_override("font_color", Color(0.75, 0.75, 0.8))
		note.text = "This map has no SunshineClouds effect."
		_sc_box.add_child(note)
		return

	var sec := _sc_section("Render")
	_sc_add_bool(sec, _sc_fx, "enabled", _sc_fx.enabled)
	# ORDER vs Sky3D's fog. The fog is a transparent screen quad (SkyDome.gd,
	# render_priority 100) that tints by the DEPTH BUFFER, which clouds do not
	# write. Pre-transparent (the addon default) = the fog is laid over the clouds
	# at the depth of whatever is BEHIND them; Post-transparent = clouds drawn on
	# top of the fog (and of water/particles).
	_sc_add_enum(sec, _sc_fx, "effect_callback_type", _sc_fx.effect_callback_type,
			PackedStringArray(["Pre-opaque", "Post-opaque", "Post-sky",
					"Pre-transparent", "Post-transparent"]))
	var row := HBoxContainer.new()
	var save := Button.new()
	save.text = "Save to .tres"
	save.tooltip_text = "Write the cloud RESOURCE values into the map's .tres (not driver/effector)."
	save.pressed.connect(_sc_save)
	row.add_child(save)
	var refresh := Button.new()
	refresh.text = "Refresh Compute"
	refresh.tooltip_text = "Rebind textures (needed after swapping a noise texture or the mask)."
	refresh.pressed.connect(_sc_refresh)
	row.add_child(refresh)
	sec.add_child(row)
	# COST, MEASURED. profile_gpu makes the addon capture GPU timestamps around
	# each of its passes (SunshineClouds.gd read_gpu_timings); this shows them.
	_sc_add_bool(sec, _sc_fx, "profile_gpu", bool(_sc_fx.get("profile_gpu")))
	_sc_timing = Label.new()
	_sc_timing.add_theme_font_size_override("font_size", 11)
	_sc_timing.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	sec.add_child(_sc_timing)

	_sc_build_props(_sc_fx, "")
	var drv := _find_sunshine_driver()
	if drv != null:
		_sc_build_props(drv, "Driver")
		var effs: Array = drv.tracked_point_effectors
		for k in effs.size():
			var eff: Node3D = effs[k]
			if eff == null:
				continue
			var esec := _sc_section("Effector %d (%s)" % [k, eff.name])
			_sc_add_vec3(esec, eff, "position", eff.position)
			_sc_build_props(eff, "", esec)


## One control per editable export of `target`, in its declared groups.
## `into` puts them all in one given section instead of per-group sections.
func _sc_build_props(target: Object, prefix: String, into: VBoxContainer = null) -> void:
	var scr := target.get_script() as Script
	if scr == null:
		return
	var group := ""
	var subgroup := ""
	var cur: VBoxContainer = into
	# The SCRIPT's own list, not target.get_property_list() gated on the script's
	# category entry: Godot adds that category only in TOOLS builds
	# (modules/gdscript/gdscript.cpp, #ifdef TOOLS_ENABLED), so in an exported game
	# the gate never opened and every export was skipped -- dev-run.sh showed the
	# whole panel, the installed game only Render and the effector's position.
	# Groups and subgroups are compiled into the script's members in every build.
	for p: Dictionary in scr.get_script_property_list():
		var usage: int = p["usage"]
		var pname: String = p["name"]
		if usage & PROPERTY_USAGE_CATEGORY:
			continue
		if usage & PROPERTY_USAGE_GROUP:
			group = pname
			subgroup = ""
			cur = into
			continue
		if usage & PROPERTY_USAGE_SUBGROUP:
			subgroup = pname
			cur = into
			continue
		if not (usage & PROPERTY_USAGE_EDITOR) or group in _SC_SKIP_GROUPS:
			continue
		var t: int = p["type"]
		if t == TYPE_CALLABLE:
			continue   # tool buttons (Refresh Compute is in Render; Generate is editor-only)
		if cur == null:
			var parts: PackedStringArray = []
			for x in [prefix, group, subgroup]:
				if x != "":
					parts.append(x)
			cur = _sc_section(" / ".join(parts) if not parts.is_empty() else "General")
		var v: Variant = target.get(pname)
		var hint: int = p["hint"]
		var hs: String = p["hint_string"]
		if t == TYPE_BOOL:
			_sc_add_bool(cur, target, pname, bool(v))
		elif t == TYPE_COLOR:
			_sc_add_color(cur, target, pname, v)
		elif t == TYPE_VECTOR3:
			_sc_add_vec3(cur, target, pname, v)
		elif t == TYPE_STRING:
			_sc_add_text(cur, target, pname, String(v))
		elif t == TYPE_ARRAY and _sc_all_ints(v):
			_sc_add_int_array(cur, target, pname, v)
		elif t == TYPE_OBJECT or t == TYPE_ARRAY:
			# Textures, light/effector node lists: shown, not editable in-game.
			_sc_add_readonly(cur, pname, v)
		elif t == TYPE_FLOAT or t == TYPE_INT:
			if hint == PROPERTY_HINT_ENUM:
				_sc_add_enum(cur, target, pname, int(v), hs.split(","))
			else:
				# No declared range (cloud_floor, step counts, wind speeds...):
				# 0..4x the current value. Typing a value widens the slider.
				var lo := 0.0
				var hi := maxf(1.0, absf(float(v)) * 4.0)
				if hint == PROPERTY_HINT_RANGE:
					var r := hs.split(",")
					lo = r[0].to_float()
					hi = r[1].to_float()
				# Floats: step 0 = no snapping, so a typed 0.3 on a -100..100 range stays 0.3.
				var step := 1.0 if t == TYPE_INT else 0.0
				_sc_add_slider(cur, target, pname, lo, hi, step, float(v))


func _sc_timing_text() -> String:
	if not bool(_sc_fx.get("profile_gpu")):
		return "GPU cost: turn on profile_gpu to measure"
	var t: Dictionary = _sc_fx.get("gpu_timings_ms")
	if not t.has("total"):
		return "GPU cost: waiting for timestamps..."
	return "GPU ms  total %.2f | march %.2f  prepass %.2f  blur %.2f  post %.2f  display %.2f" % [
			t["total"], t.get("march", 0.0), t.get("prepass", 0.0), t.get("blur", 0.0),
			t.get("post", 0.0), t.get("display", 0.0)]


func _sc_all_ints(v: Variant) -> bool:
	if not (v is Array) or (v as Array).is_empty():
		return false
	for e in v:
		if not (e is int):
			return false
	return true


func _sc_label(text: String) -> Label:
	var lbl := Label.new()
	lbl.text = text
	lbl.custom_minimum_size.x = 100
	lbl.add_theme_font_size_override("font_size", 12)
	return lbl


func _sc_row(parent: VBoxContainer, prop: String) -> HBoxContainer:
	var hbox := HBoxContainer.new()
	hbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	hbox.add_child(_sc_label(prop))
	parent.add_child(hbox)
	return hbox


func _sc_add_slider(parent: VBoxContainer, target: Object, prop: String, lo: float,
		hi: float, step: float, v: float) -> void:
	var hbox := _sc_row(parent, prop)
	var slider := HSlider.new()
	slider.min_value = minf(lo, v)
	slider.max_value = maxf(hi, v)
	slider.step = step
	slider.value = v
	slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	slider.custom_minimum_size.x = 60
	hbox.add_child(slider)
	var val_edit := _make_value_edit(v)
	hbox.add_child(val_edit)
	slider.value_changed.connect(_sc_on_slider.bind(target, prop, val_edit))
	val_edit.text_submitted.connect(_sc_on_value_text.bind(slider, val_edit))


func _sc_add_bool(parent: VBoxContainer, target: Object, prop: String, v: bool) -> void:
	var btn := CheckButton.new()
	btn.button_pressed = v
	btn.toggled.connect(_sc_on_set.bind(target, prop))
	_sc_row(parent, prop).add_child(btn)


func _sc_add_color(parent: VBoxContainer, target: Object, prop: String, v: Color) -> void:
	var pick := ColorPickerButton.new()
	pick.color = v
	pick.custom_minimum_size = Vector2(60, 22)
	pick.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	pick.color_changed.connect(_sc_on_set.bind(target, prop))
	_sc_row(parent, prop).add_child(pick)


func _sc_add_enum(parent: VBoxContainer, target: Object, prop: String, v: int,
		items: PackedStringArray) -> void:
	var opt := OptionButton.new()
	for i in items.size():
		# "Name:value" form is allowed in enum hint strings; show the name.
		opt.add_item(items[i].get_slice(":", 0), i)
	opt.selected = v
	opt.item_selected.connect(_sc_on_set.bind(target, prop))
	_sc_row(parent, prop).add_child(opt)


func _sc_add_text(parent: VBoxContainer, target: Object, prop: String, v: String) -> void:
	var edit := LineEdit.new()
	edit.text = v
	edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	edit.text_submitted.connect(_sc_on_set.bind(target, prop))
	_sc_row(parent, prop).add_child(edit)


## x / y / z value boxes; each submit writes the whole vector back.
func _sc_add_vec3(parent: VBoxContainer, target: Object, prop: String, v: Vector3) -> void:
	var hbox := _sc_row(parent, prop)
	for axis in 3:
		var e := _make_value_edit(v[axis])
		hbox.add_child(e)
		e.text_submitted.connect(_sc_on_vec3.bind(target, prop, axis, e))


## Array[int] (the driver's per-light shadow steps): one box per element. The
## whole array is reassigned so the driver's setter re-syncs its light data.
func _sc_add_int_array(parent: VBoxContainer, target: Object, prop: String, v: Array) -> void:
	var hbox := _sc_row(parent, prop)
	for k in v.size():
		var e := _make_value_edit(float(v[k]))
		e.text = str(v[k])
		hbox.add_child(e)
		e.text_submitted.connect(_sc_on_int_elem.bind(target, prop, k, e))


func _sc_add_readonly(parent: VBoxContainer, prop: String, v: Variant) -> void:
	var lbl := Label.new()
	lbl.add_theme_font_size_override("font_size", 11)
	lbl.add_theme_color_override("font_color", Color(0.65, 0.65, 0.7))
	lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	if v is Resource:
		var r := v as Resource
		lbl.text = r.resource_path.get_file() if r.resource_path != "" else r.get_class()
	elif v is Array:
		var names: PackedStringArray = []
		for e in v:
			names.append(str(e.name) if e is Node else str(e))
		lbl.text = "[%s]" % ", ".join(names)
	else:
		lbl.text = "<none>" if v == null else str(v)
	_sc_row(parent, prop).add_child(lbl)


func _sc_on_set(value: Variant, target: Object, prop: String) -> void:
	if is_instance_valid(target):
		target.set(prop, value)


func _sc_on_slider(value: float, target: Object, prop: String, val_edit: LineEdit) -> void:
	if not val_edit.has_focus():
		val_edit.text = _fmt(value)
	if is_instance_valid(target):
		var is_int := typeof(target.get(prop)) == TYPE_INT
		target.set(prop, int(value) if is_int else value)


func _sc_on_value_text(text: String, slider: HSlider, val_edit: LineEdit) -> void:
	var v := text.to_float()
	# A typed value outside the slider's range widens it rather than clamping.
	slider.min_value = minf(slider.min_value, v)
	slider.max_value = maxf(slider.max_value, v)
	slider.value = v
	val_edit.release_focus()


func _sc_on_vec3(text: String, target: Object, prop: String, axis: int, e: LineEdit) -> void:
	if is_instance_valid(target):
		var cur: Vector3 = target.get(prop)
		cur[axis] = text.to_float()
		target.set(prop, cur)
	e.release_focus()


func _sc_on_int_elem(text: String, target: Object, prop: String, k: int, e: LineEdit) -> void:
	if is_instance_valid(target):
		var arr: Array = (target.get(prop) as Array).duplicate()
		arr[k] = int(text.to_int())
		target.set(prop, arr)
	e.release_focus()


func _sc_refresh() -> void:
	if _sc_fx != null:
		_sc_fx.call("refresh_compute")


func _sc_save() -> void:
	if _sc_fx == null:
		return
	var path := _sc_fx.resource_path
	if path == "" or path.contains("::"):
		push_warning("[SkyPanel] this cloud effect was built in code, not loaded from a .tres — nothing to save to")
		return
	var err := ResourceSaver.save(_sc_fx, path)
	print("[SkyPanel] saved Sunshine clouds -> %s (%s)" % [path, error_string(err)])