OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / autoloads / settings.gd

662 lines · gd ref:
extends Node

## Schema-driven settings.
##
## Every option used to cost four edits in lockstep — a row and its [connection]s in
## game.tscn, a button array plus a handler in menu_main.gd, a field plus three match
## arms here. That does not scale to the set of world settings we actually want
## (path tracing, GI, time of day, gametype, ...), so options are DATA now: declare
## one entry in SCHEMA and the panel, persistence and apply all follow.
##
## THREE LAYERS, resolved in this order — the model the user asked for, where a map
## sets its own default and the settings tab overrides whatever that default is:
##
##     player override   (_overrides — only keys explicitly changed; PERSISTED)
##   > map default       (_map_defaults — set by the active map; NOT persisted)
##   > schema default    (SCHEMA[].default)
##
## Storing only EXPLICIT overrides is what makes that work: a key the player never
## touched keeps following the map, while one they did touch keeps following them
## across maps. A flat "current value" dictionary cannot express that difference.

const SAVE_PATH := "user://settings.json"

## type: "enum" (choices = button labels, value = index) or "bool" (checkbox).
## values: OPTIONAL real values parallel to choices. Without it an enum's value IS
##   the button index, which is right for quality tiers but wrong for a number the
##   engine actually consumes — samples-per-pixel 1/2/4/8 must reach the renderer as
##   1/2/4/8, not as 0/1/2/3.
## apply: method on this node, called as call(apply, value).
## requires_env: only offered when the active Environment exposes this property —
##   path tracing lives on our godot-rtx build and does not exist on stock Godot,
##   so the row hides rather than silently doing nothing.
const SCHEMA: Array[Dictionary] = [
	# An ACTION row, not a stored value: choosing Low/High writes every key in
	# PRESETS[i] as a player override (set_value), and get_value("preset") reads
	# back the preset whose keys ALL match, else -1 (custom — nothing highlighted).
	# Never persisted. No auto-detect: everyone starts on High (= the defaults).
	#
	# PANEL LAYOUT is data too (settings page audit, docs/settings-page-audit.md):
	#   tab      which tab the row is on (default "Graphics"), in TABS order
	#   group    "" = visible on the tab; else the collapsible group it starts hidden in
	#   widget   optional override: segmented | dropdown | slider | toggle. Default:
	#            bool -> toggle, 2-3 choices -> segmented, 4+ -> dropdown
	#   help     one sentence for the help line, with the measured cost where we have one
	#   depends  a key that must be on for this row to be enabled
	# Choices name the REAL setting ("Half res", "2048 px"), never Low/High (user, 2026-10-03).
	{"key": "preset", "label": "Quality preset", "type": "enum",
	 "choices": ["Low", "Medium", "High"], "default": 2,
	 "help": "Sets the rows below to a tested combination. Low suits a GTX 1060; High is the default. Any change afterwards reads as Custom."},
	{"key": "shadows", "label": "Shadow map size", "type": "enum",
	 "choices": ["Off", "1024 px", "2048 px", "4096 px"], "default": 1, "apply": "_apply_shadows",
	 "help": "Resolution of the sun's shadow map. Bigger is sharper and uses more video memory (~0.4 ms at 1024 px on a GTX 1060)."},
	{"key": "aa", "label": "Anti-aliasing", "type": "enum",
	 "choices": ["Off", "2× MSAA", "4× MSAA", "8× MSAA"], "default": 1, "apply": "_apply_aa",
	 "group": "More graphics options",
	 "help": "Smooths jagged geometry edges. Each step multiplies the samples per pixel; forced off while DLSS is on."},
	{"key": "bloom", "label": "Bloom", "type": "enum",
	 "choices": ["Off", "60 % glow", "100 % glow"], "default": 2, "apply": "_apply_bloom",
	 "group": "More graphics options",
	 "help": "Glow around bright light sources, as a share of full intensity."},
	{"key": "pathtracing", "label": "Path tracing", "type": "bool",
	 "default": false, "apply": "_apply_pathtracing", "requires_env": "pathtracing_enabled",
	 "group": "Ray tracing (RTX)",
	 "help": "Replaces rasterized lighting with a path tracer (RTX cards only). ~23 ms at 2 samples, 3 bounces on an RTX 2070 without DLSS."},
	{"key": "sky_area_lights", "label": "Sky area lights", "type": "bool",
	 "default": false, "apply": "_apply_sky_area_lights",
	 "requires_env": "pathtracing_enabled", "group": "Ray tracing (RTX)", "depends": "pathtracing",
	 "help": "Area lights for sky openings in imported levels; turning on takes effect on the next map load."},
	# Path-tracing quality. All gated on the Environment property so they vanish
	# entirely on a build without the pathtracer rather than showing dead controls.
	# Sample/bounce defaults MATCH scenes/test/rt_minimal_test.tscn, the canonical
	# RT smoke-test scene: 1 sample, 1 bounce.
	#
	# DLSS was ONE switch until 2026-08-10, on the reasoning that Super Resolution
	# under path tracing "would upscale the path tracer's noise without removing
	# any", so the engine should pick: RR under PT, SR under rasterization.
	#
	# MEASURED, and the reasoning does not survive (benchmarks/pt/RESULTS.md, all
	# path-traced at 1914x1053, spp2/b3):
	#
	#   no DLSS          23.50 ms   42.6 fps   --
	#   SR  @ 50%        12.21 ms   81.9 fps   1.92x   <- best config measured
	#   RR  @ 50%        18.69 ms   53.5 fps   1.26x
	#   RR  @ 67%        33.89 ms   29.5 fps   0.69x   slower than no DLSS
	#   RR  @ 100%       73.63 ms   13.6 fps   0.32x   much slower
	#
	# On Turing, Ray Reconstruction costs ~2.2x the path tracing it denoises, so RR
	# above 50% is a net LOSS against no upscaler at all. At 2spp the SR and RR
	# stills were close to indistinguishable. Auto-picking RR therefore pinned the
	# player to the slower option and made the fastest config unreachable.
	#
	# So the mode is now the player's choice. It is a 3-way enum rather than a
	# bundled ladder because SR vs RR is exactly ONE engine flag --
	# env.pathtracing_denoiser (0 = none, 1 = Ray Reconstruction) -- while the
	# render scale is a separate viewport property. Mode x scale spans the whole
	# ladder above: SR+50% = "SR Performance", RR+100% = "DLAA", and so on.
	#
	# Render scale stays a SEPARATE row below, and that part of the old decision was
	# right: bundling them -- the old "SR Performance (50%)" style list -- meant
	# turning DLSS off also snapped the scale back to 1.0, so an on/off comparison
	# silently measured a 4x resolution change instead of DLSS.
	#
	# CAVEAT, not yet measured: stills cannot show ghosting, boiling or disocclusion.
	# SR under PT has NOT been assessed in motion. That is why the default stays Off
	# rather than jumping to the fastest config.
	{"key": "dlss", "label": "DLSS", "type": "enum",
	 "choices": ["Off", "Super Resolution", "Ray Reconstruction"], "values": [0, 1, 2],
	 "default": 0, "apply": "_apply_dlss", "requires_env": "pathtracing_denoiser",
	 "group": "Ray tracing (RTX)", "widget": "dropdown",
	 "help": "NVIDIA upscaling. Super Resolution at 50 % scale measured 12.2 ms vs 23.5 ms without (path traced, RTX 2070); Ray Reconstruction is slower above 50 %."},
	{"key": "render_scale", "label": "Render scale", "type": "enum",
	 "choices": ["50 %", "67 %", "75 %", "100 %"], "values": [0.5, 0.67, 0.75, 1.0],
	 "default": 3, "apply": "_apply_render_scale", "widget": "slider",
	 "help": "3D resolution as a share of the window. 75 % draws about half the pixels of 100 %; the UI stays sharp."},
	{"key": "pt_samples", "label": "Samples per pixel", "type": "enum",
	 "choices": ["1", "2", "4", "8"], "values": [1, 2, 4, 8], "default": 0,
	 "apply": "_apply_pt_samples", "requires_env": "pathtracing_samples_per_pixel",
	 "group": "Ray tracing (RTX)", "depends": "pathtracing",
	 "help": "Path-tracer rays per pixel per frame. Cost scales roughly linearly; more is less noisy."},
	{"key": "pt_bounces", "label": "Max bounces", "type": "enum",
	 "choices": ["1", "2", "3", "4", "6", "8"], "values": [1, 2, 3, 4, 6, 8], "default": 0,
	 "apply": "_apply_pt_bounces", "requires_env": "pathtracing_max_bounces",
	 "group": "Ray tracing (RTX)", "depends": "pathtracing",
	 "help": "How many times a path-traced ray may bounce. More gives brighter, more accurate indirect light at more cost."},
	{"key": "interest_management", "label": "Interest management", "type": "bool",
	 "default": true, "tab": "Game", "group": "Network",
	 "help": "Sends far-away players less often (20 Hz near, 5 / 2 Hz farther, none past 3 km) to save bandwidth."},
	# The bridged terrain material (hyper-games #90/#91). Full = every noise per
	# pixel, the original look. Vertex = the position-only noise evaluated ONCE PER
	# VERTEX when a chunk meshes (the mesh-time bake). Measured on an RTX 2070:
	# close-up GPU 22.8-23.0 ms -> 1.9-2.7 ms, far view 12.5-12.7 -> 1.4 ms. The
	# cost is detail finer than a far chunk's vertex spacing: far view mean pixel
	# difference 1.35/255, 4.7 % of pixels over 8. Full is the default because the
	# original look wins unless the player chooses otherwise.
	# No apply: map_mountain listens to setting_changed and switches live (the
	# terrain remeshes). A map whose material carries no bake ignores it.
	{"key": "terrain_material", "label": "Terrain shading", "type": "enum",
	 "choices": ["Per pixel", "Per vertex"], "default": 0,
	 "help": "Where the mountain's material noise is computed. Per vertex measured 2.7 ms vs 22.8 ms close up (RTX 2070), with slightly softer distant detail."},
	# SunshineClouds2 volumetric clouds. Measured on a GTX 1060 before it throttled
	# (hyper-games #95 correction): 5.9 of an 18 ms GPU frame — the largest optional
	# cost. Maps still decide WHETHER they have clouds (map_mountain's export,
	# map_dc_terrain's scene); this row turns off or rescales whatever they added.
	# QUALITY = SunshineClouds2 resolution_scale (values: -1 off, 2 Quarter, 1 Half,
	# 0 Native). Measured on an RTX 2070 at 1594x873 with the mountain's clouds
	# (scenes/test/_probe_cloud_quality_cost.gd): off 0.15, Quarter 2.85, Half 4.66,
	# Native 11.26 GPU ms. Eighth (2.50) saves too little over Quarter to offer.
	# Was a bool until 2026-10-03; load_settings migrates true -> High, false -> Off.
	{"key": "clouds", "label": "Clouds", "type": "enum",
	 "choices": ["Off", "Quarter res", "Half res", "Full res"], "values": [-1, 2, 1, 0],
	 "default": 2, "apply": "_apply_clouds",
	 "help": "Volumetric cloud resolution. Measured on an RTX 2070: quarter 2.9 ms, half 4.7 ms, full 11.3 ms."},
	# Brush texture layers (hyper-games #94 P1). Each loaded texture costs three
	# res x res RGB8 layers + mips (albedo, normal, ORM): ~12.6 MB at 1024, ~3.1 MB
	# at 512. No apply: LevelPackMaterial listens to setting_changed and rebuilds.
	{"key": "texture_res", "label": "Brush texture size", "type": "enum",
	 "choices": ["512 px", "1024 px"], "values": [512, 1024], "default": 1,
	 "group": "More graphics options",
	 "help": "Size of brush textures in video memory: ~3.1 MB per texture at 512 px, ~12.6 MB at 1024 px."},
	# Brush surface detail: 0 = normal maps, 1 = parallax occlusion (relief from each
	# material's height map, fading out by 14 m). Compiled shader variants
	# (dc_level.gdshader / dc_level_pom.gdshader). Measured on a real level
	# (scenes/test/_probe_pom_level.gd, RTX 2070, 2x supersampled 1594x873 = 5.6 Mpx):
	# +0.66 ms with a brick wall filling the screen, +0.34 grazing, 0 at 16 m (faded) —
	# about +0.24 ms full-screen at 1080p. No apply: map_dc_level listens and
	# LevelPackMaterial swaps the shader.
	{"key": "surface_detail", "label": "Brush surface relief", "type": "enum",
	 "choices": ["Normal maps", "Parallax"], "default": 1,
	 "group": "More graphics options",
	 "help": "Parallax gives bricks and tiles real depth up close: +0.24 ms full-screen at 1080p (RTX 2070), none past 16 m."},
	# How the level editor SHOWS lengths (editor redesign E2, user decision 2026-10-02).
	# Display + input only: the snapping lattice stays Quake units underneath.
	{"key": "editor_units", "label": "Editor units", "type": "enum",
	 "choices": ["Quake units", "Metres"], "default": 0, "tab": "Game",
	 "help": "How the level editor shows lengths. Snapping stays on the Quake-unit grid either way."},
	# ---- DISPLAY / CONTROLS / AUDIO (#93 §7 player options; settings audit §2) ----
	# "range" = a continuous slider: min / max / step / unit, stored as a float.
	# Window mode and resolution ask "keep these?" after Apply and revert in 15 s
	# (menu_main._ask_keep_display) — a bad mode must not lock the player out.
	{"key": "window_mode", "label": "Window mode", "type": "enum", "tab": "Display",
	 "choices": ["Windowed", "Fullscreen", "Exclusive fullscreen"], "values": [0, 3, 4],
	 "default": 0, "apply": "_apply_window_mode",
	 "help": "Fullscreen is a borderless window; exclusive fullscreen can lower latency but switches the display mode. Reverts in 15 s unless you keep it."},
	{"key": "resolution", "label": "Window size", "type": "enum", "tab": "Display",
	 "choices": ["1280 × 720", "1600 × 900", "1920 × 1080", "2560 × 1440"], "default": 0,
	 "apply": "_apply_resolution",
	 "help": "Size of the window when windowed (fullscreen uses the display's size). Reverts in 15 s unless you keep it."},
	{"key": "vsync", "label": "VSync", "type": "enum", "tab": "Display",
	 "choices": ["Off", "On", "Adaptive"], "values": [0, 1, 2], "default": 1, "apply": "_apply_vsync",
	 "help": "On stops tearing but can add a frame of latency; adaptive syncs only when the frame rate keeps up."},
	{"key": "max_fps", "label": "Frame rate cap", "type": "enum", "tab": "Display",
	 "choices": ["Unlimited", "30", "60", "120", "144", "240"], "values": [0, 30, 60, 120, 144, 240],
	 "default": 0, "apply": "_apply_max_fps", "widget": "dropdown",
	 "help": "Caps frames per second; a cap saves power and heat on laptops and handhelds."},
	{"key": "fov", "label": "Field of view", "type": "range", "tab": "Display",
	 "min": 60.0, "max": 110.0, "step": 1.0, "unit": "°", "default": 90.0, "apply": "_apply_fov",
	 "help": "Vertical field of view of the player camera. Wider shows more and makes motion feel faster."},
	{"key": "mouse_sensitivity", "label": "Mouse sensitivity", "type": "range", "tab": "Controls",
	 "min": 0.05, "max": 1.0, "step": 0.01, "unit": "°/px", "default": 0.3,
	 "help": "Degrees the camera turns per pixel of mouse movement (was a fixed 0.3)."},
	{"key": "invert_y", "label": "Invert mouse Y", "type": "bool", "tab": "Controls", "default": false,
	 "help": "Moving the mouse forward looks down instead of up."},
	{"key": "master_volume", "label": "Master volume", "type": "range", "tab": "Audio",
	 "min": 0.0, "max": 100.0, "step": 1.0, "unit": "%", "default": 100.0, "apply": "_apply_master_volume",
	 "help": "Overall volume (the Master bus). Separate music / effects volumes come with their own audio buses."},
]

