2026-07-29 23:51:25 -04:00
|
|
|
class_name PortableDataMigration
|
|
|
|
|
extends RefCounted
|
|
|
|
|
|
|
|
|
|
const LEGACY_FILES := {
|
|
|
|
|
"player_save.json": "player/player_save.json",
|
|
|
|
|
"network_profile.json": "player/network_profile.json",
|
|
|
|
|
"player_appearance.json": "player/player_appearance.json",
|
|
|
|
|
"saved_servers.json": "social/saved_servers.json",
|
|
|
|
|
"known_players.json": "social/known_players.json",
|
|
|
|
|
"player_relationships.json": "social/player_relationships.json",
|
|
|
|
|
"server_trust.json": "social/server_trust.json",
|
|
|
|
|
"host_bans.json": "social/host_bans.json",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func legacy_files_present() -> bool:
|
|
|
|
|
for filename: String in LEGACY_FILES:
|
|
|
|
|
if FileAccess.file_exists("user://".path_join(filename)):
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func migrate_legacy_to(
|
|
|
|
|
data_root: PlayerDataRoot,
|
|
|
|
|
destination: String,
|
|
|
|
|
) -> Dictionary:
|
2026-07-30 00:54:53 -04:00
|
|
|
var normalized: String = destination.simplify_path().trim_suffix("/")
|
2026-07-29 23:51:25 -04:00
|
|
|
if DirAccess.dir_exists_absolute(normalized):
|
2026-07-30 00:54:53 -04:00
|
|
|
var existing_manifest: String = normalized.path_join(
|
2026-07-29 23:51:25 -04:00
|
|
|
PlayerDataRoot.MANIFEST_FILENAME
|
|
|
|
|
)
|
|
|
|
|
if FileAccess.file_exists(existing_manifest):
|
|
|
|
|
return {
|
|
|
|
|
"ok": false,
|
|
|
|
|
"requires_existing_root_decision": true,
|
|
|
|
|
"message": "The selected folder already contains NETFISHING data.",
|
|
|
|
|
}
|
|
|
|
|
if not _directory_empty(normalized):
|
|
|
|
|
return {
|
|
|
|
|
"ok": false,
|
|
|
|
|
"message": "Choose an empty folder or create a NETFISHING subfolder.",
|
|
|
|
|
}
|
2026-07-30 00:54:53 -04:00
|
|
|
var staging: String = "%s.migration-%s" % [
|
2026-07-29 23:51:25 -04:00
|
|
|
normalized, Crypto.new().generate_random_bytes(8).hex_encode()
|
|
|
|
|
]
|
|
|
|
|
if DirAccess.make_dir_recursive_absolute(staging) != OK:
|
|
|
|
|
return {"ok": false, "message": "Migration staging could not be created."}
|
2026-07-30 00:54:53 -04:00
|
|
|
var created: Dictionary = data_root.create_unbound_root(staging)
|
2026-07-29 23:51:25 -04:00
|
|
|
if not bool(created.get("ok", false)):
|
|
|
|
|
_remove_tree(staging)
|
|
|
|
|
return {"ok": false, "message": str(created.get("message", ""))}
|
|
|
|
|
var copied: Array[String] = []
|
|
|
|
|
for source_name: String in LEGACY_FILES:
|
2026-07-30 00:54:53 -04:00
|
|
|
var source: String = ProjectSettings.globalize_path(
|
2026-07-29 23:51:25 -04:00
|
|
|
"user://".path_join(source_name)
|
|
|
|
|
)
|
|
|
|
|
if not FileAccess.file_exists(source):
|
|
|
|
|
continue
|
2026-07-30 00:54:53 -04:00
|
|
|
var target: String = staging.path_join(str(LEGACY_FILES[source_name]))
|
|
|
|
|
var result: Dictionary = _copy_verified_json(source, target)
|
2026-07-29 23:51:25 -04:00
|
|
|
if not bool(result.get("ok", false)):
|
|
|
|
|
_remove_tree(staging)
|
|
|
|
|
return {
|
|
|
|
|
"ok": false,
|
|
|
|
|
"message": "Migration failed while validating %s." % source_name,
|
|
|
|
|
}
|
|
|
|
|
copied.append(source_name)
|
|
|
|
|
if DirAccess.dir_exists_absolute(normalized):
|
|
|
|
|
DirAccess.remove_absolute(normalized)
|
|
|
|
|
if DirAccess.rename_absolute(staging, normalized) != OK:
|
|
|
|
|
_remove_tree(staging)
|
|
|
|
|
return {"ok": false, "message": "Migration could not activate its destination."}
|
2026-07-30 00:54:53 -04:00
|
|
|
var manifest: Dictionary = _read_json(
|
|
|
|
|
normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)
|
|
|
|
|
)
|
|
|
|
|
var migrated_root_id: String = str(manifest.get("root_id", ""))
|
2026-07-29 23:51:25 -04:00
|
|
|
if migrated_root_id.is_empty() or not data_root.use_existing_root(normalized):
|
|
|
|
|
return {"ok": false, "message": "Migration completed but could not switch roots."}
|
2026-07-30 00:54:53 -04:00
|
|
|
var recovery: String = ProjectSettings.globalize_path(
|
|
|
|
|
"user://migration-recovery"
|
|
|
|
|
).path_join(
|
2026-07-29 23:51:25 -04:00
|
|
|
Time.get_datetime_string_from_system().replace(":", "-")
|
|
|
|
|
)
|
|
|
|
|
DirAccess.make_dir_recursive_absolute(recovery)
|
|
|
|
|
for source_name: String in copied:
|
|
|
|
|
_copy_bytes(
|
|
|
|
|
ProjectSettings.globalize_path("user://".path_join(source_name)),
|
|
|
|
|
recovery.path_join(source_name),
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"ok": true,
|
|
|
|
|
"message": "Player data moved successfully.",
|
|
|
|
|
"recovery_path": recovery,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func migrate_active_to(
|
|
|
|
|
data_root: PlayerDataRoot,
|
|
|
|
|
destination: String,
|
|
|
|
|
) -> Dictionary:
|
2026-07-30 00:54:53 -04:00
|
|
|
var normalized: String = destination.simplify_path().trim_suffix("/")
|
2026-07-29 23:51:25 -04:00
|
|
|
if normalized == data_root.root_path:
|
|
|
|
|
return {"ok": true, "message": "This data folder is already active."}
|
|
|
|
|
if DirAccess.dir_exists_absolute(normalized) and not _directory_empty(normalized):
|
|
|
|
|
if FileAccess.file_exists(normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)):
|
|
|
|
|
return {
|
|
|
|
|
"ok": false,
|
|
|
|
|
"requires_existing_root_decision": true,
|
|
|
|
|
"message": (
|
|
|
|
|
"The selected folder already contains NETFISHING data. "
|
|
|
|
|
+ "Choose it through the existing-data recovery flow."
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
return {"ok": false, "message": "Choose an empty folder or a NETFISHING subfolder."}
|
2026-07-30 00:54:53 -04:00
|
|
|
var staging: String = "%s.migration-%s" % [
|
2026-07-29 23:51:25 -04:00
|
|
|
normalized, Crypto.new().generate_random_bytes(8).hex_encode()
|
|
|
|
|
]
|
|
|
|
|
if DirAccess.make_dir_recursive_absolute(staging) != OK:
|
|
|
|
|
return {"ok": false, "message": "Migration staging could not be created."}
|
2026-07-30 00:54:53 -04:00
|
|
|
var created: Dictionary = data_root.create_unbound_root(staging)
|
2026-07-29 23:51:25 -04:00
|
|
|
if not bool(created.get("ok", false)):
|
|
|
|
|
_remove_tree(staging)
|
|
|
|
|
return {"ok": false, "message": str(created.get("message", ""))}
|
|
|
|
|
for relative: String in [
|
|
|
|
|
"player/player_save.json",
|
|
|
|
|
"player/network_profile.json",
|
|
|
|
|
"player/player_appearance.json",
|
|
|
|
|
"social/saved_servers.json",
|
|
|
|
|
"social/known_players.json",
|
|
|
|
|
"social/player_relationships.json",
|
|
|
|
|
"social/server_trust.json",
|
|
|
|
|
"social/host_bans.json",
|
|
|
|
|
]:
|
2026-07-30 00:54:53 -04:00
|
|
|
var source: String = data_root.root_path.path_join(relative)
|
2026-07-29 23:51:25 -04:00
|
|
|
if not FileAccess.file_exists(source):
|
|
|
|
|
continue
|
|
|
|
|
if not bool(_copy_verified_json(source, staging.path_join(relative)).get("ok", false)):
|
|
|
|
|
_remove_tree(staging)
|
|
|
|
|
return {"ok": false, "message": "Migration validation failed for %s." % relative}
|
|
|
|
|
if DirAccess.dir_exists_absolute(normalized):
|
|
|
|
|
DirAccess.remove_absolute(normalized)
|
|
|
|
|
if DirAccess.rename_absolute(staging, normalized) != OK:
|
|
|
|
|
_remove_tree(staging)
|
|
|
|
|
return {"ok": false, "message": "Migration could not activate its destination."}
|
2026-07-30 00:54:53 -04:00
|
|
|
var old_root: String = data_root.root_path
|
2026-07-29 23:51:25 -04:00
|
|
|
if not data_root.use_existing_root(normalized):
|
|
|
|
|
return {"ok": false, "message": "Migration copied data but did not change the pointer."}
|
|
|
|
|
return {
|
|
|
|
|
"ok": true,
|
|
|
|
|
"message": "Data folder changed. Previous data was preserved.",
|
|
|
|
|
"previous_root": old_root,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func adopt_legacy_app_data(data_root: PlayerDataRoot) -> Dictionary:
|
2026-07-30 00:54:53 -04:00
|
|
|
var source_root: String = ProjectSettings.globalize_path("user://").trim_suffix("/")
|
|
|
|
|
var root: String = ProjectSettings.globalize_path(
|
|
|
|
|
PlayerDataRoot.APP_DATA_PORTABLE_PATH
|
|
|
|
|
)
|
2026-07-29 23:51:25 -04:00
|
|
|
if DirAccess.make_dir_recursive_absolute(root) != OK:
|
|
|
|
|
return {"ok": false, "message": "The app-data folder could not be created."}
|
2026-07-30 00:54:53 -04:00
|
|
|
var created: Dictionary = data_root.create_app_data_layout_for_migration(root)
|
2026-07-29 23:51:25 -04:00
|
|
|
if not bool(created.get("ok", false)):
|
|
|
|
|
return created
|
|
|
|
|
for source_name: String in LEGACY_FILES:
|
2026-07-30 00:54:53 -04:00
|
|
|
var source: String = source_root.path_join(source_name)
|
2026-07-29 23:51:25 -04:00
|
|
|
if not FileAccess.file_exists(source):
|
|
|
|
|
continue
|
2026-07-30 00:54:53 -04:00
|
|
|
var target: String = root.path_join(str(LEGACY_FILES[source_name]))
|
2026-07-29 23:51:25 -04:00
|
|
|
if not bool(_copy_verified_json(source, target).get("ok", false)):
|
|
|
|
|
return {
|
|
|
|
|
"ok": false,
|
|
|
|
|
"message": "Could not validate %s in app-data mode." % source_name,
|
|
|
|
|
}
|
|
|
|
|
if not data_root.use_existing_root(root):
|
|
|
|
|
return {"ok": false, "message": data_root.error_message}
|
|
|
|
|
return {
|
|
|
|
|
"ok": true,
|
|
|
|
|
"message": "Existing player data remains in app data.",
|
|
|
|
|
"recovery_path": source_root,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func replace_existing_with_legacy(
|
|
|
|
|
data_root: PlayerDataRoot,
|
|
|
|
|
destination: String,
|
|
|
|
|
) -> Dictionary:
|
2026-07-30 00:54:53 -04:00
|
|
|
var normalized: String = destination.simplify_path().trim_suffix("/")
|
|
|
|
|
var manifest: String = normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)
|
2026-07-29 23:51:25 -04:00
|
|
|
if not FileAccess.file_exists(manifest):
|
|
|
|
|
return {"ok": false, "message": "The selected folder is not a NETFISHING data root."}
|
2026-07-30 00:54:53 -04:00
|
|
|
var recovery: String = ProjectSettings.globalize_path(
|
|
|
|
|
"user://migration-recovery"
|
|
|
|
|
).path_join(
|
2026-07-29 23:51:25 -04:00
|
|
|
"replaced-root-" + Time.get_datetime_string_from_system().replace(":", "-")
|
|
|
|
|
)
|
|
|
|
|
if not _copy_tree(normalized, recovery):
|
|
|
|
|
return {"ok": false, "message": "The selected data could not be backed up."}
|
|
|
|
|
_remove_tree(normalized)
|
2026-07-30 00:54:53 -04:00
|
|
|
var result: Dictionary = migrate_legacy_to(data_root, normalized)
|
2026-07-29 23:51:25 -04:00
|
|
|
if bool(result.get("ok", false)):
|
|
|
|
|
result["replaced_root_backup"] = recovery
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func replace_existing_with_active(
|
|
|
|
|
data_root: PlayerDataRoot,
|
|
|
|
|
destination: String,
|
|
|
|
|
) -> Dictionary:
|
2026-07-30 00:54:53 -04:00
|
|
|
var normalized: String = destination.simplify_path().trim_suffix("/")
|
2026-07-29 23:51:25 -04:00
|
|
|
if not FileAccess.file_exists(normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)):
|
|
|
|
|
return {"ok": false, "message": "The selected folder is not a NETFISHING data root."}
|
2026-07-30 00:54:53 -04:00
|
|
|
var recovery: String = ProjectSettings.globalize_path(
|
|
|
|
|
"user://migration-recovery"
|
|
|
|
|
).path_join(
|
2026-07-29 23:51:25 -04:00
|
|
|
"replaced-root-" + Time.get_datetime_string_from_system().replace(":", "-")
|
|
|
|
|
)
|
|
|
|
|
if not _copy_tree(normalized, recovery):
|
|
|
|
|
return {"ok": false, "message": "The selected data could not be backed up."}
|
|
|
|
|
_remove_tree(normalized)
|
2026-07-30 00:54:53 -04:00
|
|
|
var result: Dictionary = migrate_active_to(data_root, normalized)
|
2026-07-29 23:51:25 -04:00
|
|
|
if bool(result.get("ok", false)):
|
|
|
|
|
result["replaced_root_backup"] = recovery
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func _copy_verified_json(source: String, destination: String) -> Dictionary:
|
2026-07-30 00:54:53 -04:00
|
|
|
var bytes: PackedByteArray = PortableFileGuard.read_bytes(source)
|
2026-07-29 23:51:25 -04:00
|
|
|
if bytes.is_empty() and FileAccess.get_open_error() != OK:
|
|
|
|
|
return {"ok": false}
|
2026-07-30 00:54:53 -04:00
|
|
|
var json: JSON = JSON.new()
|
2026-07-29 23:51:25 -04:00
|
|
|
if (
|
|
|
|
|
json.parse(bytes.get_string_from_utf8()) != OK
|
|
|
|
|
or typeof(json.data) != TYPE_DICTIONARY
|
|
|
|
|
or not _valid_owned_data(source.get_file(), json.data)
|
|
|
|
|
):
|
|
|
|
|
return {"ok": false}
|
|
|
|
|
if DirAccess.make_dir_recursive_absolute(destination.get_base_dir()) != OK:
|
|
|
|
|
return {"ok": false}
|
|
|
|
|
if not _write_bytes(destination, bytes):
|
|
|
|
|
return {"ok": false}
|
2026-07-30 00:54:53 -04:00
|
|
|
var copied: PackedByteArray = PortableFileGuard.read_bytes(destination)
|
2026-07-29 23:51:25 -04:00
|
|
|
return {
|
|
|
|
|
"ok": (
|
|
|
|
|
PortableFileGuard.hash_bytes(copied)
|
|
|
|
|
== PortableFileGuard.hash_bytes(bytes)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func _valid_owned_data(filename: String, data: Dictionary) -> bool:
|
|
|
|
|
if filename == "player_save.json":
|
2026-07-30 00:54:53 -04:00
|
|
|
var version: int = int(data.get("save_version", -1))
|
2026-07-29 23:51:25 -04:00
|
|
|
return version >= 1 and version <= PlayerSaveManager.SAVE_VERSION
|
|
|
|
|
return (
|
|
|
|
|
filename not in LEGACY_FILES
|
|
|
|
|
or int(data.get("format_version", -1)) == 1
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func _copy_bytes(source: String, destination: String) -> bool:
|
|
|
|
|
return _write_bytes(destination, PortableFileGuard.read_bytes(source))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func _copy_tree(source: String, destination: String) -> bool:
|
|
|
|
|
if DirAccess.make_dir_recursive_absolute(destination) != OK:
|
|
|
|
|
return false
|
2026-07-30 00:54:53 -04:00
|
|
|
var access: DirAccess = DirAccess.open(source)
|
2026-07-29 23:51:25 -04:00
|
|
|
if access == null:
|
|
|
|
|
return false
|
|
|
|
|
access.list_dir_begin()
|
2026-07-30 00:54:53 -04:00
|
|
|
var name: String = access.get_next()
|
2026-07-29 23:51:25 -04:00
|
|
|
while not name.is_empty():
|
2026-07-30 00:54:53 -04:00
|
|
|
var from: String = source.path_join(name)
|
|
|
|
|
var to: String = destination.path_join(name)
|
2026-07-29 23:51:25 -04:00
|
|
|
if access.current_is_dir():
|
|
|
|
|
if not _copy_tree(from, to):
|
|
|
|
|
access.list_dir_end()
|
|
|
|
|
return false
|
|
|
|
|
elif not _copy_bytes(from, to):
|
|
|
|
|
access.list_dir_end()
|
|
|
|
|
return false
|
|
|
|
|
name = access.get_next()
|
|
|
|
|
access.list_dir_end()
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func _write_bytes(path: String, bytes: PackedByteArray) -> bool:
|
|
|
|
|
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
|
2026-07-30 00:54:53 -04:00
|
|
|
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
|
2026-07-29 23:51:25 -04:00
|
|
|
if file == null:
|
|
|
|
|
return false
|
|
|
|
|
file.store_buffer(bytes)
|
|
|
|
|
file.flush()
|
2026-07-30 00:54:53 -04:00
|
|
|
var ok: bool = file.get_error() == OK
|
2026-07-29 23:51:25 -04:00
|
|
|
file.close()
|
|
|
|
|
return ok
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func _read_json(path: String) -> Dictionary:
|
2026-07-30 00:54:53 -04:00
|
|
|
var bytes: PackedByteArray = PortableFileGuard.read_bytes(path)
|
|
|
|
|
var json: JSON = JSON.new()
|
2026-07-29 23:51:25 -04:00
|
|
|
return (
|
|
|
|
|
json.data
|
|
|
|
|
if json.parse(bytes.get_string_from_utf8()) == OK
|
|
|
|
|
and typeof(json.data) == TYPE_DICTIONARY
|
|
|
|
|
else {}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func _directory_empty(path: String) -> bool:
|
2026-07-30 00:54:53 -04:00
|
|
|
var access: DirAccess = DirAccess.open(path)
|
2026-07-29 23:51:25 -04:00
|
|
|
if access == null:
|
|
|
|
|
return true
|
|
|
|
|
access.list_dir_begin()
|
2026-07-30 00:54:53 -04:00
|
|
|
var name: String = access.get_next()
|
2026-07-29 23:51:25 -04:00
|
|
|
access.list_dir_end()
|
|
|
|
|
return name.is_empty()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func _remove_tree(path: String) -> void:
|
2026-07-30 00:54:53 -04:00
|
|
|
var access: DirAccess = DirAccess.open(path)
|
2026-07-29 23:51:25 -04:00
|
|
|
if access == null:
|
|
|
|
|
return
|
|
|
|
|
access.list_dir_begin()
|
2026-07-30 00:54:53 -04:00
|
|
|
var name: String = access.get_next()
|
2026-07-29 23:51:25 -04:00
|
|
|
while not name.is_empty():
|
2026-07-30 00:54:53 -04:00
|
|
|
var child: String = path.path_join(name)
|
2026-07-29 23:51:25 -04:00
|
|
|
if access.current_is_dir():
|
|
|
|
|
_remove_tree(child)
|
|
|
|
|
else:
|
|
|
|
|
DirAccess.remove_absolute(child)
|
|
|
|
|
name = access.get_next()
|
|
|
|
|
access.list_dir_end()
|
|
|
|
|
DirAccess.remove_absolute(path)
|