diff --git a/Core/FileLoaders/PS1EbootFileLoader.cpp b/Core/FileLoaders/PS1EbootFileLoader.cpp index 36b22bae30..4c8c16443b 100644 --- a/Core/FileLoaders/PS1EbootFileLoader.cpp +++ b/Core/FileLoaders/PS1EbootFileLoader.cpp @@ -23,6 +23,8 @@ #include "libchdr/cdrom.h" #include "libchdr/chd.h" +#include "Common/Data/Text/I18n.h" +#include "Common/File/AndroidContentURI.h" #include "Common/File/DirListing.h" #include "Common/Log.h" #include "Common/StringUtils.h" @@ -152,9 +154,21 @@ bool IsPS1DiscSheet(FileLoader *fileLoader) { const std::string upper = AsciiToUpper(text); return upper.find("FILE") != std::string::npos && upper.find("TRACK") != std::string::npos; } + if (ext == ".m3u") { + // A playlist of disc images, rather than of music. + for (const std::string &name : PlaylistFileNames(text)) { + if (IsPS1DiscImageExtension(Path(name).GetFileExtension())) { + return true; + } + } + } return false; } +bool IsPS1DiscImageExtension(const std::string &ext) { + return ext == ".cue" || ext == ".ccd" || ext == ".mds" || ext == ".chd" || ext == ".iso" || ext == ".bin" || ext == ".img" || ext == ".mdf"; +} + std::vector CueSheetFileNames(const Path &cuePath) { std::string text; if (!ReadSmallTextFile(cuePath, &text)) { @@ -197,6 +211,19 @@ Path FindCueSheetFor(const Path &imagePath) { void RemoveDescribedDiscImages(std::vector *files) { std::set described; + // Discs a playlist lists, whatever their format, show up as the playlist. + std::set inPlaylist; + for (const File::FileInfo &info : *files) { + if (info.isDirectory || info.fullName.GetFileExtension() != ".m3u") { + continue; + } + std::string text; + if (ReadSmallTextFile(info.fullName, &text)) { + for (const std::string &name : PlaylistFileNames(text)) { + inPlaylist.insert(AsciiToLower(Path(name).GetFilename())); + } + } + } for (const File::FileInfo &info : *files) { const std::string ext = info.fullName.GetFileExtension(); if (info.isDirectory || (ext != ".cue" && ext != ".ccd")) { @@ -213,7 +240,13 @@ void RemoveDescribedDiscImages(std::vector *files) { } files->erase(std::remove_if(files->begin(), files->end(), [&](const File::FileInfo &info) { const std::string ext = info.fullName.GetFileExtension(); - if (info.isDirectory || (ext != ".bin" && ext != ".img")) { + if (info.isDirectory) { + return false; + } + if (IsPS1DiscImageExtension(ext) && inPlaylist.count(AsciiToLower(info.name)) != 0) { + return true; + } + if (ext != ".bin" && ext != ".img") { return false; } return info.size % SECTOR_SIZE != 0 || described.count(AsciiToLower(info.name)) != 0; @@ -296,7 +329,7 @@ bool FindPS1DiscFiles(const std::vector &entries, const std::funct return false; } -PS1EbootFileLoader::PS1EbootFileLoader(const Path &path) : path_(path) { +PS1Disc::PS1Disc(const Path &path) { const std::string ext = path.GetFileExtension(); std::string text; bool parsed = false; @@ -374,19 +407,17 @@ PS1EbootFileLoader::PS1EbootFileLoader(const Path &path) : path_(path) { } ReadDiscID(); - title_ = FileStem(path); - BuildHeaders(); INFO_LOG(Log::Loader, "PS1 disc %s: %s, %d tracks, %d sectors", path.c_str(), discID_.c_str(), (int)tracks_.size(), numSectors_); } -PS1EbootFileLoader::~PS1EbootFileLoader() { +PS1Disc::~PS1Disc() { // It reads through files_. if (chd_) { chd_close(chd_); } } -int PS1EbootFileLoader::AddFile(const Path &path) { +int PS1Disc::AddFile(const Path &path) { FileLoader *loader = ConstructFileLoader(path); if (!loader || !loader->Exists()) { delete loader; @@ -396,7 +427,7 @@ int PS1EbootFileLoader::AddFile(const Path &path) { return (int)files_.size() - 1; } -void PS1EbootFileLoader::AddExtent(u32 lba, u32 count, int file, s64 offset, int sectorSize, bool byteSwapped, int stride) { +void PS1Disc::AddExtent(u32 lba, u32 count, int file, s64 offset, int sectorSize, bool byteSwapped, int stride) { if (count != 0) { extents_.push_back(Extent{ lba, count, file, offset, sectorSize, byteSwapped, stride ? stride : sectorSize }); } @@ -406,7 +437,7 @@ void PS1EbootFileLoader::AddExtent(u32 lba, u32 count, int file, s64 offset, int // each with an extra block (pregap, length) and the image file's name. A track's length in sectors is // stored from its offset in the .mdf, from its pregap's start (but never before LBA 0), and anything // between tracks isn't stored. -bool PS1EbootFileLoader::ParseMds(const Path &mdsPath, const std::string &data) { +bool PS1Disc::ParseMds(const Path &mdsPath, const std::string &data) { const u8 *d = (const u8 *)data.data(); const size_t size = data.size(); auto read32 = [&](size_t offset) -> u32 { @@ -487,7 +518,7 @@ static int CHDTrackSectorSize(std::string_view type) { // A CD CHD's tracks are in its metadata, one entry each, and its frames (2352 bytes of sector and 96 of // subcode) are in hunks of several. Each track's frames are padded to a multiple of four, and a pregap is // in them only if its type starts with V. -bool PS1EbootFileLoader::OpenCHD(const Path &path) { +bool PS1Disc::OpenCHD(const Path &path) { const int file = AddFile(path); if (file < 0) { return false; @@ -558,7 +589,7 @@ bool IsCDChd(FileLoader *fileLoader) { return cd; } -bool PS1EbootFileLoader::ParseCue(const Path &cuePath, const std::string &text) { +bool PS1Disc::ParseCue(const Path &cuePath, const std::string &text) { struct CueTrack { int number; bool audio; @@ -688,7 +719,7 @@ bool PS1EbootFileLoader::ParseCue(const Path &cuePath, const std::string &text) return !tracks_.empty(); } -bool PS1EbootFileLoader::ParseCcd(const Path &ccdPath, const std::string &text) { +bool PS1Disc::ParseCcd(const Path &ccdPath, const std::string &text) { const int file = AddFile(ccdPath.WithReplacedExtension(".img")); if (file < 0) { error_ = "Missing " + ccdPath.WithReplacedExtension(".img").GetFilename(); @@ -741,7 +772,7 @@ bool PS1EbootFileLoader::ParseCcd(const Path &ccdPath, const std::string &text) return true; } -bool PS1EbootFileLoader::OpenBare(const Path &path) { +bool PS1Disc::OpenBare(const Path &path) { const int file = AddFile(path); if (file < 0) { return false; @@ -765,7 +796,7 @@ bool PS1EbootFileLoader::OpenBare(const Path &path) { return true; } -bool PS1EbootFileLoader::ReadRawSectors(u32 lba, u32 count, u8 *out) { +bool PS1Disc::ReadRawSectors(u32 lba, u32 count, u8 *out) { bool ok = true; while (count > 0) { auto it = std::upper_bound(extents_.begin(), extents_.end(), lba, [](u32 lba, const Extent &e) { return lba < e.lba; }); @@ -825,7 +856,7 @@ bool PS1EbootFileLoader::ReadRawSectors(u32 lba, u32 count, u8 *out) { } // One sector's stored bytes (e.sectorSize of them) from an extent's file or the CHD. -bool PS1EbootFileLoader::ReadSourceSector(const Extent &e, u32 index, u8 *out) { +bool PS1Disc::ReadSourceSector(const Extent &e, u32 index, u8 *out) { if (e.file >= 0) { return files_[e.file]->ReadAt(e.offset + (s64)index * e.stride, e.sectorSize, out) == (size_t)e.sectorSize; } @@ -851,7 +882,7 @@ bool PS1EbootFileLoader::ReadSourceSector(const Extent &e, u32 index, u8 *out) { return true; } -bool PS1EbootFileLoader::ReadUserData(u32 lba, u8 *out) { +bool PS1Disc::ReadUserData(u32 lba, u8 *out) { u8 sector[SECTOR_SIZE]; if (!ReadRawSectors(lba, 1, sector)) { return false; @@ -861,7 +892,7 @@ bool PS1EbootFileLoader::ReadUserData(u32 lba, u8 *out) { } // The ID the disc boots by, from SYSTEM.CNF's BOOT line ("cdrom:\SLUS_007.13;1" is SLUS00713). -void PS1EbootFileLoader::ReadDiscID() { +void PS1Disc::ReadDiscID() { discID_ = "SLUS00000"; u8 pvd[2048]; if (!ReadUserData(16, pvd) || memcmp(pvd, "\x01" "CD001", 6) != 0) { @@ -921,37 +952,7 @@ void PS1EbootFileLoader::ReadDiscID() { } } -void PS1EbootFileLoader::BuildHeaders() { - ParamSFOData sfo; - sfo.SetValue("BOOTABLE", 1, 4); - sfo.SetValue("CATEGORY", "ME", 4); - sfo.SetValue("DISC_ID", discID_, 16); - sfo.SetValue("DISC_VERSION", "1.00", 8); - sfo.SetValue("PARENTAL_LEVEL", 1, 4); - sfo.SetValue("PSP_SYSTEM_VER", "3.01", 8); - sfo.SetValue("REGION", 0x8000, 4); - sfo.SetValue("TITLE", title_, 128); - u8 *sfoData = nullptr; - size_t sfoSize = 0; - sfo.WriteSFO(&sfoData, &sfoSize); - - // PARAM.SFO, then no icons, pictures, sound or DATA.PSP. - const u32 sfoEnd = 0x28 + (u32)sfoSize; - psarOffset_ = (sfoEnd + 0xFFFF) & ~0xFFFF; - head_.assign(psarOffset_ + PSAR_HEADER_SIZE, 0); - u8 *pbp = head_.data(); - memcpy(pbp, "\0PBP", 4); - u32_le *words = (u32_le *)pbp; - words[1] = 0x00010000; - words[2] = 0x28; - for (int i = 3; i < 9; i++) { - words[i] = sfoEnd; - } - words[9] = psarOffset_; - memcpy(pbp + 0x28, sfoData, sfoSize); - delete[] sfoData; - - u8 *psar = pbp + psarOffset_; +void PS1Disc::BuildHeader(const std::string &title, u8 *psar) const { auto write32 = [&](u32 offset, u32 value) { *(u32_le *)(psar + offset) = value; }; @@ -987,10 +988,191 @@ void PS1EbootFileLoader::BuildHeaders() { write32(0xBFC, PSAR_DATA_OFFSET); write32(0x1220, end + 0x2D31); write32(0x1228, 0x7FF); - memcpy(psar + 0x122C, title_.data(), std::min(title_.size(), (size_t)0x7F)); + memcpy(psar + 0x122C, title.data(), std::min(title.size(), (size_t)0x7F)); write32(0x12AC, 3); +} - size_ = (s64)psarOffset_ + end; +void PS1Disc::Cancel() { + for (auto &file : files_) { + file->Cancel(); + } +} + +std::vector PlaylistFileNames(std::string_view text) { + std::vector names; + std::vector lines; + SplitString(text, '\n', lines); + for (std::string_view line : lines) { + line = StripSpaces(line); + if (!line.empty() && line[0] != '#') { + names.emplace_back(line); + } + } + return names; +} + +// The most discs a PSTITLEIMG lists. +static const int MAX_DISCS = 5; +// A multi-disc PSAR: the PSTITLEIMG header, then each disc's PSISOIMG on a boundary of this. +static const u32 DISC_ALIGN = 0x8000; +static const u32 TITLE_HEADER_SIZE = 0x400; + +// Whether reading this image takes other files than itself, which an app given only that one file +// (an Android document, rather than a folder) can't open. +static bool NeedsOtherFiles(const std::string &ext) { + return ext == ".cue" || ext == ".ccd" || ext == ".mds" || ext == ".m3u"; +} + +PS1EbootFileLoader::PS1EbootFileLoader(const Path &path) : path_(path) { + const std::string ext = path.GetFileExtension(); + if (path.Type() == PathType::CONTENT_URI && NeedsOtherFiles(ext) && !AndroidContentURI(path.ToString()).IsTreeURI()) { + auto er = GetI18NCategory(I18NCat::ERRORS); + error_ = er->T("PS1NeedsFolderAccess", "This game is made of several files, and PPSSPP was only given access to one of them. Add the folder it's in with Browse... on the Games tab, and start it from there."); + return; + } + + std::vector discPaths; + if (ext == ".m3u") { + std::string text; + if (!ReadSmallTextFile(path, &text)) { + error_ = "Can't read " + path.GetFilename(); + return; + } + for (const std::string &name : PlaylistFileNames(text)) { + const Path absolute(name); + discPaths.push_back(absolute.IsAbsolute() ? absolute : path.NavigateUp() / name); + } + if (discPaths.empty() || discPaths.size() > MAX_DISCS) { + error_ = StringFromFormat("A playlist lists 1 to %d discs: %s", MAX_DISCS, path.GetFilename().c_str()); + return; + } + } else { + discPaths.push_back(path); + } + for (const Path &discPath : discPaths) { + discs_.emplace_back(new PS1Disc(discPath)); + if (!discs_.back()->IsValid()) { + error_ = discs_.back()->Error(); + return; + } + } + title_ = FileStem(path); + BuildHeaders(); +} + +PS1EbootFileLoader::~PS1EbootFileLoader() {} + +void PS1EbootFileLoader::BuildHeaders() { + ParamSFOData sfo; + sfo.SetValue("BOOTABLE", 1, 4); + sfo.SetValue("CATEGORY", "ME", 4); + sfo.SetValue("DISC_ID", DiscID(), 16); + sfo.SetValue("DISC_VERSION", "1.00", 8); + sfo.SetValue("PARENTAL_LEVEL", 1, 4); + sfo.SetValue("PSP_SYSTEM_VER", "3.01", 8); + sfo.SetValue("REGION", 0x8000, 4); + sfo.SetValue("TITLE", title_, 128); + u8 *sfoData = nullptr; + size_t sfoSize = 0; + sfo.WriteSFO(&sfoData, &sfoSize); + + // PARAM.SFO, then no icons, pictures, sound or DATA.PSP. + const u32 sfoEnd = 0x28 + (u32)sfoSize; + psarOffset_ = (sfoEnd + 0xFFFF) & ~0xFFFF; + const bool multiDisc = discs_.size() > 1; + head_.assign(psarOffset_ + (multiDisc ? TITLE_HEADER_SIZE : 0), 0); + u8 *pbp = head_.data(); + memcpy(pbp, "\0PBP", 4); + u32_le *words = (u32_le *)pbp; + words[1] = 0x00010000; + words[2] = 0x28; + for (int i = 3; i < 9; i++) { + words[i] = sfoEnd; + } + words[9] = psarOffset_; + memcpy(pbp + 0x28, sfoData, sfoSize); + delete[] sfoData; + + // One disc's PSISOIMG starts the PSAR. With several, a PSTITLEIMG header lists them. + s64 offset = multiDisc ? DISC_ALIGN : 0; + s64 end = 0; + for (const auto &disc : discs_) { + discHeaders_.emplace_back(PSAR_HEADER_SIZE, 0); + disc->BuildHeader(title_, discHeaders_.back().data()); + discOffsets_.push_back(offset); + end = offset + PSAR_DATA_OFFSET + (s64)disc->NumBlocks() * BLOCK_SIZE; + offset = (end + DISC_ALIGN - 1) & ~(s64)(DISC_ALIGN - 1); + } + size_ = psarOffset_ + end; + + if (multiDisc) { + // The fields the emulator reads (pops +1B2F0 in 6.60): the disc offsets, the first disc's ID, and the + // title. The end of the last disc and its +0x2D31 are where a STARTDAT would follow, as in a PSISOIMG. + // The MACs a store game's header has, and the key its disc switching strings are descrambled with, + // stay zero: PopsBoot sees to both. + u8 *title = pbp + psarOffset_; + memcpy(title, "PSTITLEIMG000000", 16); + *(u32_le *)(title + 0x10) = (u32)end; + for (size_t i = 0; i < discs_.size(); i++) { + *(u32_le *)(title + 0x200 + 4 * i) = (u32)discOffsets_[i]; + } + memcpy(title + 0x264, discHeaders_[0].data() + 0x400, 0x20); + *(u32_le *)(title + 0x284) = (u32)end + 0x2D31; + memcpy(title + 0x30C, title_.data(), std::min(title_.size(), (size_t)0x7F)); + } + + for (size_t i = 0; i < discs_.size(); i++) { + INFO_LOG(Log::Loader, "PS1 EBOOT %s: disc %d (%s) at PSAR+%llx", path_.c_str(), (int)i + 1, discs_[i]->DiscID().c_str(), (long long)discOffsets_[i]); + } +} + +// Bytes from disc's PSISOIMG, at pos in it: the header, then the block index, then the blocks. +size_t PS1EbootFileLoader::ReadDisc(int disc, s64 pos, size_t bytes, u8 *out) { + const std::vector &header = discHeaders_[disc]; + const u32 numBlocks = discs_[disc]->NumBlocks(); + const s64 indexStart = (s64)header.size(); + if (pos < indexStart) { + const size_t n = std::min(bytes, (size_t)(indexStart - pos)); + memcpy(out, header.data() + pos, n); + return n; + } + if (pos < PSAR_DATA_OFFSET) { + const u32 entry = (u32)((pos - indexStart) / PSAR_INDEX_ENTRY_SIZE); + const u32 within = (u32)((pos - indexStart) % PSAR_INDEX_ENTRY_SIZE); + u8 buf[PSAR_INDEX_ENTRY_SIZE]{}; + if (entry < numBlocks) { + *(u32_le *)buf = entry * BLOCK_SIZE; + *(u16_le *)(buf + 4) = (u16)BLOCK_SIZE; + } + const size_t n = std::min(bytes, (size_t)(PSAR_INDEX_ENTRY_SIZE - within)); + memcpy(out, buf + within, n); + return n; + } + const u32 block = (u32)((pos - PSAR_DATA_OFFSET) / BLOCK_SIZE); + const u32 within = (u32)((pos - PSAR_DATA_OFFSET) % BLOCK_SIZE); + if (block >= numBlocks) { + // Padding before the next disc. + memset(out, 0, bytes); + return bytes; + } + if (within < sizeof(STORED_HEADER)) { + const size_t n = std::min(bytes, sizeof(STORED_HEADER) - within); + memcpy(out, STORED_HEADER + within, n); + return n; + } + const u32 offset = within - sizeof(STORED_HEADER); + const size_t n = std::min(bytes, (size_t)(BLOCK_DATA_SIZE - offset)); + const u32 lba = block * BLOCK_SECTORS + offset / SECTOR_SIZE; + if (offset % SECTOR_SIZE == 0 && n % SECTOR_SIZE == 0) { + discs_[disc]->ReadRawSectors(lba, (u32)(n / SECTOR_SIZE), out); + } else { + const u32 first = offset % SECTOR_SIZE; + const u32 sectors = (u32)((first + n + SECTOR_SIZE - 1) / SECTOR_SIZE); + std::vector buf((size_t)sectors * SECTOR_SIZE); + discs_[disc]->ReadRawSectors(lba, sectors, buf.data()); + memcpy(out, buf.data() + first, n); + } + return n; } size_t PS1EbootFileLoader::ReadAt(s64 absolutePos, size_t bytes, size_t count, void *data, Flags flags) { @@ -1001,42 +1183,25 @@ size_t PS1EbootFileLoader::ReadAt(s64 absolutePos, size_t bytes, size_t count, v size_t remaining = (size_t)std::min((s64)(bytes * count), std::max(size_ - absolutePos, (s64)0)); const size_t total = remaining; s64 pos = absolutePos; - const s64 indexStart = (s64)head_.size(); - const s64 dataStart = (s64)psarOffset_ + PSAR_DATA_OFFSET; while (remaining > 0) { size_t n; - if (pos < indexStart) { - n = std::min(remaining, (size_t)(indexStart - pos)); + if (pos < (s64)head_.size()) { + n = std::min(remaining, (size_t)((s64)head_.size() - pos)); memcpy(out, head_.data() + pos, n); - } else if (pos < dataStart) { - const u32 entry = (u32)((pos - indexStart) / PSAR_INDEX_ENTRY_SIZE); - const u32 within = (u32)((pos - indexStart) % PSAR_INDEX_ENTRY_SIZE); - u8 buf[PSAR_INDEX_ENTRY_SIZE]{}; - if (entry < numBlocks_) { - *(u32_le *)buf = entry * BLOCK_SIZE; - *(u16_le *)(buf + 4) = (u16)BLOCK_SIZE; - } - n = std::min(remaining, (size_t)(PSAR_INDEX_ENTRY_SIZE - within)); - memcpy(out, buf + within, n); } else { - const u32 block = (u32)((pos - dataStart) / BLOCK_SIZE); - const u32 within = (u32)((pos - dataStart) % BLOCK_SIZE); - if (within < sizeof(STORED_HEADER)) { - n = std::min(remaining, sizeof(STORED_HEADER) - within); - memcpy(out, STORED_HEADER + within, n); + // The disc this falls in, or the zeroes before the first one. + const s64 psarPos = pos - psarOffset_; + int disc = (int)discOffsets_.size() - 1; + while (disc >= 0 && psarPos < discOffsets_[disc]) { + disc--; + } + if (disc < 0) { + n = std::min(remaining, (size_t)(discOffsets_[0] - psarPos)); + memset(out, 0, n); } else { - const u32 offset = within - sizeof(STORED_HEADER); - n = std::min(remaining, (size_t)(BLOCK_DATA_SIZE - offset)); - const u32 lba = block * BLOCK_SECTORS + offset / SECTOR_SIZE; - if (offset % SECTOR_SIZE == 0 && n % SECTOR_SIZE == 0) { - ReadRawSectors(lba, (u32)(n / SECTOR_SIZE), out); - } else { - const u32 first = offset % SECTOR_SIZE; - const u32 sectors = (u32)((first + n + SECTOR_SIZE - 1) / SECTOR_SIZE); - std::vector buf((size_t)sectors * SECTOR_SIZE); - ReadRawSectors(lba, sectors, buf.data()); - memcpy(out, buf.data() + first, n); - } + const s64 discEnd = disc + 1 < (int)discOffsets_.size() ? discOffsets_[disc + 1] : size_ - psarOffset_; + const size_t limit = std::min(remaining, (size_t)(discEnd - psarPos)); + n = ReadDisc(disc, psarPos - discOffsets_[disc], limit, out); } } out += n; @@ -1047,7 +1212,7 @@ size_t PS1EbootFileLoader::ReadAt(s64 absolutePos, size_t bytes, size_t count, v } void PS1EbootFileLoader::Cancel() { - for (auto &file : files_) { - file->Cancel(); + for (auto &disc : discs_) { + disc->Cancel(); } } diff --git a/Core/FileLoaders/PS1EbootFileLoader.h b/Core/FileLoaders/PS1EbootFileLoader.h index df2d71b015..cb380704e5 100644 --- a/Core/FileLoaders/PS1EbootFileLoader.h +++ b/Core/FileLoaders/PS1EbootFileLoader.h @@ -26,29 +26,22 @@ #include "Common/CommonTypes.h" #include "Core/Loaders.h" -// A PS1 disc image (a cue sheet, a CloneCD .ccd, an Alcohol .mds, a CHD, or a bare .bin/.img/.iso) presented as a converted -// PS1 EBOOT.PBP, the kind popstation makes, for the firmware's POPS to run. Only the headers are kept -// in memory. The disc image is stored uncompressed, as stored DEFLATE blocks, so a read of it goes -// straight to the image's files. See docs/POPS.md. -class PS1EbootFileLoader : public FileLoader { +// One PS1 disc image (a cue sheet, a CloneCD .ccd, an Alcohol .mds, a CHD, or a bare .bin/.img/.iso): +// its tracks, and its sectors as raw 2352-byte ones, however the image stores them. +class PS1Disc { public: - explicit PS1EbootFileLoader(const Path &path); - ~PS1EbootFileLoader(); + explicit PS1Disc(const Path &path); + ~PS1Disc(); bool IsValid() const { return error_.empty(); } - - bool Exists() override { return IsValid(); } - bool IsDirectory() override { return false; } - s64 FileSize() override { return size_; } - Path GetPath() const override { return path_; } - std::string GetFileExtension() const override { return ".pbp"; } - size_t ReadAt(s64 absolutePos, size_t bytes, size_t count, void *data, Flags flags = Flags::NONE) override; - using FileLoader::ReadAt; - void Cancel() override; - std::string LatestError() const override { return error_; } - + const std::string &Error() const { return error_; } const std::string &DiscID() const { return discID_; } - const std::string &Title() const { return title_; } + u32 NumBlocks() const { return numBlocks_; } + + // Its PSISOIMG header, up to the block index (PSAR_HEADER_SIZE bytes). + void BuildHeader(const std::string &title, u8 *psisoimg) const; + bool ReadRawSectors(u32 lba, u32 count, u8 *out); + void Cancel(); private: struct Track { @@ -79,12 +72,9 @@ private: void AddExtent(u32 lba, u32 count, int file, s64 offset, int sectorSize, bool byteSwapped = false, int stride = 0); bool ReadSourceSector(const Extent &e, u32 index, u8 *out); - bool ReadRawSectors(u32 lba, u32 count, u8 *out); bool ReadUserData(u32 lba, u8 *out); void ReadDiscID(); - void BuildHeaders(); - Path path_; std::string error_; std::vector> files_; std::vector extents_; @@ -99,22 +89,65 @@ private: u32 framesPerHunk_ = 0; std::string discID_; - std::string title_; - - // The PBP up to the PSAR's block index, then the index and the blocks, which are made on the fly. - std::vector head_; - u32 psarOffset_ = 0; u32 numBlocks_ = 0; +}; + +// One or more PS1 discs presented as a converted PS1 EBOOT.PBP, the kind popstation makes, for the +// firmware's POPS to run: one disc image, or an .m3u playlist of up to five, which become a multi-disc +// EBOOT. Only the headers are kept in memory. The disc images are stored uncompressed, as stored DEFLATE +// blocks, so a read of one goes straight to its files. See docs/POPS.md. +class PS1EbootFileLoader : public FileLoader { +public: + explicit PS1EbootFileLoader(const Path &path); + ~PS1EbootFileLoader(); + + bool IsValid() const { return error_.empty(); } + + bool Exists() override { return IsValid(); } + bool IsDirectory() override { return false; } + s64 FileSize() override { return size_; } + Path GetPath() const override { return path_; } + std::string GetFileExtension() const override { return ".pbp"; } + size_t ReadAt(s64 absolutePos, size_t bytes, size_t count, void *data, Flags flags = Flags::NONE) override; + using FileLoader::ReadAt; + void Cancel() override; + std::string LatestError() const override { return error_; } + + const std::string &DiscID() const { return discs_[0]->DiscID(); } + const std::string &Title() const { return title_; } + int NumDiscs() const { return (int)discs_.size(); } + +private: + void BuildHeaders(); + size_t ReadDisc(int disc, s64 pos, size_t bytes, u8 *out); + + Path path_; + std::string error_; + std::string title_; + std::vector> discs_; + + // The PBP up to the PSAR, then (with several discs) the PSTITLEIMG header. Each disc's PSISOIMG + // header follows at its offset in the PSAR, then its block index and blocks, made on the fly. + std::vector head_; + std::vector> discHeaders_; + std::vector discOffsets_; + u32 psarOffset_ = 0; s64 size_ = 0; }; +// The disc images an .m3u playlist lists, as written. +std::vector PlaylistFileNames(std::string_view text); + namespace File { struct FileInfo; } -// Whether a .cue, .ccd or .mds describes a disc, by its contents. +// Whether a .cue, .ccd or .mds describes a disc, or an .m3u lists disc images, by its contents. bool IsPS1DiscSheet(FileLoader *fileLoader); +// Whether a file with this extension can be (part of) a PS1 disc image. +bool IsPS1DiscImageExtension(const std::string &ext); + // Whether a CHD is of a CD (rather than a DVD, like a PSP disc). bool IsCDChd(FileLoader *fileLoader); @@ -137,7 +170,7 @@ bool FindPS1DiscFiles(const std::vector &entries, const std::funct // The cue sheet next to a disc image whose FILE lines name it, or an empty path. Path FindCueSheetFor(const Path &imagePath); -// Removes the .bin and .img files from a directory listing that a cue sheet or .ccd in it describes, so -// that each game shows up once, and those that can't be a raw disc image (a size that isn't a whole -// number of sectors). +// Removes the disc images from a directory listing that a playlist in it lists, and the .bin and .img +// files that a cue sheet or .ccd in it describes, so that each game shows up once, and those that can't be +// a raw disc image (a size that isn't a whole number of sectors). void RemoveDescribedDiscImages(std::vector *files); diff --git a/Core/HLE/PopsBoot.cpp b/Core/HLE/PopsBoot.cpp index 1a606d542a..9d7e8ded88 100644 --- a/Core/HLE/PopsBoot.cpp +++ b/Core/HLE/PopsBoot.cpp @@ -20,6 +20,7 @@ // Media Engine the manager's ME loop runs on. See docs/POPS.md. #include +#include #include #include @@ -27,6 +28,7 @@ #include "ext/libkirk/amctrl.h" #include "Common/Serialize/Serializer.h" +#include "Common/Data/Encoding/Utf8.h" #include "Common/Data/Text/I18n.h" #include "Common/Serialize/SerializeFuncs.h" #include "Core/CoreTiming.h" @@ -34,6 +36,7 @@ #include "Core/ELF/PBPReader.h" #include "Core/ELF/PrxDecrypter.h" #include "Core/FileSystems/MetaFileSystem.h" +#include "Core/Font/PGF.h" #include "Core/HLE/FunctionWrappers.h" #include "Core/HLE/HLE.h" #include "Core/HLE/HLEHelperThread.h" @@ -418,6 +421,294 @@ static bool SetsBlockSizeBefore(u32 addr, u32 textAddr) { return false; } +// The emulator's multi-disc strings, 14 for each of its 12 languages, as {length, UTF-16 pointer}: the HOME +// menu's item, then three dialogs of a line each. A store game's PSTITLEIMG header has the key they're +// descrambled with when a game has several discs (pops +29xxx in 6.60). A converted one has none, so the +// descrambling is skipped and they're ours, from our translations. Each dialog's lines are left-aligned and +// centred as a block. Its disc number is drawn over a gap in a line, at a position (one each for the +// switch dialog, the disc picker's label and the confirmation) from a table of its own. +enum { + DISC_MENU_ITEM = 0, + DISC_SWITCH = 1, // The current disc, then 2-3. + DISC_LABEL = 4, + DISC_CONFIRM = 5, // The disc it switches to, then 6-9. + DISC_RESET = 10, // 10-13, the current disc on 11. + DISC_STRING_COUNT = 14, + POPS_LANGUAGES = 12, +}; + +// The dialog text is ltn0.pgf at this scale, and the label's at the second. The number positions are for +// the firmware's English layout (the most lines each dialog has, the number on the first line of the switch +// dialog and the confirmation and the second of the reset dialog), and are where it goes for it: measured +// against the firmware's strings and positions (in each language that ltn0 has the glyphs for) and a +// screenshot. The reset dialog has no position of its own: its number goes this far from the confirmation's. +static const float DIALOG_TEXT_SCALE = 0.685f; +static const float LABEL_TEXT_SCALE = 0.61f; +static const float DIALOG_CENTER_X = 240.0f; +static const float NUMBER_OFFSET_X = 4.0f; +static const float LABEL_CENTER_X = 246.5f; +static const float RESET_NUMBER_OFFSET_X = 31.0f; +static const float MAX_LINE_WIDTH = 420.0f; +static const int SWITCH_LINES = 2, SWITCH_NUMBER_Y = 95; +static const int CONFIRM_LINES = 4, CONFIRM_NUMBER_Y = 88; +static const int RESET_LINES = 4; +static const int LABEL_NUMBER_Y = 160; + +// The width of text in ltn0.pgf's units, or -1 if a glyph is missing. +static float TextWidth(const PGF &font, const std::u16string &text) { + float width = 0.0f; + for (char16_t c : text) { + PGFCharInfo info{}; + if (!font.GetCharInfo(c, &info, -1)) { + return -1.0f; + } + width += info.sfp26AdvanceH / 64.0f; + } + return width; +} + +// Words into lines no wider than maxWidth pixels, or none if a word is wider or a glyph is missing. +static std::vector WrapText(const PGF &font, const std::u16string &text, float maxWidth) { + std::vector lines; + std::u16string line; + size_t pos = 0; + while (pos < text.size()) { + size_t end = text.find(u' ', pos); + if (end == std::u16string::npos) { + end = text.size(); + } + const std::u16string word = text.substr(pos, end - pos); + const std::u16string candidate = line.empty() ? word : line + u" " + word; + const float width = TextWidth(font, candidate); + if (width < 0.0f) { + return {}; + } + if (width * DIALOG_TEXT_SCALE <= maxWidth) { + line = candidate; + } else if (line.empty()) { + return {}; + } else { + lines.push_back(line); + line = word; + } + pos = end + 1; + } + if (!line.empty()) { + lines.push_back(line); + } + return lines; +} + +// Words into exactly count lines that fit the dialog, narrowing them until they split that way. The +// dialogs are centred vertically by their number of lines, which the number's position is for. +static std::vector WrapToLines(const PGF &font, const std::u16string &text, int count) { + for (float width = MAX_LINE_WIDTH; width >= 40.0f; width -= 4.0f) { + std::vector lines = WrapText(font, text, width); + if ((int)lines.size() == count) { + return lines; + } + if (lines.empty() || (int)lines.size() > count) { + break; + } + } + return {}; +} + +// A line with the disc number: %1 is a gap for it. Its text before the gap goes in prefix. False if it +// has no %1, or glyphs the font hasn't. +static bool NumberLine(const PGF &font, std::string_view text, std::u16string *line, std::u16string *prefix) { + const size_t gap = text.find("%1"); + if (gap == std::string_view::npos) { + return false; + } + *prefix = ConvertUTF8ToUCS2(text.substr(0, gap)); + *line = *prefix + u" " + ConvertUTF8ToUCS2(text.substr(gap + 2)); + return TextWidth(font, *line) >= 0.0f && TextWidth(font, *line) * DIALOG_TEXT_SCALE <= MAX_LINE_WIDTH; +} + +struct DiscSwitchText { + std::u16string strings[DISC_STRING_COUNT]; + s16 positions[3][2]; +}; + +// The strings and number positions for one wording of them. +static bool LayOutDiscSwitchText(const PGF &font, const std::function &T, DiscSwitchText *out) { + std::u16string *s = out->strings; + s[DISC_MENU_ITEM] = ConvertUTF8ToUCS2(T("Switch Discs")); + if (TextWidth(font, s[DISC_MENU_ITEM]) < 0.0f) { + return false; + } + + // Where the number goes in a dialog's line with the gap, in pixels. + auto gapX = [&](const std::u16string *lines, int count, const std::u16string &prefix) { + float widest = 0.0f; + for (int i = 0; i < count; i++) { + widest = std::max(widest, TextWidth(font, lines[i])); + } + return DIALOG_CENTER_X - DIALOG_TEXT_SCALE * widest / 2 + DIALOG_TEXT_SCALE * TextWidth(font, prefix) + NUMBER_OFFSET_X; + }; + // A dialog: optionally a line, then the line with the gap, then text wrapped to the rest of its lines. + auto dialog = [&](int first, const char *lineBefore, const char *numberText, const char *restText, int lines, std::u16string *prefix) { + std::u16string line; + if (!NumberLine(font, T(numberText), &line, prefix)) { + return false; + } + std::vector rest = WrapToLines(font, ConvertUTF8ToUCS2(T(restText)), lines - (lineBefore ? 2 : 1)); + if (rest.empty()) { + return false; + } + int i = first; + if (lineBefore) { + s[i++] = ConvertUTF8ToUCS2(T(lineBefore)); + } + s[i++] = line; + for (const std::u16string &r : rest) { + s[i++] = r; + } + return TextWidth(font, s[first]) >= 0.0f; + }; + + std::u16string prefix; + if (!dialog(DISC_SWITCH, nullptr, "The current disc is Disc %1.", "Select a disc to switch to.", SWITCH_LINES, &prefix)) { + return false; + } + out->positions[0][0] = (s16)gapX(&s[DISC_SWITCH], SWITCH_LINES, prefix); + out->positions[0][1] = SWITCH_NUMBER_Y; + + if (!NumberLine(font, T("Disc %1"), &s[DISC_LABEL], &prefix)) { + return false; + } + out->positions[1][0] = (s16)(LABEL_CENTER_X + LABEL_TEXT_SCALE * (TextWidth(font, prefix) - TextWidth(font, s[DISC_LABEL]) / 2)); + out->positions[1][1] = LABEL_NUMBER_Y; + + std::u16string confirmPrefix, resetPrefix; + if (!dialog(DISC_CONFIRM, nullptr, "This will switch to Disc %1.", "If the game didn't ask for another disc, it might not be able to continue. Switch discs anyway?", CONFIRM_LINES, &confirmPrefix) || + !dialog(DISC_RESET, "The game will be reset.", "The current disc is Disc %1.", "Select the disc to start after the reset.", RESET_LINES, &resetPrefix)) { + return false; + } + // The reset dialog's number goes where the confirmation's does, moved: pad the start of whichever of the two + // lines is short of that with spaces, to put their gaps as close to it as they go. + const std::u16string confirmLine = s[DISC_CONFIRM], resetLine = s[DISC_RESET + 1]; + float best = 1e9f; + for (int confirmPad = 0; confirmPad < 24; confirmPad++) { + for (int resetPad = 0; resetPad < 24; resetPad++) { + if (confirmPad && resetPad) { + break; + } + std::u16string c[CONFIRM_LINES], r[RESET_LINES]; + std::copy(s + DISC_CONFIRM, s + DISC_CONFIRM + CONFIRM_LINES, c); + std::copy(s + DISC_RESET, s + DISC_RESET + RESET_LINES, r); + c[0] = std::u16string(confirmPad, u' ') + confirmLine; + r[1] = std::u16string(resetPad, u' ') + resetLine; + if (TextWidth(font, c[0]) * DIALOG_TEXT_SCALE > MAX_LINE_WIDTH || TextWidth(font, r[1]) * DIALOG_TEXT_SCALE > MAX_LINE_WIDTH) { + continue; + } + const float confirmX = gapX(c, CONFIRM_LINES, std::u16string(confirmPad, u' ') + confirmPrefix); + const float resetX = gapX(r, RESET_LINES, std::u16string(resetPad, u' ') + resetPrefix); + const float error = fabsf(resetX - confirmX - RESET_NUMBER_OFFSET_X); + if (error < best) { + best = error; + out->positions[2][0] = (s16)confirmX; + s[DISC_CONFIRM] = c[0]; + s[DISC_RESET + 1] = r[1]; + } + } + } + out->positions[2][1] = CONFIRM_NUMBER_Y; + return best < 3.0f; +} + +// Puts our strings and their number positions in the emulator's tables, in every language's row. +static bool InstallDiscSwitchText(u32 stringTable, u32 positionTable) { + std::vector fontData; + PGF font; + if (pspFileSystem.ReadEntireFile("flash0:/font/ltn0.pgf", fontData, true) < 0 || !font.ReadPtr(fontData.data(), fontData.size())) { + WARN_LOG(Log::sceKernel, "POPS: no flash0:/font/ltn0.pgf to lay out the disc switching text with"); + return false; + } + auto sy = GetI18NCategory(I18NCat::SYSTEM); + DiscSwitchText text; + if (!LayOutDiscSwitchText(font, [&](const char *key) { return sy->T(key); }, &text)) { + // The translation doesn't fit, or needs glyphs ltn0 hasn't: the English text does. + if (!LayOutDiscSwitchText(font, [](const char *key) { return std::string_view(key); }, &text)) { + return false; + } + } + + u32 size = 0; + for (const std::u16string &s : text.strings) { + size += ((u32)s.size() * 2 + 3) & ~3; + } + u32 alloc = std::max(size, 4U); + const u32 strings = kernelMemory.Alloc(alloc, false, "PopsDiscText"); + if (strings == (u32)-1) { + return false; + } + u32 ptr = strings; + u32 entries[DISC_STRING_COUNT][2]; + for (int i = 0; i < DISC_STRING_COUNT; i++) { + const std::u16string &s = text.strings[i]; + for (size_t c = 0; c < s.size(); c++) { + Memory::WriteUnchecked_U16(s[c], ptr + (u32)c * 2); + } + entries[i][0] = (u32)s.size(); + entries[i][1] = s.empty() ? 0 : ptr; + ptr += ((u32)s.size() * 2 + 3) & ~3; + } + for (int lang = 0; lang < POPS_LANGUAGES; lang++) { + for (int i = 0; i < DISC_STRING_COUNT; i++) { + Memory::WriteUnchecked_U32(entries[i][0], stringTable + (lang * DISC_STRING_COUNT + i) * 8); + Memory::WriteUnchecked_U32(entries[i][1], stringTable + (lang * DISC_STRING_COUNT + i) * 8 + 4); + } + for (int i = 0; i < 3; i++) { + Memory::WriteUnchecked_U16(text.positions[i][0], positionTable + (lang * 3 + i) * 4); + Memory::WriteUnchecked_U16(text.positions[i][1], positionTable + (lang * 3 + i) * 4 + 2); + } + } + NotifyMemInfo(MemBlockFlags::WRITE, strings, size, "PopsDiscText"); + INFO_LOG(Log::sceKernel, "POPS: disc numbers at %d,%d %d,%d %d,%d", text.positions[0][0], text.positions[0][1], text.positions[1][0], text.positions[1][1], text.positions[2][0], text.positions[2][1]); + return true; +} + +// The address a lui at addr and an addiu after it make. +static u32 LuiAddiuAddress(u32 addr) { + return (Memory::ReadUnchecked_U32(addr) << 16) + (s16)(Memory::ReadUnchecked_U32(addr + 4) & 0xFFFF); +} + +// Finds the multi-disc strings' code in the emulator, and skips their descrambling for ours. +static void PatchDiscSwitchText(u32 textAddr, u32 textSize) { + u32 stringTable = 0, positionTable = 0, decode = 0, decodeEnd = 0; + for (u32 addr = textAddr; addr + 0x20 < textAddr + textSize; addr += 4) { + auto at = [&](u32 offset) { return Memory::ReadUnchecked_U32(addr + offset); }; + // The language's row: lang * 0x70 + table, into s7. + if (at(0) == 0x000219C0 && at(4) == 0x00021100 && at(8) == 0x00621823 && (at(12) >> 16) == 0x3C02 && (at(16) >> 16) == 0x2442 && at(20) == 0x0062B821) { + stringTable = LuiAddiuAddress(addr + 12); + } + // beq s7, zero, past the text; li s5, 0: then the descrambling, until bne t1, s0; addiu a1, a1, 8. + if (stringTable && !decode && (at(0) >> 16) == 0x12E0 && at(4) == 0x0000A821) { + decode = addr + 8; + } + if (decode && !decodeEnd && addr > decode && (at(0) >> 16) == 0x1530 && at(4) == 0x24A50008) { + decodeEnd = addr + 8; + } + // The number position, (lang * 3 + i) * 4 + table. + if (at(0) == 0x00031040 && at(4) == 0x00431021 && at(8) == 0x00501021 && (at(12) >> 16) == 0x3C03 && (at(16) >> 16) == 0x2463 && at(20) == 0x00021080) { + positionTable = LuiAddiuAddress(addr + 12); + } + } + if (!stringTable || !decode || !decodeEnd || !positionTable || decodeEnd - decode > 0x400) { + WARN_LOG(Log::sceKernel, "POPS: didn't find the disc switching text (%08x %08x-%08x %08x)", stringTable, decode, decodeEnd, positionTable); + return; + } + if (!InstallDiscSwitchText(stringTable, positionTable)) { + return; + } + Memory::WriteUnchecked_U32(0x10000000 | (((decodeEnd - (decode + 4)) >> 2) & 0xFFFF), decode); // b decodeEnd + Memory::WriteUnchecked_U32(MIPS_MAKE_NOP(), decode + 4); + currentMIPS->InvalidateICacheRangeDeferred(decode, 8); + INFO_LOG(Log::sceKernel, "POPS: disc switching text at %08x, numbers at %08x", stringTable, positionTable); +} + void __PopsBootPatchEmulator(u32 textAddr, u32 textSize, u32 decodeStub) { // As CFW's popcorn does: the manual's name check always passes, and an ICON0 the emulator can't // use reads as a blank one. @@ -439,6 +730,7 @@ void __PopsBootPatchEmulator(u32 textAddr, u32 textSize, u32 decodeStub) { if (!g_popsConvertedPBP) { return; } + PatchDiscSwitchText(textAddr, textSize); if (!decodeStub) { ERROR_LOG(Log::sceKernel, "POPS: the emulator doesn't import scePopsMan_0090B2C8, the disc won't read"); return; @@ -524,6 +816,16 @@ bool __PopsBootStartInit(FileLoader *pbp, const std::string &gamePath, std::stri // A converted PBP has no MACs. As popcorn does, take its data as authentic. PatchReturn(base + PopsMan().checkMac, 0x24020001); // li v0, 1 PatchReturn(base + PopsMan().checkMacExport, 0x24020001); + // With several discs, the disc header check (sceMeAudio_14447BA0) also compares the header with a + // MAC in the PSTITLEIMG header, by calling sceDrmBBMacFinal2 itself. That passes too. + const u32 headerCheck = KernelFindModuleExport("sceMeAudio", 0x14447BA0); + const u32 macFinal2 = KernelFindModuleImport("scePops_Manager", "sceAmctrl_driver", 0xF5186D8E); + for (u32 addr = headerCheck; headerCheck && macFinal2 && addr < headerCheck + 0x400; addr += 4) { + if (Memory::ReadUnchecked_U32(addr) == MIPS_MAKE_JAL(macFinal2)) { + Memory::WriteUnchecked_U32(0x24020000, addr); // li v0, 0 + currentMIPS->InvalidateICacheRangeDeferred(addr, 4); + } + } if (PopsMan().licenseCall != 0) { // It has no license, so the lookup can only fail. Memory::WriteUnchecked_U32(0x24020000, base + PopsMan().licenseCall); // li v0, 0 diff --git a/Core/Loaders.cpp b/Core/Loaders.cpp index 69e161e884..a1f68c4e8e 100644 --- a/Core/Loaders.cpp +++ b/Core/Loaders.cpp @@ -117,7 +117,7 @@ IdentifiedFileType Identify_File(FileLoader *fileLoader, std::string *errorStrin bool isDiscImage = false; if (extension == ".iso" || extension == ".cso" || extension == ".chd") { isDiscImage = true; - } else if (extension == ".cue" || extension == ".ccd" || extension == ".mds") { + } else if (extension == ".cue" || extension == ".ccd" || extension == ".mds" || extension == ".m3u") { // A PS1 disc, which boots as a converted EBOOT (see PS1EbootFileLoader). return IsPS1DiscSheet(fileLoader) ? IdentifiedFileType::PSX_ISO : IdentifiedFileType::UNKNOWN; } else if (extension == ".mdf") { diff --git a/UI/GameBrowser.cpp b/UI/GameBrowser.cpp index f21ed1ea52..6363c5a6bd 100644 --- a/UI/GameBrowser.cpp +++ b/UI/GameBrowser.cpp @@ -1128,7 +1128,7 @@ void GameBrowser::Refresh() { } } else if (!listingPending_) { std::vector fileInfo; - path_.GetListing(fileInfo, "iso:cso:chd:pbp:elf:prx:ppdmp:cue:ccd:mds:bin:img:"); + path_.GetListing(fileInfo, "iso:cso:chd:pbp:elf:prx:ppdmp:cue:ccd:mds:m3u:bin:img:"); // PS1 discs: a .bin or .img shows up as its cue sheet or .ccd, when it has one. RemoveDescribedDiscImages(&fileInfo); for (size_t i = 0; i < fileInfo.size(); i++) { diff --git a/unittest/TestPS1Eboot.cpp b/unittest/TestPS1Eboot.cpp index 95d5ffa8eb..6209f57295 100644 --- a/unittest/TestPS1Eboot.cpp +++ b/unittest/TestPS1Eboot.cpp @@ -372,6 +372,53 @@ bool TestPS1Eboot() { const std::vector expectedNames = { "Alcohol.mdf", "Alcohol.mds", "Clone.ccd", "Cooked.iso", "Game.cue", "Loose.bin", "Pregap.cue", "Stripped.cue" }; EXPECT_TRUE(names == expectedNames); + // A playlist: a multi-disc EBOOT, a PSTITLEIMG listing each disc's PSISOIMG. + EXPECT_TRUE(File::WriteStringToFile(true, "# Two discs\r\nGame.cue\r\n\r\nClone.ccd\r\n", dir / "Two.m3u")); + { + std::unique_ptr m3u(ConstructFileLoader(dir / "Two.m3u")); + std::string error; + EXPECT_TRUE(Identify_File(m3u.get(), &error) == IdentifiedFileType::PSX_ISO); + + PS1EbootFileLoader eboot(dir / "Two.m3u"); + EXPECT_TRUE(eboot.IsValid()); + EXPECT_EQ_INT(eboot.NumDiscs(), 2); + EXPECT_TRUE(Identify_File(&eboot, &error) == IdentifiedFileType::PSP_PS1_PBP); + const u32 psar = Get32(&ReadAll(&eboot, 0, 0x28)[0x24]); + const std::vector title = ReadAll(&eboot, psar, 0x400); + EXPECT_EQ_MEM(&title[0], "PSTITLEIMG000000", 16); + EXPECT_EQ_MEM(&title[0x264], "_SLUS_12345", 12); + const u32 offsets[2] = { Get32(&title[0x200]), Get32(&title[0x204]) }; + EXPECT_EQ_INT(offsets[0], 0x8000); + EXPECT_EQ_INT(offsets[1] % 0x8000, 0); + EXPECT_EQ_INT(Get32(&title[0x208]), 0); + const std::vector *discs[2] = { &disc, &data }; + for (int i = 0; i < 2; i++) { + const s64 base = psar + offsets[i]; + EXPECT_EQ_MEM(ReadAll(&eboot, base, 12).data(), "PSISOIMG0000", 12); + const std::vector expected = ExpectedBlocks(*discs[i]); + EXPECT_TRUE(ReadAll(&eboot, base + 0x100000, expected.size()) == expected); + } + const s64 end = offsets[1] + 0x100000 + (s64)ExpectedBlocks(data).size(); + EXPECT_EQ_INT(Get32(&title[0x10]), end); + EXPECT_EQ_INT(eboot.FileSize(), psar + end); + // The gap between the discs reads as zeroes. + const s64 gapStart = psar + offsets[0] + 0x100000 + (s64)ExpectedBlocks(disc).size(); + const std::vector gap = ReadAll(&eboot, gapStart, 16); + EXPECT_TRUE(gap == std::vector(16, 0)); + + // The browser shows the playlist, and not the discs it lists. + std::vector listing; + File::GetFilesInDir(dir, &listing); + RemoveDescribedDiscImages(&listing); + bool hasPlaylist = false, hasDisc = false; + for (const File::FileInfo &info : listing) { + hasPlaylist = hasPlaylist || info.name == "Two.m3u"; + hasDisc = hasDisc || info.name == "Game.cue" || info.name == "Clone.ccd"; + } + EXPECT_TRUE(hasPlaylist && !hasDisc); + } + File::Delete(dir / "Two.m3u"); + // Installing from a zip: the cue sheet and both its files, into a folder of their own. const Path zipPath = dir / "Game.zip"; {