## Tab order in the panel. A tab with no offered rows is not shown.
const TABS: PackedStringArray = ["Graphics", "Display", "Controls", "Audio", "Game"]

## What each preset (SCHEMA "preset" choice index) writes. High is exactly the
## schema defaults. Low drops the two big optional costs measured on a GTX 1060 —
## clouds (5.9 ms) and the per-pixel terrain material (Vertex: 22.8 → 2.7 ms
## close-up on a 2070) — plus MSAA, bloom and 75 % render scale, and 512 px brush
## textures (the low-end fallback the #94 decision asked for). Medium keeps the
## Vertex material (the single biggest cost) and Quarter clouds (2.85 vs 4.66 ms)
## at full resolution and textures. Ultra clouds (Native, 11.26 ms) is in no
## preset — a high-end choice set by hand. (User decisions 2026-10-03.)
const PRESETS: Array[Dictionary] = [
	{"terrain_material": 1, "clouds": 0, "render_scale": 2, "aa": 0, "bloom": 1, "shadows": 1,
	 "texture_res": 0, "surface_detail": 0},
	{"terrain_material": 1, "clouds": 1, "render_scale": 3, "aa": 0, "bloom": 2, "shadows": 1,
	 "texture_res": 1, "surface_detail": 0},
	{"terrain_material": 0, "clouds": 2, "render_scale": 3, "aa": 1, "bloom": 2, "shadows": 1,
	 "texture_res": 1, "surface_detail": 1},
]

