Compare commits

..

5 Commits

19 changed files with 669 additions and 199 deletions

2
.gitattributes vendored
View File

@ -8,8 +8,10 @@
*.strings filter= diff= merge= -text
*.gitignore filter= diff= merge= text eol=lf
*.gitattributes filter= diff= merge= text eol=lf
*.cmake filter= diff= merge= text eol=lf
*.xml filter= diff= merge= text eol=lf
*.json filter= diff= merge= text eol=lf
*.in filter= diff= merge= text eol=crlf
*.md filter= diff= merge= text eol=crlf
*.ini filter= diff= merge= text eol=crlf
*.txt filter= diff= merge= text eol=crlf

View File

@ -1,3 +1,4 @@
ShowWarningOnInitFail = true
SendAchievementsToLE = false
TestMode = true
TestMode = false
ReloadSteamClient = true

Binary file not shown.

BIN
source/Enderal DLL/cmake/version.rc.in (Stored with Git LFS)

Binary file not shown.

Binary file not shown.

View File

@ -533,6 +533,9 @@ FodyWeavers.xsd
build/
# Machine-specific, generated by build.cmd - see cmake/write_user_presets.cmake
CMakeUserPresets.json
contrib/Distribution/**/*.dll
contrib/Distribution/**/*.pdb
contrib/Distribution/**/*.pex

View File

