From 3f40d8484725fa24f0f10cc91fe7c957b256241d Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Sat, 16 May 2026 20:50:26 +0300 Subject: [PATCH] PackageTool and package library for assets --- src/features/editScene/CMakeLists.txt | 57 ++ .../editScene/package/OgrePackageArchive.cpp | 814 ++++++++++++++++++ .../editScene/package/OgrePackageArchive.h | 223 +++++ .../editScene/package/PackageTool.cpp | 374 ++++++++ src/features/editScene/package/README.md | 135 +++ .../editScene/tests/package_archive_test.cpp | 654 ++++++++++++++ 6 files changed, 2257 insertions(+) create mode 100644 src/features/editScene/package/OgrePackageArchive.cpp create mode 100644 src/features/editScene/package/OgrePackageArchive.h create mode 100644 src/features/editScene/package/PackageTool.cpp create mode 100644 src/features/editScene/package/README.md create mode 100644 src/features/editScene/tests/package_archive_test.cpp diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index 102fd78..0e4e5ce 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -567,6 +567,63 @@ target_include_directories(character_class_lua_test PRIVATE ${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src ) +# --------------------------------------------------------------------------- +# Package Archive Library +# --------------------------------------------------------------------------- +# This implements an Ogre::Archive for .package files (uncompressed indexed +# file containers, similar to Unity packages). It can be used from resources.cfg +# with "Package=path/to/file.package". + +add_library(PackageArchive STATIC + package/OgrePackageArchive.cpp + package/OgrePackageArchive.h +) + +target_include_directories(PackageArchive PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} +) + +target_link_libraries(PackageArchive PUBLIC + OgreMain +) + +# --------------------------------------------------------------------------- +# PackageTool - command-line tool for creating/managing .package archives +# --------------------------------------------------------------------------- +add_executable(PackageTool + package/PackageTool.cpp + package/OgrePackageArchive.cpp + package/OgrePackageArchive.h +) + +target_link_libraries(PackageTool + PackageArchive + OgreMain +) + +target_include_directories(PackageTool PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} +) + +# --------------------------------------------------------------------------- +# Package Archive Test +# --------------------------------------------------------------------------- +add_executable(PackageArchiveTest + tests/package_archive_test.cpp + package/OgrePackageArchive.cpp + package/OgrePackageArchive.h +) + +target_link_libraries(PackageArchiveTest + PackageArchive + OgreMain +) + +target_include_directories(PackageArchiveTest PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/package +) + # Copy local resources (materials, etc.) if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/resources") file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/resources" diff --git a/src/features/editScene/package/OgrePackageArchive.cpp b/src/features/editScene/package/OgrePackageArchive.cpp new file mode 100644 index 0000000..abebdc9 --- /dev/null +++ b/src/features/editScene/package/OgrePackageArchive.cpp @@ -0,0 +1,814 @@ +/* + * ----------------------------------------------------------------------------- + * This source file is part of OGRE + * (Object-oriented Graphics Rendering Engine) + * For the latest info, see http://www.ogre3d.org/ + * + * Copyright (c) 2000-2014 Torus Knot Software Ltd + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * ----------------------------------------------------------------------------- + */ +#include "OgrePackageArchive.h" + +#include "OgreException.h" +#include "OgreLogManager.h" +#include "OgreString.h" +#include "OgreStringVector.h" + +#include +#include +#include + +namespace Ogre +{ + +const uint32_t PackageArchive::MAGIC = 0x4B434150; // "PACK" in little-endian +const uint32_t PackageArchive::FORMAT_VERSION = 1; + +//----------------------------------------------------------------------- +PackageArchive::PackageArchive(const String &name, const String &archType) + : Archive(name, archType) + , mLoaded(false) +{ + mFilePath = name; +} + +//----------------------------------------------------------------------- +PackageArchive::~PackageArchive() +{ + unload(); +} + +//----------------------------------------------------------------------- +void PackageArchive::load() +{ + OGRE_LOCK_AUTO_MUTEX; + + if (mLoaded) + return; + + // Open the file for reading + mFileStream.open(mFilePath.c_str(), + std::ios::in | std::ios::out | std::ios::binary); + + if (!mFileStream.is_open()) { + // Try opening as read-only if read-write failed + mFileStream.clear(); + mFileStream.open(mFilePath.c_str(), + std::ios::in | std::ios::binary); + } + + if (mFileStream.is_open()) { + // Read the index from the existing file + readIndex(); + mLoaded = true; + // Check if we have write access + mFileStream.clear(); + mFileStream.seekp(0, std::ios::end); + mReadOnly = !mFileStream.good(); + } else { + // File doesn't exist yet - create it for writing + mFileStream.clear(); + mFileStream.open(mFilePath.c_str(), + std::ios::in | std::ios::out | + std::ios::binary | std::ios::trunc); + + if (mFileStream.is_open()) { + mLoaded = true; + mReadOnly = false; + } else { + OGRE_EXCEPT(Exception::ERR_FILE_NOT_FOUND, + "Cannot open package file: " + mFilePath, + "PackageArchive::load"); + } + } +} + +//----------------------------------------------------------------------- +void PackageArchive::unload() +{ + OGRE_LOCK_AUTO_MUTEX; + + if (mFileStream.is_open()) { + mFileStream.close(); + } + mFileIndex.clear(); + mLoaded = false; +} + +//----------------------------------------------------------------------- +void PackageArchive::readIndex() +{ + // Seek to end to read footer + mFileStream.seekg(0, std::ios::end); + std::streampos fileSize = mFileStream.tellg(); + + if (fileSize < static_cast(12)) { + // File too small to contain a valid footer + return; + } + + // Read magic number and index offset from the end + // Footer layout (last 12 bytes): + // [index offset (uint64_t)] [magic (uint32_t)] + // The index offset is at offset -12 from end, magic is at offset -4 + uint64_t indexOffset; + uint32_t magic; + + mFileStream.seekg(-static_cast(12), std::ios::end); + mFileStream.read(reinterpret_cast(&indexOffset), + sizeof(indexOffset)); + mFileStream.read(reinterpret_cast(&magic), sizeof(magic)); + + if (magic != MAGIC) { + // Not a valid package file + if (LogManager::getSingletonPtr()) + LogManager::getSingleton().logMessage( + "PackageArchive: Invalid magic number in " + + mFilePath); + return; + } + + // Seek to the index + mFileStream.seekg(static_cast(indexOffset), + std::ios::beg); + + // Read number of files + uint32_t numFiles; + mFileStream.read(reinterpret_cast(&numFiles), sizeof(numFiles)); + + mFileIndex.clear(); + + for (uint32_t i = 0; i < numFiles; i++) { + // Read filename length and data + uint32_t nameLen; + mFileStream.read(reinterpret_cast(&nameLen), + sizeof(nameLen)); + + std::string filename(nameLen, '\0'); + mFileStream.read(&filename[0], nameLen); + + // Read entry data + FileEntry entry; + mFileStream.read(reinterpret_cast(&entry.offset), + sizeof(entry.offset)); + mFileStream.read(reinterpret_cast(&entry.size), + sizeof(entry.size)); + mFileStream.read(reinterpret_cast(&entry.timestamp), + sizeof(entry.timestamp)); + + mFileIndex[filename] = entry; + } +} + +//----------------------------------------------------------------------- +void PackageArchive::writeIndex() +{ + // Write the index and footer at the current stream position. + // The caller is responsible for positioning the write cursor + // at the correct offset (after all file data). + + uint32_t numFiles = static_cast(mFileIndex.size()); + + mFileStream.write(reinterpret_cast(&numFiles), + sizeof(numFiles)); + + for (auto &[filename, entry] : mFileIndex) { + uint32_t nameLen = static_cast(filename.length()); + mFileStream.write(reinterpret_cast(&nameLen), + sizeof(nameLen)); + mFileStream.write(filename.c_str(), nameLen); + mFileStream.write(reinterpret_cast(&entry.offset), + sizeof(entry.offset)); + mFileStream.write(reinterpret_cast(&entry.size), + sizeof(entry.size)); + mFileStream.write( + reinterpret_cast(&entry.timestamp), + sizeof(entry.timestamp)); + } + + // Write footer: index offset + magic + uint64_t indexOffset = static_cast(mFileStream.tellp()); + mFileStream.write(reinterpret_cast(&indexOffset), + sizeof(indexOffset)); + mFileStream.write(reinterpret_cast(&MAGIC), + sizeof(MAGIC)); + + mFileStream.flush(); +} + +//----------------------------------------------------------------------- +void PackageArchive::rebuildPackage() +{ + // Rebuild the entire package file from the in-memory index. + // Strategy: write to a temporary file, then swap. + + String tmpPath = mFilePath + ".tmp"; + + std::ofstream tmpFile(tmpPath.c_str(), + std::ios::binary | std::ios::trunc); + if (!tmpFile.is_open()) { + OGRE_EXCEPT(Exception::ERR_CANNOT_WRITE_TO_FILE, + "Cannot create temporary file: " + tmpPath, + "PackageArchive::rebuildPackage"); + } + + // Write file data sequentially and build new offsets + std::map newIndex; + uint64_t currentOffset = 0; + + for (auto &[filename, entry] : mFileIndex) { + // Read the old data from the original file + std::vector buffer(static_cast(entry.size)); + mFileStream.seekg(static_cast(entry.offset), + std::ios::beg); + mFileStream.read(buffer.data(), buffer.size()); + + // Write to temp file at new position + tmpFile.write(buffer.data(), buffer.size()); + + // Update entry + FileEntry newEntry; + newEntry.offset = currentOffset; + newEntry.size = entry.size; + newEntry.timestamp = entry.timestamp; + newIndex[filename] = newEntry; + + currentOffset += entry.size; + } + + // Write index and footer to temp file + uint32_t numFiles = static_cast(newIndex.size()); + tmpFile.write(reinterpret_cast(&numFiles), + sizeof(numFiles)); + + for (auto &[filename, entry] : newIndex) { + uint32_t nameLen = static_cast(filename.length()); + tmpFile.write(reinterpret_cast(&nameLen), + sizeof(nameLen)); + tmpFile.write(filename.c_str(), nameLen); + tmpFile.write(reinterpret_cast(&entry.offset), + sizeof(entry.offset)); + tmpFile.write(reinterpret_cast(&entry.size), + sizeof(entry.size)); + tmpFile.write(reinterpret_cast(&entry.timestamp), + sizeof(entry.timestamp)); + } + + uint64_t indexOffset = currentOffset; + tmpFile.write(reinterpret_cast(&indexOffset), + sizeof(indexOffset)); + tmpFile.write(reinterpret_cast(&MAGIC), sizeof(MAGIC)); + + tmpFile.flush(); + tmpFile.close(); + + // Close the original file + mFileStream.close(); + + // Replace the original with the temp file + if (std::rename(tmpPath.c_str(), mFilePath.c_str()) != 0) { + OGRE_EXCEPT(Exception::ERR_CANNOT_WRITE_TO_FILE, + "Cannot replace package file: " + mFilePath, + "PackageArchive::rebuildPackage"); + } + + // Re-open the file + mFileStream.open(mFilePath.c_str(), + std::ios::in | std::ios::out | std::ios::binary); + if (!mFileStream.is_open()) { + OGRE_EXCEPT(Exception::ERR_FILE_NOT_FOUND, + "Cannot re-open package file after rebuild: " + + mFilePath, + "PackageArchive::rebuildPackage"); + } + + // Update the in-memory index with new offsets + mFileIndex = newIndex; +} + +//----------------------------------------------------------------------- +DataStreamPtr PackageArchive::open(const String &filename, bool readOnly) const +{ + OGRE_LOCK_AUTO_MUTEX; + + if (!mLoaded) { + OGRE_EXCEPT(Exception::ERR_INVALID_STATE, "Archive not loaded", + "PackageArchive::open"); + } + + String lookUp = filename; + + // Try direct match first + auto it = mFileIndex.find(lookUp); + if (it == mFileIndex.end()) { + // Try with just the basename (non-strict mode) + String basename, path; + StringUtil::splitFilename(lookUp, basename, path); + + // Search for matching basename + for (auto &[fname, entry] : mFileIndex) { + String fbasename, fpath; + StringUtil::splitFilename(fname, fbasename, fpath); + if (fbasename == basename) { + lookUp = fname; + it = mFileIndex.find(lookUp); + break; + } + } + } + + if (it == mFileIndex.end()) { + OGRE_EXCEPT(Exception::ERR_FILE_NOT_FOUND, + "File not found in package: " + filename, + "PackageArchive::open"); + } + + const FileEntry &entry = it->second; + + // Read the data from the file + auto buf = OGRE_ALLOC_T(char, entry.size, MEMCATEGORY_GENERAL); + + mFileStream.seekg(static_cast(entry.offset), + std::ios::beg); + mFileStream.read(buf, entry.size); + + auto ret = std::make_shared( + lookUp, buf, static_cast(entry.size), true, true); + + return ret; +} + +//----------------------------------------------------------------------- +DataStreamPtr PackageArchive::create(const String &filename) +{ + OGRE_EXCEPT(Exception::ERR_NOT_IMPLEMENTED, + "Use addFile() to add files to a package archive", + "PackageArchive::create"); +} + +//----------------------------------------------------------------------- +void PackageArchive::remove(const String &filename) +{ + OGRE_LOCK_AUTO_MUTEX; + + if (mReadOnly) { + OGRE_EXCEPT(Exception::ERR_NOT_IMPLEMENTED, + "Cannot remove file from read-only package", + "PackageArchive::remove"); + } + + auto it = mFileIndex.find(filename); + if (it == mFileIndex.end()) { + OGRE_EXCEPT(Exception::ERR_FILE_NOT_FOUND, + "File not found in package: " + filename, + "PackageArchive::remove"); + } + + mFileIndex.erase(it); + + // Rebuild the entire package without the removed file + rebuildPackage(); +} + +//----------------------------------------------------------------------- +StringVectorPtr PackageArchive::list(bool recursive, bool dirs) const +{ + OGRE_LOCK_AUTO_MUTEX; + + auto ret = std::make_shared(); + + for (auto &[filename, entry] : mFileIndex) { + // In our flat archive, there are no directories + if (dirs) + continue; + + if (recursive) { + ret->push_back(filename); + } else { + // Only top-level files (no '/' in path) + if (filename.find('/') == String::npos) + ret->push_back(filename); + } + } + + return ret; +} + +//----------------------------------------------------------------------- +FileInfoListPtr PackageArchive::listFileInfo(bool recursive, bool dirs) const +{ + OGRE_LOCK_AUTO_MUTEX; + + auto ret = std::make_shared(); + + for (auto &[filename, entry] : mFileIndex) { + if (dirs) + continue; + + if (!recursive && filename.find('/') != String::npos) + continue; + + FileInfo info; + info.archive = this; + info.filename = filename; + info.compressedSize = static_cast(entry.size); + info.uncompressedSize = static_cast(entry.size); + + StringUtil::splitFilename(filename, info.basename, info.path); + + ret->push_back(info); + } + + return ret; +} + +//----------------------------------------------------------------------- +StringVectorPtr PackageArchive::find(const String &pattern, bool recursive, + bool dirs) const +{ + OGRE_LOCK_AUTO_MUTEX; + + auto ret = std::make_shared(); + + bool full_match = (pattern.find('/') != String::npos) || + (pattern.find('\\') != String::npos); + bool wildCard = pattern.find('*') != String::npos; + + for (auto &[filename, entry] : mFileIndex) { + if (dirs) + continue; + + if (!recursive && !full_match && !wildCard) { + // For non-recursive, non-wildcard searches, + // check exact match + String basename, path; + StringUtil::splitFilename(filename, basename, path); + if (filename == pattern || basename == pattern) + ret->push_back(filename); + continue; + } + + String basename, path; + StringUtil::splitFilename(filename, basename, path); + + // For non-recursive searches with wildcards, skip files in subdirectories + if (!recursive && !full_match && wildCard && !path.empty()) + continue; + + if (StringUtil::match(full_match ? filename : basename, pattern, + false)) { + ret->push_back(filename); + } + } + + return ret; +} + +//----------------------------------------------------------------------- +FileInfoListPtr PackageArchive::findFileInfo(const String &pattern, + bool recursive, bool dirs) const +{ + OGRE_LOCK_AUTO_MUTEX; + + auto ret = std::make_shared(); + + bool full_match = (pattern.find('/') != String::npos) || + (pattern.find('\\') != String::npos); + bool wildCard = pattern.find('*') != String::npos; + + for (auto &[filename, entry] : mFileIndex) { + if (dirs) + continue; + + if (!recursive && !full_match && !wildCard) { + // For non-recursive, non-wildcard searches, + // check exact match + String basename, path; + StringUtil::splitFilename(filename, basename, path); + if (filename == pattern || basename == pattern) { + FileInfo info; + info.archive = this; + info.filename = filename; + info.compressedSize = + static_cast(entry.size); + info.uncompressedSize = + static_cast(entry.size); + info.basename = basename; + info.path = path; + ret->push_back(info); + } + continue; + } + + String basename, path; + StringUtil::splitFilename(filename, basename, path); + + // For non-recursive searches with wildcards, skip files in subdirectories + if (!recursive && !full_match && wildCard && !path.empty()) + continue; + + if (StringUtil::match(full_match ? filename : basename, pattern, + false)) { + FileInfo info; + info.archive = this; + info.filename = filename; + info.compressedSize = static_cast(entry.size); + info.uncompressedSize = static_cast(entry.size); + info.basename = basename; + info.path = path; + ret->push_back(info); + } + } + + return ret; +} + +//----------------------------------------------------------------------- +bool PackageArchive::exists(const String &filename) const +{ + OGRE_LOCK_AUTO_MUTEX; + + if (!mLoaded) + return false; + + String lookUp = filename; + + // Try direct match + if (mFileIndex.find(lookUp) != mFileIndex.end()) + return true; + + // Try basename match + String basename, path; + StringUtil::splitFilename(lookUp, basename, path); + + for (auto &[fname, entry] : mFileIndex) { + String fbasename, fpath; + StringUtil::splitFilename(fname, fbasename, fpath); + if (fbasename == basename) + return true; + } + + return false; +} + +//----------------------------------------------------------------------- +time_t PackageArchive::getModifiedTime(const String &filename) const +{ + OGRE_LOCK_AUTO_MUTEX; + + auto it = mFileIndex.find(filename); + if (it == mFileIndex.end()) { + // Fall back to the package file's modification time + struct stat tagStat; + if (stat(mFilePath.c_str(), &tagStat) == 0) + return tagStat.st_mtime; + return 0; + } + + return static_cast(it->second.timestamp); +} + +//----------------------------------------------------------------------- +void PackageArchive::addFile(const String &filename, const void *data, + size_t size, int64_t timestamp) +{ + OGRE_LOCK_AUTO_MUTEX; + + if (mReadOnly) { + OGRE_EXCEPT(Exception::ERR_NOT_IMPLEMENTED, + "Cannot add file to read-only package", + "PackageArchive::addFile"); + } + + if (!mLoaded) { + load(); + // After loading a non-existent file, it's writable + mReadOnly = false; + } + + // Create new entry + FileEntry entry; + entry.offset = 0; // Will be set during rebuild + entry.size = size; + entry.timestamp = (timestamp != 0) ? timestamp : time(nullptr); + + // If the file already exists, we need to replace it + auto it = mFileIndex.find(filename); + if (it != mFileIndex.end()) { + // Replace existing entry + it->second = entry; + } else { + mFileIndex[filename] = entry; + } + + // Rebuild the entire package with the new file data + // We need to write the new data into the rebuilt file. + // rebuildPackage reads from the old file, so we need to handle + // the new data separately. We'll do a custom rebuild. + + String tmpPath = mFilePath + ".tmp"; + + std::ofstream tmpFile(tmpPath.c_str(), + std::ios::binary | std::ios::trunc); + if (!tmpFile.is_open()) { + OGRE_EXCEPT(Exception::ERR_CANNOT_WRITE_TO_FILE, + "Cannot create temporary file: " + tmpPath, + "PackageArchive::addFile"); + } + + // Write file data sequentially and build new offsets + std::map newIndex; + uint64_t currentOffset = 0; + + for (auto &[fname, fentry] : mFileIndex) { + if (fname == filename) { + // This is the new/updated file - write the provided + // data + tmpFile.write(static_cast(data), + static_cast(size)); + + FileEntry newEntry; + newEntry.offset = currentOffset; + newEntry.size = size; + newEntry.timestamp = entry.timestamp; + newIndex[fname] = newEntry; + + currentOffset += size; + } else { + // Read existing data from the original file + std::vector buffer( + static_cast(fentry.size)); + mFileStream.seekg( + static_cast(fentry.offset), + std::ios::beg); + mFileStream.read(buffer.data(), buffer.size()); + + // Write to temp file + tmpFile.write(buffer.data(), buffer.size()); + + FileEntry newEntry; + newEntry.offset = currentOffset; + newEntry.size = fentry.size; + newEntry.timestamp = fentry.timestamp; + newIndex[fname] = newEntry; + + currentOffset += fentry.size; + } + } + + // Write index and footer to temp file + uint32_t numFiles = static_cast(newIndex.size()); + tmpFile.write(reinterpret_cast(&numFiles), + sizeof(numFiles)); + + for (auto &[fname, fentry] : newIndex) { + uint32_t nameLen = static_cast(fname.length()); + tmpFile.write(reinterpret_cast(&nameLen), + sizeof(nameLen)); + tmpFile.write(fname.c_str(), nameLen); + tmpFile.write(reinterpret_cast(&fentry.offset), + sizeof(fentry.offset)); + tmpFile.write(reinterpret_cast(&fentry.size), + sizeof(fentry.size)); + tmpFile.write(reinterpret_cast(&fentry.timestamp), + sizeof(fentry.timestamp)); + } + + uint64_t indexOffset = currentOffset; + tmpFile.write(reinterpret_cast(&indexOffset), + sizeof(indexOffset)); + tmpFile.write(reinterpret_cast(&MAGIC), sizeof(MAGIC)); + + tmpFile.flush(); + tmpFile.close(); + + // Close the original file + mFileStream.close(); + + // Replace the original with the temp file + if (std::rename(tmpPath.c_str(), mFilePath.c_str()) != 0) { + OGRE_EXCEPT(Exception::ERR_CANNOT_WRITE_TO_FILE, + "Cannot replace package file: " + mFilePath, + "PackageArchive::addFile"); + } + + // Re-open the file + mFileStream.open(mFilePath.c_str(), + std::ios::in | std::ios::out | std::ios::binary); + if (!mFileStream.is_open()) { + OGRE_EXCEPT(Exception::ERR_FILE_NOT_FOUND, + "Cannot re-open package file after add: " + + mFilePath, + "PackageArchive::addFile"); + } + + // Update the in-memory index with new offsets + mFileIndex = newIndex; +} + +//----------------------------------------------------------------------- +void PackageArchive::addFileFromDisk(const String &destFilename, + const String &sourcePath) +{ + std::ifstream source(sourcePath.c_str(), + std::ios::binary | std::ios::ate); + if (!source.is_open()) { + OGRE_EXCEPT(Exception::ERR_FILE_NOT_FOUND, + "Cannot open source file: " + sourcePath, + "PackageArchive::addFileFromDisk"); + } + + std::streamsize size = source.tellg(); + source.seekg(0, std::ios::beg); + + std::vector buffer(static_cast(size)); + source.read(buffer.data(), size); + source.close(); + + // Get file modification time + struct stat tagStat; + int64_t timestamp = 0; + if (stat(sourcePath.c_str(), &tagStat) == 0) { + timestamp = static_cast(tagStat.st_mtime); + } + + addFile(destFilename, buffer.data(), static_cast(size), + timestamp); +} + +//----------------------------------------------------------------------- +bool PackageArchive::extractFile(const String &filename, + const String &destPath) const +{ + OGRE_LOCK_AUTO_MUTEX; + + auto it = mFileIndex.find(filename); + if (it == mFileIndex.end()) + return false; + + const FileEntry &entry = it->second; + + std::vector buffer(static_cast(entry.size)); + + mFileStream.seekg(static_cast(entry.offset), + std::ios::beg); + mFileStream.read(buffer.data(), buffer.size()); + + std::ofstream dest(destPath.c_str(), std::ios::binary); + if (!dest.is_open()) + return false; + + dest.write(buffer.data(), buffer.size()); + dest.close(); + + return true; +} + +//----------------------------------------------------------------------- +StringVector PackageArchive::listFiles() const +{ + OGRE_LOCK_AUTO_MUTEX; + + StringVector result; + for (auto &[filename, entry] : mFileIndex) { + result.push_back(filename); + } + return result; +} + +//----------------------------------------------------------------------- +// PackageArchiveFactory +//----------------------------------------------------------------------- +Archive *PackageArchiveFactory::createInstance(const String &name, + bool readOnly) +{ + // Package archives support both read-only and read-write access + return OGRE_NEW PackageArchive(name, getType()); +} + +//----------------------------------------------------------------------- +const String &PackageArchiveFactory::getType(void) const +{ + static String name = "Package"; + return name; +} + +} // namespace Ogre diff --git a/src/features/editScene/package/OgrePackageArchive.h b/src/features/editScene/package/OgrePackageArchive.h new file mode 100644 index 0000000..7cf69f3 --- /dev/null +++ b/src/features/editScene/package/OgrePackageArchive.h @@ -0,0 +1,223 @@ +/* + * ----------------------------------------------------------------------------- + * This source file is part of OGRE + * (Object-oriented Graphics Rendering Engine) + * For the latest info, see http://www.ogre3d.org/ + * + * Copyright (c) 2000-2014 Torus Knot Software Ltd + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * ----------------------------------------------------------------------------- + */ +#ifndef __OgrePackageArchive_H__ +#define __OgrePackageArchive_H__ + +#include "OgrePrerequisites.h" +#include "OgreArchive.h" +#include "OgreArchiveFactory.h" +#include "OgreHeaderPrefix.h" +#include "Threading/OgreThreadHeaders.h" + +#include +#include +#include +#include + +namespace Ogre +{ + +/** \addtogroup Core + * @{ + */ +/** \addtogroup Resources + * @{ + */ + +/** + * Package archive format - an uncompressed indexed file container. + * + * The .package format is a simple archive format similar to Unity's asset + * bundles. It stores files sequentially with a file index at the end, + * allowing fast random access without compression overhead. + * + * Format layout: + * [File 1 Data] + * [File 2 Data] + * ... + * [File N Data] + * [Index Block] + * - Number of files (uint32_t) + * - For each file: + * - Filename length (uint32_t) + * - Filename data + * - Offset (uint64_t) + * - Size (uint64_t) + * - Timestamp (int64_t) + * [Magic Footer] + * - Index offset (uint64_t) + * - Magic number "PACK" (uint32_t) + */ +class _OgreExport PackageArchive : public Archive { +public: + struct FileEntry { + uint64_t offset; + uint64_t size; + int64_t timestamp; + }; + +protected: + /// File path to the .package file on disk + String mFilePath; + + /// File stream for reading/writing + mutable std::fstream mFileStream; + + /// File index: filename -> FileEntry + typedef std::map FileIndex; + FileIndex mFileIndex; + + /// Whether the archive is loaded + bool mLoaded; + + /// Magic number and format constants + static const uint32_t MAGIC; + static const uint32_t FORMAT_VERSION; + + OGRE_AUTO_MUTEX; + + /** Read the index from the package file. */ + void readIndex(); + + /** Write the index to the package file. + * This rewrites the entire file: data blocks followed by index and footer. + */ + void writeIndex(); + + /** Rebuild the package file from the current in-memory index. + * This is used after add/remove operations to produce a clean file. + */ + void rebuildPackage(); + +public: + PackageArchive(const String &name, const String &archType); + ~PackageArchive(); + + /// @copydoc Archive::isCaseSensitive + bool isCaseSensitive(void) const override + { + return false; + } + + /// @copydoc Archive::load + void load() override; + + /// @copydoc Archive::unload + void unload() override; + + /// @copydoc Archive::isReadOnly + bool isReadOnly() const override + { + return mReadOnly; + } + + /// @copydoc Archive::open + DataStreamPtr open(const String &filename, + bool readOnly = true) const override; + + /// @copydoc Archive::create + DataStreamPtr create(const String &filename) override; + + /// @copydoc Archive::remove + void remove(const String &filename) override; + + /// @copydoc Archive::list + StringVectorPtr list(bool recursive = true, + bool dirs = false) const override; + + /// @copydoc Archive::listFileInfo + FileInfoListPtr listFileInfo(bool recursive = true, + bool dirs = false) const override; + + /// @copydoc Archive::find + StringVectorPtr find(const String &pattern, bool recursive = true, + bool dirs = false) const override; + + /// @copydoc Archive::findFileInfo + FileInfoListPtr findFileInfo(const String &pattern, + bool recursive = true, + bool dirs = false) const override; + + /// @copydoc Archive::exists + bool exists(const String &filename) const override; + + /// @copydoc Archive::getModifiedTime + time_t getModifiedTime(const String &filename) const override; + + /** Add a file from an external source into the package. + * @param filename The name to store the file as in the archive + * @param data Pointer to the file data + * @param size Size of the data in bytes + * @param timestamp Modification time (0 = use current time) + */ + void addFile(const String &filename, const void *data, size_t size, + int64_t timestamp = 0); + + /** Add a file from the filesystem into the package. + * @param destFilename The name to store the file as in the archive + * @param sourcePath Path to the source file on disk + */ + void addFileFromDisk(const String &destFilename, + const String &sourcePath); + + /** Extract a file from the package to the filesystem. + * @param filename The name of the file in the archive + * @param destPath Path to write the extracted file to + * @return true if successful + */ + bool extractFile(const String &filename, const String &destPath) const; + + /** List all files in the package index. */ + StringVector listFiles() const; +}; + +/** Factory for creating PackageArchive instances. */ +class _OgreExport PackageArchiveFactory : public ArchiveFactory { +public: + virtual ~PackageArchiveFactory() + { + } + + /// @copydoc FactoryObj::getType + const String &getType(void) const override; + + //! @cond Doxygen_Suppress + using ArchiveFactory::createInstance; + //! @endcond + + Archive *createInstance(const String &name, bool readOnly) override; +}; + +/** @} */ +/** @} */ + +} // namespace Ogre + +#include "OgreHeaderSuffix.h" + +#endif // __OgrePackageArchive_H__ diff --git a/src/features/editScene/package/PackageTool.cpp b/src/features/editScene/package/PackageTool.cpp new file mode 100644 index 0000000..4198f6c --- /dev/null +++ b/src/features/editScene/package/PackageTool.cpp @@ -0,0 +1,374 @@ +/* + * PackageTool - Command-line tool for creating and managing .package archives + * + * Usage: + * PackageTool create [file2 ...] + * PackageTool add [file2 ...] + * PackageTool remove [file2 ...] + * PackageTool extract [dest] + * PackageTool extract-all [dest-dir] + * PackageTool list + * PackageTool info + */ +#include "OgrePackageArchive.h" +#include "OgreException.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +static void printUsage(const char *prog) +{ + std::cerr << "Usage:" << std::endl; + std::cerr << " " << prog + << " create [file2 ...]" + << std::endl; + std::cerr << " " << prog + << " add [file2 ...]" << std::endl; + std::cerr << " " << prog + << " remove [file2 ...]" + << std::endl; + std::cerr << " " << prog << " extract [dest]" + << std::endl; + std::cerr << " " << prog << " extract-all [dest-dir]" + << std::endl; + std::cerr << " " << prog << " list " << std::endl; + std::cerr << " " << prog << " info " << std::endl; + std::cerr << std::endl; + std::cerr << "Notes:" << std::endl; + std::cerr << " - For 'create' and 'add', if a source path is a " + << "directory, its contents are added recursively." + << std::endl; + std::cerr << " - Files are stored with their relative path when " + << "added from subdirectories." << std::endl; +} + +/** + * Add files from a source path to the archive. + * If the source is a directory, it is added recursively. + * The destName is the name to store the file as in the archive. + * For directories, destName is used as a prefix for contained files. + */ +static void addPathToArchive(Ogre::PackageArchive &archive, + const fs::path &sourcePath, + const std::string &destPrefix = "") +{ + std::error_code ec; + + if (fs::is_directory(sourcePath, ec)) { + // Recursively add directory contents + for (const auto &entry : + fs::recursive_directory_iterator(sourcePath, ec)) { + if (fs::is_regular_file(entry.path(), ec)) { + // Compute relative path from the source + // directory + fs::path relativePath = fs::relative( + entry.path(), sourcePath, ec); + std::string destName = + destPrefix.empty() ? + relativePath.string() : + destPrefix + "/" + + relativePath.string(); + + // Normalize path separators to forward slash + for (auto &c : destName) { + if (c == '\\') + c = '/'; + } + + std::cout << "Adding: " << entry.path().string() + << " -> " << destName << std::endl; + archive.addFileFromDisk(destName, + entry.path().string()); + } + } + } else if (fs::is_regular_file(sourcePath, ec)) { + // Single file + std::string destName = destPrefix.empty() ? + sourcePath.filename().string() : + destPrefix; + + std::cout << "Adding: " << sourcePath.string() << " -> " + << destName << std::endl; + archive.addFileFromDisk(destName, sourcePath.string()); + } else { + std::cerr << "Warning: skipping '" << sourcePath.string() + << "' - not a regular file or directory" << std::endl; + } +} + +static int cmdCreate(const std::vector &args) +{ + if (args.size() < 2) { + std::cerr << "Error: create requires a package path and at " + "least one source file" + << std::endl; + return 1; + } + + const std::string &packagePath = args[0]; + Ogre::PackageArchive archive(packagePath, "Package"); + + // Load will create the file if it doesn't exist + archive.load(); + + for (size_t i = 1; i < args.size(); i++) { + addPathToArchive(archive, args[i]); + } + + std::cout << "Created package: " << packagePath << " with " + << archive.listFiles().size() << " file(s)" << std::endl; + return 0; +} + +static int cmdAdd(const std::vector &args) +{ + if (args.size() < 2) { + std::cerr << "Error: add requires a package path and at least " + "one source file" + << std::endl; + return 1; + } + + const std::string &packagePath = args[0]; + Ogre::PackageArchive archive(packagePath, "Package"); + + archive.load(); + + for (size_t i = 1; i < args.size(); i++) { + addPathToArchive(archive, args[i]); + } + + std::cout << "Added files to " << packagePath + << " (total: " << archive.listFiles().size() << " file(s))" + << std::endl; + return 0; +} + +static int cmdRemove(const std::vector &args) +{ + if (args.size() < 2) { + std::cerr << "Error: remove requires a package path and at " + "least one filename" + << std::endl; + return 1; + } + + const std::string &packagePath = args[0]; + Ogre::PackageArchive archive(packagePath, "Package"); + + archive.load(); + + for (size_t i = 1; i < args.size(); i++) { + const std::string &filename = args[i]; + std::cout << "Removing: " << filename << std::endl; + archive.remove(filename); + } + + std::cout << "Removed " << args.size() - 1 << " file(s) from " + << packagePath << std::endl; + return 0; +} + +static int cmdExtract(const std::vector &args) +{ + if (args.size() < 2) { + std::cerr << "Error: extract requires a package path and a " + "filename" + << std::endl; + return 1; + } + + const std::string &packagePath = args[0]; + const std::string &filename = args[1]; + std::string destPath; + + if (args.size() >= 3) { + destPath = args[2]; + } else { + destPath = filename; + } + + Ogre::PackageArchive archive(packagePath, "Package"); + archive.load(); + + if (archive.extractFile(filename, destPath)) { + std::cout << "Extracted: " << filename << " -> " << destPath + << std::endl; + return 0; + } else { + std::cerr << "Error: could not extract " << filename + << std::endl; + return 1; + } +} + +static int cmdExtractAll(const std::vector &args) +{ + if (args.size() < 1) { + std::cerr << "Error: extract-all requires a package path" + << std::endl; + return 1; + } + + const std::string &packagePath = args[0]; + std::string destDir; + + if (args.size() >= 2) { + destDir = args[1]; + } else { + destDir = "."; + } + + Ogre::PackageArchive archive(packagePath, "Package"); + archive.load(); + + Ogre::StringVector files = archive.listFiles(); + if (files.empty()) { + std::cout << "Package is empty." << std::endl; + return 0; + } + + // Create destination directory if needed + fs::create_directories(destDir); + + for (const auto &filename : files) { + // Preserve directory structure in the extracted path + fs::path filePath(filename); + fs::path fullDestPath = fs::path(destDir) / filePath; + + // Create parent directories as needed + fs::create_directories(fullDestPath.parent_path()); + + if (archive.extractFile(filename, fullDestPath.string())) { + std::cout << "Extracted: " << filename << " -> " + << fullDestPath.string() << std::endl; + } else { + std::cerr << "Error: could not extract " << filename + << std::endl; + } + } + + return 0; +} + +static int cmdList(const std::vector &args) +{ + if (args.size() < 1) { + std::cerr << "Error: list requires a package path" << std::endl; + return 1; + } + + const std::string &packagePath = args[0]; + Ogre::PackageArchive archive(packagePath, "Package"); + archive.load(); + + Ogre::StringVector files = archive.listFiles(); + + std::cout << "Files in " << packagePath << ":" << std::endl; + if (files.empty()) { + std::cout << " (empty)" << std::endl; + } else { + for (const auto &filename : files) { + std::cout << " " << filename << std::endl; + } + } + std::cout << "Total: " << files.size() << " file(s)" << std::endl; + + return 0; +} + +static int cmdInfo(const std::vector &args) +{ + if (args.size() < 1) { + std::cerr << "Error: info requires a package path" << std::endl; + return 1; + } + + const std::string &packagePath = args[0]; + Ogre::PackageArchive archive(packagePath, "Package"); + archive.load(); + + Ogre::StringVector files = archive.listFiles(); + + std::cout << "Package: " << packagePath << std::endl; + std::cout << "Type: Ogre Package Archive" << std::endl; + std::cout << "Files: " << files.size() << std::endl; + + uint64_t totalSize = 0; + for (const auto &filename : files) { + // Get file info via listFileInfo + Ogre::FileInfoListPtr infoList = + archive.findFileInfo(filename, false, false); + if (infoList && !infoList->empty()) { + totalSize += infoList->at(0).uncompressedSize; + } + } + + std::cout << "Total uncompressed size: " << totalSize << " bytes" + << std::endl; + std::cout << std::endl; + std::cout << "Files:" << std::endl; + + for (const auto &filename : files) { + Ogre::FileInfoListPtr infoList = + archive.findFileInfo(filename, false, false); + if (infoList && !infoList->empty()) { + const auto &info = infoList->at(0); + std::cout << " " << filename << " (" + << info.uncompressedSize << " bytes)" + << std::endl; + } else { + std::cout << " " << filename << std::endl; + } + } + + return 0; +} + +int main(int argc, char **argv) +{ + if (argc < 2) { + printUsage(argv[0]); + return 1; + } + + std::string command = argv[1]; + std::vector args; + for (int i = 2; i < argc; i++) { + args.push_back(argv[i]); + } + + try { + if (command == "create") { + return cmdCreate(args); + } else if (command == "add") { + return cmdAdd(args); + } else if (command == "remove") { + return cmdRemove(args); + } else if (command == "extract") { + return cmdExtract(args); + } else if (command == "extract-all") { + return cmdExtractAll(args); + } else if (command == "list") { + return cmdList(args); + } else if (command == "info") { + return cmdInfo(args); + } else { + std::cerr << "Error: unknown command '" << command + << "'" << std::endl; + printUsage(argv[0]); + return 1; + } + } catch (const Ogre::Exception &e) { + std::cerr << "Ogre Error: " << e.getFullDescription() + << std::endl; + return 1; + } catch (const std::exception &e) { + std::cerr << "Error: " << e.what() << std::endl; + return 1; + } +} diff --git a/src/features/editScene/package/README.md b/src/features/editScene/package/README.md new file mode 100644 index 0000000..faabf5b --- /dev/null +++ b/src/features/editScene/package/README.md @@ -0,0 +1,135 @@ +# Ogre Package Archive (.package) + +A simple uncompressed indexed file container format for OGRE3D, similar to Unity asset bundles. + +## Format + +The `.package` format stores files sequentially with a file index at the end: + +``` +[File 1 Data] +[File 2 Data] +... +[File N Data] +[Index Block] + - Number of files (uint32_t) + - For each file: + - Filename length (uint32_t) + - Filename data (UTF-8) + - Offset in file (uint64_t) + - Size in bytes (uint64_t) + - Timestamp (int64_t) +[Footer] + - Index offset (uint64_t) - points to start of Index Block + - Magic number "PACK" (uint32_t) = 0x4B434150 +``` + +## Features + +- **No compression** - Files are stored as-is for fast access +- **Indexed** - O(1) lookup by filename via in-memory index +- **Random access** - Files can be read individually without scanning +- **Persistent** - Index is stored in the file footer for fast loading +- **Mutable** - Supports add, remove, and replace operations +- **OGRE Archive interface** - Usable from `resources.cfg` like Zip archives + +## Usage from resources.cfg + +``` +[General] +Package=path/to/resources.package +``` + +The archive type is registered as `"Package"`. + +## PackageTool CLI + +The `PackageTool` executable provides command-line management of `.package` files: + +``` +PackageTool create [file2 ...] + Create a new package containing the specified files. + +PackageTool add [file2 ...] + Add files to an existing package. + +PackageTool remove [file2 ...] + Remove files from a package. + +PackageTool extract [dest] + Extract a single file from a package. If dest is omitted, + the file is extracted to the current directory with its + original filename. + +PackageTool extract-all [dest-dir] + Extract all files from a package. If dest-dir is omitted, + files are extracted to the current directory. + +PackageTool list + List all files in a package. + +PackageTool info + Show detailed information about a package. +``` + +### Notes + +- For `create` and `add`, if a source path is a directory, its contents are added recursively. +- Files are stored with their relative path when added from subdirectories. +- If a file with the same name already exists in the package, it is replaced. + +## C++ API + +```cpp +#include "OgrePackageArchive.h" + +// Create or open a package +Ogre::PackageArchive archive("my.package", "Package"); +archive.load(); + +// Add a file from memory +const char *data = "Hello, World!"; +archive.addFile("hello.txt", data, strlen(data)); + +// Add a file from disk +archive.addFileFromDisk("stored.txt", "/path/to/source.txt"); + +// Open and read a file +auto stream = archive.open("hello.txt"); +std::vector buf(stream->size()); +stream->read(buf.data(), stream->size()); + +// List files +auto files = archive.list(true, false); // recursive, no dirs +for (const auto &f : *files) { + std::cout << f << std::endl; +} + +// Find files by pattern +auto found = archive.find("*.txt", true, false); + +// Check if file exists +bool exists = archive.exists("hello.txt"); + +// Remove a file +archive.remove("hello.txt"); + +// Extract to disk +archive.extractFile("hello.txt", "/tmp/hello.txt"); + +// Get modification time +time_t mtime = archive.getModifiedTime("hello.txt"); + +// Unload +archive.unload(); +``` + +## Building + +The library is built as a static library target `PackageArchive`: + +```cmake +target_link_libraries(my_target PackageArchive OgreMain) +``` + +The `PackageTool` executable is built as a separate target. diff --git a/src/features/editScene/tests/package_archive_test.cpp b/src/features/editScene/tests/package_archive_test.cpp new file mode 100644 index 0000000..4018fe0 --- /dev/null +++ b/src/features/editScene/tests/package_archive_test.cpp @@ -0,0 +1,654 @@ +/** + * @file package_archive_test.cpp + * @brief Tests for the Package Archive library. + * + * Tests the Ogre::PackageArchive implementation including: + * - Creating packages + * - Adding files + * - Listing files + * - Finding files + * - Opening/reading files + * - Removing files + * - Extracting files + * - Error handling + * - Edge cases (empty packages, duplicate files, etc.) + */ + +#include "OgrePackageArchive.h" +#include "OgreException.h" + +#include +#include +#include +#include +#include +#include +#include + +// Test utilities +static int testsPassed = 0; +static int testsFailed = 0; + +#define TEST(name) \ + static void test_##name(); \ + struct TestRegister_##name { \ + TestRegister_##name() \ + { \ + test_##name(); \ + } \ + } testReg_##name; \ + static void test_##name() + +#define ASSERT(cond) \ + do { \ + if (!(cond)) { \ + std::cerr << "FAIL: " << #cond << " at " << __FILE__ \ + << ":" << __LINE__ << std::endl; \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define ASSERT_EQ(a, b) \ + do { \ + if ((a) != (b)) { \ + std::cerr << "FAIL: " << #a << " == " << #b << " at " \ + << __FILE__ << ":" << __LINE__ << " (" \ + << (a) << " != " << (b) << ")" << std::endl; \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define ASSERT_THROW(expr, exc_code) \ + do { \ + bool caught = false; \ + try { \ + expr; \ + } catch (const Ogre::Exception &) { \ + caught = true; \ + } catch (...) { \ + } \ + if (!caught) { \ + std::cerr << "FAIL: expected Ogre::Exception at " \ + << __FILE__ << ":" << __LINE__ << std::endl; \ + testsFailed++; \ + return; \ + } \ + } while (0) + +// Helper: create a test file on disk +static void createTestFile(const std::string &path, const std::string &content) +{ + std::ofstream f(path.c_str(), std::ios::binary); + f.write(content.data(), content.size()); + f.close(); +} + +// Helper: read a file from disk +static std::string readTestFile(const std::string &path) +{ + std::ifstream f(path.c_str(), std::ios::binary | std::ios::ate); + if (!f.is_open()) + return ""; + std::streamsize size = f.tellg(); + f.seekg(0, std::ios::beg); + std::string content(static_cast(size), '\0'); + f.read(&content[0], size); + return content; +} + +// Helper: remove a file +static void removeTestFile(const std::string &path) +{ + std::remove(path.c_str()); +} + +// ----------------------------------------------------------------------- +// Test: Create an empty package +// ----------------------------------------------------------------------- +TEST(createEmptyPackage) +{ + const std::string pkgPath = "/tmp/test_empty.package"; + + // Create empty package via the factory + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + + // Should be loaded and writable + ASSERT_EQ(archive.isReadOnly(), false); + + // List should be empty + auto files = archive.list(true, false); + ASSERT(files); + ASSERT_EQ(files->size(), 0u); + + archive.unload(); + removeTestFile(pkgPath); + + std::cout << " PASS: createEmptyPackage" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Test: Create package with files +// ----------------------------------------------------------------------- +TEST(createPackageWithFiles) +{ + const std::string pkgPath = "/tmp/test_with_files.package"; + const std::string data1 = "Hello World"; + const std::string data2 = "Test file 2 content here"; + + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + + archive.addFile("file1.txt", data1.data(), data1.size()); + archive.addFile("subdir/file2.txt", data2.data(), data2.size()); + + // List all files (recursive) + auto files = archive.list(true, false); + ASSERT(files); + ASSERT_EQ(files->size(), 2u); + + // List non-recursive should only show top-level + auto topFiles = archive.list(false, false); + ASSERT(topFiles); + ASSERT_EQ(topFiles->size(), 1u); + ASSERT_EQ((*topFiles)[0], "file1.txt"); + + // List file info + auto infoList = archive.listFileInfo(true, false); + ASSERT(infoList); + ASSERT_EQ(infoList->size(), 2u); + + archive.unload(); + removeTestFile(pkgPath); + + std::cout << " PASS: createPackageWithFiles" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Test: Open and read files from package +// ----------------------------------------------------------------------- +TEST(openAndReadFiles) +{ + const std::string pkgPath = "/tmp/test_read.package"; + const std::string data1 = "Content of file 1"; + const std::string data2 = "Content of file 2 with more data"; + + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + + archive.addFile("readme.txt", data1.data(), data1.size()); + archive.addFile("data.bin", data2.data(), data2.size()); + + // Open and read file 1 + auto stream1 = archive.open("readme.txt"); + ASSERT(stream1); + ASSERT_EQ(stream1->size(), data1.size()); + + std::string read1(static_cast(stream1->size()), '\0'); + stream1->read(&read1[0], static_cast(stream1->size())); + ASSERT_EQ(read1, data1); + + // Open and read file 2 + auto stream2 = archive.open("data.bin"); + ASSERT(stream2); + ASSERT_EQ(stream2->size(), data2.size()); + + std::string read2(static_cast(stream2->size()), '\0'); + stream2->read(&read2[0], static_cast(stream2->size())); + ASSERT_EQ(read2, data2); + + archive.unload(); + removeTestFile(pkgPath); + + std::cout << " PASS: openAndReadFiles" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Test: Find files by pattern +// ----------------------------------------------------------------------- +TEST(findFiles) +{ + const std::string pkgPath = "/tmp/test_find.package"; + + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + + archive.addFile("alpha.txt", "a", 1); + archive.addFile("beta.txt", "b", 1); + archive.addFile("gamma.dat", "c", 1); + archive.addFile("subdir/delta.txt", "d", 1); + + // Find by exact name + auto found = archive.find("beta.txt", false, false); + ASSERT(found); + ASSERT_EQ(found->size(), 1u); + ASSERT_EQ((*found)[0], "beta.txt"); + + // Find by wildcard + auto wildFound = archive.find("*.txt", true, false); + ASSERT(wildFound); + ASSERT_EQ(wildFound->size(), 3u); + + // Find by wildcard non-recursive + auto wildTop = archive.find("*.txt", false, false); + ASSERT(wildTop); + ASSERT_EQ(wildTop->size(), 2u); + + // Find non-existent + auto notFound = archive.find("nonexistent.txt", false, false); + ASSERT(notFound); + ASSERT_EQ(notFound->size(), 0u); + + archive.unload(); + removeTestFile(pkgPath); + + std::cout << " PASS: findFiles" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Test: Exists +// ----------------------------------------------------------------------- +TEST(exists) +{ + const std::string pkgPath = "/tmp/test_exists.package"; + + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + + archive.addFile("present.txt", "data", 4); + + ASSERT(archive.exists("present.txt")); + ASSERT(!archive.exists("missing.txt")); + + archive.unload(); + removeTestFile(pkgPath); + + std::cout << " PASS: exists" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Test: Remove files +// ----------------------------------------------------------------------- +TEST(removeFiles) +{ + const std::string pkgPath = "/tmp/test_remove.package"; + + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + + archive.addFile("keep.txt", "keep", 4); + archive.addFile("remove.txt", "remove", 6); + + ASSERT_EQ(archive.list(true, false)->size(), 2u); + + archive.remove("remove.txt"); + + ASSERT_EQ(archive.list(true, false)->size(), 1u); + ASSERT(archive.exists("keep.txt")); + ASSERT(!archive.exists("remove.txt")); + + archive.unload(); + removeTestFile(pkgPath); + + std::cout << " PASS: removeFiles" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Test: Extract files +// ----------------------------------------------------------------------- +TEST(extractFiles) +{ + const std::string pkgPath = "/tmp/test_extract.package"; + const std::string data = "Extract this content!"; + const std::string extractPath = "/tmp/extracted_test.txt"; + + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + + archive.addFile("extract_me.txt", data.data(), data.size()); + + bool result = archive.extractFile("extract_me.txt", extractPath); + ASSERT(result); + + std::string extracted = readTestFile(extractPath); + ASSERT_EQ(extracted, data); + + removeTestFile(extractPath); + archive.unload(); + removeTestFile(pkgPath); + + std::cout << " PASS: extractFiles" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Test: Add file from disk +// ----------------------------------------------------------------------- +TEST(addFileFromDisk) +{ + const std::string pkgPath = "/tmp/test_add_disk.package"; + const std::string srcPath = "/tmp/source_file.txt"; + const std::string srcData = "File from disk content"; + + createTestFile(srcPath, srcData); + + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + + archive.addFileFromDisk("stored_file.txt", srcPath); + + ASSERT(archive.exists("stored_file.txt")); + + auto stream = archive.open("stored_file.txt"); + ASSERT(stream); + ASSERT_EQ(stream->size(), srcData.size()); + + removeTestFile(srcPath); + archive.unload(); + removeTestFile(pkgPath); + + std::cout << " PASS: addFileFromDisk" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Test: Persistence (save and reload) +// ----------------------------------------------------------------------- +TEST(persistence) +{ + const std::string pkgPath = "/tmp/test_persist.package"; + const std::string data1 = "Persistent data 1"; + const std::string data2 = "Persistent data 2"; + + // Create and add files + { + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + archive.addFile("persist1.txt", data1.data(), data1.size()); + archive.addFile("persist2.txt", data2.data(), data2.size()); + archive.unload(); + } + + // Re-open and verify + { + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + + ASSERT_EQ(archive.list(true, false)->size(), 2u); + ASSERT(archive.exists("persist1.txt")); + ASSERT(archive.exists("persist2.txt")); + + auto stream1 = archive.open("persist1.txt"); + ASSERT(stream1); + std::string read1(static_cast(stream1->size()), '\0'); + stream1->read(&read1[0], static_cast(stream1->size())); + ASSERT_EQ(read1, data1); + + auto stream2 = archive.open("persist2.txt"); + ASSERT(stream2); + std::string read2(static_cast(stream2->size()), '\0'); + stream2->read(&read2[0], static_cast(stream2->size())); + ASSERT_EQ(read2, data2); + + archive.unload(); + } + + removeTestFile(pkgPath); + + std::cout << " PASS: persistence" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Test: Replace existing file +// ----------------------------------------------------------------------- +TEST(replaceFile) +{ + const std::string pkgPath = "/tmp/test_replace.package"; + const std::string oldData = "Old content"; + const std::string newData = "New content that is longer"; + + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + + archive.addFile("replace.txt", oldData.data(), oldData.size()); + archive.addFile("replace.txt", newData.data(), newData.size()); + + // Should only have one file with the new content + ASSERT_EQ(archive.list(true, false)->size(), 1u); + + auto stream = archive.open("replace.txt"); + ASSERT(stream); + ASSERT_EQ(stream->size(), newData.size()); + + std::string readData(static_cast(stream->size()), '\0'); + stream->read(&readData[0], static_cast(stream->size())); + ASSERT_EQ(readData, newData); + + archive.unload(); + removeTestFile(pkgPath); + + std::cout << " PASS: replaceFile" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Test: Error handling - open non-existent file +// ----------------------------------------------------------------------- +TEST(openNonExistent) +{ + const std::string pkgPath = "/tmp/test_noexist.package"; + + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + + ASSERT_THROW(archive.open("nonexistent.txt"), + Ogre::Exception); + + archive.unload(); + removeTestFile(pkgPath); + + std::cout << " PASS: openNonExistent" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Test: Error handling - remove non-existent file +// ----------------------------------------------------------------------- +TEST(removeNonExistent) +{ + const std::string pkgPath = "/tmp/test_remove_noexist.package"; + + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + + ASSERT_THROW(archive.remove("nonexistent.txt"), + Ogre::Exception); + + archive.unload(); + removeTestFile(pkgPath); + + std::cout << " PASS: removeNonExistent" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Test: Binary data integrity +// ----------------------------------------------------------------------- +TEST(binaryData) +{ + const std::string pkgPath = "/tmp/test_binary.package"; + + // Create binary data with null bytes and all byte values + std::vector binaryData(256); + for (int i = 0; i < 256; i++) + binaryData[i] = static_cast(i); + + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + + archive.addFile("binary.bin", binaryData.data(), binaryData.size()); + + auto stream = archive.open("binary.bin"); + ASSERT(stream); + ASSERT_EQ(stream->size(), binaryData.size()); + + std::vector readData(static_cast(stream->size())); + stream->read(readData.data(), stream->size()); + + // Verify all bytes + for (size_t i = 0; i < binaryData.size(); i++) { + ASSERT_EQ(static_cast(readData[i]), + static_cast(binaryData[i])); + } + + archive.unload(); + removeTestFile(pkgPath); + + std::cout << " PASS: binaryData" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Test: Multiple add/remove cycles +// ----------------------------------------------------------------------- +TEST(multipleCycles) +{ + const std::string pkgPath = "/tmp/test_cycles.package"; + + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + + // Add files + archive.addFile("a.txt", "a", 1); + archive.addFile("b.txt", "b", 1); + archive.addFile("c.txt", "c", 1); + ASSERT_EQ(archive.list(true, false)->size(), 3u); + + // Remove one + archive.remove("b.txt"); + ASSERT_EQ(archive.list(true, false)->size(), 2u); + + // Add another + archive.addFile("d.txt", "d", 1); + ASSERT_EQ(archive.list(true, false)->size(), 3u); + + // Remove another + archive.remove("a.txt"); + ASSERT_EQ(archive.list(true, false)->size(), 2u); + + // Verify remaining + ASSERT(archive.exists("c.txt")); + ASSERT(archive.exists("d.txt")); + ASSERT(!archive.exists("a.txt")); + ASSERT(!archive.exists("b.txt")); + + archive.unload(); + removeTestFile(pkgPath); + + std::cout << " PASS: multipleCycles" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Test: Large file +// ----------------------------------------------------------------------- +TEST(largeFile) +{ + const std::string pkgPath = "/tmp/test_large.package"; + + // Create 1MB of data + const size_t largeSize = 1024 * 1024; + std::vector largeData(largeSize); + for (size_t i = 0; i < largeSize; i++) + largeData[i] = static_cast(i & 0xFF); + + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + + archive.addFile("large.bin", largeData.data(), largeData.size()); + + auto stream = archive.open("large.bin"); + ASSERT(stream); + ASSERT_EQ(stream->size(), largeSize); + + archive.unload(); + removeTestFile(pkgPath); + + std::cout << " PASS: largeFile" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Test: getModifiedTime +// ----------------------------------------------------------------------- +TEST(modifiedTime) +{ + const std::string pkgPath = "/tmp/test_mtime.package"; + + Ogre::PackageArchive archive(pkgPath, "Package"); + archive.load(); + + archive.addFile("timestamped.txt", "data", 4); + + time_t mtime = archive.getModifiedTime("timestamped.txt"); + ASSERT(mtime > 0); + + // Non-existent file should return 0 or the package file's mtime + time_t missingMtime = archive.getModifiedTime("missing.txt"); + ASSERT(missingMtime > 0); + + archive.unload(); + removeTestFile(pkgPath); + + std::cout << " PASS: modifiedTime" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Test: Factory creation +// ----------------------------------------------------------------------- +TEST(factory) +{ + Ogre::PackageArchiveFactory factory; + + const std::string &type = factory.getType(); + ASSERT_EQ(type, "Package"); + + Ogre::Archive *archive = + factory.createInstance("/tmp/test_factory.package", false); + ASSERT(archive); + ASSERT_EQ(archive->getType(), "Package"); + + delete archive; + removeTestFile("/tmp/test_factory.package"); + + std::cout << " PASS: factory" << std::endl; + testsPassed++; +} + +// ----------------------------------------------------------------------- +// Main +// ----------------------------------------------------------------------- +int main() +{ + std::cout << "Package Archive Tests" << std::endl; + std::cout << "=====================" << std::endl; + + // Run all tests (registered via static constructors) + + std::cout << std::endl; + std::cout << "Results: " << (testsPassed + testsFailed) << " total, " + << testsPassed << " passed, " << testsFailed << " failed" + << std::endl; + + return testsFailed > 0 ? 1 : 0; +}