var _overrides: Dictionary = {}      # explicitly set by the player; persisted
var _map_defaults: Dictionary = {}   # declared by the active map; not persisted

signal setting_changed(key: String, value: Variant)


# ---- Back-compat properties -------------------------------------------------
# game.gd and menu_main.gd address these by name. Kept as proxies so the storage
# could become generic without touching every call site.

var shadows_quality: int:
	get: return int(get_value("shadows"))
	set(v): set_value("shadows", v)

var aa_quality: int:
	get: return int(get_value("aa"))
	set(v): set_value("aa", v)

var bloom_quality: int:
	get: return int(get_value("bloom"))
	set(v): set_value("bloom", v)

var interest_management: bool:
	get: return bool(get_value("interest_management"))
	set(v): set_value("interest_management", v)


func _ready() -> void:
	load_settings()


# ---- Schema access ----------------------------------------------------------

static func schema_for(key: String) -> Dictionary:
	for o in SCHEMA:
		if o["key"] == key:
			return o
	return {}


## Effective value: player override, else the active map's default, else the schema's.
func get_value(key: String) -> Variant:
	if key == "preset":
		return _matching_preset()
	if _overrides.has(key):
		return _overrides[key]
	if _map_defaults.has(key):
		return _map_defaults[key]
	var o := schema_for(key)
	return o.get("default", null)


