diff options
author | Jack <66967891+ASpoonPlaysGames@users.noreply.github.com> | 2023-12-27 00:32:01 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-12-27 01:32:01 +0100 |
commit | f5ab6fb5e8be7b73e6003d4145081d5e0c0ce287 (patch) | |
tree | 90f2c6a4885dbd181799e2325cf33588697674e1 /primedev/mods | |
parent | bb8ed59f6891b1196c5f5bbe7346cd171c8215fa (diff) | |
download | NorthstarLauncher-f5ab6fb5e8be7b73e6003d4145081d5e0c0ce287.tar.gz NorthstarLauncher-f5ab6fb5e8be7b73e6003d4145081d5e0c0ce287.zip |
Folder restructuring from primedev (#624)v1.21.2-rc3v1.21.2
Copies of over the primedev folder structure for easier cherry-picking of further changes
Co-authored-by: F1F7Y <filip.bartos07@proton.me>
Diffstat (limited to 'primedev/mods')
-rw-r--r-- | primedev/mods/autodownload/moddownloader.cpp | 638 | ||||
-rw-r--r-- | primedev/mods/autodownload/moddownloader.h | 151 | ||||
-rw-r--r-- | primedev/mods/compiled/kb_act.cpp | 44 | ||||
-rw-r--r-- | primedev/mods/compiled/modkeyvalues.cpp | 106 | ||||
-rw-r--r-- | primedev/mods/compiled/modpdef.cpp | 118 | ||||
-rw-r--r-- | primedev/mods/compiled/modscriptsrson.cpp | 65 | ||||
-rw-r--r-- | primedev/mods/modmanager.cpp | 1150 | ||||
-rw-r--r-- | primedev/mods/modmanager.h | 187 | ||||
-rw-r--r-- | primedev/mods/modsavefiles.cpp | 572 | ||||
-rw-r--r-- | primedev/mods/modsavefiles.h | 16 |
10 files changed, 3047 insertions, 0 deletions
diff --git a/primedev/mods/autodownload/moddownloader.cpp b/primedev/mods/autodownload/moddownloader.cpp new file mode 100644 index 00000000..165399e3 --- /dev/null +++ b/primedev/mods/autodownload/moddownloader.cpp @@ -0,0 +1,638 @@ +#include "moddownloader.h" +#include <rapidjson/fwd.h> +#include <mz_strm_mem.h> +#include <mz.h> +#include <mz_strm.h> +#include <mz_zip.h> +#include <mz_compat.h> +#include <thread> +#include <future> +#include <bcrypt.h> +#include <winternl.h> +#include <fstream> + +ModDownloader* g_pModDownloader; + +ModDownloader::ModDownloader() +{ + spdlog::info("Mod downloader initialized"); + + // Initialise mods list URI + char* clachar = strstr(GetCommandLineA(), CUSTOM_MODS_URL_FLAG); + if (clachar) + { + std::string url; + int iFlagStringLength = strlen(CUSTOM_MODS_URL_FLAG); + std::string cla = std::string(clachar); + if (strncmp(cla.substr(iFlagStringLength, 1).c_str(), "\"", 1)) + { + int space = cla.find(" "); + url = cla.substr(iFlagStringLength, space - iFlagStringLength); + } + else + { + std::string quote = "\""; + int quote1 = cla.find(quote); + int quote2 = (cla.substr(quote1 + 1)).find(quote); + url = cla.substr(quote1 + 1, quote2); + } + spdlog::info("Found custom verified mods URL in command line argument: {}", url); + modsListUrl = strdup(url.c_str()); + } + else + { + spdlog::info("Custom verified mods URL not found in command line arguments, using default URL."); + modsListUrl = strdup(DEFAULT_MODS_LIST_URL); + } +} + +size_t WriteToString(void* ptr, size_t size, size_t count, void* stream) +{ + ((std::string*)stream)->append((char*)ptr, 0, size * count); + return size * count; +} + +void ModDownloader::FetchModsListFromAPI() +{ + std::thread requestThread( + [this]() + { + CURLcode result; + CURL* easyhandle; + rapidjson::Document verifiedModsJson; + std::string url = modsListUrl; + + curl_global_init(CURL_GLOBAL_ALL); + easyhandle = curl_easy_init(); + std::string readBuffer; + + // Fetching mods list from GitHub repository + curl_easy_setopt(easyhandle, CURLOPT_CUSTOMREQUEST, "GET"); + curl_easy_setopt(easyhandle, CURLOPT_TIMEOUT, 30L); + curl_easy_setopt(easyhandle, CURLOPT_URL, url.c_str()); + curl_easy_setopt(easyhandle, CURLOPT_FAILONERROR, 1L); + curl_easy_setopt(easyhandle, CURLOPT_WRITEDATA, &readBuffer); + curl_easy_setopt(easyhandle, CURLOPT_WRITEFUNCTION, WriteToString); + result = curl_easy_perform(easyhandle); + + if (result == CURLcode::CURLE_OK) + { + spdlog::info("Mods list successfully fetched."); + } + else + { + spdlog::error("Fetching mods list failed: {}", curl_easy_strerror(result)); + goto REQUEST_END_CLEANUP; + } + + // Load mods list into local state + spdlog::info("Loading mods configuration..."); + verifiedModsJson.Parse(readBuffer); + for (auto i = verifiedModsJson.MemberBegin(); i != verifiedModsJson.MemberEnd(); ++i) + { + std::string name = i->name.GetString(); + std::string dependency = i->value["DependencyPrefix"].GetString(); + + std::unordered_map<std::string, VerifiedModVersion> modVersions; + rapidjson::Value& versions = i->value["Versions"]; + assert(versions.IsArray()); + for (auto& attribute : versions.GetArray()) + { + assert(attribute.IsObject()); + std::string version = attribute["Version"].GetString(); + std::string checksum = attribute["Checksum"].GetString(); + modVersions.insert({version, {.checksum = checksum}}); + } + + VerifiedModDetails modConfig = {.dependencyPrefix = dependency, .versions = modVersions}; + verifiedMods.insert({name, modConfig}); + spdlog::info("==> Loaded configuration for mod \"" + name + "\""); + } + + spdlog::info("Done loading verified mods list."); + + REQUEST_END_CLEANUP: + curl_easy_cleanup(easyhandle); + }); + requestThread.detach(); +} + +size_t WriteData(void* ptr, size_t size, size_t nmemb, FILE* stream) +{ + size_t written; + written = fwrite(ptr, size, nmemb, stream); + return written; +} + +int ModDownloader::ModFetchingProgressCallback( + void* ptr, curl_off_t totalDownloadSize, curl_off_t finishedDownloadSize, curl_off_t totalToUpload, curl_off_t nowUploaded) +{ + if (totalDownloadSize != 0 && finishedDownloadSize != 0) + { + ModDownloader* instance = static_cast<ModDownloader*>(ptr); + auto currentDownloadProgress = roundf(static_cast<float>(finishedDownloadSize) / totalDownloadSize * 100); + instance->modState.progress = finishedDownloadSize; + instance->modState.total = totalDownloadSize; + instance->modState.ratio = currentDownloadProgress; + } + + return 0; +} + +std::optional<fs::path> ModDownloader::FetchModFromDistantStore(std::string_view modName, std::string_view modVersion) +{ + // Retrieve mod prefix from local mods list, or use mod name as mod prefix if bypass flag is set + std::string modPrefix = strstr(GetCommandLineA(), VERIFICATION_FLAG) ? modName.data() : verifiedMods[modName.data()].dependencyPrefix; + // Build archive distant URI + std::string archiveName = std::format("{}-{}.zip", modPrefix, modVersion.data()); + std::string url = STORE_URL + archiveName; + spdlog::info(std::format("Fetching mod archive from {}", url)); + + // Download destination + std::filesystem::path downloadPath = std::filesystem::temp_directory_path() / archiveName; + spdlog::info(std::format("Downloading archive to {}", downloadPath.generic_string())); + + // Update state + modState.state = DOWNLOADING; + + // Download the actual archive + bool failed = false; + FILE* fp = fopen(downloadPath.generic_string().c_str(), "wb"); + CURLcode result; + CURL* easyhandle; + easyhandle = curl_easy_init(); + + curl_easy_setopt(easyhandle, CURLOPT_URL, url.data()); + curl_easy_setopt(easyhandle, CURLOPT_FAILONERROR, 1L); + curl_easy_setopt(easyhandle, CURLOPT_WRITEDATA, fp); + curl_easy_setopt(easyhandle, CURLOPT_WRITEFUNCTION, WriteData); + curl_easy_setopt(easyhandle, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(easyhandle, CURLOPT_XFERINFOFUNCTION, ModDownloader::ModFetchingProgressCallback); + curl_easy_setopt(easyhandle, CURLOPT_XFERINFODATA, this); + result = curl_easy_perform(easyhandle); + + if (result == CURLcode::CURLE_OK) + { + spdlog::info("Mod archive successfully fetched."); + goto REQUEST_END_CLEANUP; + } + else + { + spdlog::error("Fetching mod archive failed: {}", curl_easy_strerror(result)); + failed = true; + goto REQUEST_END_CLEANUP; + } + +REQUEST_END_CLEANUP: + curl_easy_cleanup(easyhandle); + fclose(fp); + return failed ? std::optional<fs::path>() : std::optional<fs::path>(downloadPath); +} + +bool ModDownloader::IsModLegit(fs::path modPath, std::string_view expectedChecksum) +{ + if (strstr(GetCommandLineA(), VERIFICATION_FLAG)) + { + spdlog::info("Bypassing mod verification due to flag set up."); + return true; + } + + // Update state + modState.state = CHECKSUMING; + + NTSTATUS status; + BCRYPT_ALG_HANDLE algorithmHandle = NULL; + BCRYPT_HASH_HANDLE hashHandle = NULL; + std::vector<uint8_t> hash; + DWORD hashLength = 0; + DWORD resultLength = 0; + std::stringstream ss; + + constexpr size_t bufferSize {1 << 12}; + std::vector<char> buffer(bufferSize, '\0'); + std::ifstream fp(modPath.generic_string(), std::ios::binary); + + // Open an algorithm handle + // This sample passes BCRYPT_HASH_REUSABLE_FLAG with BCryptAlgorithmProvider(...) to load a provider which supports reusable hash + status = BCryptOpenAlgorithmProvider( + &algorithmHandle, // Alg Handle pointer + BCRYPT_SHA256_ALGORITHM, // Cryptographic Algorithm name (null terminated unicode string) + NULL, // Provider name; if null, the default provider is loaded + BCRYPT_HASH_REUSABLE_FLAG); // Flags; Loads a provider which supports reusable hash + if (!NT_SUCCESS(status)) + { + modState.state = MOD_CORRUPTED; + goto cleanup; + } + + // Obtain the length of the hash + status = BCryptGetProperty( + algorithmHandle, // Handle to a CNG object + BCRYPT_HASH_LENGTH, // Property name (null terminated unicode string) + (PBYTE)&hashLength, // Address of the output buffer which recieves the property value + sizeof(hashLength), // Size of the buffer in bytes + &resultLength, // Number of bytes that were copied into the buffer + 0); // Flags + if (!NT_SUCCESS(status)) + { + // goto cleanup; + modState.state = MOD_CORRUPTED; + return false; + } + + // Create a hash handle + status = BCryptCreateHash( + algorithmHandle, // Handle to an algorithm provider + &hashHandle, // A pointer to a hash handle - can be a hash or hmac object + NULL, // Pointer to the buffer that recieves the hash/hmac object + 0, // Size of the buffer in bytes + NULL, // A pointer to a key to use for the hash or MAC + 0, // Size of the key in bytes + 0); // Flags + if (!NT_SUCCESS(status)) + { + modState.state = MOD_CORRUPTED; + goto cleanup; + } + + // Hash archive content + if (!fp.is_open()) + { + spdlog::error("Unable to open archive."); + modState.state = FAILED_READING_ARCHIVE; + return false; + } + fp.seekg(0, fp.beg); + while (fp.good()) + { + fp.read(buffer.data(), bufferSize); + std::streamsize bytesRead = fp.gcount(); + if (bytesRead > 0) + { + status = BCryptHashData(hashHandle, (PBYTE)buffer.data(), bytesRead, 0); + if (!NT_SUCCESS(status)) + { + modState.state = MOD_CORRUPTED; + goto cleanup; + } + } + } + + hash = std::vector<uint8_t>(hashLength); + + // Obtain the hash of the message(s) into the hash buffer + status = BCryptFinishHash( + hashHandle, // Handle to the hash or MAC object + hash.data(), // A pointer to a buffer that receives the hash or MAC value + hashLength, // Size of the buffer in bytes + 0); // Flags + if (!NT_SUCCESS(status)) + { + modState.state = MOD_CORRUPTED; + goto cleanup; + } + + // Convert hash to string using bytes raw values + ss << std::hex << std::setfill('0'); + for (int i = 0; i < hashLength; i++) + { + ss << std::hex << std::setw(2) << static_cast<int>(hash.data()[i]); + } + + spdlog::info("Expected checksum: {}", expectedChecksum.data()); + spdlog::info("Computed checksum: {}", ss.str()); + return expectedChecksum.compare(ss.str()) == 0; + +cleanup: + if (NULL != hashHandle) + { + BCryptDestroyHash(hashHandle); // Handle to hash/MAC object which needs to be destroyed + } + + if (NULL != algorithmHandle) + { + BCryptCloseAlgorithmProvider( + algorithmHandle, // Handle to the algorithm provider which needs to be closed + 0); // Flags + } + + return false; +} + +bool ModDownloader::IsModAuthorized(std::string_view modName, std::string_view modVersion) +{ + if (strstr(GetCommandLineA(), VERIFICATION_FLAG)) + { + spdlog::info("Bypassing mod verification due to flag set up."); + return true; + } + + if (!verifiedMods.contains(modName.data())) + { + return false; + } + + std::unordered_map<std::string, VerifiedModVersion> versions = verifiedMods[modName.data()].versions; + return versions.count(modVersion.data()) != 0; +} + +int GetModArchiveSize(unzFile file, unz_global_info64 info) +{ + int totalSize = 0; + + for (int i = 0; i < info.number_entry; i++) + { + char zipFilename[256]; + unz_file_info64 fileInfo; + unzGetCurrentFileInfo64(file, &fileInfo, zipFilename, sizeof(zipFilename), NULL, 0, NULL, 0); + + totalSize += fileInfo.uncompressed_size; + + if ((i + 1) < info.number_entry) + { + unzGoToNextFile(file); + } + } + + // Reset file pointer for archive extraction + unzGoToFirstFile(file); + + return totalSize; +} + +void ModDownloader::ExtractMod(fs::path modPath) +{ + unzFile file; + std::string name; + fs::path modDirectory; + + file = unzOpen(modPath.generic_string().c_str()); + if (file == NULL) + { + spdlog::error("Cannot open archive located at {}.", modPath.generic_string()); + modState.state = FAILED_READING_ARCHIVE; + goto EXTRACTION_CLEANUP; + } + + unz_global_info64 gi; + int status; + status = unzGetGlobalInfo64(file, &gi); + if (status != UNZ_OK) + { + spdlog::error("Failed getting information from archive (error code: {})", status); + modState.state = FAILED_READING_ARCHIVE; + goto EXTRACTION_CLEANUP; + } + + // Update state + modState.state = EXTRACTING; + modState.total = GetModArchiveSize(file, gi); + modState.progress = 0; + + // Mod directory name (removing the ".zip" fom the archive name) + name = modPath.filename().string(); + name = name.substr(0, name.length() - 4); + modDirectory = GetRemoteModFolderPath() / name; + + for (int i = 0; i < gi.number_entry; i++) + { + char zipFilename[256]; + unz_file_info64 fileInfo; + status = unzGetCurrentFileInfo64(file, &fileInfo, zipFilename, sizeof(zipFilename), NULL, 0, NULL, 0); + + // Extract file + { + std::error_code ec; + fs::path fileDestination = modDirectory / zipFilename; + spdlog::info("=> {}", fileDestination.generic_string()); + + // Create parent directory if needed + if (!std::filesystem::exists(fileDestination.parent_path())) + { + spdlog::info("Parent directory does not exist, creating it.", fileDestination.generic_string()); + if (!std::filesystem::create_directories(fileDestination.parent_path(), ec) && ec.value() != 0) + { + spdlog::error("Parent directory ({}) creation failed.", fileDestination.parent_path().generic_string()); + modState.state = FAILED_WRITING_TO_DISK; + goto EXTRACTION_CLEANUP; + } + } + + // If current file is a directory, create directory... + if (fileDestination.generic_string().back() == '/') + { + // Create directory + if (!std::filesystem::create_directory(fileDestination, ec) && ec.value() != 0) + { + spdlog::error("Directory creation failed: {}", ec.message()); + modState.state = FAILED_WRITING_TO_DISK; + goto EXTRACTION_CLEANUP; + } + } + // ...else create file + else + { + // Ensure file is in zip archive + if (unzLocateFile(file, zipFilename, 0) != UNZ_OK) + { + spdlog::error("File \"{}\" was not found in archive.", zipFilename); + modState.state = FAILED_READING_ARCHIVE; + goto EXTRACTION_CLEANUP; + } + + // Create file + const int bufferSize = 8192; + void* buffer; + int err = UNZ_OK; + FILE* fout = NULL; + + // Open zip file to prepare its extraction + status = unzOpenCurrentFile(file); + if (status != UNZ_OK) + { + spdlog::error("Could not open file {} from archive.", zipFilename); + modState.state = FAILED_READING_ARCHIVE; + goto EXTRACTION_CLEANUP; + } + + // Create destination file + fout = fopen(fileDestination.generic_string().c_str(), "wb"); + if (fout == NULL) + { + spdlog::error("Failed creating destination file."); + modState.state = FAILED_WRITING_TO_DISK; + goto EXTRACTION_CLEANUP; + } + + // Allocate memory for buffer + buffer = (void*)malloc(bufferSize); + if (buffer == NULL) + { + spdlog::error("Error while allocating memory."); + modState.state = FAILED_WRITING_TO_DISK; + goto EXTRACTION_CLEANUP; + } + + // Extract file to destination + do + { + err = unzReadCurrentFile(file, buffer, bufferSize); + if (err < 0) + { + spdlog::error("error {} with zipfile in unzReadCurrentFile", err); + break; + } + if (err > 0) + { + if (fwrite(buffer, (unsigned)err, 1, fout) != 1) + { + spdlog::error("error in writing extracted file\n"); + err = UNZ_ERRNO; + break; + } + } + + // Update extraction stats + modState.progress += bufferSize; + modState.ratio = roundf(static_cast<float>(modState.progress) / modState.total * 100); + } while (err > 0); + + if (err != UNZ_OK) + { + spdlog::error("An error occurred during file extraction (code: {})", err); + modState.state = FAILED_WRITING_TO_DISK; + goto EXTRACTION_CLEANUP; + } + err = unzCloseCurrentFile(file); + if (err != UNZ_OK) + { + spdlog::error("error {} with zipfile in unzCloseCurrentFile", err); + } + + // Cleanup + if (fout) + fclose(fout); + } + } + + // Go to next file + if ((i + 1) < gi.number_entry) + { + status = unzGoToNextFile(file); + if (status != UNZ_OK) + { + spdlog::error("Error while browsing archive files (error code: {}).", status); + goto EXTRACTION_CLEANUP; + } + } + } + +EXTRACTION_CLEANUP: + if (unzClose(file) != MZ_OK) + { + spdlog::error("Failed closing mod archive after extraction."); + } +} + +void ModDownloader::DownloadMod(std::string modName, std::string modVersion) +{ + // Check if mod can be auto-downloaded + if (!IsModAuthorized(std::string_view(modName), std::string_view(modVersion))) + { + spdlog::warn("Tried to download a mod that is not verified, aborting."); + return; + } + + std::thread requestThread( + [this, modName, modVersion]() + { + fs::path archiveLocation; + + // Download mod archive + std::string expectedHash = verifiedMods[modName].versions[modVersion].checksum; + std::optional<fs::path> fetchingResult = FetchModFromDistantStore(std::string_view(modName), std::string_view(modVersion)); + if (!fetchingResult.has_value()) + { + spdlog::error("Something went wrong while fetching archive, aborting."); + modState.state = MOD_FETCHING_FAILED; + goto REQUEST_END_CLEANUP; + } + archiveLocation = fetchingResult.value(); + if (!IsModLegit(archiveLocation, std::string_view(expectedHash))) + { + spdlog::warn("Archive hash does not match expected checksum, aborting."); + modState.state = MOD_CORRUPTED; + goto REQUEST_END_CLEANUP; + } + + // Extract downloaded mod archive + ExtractMod(archiveLocation); + + REQUEST_END_CLEANUP: + try + { + remove(archiveLocation); + } + catch (const std::exception& a) + { + spdlog::error("Error while removing downloaded archive: {}", a.what()); + } + + modState.state = DONE; + spdlog::info("Done downloading {}.", modName); + }); + + requestThread.detach(); +} + +ON_DLL_LOAD_RELIESON("engine.dll", ModDownloader, (ConCommand), (CModule module)) +{ + g_pModDownloader = new ModDownloader(); + g_pModDownloader->FetchModsListFromAPI(); +} + +ADD_SQFUNC( + "bool", NSIsModDownloadable, "string name, string version", "", ScriptContext::SERVER | ScriptContext::CLIENT | ScriptContext::UI) +{ + g_pSquirrel<context>->newarray(sqvm, 0); + + const SQChar* modName = g_pSquirrel<context>->getstring(sqvm, 1); + const SQChar* modVersion = g_pSquirrel<context>->getstring(sqvm, 2); + + bool result = g_pModDownloader->IsModAuthorized(modName, modVersion); + g_pSquirrel<context>->pushbool(sqvm, result); + + return SQRESULT_NOTNULL; +} + +ADD_SQFUNC("void", NSDownloadMod, "string name, string version", "", ScriptContext::SERVER | ScriptContext::CLIENT | ScriptContext::UI) +{ + const SQChar* modName = g_pSquirrel<context>->getstring(sqvm, 1); + const SQChar* modVersion = g_pSquirrel<context>->getstring(sqvm, 2); + g_pModDownloader->DownloadMod(modName, modVersion); + + return SQRESULT_NOTNULL; +} + +ADD_SQFUNC("ModInstallState", NSGetModInstallState, "", "", ScriptContext::SERVER | ScriptContext::CLIENT | ScriptContext::UI) +{ + g_pSquirrel<context>->pushnewstructinstance(sqvm, 4); + + // state + g_pSquirrel<context>->pushinteger(sqvm, g_pModDownloader->modState.state); + g_pSquirrel<context>->sealstructslot(sqvm, 0); + + // progress + g_pSquirrel<context>->pushinteger(sqvm, g_pModDownloader->modState.progress); + g_pSquirrel<context>->sealstructslot(sqvm, 1); + + // total + g_pSquirrel<context>->pushinteger(sqvm, g_pModDownloader->modState.total); + g_pSquirrel<context>->sealstructslot(sqvm, 2); + + // ratio + g_pSquirrel<context>->pushfloat(sqvm, g_pModDownloader->modState.ratio); + g_pSquirrel<context>->sealstructslot(sqvm, 3); + + return SQRESULT_NOTNULL; +} diff --git a/primedev/mods/autodownload/moddownloader.h b/primedev/mods/autodownload/moddownloader.h new file mode 100644 index 00000000..5302c21e --- /dev/null +++ b/primedev/mods/autodownload/moddownloader.h @@ -0,0 +1,151 @@ +class ModDownloader +{ +private: + const char* VERIFICATION_FLAG = "-disablemodverification"; + const char* CUSTOM_MODS_URL_FLAG = "-customverifiedurl="; + const char* STORE_URL = "https://gcdn.thunderstore.io/live/repository/packages/"; + const char* DEFAULT_MODS_LIST_URL = "https://raw.githubusercontent.com/R2Northstar/VerifiedMods/master/verified-mods.json"; + char* modsListUrl; + + struct VerifiedModVersion + { + std::string checksum; + }; + struct VerifiedModDetails + { + std::string dependencyPrefix; + std::unordered_map<std::string, VerifiedModVersion> versions = {}; + }; + std::unordered_map<std::string, VerifiedModDetails> verifiedMods = {}; + + /** + * Mod archive download callback. + * + * This function is called by curl as it's downloading the mod archive; this + * will retrieve the current `ModDownloader` instance and update its `modState` + * member accordingly. + */ + static int ModFetchingProgressCallback( + void* ptr, curl_off_t totalDownloadSize, curl_off_t finishedDownloadSize, curl_off_t totalToUpload, curl_off_t nowUploaded); + + /** + * Downloads a mod archive from distant store. + * + * This rebuilds the URI of the mod archive using both a predefined store URI + * and the mod dependency string from the `verifiedMods` variable, or using + * input mod name as mod dependency string if bypass flag is set up; fetched + * archive is then stored in a temporary location. + * + * If something went wrong during archive download, this will return an empty + * optional object. + * + * @param modName name of the mod to be downloaded + * @param modVersion version of the mod to be downloaded + * @returns location of the downloaded archive + */ + std::optional<fs::path> FetchModFromDistantStore(std::string_view modName, std::string_view modVersion); + + /** + * Tells if a mod archive has not been corrupted. + * + * The mod validation procedure includes computing the SHA256 hash of the final + * archive, which is stored in the verified mods list. This hash is used by this + * very method to ensure the archive downloaded from the Internet is the exact + * same that has been manually verified. + * + * @param modPath path of the archive to check + * @param expectedChecksum checksum the archive should have + * @returns whether archive is legit + */ + bool IsModLegit(fs::path modPath, std::string_view expectedChecksum); + + /** + * Extracts a mod archive to the game folder. + * + * This extracts a downloaded mod archive from its original location to the + * current game profile, in the remote mods folder. + * + * @param modPath location of the downloaded archive + * @returns nothing + */ + void ExtractMod(fs::path modPath); + +public: + ModDownloader(); + + /** + * Retrieves the verified mods list from the central authority. + * + * The Northstar auto-downloading feature does NOT allow automatically installing + * all mods for various (notably security) reasons; mods that are candidate to + * auto-downloading are rather listed on a GitHub repository + * (https://raw.githubusercontent.com/R2Northstar/VerifiedMods/master/verified-mods.json), + * which this method gets via a HTTP call to load into local state. + * + * If list fetching fails, local mods list will be initialized as empty, thus + * preventing any mod from being auto-downloaded. + * + * @returns nothing + */ + void FetchModsListFromAPI(); + + /** + * Checks whether a mod is verified. + * + * A mod is deemed verified/authorized through a manual validation process that is + * described here: https://github.com/R2Northstar/VerifiedMods; in practice, a mod + * is considered authorized if their name AND exact version appear in the + * `verifiedMods` variable. + * + * @param modName name of the mod to be checked + * @param modVersion version of the mod to be checked, must follow semantic versioning + * @returns whether the mod is authorized and can be auto-downloaded + */ + bool IsModAuthorized(std::string_view modName, std::string_view modVersion); + + /** + * Downloads a given mod from Thunderstore API to local game profile. + * + * @param modName name of the mod to be downloaded + * @param modVersion version of the mod to be downloaded + * @returns nothing + **/ + void DownloadMod(std::string modName, std::string modVersion); + + enum ModInstallState + { + // Normal installation process + DOWNLOADING, + CHECKSUMING, + EXTRACTING, + DONE, // Everything went great, mod can be used in-game + + // Errors + FAILED, // Generic error message, should be avoided as much as possible + FAILED_READING_ARCHIVE, + FAILED_WRITING_TO_DISK, + MOD_FETCHING_FAILED, + MOD_CORRUPTED, // Downloaded archive checksum does not match verified hash + NO_DISK_SPACE_AVAILABLE, + NOT_FOUND // Mod is not currently being auto-downloaded + }; + + struct MOD_STATE + { + ModInstallState state; + int progress; + int total; + float ratio; + } modState = {}; + + /** + * Cancels installation of the mod. + * + * Prevents installation of the mod currently being installed, no matter the install + * progress (downloading, checksuming, extracting), and frees all resources currently + * being used in this purpose. + * + * @returns nothing + */ + void CancelDownload(); +}; diff --git a/primedev/mods/compiled/kb_act.cpp b/primedev/mods/compiled/kb_act.cpp new file mode 100644 index 00000000..6117fd28 --- /dev/null +++ b/primedev/mods/compiled/kb_act.cpp @@ -0,0 +1,44 @@ +#include "mods/modmanager.h" +#include "core/filesystem/filesystem.h" + +#include <fstream> + +const char* KB_ACT_PATH = "scripts\\kb_act.lst"; + +// compiles the file kb_act.lst, that defines entries for keybindings in the options menu +void ModManager::BuildKBActionsList() +{ + spdlog::info("Building kb_act.lst"); + + fs::create_directories(GetCompiledAssetsPath() / "scripts"); + std::ofstream soCompiledKeys(GetCompiledAssetsPath() / KB_ACT_PATH, std::ios::binary); + + // write vanilla file's content to compiled file + soCompiledKeys << ReadVPKOriginalFile(KB_ACT_PATH); + + for (Mod& mod : m_LoadedMods) + { + if (!mod.m_bEnabled) + continue; + + // write content of each modded file to compiled file + std::ifstream siModKeys(mod.m_ModDirectory / "kb_act.lst"); + + if (siModKeys.good()) + soCompiledKeys << siModKeys.rdbuf() << std::endl; + + siModKeys.close(); + } + + soCompiledKeys.close(); + + // push to overrides + ModOverrideFile overrideFile; + overrideFile.m_pOwningMod = nullptr; + overrideFile.m_Path = KB_ACT_PATH; + + if (m_ModFiles.find(KB_ACT_PATH) == m_ModFiles.end()) + m_ModFiles.insert(std::make_pair(KB_ACT_PATH, overrideFile)); + else + m_ModFiles[KB_ACT_PATH] = overrideFile; +} diff --git a/primedev/mods/compiled/modkeyvalues.cpp b/primedev/mods/compiled/modkeyvalues.cpp new file mode 100644 index 00000000..e44a81d3 --- /dev/null +++ b/primedev/mods/compiled/modkeyvalues.cpp @@ -0,0 +1,106 @@ +#include "mods/modmanager.h" +#include "core/filesystem/filesystem.h" + +#include <fstream> + +AUTOHOOK_INIT() + +void ModManager::TryBuildKeyValues(const char* filename) +{ + spdlog::info("Building KeyValues for file {}", filename); + + std::string normalisedPath = g_pModManager->NormaliseModFilePath(fs::path(filename)); + fs::path compiledPath = GetCompiledAssetsPath() / filename; + fs::path compiledDir = compiledPath.parent_path(); + fs::create_directories(compiledDir); + + fs::path kvPath(filename); + std::string ogFilePath = "mod_original_"; + ogFilePath += kvPath.filename().string(); + + std::string newKvs = "// AUTOGENERATED: MOD PATCH KV\n"; + + int patchNum = 0; + + // copy over patch kv files, and add #bases to new file, last mods' patches should be applied first + // note: #include should be identical but it's actually just broken, thanks respawn + for (int64_t i = m_LoadedMods.size() - 1; i > -1; i--) + { + if (!m_LoadedMods[i].m_bEnabled) + continue; + + size_t fileHash = STR_HASH(normalisedPath); + auto modKv = m_LoadedMods[i].KeyValues.find(fileHash); + if (modKv != m_LoadedMods[i].KeyValues.end()) + { + // should result in smth along the lines of #include "mod_patch_5_mp_weapon_car.txt" + + std::string patchFilePath = "mod_patch_"; + patchFilePath += std::to_string(patchNum++); + patchFilePath += "_"; + patchFilePath += kvPath.filename().string(); + + newKvs += "#base \""; + newKvs += patchFilePath; + newKvs += "\"\n"; + + fs::remove(compiledDir / patchFilePath); + + fs::copy_file(m_LoadedMods[i].m_ModDirectory / "keyvalues" / filename, compiledDir / patchFilePath); + } + } + + // add original #base last, #bases don't override preexisting keys, including the ones we've just done + newKvs += "#base \""; + newKvs += ogFilePath; + newKvs += "\"\n"; + + // load original file, so we can parse out the name of the root obj (e.g. WeaponData for weapons) + std::string originalFile = ReadVPKOriginalFile(filename); + + if (!originalFile.length()) + { + spdlog::warn("Tried to patch kv {} but no base kv was found!", ogFilePath); + return; + } + + char rootName[64]; + memset(rootName, 0, sizeof(rootName)); + + // iterate until we hit an ascii char that isn't in a # command or comment to get root obj name + int i = 0; + while (!(originalFile[i] >= 65 && originalFile[i] <= 122)) + { + // if we hit a comment or # thing, iterate until end of line + if (originalFile[i] == '/' || originalFile[i] == '#') + while (originalFile[i] != '\n') + i++; + + i++; + } + + int j = 0; + for (int j = 0; originalFile[i] >= 65 && originalFile[i] <= 122; j++) + rootName[j] = originalFile[i++]; + + // empty kv, all the other stuff gets #base'd + newKvs += rootName; + newKvs += "\n{\n}\n"; + + std::ofstream originalFileWriteStream(compiledDir / ogFilePath, std::ios::binary); + originalFileWriteStream << originalFile; + originalFileWriteStream.close(); + + std::ofstream writeStream(compiledPath, std::ios::binary); + writeStream << newKvs; + writeStream.close(); + + ModOverrideFile overrideFile; + overrideFile.m_pOwningMod = nullptr; + overrideFile.m_Path = normalisedPath; + + if (m_ModFiles.find(normalisedPath) == m_ModFiles.end()) + m_ModFiles.insert(std::make_pair(normalisedPath, overrideFile)); + else + m_ModFiles[normalisedPath] = overrideFile; +} diff --git a/primedev/mods/compiled/modpdef.cpp b/primedev/mods/compiled/modpdef.cpp new file mode 100644 index 00000000..d268a063 --- /dev/null +++ b/primedev/mods/compiled/modpdef.cpp @@ -0,0 +1,118 @@ +#include "mods/modmanager.h" +#include "core/filesystem/filesystem.h" + +#include <map> +#include <sstream> +#include <fstream> + +const fs::path MOD_PDEF_SUFFIX = "cfg/server/persistent_player_data_version_231.pdef"; +const char* VPK_PDEF_PATH = "cfg/server/persistent_player_data_version_231.pdef"; + +void ModManager::BuildPdef() +{ + spdlog::info("Building persistent_player_data_version_231.pdef..."); + + fs::path MOD_PDEF_PATH = fs::path(GetCompiledAssetsPath() / MOD_PDEF_SUFFIX); + + fs::remove(MOD_PDEF_PATH); + std::string pdef = ReadVPKOriginalFile(VPK_PDEF_PATH); + + for (Mod& mod : m_LoadedMods) + { + if (!mod.m_bEnabled || !mod.Pdiff.size()) + continue; + + // this code probably isn't going to be pretty lol + // refer to shared/pjson.js for an actual okish parser of the pdiff format + // but pretty much, $ENUM_ADD blocks define members added to preexisting enums + // $PROP_START ends the custom stuff, and from there it's just normal props we append to the pdef + + std::map<std::string, std::vector<std::string>> enumAdds; + + // read pdiff + bool inEnum = false; + bool inProp = false; + std::string currentEnum; + std::string currentLine; + std::istringstream pdiffStream(mod.Pdiff); + + while (std::getline(pdiffStream, currentLine)) + { + if (inProp) + { + // just append to pdef here + pdef += currentLine; + pdef += '\n'; + continue; + } + + // trim leading whitespace + size_t start = currentLine.find_first_not_of(" \n\r\t\f\v"); + size_t end = currentLine.find("//"); + if (end == std::string::npos) + end = currentLine.size() - 1; // last char + + if (!currentLine.size() || !currentLine.compare(start, 2, "//")) + continue; + + if (inEnum) + { + if (!currentLine.compare(start, 9, "$ENUM_END")) + inEnum = false; + else + enumAdds[currentEnum].push_back(currentLine); // only need to push_back current line, if there's syntax errors then game + // pdef parser will handle them + } + else if (!currentLine.compare(start, 9, "$ENUM_ADD")) + { + inEnum = true; + currentEnum = currentLine.substr(start + 10 /*$ENUM_ADD + 1*/, currentLine.size() - end - (start + 10)); + enumAdds.insert(std::make_pair(currentEnum, std::vector<std::string>())); + } + else if (!currentLine.compare(start, 11, "$PROP_START")) + { + inProp = true; + pdef += "\n// $PROP_START "; + pdef += mod.Name; + pdef += "\n"; + } + } + + // add new members to preexisting enums + // note: this code could 100% be messed up if people put //$ENUM_START comments and the like + // could make it protect against this, but honestly not worth atm + for (auto enumAdd : enumAdds) + { + std::string addStr; + for (std::string enumMember : enumAdd.second) + { + addStr += enumMember; + addStr += '\n'; + } + + // start of enum we're adding to + std::string startStr = "$ENUM_START "; + startStr += enumAdd.first; + + // insert enum values into enum + size_t insertIdx = pdef.find("$ENUM_END", pdef.find(startStr)); + pdef.reserve(addStr.size()); + pdef.insert(insertIdx, addStr); + } + } + + fs::create_directories(MOD_PDEF_PATH.parent_path()); + + std::ofstream writeStream(MOD_PDEF_PATH, std::ios::binary); + writeStream << pdef; + writeStream.close(); + + ModOverrideFile overrideFile; + overrideFile.m_pOwningMod = nullptr; + overrideFile.m_Path = VPK_PDEF_PATH; + + if (m_ModFiles.find(VPK_PDEF_PATH) == m_ModFiles.end()) + m_ModFiles.insert(std::make_pair(VPK_PDEF_PATH, overrideFile)); + else + m_ModFiles[VPK_PDEF_PATH] = overrideFile; +} diff --git a/primedev/mods/compiled/modscriptsrson.cpp b/primedev/mods/compiled/modscriptsrson.cpp new file mode 100644 index 00000000..d130745f --- /dev/null +++ b/primedev/mods/compiled/modscriptsrson.cpp @@ -0,0 +1,65 @@ +#include "mods/modmanager.h" +#include "core/filesystem/filesystem.h" +#include "squirrel/squirrel.h" + +#include <fstream> + +const std::string MOD_SCRIPTS_RSON_SUFFIX = "scripts/vscripts/scripts.rson"; +const char* VPK_SCRIPTS_RSON_PATH = "scripts\\vscripts\\scripts.rson"; + +void ModManager::BuildScriptsRson() +{ + spdlog::info("Building custom scripts.rson"); + fs::path MOD_SCRIPTS_RSON_PATH = fs::path(GetCompiledAssetsPath() / MOD_SCRIPTS_RSON_SUFFIX); + fs::remove(MOD_SCRIPTS_RSON_PATH); + + std::string scriptsRson = ReadVPKOriginalFile(VPK_SCRIPTS_RSON_PATH); + scriptsRson += "\n\n// START MODDED SCRIPT CONTENT\n\n"; // newline before we start custom stuff + + for (Mod& mod : m_LoadedMods) + { + if (!mod.m_bEnabled) + continue; + + // this isn't needed at all, just nice to have imo + scriptsRson += "// MOD: "; + scriptsRson += mod.Name; + scriptsRson += ":\n\n"; + + for (ModScript& script : mod.Scripts) + { + /* should create something with this format for each script + When: "CONTEXT" + Scripts: + [ + _coolscript.gnut + ]*/ + + scriptsRson += "When: \""; + scriptsRson += script.RunOn; + scriptsRson += "\"\n"; + + scriptsRson += "Scripts:\n[\n\t"; + scriptsRson += script.Path; + scriptsRson += "\n]\n\n"; + } + } + + fs::create_directories(MOD_SCRIPTS_RSON_PATH.parent_path()); + + std::ofstream writeStream(MOD_SCRIPTS_RSON_PATH, std::ios::binary); + writeStream << scriptsRson; + writeStream.close(); + + ModOverrideFile overrideFile; + overrideFile.m_pOwningMod = nullptr; + overrideFile.m_Path = VPK_SCRIPTS_RSON_PATH; + + if (m_ModFiles.find(VPK_SCRIPTS_RSON_PATH) == m_ModFiles.end()) + m_ModFiles.insert(std::make_pair(VPK_SCRIPTS_RSON_PATH, overrideFile)); + else + m_ModFiles[VPK_SCRIPTS_RSON_PATH] = overrideFile; + + // todo: for preventing dupe scripts in scripts.rson, we could actually parse when conditions with the squirrel vm, just need a way to + // get a result out of squirrelmanager.ExecuteCode this would probably be the best way to do this, imo +} diff --git a/primedev/mods/modmanager.cpp b/primedev/mods/modmanager.cpp new file mode 100644 index 00000000..8a0eb71d --- /dev/null +++ b/primedev/mods/modmanager.cpp @@ -0,0 +1,1150 @@ +#include "modmanager.h" +#include "core/convar/convar.h" +#include "core/convar/concommand.h" +#include "client/audio.h" +#include "masterserver/masterserver.h" +#include "core/filesystem/filesystem.h" +#include "core/filesystem/rpakfilesystem.h" +#include "config/profile.h" + +#include "rapidjson/error/en.h" +#include "rapidjson/document.h" +#include "rapidjson/ostreamwrapper.h" +#include "rapidjson/prettywriter.h" +#include <filesystem> +#include <fstream> +#include <string> +#include <sstream> +#include <vector> +#include <regex> + +ModManager* g_pModManager; + +Mod::Mod(fs::path modDir, char* jsonBuf) +{ + m_bWasReadSuccessfully = false; + + m_ModDirectory = modDir; + + rapidjson_document modJson; + modJson.Parse<rapidjson::ParseFlag::kParseCommentsFlag | rapidjson::ParseFlag::kParseTrailingCommasFlag>(jsonBuf); + + spdlog::info("Loading mod file at path '{}'", modDir.string()); + + // fail if parse error + if (modJson.HasParseError()) + { + spdlog::error( + "Failed reading mod file {}: encountered parse error \"{}\" at offset {}", + (modDir / "mod.json").string(), + GetParseError_En(modJson.GetParseError()), + modJson.GetErrorOffset()); + return; + } + + // fail if it's not a json obj (could be an array, string, etc) + if (!modJson.IsObject()) + { + spdlog::error("Failed reading mod file {}: file is not a JSON object", (modDir / "mod.json").string()); + return; + } + + // basic mod info + // name is required + if (!modJson.HasMember("Name")) + { + spdlog::error("Failed reading mod file {}: missing required member \"Name\"", (modDir / "mod.json").string()); + return; + } + + Name = modJson["Name"].GetString(); + spdlog::info("Loading mod '{}'", Name); + + // Don't load blacklisted mods + if (!strstr(GetCommandLineA(), "-nomodblacklist") && MODS_BLACKLIST.find(Name) != std::end(MODS_BLACKLIST)) + { + spdlog::warn("Skipping blacklisted mod \"{}\"!", Name); + return; + } + + if (modJson.HasMember("Description")) + Description = modJson["Description"].GetString(); + else + Description = ""; + + if (modJson.HasMember("Version")) + Version = modJson["Version"].GetString(); + else + { + Version = "0.0.0"; + spdlog::warn("Mod file {} is missing a version, consider adding a version", (modDir / "mod.json").string()); + } + + if (modJson.HasMember("DownloadLink")) + DownloadLink = modJson["DownloadLink"].GetString(); + else + DownloadLink = ""; + + if (modJson.HasMember("RequiredOnClient")) + RequiredOnClient = modJson["RequiredOnClient"].GetBool(); + else + RequiredOnClient = false; + + if (modJson.HasMember("LoadPriority")) + LoadPriority = modJson["LoadPriority"].GetInt(); + else + { + spdlog::info("Mod file {} is missing a LoadPriority, consider adding one", (modDir / "mod.json").string()); + LoadPriority = 0; + } + + // Parse all array fields + ParseConVars(modJson); + ParseConCommands(modJson); + ParseScripts(modJson); + ParseLocalization(modJson); + ParseDependencies(modJson); + ParsePluginDependencies(modJson); + ParseInitScript(modJson); + + // A mod is remote if it's located in the remote mods folder + m_bIsRemote = m_ModDirectory.generic_string().find(GetRemoteModFolderPath().generic_string()) != std::string::npos; + + m_bWasReadSuccessfully = true; +} + +void Mod::ParseConVars(rapidjson_document& json) +{ + if (!json.HasMember("ConVars")) + return; + + if (!json["ConVars"].IsArray()) + { + spdlog::warn("'ConVars' field is not an array, skipping..."); + return; + } + + for (auto& convarObj : json["ConVars"].GetArray()) + { + if (!convarObj.IsObject()) + { + spdlog::warn("ConVar is not an object, skipping..."); + continue; + } + if (!convarObj.HasMember("Name")) + { + spdlog::warn("ConVar does not have a Name, skipping..."); + continue; + } + // from here on, the ConVar can be referenced by name in logs + if (!convarObj.HasMember("DefaultValue")) + { + spdlog::warn("ConVar '{}' does not have a DefaultValue, skipping...", convarObj["Name"].GetString()); + continue; + } + + // have to allocate this manually, otherwise convar registration will break + // unfortunately this causes us to leak memory on reload, unsure of a way around this rn + ModConVar* convar = new ModConVar; + convar->Name = convarObj["Name"].GetString(); + convar->DefaultValue = convarObj["DefaultValue"].GetString(); + + if (convarObj.HasMember("HelpString")) + convar->HelpString = convarObj["HelpString"].GetString(); + else + convar->HelpString = ""; + + convar->Flags = FCVAR_NONE; + + if (convarObj.HasMember("Flags")) + { + // read raw integer flags + if (convarObj["Flags"].IsInt()) + convar->Flags = convarObj["Flags"].GetInt(); + else if (convarObj["Flags"].IsString()) + { + // parse cvar flags from string + // example string: ARCHIVE_PLAYERPROFILE | GAMEDLL + convar->Flags |= ParseConVarFlagsString(convar->Name, convarObj["Flags"].GetString()); + } + } + + ConVars.push_back(convar); + + spdlog::info("'{}' contains ConVar '{}'", Name, convar->Name); + } +} + +void Mod::ParseConCommands(rapidjson_document& json) +{ + if (!json.HasMember("ConCommands")) + return; + + if (!json["ConCommands"].IsArray()) + { + spdlog::warn("'ConCommands' field is not an array, skipping..."); + return; + } + + for (auto& concommandObj : json["ConCommands"].GetArray()) + { + if (!concommandObj.IsObject()) + { + spdlog::warn("ConCommand is not an object, skipping..."); + continue; + } + if (!concommandObj.HasMember("Name")) + { + spdlog::warn("ConCommand does not have a Name, skipping..."); + continue; + } + // from here on, the ConCommand can be referenced by name in logs + if (!concommandObj.HasMember("Function")) + { + spdlog::warn("ConCommand '{}' does not have a Function, skipping...", concommandObj["Name"].GetString()); + continue; + } + if (!concommandObj.HasMember("Context")) + { + spdlog::warn("ConCommand '{}' does not have a Context, skipping...", concommandObj["Name"].GetString()); + continue; + } + + // have to allocate this manually, otherwise concommand registration will break + // unfortunately this causes us to leak memory on reload, unsure of a way around this rn + ModConCommand* concommand = new ModConCommand; + concommand->Name = concommandObj["Name"].GetString(); + concommand->Function = concommandObj["Function"].GetString(); + concommand->Context = ScriptContextFromString(concommandObj["Context"].GetString()); + if (concommand->Context == ScriptContext::INVALID) + { + spdlog::warn("ConCommand '{}' has invalid context '{}', skipping...", concommand->Name, concommandObj["Context"].GetString()); + continue; + } + + if (concommandObj.HasMember("HelpString")) + concommand->HelpString = concommandObj["HelpString"].GetString(); + else + concommand->HelpString = ""; + + concommand->Flags = FCVAR_NONE; + + if (concommandObj.HasMember("Flags")) + { + // read raw integer flags + if (concommandObj["Flags"].IsInt()) + { + concommand->Flags = concommandObj["Flags"].GetInt(); + } + else if (concommandObj["Flags"].IsString()) + { + // parse cvar flags from string + // example string: ARCHIVE_PLAYERPROFILE | GAMEDLL + concommand->Flags |= ParseConVarFlagsString(concommand->Name, concommandObj["Flags"].GetString()); + } + } + + ConCommands.push_back(concommand); + + spdlog::info("'{}' contains ConCommand '{}'", Name, concommand->Name); + } +} + +void Mod::ParseScripts(rapidjson_document& json) +{ + if (!json.HasMember("Scripts")) + return; + + if (!json["Scripts"].IsArray()) + { + spdlog::warn("'Scripts' field is not an array, skipping..."); + return; + } + + for (auto& scriptObj : json["Scripts"].GetArray()) + { + if (!scriptObj.IsObject()) + { + spdlog::warn("Script is not an object, skipping..."); + continue; + } + if (!scriptObj.HasMember("Path")) + { + spdlog::warn("Script does not have a Path, skipping..."); + continue; + } + // from here on, the Path for a script is used as it's name in logs + if (!scriptObj.HasMember("RunOn")) + { + // "a RunOn" sounds dumb but anything else doesn't match the format of the warnings... + // this is the best i could think of within 20 seconds + spdlog::warn("Script '{}' does not have a RunOn field, skipping...", scriptObj["Path"].GetString()); + continue; + } + + ModScript script; + + script.Path = scriptObj["Path"].GetString(); + script.RunOn = scriptObj["RunOn"].GetString(); + + if (scriptObj.HasMember("ServerCallback")) + { + if (scriptObj["ServerCallback"].IsObject()) + { + ModScriptCallback callback; + callback.Context = ScriptContext::SERVER; + + if (scriptObj["ServerCallback"].HasMember("Before")) + { + if (scriptObj["ServerCallback"]["Before"].IsString()) + callback.BeforeCallback = scriptObj["ServerCallback"]["Before"].GetString(); + else + spdlog::warn("'Before' ServerCallback for script '{}' is not a string, skipping...", scriptObj["Path"].GetString()); + } + + if (scriptObj["ServerCallback"].HasMember("After")) + { + if (scriptObj["ServerCallback"]["After"].IsString()) + callback.AfterCallback = scriptObj["ServerCallback"]["After"].GetString(); + else + spdlog::warn("'After' ServerCallback for script '{}' is not a string, skipping...", scriptObj["Path"].GetString()); + } + + if (scriptObj["ServerCallback"].HasMember("Destroy")) + { + if (scriptObj["ServerCallback"]["Destroy"].IsString()) + callback.DestroyCallback = scriptObj["ServerCallback"]["Destroy"].GetString(); + else + spdlog::warn( + "'Destroy' ServerCallback for script '{}' is not a string, skipping...", scriptObj["Path"].GetString()); + } + + script.Callbacks.push_back(callback); + } + else + { + spdlog::warn("ServerCallback for script '{}' is not an object, skipping...", scriptObj["Path"].GetString()); + } + } + + if (scriptObj.HasMember("ClientCallback")) + { + if (scriptObj["ClientCallback"].IsObject()) + { + ModScriptCallback callback; + callback.Context = ScriptContext::CLIENT; + + if (scriptObj["ClientCallback"].HasMember("Before")) + { + if (scriptObj["ClientCallback"]["Before"].IsString()) + callback.BeforeCallback = scriptObj["ClientCallback"]["Before"].GetString(); + else + spdlog::warn("'Before' ClientCallback for script '{}' is not a string, skipping...", scriptObj["Path"].GetString()); + } + + if (scriptObj["ClientCallback"].HasMember("After")) + { + if (scriptObj["ClientCallback"]["After"].IsString()) + callback.AfterCallback = scriptObj["ClientCallback"]["After"].GetString(); + else + spdlog::warn("'After' ClientCallback for script '{}' is not a string, skipping...", scriptObj["Path"].GetString()); + } + + if (scriptObj["ClientCallback"].HasMember("Destroy")) + { + if (scriptObj["ClientCallback"]["Destroy"].IsString()) + callback.DestroyCallback = scriptObj["ClientCallback"]["Destroy"].GetString(); + else + spdlog::warn( + "'Destroy' ClientCallback for script '{}' is not a string, skipping...", scriptObj["Path"].GetString()); + } + + script.Callbacks.push_back(callback); + } + else + { + spdlog::warn("ClientCallback for script '{}' is not an object, skipping...", scriptObj["Path"].GetString()); + } + } + + if (scriptObj.HasMember("UICallback")) + { + if (scriptObj["UICallback"].IsObject()) + { + ModScriptCallback callback; + callback.Context = ScriptContext::UI; + + if (scriptObj["UICallback"].HasMember("Before")) + { + if (scriptObj["UICallback"]["Before"].IsString()) + callback.BeforeCallback = scriptObj["UICallback"]["Before"].GetString(); + else + spdlog::warn("'Before' UICallback for script '{}' is not a string, skipping...", scriptObj["Path"].GetString()); + } + + if (scriptObj["UICallback"].HasMember("After")) + { + if (scriptObj["UICallback"]["After"].IsString()) + callback.AfterCallback = scriptObj["UICallback"]["After"].GetString(); + else + spdlog::warn("'After' UICallback for script '{}' is not a string, skipping...", scriptObj["Path"].GetString()); + } + + if (scriptObj["UICallback"].HasMember("Destroy")) + { + if (scriptObj["UICallback"]["Destroy"].IsString()) + callback.DestroyCallback = scriptObj["UICallback"]["Destroy"].GetString(); + else + spdlog::warn("'Destroy' UICallback for script '{}' is not a string, skipping...", scriptObj["Path"].GetString()); + } + + script.Callbacks.push_back(callback); + } + else + { + spdlog::warn("UICallback for script '{}' is not an object, skipping...", scriptObj["Path"].GetString()); + } + } + + Scripts.push_back(script); + + spdlog::info("'{}' contains Script '{}'", Name, script.Path); + } +} + +void Mod::ParseLocalization(rapidjson_document& json) +{ + if (!json.HasMember("Localisation")) + return; + + if (!json["Localisation"].IsArray()) + { + spdlog::warn("'Localisation' field is not an array, skipping..."); + return; + } + + for (auto& localisationStr : json["Localisation"].GetArray()) + { + if (!localisationStr.IsString()) + { + // not a string but we still GetString() to log it :trol: + spdlog::warn("Localisation '{}' is not a string, skipping...", localisationStr.GetString()); + continue; + } + + LocalisationFiles.push_back(localisationStr.GetString()); + + spdlog::info("'{}' registered Localisation '{}'", Name, localisationStr.GetString()); + } +} + +void Mod::ParseDependencies(rapidjson_document& json) +{ + if (!json.HasMember("Dependencies")) + return; + + if (!json["Dependencies"].IsObject()) + { + spdlog::warn("'Dependencies' field is not an object, skipping..."); + return; + } + + for (auto v = json["Dependencies"].MemberBegin(); v != json["Dependencies"].MemberEnd(); v++) + { + if (!v->name.IsString()) + { + spdlog::warn("Dependency constant '{}' is not a string, skipping...", v->name.GetString()); + continue; + } + if (!v->value.IsString()) + { + spdlog::warn("Dependency constant '{}' is not a string, skipping...", v->value.GetString()); + continue; + } + + if (DependencyConstants.find(v->name.GetString()) != DependencyConstants.end() && + v->value.GetString() != DependencyConstants[v->name.GetString()]) + { + // this is fatal because otherwise the mod will probably try to use functions that dont exist, + // which will cause errors further down the line that are harder to debug + spdlog::error( + "'{}' attempted to register a dependency constant '{}' for '{}' that already exists for '{}'. " + "Change the constant name.", + Name, + v->name.GetString(), + v->value.GetString(), + DependencyConstants[v->name.GetString()]); + return; + } + + if (DependencyConstants.find(v->name.GetString()) == DependencyConstants.end()) + DependencyConstants.emplace(v->name.GetString(), v->value.GetString()); + + spdlog::info("'{}' registered dependency constant '{}' for mod '{}'", Name, v->name.GetString(), v->value.GetString()); + } +} + +void Mod::ParsePluginDependencies(rapidjson_document& json) +{ + if (!json.HasMember("PluginDependencies")) + return; + + if (!json["PluginDependencies"].IsArray()) + { + spdlog::warn("'PluginDependencies' field is not an object, skipping..."); + return; + } + + for (auto& name : json["PluginDependencies"].GetArray()) + { + if (!name.IsString()) + continue; + + spdlog::info("Plugin Constant {} defined by {}", name.GetString(), Name); + + PluginDependencyConstants.push_back(name.GetString()); + } +} + +void Mod::ParseInitScript(rapidjson_document& json) +{ + if (!json.HasMember("InitScript")) + return; + + if (!json["InitScript"].IsString()) + { + spdlog::warn("'InitScript' field is not a string, skipping..."); + return; + } + + initScript = json["InitScript"].GetString(); +} + +ModManager::ModManager() +{ + // precaculated string hashes + // note: use backslashes for these, since we use lexically_normal for file paths which uses them + m_hScriptsRsonHash = STR_HASH("scripts\\vscripts\\scripts.rson"); + m_hPdefHash = STR_HASH( + "cfg\\server\\persistent_player_data_version_231.pdef" // this can have multiple versions, but we use 231 so that's what we hash + ); + m_hKBActHash = STR_HASH("scripts\\kb_act.lst"); + + LoadMods(); +} + +struct Test +{ + std::string funcName; + ScriptContext context; +}; + +template <ScriptContext context> auto ModConCommandCallback_Internal(std::string name, const CCommand& command) +{ + if (g_pSquirrel<context>->m_pSQVM && g_pSquirrel<context>->m_pSQVM) + { + if (command.ArgC() == 1) + { + g_pSquirrel<context>->AsyncCall(name); + } + else + { + std::vector<std::string> args; + args.reserve(command.ArgC()); + for (int i = 1; i < command.ArgC(); i++) + args.push_back(command.Arg(i)); + g_pSquirrel<context>->AsyncCall(name, args); + } + } + else + { + spdlog::warn("ConCommand `{}` was called while the associated Squirrel VM `{}` was unloaded", name, GetContextName(context)); + } +} + +auto ModConCommandCallback(const CCommand& command) +{ + ModConCommand* found = nullptr; + auto commandString = std::string(command.GetCommandString()); + + // Finding the first space to remove the command's name + auto firstSpace = commandString.find(' '); + if (firstSpace) + { + commandString = commandString.substr(0, firstSpace); + } + + // Find the mod this command belongs to + for (auto& mod : g_pModManager->m_LoadedMods) + { + auto res = std::find_if( + mod.ConCommands.begin(), + mod.ConCommands.end(), + [&commandString](const ModConCommand* other) { return other->Name == commandString; }); + if (res != mod.ConCommands.end()) + { + found = *res; + break; + } + } + if (!found) + return; + + switch (found->Context) + { + case ScriptContext::CLIENT: + ModConCommandCallback_Internal<ScriptContext::CLIENT>(found->Function, command); + break; + case ScriptContext::SERVER: + ModConCommandCallback_Internal<ScriptContext::SERVER>(found->Function, command); + break; + case ScriptContext::UI: + ModConCommandCallback_Internal<ScriptContext::UI>(found->Function, command); + break; + }; +} + +void ModManager::LoadMods() +{ + if (m_bHasLoadedMods) + UnloadMods(); + + std::vector<fs::path> modDirs; + + // ensure dirs exist + fs::remove_all(GetCompiledAssetsPath()); + fs::create_directories(GetModFolderPath()); + fs::create_directories(GetThunderstoreModFolderPath()); + fs::create_directories(GetRemoteModFolderPath()); + + m_DependencyConstants.clear(); + + // read enabled mods cfg + std::ifstream enabledModsStream(GetNorthstarPrefix() + "/enabledmods.json"); + std::stringstream enabledModsStringStream; + + if (!enabledModsStream.fail()) + { + while (enabledModsStream.peek() != EOF) + enabledModsStringStream << (char)enabledModsStream.get(); + + enabledModsStream.close(); + m_EnabledModsCfg.Parse<rapidjson::ParseFlag::kParseCommentsFlag | rapidjson::ParseFlag::kParseTrailingCommasFlag>( + enabledModsStringStream.str().c_str()); + + m_bHasEnabledModsCfg = m_EnabledModsCfg.IsObject(); + } + + // get mod directories + std::filesystem::directory_iterator classicModsDir = fs::directory_iterator(GetModFolderPath()); + std::filesystem::directory_iterator remoteModsDir = fs::directory_iterator(GetRemoteModFolderPath()); + std::filesystem::directory_iterator thunderstoreModsDir = fs::directory_iterator(GetThunderstoreModFolderPath()); + + for (fs::directory_entry dir : classicModsDir) + if (fs::exists(dir.path() / "mod.json")) + modDirs.push_back(dir.path()); + + // Special case for Thunderstore and remote mods directories + // Set up regex for `AUTHOR-MOD-VERSION` pattern + std::regex pattern(R"(.*\\([a-zA-Z0-9_]+)-([a-zA-Z0-9_]+)-(\d+\.\d+\.\d+))"); + + for (fs::directory_iterator dirIterator : {thunderstoreModsDir, remoteModsDir}) + { + for (fs::directory_entry dir : dirIterator) + { + fs::path modsDir = dir.path() / "mods"; // Check for mods folder in the Thunderstore mod + // Use regex to match `AUTHOR-MOD-VERSION` pattern + if (!std::regex_match(dir.path().string(), pattern)) + { + spdlog::warn("The following directory did not match 'AUTHOR-MOD-VERSION': {}", dir.path().string()); + continue; // skip loading mod that doesn't match + } + if (fs::exists(modsDir) && fs::is_directory(modsDir)) + { + for (fs::directory_entry subDir : fs::directory_iterator(modsDir)) + { + if (fs::exists(subDir.path() / "mod.json")) + { + modDirs.push_back(subDir.path()); + } + } + } + } + } + + for (fs::path modDir : modDirs) + { + // read mod json file + std::ifstream jsonStream(modDir / "mod.json"); + std::stringstream jsonStringStream; + + // fail if no mod json + if (jsonStream.fail()) + { + spdlog::warn( + "Mod file at '{}' does not exist or could not be read, is it installed correctly?", (modDir / "mod.json").string()); + continue; + } + + while (jsonStream.peek() != EOF) + jsonStringStream << (char)jsonStream.get(); + + jsonStream.close(); + + Mod mod(modDir, (char*)jsonStringStream.str().c_str()); + + for (auto& pair : mod.DependencyConstants) + { + if (m_DependencyConstants.find(pair.first) != m_DependencyConstants.end() && m_DependencyConstants[pair.first] != pair.second) + { + spdlog::error( + "'{}' attempted to register a dependency constant '{}' for '{}' that already exists for '{}'. " + "Change the constant name.", + mod.Name, + pair.first, + pair.second, + m_DependencyConstants[pair.first]); + mod.m_bWasReadSuccessfully = false; + break; + } + if (m_DependencyConstants.find(pair.first) == m_DependencyConstants.end()) + m_DependencyConstants.emplace(pair); + } + + for (std::string& dependency : mod.PluginDependencyConstants) + { + m_PluginDependencyConstants.insert(dependency); + } + + if (m_bHasEnabledModsCfg && m_EnabledModsCfg.HasMember(mod.Name.c_str())) + mod.m_bEnabled = m_EnabledModsCfg[mod.Name.c_str()].IsTrue(); + else + mod.m_bEnabled = true; + + if (mod.m_bWasReadSuccessfully) + { + if (mod.m_bEnabled) + spdlog::info("'{}' loaded successfully, version {}", mod.Name, mod.Version); + else + spdlog::info("'{}' loaded successfully, version {} (DISABLED)", mod.Name, mod.Version); + + m_LoadedMods.push_back(mod); + } + else + spdlog::warn("Mod file at '{}' failed to load", (modDir / "mod.json").string()); + } + + // sort by load prio, lowest-highest + std::sort(m_LoadedMods.begin(), m_LoadedMods.end(), [](Mod& a, Mod& b) { return a.LoadPriority < b.LoadPriority; }); + + // This is used to check if some mods have a folder but no entry in enabledmods.json + bool newModsDetected = false; + + for (Mod& mod : m_LoadedMods) + { + if (!mod.m_bEnabled) + continue; + + // Add mod entry to enabledmods.json if it doesn't exist + if (!mod.m_bIsRemote && !m_EnabledModsCfg.HasMember(mod.Name.c_str())) + { + m_EnabledModsCfg.AddMember(rapidjson_document::StringRefType(mod.Name.c_str()), true, m_EnabledModsCfg.GetAllocator()); + newModsDetected = true; + } + + // register convars + // for reloads, this is sorta barebones, when we have a good findconvar method, we could probably reset flags and stuff on + // preexisting convars note: we don't delete convars if they already exist because they're used for script stuff, unfortunately this + // causes us to leak memory on reload, but not much, potentially find a way to not do this at some point + for (ModConVar* convar : mod.ConVars) + { + // make sure convar isn't registered yet, unsure if necessary but idk what + // behaviour is for defining same convar multiple times + if (!g_pCVar->FindVar(convar->Name.c_str())) + { + new ConVar(convar->Name.c_str(), convar->DefaultValue.c_str(), convar->Flags, convar->HelpString.c_str()); + } + } + + for (ModConCommand* command : mod.ConCommands) + { + // make sure command isnt't registered multiple times. + if (!g_pCVar->FindCommand(command->Name.c_str())) + { + ConCommand* newCommand = new ConCommand(); + std::string funcName = command->Function; + RegisterConCommand(command->Name.c_str(), ModConCommandCallback, command->HelpString.c_str(), command->Flags); + } + } + + // read vpk paths + if (fs::exists(mod.m_ModDirectory / "vpk")) + { + // read vpk cfg + std::ifstream vpkJsonStream(mod.m_ModDirectory / "vpk/vpk.json"); + std::stringstream vpkJsonStringStream; + + bool bUseVPKJson = false; + rapidjson::Document dVpkJson; + + if (!vpkJsonStream.fail()) + { + while (vpkJsonStream.peek() != EOF) + vpkJsonStringStream << (char)vpkJsonStream.get(); + + vpkJsonStream.close(); + dVpkJson.Parse<rapidjson::ParseFlag::kParseCommentsFlag | rapidjson::ParseFlag::kParseTrailingCommasFlag>( + vpkJsonStringStream.str().c_str()); + + bUseVPKJson = !dVpkJson.HasParseError() && dVpkJson.IsObject(); + } + + for (fs::directory_entry file : fs::directory_iterator(mod.m_ModDirectory / "vpk")) + { + // a bunch of checks to make sure we're only adding dir vpks and their paths are good + // note: the game will literally only load vpks with the english prefix + if (fs::is_regular_file(file) && file.path().extension() == ".vpk" && + file.path().string().find("english") != std::string::npos && + file.path().string().find(".bsp.pak000_dir") != std::string::npos) + { + std::string formattedPath = file.path().filename().string(); + + // this really fucking sucks but it'll work + std::string vpkName = formattedPath.substr(strlen("english"), formattedPath.find(".bsp") - 3); + + ModVPKEntry& modVpk = mod.Vpks.emplace_back(); + modVpk.m_bAutoLoad = !bUseVPKJson || (dVpkJson.HasMember("Preload") && dVpkJson["Preload"].IsObject() && + dVpkJson["Preload"].HasMember(vpkName) && dVpkJson["Preload"][vpkName].IsTrue()); + modVpk.m_sVpkPath = (file.path().parent_path() / vpkName).string(); + + if (m_bHasLoadedMods && modVpk.m_bAutoLoad) + (*g_pFilesystem)->m_vtable->MountVPK(*g_pFilesystem, vpkName.c_str()); + } + } + } + + // read rpak paths + if (fs::exists(mod.m_ModDirectory / "paks")) + { + // read rpak cfg + std::ifstream rpakJsonStream(mod.m_ModDirectory / "paks/rpak.json"); + std::stringstream rpakJsonStringStream; + + bool bUseRpakJson = false; + rapidjson::Document dRpakJson; + + if (!rpakJsonStream.fail()) + { + while (rpakJsonStream.peek() != EOF) + rpakJsonStringStream << (char)rpakJsonStream.get(); + + rpakJsonStream.close(); + dRpakJson.Parse<rapidjson::ParseFlag::kParseCommentsFlag | rapidjson::ParseFlag::kParseTrailingCommasFlag>( + rpakJsonStringStream.str().c_str()); + + bUseRpakJson = !dRpakJson.HasParseError() && dRpakJson.IsObject(); + } + + // read pak aliases + if (bUseRpakJson && dRpakJson.HasMember("Aliases") && dRpakJson["Aliases"].IsObject()) + { + for (rapidjson::Value::ConstMemberIterator iterator = dRpakJson["Aliases"].MemberBegin(); + iterator != dRpakJson["Aliases"].MemberEnd(); + iterator++) + { + if (!iterator->name.IsString() || !iterator->value.IsString()) + continue; + + mod.RpakAliases.insert(std::make_pair(iterator->name.GetString(), iterator->value.GetString())); + } + } + + for (fs::directory_entry file : fs::directory_iterator(mod.m_ModDirectory / "paks")) + { + // ensure we're only loading rpaks + if (fs::is_regular_file(file) && file.path().extension() == ".rpak") + { + std::string pakName(file.path().filename().string()); + + ModRpakEntry& modPak = mod.Rpaks.emplace_back(); + modPak.m_bAutoLoad = + !bUseRpakJson || (dRpakJson.HasMember("Preload") && dRpakJson["Preload"].IsObject() && + dRpakJson["Preload"].HasMember(pakName) && dRpakJson["Preload"][pakName].IsTrue()); + + // postload things + if (!bUseRpakJson || + (dRpakJson.HasMember("Postload") && dRpakJson["Postload"].IsObject() && dRpakJson["Postload"].HasMember(pakName))) + modPak.m_sLoadAfterPak = dRpakJson["Postload"][pakName].GetString(); + + modPak.m_sPakName = pakName; + + // read header of file and get the starpak paths + // this is done here as opposed to on starpak load because multiple rpaks can load a starpak + // and there is seemingly no good way to tell which rpak is causing the load of a starpak :/ + + std::ifstream rpakStream(file.path(), std::ios::binary); + + // seek to the point in the header where the starpak reference size is + rpakStream.seekg(0x38, std::ios::beg); + int starpaksSize = 0; + rpakStream.read((char*)&starpaksSize, 2); + + // seek to just after the header + rpakStream.seekg(0x58, std::ios::beg); + // read the starpak reference(s) + std::vector<char> buf(starpaksSize); + rpakStream.read(buf.data(), starpaksSize); + + rpakStream.close(); + + // split the starpak reference(s) into strings to hash + std::string str = ""; + for (int i = 0; i < starpaksSize; i++) + { + // if the current char is null, that signals the end of the current starpak path + if (buf[i] != 0x00) + { + str += buf[i]; + } + else + { + // only add the string we are making if it isnt empty + if (!str.empty()) + { + mod.StarpakPaths.push_back(STR_HASH(str)); + spdlog::info("Mod {} registered starpak '{}'", mod.Name, str); + str = ""; + } + } + } + + // not using atm because we need to resolve path to rpak + // if (m_hasLoadedMods && modPak.m_bAutoLoad) + // g_pPakLoadManager->LoadPakAsync(pakName.c_str()); + } + } + } + + // read keyvalues paths + if (fs::exists(mod.m_ModDirectory / "keyvalues")) + { + for (fs::directory_entry file : fs::recursive_directory_iterator(mod.m_ModDirectory / "keyvalues")) + { + if (fs::is_regular_file(file)) + { + std::string kvStr = + g_pModManager->NormaliseModFilePath(file.path().lexically_relative(mod.m_ModDirectory / "keyvalues")); + mod.KeyValues.emplace(STR_HASH(kvStr), kvStr); + } + } + } + + // read pdiff + if (fs::exists(mod.m_ModDirectory / "mod.pdiff")) + { + std::ifstream pdiffStream(mod.m_ModDirectory / "mod.pdiff"); + + if (!pdiffStream.fail()) + { + std::stringstream pdiffStringStream; + while (pdiffStream.peek() != EOF) + pdiffStringStream << (char)pdiffStream.get(); + + pdiffStream.close(); + + mod.Pdiff = pdiffStringStream.str(); + } + } + + // read bink video paths + if (fs::exists(mod.m_ModDirectory / "media")) + { + for (fs::directory_entry file : fs::recursive_directory_iterator(mod.m_ModDirectory / "media")) + if (fs::is_regular_file(file) && file.path().extension() == ".bik") + mod.BinkVideos.push_back(file.path().filename().string()); + } + + // try to load audio + if (fs::exists(mod.m_ModDirectory / "audio")) + { + for (fs::directory_entry file : fs::directory_iterator(mod.m_ModDirectory / "audio")) + { + if (fs::is_regular_file(file) && file.path().extension().string() == ".json") + { + if (!g_CustomAudioManager.TryLoadAudioOverride(file.path())) + { + spdlog::warn("Mod {} has an invalid audio def {}", mod.Name, file.path().filename().string()); + continue; + } + } + } + } + } + + // If there are new mods, we write entries accordingly in enabledmods.json + if (newModsDetected) + { + std::ofstream writeStream(GetNorthstarPrefix() + "/enabledmods.json"); + rapidjson::OStreamWrapper writeStreamWrapper(writeStream); + rapidjson::PrettyWriter<rapidjson::OStreamWrapper> writer(writeStreamWrapper); + m_EnabledModsCfg.Accept(writer); + } + + // in a seperate loop because we register mod files in reverse order, since mods loaded later should have their files prioritised + for (int64_t i = m_LoadedMods.size() - 1; i > -1; i--) + { + if (!m_LoadedMods[i].m_bEnabled) + continue; + + if (fs::exists(m_LoadedMods[i].m_ModDirectory / MOD_OVERRIDE_DIR)) + { + for (fs::directory_entry file : fs::recursive_directory_iterator(m_LoadedMods[i].m_ModDirectory / MOD_OVERRIDE_DIR)) + { + std::string path = + g_pModManager->NormaliseModFilePath(file.path().lexically_relative(m_LoadedMods[i].m_ModDirectory / MOD_OVERRIDE_DIR)); + if (file.is_regular_file() && m_ModFiles.find(path) == m_ModFiles.end()) + { + ModOverrideFile modFile; + modFile.m_pOwningMod = &m_LoadedMods[i]; + modFile.m_Path = path; + m_ModFiles.insert(std::make_pair(path, modFile)); + } + } + } + } + + // build modinfo obj for masterserver + rapidjson_document modinfoDoc; + auto& alloc = modinfoDoc.GetAllocator(); + modinfoDoc.SetObject(); + modinfoDoc.AddMember("Mods", rapidjson::kArrayType, alloc); + + int currentModIndex = 0; + for (Mod& mod : m_LoadedMods) + { + if (!mod.m_bEnabled || (!mod.RequiredOnClient && !mod.Pdiff.size())) + continue; + + modinfoDoc["Mods"].PushBack(rapidjson::kObjectType, modinfoDoc.GetAllocator()); + modinfoDoc["Mods"][currentModIndex].AddMember("Name", rapidjson::StringRef(&mod.Name[0]), modinfoDoc.GetAllocator()); + modinfoDoc["Mods"][currentModIndex].AddMember("Version", rapidjson::StringRef(&mod.Version[0]), modinfoDoc.GetAllocator()); + modinfoDoc["Mods"][currentModIndex].AddMember("RequiredOnClient", mod.RequiredOnClient, modinfoDoc.GetAllocator()); + modinfoDoc["Mods"][currentModIndex].AddMember("Pdiff", rapidjson::StringRef(&mod.Pdiff[0]), modinfoDoc.GetAllocator()); + + currentModIndex++; + } + + rapidjson::StringBuffer buffer; + buffer.Clear(); + rapidjson::Writer<rapidjson::StringBuffer> writer(buffer); + modinfoDoc.Accept(writer); + g_pMasterServerManager->m_sOwnModInfoJson = std::string(buffer.GetString()); + + m_bHasLoadedMods = true; +} + +void ModManager::UnloadMods() +{ + // clean up stuff from mods before we unload + m_ModFiles.clear(); + fs::remove_all(GetCompiledAssetsPath()); + + g_CustomAudioManager.ClearAudioOverrides(); + + if (!m_bHasEnabledModsCfg) + m_EnabledModsCfg.SetObject(); + + for (Mod& mod : m_LoadedMods) + { + // remove all built kvs + for (std::pair<size_t, std::string> kvPaths : mod.KeyValues) + fs::remove(GetCompiledAssetsPath() / fs::path(kvPaths.second).lexically_relative(mod.m_ModDirectory)); + + mod.KeyValues.clear(); + + // write to m_enabledModsCfg + // should we be doing this here or should scripts be doing this manually? + // main issue with doing this here is when we reload mods for connecting to a server, we write enabled mods, which isn't necessarily + // what we wanna do + if (!m_EnabledModsCfg.HasMember(mod.Name.c_str())) + m_EnabledModsCfg.AddMember(rapidjson_document::StringRefType(mod.Name.c_str()), false, m_EnabledModsCfg.GetAllocator()); + + m_EnabledModsCfg[mod.Name.c_str()].SetBool(mod.m_bEnabled); + } + + std::ofstream writeStream(GetNorthstarPrefix() + "/enabledmods.json"); + rapidjson::OStreamWrapper writeStreamWrapper(writeStream); + rapidjson::PrettyWriter<rapidjson::OStreamWrapper> writer(writeStreamWrapper); + m_EnabledModsCfg.Accept(writer); + + // do we need to dealloc individual entries in m_loadedMods? idk, rework + m_LoadedMods.clear(); +} + +std::string ModManager::NormaliseModFilePath(const fs::path path) +{ + std::string str = path.lexically_normal().string(); + + // force to lowercase + for (char& c : str) + if (c <= 'Z' && c >= 'A') + c = c - ('Z' - 'z'); + + return str; +} + +void ModManager::CompileAssetsForFile(const char* filename) +{ + size_t fileHash = STR_HASH(NormaliseModFilePath(fs::path(filename))); + + if (fileHash == m_hScriptsRsonHash) + BuildScriptsRson(); + else if (fileHash == m_hPdefHash) + BuildPdef(); + else if (fileHash == m_hKBActHash) + BuildKBActionsList(); + else + { + // check if we should build keyvalues, depending on whether any of our mods have patch kvs for this file + for (Mod& mod : m_LoadedMods) + { + if (!mod.m_bEnabled) + continue; + + if (mod.KeyValues.find(fileHash) != mod.KeyValues.end()) + { + TryBuildKeyValues(filename); + return; + } + } + } +} + +void ConCommand_reload_mods(const CCommand& args) +{ + g_pModManager->LoadMods(); +} + +fs::path GetModFolderPath() +{ + return fs::path(GetNorthstarPrefix() + MOD_FOLDER_SUFFIX); +} +fs::path GetThunderstoreModFolderPath() +{ + return fs::path(GetNorthstarPrefix() + THUNDERSTORE_MOD_FOLDER_SUFFIX); +} +fs::path GetRemoteModFolderPath() +{ + return fs::path(GetNorthstarPrefix() + REMOTE_MOD_FOLDER_SUFFIX); +} +fs::path GetCompiledAssetsPath() +{ + return fs::path(GetNorthstarPrefix() + COMPILED_ASSETS_SUFFIX); +} + +ON_DLL_LOAD_RELIESON("engine.dll", ModManager, (ConCommand, MasterServer), (CModule module)) +{ + g_pModManager = new ModManager; + + RegisterConCommand("reload_mods", ConCommand_reload_mods, "reloads mods", FCVAR_NONE); +} diff --git a/primedev/mods/modmanager.h b/primedev/mods/modmanager.h new file mode 100644 index 00000000..233f004d --- /dev/null +++ b/primedev/mods/modmanager.h @@ -0,0 +1,187 @@ +#pragma once +#include "core/convar/convar.h" +#include "core/memalloc.h" +#include "squirrel/squirrel.h" + +#include "rapidjson/document.h" +#include <string> +#include <vector> +#include <filesystem> +#include <unordered_set> + +const std::string MOD_FOLDER_SUFFIX = "\\mods"; +const std::string THUNDERSTORE_MOD_FOLDER_SUFFIX = "\\packages"; +const std::string REMOTE_MOD_FOLDER_SUFFIX = "\\runtime\\remote\\mods"; +const fs::path MOD_OVERRIDE_DIR = "mod"; +const std::string COMPILED_ASSETS_SUFFIX = "\\runtime\\compiled"; + +const std::set<std::string> MODS_BLACKLIST = {"Mod Settings"}; + +struct ModConVar +{ +public: + std::string Name; + std::string DefaultValue; + std::string HelpString; + int Flags; +}; + +struct ModConCommand +{ +public: + std::string Name; + std::string Function; + std::string HelpString; + ScriptContext Context; + int Flags; +}; + +struct ModScriptCallback +{ +public: + ScriptContext Context; + + // called before the codecallback is executed + std::string BeforeCallback; + // called after the codecallback has finished executing + std::string AfterCallback; + // called right before the vm is destroyed. + std::string DestroyCallback; +}; + +struct ModScript +{ +public: + std::string Path; + std::string RunOn; + + std::vector<ModScriptCallback> Callbacks; +}; + +// these are pretty much identical, could refactor to use the same stuff? +struct ModVPKEntry +{ +public: + bool m_bAutoLoad; + std::string m_sVpkPath; +}; + +struct ModRpakEntry +{ +public: + bool m_bAutoLoad; + std::string m_sPakName; + std::string m_sLoadAfterPak; +}; + +class Mod +{ +public: + // runtime stuff + bool m_bEnabled = true; + bool m_bWasReadSuccessfully = false; + fs::path m_ModDirectory; + bool m_bIsRemote; + + // mod.json stuff: + + // the mod's name + std::string Name; + // the mod's description + std::string Description; + // the mod's version, should be in semver + std::string Version; + // a download link to the mod, for clients that try to join without the mod + std::string DownloadLink; + + // whether clients need the mod to join servers running this mod + bool RequiredOnClient; + // the priority for this mod's files, mods with prio 0 are loaded first, then 1, then 2, etc + int LoadPriority; + + // custom scripts used by the mod + std::vector<ModScript> Scripts; + // convars created by the mod + std::vector<ModConVar*> ConVars; + // concommands created by the mod + std::vector<ModConCommand*> ConCommands; + // custom localisation files created by the mod + std::vector<std::string> LocalisationFiles; + // custom script init.nut + std::string initScript; + + // other files: + + std::vector<ModVPKEntry> Vpks; + std::unordered_map<size_t, std::string> KeyValues; + std::vector<std::string> BinkVideos; + std::string Pdiff; // only need one per mod + + std::vector<ModRpakEntry> Rpaks; + std::unordered_map<std::string, std::string> + RpakAliases; // paks we alias to other rpaks, e.g. to load sp_crashsite paks on the map mp_crashsite + std::vector<size_t> StarpakPaths; // starpaks that this mod contains + // there seems to be no nice way to get the rpak that is causing the load of a starpak? + // hashed with STR_HASH + + std::unordered_map<std::string, std::string> DependencyConstants; + std::vector<std::string> PluginDependencyConstants; + +public: + Mod(fs::path modPath, char* jsonBuf); + +private: + void ParseConVars(rapidjson_document& json); + void ParseConCommands(rapidjson_document& json); + void ParseScripts(rapidjson_document& json); + void ParseLocalization(rapidjson_document& json); + void ParseDependencies(rapidjson_document& json); + void ParsePluginDependencies(rapidjson_document& json); + void ParseInitScript(rapidjson_document& json); +}; + +struct ModOverrideFile +{ +public: + Mod* m_pOwningMod; + fs::path m_Path; +}; + +class ModManager +{ +private: + bool m_bHasLoadedMods = false; + bool m_bHasEnabledModsCfg; + rapidjson_document m_EnabledModsCfg; + + // precalculated hashes + size_t m_hScriptsRsonHash; + size_t m_hPdefHash; + size_t m_hKBActHash; + +public: + std::vector<Mod> m_LoadedMods; + std::unordered_map<std::string, ModOverrideFile> m_ModFiles; + std::unordered_map<std::string, std::string> m_DependencyConstants; + std::unordered_set<std::string> m_PluginDependencyConstants; + +public: + ModManager(); + void LoadMods(); + void UnloadMods(); + std::string NormaliseModFilePath(const fs::path path); + void CompileAssetsForFile(const char* filename); + + // compile asset type stuff, these are done in files under runtime/compiled/ + void BuildScriptsRson(); + void TryBuildKeyValues(const char* filename); + void BuildPdef(); + void BuildKBActionsList(); +}; + +fs::path GetModFolderPath(); +fs::path GetRemoteModFolderPath(); +fs::path GetThunderstoreModFolderPath(); +fs::path GetCompiledAssetsPath(); + +extern ModManager* g_pModManager; diff --git a/primedev/mods/modsavefiles.cpp b/primedev/mods/modsavefiles.cpp new file mode 100644 index 00000000..68e33864 --- /dev/null +++ b/primedev/mods/modsavefiles.cpp @@ -0,0 +1,572 @@ +#include <filesystem> +#include <sstream> +#include <fstream> +#include "squirrel/squirrel.h" +#include "util/utils.h" +#include "mods/modmanager.h" +#include "modsavefiles.h" +#include "rapidjson/document.h" +#include "rapidjson/writer.h" +#include "rapidjson/stringbuffer.h" +#include "config/profile.h" +#include "core/tier0.h" +#include "rapidjson/error/en.h" +#include "scripts/scriptjson.h" + +SaveFileManager* g_pSaveFileManager; +int MAX_FOLDER_SIZE = 52428800; // 50MB (50 * 1024 * 1024) +fs::path savePath; + +/// <summary></summary> +/// <param name="dir">The directory we want the size of.</param> +/// <param name="file">The file we're excluding from the calculation.</param> +/// <returns>The size of the contents of the current directory, excluding a specific file.</returns> +uintmax_t GetSizeOfFolderContentsMinusFile(fs::path dir, std::string file) +{ + uintmax_t result = 0; + for (const auto& entry : fs::directory_iterator(dir)) + { + if (entry.path().filename() == file) + continue; + // fs::file_size may not work on directories - but does in some cases. + // per cppreference.com, it's "implementation-defined". + try + { + result += fs::file_size(entry.path()); + } + catch (fs::filesystem_error& e) + { + if (entry.is_directory()) + { + result += GetSizeOfFolderContentsMinusFile(entry.path(), ""); + } + } + } + return result; +} + +uintmax_t GetSizeOfFolder(fs::path dir) +{ + uintmax_t result = 0; + for (const auto& entry : fs::directory_iterator(dir)) + { + // fs::file_size may not work on directories - but does in some cases. + // per cppreference.com, it's "implementation-defined". + try + { + result += fs::file_size(entry.path()); + } + catch (fs::filesystem_error& e) + { + if (entry.is_directory()) + { + result += GetSizeOfFolderContentsMinusFile(entry.path(), ""); + } + } + } + return result; +} + +// Saves a file asynchronously. +template <ScriptContext context> void SaveFileManager::SaveFileAsync(fs::path file, std::string contents) +{ + auto mutex = std::ref(fileMutex); + std::thread writeThread( + [mutex, file, contents]() + { + try + { + mutex.get().lock(); + + fs::path dir = file.parent_path(); + // this actually allows mods to go over the limit, but not by much + // the limit is to prevent mods from taking gigabytes of space, + // we don't need to be particularly strict. + if (GetSizeOfFolderContentsMinusFile(dir, file.filename().string()) + contents.length() > MAX_FOLDER_SIZE) + { + // tbh, you're either trying to fill the hard drive or use so much data, you SHOULD be congratulated. + spdlog::error(fmt::format("Mod spamming save requests? Folder limit bypassed despite previous checks. Not saving.")); + mutex.get().unlock(); + return; + } + + std::ofstream fileStr(file); + if (fileStr.fail()) + { + mutex.get().unlock(); + return; + } + + fileStr.write(contents.c_str(), contents.length()); + fileStr.close(); + + mutex.get().unlock(); + // side-note: this causes a leak? + // when a file is added to the map, it's never removed. + // no idea how to fix this - because we have no way to check if there are other threads waiting to use this file(?) + // tried to use m.try_lock(), but it's unreliable, it seems. + } + catch (std::exception ex) + { + spdlog::error("SAVE FAILED!"); + mutex.get().unlock(); + spdlog::error(ex.what()); + } + }); + + writeThread.detach(); +} + +// Loads a file asynchronously. +template <ScriptContext context> int SaveFileManager::LoadFileAsync(fs::path file) +{ + int handle = ++m_iLastRequestHandle; + auto mutex = std::ref(fileMutex); + std::thread readThread( + [mutex, file, handle]() + { + try + { + mutex.get().lock(); + + std::ifstream fileStr(file); + if (fileStr.fail()) + { + spdlog::error("A file was supposed to be loaded but we can't access it?!"); + + g_pSquirrel<context>->AsyncCall("NSHandleLoadResult", handle, false, ""); + mutex.get().unlock(); + return; + } + + std::stringstream stringStream; + stringStream << fileStr.rdbuf(); + + g_pSquirrel<context>->AsyncCall("NSHandleLoadResult", handle, true, stringStream.str()); + + fileStr.close(); + mutex.get().unlock(); + // side-note: this causes a leak? + // when a file is added to the map, it's never removed. + // no idea how to fix this - because we have no way to check if there are other threads waiting to use this file(?) + // tried to use m.try_lock(), but it's unreliable, it seems. + } + catch (std::exception ex) + { + spdlog::error("LOAD FAILED!"); + g_pSquirrel<context>->AsyncCall("NSHandleLoadResult", handle, false, ""); + mutex.get().unlock(); + spdlog::error(ex.what()); + } + }); + + readThread.detach(); + return handle; +} + +// Deletes a file asynchronously. +template <ScriptContext context> void SaveFileManager::DeleteFileAsync(fs::path file) +{ + // P.S. I don't like how we have to async delete calls but we do. + auto m = std::ref(fileMutex); + std::thread deleteThread( + [m, file]() + { + try + { + m.get().lock(); + + fs::remove(file); + + m.get().unlock(); + // side-note: this causes a leak? + // when a file is added to the map, it's never removed. + // no idea how to fix this - because we have no way to check if there are other threads waiting to use this file(?) + // tried to use m.try_lock(), but it's unreliable, it seems. + } + catch (std::exception ex) + { + spdlog::error("DELETE FAILED!"); + m.get().unlock(); + spdlog::error(ex.what()); + } + }); + + deleteThread.detach(); +} + +// Checks if a file contains null characters. +bool ContainsInvalidChars(std::string str) +{ + // we don't allow null characters either, even if they're ASCII characters because idk if people can + // use it to circumvent the file extension suffix. + return std::any_of(str.begin(), str.end(), [](char c) { return c == '\0'; }); +} + +// Checks if the relative path (param) remains inside the mod directory (dir). +// Paths are restricted to ASCII because encoding is fucked and we decided we won't bother. +bool IsPathSafe(const std::string param, fs::path dir) +{ + try + { + auto const normRoot = fs::weakly_canonical(dir); + auto const normChild = fs::weakly_canonical(dir / param); + + auto itr = std::search(normChild.begin(), normChild.end(), normRoot.begin(), normRoot.end()); + // we return if the file is safe (inside the directory) and uses only ASCII chars in the path. + return itr == normChild.begin() && std::none_of( + param.begin(), + param.end(), + [](char c) + { + unsigned char unsignedC = static_cast<unsigned char>(c); + return unsignedC > 127 || unsignedC < 0; + }); + } + catch (fs::filesystem_error err) + { + return false; + } +} + +// void NSSaveFile( string file, string data ) +ADD_SQFUNC("void", NSSaveFile, "string file, string data", "", ScriptContext::SERVER | ScriptContext::CLIENT | ScriptContext::UI) +{ + Mod* mod = g_pSquirrel<context>->getcallingmod(sqvm); + if (mod == nullptr) + { + g_pSquirrel<context>->raiseerror(sqvm, "Has to be called from a mod function!"); + return SQRESULT_ERROR; + } + + fs::path dir = savePath / fs::path(mod->m_ModDirectory).filename(); + std::string fileName = g_pSquirrel<context>->getstring(sqvm, 1); + if (!IsPathSafe(fileName, dir)) + { + g_pSquirrel<context>->raiseerror( + sqvm, + fmt::format( + "File name invalid ({})! Make sure it does not contain any non-ASCII character, and results in a path inside your mod's " + "save folder.", + fileName, + mod->Name) + .c_str()); + return SQRESULT_ERROR; + } + + std::string content = g_pSquirrel<context>->getstring(sqvm, 2); + if (ContainsInvalidChars(content)) + { + g_pSquirrel<context>->raiseerror( + sqvm, fmt::format("File contents may not contain NUL/\\0 characters! Make sure your strings are valid!", mod->Name).c_str()); + return SQRESULT_ERROR; + } + + fs::create_directories(dir); + // this actually allows mods to go over the limit, but not by much + // the limit is to prevent mods from taking gigabytes of space, + // this ain't a cloud service. + if (GetSizeOfFolderContentsMinusFile(dir, fileName) + content.length() > MAX_FOLDER_SIZE) + { + g_pSquirrel<context>->raiseerror( + sqvm, + fmt::format( + "The mod {} has reached the maximum folder size.\n\nAsk the mod developer to optimize their data usage," + "or increase the maximum folder size using the -maxfoldersize launch parameter.", + mod->Name) + .c_str()); + return SQRESULT_ERROR; + } + + g_pSaveFileManager->SaveFileAsync<context>(dir / fileName, content); + + return SQRESULT_NULL; +} + +// void NSSaveJSONFile(string file, table data) +ADD_SQFUNC("void", NSSaveJSONFile, "string file, table data", "", ScriptContext::SERVER | ScriptContext::CLIENT | ScriptContext::UI) +{ + Mod* mod = g_pSquirrel<context>->getcallingmod(sqvm); + if (mod == nullptr) + { + g_pSquirrel<context>->raiseerror(sqvm, "Has to be called from a mod function!"); + return SQRESULT_ERROR; + } + + fs::path dir = savePath / fs::path(mod->m_ModDirectory).filename(); + std::string fileName = g_pSquirrel<context>->getstring(sqvm, 1); + if (!IsPathSafe(fileName, dir)) + { + g_pSquirrel<context>->raiseerror( + sqvm, + fmt::format( + "File name invalid ({})! Make sure it does not contain any non-ASCII character, and results in a path inside your mod's " + "save folder.", + fileName, + mod->Name) + .c_str()); + return SQRESULT_ERROR; + } + + // Note - this cannot be done in the async func since the table may get garbage collected. + // This means that especially large tables may still clog up the system. + std::string content = EncodeJSON<context>(sqvm); + if (ContainsInvalidChars(content)) + { + g_pSquirrel<context>->raiseerror( + sqvm, fmt::format("File contents may not contain NUL/\\0 characters! Make sure your strings are valid!", mod->Name).c_str()); + return SQRESULT_ERROR; + } + + fs::create_directories(dir); + // this actually allows mods to go over the limit, but not by much + // the limit is to prevent mods from taking gigabytes of space, + // this ain't a cloud service. + if (GetSizeOfFolderContentsMinusFile(dir, fileName) + content.length() > MAX_FOLDER_SIZE) + { + g_pSquirrel<context>->raiseerror( + sqvm, + fmt::format( + "The mod {} has reached the maximum folder size.\n\nAsk the mod developer to optimize their data usage," + "or increase the maximum folder size using the -maxfoldersize launch parameter.", + mod->Name) + .c_str()); + return SQRESULT_ERROR; + } + + g_pSaveFileManager->SaveFileAsync<context>(dir / fileName, content); + + return SQRESULT_NULL; +} + +// int NS_InternalLoadFile(string file) +ADD_SQFUNC("int", NS_InternalLoadFile, "string file", "", ScriptContext::SERVER | ScriptContext::CLIENT | ScriptContext::UI) +{ + Mod* mod = g_pSquirrel<context>->getcallingmod(sqvm, 1); // the function that called NSLoadFile :) + if (mod == nullptr) + { + g_pSquirrel<context>->raiseerror(sqvm, "Has to be called from a mod function!"); + return SQRESULT_ERROR; + } + + fs::path dir = savePath / fs::path(mod->m_ModDirectory).filename(); + std::string fileName = g_pSquirrel<context>->getstring(sqvm, 1); + if (!IsPathSafe(fileName, dir)) + { + g_pSquirrel<context>->raiseerror( + sqvm, + fmt::format( + "File name invalid ({})! Make sure it does not contain any non-ASCII character, and results in a path inside your mod's " + "save folder.", + fileName, + mod->Name) + .c_str()); + return SQRESULT_ERROR; + } + + g_pSquirrel<context>->pushinteger(sqvm, g_pSaveFileManager->LoadFileAsync<context>(dir / fileName)); + + return SQRESULT_NOTNULL; +} + +// bool NSDoesFileExist(string file) +ADD_SQFUNC("bool", NSDoesFileExist, "string file", "", ScriptContext::SERVER | ScriptContext::CLIENT | ScriptContext::UI) +{ + Mod* mod = g_pSquirrel<context>->getcallingmod(sqvm); + + fs::path dir = savePath / fs::path(mod->m_ModDirectory).filename(); + std::string fileName = g_pSquirrel<context>->getstring(sqvm, 1); + if (!IsPathSafe(fileName, dir)) + { + g_pSquirrel<context>->raiseerror( + sqvm, + fmt::format( + "File name invalid ({})! Make sure it does not contain any non-ASCII character, and results in a path inside your mod's " + "save folder.", + fileName, + mod->Name) + .c_str()); + return SQRESULT_ERROR; + } + + g_pSquirrel<context>->pushbool(sqvm, fs::exists(dir / (fileName))); + return SQRESULT_NOTNULL; +} + +// int NSGetFileSize(string file) +ADD_SQFUNC("int", NSGetFileSize, "string file", "", ScriptContext::SERVER | ScriptContext::CLIENT | ScriptContext::UI) +{ + Mod* mod = g_pSquirrel<context>->getcallingmod(sqvm); + + fs::path dir = savePath / fs::path(mod->m_ModDirectory).filename(); + std::string fileName = g_pSquirrel<context>->getstring(sqvm, 1); + if (!IsPathSafe(fileName, dir)) + { + g_pSquirrel<context>->raiseerror( + sqvm, + fmt::format( + "File name invalid ({})! Make sure it does not contain any non-ASCII character, and results in a path inside your mod's " + "save folder.", + fileName, + mod->Name) + .c_str()); + return SQRESULT_ERROR; + } + try + { + // throws if file does not exist + // we don't want stuff such as "file does not exist, file is unavailable" to be lethal, so we just try/catch fs errors + g_pSquirrel<context>->pushinteger(sqvm, (int)(fs::file_size(dir / fileName) / 1024)); + } + catch (std::filesystem::filesystem_error const& ex) + { + spdlog::error("GET FILE SIZE FAILED! Is the path valid?"); + g_pSquirrel<context>->raiseerror(sqvm, ex.what()); + return SQRESULT_ERROR; + } + return SQRESULT_NOTNULL; +} + +// void NSDeleteFile(string file) +ADD_SQFUNC("void", NSDeleteFile, "string file", "", ScriptContext::SERVER | ScriptContext::CLIENT | ScriptContext::UI) +{ + Mod* mod = g_pSquirrel<context>->getcallingmod(sqvm); + + fs::path dir = savePath / fs::path(mod->m_ModDirectory).filename(); + std::string fileName = g_pSquirrel<context>->getstring(sqvm, 1); + if (!IsPathSafe(fileName, dir)) + { + g_pSquirrel<context>->raiseerror( + sqvm, + fmt::format( + "File name invalid ({})! Make sure it does not contain any non-ASCII character, and results in a path inside your mod's " + "save folder.", + fileName, + mod->Name) + .c_str()); + return SQRESULT_ERROR; + } + + g_pSaveFileManager->DeleteFileAsync<context>(dir / fileName); + return SQRESULT_NOTNULL; +} + +// The param is not optional because that causes issues :) +ADD_SQFUNC("array<string>", NS_InternalGetAllFiles, "string path", "", ScriptContext::CLIENT | ScriptContext::UI | ScriptContext::SERVER) +{ + // depth 1 because this should always get called from Northstar.Custom + Mod* mod = g_pSquirrel<context>->getcallingmod(sqvm, 1); + fs::path dir = savePath / fs::path(mod->m_ModDirectory).filename(); + std::string pathStr = g_pSquirrel<context>->getstring(sqvm, 1); + fs::path path = dir; + if (pathStr != "") + path = dir / pathStr; + if (!IsPathSafe(pathStr, dir)) + { + g_pSquirrel<context>->raiseerror( + sqvm, + fmt::format( + "File name invalid ({})! Make sure it does not contain any non-ASCII character, and results in a path inside your mod's " + "save folder.", + pathStr, + mod->Name) + .c_str()); + return SQRESULT_ERROR; + } + try + { + g_pSquirrel<context>->newarray(sqvm, 0); + for (const auto& entry : fs::directory_iterator(path)) + { + g_pSquirrel<context>->pushstring(sqvm, entry.path().filename().string().c_str()); + g_pSquirrel<context>->arrayappend(sqvm, -2); + } + return SQRESULT_NOTNULL; + } + catch (std::exception ex) + { + spdlog::error("DIR ITERATE FAILED! Is the path valid?"); + g_pSquirrel<context>->raiseerror(sqvm, ex.what()); + return SQRESULT_ERROR; + } +} + +ADD_SQFUNC("bool", NSIsFolder, "string path", "", ScriptContext::CLIENT | ScriptContext::UI | ScriptContext::SERVER) +{ + Mod* mod = g_pSquirrel<context>->getcallingmod(sqvm); + fs::path dir = savePath / fs::path(mod->m_ModDirectory).filename(); + std::string pathStr = g_pSquirrel<context>->getstring(sqvm, 1); + fs::path path = dir; + if (pathStr != "") + path = dir / pathStr; + if (!IsPathSafe(pathStr, dir)) + { + g_pSquirrel<context>->raiseerror( + sqvm, + fmt::format( + "File name invalid ({})! Make sure it does not contain any non-ASCII character, and results in a path inside your mod's " + "save folder.", + pathStr, + mod->Name) + .c_str()); + return SQRESULT_ERROR; + } + try + { + g_pSquirrel<context>->pushbool(sqvm, fs::is_directory(path)); + return SQRESULT_NOTNULL; + } + catch (std::exception ex) + { + spdlog::error("DIR READ FAILED! Is the path valid?"); + spdlog::info(path.string()); + g_pSquirrel<context>->raiseerror(sqvm, ex.what()); + return SQRESULT_ERROR; + } +} + +// side note, expensive. +ADD_SQFUNC("int", NSGetTotalSpaceRemaining, "", "", ScriptContext::CLIENT | ScriptContext::UI | ScriptContext::SERVER) +{ + Mod* mod = g_pSquirrel<context>->getcallingmod(sqvm); + fs::path dir = savePath / fs::path(mod->m_ModDirectory).filename(); + g_pSquirrel<context>->pushinteger(sqvm, (MAX_FOLDER_SIZE - GetSizeOfFolder(dir)) / 1024); + return SQRESULT_NOTNULL; +} + +// ok, I'm just gonna explain what the fuck is going on here because this +// is the pinnacle of my stupidity and I do not want to touch this ever +// again, yet someone will eventually have to maintain this. +template <ScriptContext context> std::string EncodeJSON(HSquirrelVM* sqvm) +{ + // new rapidjson + rapidjson_document doc; + doc.SetObject(); + + // get the SECOND param + SQTable* table = sqvm->_stackOfCurrentFunction[2]._VAL.asTable; + // take the table and copy it's contents over into the rapidjson_document + EncodeJSONTable<context>(table, &doc, doc.GetAllocator()); + + // convert JSON document to string + rapidjson::StringBuffer buffer; + rapidjson::Writer<rapidjson::StringBuffer> writer(buffer); + doc.Accept(writer); + + // return the converted string + return buffer.GetString(); +} + +ON_DLL_LOAD("engine.dll", ModSaveFFiles_Init, (CModule module)) +{ + savePath = fs::path(GetNorthstarPrefix()) / "save_data"; + g_pSaveFileManager = new SaveFileManager; + int parm = CommandLine()->FindParm("-maxfoldersize"); + if (parm) + MAX_FOLDER_SIZE = std::stoi(CommandLine()->GetParm(parm)); +} + +int GetMaxSaveFolderSize() +{ + return MAX_FOLDER_SIZE; +} diff --git a/primedev/mods/modsavefiles.h b/primedev/mods/modsavefiles.h new file mode 100644 index 00000000..f9d39723 --- /dev/null +++ b/primedev/mods/modsavefiles.h @@ -0,0 +1,16 @@ +#pragma once +int GetMaxSaveFolderSize(); +bool ContainsInvalidChars(std::string str); + +class SaveFileManager +{ +public: + template <ScriptContext context> void SaveFileAsync(fs::path file, std::string content); + template <ScriptContext context> int LoadFileAsync(fs::path file); + template <ScriptContext context> void DeleteFileAsync(fs::path file); + // Future proofed in that if we ever get multi-threaded SSDs this code will take advantage of them. + std::mutex fileMutex; + +private: + int m_iLastRequestHandle = 0; +}; |