diff --git a/dll/dll/settings.h b/dll/dll/settings.h index 71f56a8a..7110332d 100644 --- a/dll/dll/settings.h +++ b/dll/dll/settings.h @@ -300,6 +300,9 @@ public: // synchronize user stats/achievements with game servers as soon as possible instead of caching them. bool immediate_gameserver_stats = false; + // steam_game_stats + std::string steam_game_stats_reports_dir{}; + //overlay bool disable_overlay = true; int overlay_hook_delay_sec = 0; // "Saints Row (2022)" needs a lot of time to initialize, otherwise detection will fail diff --git a/dll/dll/steam_gamestats.h b/dll/dll/steam_gamestats.h index 54b9da67..a0c12b8f 100644 --- a/dll/dll/steam_gamestats.h +++ b/dll/dll/steam_gamestats.h @@ -64,11 +64,14 @@ private: struct Session_t { + bool ended = false; + bool saved_to_disk = false; + uint64 account_id{}; + EGameStatsAccountType nAccountType{}; RTime32 rtTimeStarted{}; RTime32 rtTimeEnded{}; int nReasonCode{}; - bool ended = false; std::map attributes{}; std::vector> tables{}; @@ -90,6 +93,8 @@ private: Attribute_t *get_or_create_row_att(uint64 ulRowID, const char *att_name, Table_t &table, AttributeType_t type_if_create); Session_t* get_last_active_session(); + std::string sanitize_csv_value(std::string_view value); + void save_session_to_disk(Steam_GameStats::Session_t &session, uint64 session_id); void steam_run_callback(); // user connect/disconnect diff --git a/dll/settings_parser.cpp b/dll/settings_parser.cpp index 2dfcf71f..6592b67b 100644 --- a/dll/settings_parser.cpp +++ b/dll/settings_parser.cpp @@ -1345,6 +1345,20 @@ static void parse_overlay_general_config(class Settings *settings_client, class } +// main::misc::steam_game_stats_reports_dir +static void parse_steam_game_stats_reports_dir(class Settings *settings_client, class Settings *settings_server) +{ + std::string line(common_helpers::string_strip(ini.GetValue("main::misc", "steam_game_stats_reports_dir", ""))); + if (line.size()) { + auto folder = common_helpers::to_absolute(line, get_full_program_path()); + if (folder.size()) { + PRINT_DEBUG("ISteamGameStats reports will be saved to '%s'", folder.c_str()); + settings_client->steam_game_stats_reports_dir = folder; + settings_server->steam_game_stats_reports_dir = folder; + } + } +} + // mainly enable/disable features static void parse_simple_features(class Settings *settings_client, class Settings *settings_server) { @@ -1705,6 +1719,7 @@ uint32 create_localstorage_settings(Settings **settings_client_out, Settings **s parse_overlay_general_config(settings_client, settings_server); load_overlay_appearance(settings_client, settings_server, local_storage); + parse_steam_game_stats_reports_dir(settings_client, settings_server); *settings_client_out = settings_client; *settings_server_out = settings_server; diff --git a/dll/steam_gamestats.cpp b/dll/steam_gamestats.cpp index 9039acb3..297d0ccc 100644 --- a/dll/steam_gamestats.cpp +++ b/dll/steam_gamestats.cpp @@ -22,6 +22,7 @@ */ #include "dll/steam_gamestats.h" +#include Steam_GameStats::Attribute_t::Attribute_t(AttributeType_t type) @@ -194,6 +195,7 @@ SteamAPICall_t Steam_GameStats::GetNewSession( int8 nAccountType, uint64 ulAccou auto session_id = create_session_id(); Session_t new_session{}; + new_session.account_id = ulAccountID; new_session.nAccountType = (EGameStatsAccountType)nAccountType; new_session.rtTimeStarted = rtTimeStarted; sessions.insert_or_assign(session_id, new_session); @@ -484,6 +486,113 @@ EResult Steam_GameStats::AddRowAttributeInt64( uint64 ulRowID, const char *pstrN // --- steam callbacks +std::string Steam_GameStats::sanitize_csv_value(std::string_view value) +{ + // ref: https://en.wikipedia.org/wiki/Comma-separated_values + // double quotes must be represented by a pair of double quotes + auto val_str = common_helpers::str_replace_all(value, "\"", "\"\""); + // multiline values aren't supported by all parsers + val_str = common_helpers::str_replace_all(val_str, "\r\n", "\n"); + val_str = common_helpers::str_replace_all(val_str, "\n", " "); + return val_str; +} + +void Steam_GameStats::save_session_to_disk(Steam_GameStats::Session_t &session, uint64 session_id) +{ + auto folder = std::to_string(session.account_id) + "_" + std::to_string(session.rtTimeStarted) + "_" + std::to_string(session_id); + auto folder_p = std::filesystem::u8path(settings->steam_game_stats_reports_dir) / std::filesystem::u8path(folder); + auto folder_u8_str = folder_p.u8string(); + + // save session attributes + if (session.attributes.size()) { + std::stringstream ss{}; + ss << "Session attribute,Value\n"; + for (const auto& [name, val] : session.attributes) { + std::string val_str{}; + switch (val.type) { + case AttributeType_t::Int: val_str = std::to_string(val.n_data); break; + case AttributeType_t::Str: val_str = val.s_data; break; + case AttributeType_t::Float: val_str = std::to_string(val.f_data); break; + case AttributeType_t::Int64: val_str = std::to_string(val.ll_data); break; + } + + val_str = sanitize_csv_value(val_str); + auto name_str = sanitize_csv_value(name); + ss << "\"" << name_str << "\",\"" << val_str << "\"\n"; + } + auto ss_str = ss.str(); + Local_Storage::store_file_data(folder_u8_str, "session_attributes.csv", ss_str.c_str(), (unsigned int)ss_str.size()); + } + + // save each table + for (const auto& [table_name, table_data] : session.tables) { + bool rows_has_attributes = std::any_of(table_data.rows.begin(), table_data.rows.end(), [](const Steam_GameStats::Row_t &item) { + return item.attributes.size() > 0; + }); + + if (!rows_has_attributes) continue; + + // convert the data representation to be column oriented + // key=column header/title + // value = list of column values + std::unordered_map> columns{}; + for (size_t row_idx = 0; row_idx < table_data.rows.size(); ++row_idx) { + const auto &row = table_data.rows[row_idx]; + for (const auto& [att_name, att_val] : row.attributes) { + auto [column_it, new_column] = columns.emplace(att_name, std::vector{}); + auto &column_values = column_it->second; + // when adding new column make sure we have correct rows count + if (new_column) { + column_values.assign(table_data.rows.size(), nullptr); + } + // add the row value in its correct place + column_values[row_idx] = &att_val; + } + } + + std::stringstream ss_table{}; + // write header + bool first_header_atom = true; + for (const auto& [col_name, _] : columns) { + auto csv_col_name = sanitize_csv_value(col_name); + if (first_header_atom) { + first_header_atom = false; + ss_table << "\"" << csv_col_name << "\""; + } else { + ss_table << ",\"" << csv_col_name << "\""; + } + } + ss_table << "\n"; + // write values + for (size_t row_idx = 0; row_idx < table_data.rows.size(); ++row_idx) { + bool first_col_cell = true; + for (const auto& [_, col_values] : columns) { + auto &cell_val = col_values[row_idx]; + + std::string val_str{}; + switch (cell_val->type) { + case AttributeType_t::Int: val_str = std::to_string(cell_val->n_data); break; + case AttributeType_t::Str: val_str = cell_val->s_data; break; + case AttributeType_t::Float: val_str = std::to_string(cell_val->f_data); break; + case AttributeType_t::Int64: val_str = std::to_string(cell_val->ll_data); break; + } + + val_str = sanitize_csv_value(val_str); + if (first_col_cell) { + first_col_cell = false; + ss_table << "\"" << val_str << "\""; + } else { + ss_table << ",\"" << val_str << "\""; + } + } + } + ss_table << "\n"; + auto ss_str = ss_table.str(); + Local_Storage::store_file_data(folder_u8_str, table_name.c_str(), ss_str.c_str(), (unsigned int)ss_str.size()); + + } +} + void Steam_GameStats::steam_run_callback() { // remove ended sessions that are inactive @@ -496,6 +605,13 @@ void Steam_GameStats::steam_run_callback() auto &session = session_it->second; if (session.ended) { + if (!session.saved_to_disk) { + session.saved_to_disk = true; + if (settings->steam_game_stats_reports_dir.size()) { + save_session_to_disk(session, session_it->first); + } + } + if ( (now_epoch_sec.count() - (long long)session.rtTimeEnded) >= MAX_DEAD_SESSION_SECONDS ) { should_remove = true; PRINT_DEBUG("removing outdated session id=%llu", session_it->first); diff --git a/dll/steam_matchmaking.cpp b/dll/steam_matchmaking.cpp index c359473b..b3e3a50f 100644 --- a/dll/steam_matchmaking.cpp +++ b/dll/steam_matchmaking.cpp @@ -362,7 +362,7 @@ int Steam_Matchmaking::AddFavoriteGame( AppId_t nAppID, uint32 nIP, uint16 nConn directory_path = file_path.substr(0, file_directory); file_name = file_path.substr(file_directory); } - Local_Storage::store_file_data(directory_path, file_name, (char *)list.data(), list.size()); + Local_Storage::store_file_data(directory_path, file_name, (char *)list.data(), (unsigned int)list.size()); ++list_lines; return static_cast(list_lines); @@ -379,7 +379,7 @@ int Steam_Matchmaking::AddFavoriteGame( AppId_t nAppID, uint32 nIP, uint16 nConn directory_path = file_path.substr(0, file_directory); file_name = file_path.substr(file_directory); } - Local_Storage::store_file_data(directory_path, file_name, (char *)newip_string.data(), newip_string.size()); + Local_Storage::store_file_data(directory_path, file_name, (char *)newip_string.data(), (unsigned int)newip_string.size()); return 1; } @@ -429,7 +429,7 @@ bool Steam_Matchmaking::RemoveFavoriteGame( AppId_t nAppID, uint32 nIP, uint16 n directory_path = file_path.substr(0, file_directory); file_name = file_path.substr(file_directory); } - Local_Storage::store_file_data(directory_path, file_name, (char *)list.data(), list.size()); + Local_Storage::store_file_data(directory_path, file_name, (char *)list.data(), (unsigned int)list.size()); return true; } diff --git a/helpers/common_helpers.cpp b/helpers/common_helpers.cpp index e3fcd193..f040fde0 100644 --- a/helpers/common_helpers.cpp +++ b/helpers/common_helpers.cpp @@ -477,3 +477,31 @@ std::string common_helpers::to_str(std::wstring_view wstr) return {}; } + +std::string common_helpers::str_replace_all(std::string_view source, std::string_view substr, std::string_view replace) +{ + if (source.empty() || substr.empty()) return std::string(source); + + std::string out{}; + out.reserve(source.size() / 4); // out could be bigger or smaller than source, start small + + size_t start_offset = 0; + auto f_idx = source.find(substr); + while (std::string::npos != f_idx) { + // copy the chars before the match + auto chars_count_until_match = f_idx - start_offset; + out.append(source, start_offset, chars_count_until_match); + // copy the replace str + out.append(replace); + + // adjust the start offset to point at the char after this match + start_offset = f_idx + substr.size(); + // search for next match + f_idx = source.find(substr, start_offset); + } + + // copy last remaining part + out.append(source, start_offset, std::string::npos); + + return out; +} diff --git a/helpers/common_helpers/common_helpers.hpp b/helpers/common_helpers/common_helpers.hpp index ea30c4ed..7d6707a0 100644 --- a/helpers/common_helpers/common_helpers.hpp +++ b/helpers/common_helpers/common_helpers.hpp @@ -110,4 +110,6 @@ std::string get_utc_time(); std::wstring to_wstr(std::string_view str); std::string to_str(std::wstring_view wstr); +std::string str_replace_all(std::string_view source, std::string_view substr, std::string_view replace); + } diff --git a/post_build/steam_settings.EXAMPLE/configs.main.EXAMPLE.ini b/post_build/steam_settings.EXAMPLE/configs.main.EXAMPLE.ini index 4f4799ca..e76f2bd1 100644 --- a/post_build/steam_settings.EXAMPLE/configs.main.EXAMPLE.ini +++ b/post_build/steam_settings.EXAMPLE/configs.main.EXAMPLE.ini @@ -124,3 +124,9 @@ disable_steamoverlaygameid_env_var=0 # https://developer.valvesoftware.com/wiki/Dedicated_Servers_List # default=0 enable_steam_preowned_ids=0 +# some Source-based games use the interface `ISteamGameStats` to report some stats +# you can make the emu save this data to a folder +# empty value = don't save anything (default) +# the emu will create the folders if they are missing but the path specified must be writable +# default= +steam_game_stats_reports_dir=./path/relative/to/dll/