## The value the ENGINE receives: values[index] when the option declares a values
## table, otherwise the stored value itself. get_value stays the UI-facing index so
## button highlighting keeps working unchanged.
func resolved_value(key: String) -> Variant:
	var o := schema_for(key)
	var v = get_value(key)
	if o.has("values"):
		var vals: Array = o["values"]
		var i := int(v)
		return vals[i] if i >= 0 and i < vals.size() else vals[0]
	return v


## Set a PLAYER override. This is what the settings tab writes, and it deliberately
## outranks the map — "should just override whatever the default is in each map".
func set_value(key: String, value: Variant) -> void:
	if key == "preset":
		_apply_preset(int(value))
		return
	if _overrides.get(key) == value:
		return
	_overrides[key] = value
	setting_changed.emit(key, value)


## Write every key of a preset as a player override (the panel's Apply persists them).
func _apply_preset(index: int) -> void:
	if index < 0 or index >= PRESETS.size():
		return
	for k: String in PRESETS[index]:
		set_value(k, PRESETS[index][k])


## The preset whose keys all match the effective values, or -1 (custom).
func _matching_preset() -> int:
	for i in PRESETS.size():
		var all := true
		for k: String in PRESETS[i]:
			if get_value(k) != PRESETS[i][k]:
				all = false
				break
		if all:
			return i
	return -1


