OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / settings_dump.gd

240 lines · gd ref:
extends RefCounted
class_name SettingsDump
## SETTINGS DUMP — Ctrl+Shift+F5 on any map (map_core). A developer tool.
##
## The problem it solves: a look tuned at runtime -- sky sliders, the Sunshine
## Clouds section, the Remote inspector -- is saved to the ROOM at best, and often
## not at all. It never reaches the map's defaults, so every new room starts from
## the old, worse ones. Making the defaults editable in-game would let any player
## change them for everyone; instead this WRITES A FILE, and turning that file into
## new defaults is a source change (map scene, .tres, map script) made on purpose.
##
## One JSON file under user://settings_dumps/, path printed. Four sections:
##
##   map_changes  every STORED property of every node in the running map whose
##                live value differs from the map's scene FILE, recursing into
##                resources (materials, generators, the clouds resource). Each
##                entry names the file the value is authored in (`edit_in`).
##                The file is loaded with CACHE_MODE_IGNORE_DEEP: a cached load
##                returns the very resource instance that was edited live, and
##                the comparison would find nothing.
##                ⚠ Values a map SCRIPT sets in _ready also differ from the file
##                (map_flat's _apply_extent resizes its plane). They are listed
##                too; they are not tweaks, and are recognisable as such.
##   sky_panel    every sky slider: live value, the scene value the panel captured
##                before any room state landed, and whether the room saved it. The
##                sky hangs off Game, not the map, so it is not in map_changes.
##   room_saved   every settings-type key this room has saved for this map.
##   meta         map id, scene, room, version, time.

const DIR := "user://settings_dumps"
const ROOM_PREFIXES: PackedStringArray = ["world:sky:", "world:settings:", "world:terrain:noise", "state:"]
const MAX_DEPTH := 3
## Never compared: identity and plumbing, not look.
const SKIP_PROPS: PackedStringArray = ["script", "owner", "unique_name_in_owner", "scene_file_path",
		"resource_local_to_scene", "resource_path", "resource_name", "resource_scene_unique_id"]
## Resources not recursed into: bulk data (pixels, vertices, code), never a knob.
const OPAQUE_RESOURCES: PackedStringArray = ["Texture", "Mesh", "Image", "Shader", "Script",
		"PackedScene", "AudioStream", "Font", "Shape3D", "Shape2D", "Animation", "Skin"]


## Write the dump for `map` (the map scene's root). Returns the absolute path,
## or "" if the file could not be written.
static func write(map: Node) -> String:
	var t0 := Time.get_ticks_msec()
	var gs: Node = map.get_node_or_null(^"/root/GameState")
	var keet: Node = map.get_node_or_null(^"/root/KeetService")
	var out := {
		"meta": {
			"map_id": String(gs.get("current_map_id")) if gs else "",
			"room": String(keet.call("get_current_room")) if keet and keet.has_method("get_current_room") else "",
			"version": str(ProjectSettings.get_setting("application/config/version", "")),
			"written": Time.get_datetime_string_from_system(),
		},
		"map_scene": map.scene_file_path,
		"map_changes": _map_changes(map),
		"sky_panel": _sky_panel(map.get_tree()),
		"room_saved": _room_saved(gs),
	}
	out.meta["took_ms"] = Time.get_ticks_msec() - t0
	DirAccess.make_dir_recursive_absolute(DIR)
	var stamp := Time.get_datetime_string_from_system().replace(":", "-")
	var map_id: String = String(out.meta.map_id)
	var name: String = map_id if map_id != "" else String(map.name)
	var path := "%s/%s_%s.json" % [DIR, name, stamp]
	var f := FileAccess.open(path, FileAccess.WRITE)
	if f == null:
		push_error("[SettingsDump] cannot write %s (%s)" % [path, error_string(FileAccess.get_open_error())])
		return ""
	f.store_string(JSON.stringify(_plain(out), "  "))
	f.close()
	return ProjectSettings.globalize_path(path)


# ---- map_changes ---------------------------------------------------------------

static func _map_changes(map: Node) -> Array:
	var changes: Array = []
	if map.scene_file_path == "":
		return changes
	var ps := ResourceLoader.load(map.scene_file_path, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) as PackedScene
	if ps == null:
		return changes
	# NOT added to the tree, so no _ready runs: this is the file, as authored.
	var fresh: Node = ps.instantiate()
	_walk(map, map, fresh, changes)
	fresh.free()
	return changes


static func _walk(root: Node, live: Node, fresh_root: Node, changes: Array) -> void:
	var rel := String(root.get_path_to(live))
	var fresh: Node = fresh_root if rel == "." else fresh_root.get_node_or_null(NodePath(rel))
	# A node the file does not have was made at runtime: no authored value to
	# compare against, and nothing in the file to edit.
	if fresh != null:
		var edit_in := _authored_in(live, root)
		_diff_object(live, fresh, "" if rel == "." else rel, "", edit_in, 0, {}, changes)
	for c in live.get_children():
		_walk(root, c, fresh_root, changes)


