diff --git a/README.md b/README.md index be285cd351..8df1e59227 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ Information about configuration and supported features can be found on [**TheArc Join us on our [**Discord server**](https://discord.gg/X8YWP8w) for a chat. +## Loading games from 7z archives + +Dreamcast games can be loaded directly from `.7z` files containing exactly one `.gdi`, `.cue`, `.cdi`, or `.chd` image. Include all tracks referenced by a GDI or CUE file, keeping their relative folder paths intact. Archives containing multiple disc images must be split into one archive per disc. + +Flycast extracts the required files to temporary disk storage for the duration of play, so enough free space for the uncompressed image is needed. Loading may take longer than opening an unpacked image, and solid archives can require enough memory to decompress an entire solid block. Existing arcade `.7z` ROM sets remain supported with their standard ROM set names. + ## Downloads ![android](https://flyinghead.github.io/flycast-builds/android.jpg) ![windows](https://flyinghead.github.io/flycast-builds/windows.png) ![linux](https://flyinghead.github.io/flycast-builds/ubuntu.png) ![apple](https://flyinghead.github.io/flycast-builds/apple.png) ![switch](https://flyinghead.github.io/flycast-builds/switch.png) ![xbox](https://flyinghead.github.io/flycast-builds/xbox.png) Get builds for your system from the [**builds page**](https://flyinghead.github.io/flycast-builds/) or [**GitHub Releases**](https://github.com/flyinghead/flycast/releases). diff --git a/core/archive/7zArchive.cpp b/core/archive/7zArchive.cpp index 72d0652408..a070d4309f 100644 --- a/core/archive/7zArchive.cpp +++ b/core/archive/7zArchive.cpp @@ -22,12 +22,14 @@ #include "lzma/7z.h" #include "lzma/7zCrc.h" #include "lzma/Alloc.h" +#include "nowide/utf/convert.hpp" -#include +#include +#include #define kInputBufSize ((size_t)1 << 18) -static bool crc_tables_generated; +static std::once_flag crc_tables_generated; SRes SzArchive::ArchiveStream::Read(const ISeekInStream *p, void *buf, size_t *size) { @@ -50,95 +52,107 @@ SRes SzArchive::ArchiveStream::Seek(const ISeekInStream *p, Int64 *pos, ESzSeek bool SzArchive::Open(hostfs::File *file) { - SzArEx_Init(&szarchive); - - if (archiveStream.file != nullptr) - delete archiveStream.file; + Close(); + if (file == nullptr) + return false; archiveStream.vt.Read = ArchiveStream::Read; archiveStream.vt.Seek = ArchiveStream::Seek; archiveStream.file = file; LookToRead2_CreateVTable(&lookStream, 0); lookStream.buf = (Byte *)ISzAlloc_Alloc(&g_Alloc, kInputBufSize); - if (lookStream.buf == NULL) + if (lookStream.buf == nullptr) { - delete archiveStream.file; + Close(); return false; } lookStream.bufSize = kInputBufSize; lookStream.realStream = &archiveStream.vt; LookToRead2_Init(&lookStream); - if (!crc_tables_generated) - { - CrcGenerateTable(); - crc_tables_generated = true; - } + std::call_once(crc_tables_generated, CrcGenerateTable); SRes res = SzArEx_Open(&szarchive, &lookStream.vt, &g_Alloc, &g_Alloc); + if (res != SZ_OK) + Close(); return (res == SZ_OK); } -ArchiveFile* SzArchive::OpenFile(const char* name) +std::string SzArchive::GetFileName(size_t index) const { - u16 fname[512]; - for (UInt32 i = 0; i < szarchive.NumFiles; i++) - { - if (SzArEx_IsDir(&szarchive, i)) - continue; + if (index >= szarchive.NumFiles) + return {}; + size_t len = SzArEx_GetFileNameUtf16(&szarchive, index, nullptr); + if (len == 0) + return {}; + std::vector name(len); + SzArEx_GetFileNameUtf16(&szarchive, index, name.data()); + return nowide::utf::convert_string(name.data(), name.data() + len - 1); +} - size_t len = SzArEx_GetFileNameUtf16(&szarchive, i, fname); - char szname[512]; - size_t j = 0; - for (; j < len && j < sizeof(szname) - 1; j++) - szname[j] = fname[j]; - szname[j] = 0; - if (strcmp(name, szname)) - continue; +bool SzArchive::IsDirectory(size_t index) const +{ + return index < szarchive.NumFiles && SzArEx_IsDir(&szarchive, index); +} - size_t offset = 0; - size_t out_size_processed = 0; - SRes res = SzArEx_Extract(&szarchive, &lookStream.vt, i, &block_idx, &out_buffer, &out_buffer_size, &offset, &out_size_processed, &g_Alloc, &g_Alloc); - if (res != SZ_OK) - return NULL; +u64 SzArchive::GetFileSize(size_t index) const +{ + return index < szarchive.NumFiles ? SzArEx_GetFileSize(&szarchive, index) : 0; +} - return new SzArchiveFile(out_buffer, offset, (u32)out_size_processed); - } - return NULL; +ArchiveFile* SzArchive::OpenFileByIndex(size_t index) +{ + if (index >= szarchive.NumFiles || IsDirectory(index)) + return nullptr; + + size_t offset = 0; + size_t out_size_processed = 0; + SRes res = SzArEx_Extract(&szarchive, &lookStream.vt, static_cast(index), &block_idx, &out_buffer, &out_buffer_size, &offset, &out_size_processed, &g_Alloc, &g_Alloc); + if (res != SZ_OK) + return nullptr; + + return new SzArchiveFile(out_buffer, offset, out_size_processed); +} + +ArchiveFile* SzArchive::OpenFile(const char* name) +{ + for (size_t i = 0; i < GetFileCount(); i++) + if (!IsDirectory(i) && GetFileName(i) == name) + return OpenFileByIndex(i); + return nullptr; } ArchiveFile* SzArchive::OpenFileByCrc(u32 crc) { if (crc == 0) - return NULL; - for (UInt32 i = 0; i < szarchive.NumFiles; i++) + return nullptr; + for (size_t i = 0; i < GetFileCount(); i++) { - unsigned isDir = SzArEx_IsDir(&szarchive, i); - if (isDir) + if (IsDirectory(i) || !SzBitWithVals_Check(&szarchive.CRCs, i)) continue; if (crc != szarchive.CRCs.Vals[i]) continue; - size_t offset = 0; - size_t out_size_processed = 0; - SRes res = SzArEx_Extract(&szarchive, &lookStream.vt, i, &block_idx, &out_buffer, &out_buffer_size, &offset, &out_size_processed, &g_Alloc, &g_Alloc); - if (res != SZ_OK) - return NULL; - - return new SzArchiveFile(out_buffer, offset, (u32)out_size_processed); + return OpenFileByIndex(i); } - return NULL; + return nullptr; } SzArchive::~SzArchive() { - if (lookStream.buf != NULL) - { - delete archiveStream.file; - ISzAlloc_Free(&g_Alloc, lookStream.buf); - if (out_buffer != NULL) - ISzAlloc_Free(&g_Alloc, out_buffer); - SzArEx_Free(&szarchive, &g_Alloc); - } + Close(); +} + +void SzArchive::Close() +{ + delete archiveStream.file; + archiveStream.file = nullptr; + ISzAlloc_Free(&g_Alloc, lookStream.buf); + lookStream.buf = nullptr; + ISzAlloc_Free(&g_Alloc, out_buffer); + out_buffer = nullptr; + out_buffer_size = 0; + block_idx = 0; + SzArEx_Free(&szarchive, &g_Alloc); } diff --git a/core/archive/7zArchive.h b/core/archive/7zArchive.h index 2050a2e886..7cfca72be2 100644 --- a/core/archive/7zArchive.h +++ b/core/archive/7zArchive.h @@ -29,19 +29,25 @@ class SzArchive : public Archive { public: - SzArchive() : out_buffer(NULL) { - memset(&archiveStream, 0, sizeof(archiveStream)); - memset(&lookStream, 0, sizeof(lookStream)); + SzArchive() { + SzArEx_Init(&szarchive); } ~SzArchive() override; + // Takes ownership of file, including when opening fails. + bool Open(hostfs::File *file) override; ArchiveFile* OpenFile(const char* name) override; ArchiveFile *OpenFileByCrc(u32 crc) override; - -protected: - bool Open(hostfs::File *file) override; + // Returned files share the extraction buffer and must be consumed before opening another file. + ArchiveFile *OpenFileByIndex(size_t index); + size_t GetFileCount() const { return szarchive.NumFiles; } + std::string GetFileName(size_t index) const; + bool IsDirectory(size_t index) const; + u64 GetFileSize(size_t index) const; private: + void Close(); + struct ArchiveStream { static SRes Read(const ISeekInStream *p, void *buf, size_t *size); @@ -52,23 +58,25 @@ class SzArchive : public Archive }; CSzArEx szarchive; - UInt32 block_idx; /* it can have any value before first call (if outBuffer = 0) */ - Byte *out_buffer; /* it must be 0 before first call for each new archive. */ - size_t out_buffer_size; /* it can have any value before first call (if outBuffer = 0) */ - ArchiveStream archiveStream; - CLookToRead2 lookStream; + UInt32 block_idx = 0; + Byte *out_buffer = nullptr; + size_t out_buffer_size = 0; + ArchiveStream archiveStream {}; + CLookToRead2 lookStream {}; }; class SzArchiveFile : public ArchiveFile { public: - SzArchiveFile(u8 *data, u32 offset, u32 length) + SzArchiveFile(u8 *data, size_t offset, size_t length) : data(data), offset(offset), _length(length) {} u32 Read(void *buffer, u32 length) override { - length = std::min(length, this->_length); - memcpy(buffer, data + offset, length); + length = std::min(length, _length - position); + if (length != 0) + memcpy(buffer, data + offset + position, length); + position += length; return length; } @@ -78,6 +86,7 @@ class SzArchiveFile : public ArchiveFile private: u8 *data; - u32 offset; - u32 _length; + size_t offset; + size_t _length; + size_t position = 0; }; diff --git a/core/emulator.cpp b/core/emulator.cpp index 25e685bcc5..b792a067e4 100644 --- a/core/emulator.cpp +++ b/core/emulator.cpp @@ -553,6 +553,8 @@ int getGamePlatform(const std::string& filename) std::string extension = get_file_extension(filename); if (extension.empty()) return DC_PLATFORM_DREAMCAST; // unknown + if (extension == "7z" && !naomi_cart_IsKnownGame(filename.c_str())) + return DC_PLATFORM_DREAMCAST; if (extension == "zip" || extension == "7z") return naomi_cart_GetPlatform(filename.c_str()); if (extension == "bin" || extension == "dat" || extension == "lst") diff --git a/core/hw/naomi/naomi_cart.cpp b/core/hw/naomi/naomi_cart.cpp index 85fda83300..fba5726b5e 100644 --- a/core/hw/naomi/naomi_cart.cpp +++ b/core/hw/naomi/naomi_cart.cpp @@ -156,6 +156,7 @@ static const Game *FindGame(const char *filename) size_t folder_pos = get_last_slash_pos(gameName); // Only for standard path if (folder_pos != std::string::npos) gameName = gameName.substr(folder_pos + 1); + string_tolower(gameName); for (int i = 0; Games[i].name != nullptr; i++) if (gameName == Games[i].name) @@ -164,6 +165,11 @@ static const Game *FindGame(const char *filename) return nullptr; } +bool naomi_cart_IsKnownGame(const char *filename) +{ + return FindGame(filename) != nullptr; +} + void naomi_cart_LoadBios(const char *filename) { const Game *game = FindGame(filename); diff --git a/core/hw/naomi/naomi_cart.h b/core/hw/naomi/naomi_cart.h index 4011837e11..58e94ac175 100644 --- a/core/hw/naomi/naomi_cart.h +++ b/core/hw/naomi/naomi_cart.h @@ -138,6 +138,7 @@ class NaomiCartException : public FlycastException void naomi_cart_LoadRom(const std::string& path, const std::string& fileName, LoadProgress *progress); void naomi_cart_Close(); int naomi_cart_GetPlatform(const char *path); +bool naomi_cart_IsKnownGame(const char *filename); void naomi_cart_LoadBios(const char *filename); void naomi_cart_ConfigureEEPROM(); void naomi_cart_serialize(Serializer& ser); diff --git a/core/imgread/7z.cpp b/core/imgread/7z.cpp new file mode 100644 index 0000000000..a1607c30fc --- /dev/null +++ b/core/imgread/7z.cpp @@ -0,0 +1,238 @@ +#include "common.h" +#include "archive/7zArchive.h" +#include "oslib/i18n.h" +#include "stdclass.h" + +#include +#include +#include +#include + +namespace { + +// Archive paths never become host paths, including the paths in GDI/CUE files. +std::string memberPath(std::string path) +{ + std::replace(path.begin(), path.end(), '\\', '/'); + if ((!path.empty() && path.front() == '/') || path.find(':') != std::string::npos) + throw FlycastException(i18n::Ts("Invalid path in 7z archive")); + std::vector components; + size_t start = 0; + while (start < path.size()) + { + size_t end = path.find('/', start); + if (end == std::string::npos) + end = path.size(); + std::string part = path.substr(start, end - start); + if (part == "..") { + if (components.empty()) + throw FlycastException(i18n::Ts("Invalid path in 7z archive")); + components.pop_back(); + } + else if (!part.empty() && part != ".") + components.push_back(part); + start = end + 1; + } + std::string result; + for (const std::string& part : components) { + if (!result.empty()) + result += '/'; + result += part; + } + return result; +} + +// Use Flycast's writable directory: the system temporary directory isn't writable +// on every platform. Only generated names are used, with exclusive creation. +struct TemporaryFile +{ + std::string path; + FILE *writer = nullptr; + + TemporaryFile() + { + std::random_device random; + for (int attempt = 0; attempt < 16; attempt++) + { + path = get_writable_data_path(".flycast-7z-") + + std::to_string(random()) + "-" + std::to_string(random()) + + "-" + std::to_string(random()) + ".tmp"; + writer = nowide::fopen(path.c_str(), "wbx"); + if (writer != nullptr) + return; + if (errno != EEXIST) + break; + } + throw FlycastException(i18n::Ts("Cannot create temporary disc file")); + } + + ~TemporaryFile() + { + if (writer != nullptr) + std::fclose(writer); + nowide::remove(path.c_str()); + } + + void finish() + { + int result = std::fclose(writer); + writer = nullptr; + if (result != 0) + throw FlycastException(i18n::Ts("Cannot write temporary disc file")); + } +}; + +// Readers have separate cursors. The backing file is removed only after its last +// reader closes, including when parsing fails or two discs are open at once. +class CachedFile : public hostfs::File +{ + std::shared_ptr backing; + hostfs::StdFile file; // destroyed before backing (required on Windows) + +public: + CachedFile(std::shared_ptr backing, FILE *file) + : backing(std::move(backing)), file(file) {} + + size_t read(void *buffer, size_t size, size_t count) override { return file.read(buffer, size, count); } + size_t write(const void *, size_t, size_t) override { return 0; } + s64 tell() override { return file.tell(); } + int seek(s64 offset, int whence) override { return file.seek(offset, whence); } + char *gets(char *str, int count) override { return file.gets(str, count); } + s64 size() override { return file.size(); } + int eof() override { return file.eof(); } + int error() override { return file.error(); } +}; + +class ArchiveStorage : public hostfs::Storage +{ + struct Member + { + size_t index; + size_t size; + std::shared_ptr cached; + }; + + SzArchive archive; + std::map members; + std::string discName; + +public: + explicit ArchiveStorage(const std::string& path) + { + hostfs::File *file = hostfs::storage().openFile(path, "rb"); + if (file == nullptr || !archive.Open(file)) + throw FlycastException(i18n::Ts("Cannot open 7z archive")); + for (size_t i = 0; i < archive.GetFileCount(); i++) + { + if (archive.IsDirectory(i)) + continue; + std::string name = memberPath(archive.GetFileName(i)); + u64 size = archive.GetFileSize(i); + if (name.empty() || size > std::numeric_limits::max() + || size > (u64)std::numeric_limits::max() + || !members.emplace(name, Member { i, (size_t)size, nullptr }).second) + throw FlycastException(i18n::Ts("Invalid file in 7z archive")); + std::string extension = get_file_extension(name); + if (extension == "gdi" || extension == "cue" || extension == "cdi" || extension == "chd") + { + if (!discName.empty()) + throw FlycastException(i18n::Ts("7z archive contains multiple disc images")); + discName = name; + } + } + if (discName.empty()) + throw FlycastException(i18n::Ts("No supported disc image in 7z archive")); + } + + const std::string& getDiscName() const { return discName; } + bool isKnownPath(const std::string&) override { return true; } + + std::vector listContent(const std::string&) override + { + std::vector result; + for (const auto& entry : members) + result.emplace_back(entry.first, entry.first, false, entry.second.size); + return result; + } + + hostfs::File *openFile(const std::string& path, const std::string& mode) override + { + if (mode != "rb") + return nullptr; + auto it = members.find(memberPath(path)); + if (it == members.end()) + return nullptr; + Member& member = it->second; + if (!member.cached) + { + std::unique_ptr input(archive.OpenFileByIndex(member.index)); + if (!input || input->length() != member.size) + throw FlycastException(i18n::Ts("Cannot extract disc file from 7z archive")); + auto output = std::make_shared(); + std::array buffer; + size_t remaining = member.size; + while (remaining != 0) + { + u32 count = (u32)std::min(remaining, buffer.size()); + if (input->Read(buffer.data(), count) != count) + throw FlycastException(i18n::Ts("Cannot extract disc file from 7z archive")); + if (std::fwrite(buffer.data(), 1, count, output->writer) != count) + throw FlycastException(i18n::Ts("Cannot write temporary disc file")); + remaining -= count; + } + output->finish(); + member.cached = std::move(output); + } + FILE *file = nowide::fopen(member.cached->path.c_str(), "rb"); + if (file == nullptr) + throw FlycastException(i18n::Ts("Cannot open temporary disc file")); + return new CachedFile(member.cached, file); + } + + std::string getParentPath(const std::string& path) override + { + std::string name = memberPath(path); + size_t slash = name.find_last_of('/'); + return slash == std::string::npos ? "" : name.substr(0, slash); + } + + std::string getSubPath(const std::string& reference, const std::string& subpath) override + { + // Check absolute paths before joining; relative '..' may stay inside the archive. + if (!subpath.empty() && (subpath.front() == '/' || subpath.front() == '\\')) + throw FlycastException(i18n::Ts("Invalid path in 7z archive")); + return memberPath(reference.empty() ? subpath : reference + '/' + subpath); + } + + hostfs::FileInfo getFileInfo(const std::string& path) override + { + std::string name = memberPath(path); + auto it = members.find(name); + if (it == members.end()) + throw hostfs::StorageException(i18n::Ts("File not found in 7z archive")); + return { name.substr(name.find_last_of('/') + 1), name, false, it->second.size }; + } + + bool exists(const std::string& path) override + { + return members.count(memberPath(path)) != 0; + } +}; + +} // namespace + +bool is7zDisc(const std::string& path) +{ + try { + ArchiveStorage storage(path); + return true; + } catch (const std::exception&) { + return false; + } +} + +Disc *sz_parse(const std::string& path, std::vector *digest) +{ + ArchiveStorage storage(path); + return OpenDisc(storage.getDiscName(), digest, storage); +} diff --git a/core/imgread/CMakeLists.txt b/core/imgread/CMakeLists.txt index ca2ecf2e9c..fb21f6c5eb 100644 --- a/core/imgread/CMakeLists.txt +++ b/core/imgread/CMakeLists.txt @@ -1,4 +1,5 @@ target_sources(${PROJECT_NAME} PRIVATE + 7z.cpp cdi.cpp cdio.cpp chd.cpp diff --git a/core/imgread/cdi.cpp b/core/imgread/cdi.cpp index b79bab395a..9e2b429da5 100644 --- a/core/imgread/cdi.cpp +++ b/core/imgread/cdi.cpp @@ -4,12 +4,12 @@ #include "oslib/i18n.h" #include "chdpsr/cdipsr.h" -Disc* cdi_parse(const char* file, std::vector *digest) +Disc* cdi_parse(const char* file, std::vector *digest, hostfs::Storage& storage) { if (get_file_extension(file) != "cdi") return nullptr; - hostfs::File *fsource = hostfs::storage().openFile(file, "rb"); + hostfs::File *fsource = storage.openFile(file, "rb"); if (fsource == nullptr) { @@ -120,7 +120,7 @@ Disc* cdi_parse(const char* file, std::vector *digest) t.CTRL=track.mode==0?0:4; t.StartFAD=track.start_lba+track.pregap_length; t.EndFAD=t.StartFAD+track.length-1; - hostfs::File *trackFile = hostfs::storage().openFile(file, "rb"); + hostfs::File *trackFile = storage.openFile(file, "rb"); if (trackFile == nullptr) { WARN_LOG(GDROM, "Cannot re-open file '%s' errno %d", file, errno); throw FlycastException(i18n::Ts("Cannot re-open CDI file")); diff --git a/core/imgread/chd.cpp b/core/imgread/chd.cpp index f9af16108c..f1cbae2fcf 100644 --- a/core/imgread/chd.cpp +++ b/core/imgread/chd.cpp @@ -18,7 +18,7 @@ struct CHDDisc : Disc u32 hunkbytes = 0; u32 sph = 0; - void tryOpen(const char* file); + void tryOpen(const char* file, hostfs::Storage& storage); ~CHDDisc() override { @@ -106,9 +106,9 @@ static u32 getSectorSize(const std::string& type) throw FlycastException(strprintf(i18n::T("chd: track type %s is not supported"), type.c_str())); } -void CHDDisc::tryOpen(const char* file) +void CHDDisc::tryOpen(const char* file, hostfs::Storage& storage) { - hostfs::File *fp = hostfs::storage().openFile(file, "rb"); + hostfs::File *fp = storage.openFile(file, "rb"); if (fp == nullptr) { WARN_LOG(COMMON, "Cannot open file '%s' errno %d", file, errno); @@ -277,20 +277,15 @@ void CHDDisc::tryOpen(const char* file) } -Disc* chd_parse(const char* file, std::vector *digest) +Disc* chd_parse(const char* file, std::vector *digest, hostfs::Storage& storage) { -#ifdef LIBRETRO - if (!strstr(&file[strlen(file) - 3], "chd")) - return nullptr; -#else if (get_file_extension(file) != "chd") return nullptr; -#endif CHDDisc* rv = new CHDDisc(); try { - rv->tryOpen(file); + rv->tryOpen(file, storage); if (digest != nullptr) { digest->resize(sizeof(chd_get_header(rv->chd)->sha1)); diff --git a/core/imgread/common.cpp b/core/imgread/common.cpp index 193c9efeab..25e4eb08ec 100644 --- a/core/imgread/common.cpp +++ b/core/imgread/common.cpp @@ -6,25 +6,23 @@ #include "serialize.h" #include "oslib/i18n.h" -Disc* chd_parse(const char* file, std::vector *digest); -Disc* gdi_parse(const char* file, std::vector *digest); -Disc* cdi_parse(const char* file, std::vector *digest); -Disc* cue_parse(const char* file, std::vector *digest); +Disc* chd_parse(const char* file, std::vector *digest, hostfs::Storage& storage); +Disc* gdi_parse(const char* file, std::vector *digest, hostfs::Storage& storage); +Disc* cdi_parse(const char* file, std::vector *digest, hostfs::Storage& storage); +Disc* cue_parse(const char* file, std::vector *digest, hostfs::Storage& storage); Disc *cdio_parse(const char *file, std::vector *digest); +Disc *sz_parse(const std::string& path, std::vector *digest); static u32 NullDriveDiscType; Disc* disc; static int schedId = -1; -constexpr Disc* (*drivers[])(const char* path, std::vector *digest) +constexpr Disc* (*drivers[])(const char* path, std::vector *digest, hostfs::Storage& storage) { chd_parse, gdi_parse, cdi_parse, cue_parse, -#ifdef USE_LIBCDIO - cdio_parse, -#endif }; static u8 q_subchannel[96]; @@ -84,11 +82,11 @@ static bool convertSector(u8* in_buff , u8* out_buff , int from , int to,int sec return true; } -Disc* OpenDisc(const std::string& path, std::vector *digest) +Disc* OpenDisc(const std::string& path, std::vector *digest, hostfs::Storage& storage) { for (auto driver : drivers) { - Disc *disc = driver(path.c_str(), digest); + Disc *disc = driver(path.c_str(), digest, storage); if (disc != nullptr) return disc; @@ -97,6 +95,23 @@ Disc* OpenDisc(const std::string& path, std::vector *digest) throw FlycastException(i18n::Ts("Unknown disk format")); } +Disc* OpenDisc(const std::string& path, std::vector *digest) +{ + if (get_file_extension(path) == "7z") + return sz_parse(path, digest); +#ifdef USE_LIBCDIO + // Physical drives are only available through host storage. + if (get_file_extension(path) != "chd" && get_file_extension(path) != "gdi" + && get_file_extension(path) != "cdi" && get_file_extension(path) != "cue") + { + Disc *disc = cdio_parse(path.c_str(), digest); + if (disc != nullptr) + return disc; + } +#endif + return OpenDisc(path, digest, hostfs::storage()); +} + namespace gdr { static bool loadDisk(const std::string& path) diff --git a/core/imgread/common.h b/core/imgread/common.h index 23810cf289..0602207a2e 100644 --- a/core/imgread/common.h +++ b/core/imgread/common.h @@ -212,6 +212,10 @@ struct Disc }; Disc* OpenDisc(const std::string& path, std::vector *digest = nullptr); +// Internal disc readers can also read members of an archive through a Storage. +Disc* OpenDisc(const std::string& path, std::vector *digest, hostfs::Storage& storage); +// Inspects the archive directory without decompressing disc data. +bool is7zDisc(const std::string& path); struct RawTrackFile : TrackFile { diff --git a/core/imgread/cue.cpp b/core/imgread/cue.cpp index 3436f25a66..882048673f 100644 --- a/core/imgread/cue.cpp +++ b/core/imgread/cue.cpp @@ -44,12 +44,12 @@ static u32 getSectorSize(const std::string& type) { return 0; } -Disc* cue_parse(const char* file, std::vector *digest) +Disc* cue_parse(const char* file, std::vector *digest, hostfs::Storage& storage) { if (get_file_extension(file) != "cue") return nullptr; - hostfs::File *fsource = hostfs::storage().openFile(file, "rb"); + hostfs::File *fsource = storage.openFile(file, "rb"); if (fsource == nullptr) { @@ -57,7 +57,7 @@ Disc* cue_parse(const char* file, std::vector *digest) throw FlycastException(strprintf(i18n::T("Cannot open CUE file %s"), file)); } - hostfs::FileInfo fileInfo = hostfs::storage().getFileInfo(file); + hostfs::FileInfo fileInfo = storage.getFileInfo(file); size_t cue_len = fileInfo.size; char cue_data[64_KB] = { 0 }; @@ -75,7 +75,7 @@ Disc* cue_parse(const char* file, std::vector *digest) std::istringstream istream(cue_data); istream.imbue(std::locale::classic()); - std::string basepath = hostfs::storage().getParentPath(file); + std::string basepath = storage.getParentPath(file); MD5Sum md5; @@ -183,14 +183,14 @@ Disc* cue_parse(const char* file, std::vector *digest) WARN_LOG(GDROM, "CUE parse error: unsupported FILE format %s. Expected BINARY", token.c_str()); throw FlycastException(i18n::T("Invalid CUE file")); } - track_filename = hostfs::storage().getSubPath(basepath, track_filename); - hostfs::File *track_file = hostfs::storage().openFile(track_filename, "rb"); + track_filename = storage.getSubPath(basepath, track_filename); + hostfs::File *track_file = storage.openFile(track_filename, "rb"); if (track_file == nullptr) throw FlycastException(strprintf(i18n::T("CUE file: cannot open track %s"), track_filename.c_str())); if (digest != nullptr) md5.add(track_file); delete track_file; - fileInfo = hostfs::storage().getFileInfo(track_filename); + fileInfo = storage.getFileInfo(track_filename); fileStartFAD = currentFAD; // Clear track context track_number = -1; @@ -247,7 +247,7 @@ Disc* cue_parse(const char* file, std::vector *digest) t.isrc = track_isrc; DEBUG_LOG(GDROM, "file[%zd] \"%s\": session %d type %s FAD:%d -> %d %s", disc->tracks.size() + 1, track_filename.c_str(), session_number, track_type.c_str(), t.StartFAD, t.EndFAD, t.isrc.empty() ? "" : ("ISRC " + t.isrc).c_str()); - hostfs::File *track_file = hostfs::storage().openFile(track_filename, "rb"); + hostfs::File *track_file = storage.openFile(track_filename, "rb"); t.file = new RawTrackFile(track_file, indexFAD * track_secsize, t.StartFAD, track_secsize); disc->tracks.push_back(t); if (disc->tracks.size() >= 2) { diff --git a/core/imgread/gdi.cpp b/core/imgread/gdi.cpp index c49f216ca0..4cf3b3dd3b 100644 --- a/core/imgread/gdi.cpp +++ b/core/imgread/gdi.cpp @@ -4,16 +4,16 @@ #include "oslib/i18n.h" #include -static Disc* load_gdi(const char* file, std::vector *digest) +static Disc* load_gdi(const char* file, std::vector *digest, hostfs::Storage& storage) { - hostfs::File *t = hostfs::storage().openFile(file, "rb"); + hostfs::File *t = storage.openFile(file, "rb"); if (t == nullptr) { WARN_LOG(COMMON, "Cannot open file '%s' errno %d", file, errno); throw FlycastException(strprintf(i18n::T("Cannot open GDI file %s"), file)); } - hostfs::FileInfo fileInfo = hostfs::storage().getFileInfo(file); + hostfs::FileInfo fileInfo = storage.getFileInfo(file); size_t gdi_len = fileInfo.size; char gdi_data[16384] {}; @@ -40,7 +40,7 @@ static Disc* load_gdi(const char* file, std::vector *digest) INFO_LOG(GDROM, "GDI: %d tracks", trackCount); - std::string basepath = hostfs::storage().getParentPath(file); + std::string basepath = storage.getParentPath(file); MD5Sum md5; @@ -128,14 +128,14 @@ static Disc* load_gdi(const char* file, std::vector *digest) t.StartFAD = FADS + 150; t.CTRL = CTRL; - std::string path = hostfs::storage().getSubPath(basepath, track_filename); - hostfs::File *file = hostfs::storage().openFile(path, "rb"); + std::string path = storage.getSubPath(basepath, track_filename); + hostfs::File *file = storage.openFile(path, "rb"); if (file == nullptr) throw FlycastException(strprintf(i18n::T("GDI file: Cannot open track %s"), path.c_str())); if (digest != nullptr) md5.add(file); t.file = new RawTrackFile(file, OFFSET, t.StartFAD, SSIZE); - hostfs::FileInfo fileInfo = hostfs::storage().getFileInfo(path); + hostfs::FileInfo fileInfo = storage.getFileInfo(path); if ((fileInfo.size - OFFSET) % SSIZE != 0) WARN_LOG(GDROM, "Warning: Size of track %s is not multiple of sector size %d", track_filename.c_str(), SSIZE); t.EndFAD = t.StartFAD + (u32)(fileInfo.size - OFFSET) / SSIZE - 1; @@ -154,10 +154,10 @@ static Disc* load_gdi(const char* file, std::vector *digest) } -Disc* gdi_parse(const char* file, std::vector *digest) +Disc* gdi_parse(const char* file, std::vector *digest, hostfs::Storage& storage) { if (get_file_extension(file) != "gdi") return nullptr; - return load_gdi(file, digest); + return load_gdi(file, digest, storage); } diff --git a/core/ui/game_scanner.cpp b/core/ui/game_scanner.cpp index 6a9c33125c..192895f0bb 100644 --- a/core/ui/game_scanner.cpp +++ b/core/ui/game_scanner.cpp @@ -22,6 +22,7 @@ #include "oslib/storage.h" #include "cfg/option.h" #include "oslib/i18n.h" +#include "imgread/common.h" static bool operator<(const GameMedia &left, const GameMedia &right) { @@ -74,10 +75,15 @@ void GameScanner::add_game_directory(const std::string& path) std::string extension = get_file_extension(item.name); if (extension == "zip" || extension == "7z") { - string_tolower(gameName); - auto it = arcade_games.find(gameName); + std::string arcadeName = gameName; + string_tolower(arcadeName); + auto it = arcade_games.find(arcadeName); if (it == arcade_games.end()) + { + if (extension == "7z" && is7zDisc(item.path)) + insert_game(GameMedia{ fileName, item.path, item.name, gameName }); continue; + } gameName = it->second->description; fileName = fileName + " (" + gameName + ")"; insert_game(GameMedia{ fileName, item.path, item.name, gameName, true }); diff --git a/core/windows/winmain.cpp b/core/windows/winmain.cpp index e80c0daa77..119393b047 100644 --- a/core/windows/winmain.cpp +++ b/core/windows/winmain.cpp @@ -271,7 +271,7 @@ FILE *fopen(char const *file_name, char const *mode) else dwDesiredAccess = GENERIC_WRITE; if (strchr(mode, 'w') != nullptr) - dwCreationDisposition = CREATE_ALWAYS; + dwCreationDisposition = strchr(mode, 'x') != nullptr ? CREATE_NEW : CREATE_ALWAYS; else if (strchr(mode, 'a') != nullptr) { dwCreationDisposition = OPEN_ALWAYS; @@ -284,7 +284,11 @@ FILE *fopen(char const *file_name, char const *mode) HANDLE fileh = CreateFile2FromAppW(wname.get(), dwDesiredAccess, FILE_SHARE_READ, dwCreationDisposition, nullptr); if (fileh == INVALID_HANDLE_VALUE) + { + if (dwCreationDisposition == CREATE_NEW) + errno = GetLastError() == ERROR_FILE_EXISTS ? EEXIST : EACCES; return nullptr; + } int fd = _open_osfhandle((intptr_t)fileh, openFlags); if (fd == -1) @@ -294,7 +298,13 @@ FILE *fopen(char const *file_name, char const *mode) return nullptr; } - return _fdopen(fd, mode); + // Exclusive creation has already been handled by CreateFile2FromAppW. + std::string streamMode = mode; + streamMode.erase(std::remove(streamMode.begin(), streamMode.end(), 'x'), streamMode.end()); + FILE *stream = _fdopen(fd, streamMode.c_str()); + if (stream == nullptr) + _close(fd); + return stream; } int remove(char const *name) diff --git a/shell/libretro/libretro.cpp b/shell/libretro/libretro.cpp index a4444c9a65..6983c2d901 100644 --- a/shell/libretro/libretro.cpp +++ b/shell/libretro/libretro.cpp @@ -2265,8 +2265,7 @@ bool retro_load_game(const struct retro_game_info *game) char *ext = strrchr(g_base_name, '.'); { - /* Check for extension .lst, .bin, .dat or .zip. If found, we will set the system type - * automatically to Naomi or AtomisWave. */ + /* Detect arcade ROMs and Dreamcast disc archives before initializing the frontend. */ if (ext) { log_cb(RETRO_LOG_INFO, "File extension is: %s\n", ext); @@ -2276,7 +2275,10 @@ bool retro_load_game(const struct retro_game_info *game) || !strcmp(".zip", ext) || !strcmp(".ZIP", ext) || !strcmp(".7z", ext) || !strcmp(".7Z", ext)) { - settings.platform.system = naomi_cart_GetPlatform(game->path); + if (!strcmp(".7z", ext) || !strcmp(".7Z", ext)) + settings.platform.system = getGamePlatform(game->path); + else + settings.platform.system = naomi_cart_GetPlatform(game->path); // Users should use the superior format instead, let's warn them if (!strcmp(".lst", ext) || !strcmp(".bin", ext) || !strcmp(".BIN", ext) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 76157ba510..b95a50021d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,6 +24,8 @@ target_sources(${PROJECT_NAME} PRIVATE src/imgread/ChdTest.cpp src/imgread/CueTest.cpp src/imgread/GdiTest.cpp + src/imgread/SevenZipTest.cpp + src/archive/SzArchiveTest.cpp src/input/ButtonComboTest.cpp src/input/GamepadInputHandlingTest.cpp src/input/MultiBindMappingTest.cpp diff --git a/tests/files/test_7z/archive.7z b/tests/files/test_7z/archive.7z new file mode 100644 index 0000000000..7af5c2f7fc Binary files /dev/null and b/tests/files/test_7z/archive.7z differ diff --git a/tests/files/test_7z/cdi.7Z b/tests/files/test_7z/cdi.7Z new file mode 100644 index 0000000000..d5bbc71399 Binary files /dev/null and b/tests/files/test_7z/cdi.7Z differ diff --git a/tests/files/test_7z/chd.7z b/tests/files/test_7z/chd.7z new file mode 100644 index 0000000000..4d27864827 Binary files /dev/null and b/tests/files/test_7z/chd.7z differ diff --git a/tests/files/test_7z/corrupt-data.7z b/tests/files/test_7z/corrupt-data.7z new file mode 100644 index 0000000000..24207b12c2 Binary files /dev/null and b/tests/files/test_7z/corrupt-data.7z differ diff --git a/tests/files/test_7z/cue-shared.7z b/tests/files/test_7z/cue-shared.7z new file mode 100644 index 0000000000..e331a5aba3 Binary files /dev/null and b/tests/files/test_7z/cue-shared.7z differ diff --git a/tests/files/test_7z/gdi-nonsolid.7z b/tests/files/test_7z/gdi-nonsolid.7z new file mode 100644 index 0000000000..100f0d13b6 Binary files /dev/null and b/tests/files/test_7z/gdi-nonsolid.7z differ diff --git a/tests/files/test_7z/gdi-solid.7z b/tests/files/test_7z/gdi-solid.7z new file mode 100644 index 0000000000..821d25f135 Binary files /dev/null and b/tests/files/test_7z/gdi-solid.7z differ diff --git a/tests/files/test_7z/invalid-descriptor.7z b/tests/files/test_7z/invalid-descriptor.7z new file mode 100644 index 0000000000..9a208f2688 Binary files /dev/null and b/tests/files/test_7z/invalid-descriptor.7z differ diff --git a/tests/files/test_7z/long-path.7z b/tests/files/test_7z/long-path.7z new file mode 100644 index 0000000000..1e47dc6bdb Binary files /dev/null and b/tests/files/test_7z/long-path.7z differ diff --git a/tests/files/test_7z/make_fixtures.py b/tests/files/test_7z/make_fixtures.py new file mode 100644 index 0000000000..6b13a5583c --- /dev/null +++ b/tests/files/test_7z/make_fixtures.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Regenerate the synthetic 7z fixtures (requires 7z or 7zz). + +No game data is used. Raw sectors contain a track/frame marker followed by zeros; +the CHD fixture is the synthetic image generated by ../test_chds/make_fixtures.py. +Archive timestamps and header compression are disabled for reproducible fixtures. +""" + +from pathlib import Path +import shutil +import struct +import subprocess +import tempfile + +HERE = Path(__file__).resolve().parent +SECTOR = 2352 + + +def sectors(track, count=4): + return b"".join(bytes([track, 0x11, frame, 0x22, 0, 0x33, 0x44, 0x55]) + + bytes(SECTOR - 8) for frame in range(count)) + + +def cdi(): + """Minimal DiscJuggler v2 image: one session, two four-sector tracks.""" + data = sectors(1) + sectors(2) + header = bytearray(struct.pack(" +#include + +namespace +{ + +hostfs::File *openFixture(const char *name) +{ + const std::string path = std::string(FLYCAST_TEST_FILES) + "/test_7z/" + name; + FILE *file = std::fopen(path.c_str(), "rb"); + return file == nullptr ? nullptr : new hostfs::StdFile(file); +} + +} + +TEST(SzArchiveTest, ReadsLargeFileInChunks) +{ + SzArchive archive; + ASSERT_TRUE(archive.Open(openFixture("archive.7z"))); + std::unique_ptr file(archive.OpenFile("payload.bin")); + ASSERT_NE(nullptr, file); + ASSERT_EQ(131073u, file->length()); + + std::vector data(file->length()); + ASSERT_EQ(65536u, file->Read(data.data(), 65536)); + ASSERT_EQ(65536u, file->Read(data.data() + 65536, 65536)); + ASSERT_EQ(1u, file->Read(data.data() + 131072, 65536)); + ASSERT_EQ(0u, file->Read(data.data(), 65536)); + EXPECT_EQ(131073u, file->length()); + for (size_t i = 0; i < data.size(); i++) + ASSERT_EQ(i % 251, data[i]) << "offset " << i; +} + +TEST(SzArchiveTest, EnumeratesFilesAndDecodesUtf8Names) +{ + SzArchive archive; + ASSERT_TRUE(archive.Open(openFixture("archive.7z"))); + bool foundPayload = false; + bool foundEmpty = false; + bool foundUnicode = false; + for (size_t i = 0; i < archive.GetFileCount(); i++) + { + if (archive.IsDirectory(i)) + { + EXPECT_EQ(nullptr, archive.OpenFileByIndex(i)); + continue; + } + const std::string name = archive.GetFileName(i); + std::unique_ptr file(archive.OpenFileByIndex(i)); + ASSERT_NE(nullptr, file) << name; + EXPECT_EQ(archive.GetFileSize(i), file->length()); + if (name == "payload.bin") + { + foundPayload = true; + EXPECT_EQ(131073u, file->length()); + } + else if (name == "empty") + { + foundEmpty = true; + EXPECT_EQ(0u, file->length()); + EXPECT_EQ(0u, file->Read(nullptr, 1)); + } + else if (name == u8"folder/日本語😀.bin") + { + foundUnicode = true; + char data[4]; + ASSERT_EQ(sizeof(data), file->Read(data, sizeof(data))); + EXPECT_EQ("utf8", std::string(data, sizeof(data))); + } + } + EXPECT_TRUE(foundPayload); + EXPECT_TRUE(foundEmpty); + EXPECT_TRUE(foundUnicode); + EXPECT_EQ(nullptr, archive.OpenFileByIndex(archive.GetFileCount())); + EXPECT_TRUE(archive.GetFileName(archive.GetFileCount()).empty()); + EXPECT_EQ(0u, archive.GetFileSize(archive.GetFileCount())); + EXPECT_FALSE(archive.IsDirectory(archive.GetFileCount())); + EXPECT_EQ(nullptr, archive.OpenFile("missing")); +} + +TEST(SzArchiveTest, OpensLongUtf8Names) +{ + SzArchive archive; + ASSERT_TRUE(archive.Open(openFixture("long-path.7z"))); + bool found = false; + for (size_t i = 0; i < archive.GetFileCount(); i++) + { + const std::string name = archive.GetFileName(i); + if (archive.IsDirectory(i) || name.find(u8"音声 02.raw") == std::string::npos) + continue; + found = true; + ASSERT_GT(name.size(), 512u); + std::unique_ptr file(archive.OpenFile(name.c_str())); + ASSERT_NE(nullptr, file); + u8 marker[2]; + ASSERT_EQ(sizeof(marker), file->Read(marker, sizeof(marker))); + EXPECT_EQ(2u, marker[0]); + EXPECT_EQ(0x11u, marker[1]); + } + EXPECT_TRUE(found); +} + +TEST(SzArchiveTest, LooksUpDefinedCrc) +{ + SzArchive archive; + ASSERT_TRUE(archive.Open(openFixture("archive.7z"))); + std::unique_ptr file(archive.OpenFileByCrc(CrcCalc("utf8", 4))); + ASSERT_NE(nullptr, file); + char data[4]; + ASSERT_EQ(sizeof(data), file->Read(data, sizeof(data))); + EXPECT_EQ("utf8", std::string(data, sizeof(data))); + EXPECT_EQ(nullptr, archive.OpenFileByCrc(0)); +} + +TEST(SzArchiveTest, IgnoresUndefinedCrc) +{ + SzArchive archive; + ASSERT_TRUE(archive.Open(openFixture("no-crc.7z"))); + ASSERT_EQ(1u, archive.GetFileCount()); + EXPECT_EQ(nullptr, archive.OpenFileByCrc(CrcCalc("no checksum", 11))); + std::unique_ptr file(archive.OpenFile("no-crc.bin")); + ASSERT_NE(nullptr, file); + char data[11]; + ASSERT_EQ(sizeof(data), file->Read(data, sizeof(data))); + EXPECT_EQ("no checksum", std::string(data, sizeof(data))); +} + +TEST(SzArchiveTest, CanReopenAfterFailure) +{ + SzArchive archive; + EXPECT_EQ(0u, archive.GetFileCount()); + EXPECT_EQ(nullptr, archive.OpenFileByIndex(0)); + EXPECT_FALSE(archive.Open(nullptr)); + EXPECT_FALSE(archive.Open(openFixture("truncated.7z"))); + EXPECT_EQ(0u, archive.GetFileCount()); + ASSERT_TRUE(archive.Open(openFixture("archive.7z"))); + EXPECT_GT(archive.GetFileCount(), 0u); + ASSERT_TRUE(archive.Open(openFixture("no-crc.7z"))); + EXPECT_EQ(1u, archive.GetFileCount()); + EXPECT_FALSE(archive.Open(openFixture("truncated.7z"))); + EXPECT_EQ(0u, archive.GetFileCount()); + EXPECT_EQ(nullptr, archive.OpenFileByIndex(0)); +} + +TEST(SzArchiveTest, PreservesFileLengthsLargerThan32Bits) +{ + if (sizeof(size_t) <= sizeof(u32)) + GTEST_SKIP() << "Requires a 64-bit address space"; + u8 data[1] {}; + const size_t length = static_cast((u64(1) << 32) + 7); + SzArchiveFile file(data, 0, length); + EXPECT_EQ(length, file.length()); +} diff --git a/tests/src/imgread/SevenZipTest.cpp b/tests/src/imgread/SevenZipTest.cpp new file mode 100644 index 0000000000..ffd97f3809 --- /dev/null +++ b/tests/src/imgread/SevenZipTest.cpp @@ -0,0 +1,167 @@ +#include "gtest/gtest.h" +#include "imgread/common.h" +#include "stdclass.h" + +#include +#include +#include + +class SevenZipTest : public ::testing::Test +{ +protected: + std::filesystem::path dataDir; + std::string previousDataDir; + + void SetUp() override + { + previousDataDir = get_writable_data_path(""); + auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + dataDir = std::filesystem::temp_directory_path() / ("flycast-7z-test-" + std::to_string(stamp)); + ASSERT_TRUE(std::filesystem::create_directory(dataDir)); + set_user_data_dir(dataDir.string()); + } + + void TearDown() override + { + EXPECT_TRUE(std::filesystem::is_empty(dataDir)) << "Temporary archive files were not removed"; + set_user_data_dir(previousDataDir.empty() ? "." : previousDataDir); + std::filesystem::remove_all(dataDir); + } + + static std::string fixture(const char *name) + { + return std::string(FLYCAST_TEST_FILES "/test_7z/") + name; + } + + static void verifySector(Disc *disc, u32 fad, u8 track, u8 frame) + { + u8 sector[2352] {}; + ASSERT_EQ(1u, disc->ReadSectors(fad, 1, sector, sizeof(sector))) << "FAD " << fad; + const u8 expected[] { track, 0x11, frame, 0x22, 0, 0x33, 0x44, 0x55 }; + EXPECT_EQ(0, memcmp(sector, expected, sizeof(expected))) << "FAD " << fad; + } + + static void verifyGdi(Disc *disc) + { + ASSERT_EQ(GdRom, disc->type); + ASSERT_EQ(3u, disc->tracks.size()); + EXPECT_EQ(150u, disc->tracks[0].StartFAD); + EXPECT_EQ(600u, disc->tracks[1].StartFAD); + EXPECT_EQ(45150u, disc->tracks[2].StartFAD); + // Read tracks and sectors out of order to exercise seeking. + verifySector(disc, 45153, 3, 3); + verifySector(disc, 45189, 3, 39); // beyond the first 64 KiB extraction chunk + verifySector(disc, 601, 2, 1); + verifySector(disc, 152, 1, 2); + verifySector(disc, 45150, 3, 0); + verifySector(disc, 150, 1, 0); + } +}; + +TEST_F(SevenZipTest, GdiSolidAndNonSolidWithNestedUnicodePaths) +{ + for (const char *name : { "gdi-solid.7z", "gdi-nonsolid.7z", "long-path.7z" }) + { + SCOPED_TRACE(name); + ASSERT_TRUE(is7zDisc(fixture(name))); + std::unique_ptr disc(OpenDisc(fixture(name))); + verifyGdi(disc.get()); + } +} + +TEST_F(SevenZipTest, CueTracksShareMemberWithIndependentHandles) +{ + std::unique_ptr disc(OpenDisc(fixture("cue-shared.7z"))); + ASSERT_EQ(3u, disc->tracks.size()); + EXPECT_EQ(150u, disc->tracks[0].StartFAD); + EXPECT_EQ(154u, disc->tracks[1].StartFAD); + EXPECT_EQ(158u, disc->tracks[2].StartFAD); + verifySector(disc.get(), 161, 3, 3); + verifySector(disc.get(), 154, 2, 0); + verifySector(disc.get(), 152, 1, 2); + verifySector(disc.get(), 159, 3, 1); + // Closing one track must not close the other handles to its member. + disc->tracks[0].Destroy(); + verifySector(disc.get(), 155, 2, 1); + verifySector(disc.get(), 160, 3, 2); +} + +TEST_F(SevenZipTest, ChdInsideArchive) +{ + std::unique_ptr disc(OpenDisc(fixture("chd.7z"))); + ASSERT_EQ(CdRom, disc->type); + ASSERT_EQ(3u, disc->tracks.size()); + verifySector(disc.get(), 150, 1, 0); + verifySector(disc.get(), 300, 2, 0); + verifySector(disc.get(), 599, 3, 149); +} + +TEST_F(SevenZipTest, CdiWithUppercaseExtensions) +{ + ASSERT_TRUE(is7zDisc(fixture("cdi.7Z"))); + std::unique_ptr disc(OpenDisc(fixture("cdi.7Z"))); + ASSERT_EQ(2u, disc->tracks.size()); + EXPECT_TRUE(disc->tracks[0].isDataTrack()); + EXPECT_FALSE(disc->tracks[1].isDataTrack()); + EXPECT_EQ(150u, disc->tracks[0].StartFAD); + EXPECT_EQ(154u, disc->tracks[1].StartFAD); + verifySector(disc.get(), 157, 2, 3); + verifySector(disc.get(), 151, 1, 1); + verifySector(disc.get(), 154, 2, 0); +} + +TEST_F(SevenZipTest, ReopenAndKeepTwoDiscsAlive) +{ + for (int i = 0; i < 3; ++i) + { + std::unique_ptr first(OpenDisc(fixture("gdi-solid.7z"))); + std::unique_ptr second(OpenDisc(fixture("gdi-solid.7z"))); + verifyGdi(first.get()); + verifyGdi(second.get()); + first.reset(); + verifyGdi(second.get()); + } +} + +TEST_F(SevenZipTest, ChdDigestMatchesUncompressedImage) +{ + std::vector archiveDigest; + std::vector rawDigest; + std::unique_ptr archive(OpenDisc(fixture("chd.7z"), &archiveDigest)); + std::unique_ptr raw(OpenDisc(FLYCAST_TEST_FILES "/test_chds/audiocd.chd", &rawDigest)); + ASSERT_FALSE(archiveDigest.empty()); + EXPECT_EQ(archiveDigest, rawDigest); + verifySector(archive.get(), 599, 3, 149); +} + +TEST_F(SevenZipTest, RejectInvalidArchivesAndImages) +{ + for (const char *name : { "does-not-exist.7z", "truncated.7z", "corrupt-data.7z", + "no-disc.7z", "multiple-discs.7z", "mixed-descriptors.7z", + "missing-track.7z", "invalid-descriptor.7z", "traversal-track.7z", "traversal-entry.7z" }) + { + SCOPED_TRACE(name); + EXPECT_THROW(std::unique_ptr disc(OpenDisc(fixture(name))), FlycastException); + EXPECT_TRUE(std::filesystem::is_empty(dataDir)); + } +} + +TEST_F(SevenZipTest, MetadataProbeDoesNotExtractMembers) +{ + for (const char *name : { "gdi-solid.7z", "cue-shared.7z", "chd.7z" }) + EXPECT_TRUE(is7zDisc(fixture(name))) << name; + for (const char *name : { "does-not-exist.7z", "truncated.7z", "no-disc.7z", + "multiple-discs.7z", "mixed-descriptors.7z", "traversal-entry.7z" }) + EXPECT_FALSE(is7zDisc(fixture(name))) << name; + EXPECT_TRUE(std::filesystem::is_empty(dataDir)); +} + +TEST_F(SevenZipTest, PreserveArcadePlatformDetection) +{ + EXPECT_EQ(DC_PLATFORM_DREAMCAST, getGamePlatform("synthetic-dreamcast-game.7z")); + EXPECT_EQ(DC_PLATFORM_DREAMCAST, getGamePlatform("synthetic-dreamcast-game.7Z")); + EXPECT_EQ(DC_PLATFORM_NAOMI, getGamePlatform("ikaruga.7z")); + EXPECT_EQ(DC_PLATFORM_ATOMISWAVE, getGamePlatform("claychal.7z")); + EXPECT_EQ(DC_PLATFORM_NAOMI2, getGamePlatform("vf4.7z")); + EXPECT_EQ(DC_PLATFORM_NAOMI, getGamePlatform("unknown-arcade-game.zip")); +}