## True when the player has explicitly set this key (so it is following them, not the map).
func is_overridden(key: String) -> bool:
	return _overrides.has(key)


## Drop a player override so the key follows the map again.
func clear_override(key: String) -> void:
	if _overrides.erase(key):
		setting_changed.emit(key, get_value(key))


## Declare the active map's defaults, e.g. a level that wants path tracing on by
## default. Replaces the previous map's set — these are per-map, not cumulative —
## and never touches player overrides.
func set_map_defaults(defaults: Dictionary) -> void:
	_map_defaults = defaults.duplicate()
	apply_settings()


func clear_map_defaults() -> void:
	_map_defaults.clear()


## Is this option offered on this build? An option gated on an Environment property
## is only available when the running engine actually has it.
func is_available(key: String) -> bool:
	var o := schema_for(key)
	if not o.has("requires_env"):
		return true
	var we := _find_world_environment()
	if we == null or we.environment == null:
		return false
	return String(o["requires_env"]) in we.environment


# ---- Persistence ------------------------------------------------------------
# Only OVERRIDES are written. Persisting effective values would silently freeze
# whatever the map happened to default to at the moment the file was saved.

func load_settings() -> void:
	_overrides.clear()
	if not FileAccess.file_exists(SAVE_PATH):
		return
	var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
	if file == null:
		return
	var parsed = JSON.parse_string(file.get_as_text())
	if not parsed is Dictionary:
		return
	# Same key names the old format used, so existing user://settings.json still loads.
	for o in SCHEMA:
		var k: String = o["key"]
		if not parsed.has(k):
			continue
		var raw: Variant = parsed[k]
		# `dlss` was a bool until 2026-08-10 and is now a 3-way mode. A stored `true`
		# meant "DLSS on", which under path tracing evaluated Ray Reconstruction — so
		# it migrates to 2, NOT to int(true) == 1 (Super Resolution). Falling through
		# to the generic enum branch would silently change what an existing player's
		# saved config does the first time they load it.
		if k == "dlss" and typeof(raw) == TYPE_BOOL:
			_overrides[k] = 2 if bool(raw) else 0
			continue
		# `clouds` was an on/off bool until 2026-10-03. On meant what ships as High;
		# int(true) == 1 would silently drop an existing player to Low.
		if k == "clouds" and typeof(raw) == TYPE_BOOL:
			_overrides[k] = 2 if bool(raw) else 0
			continue
		match String(o["type"]):
			"enum":
				_overrides[k] = int(raw)
			"range":
				_overrides[k] = clampf(float(raw), float(o.get("min", -INF)), float(o.get("max", INF)))
			_:
				_overrides[k] = bool(raw)