@ -1,31 +1,58 @@
option(ENABLE_VCPKG OFF)
cmake_minimum_required(VERSION 3.21)
message("Using toolchain file ${CMAKE_TOOLCHAIN_FILE}.")
########################################################################################################################
## Define project
########################################################################################################################
# Get current version
set(ENDERAL_VERSION_INI "${CMAKE_CURRENT_SOURCE_DIR}/../../SKSE/Plugins/EnderalVersion.ini")
file(READ "${ENDERAL_VERSION_INI}" CONFIG_CONTENT)
string(REGEX MATCH "version[ \t]*=[ \t]*([0-9.]+)" _ ${CONFIG_CONTENT})
set(VERSION_NUMBER "${CMAKE_MATCH_1}")
# file(READ) does not register a dependency: without this, bumping the version in
# the INI would not re-run CMake and the DLL would keep the previous version.
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${ENDERAL_VERSION_INI}")
project(
EnderalSteam
VERSION 2.1.4
VERSION ${VERSION_NUMBER}
DESCRIPTION "Enderal SE Steam Support"
LANGUAGES CXX)
LANGUAGES CXX
)
# Shown as ProductName in the version resource.
set(PROJECT_FRIENDLY_NAME "Enderal SE")
# The binary FILEVERSION/PRODUCTVERSION fields need all four components;
# PROJECT_VERSION_TWEAK is empty when the INI carries only three.
if(NOT PROJECT_VERSION_TWEAK)
set(PROJECT_VERSION_TWEAK 0)
endif()
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON)
# Link-time optimization for release builds only (debug builds stay fast).
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE ON)
# CommonLibSSE-NG requires the dynamic CRT. Dependencies used to be supplied by
# vcpkg (which set this via a preset); now that they come from FetchContent we
# set it here so both build.cmd and Visual Studio "Open Folder" pick it up.
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
add_compile_definitions(NOMINMAX _USE_MATH_DEFINES WIN32_LEAN_AND_MEAN)
add_definitions(-DUNICODE -D_UNICODE)
add_compile_options(/Zc:preprocessor /EHsc)
include(GNUInstallDirs)
configure_file(
${CMAKE_CURRENT_SOURCE_DIR}/cmake/version.rc.in
${CMAKE_CURRENT_BINARY_DIR}/version.rc
@ONLY
)
#include(GNUInstallDirs)
file(
GLOB_RECURSE
sources
@ -41,81 +68,82 @@ source_group(
)
########################################################################################################################
## Configure target DLL
## Fetch dependencies (pinned versions, no vcpkg required)
########################################################################################################################
include(FetchContent)
# DirectXMath
# DirectXMath - required by DirectXTK.
FetchContent_Declare(
DirectXMath
URL "https://github.com/microsoft/DirectXMath/archive/refs/tags/apr2025.tar.gz"
OVERRIDE_FIND_PACKAGE
DOWNLOAD_EXTRACT_TIMESTAMP 1
DOWNLOAD_EXTRACT_TIMESTAMP ON
EXCLUDE_FROM_ALL
SYSTEM
)
FetchContent_MakeAvailable(DirectXMath)
add_library("Microsoft::DirectXMath" ALIAS "DirectXMath")
# DirectXTK
# DirectXTK - required by CommonLibSSE-NG (find_package(directxtk CONFIG REQUIRED)).
# PATCH_COMMAND fixes its shader-compile step under modern CMake (see the script).
FetchContent_Declare(
DirectXTK
URL "https://github.com/microsoft/DirectXTK/archive/refs/tags/jul2025.tar.gz"
DOWNLOAD_EXTRACT_TIMESTAMP 1
DOWNLOAD_EXTRACT_TIMESTAMP ON
OVERRIDE_FIND_PACKAGE
EXCLUDE_FROM_ALL
SYSTEM
PATCH_COMMAND ${CMAKE_COMMAND} -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/patch_directxtk.cmake"
)
FetchContent_MakeAvailable(DirectXTK)
add_library("Microsoft::DirectXTK" ALIAS "DirectXTK")
# simpleini
# simpleini - used directly (Util.h).
FetchContent_Declare(
simpleini
URL "https://github.com/brofield/simpleini/archive/refs/tags/v4.22.tar.gz"
DOWNLOAD_EXTRACT_TIMESTAMP 1
DOWNLOAD_EXTRACT_TIMESTAMP ON
)
FetchContent_MakeAvailable(simpleini)
INCLUDE_DIRECTORIES(${simpleini_SOURCE_DIR})
# rapidcsv
# rapidcsv - header only, required by CommonLibSSE-NG (find_path "rapidcsv.h").
FetchContent_Declare(
rapidcsv
URL "https://github.com/d99kris/rapidcsv/archive/refs/tags/v8.87.tar.gz"
DOWNLOAD_EXTRACT_TIMESTAMP 1
DOWNLOAD_EXTRACT_TIMESTAMP ON
OVERRIDE_FIND_PACKAGE
)
FetchContent_MakeAvailable(rapidcsv)
set(RAPIDCSV_INCLUDE_DIRS ${rapidcsv_SOURCE_DIR}/src)
# spdlog
set(SPDLOG_INSTALL ON CACHE INTERNAL "Install SPDLOG for CommonLibSSE")
set(SPDLOG_USE_STD_FORMAT ON CACHE INTERNAL "Use std::format in SPDLOG, not fmt")
# spdlog - used directly (PCH.h) and by CommonLibSSE-NG.
set(SPDLOG_INSTALL ON CACHE BOOL " " FORCE)
set(SPDLOG_USE_STD_FORMAT ON CACHE BOOL " " FORCE)
FetchContent_Declare(
spdlog
URL "https://github.com/gabime/spdlog/archive/refs/tags/v1.15.3.tar.gz"
DOWNLOAD_EXTRACT_TIMESTAMP 1
DOWNLOAD_EXTRACT_TIMESTAMP ON
OVERRIDE_FIND_PACKAGE
)
FetchContent_MakeAvailable(spdlog)
# xbyak
# xbyak - required by CommonLibSSE-NG when SKSE_SUPPORT_XBYAK is on.
FetchContent_Declare(
xbyak
URL "https://github.com/herumi/xbyak/archive/v7.28.tar.gz"
DOWNLOAD_EXTRACT_TIMESTAMP 1
DOWNLOAD_EXTRACT_TIMESTAMP ON
)
FetchContent_MakeAvailable(xbyak)
# CommonLibSSE
# CommonLibSSE-NG - pinned commit (SE + AE + VR runtime support).
set(SKSE_SUPPORT_XBYAK ON CACHE BOOL " " FORCE)
set(ENABLE_SKYRIM_SE ON CACHE BOOL " " FORCE)
set(ENABLE_SKYRIM_AE ON CACHE BOOL " " FORCE)
set(ENABLE_SKYRIM_VR ON CACHE BOOL " " FORCE)
set(BUILD_TESTS OFF CACHE BOOL " " FORCE)
message(STATUS "Fetching CommonLibSSE-NG (5e5417e3585c9434295e919bdda27737244e9c5a)...")
message(STATUS "Fetching CommonLibSSE-NG...")
FetchContent_Declare(
CommonLibSSE
GIT_REPOSITORY https://github.com/eddoursul/CommonLibVR.git
@ -123,15 +151,19 @@ FetchContent_Declare(
)
FetchContent_MakeAvailable(CommonLibSSE)
get_target_property(COMMONLIB_SRC_DIR CommonLibSSE SOURCE_DIR)
include(${COMMONLIB_SRC_DIR}/cmake/CommonLibSSE.cmake)
########################################################################################################################
## Configure target DLL
########################################################################################################################
add_commonlibsse_plugin(${PROJECT_NAME} SOURCES ${headers} ${sources})
add_library("${PROJECT_NAME}::${PROJECT_NAME}" ALIAS "${PROJECT_NAME}")
# Steamworks SDK import library, shipped in src/ next to the steam/ headers.
target_link_libraries(${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/steam_api64.lib)
target_link_libraries(CommonLibSSE PUBLIC
@ -164,7 +196,6 @@ target_precompile_headers(${PROJECT_NAME}
install(TARGETS ${PROJECT_NAME} DESTINATION "${CMAKE_INSTALL_LIBDIR}")
########################################################################################################################
## Automatic plugin deployment
########################################################################################################################

View File

@ -13,17 +13,6 @@
"CMAKE_CXX_FLAGS": "$env{COMMONLIBSSE_COMPILER} $env{COMMONLIBSSE_PLATFORM} $env{COMMONLIBSSE_TEXT}"
}
},
{
"name": "vcpkg",
"hidden": true,
"cacheVariables": {
"CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake",
"VCPKG_TARGET_TRIPLET": "x64-windows-skse",
"VCPKG_HOST_TRIPLET": "x64-windows-skse",
"VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/cmake",
"CMAKE_MSVC_RUNTIME_LIBRARY": "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL"
}
},
{
"name": "win32",
"hidden": true,
@ -79,100 +68,68 @@
}
}
},
{
"name": "build-tests",
"displayName": "Build Tests",
"hidden": true,
"description": "Include test suites in the build.",
"cacheVariables": {
"BUILD_TESTS": {
"type": "STRING",
"value": "ON"
}
}
},
{
"name": "build-release-msvc",
"inherits": [
"base",
"vcpkg",
"win32-unicode",
"x64",
"build-tests",
"msvc"
],
"displayName": "Release",
"displayName": "Release (MSVC)",
"description": "Optimized release build.",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/release-msvc",
"cacheVariables": {
"CMAKE_BUILD_TYPE": {
"type": "STRING",
"value": "Release"
}
"CMAKE_BUILD_TYPE": "Release"
}
},
{
"name": "build-debug-msvc",
"inherits": [
"base",
"vcpkg",
"win32-unicode",
"x64",
"build-tests",
"msvc"
],
"displayName": "Debug",
"displayName": "Debug (MSVC)",
"description": "Debug build for testing.",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/debug-msvc",
"cacheVariables": {
"CMAKE_BUILD_TYPE": {
"type": "STRING",
"value": "Debug"
}
}
},
{
"name": "build-debug-clang-cl",
"inherits": [
"base",
"vcpkg",
"win32-unicode",
"x64",
"build-tests",
"clang-cl"
],
"displayName": "Debug",
"description": "Debug build for testing.",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/debug-clang",
"cacheVariables": {
"CMAKE_BUILD_TYPE": {
"type": "STRING",
"value": "Debug"
}
"CMAKE_BUILD_TYPE": "Debug"
}
},
{
"name": "build-release-clang-cl",
"inherits": [
"base",
"vcpkg",
"win32-unicode",
"x64",
"build-tests",
"clang-cl"
],
"displayName": "Release",
"displayName": "Release (Clang)",
"description": "Optimized release build.",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/release-clang",
"cacheVariables": {
"CMAKE_BUILD_TYPE": {
"type": "STRING",
"value": "Release"
"CMAKE_BUILD_TYPE": "Release"
}
},
{
"name": "build-debug-clang-cl",
"inherits": [
"base",
"win32-unicode",
"x64",
"clang-cl"
],
"displayName": "Debug (Clang)",
"description": "Debug build for testing.",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/debug-clang",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug"
}
}
],
@ -201,52 +158,5 @@
"configurePreset": "build-debug-clang-cl",
"description": "Debug build for testing."
}
],
"testPresets": [
{
"name": "tests-all",
"displayName": "All Tests",
"configurePreset": "build-debug-msvc",
"output": {
"outputOnFailure": true
},
"execution": {
"noTestsAction": "error",
"stopOnFailure": false
}
},
{
"name": "tests-unit",
"displayName": "Unit Tests",
"description": "Runs tests that do not require any Skyrim module loaded into the process.",
"inherits": "tests-all",
"filter": {
"exclude": {
"label": "[integration],[e2e]"
}
}
},
{
"name": "tests-integration",
"displayName": "Integration Tests",
"description": "Runs tests that interact with a Skyrim module at rest (do not require the Skyrim module to have run any main function).",
"inherits": "tests-all",
"filter": {
"include": {
"label": "[integration]"
}
}
},
{
"name": "tests-e2e",
"displayName": "End-to-End Tests",
"description": "Runs test that depend on a fully running Skyrim engine in the process.",
"inherits": "tests-all",
"filter": {
"include": {
"label": "[e2e]"
}
}
}
]
}

