diff --git a/CMakeLists.txt b/CMakeLists.txt index 91826ebf..c2dee15c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,6 +11,7 @@ option(ZK_ENABLE_ASAN "ZenKit: Enable sanitizers in debug builds." ON) option(ZK_ENABLE_DEPRECATION "ZenKit: Enable deprecation warnings." ON) option(ZK_ENABLE_INSTALL "ZenKit: Enable CMake install target creation." ON) option(ZK_ENABLE_MMAP "ZenKit: Build ZenKit with memory-mapping support." ON) +option(ZK_ENABLE_ZIPPED_VDF "ZenKit: Build with support for reading and writing compressed VDF files (Union ZippedStream format)." OFF) option(ZK_ENABLE_FUTURE "ZenKit: Enable breaking changes to be release in a future version" OFF) add_subdirectory(vendor) @@ -137,7 +138,14 @@ target_include_directories(zenkit PUBLIC include) target_compile_definitions(zenkit PRIVATE _ZKEXPORT=1 ZKNO_REM=1) target_compile_options(zenkit PRIVATE ${_ZK_COMPILE_FLAGS}) target_link_options(zenkit PUBLIC ${_ZK_LINK_FLAGS}) -target_link_libraries(zenkit PUBLIC squish) +if (ZK_ENABLE_ZIPPED_VDF) + message(STATUS "ZenKit: Building with zipped VDF support") + target_compile_definitions(zenkit PUBLIC _ZK_WITH_ZIPPED_VDF=1) + target_link_libraries(zenkit PUBLIC miniz squish) +else () + message(STATUS "ZenKit: Building WITHOUT zipped VDF support") + target_link_libraries(zenkit PUBLIC squish) +endif () set_target_properties(zenkit PROPERTIES DEBUG_POSTFIX "d" VERSION ${PROJECT_VERSION}) if (ZK_ENABLE_INSTALL) diff --git a/include/zenkit/Stream.hh b/include/zenkit/Stream.hh index 510cd5fa..65130b4d 100644 --- a/include/zenkit/Stream.hh +++ b/include/zenkit/Stream.hh @@ -99,6 +99,9 @@ namespace zenkit { virtual void seek(ssize_t off, Whence whence) noexcept = 0; [[nodiscard]] virtual size_t tell() const noexcept = 0; [[nodiscard]] virtual bool eof() const noexcept = 0; +#ifdef _ZK_WITH_ZIPPED_VDF + [[nodiscard]] static std::unique_ptr from_zipped(std::unique_ptr stream); +#endif [[nodiscard]] static std::unique_ptr from(FILE* stream); [[nodiscard]] static std::unique_ptr from(std::istream* stream); diff --git a/include/zenkit/Vfs.hh b/include/zenkit/Vfs.hh index 9a9244a0..2d326eb8 100644 --- a/include/zenkit/Vfs.hh +++ b/include/zenkit/Vfs.hh @@ -39,8 +39,10 @@ namespace zenkit { struct VfsFileDescriptor { std::byte const* memory; std::size_t size; + std::size_t raw_size; ///< The catalog entry size (uncompressed size for zipped files). + bool zipped; ///< Whether the file data is stored as a Union ZippedStream. - VfsFileDescriptor(std::byte const* mem, size_t len, bool del); + VfsFileDescriptor(std::byte const* mem, size_t len, bool del, bool zipped = false, size_t raw_size = 0); VfsFileDescriptor(VfsFileDescriptor const& cpy); ~VfsFileDescriptor() noexcept; @@ -186,10 +188,27 @@ namespace zenkit { /// \return The node with the given name or `nullptr` if no node with the given name was found. [[nodiscard]] ZKAPI VfsNode* find(std::string_view name) noexcept; + /// \brief Save the Vfs contents as an uncompressed VDF archive. + /// \param w The output stream to write the VDF archive to. + /// \param version The game version determining the VDF signature format. + /// \param unix_t The timestamp to store in the VDF header. If 0, the current time is used. ZKAPI void save(Write* w, GameVersion version, time_t unix_t = 0) const; + /// \brief Save the Vfs contents as a compressed VDF archive (Union ZippedStream format). + /// + /// File data is written as ZippedStream blocks (volume flag 0xA0). + /// Audio files (.WAV, .OGG) are always stored uncompressed, following Union's convention. + /// + /// \param w The output stream to write the VDF archive to. + /// \param version The game version determining the VDF signature format. + /// \param unix_t The timestamp to store in the VDF header. If 0, the current time is used. +#ifdef _ZK_WITH_ZIPPED_VDF + ZKAPI void save_compressed(Write* w, GameVersion version, time_t unix_t = 0) const; +#endif + private: ZKINT void mount_disk(std::byte const* buf, std::size_t size, VfsOverwriteBehavior overwrite); + ZKINT void save_internal(Write* w, GameVersion version, time_t unix_t, bool compressed) const; VfsNode _m_root; std::vector> _m_data; diff --git a/src/Stream.cc b/src/Stream.cc index 4bd60135..efedbc22 100644 --- a/src/Stream.cc +++ b/src/Stream.cc @@ -8,6 +8,10 @@ #include #include #include +#ifdef _ZK_WITH_ZIPPED_VDF +#define MINIZ_NO_ZLIB_COMPATIBLE_NAMES +#include +#endif namespace zenkit { template @@ -477,4 +481,190 @@ namespace zenkit { std::unique_ptr Write::to(std::vector* vector) { return std::make_unique(vector); } + // ----------------------------------------------------------------------------------------------------------------- + +#ifdef _ZK_WITH_ZIPPED_VDF + namespace detail { + /// Reads file data stored as a Union ZippedStream. + /// + /// In a zipped VDF (VolumeHeader.Flags == 0xA0), the catalog/file table remains + /// uncompressed, but each file's data at its catalog offset is stored as a + /// ZippedStream — a block-compressed format used by Union's VDFS library. + /// + /// ZippedStream layout (at the file's offset): + /// Stream header: Length (4) | BlockSize (4) | BlocksCount (4) + /// Per block (interleaved): + /// Block header: LengthSource (4) | LengthCompressed (4) | BlockSize (4) + /// Block data: [LengthCompressed bytes of zlib-compressed data] + /// + /// Each block is independently zlib-compressed and can be decompressed on demand. + class ReadZipped final : public Read { + public: + explicit ReadZipped(std::unique_ptr r) : _m_stream(std::move(r)) { + _m_stream->seek(0, Whence::BEG); + } + + ~ReadZipped() override = default; + + size_t read(void* buf, size_t len) noexcept override { + // Implementation of reading logic using blocks + uint8_t* out = static_cast(buf); + size_t total_read = 0; + + while (len > 0) { + if (_m_current_block >= _m_header.blocks_count) break; + + // Ensure current block is cached/decompressed + if (!_m_cache_valid || _m_cache_idx != _m_current_block) { + if (!cache_block(_m_current_block)) { + ZKLOGE("ReadZipped", "Failed to decompress block %u", _m_current_block); + return total_read; + } + } + + size_t offset_in_block = _m_position - (_m_current_block * _m_header.block_size); + size_t available = _m_blocks[_m_current_block].len_src - offset_in_block; + size_t to_copy = std::min(len, available); + + memcpy(out, _m_cache.data() + offset_in_block, to_copy); + + out += to_copy; + len -= to_copy; + total_read += to_copy; + _m_position += to_copy; + + if (to_copy == available) { + _m_current_block++; + } + } + return total_read; + } + + void seek(ssize_t off, Whence whence) noexcept override { + // Update _m_position and _m_current_block + ssize_t new_pos = 0; + if (whence == Whence::BEG) + new_pos = off; + else if (whence == Whence::CUR) + new_pos = static_cast(_m_position) + off; + else if (whence == Whence::END) + new_pos = static_cast(_m_header.length_uncompressed) + off; + + // Clamp to [0, length_uncompressed] + if (new_pos < 0) new_pos = 0; + if (static_cast(new_pos) > _m_header.length_uncompressed) + new_pos = static_cast(_m_header.length_uncompressed); + + _m_position = static_cast(new_pos); + if (_m_header.block_size > 0) _m_current_block = _m_position / _m_header.block_size; + } + + [[nodiscard]] size_t tell() const noexcept override { + return _m_position; + } + + [[nodiscard]] bool eof() const noexcept override { + return _m_position >= _m_header.length_uncompressed; + } + + /// Reads the ZippedStream header and block table from the underlying stream. + /// Block headers and data are interleaved: each block header (12 bytes) is + /// immediately followed by its compressed payload (len_cmp bytes). + bool init() { + try { + _m_header.length_uncompressed = _m_stream->read_uint(); + _m_header.block_size = _m_stream->read_uint(); + _m_header.blocks_count = _m_stream->read_uint(); + + // Validate: a valid ZippedStream must have at least one block + // and a non-zero block size. + if (_m_header.blocks_count == 0 || _m_header.block_size == 0 || + _m_header.length_uncompressed == 0) { + ZKLOGE("ReadZipped", "Invalid ZippedStream header: length=%u, block_size=%u, blocks_count=%u", + _m_header.length_uncompressed, _m_header.block_size, _m_header.blocks_count); + return false; + } + + // Validate: blocks_count must be consistent with the header. + // In a valid ZippedStream, blocks_count == ceil(length / block_size). + // Reject if it doesn't match — the data is not a ZippedStream. + uint32_t expected_blocks = + (_m_header.length_uncompressed + _m_header.block_size - 1) / _m_header.block_size; + if (_m_header.blocks_count != expected_blocks) { + ZKLOGE("ReadZipped", "Block count mismatch: expected %u, got %u", expected_blocks, + _m_header.blocks_count); + return false; + } + + _m_blocks.resize(_m_header.blocks_count); + + // Scan through the interleaved block headers to record each + // block's compressed data offset, then skip past its data. + for (auto& blk : _m_blocks) { + blk.len_src = _m_stream->read_uint(); + blk.len_cmp = _m_stream->read_uint(); + blk.size_blk = _m_stream->read_uint(); + blk.offset = _m_stream->tell(); // compressed data starts here + _m_stream->seek(static_cast(blk.len_cmp), Whence::CUR); + } + + return true; + } catch (...) { + return false; + } + } + + private: + std::unique_ptr _m_stream; + + struct StreamHeader { + uint32_t length_uncompressed; + uint32_t block_size; + uint32_t blocks_count; + } _m_header {}; + + struct BlockInfo { + uint32_t len_src; + uint32_t len_cmp; + uint32_t size_blk; + size_t offset; + }; + std::vector _m_blocks; + + size_t _m_position = 0; + uint32_t _m_current_block = 0; + + // Cache + std::vector _m_cache; + uint32_t _m_cache_idx = 0xFFFFFFFF; + bool _m_cache_valid = false; + + bool cache_block(uint32_t idx) { + if (idx >= _m_blocks.size()) return false; + + BlockInfo& blk = _m_blocks[idx]; + _m_stream->seek(blk.offset, Whence::BEG); + std::vector cmp_data(blk.len_cmp); + if (_m_stream->read(cmp_data.data(), blk.len_cmp) != blk.len_cmp) return false; + + _m_cache.resize(blk.len_src); + + mz_ulong out_len = static_cast(blk.len_src); + int res = mz_uncompress(_m_cache.data(), &out_len, cmp_data.data(), static_cast(blk.len_cmp)); + + if (res != MZ_OK) return false; + + _m_cache_idx = idx; + _m_cache_valid = true; + return true; + } + }; + } // namespace detail + + std::unique_ptr Read::from_zipped(std::unique_ptr stream) { + auto reader = std::make_unique(std::move(stream)); + if (!reader->init()) return nullptr; + return reader; + } +#endif // _ZK_WITH_ZIPPED_VDF } // namespace zenkit diff --git a/src/Vfs.cc b/src/Vfs.cc index 216480c8..690a7026 100644 --- a/src/Vfs.cc +++ b/src/Vfs.cc @@ -7,7 +7,13 @@ #include "zenkit/Error.hh" #include "zenkit/Stream.hh" +#ifdef _ZK_WITH_ZIPPED_VDF +#define MINIZ_NO_ZLIB_COMPATIBLE_NAMES +#include +#endif + #include +#include #include #include #include @@ -18,6 +24,13 @@ namespace zenkit { static constexpr std::string_view VFS_DISK_SIGNATURE_G2 = "PSVDSC_V2.00\n\r\n\r"; static constexpr std::string_view VFS_DISK_SIGNATURE_VDFSTOOL = "PSVDSC_V2.00\x1A\x1A\x1A\x1A"; + /// Volume header flags (VolumeHeader.Flags in Union VDFS). + /// These indicate whether file data within the VDF is stored compressed. + /// The catalog/file table is always uncompressed regardless of this flag; + /// only the actual file contents at each entry's offset are affected. + static constexpr uint32_t VFS_VOLUME_FLAG_NORMAL = 0x50; ///< 80 — file data is stored uncompressed + static constexpr uint32_t VFS_VOLUME_FLAG_ZIPPED = 0xA0; ///< 160 — file data is stored as ZippedStream blocks + VfsBrokenDiskError::VfsBrokenDiskError(std::string const& signature) : Error("VFS disk signature not recognized: \"" + signature + "\"") {} @@ -25,11 +38,11 @@ namespace zenkit { VfsNotFoundError::VfsNotFoundError(std::string const& name) : Error("not found: \"" + name + "\"") {} - VfsFileDescriptor::VfsFileDescriptor(std::byte const* mem, size_t len, bool del) - : memory(mem), size(len), refcnt(del ? new size_t(1) : nullptr) {} + VfsFileDescriptor::VfsFileDescriptor(std::byte const* mem, size_t len, bool del, bool zip, size_t raw) + : memory(mem), size(len), raw_size(raw == 0 ? len : raw), zipped(zip), refcnt(del ? new size_t(1) : nullptr) {} VfsFileDescriptor::VfsFileDescriptor(VfsFileDescriptor const& cpy) - : memory(cpy.memory), size(cpy.size), refcnt(cpy.refcnt) { + : memory(cpy.memory), size(cpy.size), raw_size(cpy.raw_size), zipped(cpy.zipped), refcnt(cpy.refcnt) { if (this->refcnt == nullptr) return; *this->refcnt += 1; } @@ -112,7 +125,22 @@ namespace zenkit { std::unique_ptr VfsNode::open_read() const { auto fd = std::get(_m_data); - return Read::from(fd.memory, fd.size); + auto reader = Read::from(fd.memory, fd.size); + +#ifdef _ZK_WITH_ZIPPED_VDF + if (fd.zipped) { + auto zipped = Read::from_zipped(std::move(reader)); + if (zipped != nullptr) { + return zipped; + } + + // ZippedStream header validation failed (e.g. raw Ogg Vorbis audio). + // Fall back to raw data using the catalog entry size. + return Read::from(fd.memory, fd.raw_size); + } +#endif + + return reader; } VfsNode VfsNode::directory(std::string_view name) { @@ -220,7 +248,67 @@ namespace zenkit { return dos; } +#ifdef _ZK_WITH_ZIPPED_VDF + /// Default ZippedStream block size (8 KB), matching Union's default. + static constexpr uint32_t VFS_ZIPPED_BLOCK_SIZE = 8192; + + /// Returns true if the file name has a .WAV or .OGG extension (case-insensitive). + /// These files are stored uncompressed in zipped VDFs, following Union's convention. + static bool vfs_is_wave_file(std::string_view name) { + if (name.size() < 4) return false; + auto ext = name.substr(name.size() - 4); + return iequals(ext, ".wav") || iequals(ext, ".ogg"); + } + + /// Writes file data as a ZippedStream to the output. + /// + /// ZippedStream layout: + /// Stream header: Length (4) | BlockSize (4) | BlocksCount (4) + /// Per block (interleaved): + /// Block header: LengthSource (4) | LengthCompressed (4) | BlockSize (4) + /// Block data: [LengthCompressed bytes of zlib-compressed data] + static void vfs_write_zipped(Write* w, std::byte const* data, size_t size) { + uint32_t block_size = VFS_ZIPPED_BLOCK_SIZE; + uint32_t blocks_count = static_cast((size + block_size - 1) / block_size); + + // Write stream header + w->write_uint(static_cast(size)); // Length (uncompressed) + w->write_uint(block_size); // BlockSize + w->write_uint(blocks_count); // BlocksCount + + // Write each block: header + compressed data (interleaved) + std::vector cmp_buf; + for (uint32_t i = 0; i < blocks_count; i++) { + size_t src_offset = static_cast(i) * block_size; + uint32_t src_len = static_cast(std::min(block_size, size - src_offset)); + + mz_ulong cmp_len = mz_compressBound(src_len); + cmp_buf.resize(cmp_len); + + int res = + mz_compress(cmp_buf.data(), &cmp_len, reinterpret_cast(data + src_offset), src_len); + assert(res == MZ_OK && "mz_compress failed with a correctly sized buffer — this is a bug"); + + // Block header + w->write_uint(src_len); // LengthSource + w->write_uint(static_cast(cmp_len)); // LengthCompressed + w->write_uint(block_size); // BlockSize + + // Block data + w->write(cmp_buf.data(), cmp_len); + } + } + + void Vfs::save_compressed(Write* w, GameVersion version, time_t unix_t) const { + save_internal(w, version, unix_t, true); + } +#endif // _ZK_WITH_ZIPPED_VDF + void Vfs::save(Write* w, GameVersion version, time_t unix_t) const { + save_internal(w, version, unix_t, false); + } + + void Vfs::save_internal(Write* w, GameVersion version, time_t unix_t, [[maybe_unused]] bool compressed) const { std::vector catalog; auto write_catalog = Write::to(&catalog); @@ -250,13 +338,22 @@ namespace zenkit { auto sz = rd->tell(); rd->seek(0, Whence::BEG); - write_catalog->write_uint(w->tell()); // Offset - write_catalog->write_uint(sz); // Size - write_catalog->write_uint(i + 1 == node->children().size() ? 0x40000000 : 0); // Type - cache.resize(sz); rd->read(cache.data(), sz); + + write_catalog->write_uint(w->tell()); // Offset + write_catalog->write_uint(sz); // Size (always uncompressed) + write_catalog->write_uint(i + 1 == node->children().size() ? 0x40000000 : 0); // Type + +#ifdef _ZK_WITH_ZIPPED_VDF + if (compressed && !vfs_is_wave_file(child.name())) { + vfs_write_zipped(w, cache.data(), sz); + } else { + w->write(cache.data(), sz); + } +#else w->write(cache.data(), sz); +#endif files += 1; } else { @@ -297,7 +394,11 @@ namespace zenkit { w->write_uint(unix_t == 0 ? vfs_unix_to_dos_time(time(nullptr)) : vfs_unix_to_dos_time(unix_t)); w->write_uint(off + catalog.size()); w->write_uint(header_size); - w->write_uint(80); +#ifdef _ZK_WITH_ZIPPED_VDF + w->write_uint(compressed ? VFS_VOLUME_FLAG_ZIPPED : VFS_VOLUME_FLAG_NORMAL); +#else + w->write_uint(VFS_VOLUME_FLAG_NORMAL); +#endif w->seek(static_cast(header_size), Whence::BEG); w->write(catalog.data(), catalog.size()); } @@ -488,12 +589,21 @@ namespace zenkit { [[maybe_unused]] auto entry_count = r->read_uint(); [[maybe_unused]] auto file_count = r->read_uint(); auto timestamp = vfs_dos_to_unix_time(r->read_uint()); - [[maybe_unused]] auto _size = r->read_uint(); + [[maybe_unused]] auto archive_size = r->read_uint(); auto catalog_offset = r->read_uint(); - - // Check that we're not loading a compressed Union disk. - if (r->read_uint() != 80) { - throw VfsBrokenDiskError {"Detected unsupported Union disk"}; + auto volume_flags = r->read_uint(); + + bool zipped = false; + if (volume_flags == VFS_VOLUME_FLAG_NORMAL) { + zipped = false; + } else if (volume_flags == VFS_VOLUME_FLAG_ZIPPED) { +#ifdef _ZK_WITH_ZIPPED_VDF + zipped = true; +#else + throw VfsBrokenDiskError {"Detected compressed VDF (build ZenKit with ZK_ENABLE_ZIPPED_VDF=ON to enable support)"}; +#endif + } else { + ZKLOGW("Vfs", "Unknown volume flags: 0x%X (%u), assuming uncompressed", volume_flags, volume_flags); } if (signature == VFS_DISK_SIGNATURE_VDFSTOOL) { @@ -515,7 +625,7 @@ namespace zenkit { } std::function load_entry = - [&load_entry, overwrite, catalog_offset, timestamp, &r, buf, size](VfsNode* parent) { + [&load_entry, overwrite, catalog_offset, timestamp, zipped, &r, buf, size](VfsNode* parent) { auto e_name = r->read_string(64); auto e_offset = r->read_uint(); auto e_size = r->read_uint(); @@ -576,7 +686,10 @@ namespace zenkit { ; r->seek(static_cast(self_offset), Whence::BEG); } else { - if (e_offset + e_size > size) { + // For zipped VDFs, entry.Size is the uncompressed size; the actual + // compressed data at e_offset is smaller. Only check offset validity. + // For normal VDFs, the full extent must fit within the archive. + if (zipped ? (e_offset >= size) : (e_offset + e_size > size)) { return last; } @@ -601,8 +714,14 @@ namespace zenkit { parent->remove(e_name); } + // For zipped files, the descriptor gets the full remaining buffer + // so ReadZipped can read the compressed stream. raw_size preserves + // the catalog entry size for fallback (e.g. raw audio files). + auto desc_size = zipped ? (size - e_offset) : static_cast(e_size); (void) parent->create( - VfsNode::file(e_name, VfsFileDescriptor {buf + e_offset, e_size, false}, timestamp)); + VfsNode::file(e_name, + VfsFileDescriptor {buf + e_offset, desc_size, false, zipped, e_size}, + timestamp)); } return last; diff --git a/tests/TestVfs.cc b/tests/TestVfs.cc index e1c04609..9b1edf9a 100644 --- a/tests/TestVfs.cc +++ b/tests/TestVfs.cc @@ -1,9 +1,14 @@ // Copyright © 2023 GothicKit Contributors. // SPDX-License-Identifier: MIT +#include #include #include +#include +#include +#include + void check_vfs(zenkit::Vfs const& vdf) { // Checks if all entries are here @@ -69,4 +74,123 @@ TEST_SUITE("Vfs") { vdf.mount_host("./samples/basic.vdf.dir", "/"); check_vfs(vdf); } + +#ifdef _ZK_WITH_ZIPPED_VDF + TEST_CASE("Vfs.mount_disk(basic_zipped)") { + auto vdf = zenkit::Vfs {}; + vdf.mount_disk("./samples/basic_zipped.vdf"); + check_vfs(vdf); + } + + TEST_CASE("Vfs.save_compressed") { + // Build a VFS from scratch with various file types and sizes. + auto vfs = zenkit::Vfs {}; + + // Helper: create a byte pattern of the given size. + auto make_data = [](size_t size, uint8_t seed) { + std::vector data(size); + for (size_t i = 0; i < size; i++) { + data[i] = static_cast((seed + i * 7 + i / 256) & 0xFF); + } + return data; + }; + + struct TestFile { + std::string path; // directory path (empty for root) + std::string name; + std::vector data; + }; + + std::vector test_files = { + // Small file — fits in a single block + {"", "README.TXT", make_data(128, 0x41)}, + // File exactly one block (8192 bytes) + {"", "ONEBLOCK.BIN", make_data(8192, 0x10)}, + // Multi-block file — spans several ZippedStream blocks + {"SCRIPTS", "STARTUP.D", make_data(25000, 0x55)}, + // Large compressible data (repeating pattern) + {"MESHES", "WORLD.MRM", std::vector(50000, std::byte {0xAB})}, + // Audio file — should be stored raw even in compressed mode + {"SOUND", "THEME.WAV", make_data(4096, 0xCC)}, + // Another audio format + {"SOUND", "VOICE.OGG", make_data(2048, 0xDD)}, + // File in a nested directory + {"TEXTURES/_COMPILED", "STONE.TEX", make_data(16384, 0x77)}, + }; + + // Create directory structure and add files + for (auto& tf : test_files) { + zenkit::VfsNode* parent = nullptr; + if (tf.path.empty()) { + parent = const_cast(&vfs.root()); + } else { + parent = &vfs.mkdir(tf.path); + } + + parent->create( + zenkit::VfsNode::file(tf.name, zenkit::VfsFileDescriptor {tf.data.data(), tf.data.size(), false})); + } + + // Save as compressed VDF + std::vector compressed_output; + { + auto writer = zenkit::Write::to(&compressed_output); + vfs.save_compressed(writer.get(), zenkit::GameVersion::GOTHIC_2); + } + CHECK_GT(compressed_output.size(), 0); + + // Also save uncompressed for size comparison + std::vector normal_output; + { + auto writer = zenkit::Write::to(&normal_output); + vfs.save(writer.get(), zenkit::GameVersion::GOTHIC_2); + } + + // Compressed should be smaller (the repeating-pattern file compresses well) + CHECK_LT(compressed_output.size(), normal_output.size()); + + // Reload the compressed VDF and verify every file + auto vfs_loaded = zenkit::Vfs {}; + auto rd = zenkit::Read::from(&compressed_output); + vfs_loaded.mount_disk(rd.get()); + + for (auto const& tf : test_files) { + auto const* node = vfs_loaded.find(tf.name); + REQUIRE_NE(node, nullptr); + REQUIRE(node->type() == zenkit::VfsNodeType::FILE); + + auto reader = node->open_read(); + reader->seek(0, zenkit::Whence::END); + auto sz = reader->tell(); + reader->seek(0, zenkit::Whence::BEG); + + CHECK_EQ(sz, tf.data.size()); + + std::vector buf(sz); + reader->read(buf.data(), sz); + CHECK_EQ(buf, tf.data); + } + + // Also reload the uncompressed VDF and cross-verify + auto vfs_normal = zenkit::Vfs {}; + auto rd2 = zenkit::Read::from(&normal_output); + vfs_normal.mount_disk(rd2.get()); + + for (auto const& tf : test_files) { + auto const* node = vfs_normal.find(tf.name); + REQUIRE_NE(node, nullptr); + + auto reader = node->open_read(); + reader->seek(0, zenkit::Whence::END); + auto sz = reader->tell(); + reader->seek(0, zenkit::Whence::BEG); + + CHECK_EQ(sz, tf.data.size()); + + std::vector buf(sz); + reader->read(buf.data(), sz); + CHECK_EQ(buf, tf.data); + } + } +#endif // _ZK_WITH_ZIPPED_VDF } diff --git a/tests/samples/basic_zipped.vdf b/tests/samples/basic_zipped.vdf new file mode 100644 index 00000000..2aec8337 Binary files /dev/null and b/tests/samples/basic_zipped.vdf differ diff --git a/vendor/CMakeLists.txt b/vendor/CMakeLists.txt index eb3c550c..9c166381 100644 --- a/vendor/CMakeLists.txt +++ b/vendor/CMakeLists.txt @@ -33,6 +33,9 @@ endfunction() px_add_dependency(doctest https://github.com/doctest/doctest/archive/refs/tags/v2.4.9.zip d1563419fa370c34c90e028c2e903a70c8dc07b2) px_add_dependency(libsquish https://github.com/lmichaelis/phoenix-libsquish/archive/cc82beff55210816e1bd531fc6057203dc309807.zip 953f5cd072cd6674d1aeaff5ff91225f2197283c) +if (ZK_ENABLE_ZIPPED_VDF) + px_add_dependency(miniz https://github.com/richgel999/miniz/archive/refs/tags/3.1.1.zip b761904541c8a49b140ade22f9477db31ec85439) +endif () # msvc: disable -wno-* flags if (NOT MSVC)