diff --git a/SKSE/Plugins/EnderalSteam.ini b/SKSE/Plugins/EnderalSteam.ini index dec0b50e9..fa8d79d5e 100644 --- a/SKSE/Plugins/EnderalSteam.ini +++ b/SKSE/Plugins/EnderalSteam.ini @@ -1,3 +1,4 @@ ShowWarningOnInitFail = true SendAchievementsToLE = false -TestMode = true +TestMode = false +ReloadSteamClient = true diff --git a/source/Steam DLL/cmake/version.rc.in b/source/Steam DLL/cmake/version.rc.in index fbbaba1ae..afab832b3 100644 --- a/source/Steam DLL/cmake/version.rc.in +++ b/source/Steam DLL/cmake/version.rc.in @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2e9bdb72c2a13eb8bf16df3ff636884e73787bc7f86fbd2e88100bfc65908eb0 -size 985 +oid sha256:7ce4c96c3840d94508d91b610ddc4b41cecd56bfa59be71c4d0cc75899cda5ed +size 975 diff --git a/source/Steam DLL/src/Achievements.cpp b/source/Steam DLL/src/Achievements.cpp index 03d7f08ac..ba372f894 100644 --- a/source/Steam DLL/src/Achievements.cpp +++ b/source/Steam DLL/src/Achievements.cpp @@ -1,66 +1,182 @@ #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 } + { "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 { - if (SteamInstance() == nullptr) { - SteamAPI_Shutdown(); + const char* appId = settings.at("SendAchievementsToLE") ? APPID_ENDERAL_LE : APPID_ENDERAL_SE; - if (settings.at("SendAchievementsToLE")) { - SetEnvironmentVariable(L"SteamAppID", L"933480"); - SetEnvironmentVariable(L"SteamGameId", L"933480"); - } else { - SetEnvironmentVariable(L"SteamAppID", L"976620"); - SetEnvironmentVariable(L"SteamGameId", L"976620"); - } + logModuleState("BEFORE"); + logEnvironment("BEFORE"); + logger::info("[BEFORE] HSteamUser = {}", SteamAPI_GetHSteamUser()); - bool success = SteamAPI_Init(); + // 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 (success) { - logger::info("{}", "Steam api init was successfull"); - } else { - logger::error("{}", "Error while initializing the steam api"); - if (settings.at("ShowWarningOnInitFail")) { - RE::DebugMessageBox("Unable to initialize Steam achievements. Try to restart the game and the Steam client. This warning can be disabled in SKSE\\Plugins\\EnderalSteam.ini."); - } - } - - SteamInstance(new AchievementHolder()); + if (settings.at("ReloadSteamClient")) { + unloadSteamClient(); } - else { - logger::info("{}", "Already initialized steam api, skipping it"); + + 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()); - if (settings.at("ShowWarningOnInitFail")) { - RE::DebugMessageBox("Unable to initialize Steam achievements. Try to restart the game and the Steam client. This warning can be disabled in SKSE\\Plugins\\EnderalSteam.ini."); - } } } 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) { @@ -80,6 +196,11 @@ namespace Achievements { 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); @@ -96,6 +217,12 @@ namespace Achievements { void AchievementHolder::start() { - this->stats->RequestCurrentStats(); + if (!this->stats) { + return; + } + + if (!this->stats->RequestCurrentStats()) { + logger::error("{}", "RequestCurrentStats failed, achievements may not unlock"); + } } -} \ No newline at end of file +} diff --git a/source/Steam DLL/src/Achievements.h b/source/Steam DLL/src/Achievements.h index f15722e58..26ee7736e 100644 --- a/source/Steam DLL/src/Achievements.h +++ b/source/Steam DLL/src/Achievements.h @@ -1,7 +1,6 @@ #pragma once -//Steam API Version 1.31 matches the Skyrim Steam API version +//Steam API version 1.55, matching Skyrim SE 1.6.1170 (pre-1.6.1130 shipped 1.31) #include "steam/steam_api.h" -#include namespace Achievements { @@ -18,7 +17,11 @@ namespace Achievements { bool setAchievementUnlocked(const char * achievementName); }; - static std::unique_ptr singleton(nullptr); - + // Opens the Steam session under Enderal's AppID. Must run before the game + // opens its own - see the comment inside. void startSteam(); -} \ No newline at end of file + + // Result of startSteam(), so the warning can be shown once a UI exists. + bool steamInitFailed(); + bool shouldWarnOnInitFail(); +} diff --git a/source/Steam DLL/src/EventListener.cpp b/source/Steam DLL/src/EventListener.cpp index 168054790..cac7de509 100644 --- a/source/Steam DLL/src/EventListener.cpp +++ b/source/Steam DLL/src/EventListener.cpp @@ -20,8 +20,12 @@ auto EventListener::ProcessEvent( { if (a_event->opening && a_event->menuName == "Main Menu") { RE::UI::GetSingleton()->RemoveEventSink(GetSingleton()); - logger::info("{}", "Main menu opened, trying to init steam API."); - Achievements::startSteam(); + + // Steam is started at plugin load, long before there is a UI to complain to, + // so the warning waits until the main menu is up. + if (Achievements::steamInitFailed() && Achievements::shouldWarnOnInitFail()) { + RE::DebugMessageBox("Unable to initialize Steam achievements. Try to restart the game and the Steam client. This warning can be disabled in SKSE\\Plugins\\EnderalSteam.ini."); + } } return RE::BSEventNotifyControl::kContinue; diff --git a/source/Steam DLL/src/Main.cpp b/source/Steam DLL/src/Main.cpp index 0131027e2..6677ce7db 100644 --- a/source/Steam DLL/src/Main.cpp +++ b/source/Steam DLL/src/Main.cpp @@ -38,6 +38,11 @@ SKSEPluginLoad(const LoadInterface* skse) { Init(skse); + // Has to happen here, not from a game event: the game opens its own Steam session + // from BSWin32SystemUtility during WinMain, and whichever session is opened first + // decides which AppID the process stays registered as. + Achievements::startSteam(); + EventListener::Install(); GetPapyrusInterface()->Register(Papyrus::Bind); diff --git a/source/Steam DLL/src/PapyrusFunctions.h b/source/Steam DLL/src/PapyrusFunctions.h index 1d273f732..4bc421a33 100644 --- a/source/Steam DLL/src/PapyrusFunctions.h +++ b/source/Steam DLL/src/PapyrusFunctions.h @@ -8,7 +8,12 @@ namespace Papyrus::PapyrusFunctions bool CallUnlockAchievement(RE::StaticFunctionTag* tag, RE::BSFixedString achievement) { if (AchievementsEnabled()) { - return SteamInstance()->setAchievementUnlocked(achievement.c_str()); + auto* steam = SteamInstance(); + if (!steam) { + logger::error("Steam is not initialized, cannot unlock achievement: {}", achievement.c_str()); + return false; + } + return steam->setAchievementUnlocked(achievement.c_str()); } else { RE::DebugNotification(std::format("Achievement unlocked: {}", achievement.c_str()).c_str()); logger::info("{}", std::format("Achievement unlocked: {}", achievement.c_str()).c_str()); diff --git a/source/Steam DLL/src/Util.h b/source/Steam DLL/src/Util.h index 0e6543f79..2afcd5094 100644 --- a/source/Steam DLL/src/Util.h +++ b/source/Steam DLL/src/Util.h @@ -2,6 +2,7 @@ #include "Achievements.h" #include +#include inline const SKSE::LoadInterface* GetLoadInterface(const SKSE::LoadInterface* loadInterface = nullptr) { @@ -21,11 +22,11 @@ inline Achievements::AchievementHolder* SteamInstance(Achievements::AchievementH return singleton; } -inline bool AchievementsEnabled(bool bEnabled = NULL) +inline bool AchievementsEnabled(std::optional bEnabled = std::nullopt) { - static bool value; - if (bEnabled != NULL) { - value = bEnabled; + static bool value = false; + if (bEnabled.has_value()) { + value = *bEnabled; } return value; } @@ -68,7 +69,7 @@ inline void LoadINI(std::map* settings, const char* iniPath) } if (bUpdateINI) { - logger::info("New settings detected, adding to ArtifactTracker.ini"); + logger::info("New settings detected, updating {}", iniPath); ini.SaveFile(iniPath); }