View File

@ -0,0 +1,47 @@
@echo off
rem ---------------------------------------------------------------------------
rem Command-line build wrapper.
rem
rem Sets up the MSVC x64 environment (the piece Visual Studio configures for you
rem automatically) and then drives the exact same CMake presets that Visual
rem Studio uses, so both build paths stay in sync.
rem
rem Usage: build.cmd [preset]
rem preset defaults to "release-msvc". Other options: debug-msvc,
rem release-clang-cl, debug-clang-cl (must match a name in CMakePresets.json).
rem ---------------------------------------------------------------------------
setlocal
set "PRESET=%~1"
if "%PRESET%"=="" set "PRESET=release-msvc"
set "CONFIGURE_PRESET=build-%PRESET%"
cd /d "%~dp0"
rem --- Locate Visual Studio (or the C++ Build Tools) ---
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
if not exist "%VSWHERE%" (
echo [ERROR] vswhere.exe not found. Install Visual Studio 2022 or the C++ Build Tools.
exit /b 1
)
set "VSINSTALL="
for /f "usebackq delims=" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSINSTALL=%%i"
if not defined VSINSTALL (
echo [ERROR] No Visual Studio installation with the C++ toolset was found.
exit /b 1
)
rem --- Import the MSVC x64 developer environment ---
call "%VSINSTALL%\VC\Auxiliary\Build\vcvars64.bat" || exit /b 1
rem --- Point Visual Studio at this same CMake, so the two drivers can share the
rem build tree incrementally (see cmake\write_user_presets.cmake) ---
cmake -P cmake\write_user_presets.cmake || exit /b 1
rem --- Configure and build using the shared presets ---
cmake --preset %CONFIGURE_PRESET% || exit /b 1
cmake --build --preset %PRESET% || exit /b 1
echo.
echo [OK] Built preset "%PRESET%". Plugin deployed to SKSE\Plugins\.

