#include #include #include #include #include #include #include "spdlog/spdlog.h" namespace { struct ParsedVirtualPath { std::string scheme; std::filesystem::path relativePath; }; [[nodiscard]] std::filesystem::path ValidateRelativePath(std::string_view value) { if (value.empty()) { throw std::runtime_error("asset path is empty"); } const std::filesystem::path path{std::string(value)}; if (path.empty() || path.is_absolute() || path.has_root_path()) { throw std::runtime_error("asset path must be relative"); } for (const auto& part : path) { if (part == "..") { throw std::runtime_error("asset path traversal is not allowed"); } } const std::filesystem::path normalized = path.lexically_normal(); if (normalized.empty() || normalized == ".") { throw std::runtime_error("asset path is invalid"); } return normalized; } [[nodiscard]] ParsedVirtualPath ParseVirtualPath(std::string_view value) { const std::size_t separator = value.find("://"); if (separator == std::string_view::npos || separator == 0) { throw std::runtime_error("asset path must use scheme://relative/path syntax"); } const std::string scheme(value.substr(0, separator)); if (scheme.find_first_of("/\\:") != std::string::npos) { throw std::runtime_error("asset scheme is invalid"); } return { scheme, ValidateRelativePath(value.substr(separator + 3)) }; } [[nodiscard]] std::filesystem::path ResolvePath(const FSMount& mount, const std::filesystem::path& relativePath) { const std::filesystem::path root = mount.root.lexically_normal(); const std::filesystem::path full = (root / relativePath).lexically_normal(); const std::filesystem::path relative = full.lexically_relative(root); if (relative.empty() || relative.is_absolute() || relative == ".." || relative.generic_string().starts_with("../")) { throw std::runtime_error("asset path escapes its mount"); } std::error_code error; const std::filesystem::path canonicalRoot = std::filesystem::weakly_canonical(root, error); if (error) { throw std::runtime_error("failed to resolve asset mount: " + root.string()); } error.clear(); const std::filesystem::path canonicalFull = std::filesystem::weakly_canonical(full, error); if (error) { throw std::runtime_error("failed to resolve asset path: " + full.string()); } const std::filesystem::path canonicalRelative = canonicalFull.lexically_relative(canonicalRoot); if (canonicalRelative.empty() || canonicalRelative.is_absolute() || canonicalRelative == ".." || canonicalRelative.generic_string().starts_with("../")) { throw std::runtime_error("asset path escapes its mount"); } return full; } [[nodiscard]] const FSMount* FindMount(const std::vector& mounts, const std::string& scheme) { for (const FSMount& mount : mounts) { if (mount.scheme == scheme) { return &mount; } } return nullptr; } [[nodiscard]] std::optional RelativeToMount(const FSMount& mount, const std::filesystem::path& fullPath) { const std::filesystem::path relative = fullPath.lexically_normal().lexically_relative(mount.root); if (relative.empty() || relative.is_absolute() || relative == "." || relative == ".." || relative.generic_string().starts_with("../")) { return std::nullopt; } return relative; } struct PackLocation { const FSMount* mount; std::string key; }; // Everything under a packed mount comes from the pack, even files that also exist on disk [[nodiscard]] std::optional FindInPack(const std::vector& mounts, const std::filesystem::path& fullPath) { for (const FSMount& mount : mounts) { if (!mount.pack) { continue; } if (const auto relative = RelativeToMount(mount, fullPath)) { return PackLocation{&mount, mount.scheme + "/" + relative->generic_string()}; } } return std::nullopt; } [[nodiscard]] std::vector ReadDiskFile(const std::filesystem::path& fullPath) { std::ifstream file(fullPath, std::ios::binary); if (!file) { throw std::runtime_error("failed to open file: " + fullPath.string()); } file.seekg(0, std::ios::end); const std::streamsize size = file.tellg(); if (size < 0) { throw std::runtime_error("failed to determine file size: " + fullPath.string()); } file.seekg(0, std::ios::beg); std::vector buffer(static_cast(size)); if (size > 0 && !file.read(reinterpret_cast(buffer.data()), size)) { throw std::runtime_error("failed to read file: " + fullPath.string()); } return buffer; } [[nodiscard]] std::optional LoadManifest(const FSMount& mount) { const std::filesystem::path path = mount.root / "manifest.json"; std::vector bytes; if (mount.pack) { const PackFile::Entry* entry = mount.pack->Find(mount.scheme + "/manifest.json"); if (entry == nullptr) { return std::nullopt; } bytes = mount.pack->Read(*entry); } else { std::error_code error; if (!std::filesystem::is_regular_file(path, error)) { return std::nullopt; } bytes = ReadDiskFile(path); } try { return FS::ParseAssetManifest(bytes); } catch (const std::exception& exception) { throw std::runtime_error("invalid manifest " + path.string() + ": " + exception.what()); } } } void AssetFS::Init(std::filesystem::path exeDir) { if (initialized) { return; } const std::size_t originalMountCount = mounts.size(); try { const std::filesystem::path packPath = exeDir / PackName; std::error_code error; if (std::filesystem::is_regular_file(packPath, error)) { MountPack(packPath, exeDir / "assets"); } else { Mount("engine", exeDir / "assets/engine"); Mount("game", exeDir / "assets/game"); } initialized = true; } catch (...) { mounts.resize(originalMountCount); throw; } } void AssetFS::Reset() { mounts.clear(); initialized = false; } void AssetFS::Mount(std::string scheme, std::filesystem::path root) { AddMount(std::move(scheme), std::move(root), nullptr); } // Each scheme in the pack mounts at root/, so full paths look the same as with the unpacked folders void AssetFS::MountPack(const std::filesystem::path& packPath, const std::filesystem::path& root) { auto pack = std::make_shared(packPath); std::vector schemes; for (const PackFile::Entry& entry : pack->Entries()) { const std::size_t separator = entry.path.find('/'); if (separator == std::string::npos || separator == 0) { throw std::runtime_error("asset pack entry has no scheme: " + entry.path); } std::string scheme = entry.path.substr(0, separator); if (std::ranges::find(schemes, scheme) == schemes.end()) { schemes.push_back(std::move(scheme)); } } const std::size_t originalMountCount = mounts.size(); try { for (std::string& scheme : schemes) { std::filesystem::path schemeRoot = root / scheme; AddMount(std::move(scheme), std::move(schemeRoot), pack); } } catch (...) { mounts.resize(originalMountCount); throw; } spdlog::info("Mounted asset pack {} ({} files)", packPath.string(), pack->Entries().size()); } void AssetFS::AddMount(std::string scheme, std::filesystem::path root, std::shared_ptr pack) { if (scheme.empty() || scheme.find_first_of("/\\:") != std::string::npos) { throw std::runtime_error("asset mount scheme is invalid"); } if (FindMount(mounts, scheme) != nullptr) { throw std::runtime_error("asset mount already exists: " + scheme); } FSMount mount; mount.scheme = std::move(scheme); mount.root = std::filesystem::absolute(std::move(root)).lexically_normal(); mount.pack = std::move(pack); mount.manifest = LoadManifest(mount); spdlog::debug("Mounting assetfs scheme '{}' to root '{}'", mount.scheme, mount.root.string()); mounts.push_back(std::move(mount)); } void AssetFS::ReloadManifest(std::string_view scheme) { for (FSMount& mount : mounts) { if (mount.scheme != scheme) { continue; } mount.manifest.reset(); mount.manifest = LoadManifest(mount); return; } throw std::runtime_error("asset mount not found: " + std::string{scheme}); } std::vector AssetFS::ReadBytes(std::string_view vpath) { return ReadFile(GetFullPath(vpath)); } std::vector AssetFS::ReadFile(const std::filesystem::path& fullPath) const { if (const auto location = FindInPack(mounts, fullPath)) { const PackFile::Entry* entry = location->mount->pack->Find(location->key); if (entry == nullptr) { throw std::runtime_error("failed to open file: " + fullPath.string() + " is not in " + location->mount->pack->Path().string()); } return location->mount->pack->Read(*entry); } return ReadDiskFile(fullPath); } bool AssetFS::Exists(const std::filesystem::path& fullPath) const { if (const auto location = FindInPack(mounts, fullPath)) { return location->mount->pack->Find(location->key) != nullptr; } std::error_code error; return std::filesystem::is_regular_file(fullPath, error); } std::vector AssetFS::ListFiles(const FSMount& mount) const { std::vector files; if (mount.pack) { const std::string prefix = mount.scheme + "/"; for (const PackFile::Entry& entry : mount.pack->Entries()) { if (entry.path.starts_with(prefix)) { files.push_back((mount.root / entry.path.substr(prefix.size())).lexically_normal()); } } return files; } std::error_code error; for (const auto& entry : std::filesystem::recursive_directory_iterator(mount.root, error)) { if (entry.is_regular_file(error)) { files.push_back(entry.path()); } } return files; } bool AssetFS::IsPacked() const { return std::ranges::any_of(mounts, [](const FSMount& mount) { return mount.pack != nullptr; }); } std::filesystem::path AssetFS::GetFullPath(std::string_view vpath) const { if (!initialized) { throw std::runtime_error("AssetFS is not initialized"); } const ParsedVirtualPath parsed = ParseVirtualPath(vpath); const FSMount* mount = FindMount(mounts, parsed.scheme); if (mount == nullptr) { throw std::runtime_error("asset mount not found: " + parsed.scheme); } return ResolvePath(*mount, parsed.relativePath); } std::filesystem::path AssetFS::GetCookedPathForFile(std::string_view vpath) const { if (!initialized) { throw std::runtime_error("AssetFS is not initialized"); } const ParsedVirtualPath parsed = ParseVirtualPath(vpath); const FSMount* mount = FindMount(mounts, parsed.scheme); if (mount == nullptr) { throw std::runtime_error("asset mount not found: " + parsed.scheme); } const std::filesystem::path rawPath = ResolvePath(*mount, parsed.relativePath); if (mount->manifest) { if (const ManifestAsset* asset = mount->manifest->FindBySrc(parsed.relativePath.generic_string())) { if (asset->out) { const std::filesystem::path cookedRelative = ValidateRelativePath(*asset->out); const std::filesystem::path cookedPath = ResolvePath(*mount, cookedRelative); if (mount->pack && Exists(cookedPath)) { return cookedPath; } std::error_code error; if (std::filesystem::is_regular_file(cookedPath, error)) { error.clear(); const auto sourceTime = std::filesystem::last_write_time(rawPath, error); error.clear(); const auto cookedTime = std::filesystem::last_write_time(cookedPath, error); if (!error && sourceTime > cookedTime) { throw std::runtime_error( "cooked asset output is stale: " + cookedPath.string()); } return cookedPath; } throw std::runtime_error( "cooked asset output is missing: " + cookedPath.string()); } } } // Assets without a cooked output, such as shader includes, use the source path. return rawPath; } std::optional AssetFS::ToVirtualPath(const std::filesystem::path& fullPath) const { for (const FSMount& mount : mounts) { if (const auto relative = RelativeToMount(mount, fullPath)) { return mount.scheme + "://" + relative->generic_string(); } } return std::nullopt; } void AssetFS::SetSourceRoot(std::string_view scheme, std::filesystem::path sourceRoot) { for (FSMount& mount : mounts) { if (mount.scheme == scheme) { mount.sourceRoot = std::filesystem::absolute(std::move(sourceRoot)).lexically_normal(); return; } } throw std::runtime_error("asset mount not found: " + std::string{scheme}); } std::optional AssetFS::GetSourcePath(std::string_view vpath) const { const ParsedVirtualPath parsed = ParseVirtualPath(vpath); const FSMount* mount = FindMount(mounts, parsed.scheme); if (mount == nullptr || !mount->sourceRoot) { return std::nullopt; } return (*mount->sourceRoot / parsed.relativePath).lexically_normal(); } bool AssetFS::WriteFile(std::string_view vpath, std::string_view contents) { std::vector targets{GetFullPath(vpath)}; if (FindInPack(mounts, targets.front())) { spdlog::error("Can't write {}, it's in a read only asset pack", vpath); return false; } if (auto source = GetSourcePath(vpath)) { targets.push_back(std::move(*source)); } for (const auto& target : targets) { std::error_code error; std::filesystem::create_directories(target.parent_path(), error); std::filesystem::path temporary = target; temporary += ".tmp"; { std::ofstream file(temporary, std::ios::binary | std::ios::trunc); if (!file.write(contents.data(), static_cast(contents.size()))) { spdlog::error("Failed to write {}", temporary.string()); return false; } } std::filesystem::rename(temporary, target, error); if (error) { spdlog::error("Failed to replace {}: {}", target.string(), error.message()); std::filesystem::remove(temporary, error); return false; } } return true; }