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