From d8a1a0e43a1b2bc6fcdcd8bfd424a221864c0784 Mon Sep 17 00:00:00 2001 From: skejt23 Date: Sun, 8 Feb 2026 15:30:24 +0100 Subject: [PATCH 1/5] feat(Vfs): support compressed VDF Union files --- .gitmodules | 3 + CMakeLists.txt | 2 +- include/zenkit/Stream.hh | 1 + include/zenkit/Vfs.hh | 19 ++++- src/Stream.cc | 174 +++++++++++++++++++++++++++++++++++++++ src/Vfs.cc | 135 ++++++++++++++++++++++++++---- tests/TestVfs.cc | 116 ++++++++++++++++++++++++++ vendor/CMakeLists.txt | 1 + vendor/miniz | 1 + 9 files changed, 433 insertions(+), 19 deletions(-) create mode 160000 vendor/miniz diff --git a/.gitmodules b/.gitmodules index 25ac7e7c..2081c1c2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "vendor/libsquish"] path = vendor/libsquish url = https://github.com/lmichaelis/phoenix-libsquish.git +[submodule "vendor/miniz"] + path = vendor/miniz + url = https://github.com/richgel999/miniz diff --git a/CMakeLists.txt b/CMakeLists.txt index 91826ebf..6fc60e08 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -137,7 +137,7 @@ 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) +target_link_libraries(zenkit PUBLIC miniz squish) 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..56e938ee 100644 --- a/include/zenkit/Stream.hh +++ b/include/zenkit/Stream.hh @@ -99,6 +99,7 @@ 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; + [[nodiscard]] static std::unique_ptr from_zipped(std::unique_ptr stream); [[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..95e73930 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,25 @@ 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. + ZKAPI void save_compressed(Write* w, GameVersion version, time_t unix_t = 0) const; + 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..ce8813ac 100644 --- a/src/Stream.cc +++ b/src/Stream.cc @@ -8,6 +8,8 @@ #include #include #include +#define MINIZ_NO_ZLIB_COMPATIBLE_NAMES +#include namespace zenkit { template @@ -477,4 +479,176 @@ namespace zenkit { std::unique_ptr Write::to(std::vector* vector) { return std::make_unique(vector); } + // ----------------------------------------------------------------------------------------------------------------- + + 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)) 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 + size_t new_pos = 0; + if (whence == Whence::BEG) + new_pos = off; + else if (whence == Whence::CUR) + new_pos = _m_position + off; + else if (whence == Whence::END) + new_pos = _m_header.length_uncompressed + off; // stored in header + + _m_position = 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) { + 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) { + 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; + } } // namespace zenkit diff --git a/src/Vfs.cc b/src/Vfs.cc index 216480c8..f51db508 100644 --- a/src/Vfs.cc +++ b/src/Vfs.cc @@ -7,7 +7,11 @@ #include "zenkit/Error.hh" #include "zenkit/Stream.hh" +#define MINIZ_NO_ZLIB_COMPATIBLE_NAMES +#include + #include +#include #include #include #include @@ -18,6 +22,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 +36,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 +123,20 @@ 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); + + 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); + } + + return reader; } VfsNode VfsNode::directory(std::string_view name) { @@ -220,7 +244,65 @@ namespace zenkit { return dos; } + /// 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(Write* w, GameVersion version, time_t unix_t) const { + save_internal(w, version, unix_t, false); + } + + void Vfs::save_compressed(Write* w, GameVersion version, time_t unix_t) const { + save_internal(w, version, unix_t, true); + } + + void Vfs::save_internal(Write* w, GameVersion version, time_t unix_t, bool compressed) const { std::vector catalog; auto write_catalog = Write::to(&catalog); @@ -250,13 +332,18 @@ 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); - w->write(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 + + if (compressed && !vfs_is_wave_file(child.name())) { + vfs_write_zipped(w, cache.data(), sz); + } else { + w->write(cache.data(), sz); + } files += 1; } else { @@ -297,7 +384,7 @@ 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); + w->write_uint(compressed ? VFS_VOLUME_FLAG_ZIPPED : VFS_VOLUME_FLAG_NORMAL); w->seek(static_cast(header_size), Whence::BEG); w->write(catalog.data(), catalog.size()); } @@ -488,12 +575,17 @@ 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(); + auto volume_flags = r->read_uint(); - // Check that we're not loading a compressed Union disk. - if (r->read_uint() != 80) { - throw VfsBrokenDiskError {"Detected unsupported Union disk"}; + bool zipped = false; + if (volume_flags == VFS_VOLUME_FLAG_NORMAL) { + zipped = false; + } else if (volume_flags == VFS_VOLUME_FLAG_ZIPPED) { + zipped = true; + } else { + ZKLOGW("Vfs", "Unknown volume flags: 0x%X (%u), assuming uncompressed", volume_flags, volume_flags); } if (signature == VFS_DISK_SIGNATURE_VDFSTOOL) { @@ -515,7 +607,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 +668,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 +696,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..780d6135 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,115 @@ TEST_SUITE("Vfs") { vdf.mount_host("./samples/basic.vdf.dir", "/"); 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); + } + } } diff --git a/vendor/CMakeLists.txt b/vendor/CMakeLists.txt index eb3c550c..82b89cff 100644 --- a/vendor/CMakeLists.txt +++ b/vendor/CMakeLists.txt @@ -33,6 +33,7 @@ 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) +px_add_dependency(miniz https://github.com/richgel999/miniz/archive/refs/tags/3.1.1.zip b761904541c8a49b140ade22f9477db31ec85439) # msvc: disable -wno-* flags if (NOT MSVC) diff --git a/vendor/miniz b/vendor/miniz new file mode 160000 index 00000000..d10b03cc --- /dev/null +++ b/vendor/miniz @@ -0,0 +1 @@ +Subproject commit d10b03cc73475af673df40f06e5cefd1d5f940d9 From a47d9d2563b2cc1eb1312536714cbe3e5f4e1325 Mon Sep 17 00:00:00 2001 From: skejt23 Date: Sat, 21 Feb 2026 13:15:13 +0100 Subject: [PATCH 2/5] feat(Vfs): support for compressed VDF files depends on ZK_ENABLE_ZIPPED_VDF option --- .gitmodules | 3 --- CMakeLists.txt | 10 +++++++++- include/zenkit/Stream.hh | 2 ++ include/zenkit/Vfs.hh | 2 ++ src/Stream.cc | 4 ++++ src/Vfs.cc | 26 ++++++++++++++++++++++---- tests/TestVfs.cc | 2 ++ vendor/CMakeLists.txt | 4 +++- vendor/miniz | 1 - 9 files changed, 44 insertions(+), 10 deletions(-) delete mode 160000 vendor/miniz diff --git a/.gitmodules b/.gitmodules index 2081c1c2..25ac7e7c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,6 +7,3 @@ [submodule "vendor/libsquish"] path = vendor/libsquish url = https://github.com/lmichaelis/phoenix-libsquish.git -[submodule "vendor/miniz"] - path = vendor/miniz - url = https://github.com/richgel999/miniz diff --git a/CMakeLists.txt b/CMakeLists.txt index 6fc60e08..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 miniz 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 56e938ee..65130b4d 100644 --- a/include/zenkit/Stream.hh +++ b/include/zenkit/Stream.hh @@ -99,7 +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 95e73930..2d326eb8 100644 --- a/include/zenkit/Vfs.hh +++ b/include/zenkit/Vfs.hh @@ -202,7 +202,9 @@ namespace zenkit { /// \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); diff --git a/src/Stream.cc b/src/Stream.cc index ce8813ac..b1cffa51 100644 --- a/src/Stream.cc +++ b/src/Stream.cc @@ -8,8 +8,10 @@ #include #include #include +#ifdef _ZK_WITH_ZIPPED_VDF #define MINIZ_NO_ZLIB_COMPATIBLE_NAMES #include +#endif namespace zenkit { template @@ -481,6 +483,7 @@ namespace zenkit { } // ----------------------------------------------------------------------------------------------------------------- +#ifdef _ZK_WITH_ZIPPED_VDF namespace detail { /// Reads file data stored as a Union ZippedStream. /// @@ -651,4 +654,5 @@ namespace zenkit { 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 f51db508..092d26b3 100644 --- a/src/Vfs.cc +++ b/src/Vfs.cc @@ -7,8 +7,10 @@ #include "zenkit/Error.hh" #include "zenkit/Stream.hh" +#ifdef _ZK_WITH_ZIPPED_VDF #define MINIZ_NO_ZLIB_COMPATIBLE_NAMES #include +#endif #include #include @@ -125,6 +127,7 @@ namespace zenkit { auto fd = std::get(_m_data); 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) { @@ -135,6 +138,7 @@ namespace zenkit { // Fall back to raw data using the catalog entry size. return Read::from(fd.memory, fd.raw_size); } +#endif return reader; } @@ -244,6 +248,7 @@ 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; @@ -294,12 +299,13 @@ namespace zenkit { } } - void Vfs::save(Write* w, GameVersion version, time_t unix_t) const { - save_internal(w, version, unix_t, false); + 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_compressed(Write* w, GameVersion version, time_t unix_t) const { - save_internal(w, version, unix_t, true); + 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, bool compressed) const { @@ -339,11 +345,15 @@ namespace zenkit { 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 { @@ -384,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); +#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()); } @@ -583,7 +597,11 @@ namespace zenkit { 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); } diff --git a/tests/TestVfs.cc b/tests/TestVfs.cc index 780d6135..d57dd3d6 100644 --- a/tests/TestVfs.cc +++ b/tests/TestVfs.cc @@ -75,6 +75,7 @@ TEST_SUITE("Vfs") { check_vfs(vdf); } +#ifdef _ZK_WITH_ZIPPED_VDF TEST_CASE("Vfs.save_compressed") { // Build a VFS from scratch with various file types and sizes. auto vfs = zenkit::Vfs {}; @@ -185,4 +186,5 @@ TEST_SUITE("Vfs") { CHECK_EQ(buf, tf.data); } } +#endif // _ZK_WITH_ZIPPED_VDF } diff --git a/vendor/CMakeLists.txt b/vendor/CMakeLists.txt index 82b89cff..9c166381 100644 --- a/vendor/CMakeLists.txt +++ b/vendor/CMakeLists.txt @@ -33,7 +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) -px_add_dependency(miniz https://github.com/richgel999/miniz/archive/refs/tags/3.1.1.zip b761904541c8a49b140ade22f9477db31ec85439) +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) diff --git a/vendor/miniz b/vendor/miniz deleted file mode 160000 index d10b03cc..00000000 --- a/vendor/miniz +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d10b03cc73475af673df40f06e5cefd1d5f940d9 From 8480276eda79516545bf141acdb0d1ec3e7f9ca7 Mon Sep 17 00:00:00 2001 From: skejt23 Date: Wed, 25 Feb 2026 19:38:41 +0100 Subject: [PATCH 3/5] fix(Vfs): small fix in zipped stream and one more test --- src/Stream.cc | 13 +++++++++---- tests/TestVfs.cc | 6 ++++++ tests/samples/basic_zipped.vdf | Bin 0 -> 19038 bytes 3 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 tests/samples/basic_zipped.vdf diff --git a/src/Stream.cc b/src/Stream.cc index b1cffa51..ef5795f2 100644 --- a/src/Stream.cc +++ b/src/Stream.cc @@ -539,15 +539,20 @@ namespace zenkit { void seek(ssize_t off, Whence whence) noexcept override { // Update _m_position and _m_current_block - size_t new_pos = 0; + ssize_t new_pos = 0; if (whence == Whence::BEG) new_pos = off; else if (whence == Whence::CUR) - new_pos = _m_position + off; + new_pos = static_cast(_m_position) + off; else if (whence == Whence::END) - new_pos = _m_header.length_uncompressed + off; // stored in header + new_pos = static_cast(_m_header.length_uncompressed) + off; - _m_position = new_pos; + // 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; } diff --git a/tests/TestVfs.cc b/tests/TestVfs.cc index d57dd3d6..9b1edf9a 100644 --- a/tests/TestVfs.cc +++ b/tests/TestVfs.cc @@ -76,6 +76,12 @@ TEST_SUITE("Vfs") { } #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 {}; diff --git a/tests/samples/basic_zipped.vdf b/tests/samples/basic_zipped.vdf new file mode 100644 index 0000000000000000000000000000000000000000..2aec8337c8b637cf5bb56ad53aa0bf6924b3d59d GIT binary patch literal 19038 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 literal 0 HcmV?d00001 From 6e044a91ad14cd49d59b9fc25f2dd1e21c0a0971 Mon Sep 17 00:00:00 2001 From: skejt23 Date: Wed, 25 Feb 2026 19:48:47 +0100 Subject: [PATCH 4/5] fix(Vfs): indentation style fix --- src/Vfs.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Vfs.cc b/src/Vfs.cc index 092d26b3..3e2e2512 100644 --- a/src/Vfs.cc +++ b/src/Vfs.cc @@ -299,7 +299,7 @@ namespace zenkit { } } - void Vfs::save_compressed(Write* w, GameVersion version, time_t unix_t) const { + 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 From 9af95f022f1fa29182b7018f47def2586bfedc83 Mon Sep 17 00:00:00 2001 From: skejt23 Date: Sun, 15 Mar 2026 12:35:42 +0100 Subject: [PATCH 5/5] fixup! feat(Vfs): support compressed VDF Union files --- src/Stream.cc | 9 ++++++++- src/Vfs.cc | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Stream.cc b/src/Stream.cc index ef5795f2..efedbc22 100644 --- a/src/Stream.cc +++ b/src/Stream.cc @@ -516,7 +516,10 @@ namespace zenkit { // Ensure current block is cached/decompressed if (!_m_cache_valid || _m_cache_idx != _m_current_block) { - if (!cache_block(_m_current_block)) return total_read; + 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); @@ -577,6 +580,8 @@ namespace zenkit { // 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; } @@ -586,6 +591,8 @@ namespace zenkit { 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; } diff --git a/src/Vfs.cc b/src/Vfs.cc index 3e2e2512..690a7026 100644 --- a/src/Vfs.cc +++ b/src/Vfs.cc @@ -308,7 +308,7 @@ namespace zenkit { save_internal(w, version, unix_t, false); } - void Vfs::save_internal(Write* w, GameVersion version, time_t unix_t, bool compressed) const { + 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);