View File

@ -0,0 +1,23 @@
# Patch applied to DirectXTK's CMakeLists.txt via FetchContent PATCH_COMMAND.
#
# DirectXTK compiles its HLSL shaders by running Src/Shaders/CompileShaders.cmd
# through `cmake -E env ... CompileShaders.cmd` (a bare script name, relying on
# the custom command's WORKING_DIRECTORY). Modern CMake no longer resolves a
# bare command name against the working directory, so the step fails with
# "no such file or directory" and the whole build stops.
#
# Rewrite that single invocation to an absolute path. WORKING_DIRECTORY is left
# untouched so the script still runs with Src/Shaders as its CWD. The replace is
# idempotent: after patching, the original "CompileShaders.cmd ARGS" substring is
# gone, so re-running the patch is a no-op. It also targets only the COMMAND line
# (the MAIN_DEPENDENCY reference already uses a full path and is not matched).
set(_dxtk_cmakelists "CMakeLists.txt")
file(READ "${_dxtk_cmakelists}" _dxtk_content)
string(REPLACE
"CompileShaders.cmd ARGS"
"\"\${PROJECT_SOURCE_DIR}/Src/Shaders/CompileShaders.cmd\" ARGS"
_dxtk_content "${_dxtk_content}")
file(WRITE "${_dxtk_cmakelists}" "${_dxtk_content}")
message(STATUS "Patched DirectXTK CompileShaders.cmd invocation to use an absolute path.")

