From 34fd7817aa6b6ab9f334f1d518e29ecd0e54fe07 Mon Sep 17 00:00:00 2001 From: Goober5000 Date: Wed, 6 May 2026 02:21:41 -0400 Subject: [PATCH 1/3] fix campaign reset wiping player stats campaign_reset() deletes the CSG file before reloading, so mission_campaign_load() finds no savefile and calls csg_reset_data(), which unconditionally zeros p->stats. Save and restore Player->stats around the reset so accumulated score, rank, kills, and medals are preserved regardless of whether the reload succeeds. Also re-save the CSG file after restoring stats: mission_campaign_load() already wrote a fresh CSG with zeros when it found no existing savefile, so without re-saving the next game load would read zeros from disk. Also now protects against resetting a campaign that is not the active one. Follow-up to #1450, #1451, #6238, and #6419. Co-Authored-By: Claude Sonnet 4.6 --- code/menuui/readyroom.cpp | 24 +++++++++++++++++++++--- code/playerman/player.h | 2 +- code/scripting/api/objs/player.cpp | 5 ++++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/code/menuui/readyroom.cpp b/code/menuui/readyroom.cpp index 899b427f828..cad49fb1fc1 100644 --- a/code/menuui/readyroom.cpp +++ b/code/menuui/readyroom.cpp @@ -1575,22 +1575,40 @@ void campaign_room_scroll_info_down() void campaign_reset(const SCP_string& campaign_file) { + Assert(Player != nullptr); + + // if the caller is resetting a campaign other than the active one (only reachable via the Lua resetCampaign API), + // we can't go through mission_campaign_load(): that would swap the global Campaign struct over to the target + // campaign and overwrite Player->stats with the wrong running totals. Just delete the CSG; the next time the + // player activates this campaign, mission_campaign_load() will recreate a fresh one. + if (stricmp(campaign_file.c_str(), Campaign.filename) != 0) { + mission_campaign_savefile_delete(campaign_file.c_str()); + return; + } + // note: we do not toss all-time stats from player's performance in campaign up till now + // save stats before the reset because csg_reset_data() will zero them when the deleted CSG is not found + scoring_struct saved_stats = Player->stats; + mission_campaign_savefile_delete(campaign_file.c_str()); const int load_status = mission_campaign_load(campaign_file.c_str(), nullptr, nullptr, 1); + // restore the stats that were cleared during the campaign reload + Player->stats = saved_stats; + // see if we successfully loaded this campaign if (load_status == 0) { // Goober5000 - reinitialize tech database if needed if ((Campaign.flags & CF_CUSTOM_TECH_DATABASE) || !stricmp(Campaign.filename, "freespace2")) { // reset tech database to what's in the tables tech_reset_to_default(); - - // write the savefile so that we don't later load a stale techroom - Pilot.save_savefile(); } + // re-save the savefile so the CSG persists the restored stats (and the tech database reset, if applicable); + // mission_campaign_load() already wrote a fresh CSG with zeroed stats when it found no existing savefile + Pilot.save_savefile(); + if (OnCampaignBeginHook->isActive()) { OnCampaignBeginHook->run(scripting::hook_param_list(scripting::hook_param("Campaign", 's', Campaign.filename))); } diff --git a/code/playerman/player.h b/code/playerman/player.h index 2bf6f6098a3..9518d40839e 100644 --- a/code/playerman/player.h +++ b/code/playerman/player.h @@ -123,7 +123,7 @@ class player int objnum; // object number for this player button_info bi; // structure that holds bit vectors for button presses control_info ci; // control info structure for this player - scoring_struct stats; // scoring and stats info for the player (points to multi_stats or single_stats) + scoring_struct stats; // scoring and stats info for the player that are currently accumulating; loaded from multi_stats or all_time_stats int friendly_hits; // Number of times hit a friendly ship this mission. float friendly_damage; // Total friendly damage done in mission. Diminishes over time. diff --git a/code/scripting/api/objs/player.cpp b/code/scripting/api/objs/player.cpp index 797b3f913cd..9b542f23297 100644 --- a/code/scripting/api/objs/player.cpp +++ b/code/scripting/api/objs/player.cpp @@ -66,7 +66,7 @@ player_h& player_h::operator=(player_h&& other) noexcept ADE_OBJ(l_Player, player_h, "player", "Player handle"); -ADE_VIRTVAR(Stats, l_Player, "scoring_stats stats", "The scoring stats of this player (read-only)", "scoring_stats", "The player stats or invalid handle") { +ADE_VIRTVAR(Stats, l_Player, "scoring_stats stats", "The scoring stats of this player that are currently accumulating, e.g. from a campaign or multiplayer session; read-only", "scoring_stats", "The player stats or invalid handle") { player_h* plr; if (!ade_get_args(L, "o", l_Player.GetPtr(&plr))) return ade_set_error(L, "o", l_ScoringStats.Set(scoring_stats_h())); @@ -74,6 +74,9 @@ ADE_VIRTVAR(Stats, l_Player, "scoring_stats stats", "The scoring stats of this p if (!plr->isValid()) return ade_set_error(L, "o", l_ScoringStats.Set(scoring_stats_h())); + if (ADE_SETTING_VAR) + LuaError(L, "This property is read only."); + return ade_set_args(L, "o", l_ScoringStats.Set(scoring_stats_h(plr->get()->stats, plr->get()))); } From 5f82940837fc90e32ccedfea8779fe8df6acf82f Mon Sep 17 00:00:00 2001 From: Goober5000 Date: Sun, 10 May 2026 00:32:48 -0400 Subject: [PATCH 2/3] to be squashed: use preserve_stats --- code/menuui/readyroom.cpp | 20 +++++++------------ code/mission/missioncampaign.cpp | 7 +++++-- code/mission/missioncampaign.h | 2 +- code/pilotfile/csg.cpp | 13 +++++++----- code/pilotfile/pilotfile.h | 4 ++-- fred2/campaigneditordlg.cpp | 2 +- .../dialogs/CampaignEditorDialogModel.cpp | 2 +- 7 files changed, 25 insertions(+), 25 deletions(-) diff --git a/code/menuui/readyroom.cpp b/code/menuui/readyroom.cpp index cad49fb1fc1..6a5a131c520 100644 --- a/code/menuui/readyroom.cpp +++ b/code/menuui/readyroom.cpp @@ -1586,16 +1586,11 @@ void campaign_reset(const SCP_string& campaign_file) return; } - // note: we do not toss all-time stats from player's performance in campaign up till now - // save stats before the reset because csg_reset_data() will zero them when the deleted CSG is not found - scoring_struct saved_stats = Player->stats; - + // note: we do not toss all-time stats from player's performance in campaign up till now; + // preserve_stats=true tells mission_campaign_load() (and through it, csg_reset_data()) + // to leave Player->stats alone when it recreates the CSG, so accumulated score/rank/kills/medals carry over mission_campaign_savefile_delete(campaign_file.c_str()); - - const int load_status = mission_campaign_load(campaign_file.c_str(), nullptr, nullptr, 1); - - // restore the stats that were cleared during the campaign reload - Player->stats = saved_stats; + const int load_status = mission_campaign_load(campaign_file.c_str(), nullptr, nullptr, true, true); // see if we successfully loaded this campaign if (load_status == 0) { @@ -1603,11 +1598,10 @@ void campaign_reset(const SCP_string& campaign_file) if ((Campaign.flags & CF_CUSTOM_TECH_DATABASE) || !stricmp(Campaign.filename, "freespace2")) { // reset tech database to what's in the tables tech_reset_to_default(); - } - // re-save the savefile so the CSG persists the restored stats (and the tech database reset, if applicable); - // mission_campaign_load() already wrote a fresh CSG with zeroed stats when it found no existing savefile - Pilot.save_savefile(); + // write the savefile so that we don't later load a stale techroom + Pilot.save_savefile(); + } if (OnCampaignBeginHook->isActive()) { OnCampaignBeginHook->run(scripting::hook_param_list(scripting::hook_param("Campaign", 's', Campaign.filename))); diff --git a/code/mission/missioncampaign.cpp b/code/mission/missioncampaign.cpp index 4c60527038f..b70dce516d7 100644 --- a/code/mission/missioncampaign.cpp +++ b/code/mission/missioncampaign.cpp @@ -437,7 +437,7 @@ void mission_campaign_get_sw_info() * this file. If you change the format of the campaign file, you should be sure these related * functions work properly and update them if it breaks them. */ -int mission_campaign_load(const char* filename, const char* full_path, player* pl, int load_savefile) +int mission_campaign_load(const char* filename, const char* full_path, player* pl, bool load_savefile, bool preserve_stats) { int i; char name[NAME_LENGTH], type[NAME_LENGTH], temp[NAME_LENGTH]; @@ -709,7 +709,10 @@ int mission_campaign_load(const char* filename, const char* full_path, player* p } // start with fresh new campaign data else { - Pilot.clear_savefile(false); // don't reset ships and weapons because they are currently the campaign's starting ones + // don't reset ships and weapons because they have just been set to the campaign's starting ones; + // preserve_stats is true when the caller is resetting an existing campaign and wants to + // keep the player's accumulated totals (score, rank, kills, medals) + Pilot.clear_savefile(false, preserve_stats); Campaign.next_mission = 0; Pilot.save_savefile(); } diff --git a/code/mission/missioncampaign.h b/code/mission/missioncampaign.h index 6e9e6658e48..a0ace7592d8 100644 --- a/code/mission/missioncampaign.h +++ b/code/mission/missioncampaign.h @@ -175,7 +175,7 @@ void player_loadout_init(); void mission_campaign_init( void ); // load up and initialize a new campaign -int mission_campaign_load(const char* filename, const char* full_path = nullptr, player* pl = nullptr, int load_savefile = 1); +int mission_campaign_load(const char* filename, const char* full_path = nullptr, player* pl = nullptr, bool load_savefile = true, bool preserve_stats = false); bool campaign_is_ignored(const char *filename); diff --git a/code/pilotfile/csg.cpp b/code/pilotfile/csg.cpp index 6b0f20933cc..562806846b8 100644 --- a/code/pilotfile/csg.cpp +++ b/code/pilotfile/csg.cpp @@ -1594,7 +1594,7 @@ void pilotfile::csg_write_container(const sexp_container &container) } } -void pilotfile::csg_reset_data(bool reset_ships_and_weapons) +void pilotfile::csg_reset_data(bool reset_ships_and_weapons, bool preserve_stats) { int idx; cmission *missionp; @@ -1605,8 +1605,11 @@ void pilotfile::csg_reset_data(bool reset_ships_and_weapons) m_data_invalid = false; - // init stats - p->stats.init(); + // init stats (unless the caller wants to keep the player's accumulated totals, + // e.g. for a campaign reset) + if (!preserve_stats) { + p->stats.init(); + } // zero out allowed ships/weapons if (reset_ships_and_weapons) { @@ -1948,7 +1951,7 @@ bool pilotfile::save_savefile() return true; } -void pilotfile::clear_savefile(bool reset_ships_and_weapons) +void pilotfile::clear_savefile(bool reset_ships_and_weapons, bool preserve_stats) { if (Game_mode & GM_MULTIPLAYER) { return; @@ -1958,7 +1961,7 @@ void pilotfile::clear_savefile(bool reset_ships_and_weapons) Assert((Player_num >= 0) && (Player_num < MAX_PLAYERS)); p = &Players[Player_num]; - csg_reset_data(reset_ships_and_weapons); + csg_reset_data(reset_ships_and_weapons, preserve_stats); } /* diff --git a/code/pilotfile/pilotfile.h b/code/pilotfile/pilotfile.h index c3b0616acc7..3f8271c2a9c 100644 --- a/code/pilotfile/pilotfile.h +++ b/code/pilotfile/pilotfile.h @@ -93,7 +93,7 @@ class pilotfile { bool save_player(player *_p = nullptr); bool save_savefile(); - void clear_savefile(bool reset_ships_and_weapons); + void clear_savefile(bool reset_ships_and_weapons, bool preserve_stats = false); // updating stats, multi and/or all-time void update_stats(scoring_struct *stats, bool training = false); @@ -215,7 +215,7 @@ class pilotfile { // -------------------------------------------------------------------- // CSG specific // -------------------------------------------------------------------- - void csg_reset_data(bool reset_ships_and_weapons); + void csg_reset_data(bool reset_ships_and_weapons, bool preserve_stats = false); void csg_close(); void csg_read_flags(); diff --git a/fred2/campaigneditordlg.cpp b/fred2/campaigneditordlg.cpp index 2b31c02810f..f6694de0837 100644 --- a/fred2/campaigneditordlg.cpp +++ b/fred2/campaigneditordlg.cpp @@ -201,7 +201,7 @@ void campaign_editor::load_campaign(const char *filename, const char *full_path) else m_current_campaign_path = _T(""); - auto result = mission_campaign_load(filename, full_path, nullptr, 0); + auto result = mission_campaign_load(filename, full_path, nullptr, false); if (result != 0) { if (result == CAMPAIGN_ERROR_CORRUPT) MessageBox("Requested campaign file is corrupt.", "Could not load campaign file"); diff --git a/qtfred/src/mission/dialogs/CampaignEditorDialogModel.cpp b/qtfred/src/mission/dialogs/CampaignEditorDialogModel.cpp index 21196c3e58d..fb319e7b98f 100644 --- a/qtfred/src/mission/dialogs/CampaignEditorDialogModel.cpp +++ b/qtfred/src/mission/dialogs/CampaignEditorDialogModel.cpp @@ -423,7 +423,7 @@ void CampaignEditorDialogModel::loadCampaignFromFile(const SCP_string& filename) // Attempt to load the selected file into the global Campaign struct. // We pass the full path via the filename argument now. - if (mission_campaign_load(filename.c_str(), filename.c_str(), nullptr, 0) != 0) { + if (mission_campaign_load(filename.c_str(), filename.c_str(), nullptr, false) != 0) { // Load failed. Reset the model to a clean "new campaign" state. initializeData(nullptr); clearCampaignGlobal(); // Ensure cleanup after failed load From 5e65bdab1a9a132ef1aa044e86c6013baeb43758 Mon Sep 17 00:00:00 2001 From: Goober5000 Date: Sun, 10 May 2026 02:19:04 -0400 Subject: [PATCH 3/3] this might finally work --- code/menuui/readyroom.cpp | 6 +++--- code/mission/missioncampaign.cpp | 9 +++++---- code/mission/missioncampaign.h | 2 +- code/pilotfile/csg.cpp | 13 +++++++------ code/pilotfile/pilotfile.h | 4 ++-- code/stats/scoring.cpp | 11 +++++++---- code/stats/scoring.h | 2 +- 7 files changed, 26 insertions(+), 21 deletions(-) diff --git a/code/menuui/readyroom.cpp b/code/menuui/readyroom.cpp index 6a5a131c520..ec08bd4cc0b 100644 --- a/code/menuui/readyroom.cpp +++ b/code/menuui/readyroom.cpp @@ -1586,11 +1586,11 @@ void campaign_reset(const SCP_string& campaign_file) return; } - // note: we do not toss all-time stats from player's performance in campaign up till now; - // preserve_stats=true tells mission_campaign_load() (and through it, csg_reset_data()) + // note: just as in retail, we do not toss all-time stats from player's performance in campaign up till now; + // reset_accumulated_stats=false tells mission_campaign_load() (and through it, csg_reset_data()) // to leave Player->stats alone when it recreates the CSG, so accumulated score/rank/kills/medals carry over mission_campaign_savefile_delete(campaign_file.c_str()); - const int load_status = mission_campaign_load(campaign_file.c_str(), nullptr, nullptr, true, true); + const int load_status = mission_campaign_load(campaign_file.c_str(), nullptr, nullptr, true, false); // see if we successfully loaded this campaign if (load_status == 0) { diff --git a/code/mission/missioncampaign.cpp b/code/mission/missioncampaign.cpp index b70dce516d7..6612d881c6e 100644 --- a/code/mission/missioncampaign.cpp +++ b/code/mission/missioncampaign.cpp @@ -437,7 +437,7 @@ void mission_campaign_get_sw_info() * this file. If you change the format of the campaign file, you should be sure these related * functions work properly and update them if it breaks them. */ -int mission_campaign_load(const char* filename, const char* full_path, player* pl, bool load_savefile, bool preserve_stats) +int mission_campaign_load(const char* filename, const char* full_path, player* pl, bool load_savefile, bool reset_accumulated_stats) { int i; char name[NAME_LENGTH], type[NAME_LENGTH], temp[NAME_LENGTH]; @@ -710,9 +710,10 @@ int mission_campaign_load(const char* filename, const char* full_path, player* p // start with fresh new campaign data else { // don't reset ships and weapons because they have just been set to the campaign's starting ones; - // preserve_stats is true when the caller is resetting an existing campaign and wants to - // keep the player's accumulated totals (score, rank, kills, medals) - Pilot.clear_savefile(false, preserve_stats); + // don't reset score/rank because it tracks all-time score/rank and we want to stay consistent; + // reset_accumulated_stats is false when the caller is resetting an existing campaign and wants to + // keep the player's accumulated totals + Pilot.clear_savefile(false, reset_accumulated_stats, false); Campaign.next_mission = 0; Pilot.save_savefile(); } diff --git a/code/mission/missioncampaign.h b/code/mission/missioncampaign.h index a0ace7592d8..232e16da1ba 100644 --- a/code/mission/missioncampaign.h +++ b/code/mission/missioncampaign.h @@ -175,7 +175,7 @@ void player_loadout_init(); void mission_campaign_init( void ); // load up and initialize a new campaign -int mission_campaign_load(const char* filename, const char* full_path = nullptr, player* pl = nullptr, bool load_savefile = true, bool preserve_stats = false); +int mission_campaign_load(const char* filename, const char* full_path = nullptr, player* pl = nullptr, bool load_savefile = true, bool reset_accumulated_stats = true); bool campaign_is_ignored(const char *filename); diff --git a/code/pilotfile/csg.cpp b/code/pilotfile/csg.cpp index 562806846b8..c9c82dfe042 100644 --- a/code/pilotfile/csg.cpp +++ b/code/pilotfile/csg.cpp @@ -1594,7 +1594,7 @@ void pilotfile::csg_write_container(const sexp_container &container) } } -void pilotfile::csg_reset_data(bool reset_ships_and_weapons, bool preserve_stats) +void pilotfile::csg_reset_data(bool reset_ships_and_weapons, bool reset_accumulated_stats, bool reset_score_and_rank) { int idx; cmission *missionp; @@ -1607,8 +1607,9 @@ void pilotfile::csg_reset_data(bool reset_ships_and_weapons, bool preserve_stats // init stats (unless the caller wants to keep the player's accumulated totals, // e.g. for a campaign reset) - if (!preserve_stats) { - p->stats.init(); + if (reset_accumulated_stats) { + // even when we do clear stats, we might not clear score or rank + p->stats.init(reset_score_and_rank); } // zero out allowed ships/weapons @@ -1726,7 +1727,7 @@ bool pilotfile::load_savefile(player *_p, const char *campaign) mprintf(("CSG => Loading '%s' with version %d...\n", filename.c_str(), (int)csg_ver)); - csg_reset_data(true); + csg_reset_data(true, true, true); // the point of all this: read in the CSG contents while ( !cfeof(cfp) ) { @@ -1951,7 +1952,7 @@ bool pilotfile::save_savefile() return true; } -void pilotfile::clear_savefile(bool reset_ships_and_weapons, bool preserve_stats) +void pilotfile::clear_savefile(bool reset_ships_and_weapons, bool reset_accumulated_stats, bool reset_score_and_rank) { if (Game_mode & GM_MULTIPLAYER) { return; @@ -1961,7 +1962,7 @@ void pilotfile::clear_savefile(bool reset_ships_and_weapons, bool preserve_stats Assert((Player_num >= 0) && (Player_num < MAX_PLAYERS)); p = &Players[Player_num]; - csg_reset_data(reset_ships_and_weapons, preserve_stats); + csg_reset_data(reset_ships_and_weapons, reset_accumulated_stats, reset_score_and_rank); } /* diff --git a/code/pilotfile/pilotfile.h b/code/pilotfile/pilotfile.h index 3f8271c2a9c..35d6b13aeb9 100644 --- a/code/pilotfile/pilotfile.h +++ b/code/pilotfile/pilotfile.h @@ -93,7 +93,7 @@ class pilotfile { bool save_player(player *_p = nullptr); bool save_savefile(); - void clear_savefile(bool reset_ships_and_weapons, bool preserve_stats = false); + void clear_savefile(bool reset_ships_and_weapons, bool reset_accumulated_stats, bool reset_score_and_rank); // updating stats, multi and/or all-time void update_stats(scoring_struct *stats, bool training = false); @@ -215,7 +215,7 @@ class pilotfile { // -------------------------------------------------------------------- // CSG specific // -------------------------------------------------------------------- - void csg_reset_data(bool reset_ships_and_weapons, bool preserve_stats = false); + void csg_reset_data(bool reset_ships_and_weapons, bool reset_accumulated_stats, bool reset_score_and_rank); void csg_close(); void csg_read_flags(); diff --git a/code/stats/scoring.cpp b/code/stats/scoring.cpp index 6b8542da3d6..0e9acfba475 100644 --- a/code/stats/scoring.cpp +++ b/code/stats/scoring.cpp @@ -408,13 +408,16 @@ void traitor_init() } // initialize a nice blank scoring element -void scoring_struct::init() +void scoring_struct::init(bool reset_score_and_rank) { flags = 0; - score = 0; - rank = 0; - medal_counts.assign((int)Medals.size(), 0); + if (reset_score_and_rank) { + score = 0; + rank = 0; + } + + medal_counts.assign(Medals.size(), 0); memset(kills, 0, sizeof(kills)); assists = 0; diff --git a/code/stats/scoring.h b/code/stats/scoring.h index ac9d3dc787f..27667ee0c82 100644 --- a/code/stats/scoring.h +++ b/code/stats/scoring.h @@ -142,7 +142,7 @@ class scoring_struct scoring_struct(const scoring_struct &s) { assign(s); } scoring_struct& operator=(const scoring_struct &s) { assign(s); return *this; } - void init(); + void init(bool reset_score_and_rank = true); void assign(const scoring_struct &s); bool operator==(const scoring_struct& rhs) const;