diff --git a/CMakeLists.txt b/CMakeLists.txt index a7c4fdd51..569a47e25 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) option(SOURCEPP_LIBS_START_ENABLED "Libraries will all build by default" ON) option(SOURCEPP_USE_BSPPP "Build bsppp library" ${SOURCEPP_LIBS_START_ENABLED}) option(SOURCEPP_USE_DMXPP "Build dmxpp library" ${SOURCEPP_LIBS_START_ENABLED}) +option(SOURCEPP_USE_FSPP "Build fspp library" ${SOURCEPP_LIBS_START_ENABLED}) option(SOURCEPP_USE_GAMEPP "Build gamepp library" ${SOURCEPP_LIBS_START_ENABLED}) option(SOURCEPP_USE_KVPP "Build kvpp library" ${SOURCEPP_LIBS_START_ENABLED}) option(SOURCEPP_USE_MDLPP "Build mdlpp library" ${SOURCEPP_LIBS_START_ENABLED}) @@ -48,6 +49,12 @@ option(SOURCEPP_VPKPP_SUPPORT_VPK_V54 "Support compressed v54 VPKs" ON) if(SOURCEPP_USE_BSPPP) set(SOURCEPP_USE_VPKPP ON CACHE INTERNAL "" FORCE) endif() +if(SOURCEPP_USE_FSPP) + set(SOURCEPP_USE_BSPPP ON CACHE INTERNAL "" FORCE) + set(SOURCEPP_USE_KVPP ON CACHE INTERNAL "" FORCE) + set(SOURCEPP_USE_STEAMPP ON CACHE INTERNAL "" FORCE) + set(SOURCEPP_USE_VPKPP ON CACHE INTERNAL "" FORCE) +endif() if(SOURCEPP_USE_STEAMPP) set(SOURCEPP_USE_KVPP ON CACHE INTERNAL "" FORCE) endif() @@ -188,6 +195,7 @@ endif() # Add libraries add_sourcepp_library(bsppp NO_TEST ) # sourcepp::bsppp add_sourcepp_library(dmxpp ) # sourcepp::dmxpp +add_sourcepp_library(fspp ) # sourcepp::fspp add_sourcepp_library(gamepp C PYTHON ) # sourcepp::gamepp add_sourcepp_library(kvpp BENCH) # sourcepp::kvpp add_sourcepp_library(mdlpp ) # sourcepp::mdlpp @@ -257,7 +265,7 @@ endif() # Print options print_options(OPTIONS - USE_BSPPP USE_DMXPP USE_GAMEPP USE_KVPP USE_MDLPP USE_STEAMPP USE_TOOLPP USE_VCRYPTPP USE_VPKPP USE_VTFPP + USE_BSPPP USE_DMXPP USE_FSPP USE_GAMEPP USE_KVPP USE_MDLPP USE_STEAMPP USE_TOOLPP USE_VCRYPTPP USE_VPKPP USE_VTFPP BUILD_BENCHMARKS BUILD_C_WRAPPERS BUILD_CSHARP_WRAPPERS BUILD_PYTHON_WRAPPERS BUILD_WITH_OPENCL BUILD_WITH_TBB BUILD_WITH_THREADS BUILD_TESTS BUILD_WIN7_COMPAT LINK_STATIC_MSVC_RUNTIME VPKPP_SUPPORT_VPK_V54) diff --git a/include/fspp/fspp.h b/include/fspp/fspp.h new file mode 100644 index 000000000..ecd69d9f4 --- /dev/null +++ b/include/fspp/fspp.h @@ -0,0 +1,75 @@ +#pragma once + +#include + +#include +#include + +namespace fspp { + +#if defined(_WIN32) + constexpr std::string_view DEFAULT_PLATFORM = "win64"; +#elif defined(__APPLE__) + constexpr std::string_view DEFAULT_PLATFORM = "osx64"; +#elif defined(__linux__) + constexpr std::string_view DEFAULT_PLATFORM = "linux64"; +#else + #warning "Unknown platform! Leaving the default platform blank..." + constexpr std::string_view DEFAULT_PLATFORM = ""; +#endif + +struct FileSystemOptions { + std::string binPlatform{DEFAULT_PLATFORM}; + //std::string language{}; // todo: add a _ dir or __dir.vpk for each GAME path + //bool loadPakXXVPKs = true; // todo: load pakXX_dir.vpk for each dir path + //bool loadSteamMounts = true; // todo: cfg/mounts.kv, the mounts block in gameinfo (Strata) + //bool loadAddonList = false; // todo: addonlist.txt (L4D2), addonlist.kv3 (Strata) + //bool useDLCFolders = true; // todo: dlc1, dlc2, etc. + //bool useUpdate = true; // todo: mount update folder on GAME/MOD with highest priority + //bool useXLSPPatch = true; // todo: mount xlsppatch folder on GAME/MOD with highester priority +}; + +class FileSystem { +public: + using SearchPathMapDir = std::unordered_map>; + using SearchPathMapVPK = std::unordered_map>>; + + /** + * Creates a FileSystem based on a Steam installation + * @param appID The AppID of the base game + * @param gameID The name of the directory where gameinfo.txt is located (e.g. "portal2") + * @param options FileSystem creation options + * @return The created FileSystem if the specified Steam game is installed + */ + [[nodiscard]] static std::optional load(steampp::AppID appID, std::string_view gameID, const FileSystemOptions& options = {}); + + /** + * Creates a FileSystem based on a local installation + * @param gamePath The full path to the directory where gameinfo.txt is located (e.g. "path/to/portal2") + * @param options FileSystem creation options + * @return The created FileSystem if gameinfo.txt is found + */ + [[nodiscard]] static std::optional load(std::string_view gamePath, const FileSystemOptions& options = {}); + + [[nodiscard]] const SearchPathMapDir& getSearchPathDirs() const; + + [[nodiscard]] SearchPathMapDir& getSearchPathDirs(); + + [[nodiscard]] const SearchPathMapVPK& getSearchPathVPKs() const; + + [[nodiscard]] SearchPathMapVPK& getSearchPathVPKs(); + + [[nodiscard]] std::optional> read(std::string_view filePath, std::string_view searchPath = "GAME", bool prioritizeVPKs = true) const; + + [[nodiscard]] std::optional> readForMap(const vpkpp::PackFile* map, std::string_view filePath, std::string_view searchPath = "GAME", bool prioritizeVPKs = true) const; + +protected: + explicit FileSystem(std::string_view gamePath, const FileSystemOptions& options = {}); + +private: + std::string rootPath; + SearchPathMapDir searchPathDirs; + SearchPathMapVPK searchPathVPKs; +}; + +} // namespace fspp diff --git a/src/fspp/_fspp.cmake b/src/fspp/_fspp.cmake new file mode 100644 index 000000000..410957112 --- /dev/null +++ b/src/fspp/_fspp.cmake @@ -0,0 +1,6 @@ +add_pretty_parser(fspp + DEPS sourcepp::kvpp sourcepp::steampp + DEPS_PUBLIC sourcepp::bsppp sourcepp::vpkpp + SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/include/fspp/fspp.h" + "${CMAKE_CURRENT_LIST_DIR}/fspp.cpp") diff --git a/src/fspp/fspp.cpp b/src/fspp/fspp.cpp new file mode 100644 index 000000000..372f6b25c --- /dev/null +++ b/src/fspp/fspp.cpp @@ -0,0 +1,243 @@ +#include + +#include + +#include +#include +#include +#include +#include + +using namespace bsppp; +using namespace fspp; +using namespace kvpp; +using namespace sourcepp; +using namespace steampp; +using namespace vpkpp; + +namespace { + +[[nodiscard]] std::string getAppInstallDir(AppID appID) { + static Steam steam; + return steam.getAppInstallDir(appID); +} + +} // namespace + +std::optional FileSystem::load(steampp::AppID appID, std::string_view gameID, const FileSystemOptions& options) { + const auto gamePath = ::getAppInstallDir(appID); + if (gamePath.empty()) { + return std::nullopt; + } + return load((std::filesystem::path{gamePath} / gameID).string(), options); +} + +std::optional FileSystem::load(std::string_view gamePath, const FileSystemOptions& options) { + if (!std::filesystem::exists(std::filesystem::path{gamePath} / "gameinfo.txt") || !std::filesystem::is_regular_file(std::filesystem::path{gamePath} / "gameinfo.txt")) { + return std::nullopt; + } + return FileSystem{gamePath, options}; +} + +FileSystem::FileSystem(std::string_view gamePath, const FileSystemOptions& options) + : rootPath(std::filesystem::path{gamePath}.parent_path().string()) { + string::normalizeSlashes(this->rootPath); + const auto gameID = std::filesystem::path{gamePath}.filename().string(); + + // Load gameinfo.txt + KV1 gameinfo{fs::readFileText((std::filesystem::path{gamePath} / "gameinfo.txt").string())}; + if (gameinfo.getChildCount() == 0) { + return; + } + + // Load searchpaths + const auto& searchPathKVs = gameinfo[0]["FileSystem"]["SearchPaths"]; + if (searchPathKVs.isInvalid()) { + return; + } + for (int i = 0; i < searchPathKVs.getChildCount(); i++) { + auto searches = string::split(string::toLower(searchPathKVs[i].getKey()), '+'); + auto path = std::string{string::toLower(searchPathKVs[i].getValue())}; + + // Replace |all_source_engine_paths| with "", |gameinfo_path| with "/" + static constexpr std::string_view ALL_SOURCE_ENGINE_PATHS = "|all_source_engine_paths|"; + static constexpr std::string_view GAMEINFO_PATH = "|gameinfo_path|"; + if (path.starts_with(ALL_SOURCE_ENGINE_PATHS)) { + path = path.substr(ALL_SOURCE_ENGINE_PATHS.length()); + } else if (path.starts_with(GAMEINFO_PATH)) { + path = gameID + '/' + path.substr(GAMEINFO_PATH.length()); + } + if (path.ends_with(".") && !path.ends_with("..")) { + path.pop_back(); + } + string::normalizeSlashes(path); + + if (path.ends_with(".vpk")) { + auto fullPath = this->rootPath + '/' + path; + + // Normalize the ending (add _dir if present) + if (!std::filesystem::exists(fullPath)) { + auto fullPathWithDir = (std::filesystem::path{fullPath}.parent_path() / std::filesystem::path{fullPath}.stem()).string() + "_dir.vpk"; + if (!std::filesystem::exists(fullPathWithDir)) { + continue; + } + fullPath = fullPathWithDir; + } + + // Add the VPK search path + for (const auto& search : searches) { + if (!this->searchPathVPKs.contains(search)) { + this->searchPathVPKs[search] = std::vector>{}; + } + auto packFile = PackFile::open(fullPath); + if (packFile) { + this->searchPathVPKs[search].push_back(std::move(packFile)); + } + } + } else { + for (const auto& search : searches) { + if (!this->searchPathDirs.contains(search)) { + this->searchPathDirs[search] = {}; + } + if (path.ends_with("/*")) { + // Add the glob dir searchpath + if (const auto globParentPath = this->rootPath + '/' + path.substr(0, path.length() - 2); std::filesystem::exists(globParentPath) && std::filesystem::is_directory(globParentPath)) { + for (const auto directoryIterator : std::filesystem::directory_iterator{globParentPath, std::filesystem::directory_options::skip_permission_denied}) { + auto globChildPath = std::filesystem::relative(directoryIterator.path(), this->rootPath).string(); + string::normalizeSlashes(globChildPath); + this->searchPathDirs[search].push_back(globChildPath); + } + } + } else if (std::filesystem::exists(this->rootPath + '/' + path)) { + // Add the dir searchpath + this->searchPathDirs[search].push_back(path); + + if (search == "game") { + // Add dir/bin to GAMEBIN searchpath + if (!this->searchPathDirs.contains("gamebin")) { + this->searchPathDirs["gamebin"] = {}; + } + this->searchPathDirs["gamebin"].push_back(path + "/bin"); + + if (i == 0) { + // Add dir to MOD searchpath + if (!this->searchPathDirs.contains("mod")) { + this->searchPathDirs["mod"] = {}; + } + this->searchPathDirs["mod"].push_back(path); + } + } + } + + // todo: Add the pakXX_dir VPK searchpath(s) if they exist + } + } + } + + // todo: Add DLCs / update dir / xlsppatch dir if they exist + + // Add EXECUTABLE_PATH if it doesn't exist, point it at "bin/"; "bin"; "" + if (!this->searchPathDirs.contains("executable_path")) { + if (!options.binPlatform.empty() && std::filesystem::exists(std::filesystem::path{this->rootPath} / "bin" / options.binPlatform)) { + this->searchPathDirs["executable_path"] = {"bin/" + options.binPlatform}; + } else { + this->searchPathDirs["executable_path"] = {}; + } + this->searchPathDirs["executable_path"].push_back("bin"); + this->searchPathDirs["executable_path"].push_back(""); + } + + // Add PLATFORM if it doesn't exist, point it at "platform" + if (!this->searchPathDirs.contains("platform")) { + this->searchPathDirs["platform"] = {"platform"}; + } + + // Add PLATFORM path to GAME searchpath as well + if (this->searchPathDirs.contains("game")) { + bool foundPlatform = false; + for (const auto& path : this->searchPathDirs["game"]) { + if (path == "platform") { + foundPlatform = true; + } + } + if (!foundPlatform) { + this->searchPathDirs["game"].push_back("platform"); + } + } + + // Add DEFAULT_WRITE_PATH if it doesn't exist, point it at "" + if (!this->searchPathDirs.contains("default_write_path")) { + this->searchPathDirs["default_write_path"] = {gameID}; + } + + // Add LOGDIR if it doesn't exist, point it at "" + if (!this->searchPathDirs.contains("logdir")) { + this->searchPathDirs["logdir"] = {gameID}; + } + + // Add CONFIG if it doesn't exist, point it at "platform/config" + if (!this->searchPathDirs.contains("config")) { + this->searchPathDirs["config"] = {"platform/config"}; + } +} + +const FileSystem::SearchPathMapDir& FileSystem::getSearchPathDirs() const { + return this->searchPathDirs; +} + +FileSystem::SearchPathMapDir& FileSystem::getSearchPathDirs() { + return this->searchPathDirs; +} + +const FileSystem::SearchPathMapVPK& FileSystem::getSearchPathVPKs() const { + return this->searchPathVPKs; +} + +FileSystem::SearchPathMapVPK& FileSystem::getSearchPathVPKs() { + return this->searchPathVPKs; +} + +std::optional> FileSystem::read(std::string_view filePath, std::string_view searchPath, bool prioritizeVPKs) const { + std::string filePathStr = string::toLower(filePath); + string::normalizeSlashes(filePathStr, true); + std::string searchPathStr = string::toLower(searchPath); + + const auto checkVPKs = [this, &filePathStr, &searchPathStr]() -> std::optional> { + if (!this->searchPathVPKs.contains(searchPathStr)) { + return std::nullopt; + } + for (const auto& packFile : this->searchPathVPKs.at(searchPathStr)) { + if (packFile->hasEntry(filePathStr)) { + return packFile->readEntry(filePathStr); + } + } + return std::nullopt; + }; + + if (prioritizeVPKs) { + if (auto data = checkVPKs()) { + return data; + } + } + + if (this->searchPathDirs.contains(searchPathStr)) { + for (const auto& basePath : this->searchPathDirs.at(searchPathStr)) { + // todo: case insensitivity on Linux + if (const auto testPath = this->rootPath + '/' + basePath + '/' + filePathStr; std::filesystem::exists(testPath)) { + return fs::readFileBuffer(testPath); + } + } + } + + if (!prioritizeVPKs) { + return checkVPKs(); + } + return std::nullopt; +} + +std::optional> FileSystem::readForMap(const PackFile* map, std::string_view filePath, std::string_view searchPath, bool prioritizeVPKs) const { + if (const auto filePathStr = std::string{filePath}; map && map->hasEntry(filePathStr)) { + return map->readEntry(filePathStr); + } + return this->read(filePath, searchPath, prioritizeVPKs); +} diff --git a/test/fspp.cpp b/test/fspp.cpp new file mode 100644 index 000000000..534da0357 --- /dev/null +++ b/test/fspp.cpp @@ -0,0 +1,20 @@ +#include + +#include + +using namespace fspp; +using namespace sourcepp; + +#if 0 + +TEST(fspp, open_portal2) { + auto fs = FileSystem::load(620, "portal2"); + ASSERT_TRUE(fs); +} + +TEST(fspp, open_p2ce) { + auto fs = FileSystem::load(440000, "p2ce"); + ASSERT_TRUE(fs); +} + +#endif