aboutsummaryrefslogtreecommitdiff
path: root/primedev/mods
diff options
context:
space:
mode:
authorJack <66967891+ASpoonPlaysGames@users.noreply.github.com>2023-12-27 00:32:01 +0000
committerGitHub <noreply@github.com>2023-12-27 01:32:01 +0100
commitf5ab6fb5e8be7b73e6003d4145081d5e0c0ce287 (patch)
tree90f2c6a4885dbd181799e2325cf33588697674e1 /primedev/mods
parentbb8ed59f6891b1196c5f5bbe7346cd171c8215fa (diff)
downloadNorthstarLauncher-1.21.2.tar.gz
NorthstarLauncher-1.21.2.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.cpp638
-rw-r--r--primedev/mods/autodownload/moddownloader.h151
-rw-r--r--primedev/mods/compiled/kb_act.cpp44
-rw-r--r--primedev/mods/compiled/modkeyvalues.cpp106
-rw-r--r--primedev/mods/compiled/modpdef.cpp118
-rw-r--r--primedev/mods/compiled/modscriptsrson.cpp65
-rw-r--r--primedev/mods/modmanager.cpp1150
-rw-r--r--primedev/mods/modmanager.h187
-rw-r--r--primedev/mods/modsavefiles.cpp572
-rw-r--r--primedev/mods/modsavefiles.h16
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;
+};