func save_settings() -> void:
	var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
	if file:
		file.store_string(JSON.stringify(_overrides, "\t"))


# ---- Apply ------------------------------------------------------------------

## Order matters: everything else applies before `pathtracing`, so the pathtracer
## initialises against its final denoiser/sample/bounce configuration rather than
## being switched on and reconfigured underneath.
func apply_settings() -> void:
	for o in SCHEMA:
		if String(o["key"]) == "pathtracing":
			continue
		if o.has("apply"):
			call(String(o["apply"]), resolved_value(String(o["key"])))
	if is_available("pathtracing"):
		_apply_pathtracing(get_value("pathtracing"))
	return

func _apply_shadows(v: Variant) -> void:
	match int(v):
		0: RenderingServer.directional_shadow_atlas_set_size(512, false)
		1: RenderingServer.directional_shadow_atlas_set_size(1024, true)
		2: RenderingServer.directional_shadow_atlas_set_size(2048, true)
		3: RenderingServer.directional_shadow_atlas_set_size(4096, true)


## MSAA and a temporal upscaler do not compose. DLSS wants jittered, unresolved
## samples and performs its own antialiasing; resolving with MSAA first destroys
## the sub-pixel information it needs and costs bandwidth for an effect DLSS is
## about to redo. So MSAA is forced off whenever DLSS is on, rather than letting
## the two rows contradict each other silently.
func _apply_aa(v: Variant) -> void:
	var vp := get_viewport()
	if vp == null:
		return
	# int, not bool: `dlss` is a 3-way mode now (0 Off / 1 SR / 2 RR). bool(2) happens
	# to be true, so this would still work by luck — spell it out so it keeps working
	# if a mode is ever added or reordered.
	if is_available("dlss") and int(resolved_value("dlss")) != 0:
		vp.msaa_3d = Viewport.MSAA_DISABLED
		return
	match int(v):
		0: vp.msaa_3d = Viewport.MSAA_DISABLED
		1: vp.msaa_3d = Viewport.MSAA_2X
		2: vp.msaa_3d = Viewport.MSAA_4X
		3: vp.msaa_3d = Viewport.MSAA_8X


func _apply_bloom(v: Variant) -> void:
	var env := _active_environment()
	if env == null:
		return
	match int(v):
		0: env.glow_enabled = false
		1:
			env.glow_enabled = true
			env.glow_intensity = 0.6
		2:
			env.glow_enabled = true
			env.glow_intensity = 1.0