BIN
source/Steam DLL/cmake/version.rc.in (Stored with Git LFS)

Binary file not shown.

View File

@ -0,0 +1,116 @@
# Generates CMakeUserPresets.json, pinning "cmakeExecutable" to the CMake that is
# running this script - i.e. the one build.cmd itself uses.
#
# Why: both drivers share one binaryDir per configuration, so build.cmd and Visual
# Studio write the same Ninja tree. Visual Studio otherwise uses its own bundled
# CMake, and two CMake versions generating one tree emit different compile command
# lines, which makes Ninja rebuild everything on every switch. See CLAUDE.md.
#
# Why generated rather than committed: Visual Studio resolves "cmakeExecutable"
# against the current directory only - a bare "cmake" is not looked up on PATH - so
# the value must be an absolute path, which is machine-specific and cannot live in
# the committed CMakePresets.json.
#
# Run as: cmake -P cmake/write_user_presets.cmake
cmake_minimum_required(VERSION 3.21)
# Marks the file as ours, so a hand-written one is never clobbered.
set(GENERATED_MARKER "local-cmake")
set(TEMPLATE [==[
{
"version": 2,
"configurePresets": [
{
"name": "local-cmake",
"hidden": true,
"cmakeExecutable": "@CMAKE_COMMAND@"
},
{
"name": "build-release-msvc-local",
"inherits": [
"local-cmake",
"build-release-msvc"
],
"displayName": "Release (MSVC) [local CMake]",
"description": "Select this in Visual Studio: same build tree as build.cmd, pinned to the same CMake. Generated by build.cmd - do not edit."
},
{
"name": "build-debug-msvc-local",
"inherits": [
"local-cmake",
"build-debug-msvc"
],
"displayName": "Debug (MSVC) [local CMake]",
"description": "Select this in Visual Studio: same build tree as build.cmd, pinned to the same CMake. Generated by build.cmd - do not edit."
},
{
"name": "build-release-clang-cl-local",
"inherits": [
"local-cmake",
"build-release-clang-cl"
],
"displayName": "Release (Clang) [local CMake]",
"description": "Select this in Visual Studio: same build tree as build.cmd, pinned to the same CMake. Generated by build.cmd - do not edit."
},
{
"name": "build-debug-clang-cl-local",
"inherits": [
"local-cmake",
"build-debug-clang-cl"
],
"displayName": "Debug (Clang) [local CMake]",
"description": "Select this in Visual Studio: same build tree as build.cmd, pinned to the same CMake. Generated by build.cmd - do not edit."
}
],
"buildPresets": [
{
"name": "release-msvc-local",
"configurePreset": "build-release-msvc-local",
"displayName": "Release (MSVC) [local CMake]"
},
{
"name": "debug-msvc-local",
"configurePreset": "build-debug-msvc-local",
"displayName": "Debug (MSVC) [local CMake]"
},
{
"name": "release-clang-cl-local",
"configurePreset": "build-release-clang-cl-local",
"displayName": "Release (Clang) [local CMake]"
},
{
"name": "debug-clang-cl-local",
"configurePreset": "build-debug-clang-cl-local",
"displayName": "Debug (Clang) [local CMake]"
}
]
}
]==])
string(CONFIGURE "${TEMPLATE}" CONTENT @ONLY)
get_filename_component(OUTPUT_FILE "${CMAKE_CURRENT_LIST_DIR}/../CMakeUserPresets.json" ABSOLUTE)
if(EXISTS "${OUTPUT_FILE}")
file(READ "${OUTPUT_FILE}" EXISTING)
# Unchanged: leave the timestamp alone, or Visual Studio reloads the presets
# after every command-line build.
if(EXISTING STREQUAL CONTENT)
return()
endif()
string(FIND "${EXISTING}" "\"${GENERATED_MARKER}\"" MARKER_POS)
if(MARKER_POS EQUAL -1)
message(WARNING
"CMakeUserPresets.json was not generated by build.cmd - leaving it untouched.\n"
"Delete it to let build.cmd manage it, or add \"cmakeExecutable\": \"${CMAKE_COMMAND}\" to it yourself."
)
return()
endif()
endif()
file(WRITE "${OUTPUT_FILE}" "${CONTENT}")
message(STATUS "Wrote CMakeUserPresets.json (cmakeExecutable: ${CMAKE_COMMAND})")

