#include "Achievements.h" #include "Util.h" #include "steam/steam_api.h" #include #include #include namespace Achievements { namespace { // Enderal's own AppIDs. Skyrim SE is 489830. constexpr const char* APPID_ENDERAL_SE = "976620"; constexpr const char* APPID_ENDERAL_LE = "933480"; bool bInitFailed = false; bool bWarnOnInitFail = true; constexpr const char* STEAMCLIENT_MODULE = "steamclient64.dll"; // Which app a process is registered as is decided by steamclient64.dll, and // these variables are how it is told. It latches them when the module is // *loaded* into the process - not when a pipe is created - so changing them // afterwards does nothing at all. steam_api never reads them back either, it // asks ISteamUtils::GetAppID(). (steam_appid.txt is no help: only // SteamAPI_RestartAppIfNecessary reads it, and Skyrim does not call that.) void setAppIdEnvironment(const char* appId) { SetEnvironmentVariableA("SteamAppId", appId); SetEnvironmentVariableA("SteamGameId", appId); SetEnvironmentVariableA("SteamOverlayGameId", appId); } void logEnvironment(const char* label) { static const char* const vars[] = { "SteamAppId", "SteamGameId", "SteamOverlayGameId", "SteamClientLaunch" }; for (const char* name : vars) { char value[64] = ""; GetEnvironmentVariableA(name, value, sizeof(value)); logger::info("[{}] {} = {}", label, name, value); } } void logModuleState(const char* label) { logger::info("[{}] steam_api64={}, {}={}, GameOverlayRenderer64={}", label, GetModuleHandleA("steam_api64.dll") ? "loaded" : "absent", STEAMCLIENT_MODULE, GetModuleHandleA(STEAMCLIENT_MODULE) ? "loaded" : "absent", GetModuleHandleA("GameOverlayRenderer64.dll") ? "loaded" : "absent"); } // Drop every outstanding reference to steamclient64.dll so that the // SteamAPI_Init() below loads a fresh copy, which then picks up the AppID we // just put in the environment. Something loads it before SKSE gets control // (the SteamStub DRM wrapper is the only candidate that runs that early) and // leaves it resident with Skyrim's AppID latched in. // // SteamAPI_Shutdown() has to run first: it releases steam_api's own reference // and clears its cached module handle, so it will not later FreeLibrary a // handle we already invalidated. bool unloadSteamClient() { constexpr int MAX_REFERENCES = 32; int released = 0; while (auto* module = GetModuleHandleA(STEAMCLIENT_MODULE)) { if (released >= MAX_REFERENCES) { logger::error("{} is still loaded after releasing {} references, giving up", STEAMCLIENT_MODULE, released); return false; } FreeLibrary(module); ++released; } if (released > 0) { logger::info("Unloaded {} after releasing {} reference(s)", STEAMCLIENT_MODULE, released); } return true; } } bool steamInitFailed() { return bInitFailed; } bool shouldWarnOnInitFail() { return bWarnOnInitFail; } void startSteam() { std::map settings{ { "SendAchievementsToLE", false }, { "TestMode", false }, { "ShowWarningOnInitFail", true }, { "ReloadSteamClient", true } }; LoadINI(&settings, "Data/SKSE/Plugins/EnderalSteam.ini"); AchievementsEnabled(!settings.at("TestMode")); bWarnOnInitFail = settings.at("ShowWarningOnInitFail"); if (settings.at("TestMode")) { logger::info("{}", "TestMode is on, leaving the Steam session alone"); return; } if (SteamInstance() != nullptr) { logger::info("{}", "Already initialized steam api, skipping it"); return; } try { const char* appId = settings.at("SendAchievementsToLE") ? APPID_ENDERAL_LE : APPID_ENDERAL_SE; logModuleState("BEFORE"); logEnvironment("BEFORE"); logger::info("[BEFORE] HSteamUser = {}", SteamAPI_GetHSteamUser()); // This runs from SKSEPluginLoad, i.e. after the CRT global initializers but // before WinMain. Skyrim opens its own Steam session much later, from // BSWin32SystemUtility, so we get here first and its SteamAPI_Init() then // returns early because ours is already up. // // Being first is not enough on its own, though - see unloadSteamClient(). SteamAPI_Shutdown(); if (settings.at("ReloadSteamClient")) { unloadSteamClient(); } setAppIdEnvironment(appId); if (!SteamAPI_Init()) { bInitFailed = true; logger::error("{}", "Error while initializing the steam api"); return; } logger::info("Steam api init was successfull, requested AppID {}", appId); // The AppID the Steam client actually gave us. Anything other than the one // requested above means the session still belongs to another app - Enderal's // achievement names would not resolve there - so treat it like a failed init: // leave achievements unwired and let the main-menu warning fire. if (auto* utils = SteamUtils()) { const uint32 sessionAppId = utils->GetAppID(); logger::info("Steam session is registered as AppID {}", sessionAppId); if (sessionAppId != static_cast(std::strtoul(appId, nullptr, 10))) { bInitFailed = true; logger::error("{}", "Session kept a foreign AppID, not wiring up achievements"); return; } } else { logger::warn("{}", "ISteamUtils is unavailable, cannot verify the session AppID"); } auto* holder = new AchievementHolder(); SteamInstance(holder); // Steam wants the user's stats downloaded before achievements can be set. // Nothing used to call this, so UserStatsReceived_t never fired. holder->start(); } catch (const std::exception& ex) { bInitFailed = true; std::string msg = "Exception while initializing the Steam API, steam achievements will not be available: " + std::string(ex.what()); logger::error("{}", msg.c_str()); } } AchievementHolder::AchievementHolder() : stats(SteamUserStats()), callback(this, &AchievementHolder::onUserStatsReceived) { if (!this->stats) { logger::error("{}", "ISteamUserStats is unavailable, achievements will not be unlocked"); } } void AchievementHolder::onUserStatsReceived(UserStatsReceived_t * event) { try { std::string msg = "User id: " + std::to_string(event->m_steamIDUser.ConvertToUint64()) + ", game id: " + std::to_string(event->m_nGameID) + ", success state: " + std::to_string(event->m_eResult); logger::info("{}", msg.c_str()); uint32 achievementCount = this->stats->GetNumAchievements(); msg = "There are " + std::to_string(achievementCount) + " achievements"; logger::info("{}", msg.c_str()); } catch (const std::exception& ex) { std::string msg = "Exception during steam callback: onUserStatsReceived. Failed to print data: " + std::string(ex.what()); logger::info("{}", msg.c_str()); } } bool AchievementHolder::setAchievementUnlocked(const char * achievementName) { if (!this->stats) { logger::error("{}", "Cannot unlock achievement, ISteamUserStats is unavailable"); return false; } std::string msg = "Unlocking achievement: " + std::string(achievementName); logger::info("{}", msg.c_str()); bool success = this->stats->SetAchievement(achievementName); if (!success) { logger::error("{}", "Error while unlocking achievement"); return false; } success = this->stats->StoreStats(); if (!success) { logger::error("{}", "Error while storing unlocked achievement"); } return success; } void AchievementHolder::start() { if (!this->stats) { return; } if (!this->stats->RequestCurrentStats()) { logger::error("{}", "RequestCurrentStats failed, achievements may not unlock"); } } }