From c8330e3a4345c6140a0f153c58a3e60d82ca1ca8 Mon Sep 17 00:00:00 2001 From: CHOMPStation2StaffMirrorBot <94713762+CHOMPStation2StaffMirrorBot@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:36:36 -0700 Subject: [PATCH] [MIRROR] Add admin verb: to convert player save files between .sav and .json (#12701) Co-authored-by: ARGUS <268038739+ARGUS-Memory@users.noreply.github.com> Co-authored-by: sharkmare Co-authored-by: Cameron Lennox --- code/datums/json_savefile.dm | 23 +++++ code/modules/admin/savefile_convert.dm | 114 +++++++++++++++++++++++++ vorestation.dme | 1 + 3 files changed, 138 insertions(+) create mode 100644 code/modules/admin/savefile_convert.dm diff --git a/code/datums/json_savefile.dm b/code/datums/json_savefile.dm index f93c885687..32d00a3024 100644 --- a/code/datums/json_savefile.dm +++ b/code/datums/json_savefile.dm @@ -59,6 +59,29 @@ GENERAL_PROTECT_DATUM(/datum/json_savefile) if(path) rustg_file_write(json_encode(tree, JSON_PRETTY_PRINT), path) +/// Writes the entire JSON tree back into a BYOND savefile. +/// This is the reverse of import_byond_savefile() and is used by the admin save conversion verb. +/// Nested lists become savefile directories; all other values are written as savefile entries. +/datum/json_savefile/proc/export_to_byond_savefile(savefile/savefile) + _write_subtree_to_savefile(savefile, "", tree) + +/// Recursive helper: walks 'subtree' and writes each key under 'path_prefix' in the savefile. +/datum/json_savefile/proc/_write_subtree_to_savefile(savefile/savefile, var/path_prefix, var/list/subtree) + for(var/key, value in subtree) + var/full_path = "[path_prefix]/[key]" + // Only recurse if value is a non-empty associative list (JSON object). + // A regular list (JSON array) has no associations, so value[first_item] == null. + // Writing a regular list as a subdirectory would corrupt it on re-import + // (each element becomes a key with null value instead of an array entry). + var/first_key = islist(value) && length(value) ? value[1] : null + if(istext(first_key) && value[first_key] != null) + // Non-empty associative list → savefile subdirectory. + _write_subtree_to_savefile(savefile, full_path, value) + else + // Primitive, empty list, or regular (non-associative) list → direct value. + savefile.cd = full_path + WRITE_FILE(savefile, value) + /// Traverses the entire dir tree of the given savefile and dynamically assembles the tree from it /datum/json_savefile/proc/import_byond_savefile(savefile/savefile) tree.Cut() diff --git a/code/modules/admin/savefile_convert.dm b/code/modules/admin/savefile_convert.dm new file mode 100644 index 0000000000..165906eb2d --- /dev/null +++ b/code/modules/admin/savefile_convert.dm @@ -0,0 +1,114 @@ +/** + * Admin verb: Convert Player Savefile + * + * Converts a player's save data between BYOND .sav (binary) and JSON formats. + * Used for redundancy, import/export processing, and save file recovery. + * + * IMPORTANT: The target player must be logged off before conversion. + * The verb refuses to run if the player is currently connected, and + * instructs the admin to tell them to log off first. + * + * Files inside the player's vore/ subfolder are excluded from this process. + * Those files are already JSON and are managed separately. + */ + +/// Returns the base save directory for a given ckey. +/proc/get_player_save_dir(var/ckey) + return "data/player_saves/[copytext(ckey, 1, 2)]/[ckey]" + +ADMIN_VERB(admin_convert_savefile, R_ADMIN, "Convert Player Savefile", "Convert a player's preferences.sav to preferences.json or vice versa. Player must be logged off.", ADMIN_CATEGORY_SERVER_ADMIN) + // Pick your target. + var/target_ckey = tgui_input_text(user, "Enter the ckey of the player whose save file you want to convert.", "Convert Player Savefile") + if(!target_ckey) + return + target_ckey = lowertext(target_ckey) + + // Refuse outright if the player is currently connected. + // Modifying save files while the client is online risks data loss or corruption + // because the server may overwrite the converted file on the next auto-save. + for(var/client/C as anything in GLOB.clients) + if(C.ckey == target_ckey) + to_chat(user, span_danger("[target_ckey] is currently logged in. Tell them to log off before you try again.")) + message_admins("[key_name_admin(user)] attempted to convert [target_ckey]'s save file, but that player is online.") + return + + // Also block if they connected at any point this round, even if currently offline. + if(GLOB.persistent_clients_by_ckey[target_ckey]) + to_chat(user, span_danger("[target_ckey] has connected this round. Their save may have been written by the server since then. Wait until next round to convert.")) + message_admins("[key_name_admin(user)] attempted to convert [target_ckey]'s save file, but that player connected this round.") + return + + // Make sure save files actually exist for this ckey. + var/save_dir = get_player_save_dir(target_ckey) + var/has_sav = fexists("[save_dir]/preferences.sav") + var/has_json = fexists("[save_dir]/preferences.json") + + if(!has_sav && !has_json) + to_chat(user, span_danger("No save files found for '[target_ckey]'. Check that the ckey is correct.")) + return + + // Let the admin pick the conversion direction. + var/list/options = list() + if(has_sav) + options += "preferences.sav -> preferences.json" + if(has_json) + options += "preferences.json -> preferences.sav" + + var/direction = tgui_input_list(user, "Select the conversion to perform for '[target_ckey]'.", "Convert Player Savefile", options) + if(!direction) + return + + // Warn the admin in case the player has logged in since we checked. + var/confirm = tgui_alert( + user, + "WARNING: '[target_ckey]' should be logged off before this runs. Proceeding while they are online can corrupt their save.\n\nAre you sure [target_ckey] is logged off?", + "Convert Player Savefile", + list("Cancel", "Yes, they are logged off")) + if(confirm != "Yes, they are logged off") + return + + // Re-verify the player is still offline immediately before touching any files. + for(var/client/C as anything in GLOB.clients) + if(C.ckey == target_ckey) + to_chat(user, span_danger("[target_ckey] is now online. Conversion aborted. Tell them to log off and try again.")) + message_admins("[key_name_admin(user)] attempted to convert [target_ckey]'s save file, but that player came online during the confirmation. Blocked.") + return + + if(direction == "preferences.sav -> preferences.json") + var/sav_path = "[save_dir]/preferences.sav" + var/json_path = "[save_dir]/preferences.json" + + // Back up the existing JSON file before overwriting it. + if(fexists(json_path)) + var/bak = "[json_path].convbak" + if(fexists(bak)) + fdel(bak) + fcopy(json_path, bak) + + var/datum/json_savefile/result = new(json_path) + result.import_byond_savefile(new /savefile(sav_path)) + result.save() + + log_and_message_admins("converted [target_ckey]'s preferences.sav to preferences.json", user) + to_chat(user, span_filter_adminlog("Done. [target_ckey]'s preferences.sav has been converted to preferences.json.")) + + else if(direction == "preferences.json -> preferences.sav") + var/json_path = "[save_dir]/preferences.json" + var/sav_path = "[save_dir]/preferences.sav" + + // Back up the existing .sav file before overwriting it. + if(fexists(sav_path)) + var/bak = "[sav_path].convbak" + if(fexists(bak)) + fdel(bak) + fcopy(sav_path, bak) + // Delete the original so new() creates a blank savefile. + // Without this, old entries not present in the JSON would persist in the file. + fdel(sav_path) + + var/datum/json_savefile/source = new(json_path) + var/savefile/result = new(sav_path) + source.export_to_byond_savefile(result) + + log_and_message_admins("converted [target_ckey]'s preferences.json to preferences.sav", user) + to_chat(user, span_filter_adminlog("Done. [target_ckey]'s preferences.json has been converted to preferences.sav.")) diff --git a/vorestation.dme b/vorestation.dme index 761c93d74d..2c8abb0015 100644 --- a/vorestation.dme +++ b/vorestation.dme @@ -2301,6 +2301,7 @@ #include "code\modules\admin\player_notes.dm" #include "code\modules\admin\player_panel.dm" #include "code\modules\admin\random_quest.dm" +#include "code\modules\admin\savefile_convert.dm" #include "code\modules\admin\spawn_menu.dm" #include "code\modules\admin\tag.dm" #include "code\modules\admin\topic.dm"