## Path tracing is a property of the Environment RESOURCE, not a project setting —
## and game.gd hands WorldEnvironment ownership to any map that brings its own
## (_remove_map_world_environment), so the active one is resolved on every apply
## rather than cached. Absent on stock Godot; the guard makes 4.6 a no-op.
func _apply_pathtracing(v: Variant) -> void:
	var env := _active_environment()
	if env == null or not ("pathtracing_enabled" in env):
		return
	env.pathtracing_enabled = bool(v)


## 0 = Off, 1 = Super Resolution, 2 = Ray Reconstruction. Two independent engine
## controls, which is why this is a mode rather than a bundle:
##   - viewport.scaling_3d_mode picks whether the DLSS resolve runs at all
##   - env.pathtracing_denoiser picks WHICH NGX feature it evaluates
## render_forward_clustered.cpp sets params.dlss_rr only when the RR guide buffers
## exist, and those are allocated only while path tracing runs with the RR denoiser.
## So denoiser 1 under PT evaluates Ray Reconstruction, and denoiser 0 evaluates
## plain Super Resolution — which is the config that measured fastest by a wide
## margin (SCHEMA note above). Under rasterization there is nothing to denoise and
## both non-Off modes come out as Super Resolution.
##
## The render SCALE is deliberately not touched here — it is its own row, so every
## mode is comparable at a fixed scale. Mirrors scenes/test/rtx_test.gd's DLSSMode,
## which is the harness the numbers came from.
## SCALING_3D_MODE_DLSS RESOLVED AT RUNTIME, not named as a constant.
##
## It exists only in the godot-rtx 4.7 build. Naming it directly is a COMPILE error
## on stock 4.6 — and because this is an AUTOLOAD, that error does not merely
## disable this setting, it aborts autoload registration, so GameState and every
## singleton after it never come up. Every headless harness on 4.6 then dies with
## "Identifier not found: GameState", nowhere near the actual cause. Measured: both
## local 4.6 builds report the constant absent.
##
## Same defensive shape map_dc_level.gd already uses for AreaLight3D (4.7+, checked
## with ClassDB.class_exists so it simply does not appear on 4.6). On the 4.7 build
## this resolves to exactly the value the constant had; on 4.6 DLSS is not
## selectable at all, so bilinear is what the row means there.
static func _dlss_scaling_mode() -> int:
	var consts: PackedStringArray = ClassDB.class_get_integer_constant_list("Viewport", true)
	if consts.has("SCALING_3D_MODE_DLSS"):
		return ClassDB.class_get_integer_constant("Viewport", "SCALING_3D_MODE_DLSS")
	return Viewport.SCALING_3D_MODE_BILINEAR


func _apply_dlss(v: Variant) -> void:
	var mode := int(v)
	var vp := get_viewport()
	if vp != null:
		vp.scaling_3d_mode = Viewport.SCALING_3D_MODE_BILINEAR if mode == 0 \
			else _dlss_scaling_mode()
	var env := _active_environment()
	if env != null and "pathtracing_denoiser" in env:
		env.pathtracing_denoiser = 1 if mode == 2 else 0
	# MSAA is mutually exclusive with DLSS, so its row has to be re-resolved
	# whenever this one moves -- schema order applies "aa" before "dlss".
	_apply_aa(get_value("aa"))


## The lights are built during map import (map_dc_level._spawn_sky_area_lights),
## so switching this ON cannot conjure them -- that takes effect on the next load.
## Switching OFF is applied immediately by hiding the container, so the row is not
## inert the way it was when it had no apply at all and was read only at import.
func _apply_sky_area_lights(v: Variant) -> void:
	var container := get_tree().root.find_child("SkyAreaLights", true, false)
	if container != null and container is Node3D:
		(container as Node3D).visible = bool(v)


## Independent of the DLSS mode: at 100% with DLSS on you get DLAA, at 100% with
## DLSS off you get native rendering, and any scale is comparable across modes.
func _apply_render_scale(v: Variant) -> void:
	var vp := get_viewport()
	if vp != null:
		vp.scaling_3d_scale = float(v)


func _apply_pt_samples(v: Variant) -> void:
	var env := _active_environment()
	if env != null and "pathtracing_samples_per_pixel" in env:
		env.pathtracing_samples_per_pixel = int(v)


func _apply_pt_bounces(v: Variant) -> void:
	var env := _active_environment()
	if env != null and "pathtracing_max_bounces" in env:
		env.pathtracing_max_bounces = int(v)