## Which file a node's properties are authored in: the nearest scene root at or
## above it (an instanced sub-scene is edited in its own .tscn).
static func _authored_in(n: Node, root: Node) -> String:
	var cur := n
	while cur != null:
		if cur.scene_file_path != "":
			return cur.scene_file_path
		if cur == root:
			break
		cur = cur.get_parent()
	return root.scene_file_path


static func _diff_object(live: Object, fresh: Object, node: String, prefix: String, edit_in: String,
		depth: int, seen: Dictionary, changes: Array) -> void:
	if seen.has(live.get_instance_id()):
		return
	seen[live.get_instance_id()] = true
	for p in live.get_property_list():
		var pname: String = p.name
		if (int(p.usage) & PROPERTY_USAGE_STORAGE) == 0 or pname in SKIP_PROPS or pname.begins_with("metadata/"):
			continue
		var lv: Variant = live.get(pname)
		var fv: Variant = fresh.get(pname)
		var key := prefix + pname
		if lv is Resource and fv is Resource and depth < MAX_DEPTH and not _opaque(lv):
			# Where THIS resource's own values live: its file if it has one, else it
			# is embedded in whatever file holds its owner.
			var rp: String = (lv as Resource).resource_path
			var res_edit_in: String = rp if rp != "" and not rp.contains("::") else edit_in
			_diff_object(lv, fv, node, key + "/", res_edit_in, depth + 1, seen, changes)
			continue
		if lv is Object or fv is Object:
			# A different resource or node REFERENCE: report which, not its contents.
			if _ref_name(lv) != _ref_name(fv):
				changes.append({"node": node, "prop": key, "live": _ref_name(lv), "file": _ref_name(fv), "edit_in": edit_in})
			continue
		if not _same(lv, fv):
			changes.append({"node": node, "prop": key, "live": lv, "file": fv, "edit_in": edit_in})


static func _opaque(r: Resource) -> bool:
	for cls in OPAQUE_RESOURCES:
		if r.is_class(cls):
			return true
	return false


static func _ref_name(v: Variant) -> String:
	if v == null:
		return "null"
	if v is Resource:
		var r := v as Resource
		return "%s(%s)" % [r.get_class(), r.resource_path]
	if v is Node:
		return "%s(%s)" % [(v as Node).get_class(), (v as Node).name]
	return str(v)


static func _same(a: Variant, b: Variant) -> bool:
	if typeof(a) != typeof(b):
		# int vs float from an untyped default is not a change
		if (a is float or a is int) and (b is float or b is int):
			return is_equal_approx(float(a), float(b))
		return false
	if a is float:
		return is_equal_approx(a, b)
	if a is Vector2 or a is Vector3 or a is Vector4 or a is Color or a is Quaternion \
			or a is Basis or a is Transform3D or a is Transform2D:
		return a.is_equal_approx(b)
	return a == b


# ---- sky_panel ---------------------------------------------------------------

static func _sky_panel(tree: SceneTree) -> Array:
	var rows: Array = []
	var panel: Node = null
	for n in tree.root.find_children("*", "PanelContainer", true, false):
		var s: Script = n.get_script()
		if s != null and s.resource_path.ends_with("sky_debug_panel.gd"):
			panel = n
			break
	if panel == null:
		return rows
	var gs: Node = tree.root.get_node_or_null(^"GameState")
	var sliders: Dictionary = panel.get("_sliders")
	var defaults: Dictionary = panel.get("_defaults")
	var names: Array = sliders.keys()
	names.sort()
	for prop: String in names:
		var live := float((sliders[prop] as HSlider).value)
		var scene: Variant = defaults.get(prop, null)
		var saved: bool = gs != null and bool(gs.call("has_state", "world:sky:" + prop))
		rows.append({"param": prop, "live": live, "scene": scene, "room_saved": saved,
				"changed": scene == null or not is_equal_approx(live, float(scene))})
	return rows


# ---- room_saved --------------------------------------------------------------

static func _room_saved(gs: Node) -> Dictionary:
	var out := {}
	if gs == null:
		return out
	for prefix in ROOM_PREFIXES:
		for k: String in gs.call("keys_with_prefix", prefix):
			out[k] = gs.call("get_state", k)
	return out


# ---- JSON --------------------------------------------------------------------

## Vectors and colours as arrays, objects as names: JSON has no Variant types.
static func _plain(v: Variant) -> Variant:
	if v is Dictionary:
		var d := {}
		for k in v:
			d[str(k)] = _plain(v[k])
		return d
	if v is Array:
		var a: Array = []
		for x in v:
			a.append(_plain(x))
		return a
	if v is Vector2 or v is Vector2i:
		return [v.x, v.y]
	if v is Vector3 or v is Vector3i:
		return [v.x, v.y, v.z]
	if v is Vector4 or v is Quaternion:
		return [v.x, v.y, v.z, v.w]
	if v is Color:
		return [v.r, v.g, v.b, v.a]
	if v is Object or v is NodePath or v is StringName or v is Basis or v is Transform3D or v is Transform2D:
		return str(v)
	if v is PackedFloat32Array or v is PackedInt32Array or v is PackedFloat64Array or v is PackedInt64Array:
		return Array(v)
	return v