Binary file not shown.

View File

@ -1,66 +1,182 @@
#include "Achievements.h"
#include "Util.h"
#include "steam/steam_api.h"
#include <cstdlib>
#include <string>
#include <processenv.h>
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] = "<unset>";
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<std::string, bool> 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) {
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("SendAchievementsToLE")) {
SetEnvironmentVariable(L"SteamAppID", L"933480");
SetEnvironmentVariable(L"SteamGameId", L"933480");
} else {
SetEnvironmentVariable(L"SteamAppID", L"976620");
SetEnvironmentVariable(L"SteamGameId", L"976620");
if (settings.at("ReloadSteamClient")) {
unloadSteamClient();
}
bool success = SteamAPI_Init();
setAppIdEnvironment(appId);
if (success) {
logger::info("{}", "Steam api init was successfull");
} else {
if (!SteamAPI_Init()) {
bInitFailed = true;
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.");
}
return;
}
SteamInstance(new AchievementHolder());
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<uint32>(std::strtoul(appId, nullptr, 10))) {
bInitFailed = true;
logger::error("{}", "Session kept a foreign AppID, not wiring up achievements");
return;
}
else {
logger::info("{}", "Already initialized steam api, skipping it");
} 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");
}
}
}

View File

@ -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 <memory>
namespace Achievements {
@ -18,7 +17,11 @@ namespace Achievements {
bool setAchievementUnlocked(const char * achievementName);
};
static std::unique_ptr<AchievementHolder> singleton(nullptr);
// Opens the Steam session under Enderal's AppID. Must run before the game
// opens its own - see the comment inside.
void startSteam();
// Result of startSteam(), so the warning can be shown once a UI exists.
bool steamInitFailed();
bool shouldWarnOnInitFail();
}

View File

@ -20,8 +20,12 @@ auto EventListener::ProcessEvent(
{
if (a_event->opening && a_event->menuName == "Main Menu") {
RE::UI::GetSingleton()->RemoveEventSink<RE::MenuOpenCloseEvent>(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;

View File

@ -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);

View File

@ -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());

View File

@ -2,6 +2,7 @@
#include "Achievements.h"
#include <SimpleIni.h>
#include <optional>
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<bool> 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<std::string, bool>* 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);
}