## Volumetric clouds are a SunshineCloudsGD CompositorEffect that a map adds to a
## WorldEnvironment's compositor (the shared Sky3DExtended). `v` is the resolved
## resolution_scale (0 Native, 1 Half, 2 Quarter) or -1 for off, which toggles
## `enabled`. Re-enable ONLY what this row turned off: the addon itself sets
## enabled=false when it fails to initialise (no rendering device / missing
## shaders, SunshineClouds.gd initialize_compute), and that must stay off.
const _CLOUDS_OFF_META := &"_settings_clouds_off"

func _apply_clouds(v: Variant) -> void:
	var scale := int(v)
	var on := scale >= 0
	for we: Node in get_tree().root.find_children("*", "WorldEnvironment", true, false):
		var comp: Compositor = (we as WorldEnvironment).compositor
		if comp == null:
			continue
		for e: CompositorEffect in comp.compositor_effects:
			if not (e is SunshineCloudsGD):
				continue
			if not on and e.enabled:
				e.enabled = false
				e.set_meta(_CLOUDS_OFF_META, true)
			elif on and e.has_meta(_CLOUDS_OFF_META):
				e.enabled = true
				e.remove_meta(_CLOUDS_OFF_META)
			if on and e.get("resolution_scale") != scale:
				e.set("resolution_scale", scale)


## Display / Controls / Audio applies. Window calls are skipped on the headless
## display server (gates, --daemon-only), which has no window to change.
func _windowed_ok() -> bool:
	return DisplayServer.get_name() != "headless"


func _apply_window_mode(v: Variant) -> void:
	if _windowed_ok():
		DisplayServer.window_set_mode(int(v) as DisplayServer.WindowMode)


const _RESOLUTIONS := [Vector2i(1280, 720), Vector2i(1600, 900), Vector2i(1920, 1080), Vector2i(2560, 1440)]

func _apply_resolution(v: Variant) -> void:
	var i := clampi(int(v), 0, _RESOLUTIONS.size() - 1)
	if not _windowed_ok() or DisplayServer.window_get_mode() != DisplayServer.WINDOW_MODE_WINDOWED:
		return
	DisplayServer.window_set_size(_RESOLUTIONS[i])
	# Keep it on screen: centre on the current display.
	var scr := DisplayServer.screen_get_usable_rect(DisplayServer.window_get_current_screen())
	DisplayServer.window_set_position(scr.position + (scr.size - _RESOLUTIONS[i]) / 2)


func _apply_vsync(v: Variant) -> void:
	if _windowed_ok():
		DisplayServer.window_set_vsync_mode(int(v) as DisplayServer.VSyncMode)


func _apply_max_fps(v: Variant) -> void:
	Engine.max_fps = int(v)


func _apply_master_volume(v: Variant) -> void:
	var lin := clampf(float(v) / 100.0, 0.0, 1.0)
	AudioServer.set_bus_mute(0, lin <= 0.0)
	AudioServer.set_bus_volume_db(0, linear_to_db(maxf(lin, 0.0001)))


## Mouse look in DEGREES per pixel, (x, y) — y negative when inverted. The player
## scripts multiply event.relative by this (it replaced their fixed 0.3).
## FOV follows the setting live: every Camera3D in the "player_camera" group. The
## player scripts add their camera to the group; the setting applies on change and on
## every apply_settings (map load), so a camera spawned later is picked up too.
func _apply_fov(v: Variant) -> void:
	if not is_inside_tree():
		return
	for c: Node in get_tree().get_nodes_in_group("player_camera"):
		if c is Camera3D:
			(c as Camera3D).fov = float(v)


func mouse_look_scale() -> Vector2:
	var s := float(get_value("mouse_sensitivity"))
	return Vector2(s, -s if bool(get_value("invert_y")) else s)


func _active_environment() -> Environment:
	var we := _find_world_environment()
	return we.environment if we != null else null


func _find_world_environment() -> WorldEnvironment:
	for node in get_tree().root.get_children():
		var we := _search_for_world_env(node)
		if we:
			return we
	return null


func _search_for_world_env(node: Node) -> WorldEnvironment:
	if node is WorldEnvironment:
		return node
	for child in node.get_children():
		var result := _search_for_world_env(child)
		if result:
			return result
	return null