diff --git a/CMakeLists.txt b/CMakeLists.txt index 45b4971a69..f4c018c551 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1425,6 +1425,7 @@ if(UNITTEST) unittest/TestVertexJit.h unittest/TestVFS.cpp unittest/TestZipSlip.cpp + unittest/TestPS1Eboot.cpp unittest/TestLzrc.cpp unittest/TestCwCheat.cpp unittest/TestMpegCsc.cpp diff --git a/Core/CMakeLists.txt b/Core/CMakeLists.txt index 9a585939ae..c5d74a0f96 100644 --- a/Core/CMakeLists.txt +++ b/Core/CMakeLists.txt @@ -612,6 +612,8 @@ add_library(Core STATIC FileLoaders/HTTPFileLoader.h FileLoaders/LocalFileLoader.cpp FileLoaders/LocalFileLoader.h + FileLoaders/PS1EbootFileLoader.cpp + FileLoaders/PS1EbootFileLoader.h FileLoaders/RamCachingFileLoader.cpp FileLoaders/RamCachingFileLoader.h FileLoaders/RetryingFileLoader.cpp diff --git a/Core/Core.vcxproj b/Core/Core.vcxproj index 4098967edf..010a5d2ea2 100644 --- a/Core/Core.vcxproj +++ b/Core/Core.vcxproj @@ -587,6 +587,7 @@ + @@ -931,6 +932,7 @@ + diff --git a/Core/Core.vcxproj.filters b/Core/Core.vcxproj.filters index a0b8550a09..4556eb843d 100644 --- a/Core/Core.vcxproj.filters +++ b/Core/Core.vcxproj.filters @@ -619,6 +619,9 @@ FileLoaders + + FileLoaders + FileLoaders @@ -1978,6 +1981,9 @@ FileLoaders + + FileLoaders + FileLoaders diff --git a/Core/FileLoaders/PS1EbootFileLoader.cpp b/Core/FileLoaders/PS1EbootFileLoader.cpp new file mode 100644 index 0000000000..2702737849 --- /dev/null +++ b/Core/FileLoaders/PS1EbootFileLoader.cpp @@ -0,0 +1,755 @@ +// Copyright (c) 2012- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#include +#include +#include + +#include "Common/File/DirListing.h" +#include "Common/Log.h" +#include "Common/StringUtils.h" +#include "Common/Swap.h" +#include "Core/ELF/ParamSFO.h" +#include "Core/FileLoaders/PS1EbootFileLoader.h" + +static const u32 SECTOR_SIZE = 2352; +// The PSAR's disc image is in blocks of 16 sectors, each stored as one uncompressed DEFLATE block: a +// header byte (final, stored), the length, its complement, then the sectors. POPS doesn't take an +// uncompressed block in its own format (0x9300 bytes), but its decoder call goes to an inflate for a +// converted PBP (__PopsDeflateDecompress), and this way every block has the same size. +static const u32 BLOCK_SECTORS = 16; +static const u32 BLOCK_DATA_SIZE = BLOCK_SECTORS * SECTOR_SIZE; +static const u8 STORED_HEADER[5] = { 0x01, BLOCK_DATA_SIZE & 0xFF, BLOCK_DATA_SIZE >> 8, (u8)~BLOCK_DATA_SIZE, (u8)(~BLOCK_DATA_SIZE >> 8) }; +static const u32 BLOCK_SIZE = BLOCK_DATA_SIZE + sizeof(STORED_HEADER); + +// The PSISOIMG header, as popstation writes it: the disc ID at 0x400, the TOC at 0x800, the block index +// (32 bytes a block) from 0x4000, and the blocks from 0x100000. +static const u32 PSAR_HEADER_SIZE = 0x4000; +static const u32 PSAR_INDEX_ENTRY_SIZE = 32; +static const u32 PSAR_DATA_OFFSET = 0x100000; +static const u32 MAX_BLOCKS = (PSAR_DATA_OFFSET - PSAR_HEADER_SIZE) / PSAR_INDEX_ENTRY_SIZE; + +static const u8 SYNC[12] = { 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00 }; + +static std::string AsciiToUpper(std::string_view s) { + std::string out(s); + for (char &c : out) { + c = (char)toupper((unsigned char)c); + } + return out; +} + +static std::string AsciiToLower(std::string_view s) { + std::string out(s); + for (char &c : out) { + c = (char)tolower((unsigned char)c); + } + return out; +} + +// The file name without its extension. +static std::string FileStem(const Path &path) { + const std::string name = path.GetFilename(); + return name.substr(0, name.size() - path.GetFileExtension().size()); +} + +static u8 ToBCD(u32 v) { + return (u8)(((v / 10) << 4) | (v % 10)); +} + +// The absolute time of an LBA (which starts at 00:02:00), in BCD. +static void LBAToMSF(s32 lba, u8 *out) { + const u32 frames = (u32)(lba + 150); + out[0] = ToBCD(frames / (75 * 60)); + out[1] = ToBCD((frames / 75) % 60); + out[2] = ToBCD(frames % 75); +} + +// "mm:ss:ff" to a frame count, -1 if it isn't one. +static int ParseMSF(std::string_view s) { + int m, sec, f; + char c1, c2; + if (sscanf(std::string(s).c_str(), "%d%c%d%c%d", &m, &c1, &sec, &c2, &f) != 5 || c1 != ':' || c2 != ':') { + return -1; + } + return (m * 60 + sec) * 75 + f; +} + +static bool ReadSmallTextFile(const Path &path, std::string *text) { + std::unique_ptr loader(ConstructFileLoader(path)); + if (!loader || !loader->Exists()) { + return false; + } + const s64 size = loader->FileSize(); + if (size <= 0 || size > 64 * 1024) { + return false; + } + text->resize((size_t)size); + text->resize(loader->ReadAt(0, (size_t)size, &(*text)[0])); + if (startsWith(*text, "\xEF\xBB\xBF")) { + text->erase(0, 3); + } + return true; +} + +// The next token of a cue sheet line: a quoted string, or up to the next space. +static std::string_view NextToken(std::string_view *line) { + *line = StripSpaces(*line); + if (line->empty()) { + return {}; + } + size_t end; + std::string_view token; + if ((*line)[0] == '"') { + end = line->find('"', 1); + if (end == std::string_view::npos) { + end = line->size(); + } + token = line->substr(1, end - 1); + end = std::min(end + 1, line->size()); + } else { + end = line->find_first_of(" \t"); + if (end == std::string_view::npos) { + end = line->size(); + } + token = line->substr(0, end); + } + line->remove_prefix(end); + return token; +} + +bool IsPS1CueOrCcd(FileLoader *fileLoader) { + char buf[4096]{}; + const size_t size = fileLoader->ReadAt(0, sizeof(buf) - 1, buf); + const std::string text(buf, size); + const std::string ext = fileLoader->GetFileExtension(); + if (ext == ".ccd") { + return text.find("[CloneCD]") != std::string::npos; + } + if (ext == ".cue") { + const std::string upper = AsciiToUpper(text); + return upper.find("FILE") != std::string::npos && upper.find("TRACK") != std::string::npos; + } + return false; +} + +std::vector CueSheetFileNames(const Path &cuePath) { + std::vector names; + std::string text; + if (!ReadSmallTextFile(cuePath, &text)) { + return names; + } + std::vector lines; + SplitString(text, '\n', lines); + for (std::string_view line : lines) { + if (equalsNoCase(NextToken(&line), "FILE")) { + names.emplace_back(NextToken(&line)); + } + } + return names; +} + +Path FindCueSheetFor(const Path &imagePath) { + if (!imagePath.CanNavigateUp()) { + return Path(); + } + const std::string name = imagePath.GetFilename(); + std::vector cues; + File::GetFilesInDir(imagePath.NavigateUp(), &cues, "cue:"); + for (const File::FileInfo &cue : cues) { + if (cue.isDirectory) { + continue; + } + for (const std::string &file : CueSheetFileNames(cue.fullName)) { + if (equalsNoCase(Path(file).GetFilename(), name)) { + return cue.fullName; + } + } + } + return Path(); +} + +void RemoveDescribedDiscImages(std::vector *files) { + std::set described; + for (const File::FileInfo &info : *files) { + const std::string ext = info.fullName.GetFileExtension(); + if (info.isDirectory || (ext != ".cue" && ext != ".ccd")) { + continue; + } + const std::string stem = AsciiToLower(FileStem(info.fullName)); + described.insert(stem + ".bin"); + described.insert(stem + ".img"); + if (ext == ".cue") { + for (const std::string &file : CueSheetFileNames(info.fullName)) { + described.insert(AsciiToLower(Path(file).GetFilename())); + } + } + } + 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")) { + return false; + } + return info.size % SECTOR_SIZE != 0 || described.count(AsciiToLower(info.name)) != 0; + }), files->end()); +} + +PS1EbootFileLoader::PS1EbootFileLoader(const Path &path) : path_(path) { + const std::string ext = path.GetFileExtension(); + std::string text; + bool parsed = false; + if (ext == ".cue" || ext == ".ccd") { + if (!ReadSmallTextFile(path, &text)) { + error_ = "Can't read " + path.GetFilename(); + return; + } + parsed = ext == ".cue" ? ParseCue(path, text) : ParseCcd(path, text); + } else { + // A bare image with a cue sheet or a .ccd next to it is described by that, which has its audio tracks. + Path cue = FindCueSheetFor(path); + if (cue.empty()) { + cue = path.WithReplacedExtension(".cue"); + } + const Path ccd = path.WithReplacedExtension(".ccd"); + if (ReadSmallTextFile(cue, &text) && ParseCue(cue, text)) { + parsed = true; + } else if (ReadSmallTextFile(ccd, &text) && ParseCcd(ccd, text)) { + parsed = true; + } else { + files_.clear(); + extents_.clear(); + tracks_.clear(); + error_.clear(); + parsed = OpenBare(path); + } + } + if (!parsed || tracks_.empty() || extents_.empty()) { + if (error_.empty()) { + error_ = "Not a PS1 disc image: " + path.GetFilename(); + } + return; + } + + // LBA 0 is the first track's INDEX 01. An image that has its pregap starts before it. + const u32 origin = tracks_[0].start; + if (origin != 0) { + std::vector shifted; + for (Extent e : extents_) { + if (e.lba + e.count <= origin) { + continue; + } + if (e.lba < origin) { + const u32 skip = origin - e.lba; + e.offset += (s64)skip * e.sectorSize; + e.count -= skip; + e.lba = origin; + } + e.lba -= origin; + shifted.push_back(e); + } + extents_ = shifted; + for (Track &t : tracks_) { + t.start -= origin; + } + } + tracks_[0].pregap = 150; + numSectors_ = extents_.back().lba + extents_.back().count; + numBlocks_ = (numSectors_ + BLOCK_SECTORS - 1) / BLOCK_SECTORS; + if (numBlocks_ > MAX_BLOCKS) { + error_ = "Disc image too large"; + return; + } + + 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() {} + +int PS1EbootFileLoader::AddFile(const Path &path) { + FileLoader *loader = ConstructFileLoader(path); + if (!loader || !loader->Exists()) { + delete loader; + return -1; + } + files_.emplace_back(loader); + return (int)files_.size() - 1; +} + +void PS1EbootFileLoader::AddExtent(u32 lba, u32 count, int file, s64 offset, int sectorSize) { + if (count != 0) { + extents_.push_back(Extent{ lba, count, file, offset, sectorSize }); + } +} + +bool PS1EbootFileLoader::ParseCue(const Path &cuePath, const std::string &text) { + struct CueTrack { + int number; + bool audio; + int index0 = -1; + int index1 = -1; + int pregap = 0; + }; + struct CueFile { + Path path; + int sectorSize = 0; + std::vector tracks; + }; + std::vector cueFiles; + + std::vector lines; + SplitString(text, '\n', lines); + for (std::string_view line : lines) { + const std::string command = AsciiToUpper(NextToken(&line)); + if (command == "FILE") { + const std::string_view name = NextToken(&line); + const std::string type = AsciiToUpper(NextToken(&line)); + if (type != "BINARY" && type != "MOTOROLA") { + error_ = "Unsupported file type in cue sheet: " + type; + return false; + } + CueFile file; + file.path = cuePath.NavigateUp() / name; + cueFiles.push_back(file); + } else if (command == "TRACK") { + if (cueFiles.empty()) { + return false; + } + const int number = atoi(std::string(NextToken(&line)).c_str()); + const std::string mode = AsciiToUpper(NextToken(&line)); + int sectorSize; + if (mode == "AUDIO" || mode == "MODE1/2352" || mode == "MODE2/2352") { + sectorSize = 2352; + } else if (mode == "MODE2/2336") { + sectorSize = 2336; + } else if (mode == "MODE1/2048" || mode == "MODE2/2048") { + sectorSize = 2048; + } else { + error_ = "Unsupported track mode in cue sheet: " + mode; + return false; + } + CueFile &file = cueFiles.back(); + if (file.sectorSize == 0) { + file.sectorSize = sectorSize; + } else if (file.sectorSize != sectorSize) { + error_ = "Tracks with different sector sizes in one file"; + return false; + } + CueTrack track; + track.number = number; + track.audio = mode == "AUDIO"; + file.tracks.push_back(track); + } else if (command == "INDEX" || command == "PREGAP") { + if (cueFiles.empty() || cueFiles.back().tracks.empty()) { + return false; + } + CueTrack &track = cueFiles.back().tracks.back(); + const int index = command == "INDEX" ? atoi(std::string(NextToken(&line)).c_str()) : -1; + const int frames = ParseMSF(NextToken(&line)); + if (frames < 0) { + return false; + } + if (index == -1) { + track.pregap = frames; + } else if (index == 0) { + track.index0 = frames; + } else if (index == 1) { + track.index1 = frames; + } + } + } + + // Lay the files out one after another, with any generated pregap before its track. + u32 lba = 0; + for (CueFile &cueFile : cueFiles) { + int file = AddFile(cueFile.path); + if (file < 0) { + // Renamed images are common: try the cue sheet's own name. + const std::string ext = cueFile.path.GetFileExtension(); + for (const char *alt : { ext.c_str(), ".bin", ".img" }) { + file = AddFile(cuePath.WithReplacedExtension(alt)); + if (file >= 0) { + break; + } + } + if (file < 0 || cueFiles.size() != 1) { + error_ = "Missing file in cue sheet: " + cueFile.path.GetFilename(); + return false; + } + } + if (cueFile.tracks.empty()) { + continue; + } + const u32 fileSectors = (u32)(files_[file]->FileSize() / cueFile.sectorSize); + u32 fileSector = 0; + for (const CueTrack &cueTrack : cueFile.tracks) { + if (cueTrack.index1 < 0) { + error_ = "Track without INDEX 01 in cue sheet"; + return false; + } + const u32 split = (u32)(cueTrack.index0 >= 0 ? cueTrack.index0 : cueTrack.index1); + if (split < fileSector || (u32)cueTrack.index1 < split) { + return false; + } + AddExtent(lba, split - fileSector, file, (s64)fileSector * cueFile.sectorSize, cueFile.sectorSize); + lba += split - fileSector; + AddExtent(lba, cueTrack.pregap, -1, 0, SECTOR_SIZE); + lba += cueTrack.pregap; + fileSector = split; + + Track track; + track.number = cueTrack.number; + track.audio = cueTrack.audio; + track.start = lba + (cueTrack.index1 - split); + track.pregap = cueTrack.pregap + (cueTrack.index1 - split); + tracks_.push_back(track); + } + if (fileSectors > fileSector) { + AddExtent(lba, fileSectors - fileSector, file, (s64)fileSector * cueFile.sectorSize, cueFile.sectorSize); + lba += fileSectors - fileSector; + } + } + return !tracks_.empty(); +} + +bool PS1EbootFileLoader::ParseCcd(const Path &ccdPath, const std::string &text) { + const int file = AddFile(ccdPath.WithReplacedExtension(".img")); + if (file < 0) { + error_ = "Missing " + ccdPath.WithReplacedExtension(".img").GetFilename(); + return false; + } + AddExtent(0, (u32)(files_[file]->FileSize() / SECTOR_SIZE), file, 0, SECTOR_SIZE); + + // [TRACK n] sections: MODE=0 is audio, INDEX 1= is where the track starts, INDEX 0= its pregap. + std::vector lines; + SplitString(text, '\n', lines); + Track *track = nullptr; + int index0 = -1; + auto finishTrack = [&]() { + if (track && index0 >= 0 && (u32)index0 <= track->start) { + track->pregap = track->start - index0; + } + index0 = -1; + }; + for (std::string_view line : lines) { + line = StripSpaces(line); + if (startsWith(line, "[")) { + finishTrack(); + track = nullptr; + if (startsWithNoCase(line, "[TRACK ")) { + tracks_.push_back(Track{ atoi(std::string(line.substr(7)).c_str()), false, 0, 0 }); + track = &tracks_.back(); + } + continue; + } + std::string_view key, value; + if (!track || !SplitStringOnce(line, &key, &value, '=')) { + continue; + } + key = StripSpaces(key); + const int v = atoi(std::string(StripSpaces(value)).c_str()); + if (equalsNoCase(key, "MODE")) { + track->audio = v == 0; + } else if (equalsNoCase(key, "INDEX 1")) { + track->start = v; + } else if (equalsNoCase(key, "INDEX 0")) { + index0 = v; + } + } + finishTrack(); + std::sort(tracks_.begin(), tracks_.end(), [](const Track &a, const Track &b) { return a.number < b.number; }); + if (tracks_.empty()) { + // No tracks listed: one data track. + tracks_.push_back(Track{ 1, false, 0, 150 }); + } + return true; +} + +bool PS1EbootFileLoader::OpenBare(const Path &path) { + const int file = AddFile(path); + if (file < 0) { + return false; + } + FileLoader *loader = files_[file].get(); + const s64 size = loader->FileSize(); + u8 sync[12]{}; + u8 cd001[6]{}; + loader->ReadAt(0, sizeof(sync), sync); + loader->ReadAt(16 * 2048, sizeof(cd001), cd001); + int sectorSize; + if (size % SECTOR_SIZE == 0 && memcmp(sync, SYNC, sizeof(SYNC)) == 0) { + sectorSize = SECTOR_SIZE; + } else if (size % 2048 == 0 && memcmp(cd001, "\x01" "CD001", 6) == 0) { + sectorSize = 2048; + } else { + return false; + } + AddExtent(0, (u32)(size / sectorSize), file, 0, sectorSize); + tracks_.push_back(Track{ 1, false, 0, 150 }); + return true; +} + +bool PS1EbootFileLoader::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; }); + if (lba >= numSectors_ || it == extents_.begin()) { + memset(out, 0, (size_t)count * SECTOR_SIZE); + return ok; + } + const Extent &e = *(it - 1); + const u32 run = std::min(count, e.lba + e.count - lba); + const s64 offset = e.offset + (s64)(lba - e.lba) * e.sectorSize; + if (e.file < 0) { + memset(out, 0, (size_t)run * SECTOR_SIZE); + } else if (e.sectorSize == (int)SECTOR_SIZE) { + const size_t want = (size_t)run * SECTOR_SIZE; + const size_t got = files_[e.file]->ReadAt(offset, want, out); + if (got < want) { + memset(out + got, 0, want - got); + ok = false; + } + } else { + // Sectors without their sync and header (2336), or only their user data (2048, which can only + // be Mode 2 Form 1). The EDC and ECC of those stay zero. + for (u32 i = 0; i < run; i++) { + u8 *sector = out + (size_t)i * SECTOR_SIZE; + memset(sector, 0, SECTOR_SIZE); + memcpy(sector, SYNC, sizeof(SYNC)); + LBAToMSF(lba + i, sector + 12); + sector[15] = 2; + u8 *data = sector + 16; + if (e.sectorSize == 2048) { + static const u8 subheader[8] = { 0, 0, 8, 0, 0, 0, 8, 0 }; + memcpy(sector + 16, subheader, sizeof(subheader)); + data = sector + 24; + } + const s64 pos = offset + (s64)i * e.sectorSize; + if (files_[e.file]->ReadAt(pos, e.sectorSize, data) != (size_t)e.sectorSize) { + ok = false; + } + } + } + lba += run; + count -= run; + out += (size_t)run * SECTOR_SIZE; + } + return ok; +} + +bool PS1EbootFileLoader::ReadUserData(u32 lba, u8 *out) { + u8 sector[SECTOR_SIZE]; + if (!ReadRawSectors(lba, 1, sector)) { + return false; + } + memcpy(out, sector + (sector[15] == 1 ? 16 : 24), 2048); + return true; +} + +// The ID the disc boots by, from SYSTEM.CNF's BOOT line ("cdrom:\SLUS_007.13;1" is SLUS00713). +void PS1EbootFileLoader::ReadDiscID() { + discID_ = "SLUS00000"; + u8 pvd[2048]; + if (!ReadUserData(16, pvd) || memcmp(pvd, "\x01" "CD001", 6) != 0) { + return; + } + const u32 rootLBA = pvd[156 + 2] | (pvd[156 + 3] << 8) | (pvd[156 + 4] << 16) | ((u32)pvd[156 + 5] << 24); + const u32 rootSize = pvd[156 + 10] | (pvd[156 + 11] << 8) | (pvd[156 + 12] << 16) | ((u32)pvd[156 + 13] << 24); + std::string cnf; + for (u32 s = 0; s < std::min(rootSize / 2048, 16U) && cnf.empty(); s++) { + u8 dir[2048]; + if (!ReadUserData(rootLBA + s, dir)) { + return; + } + for (u32 pos = 0; pos + 33 < sizeof(dir) && dir[pos] != 0; pos += dir[pos]) { + const u8 nameLen = dir[pos + 32]; + if (pos + 33 + nameLen > sizeof(dir)) { + break; + } + std::string_view name((const char *)&dir[pos + 33], nameLen); + name = name.substr(0, name.find(';')); + if (!equalsNoCase(name, "SYSTEM.CNF")) { + continue; + } + const u32 lba = dir[pos + 2] | (dir[pos + 3] << 8) | (dir[pos + 4] << 16) | ((u32)dir[pos + 5] << 24); + const u32 size = std::min(dir[pos + 10] | (dir[pos + 11] << 8) | (dir[pos + 12] << 16) | ((u32)dir[pos + 13] << 24), 2048U); + u8 data[2048]; + if (ReadUserData(lba, data)) { + cnf.assign((const char *)data, size); + } + break; + } + } + + std::vector lines; + SplitString(cnf, '\n', lines); + for (std::string_view line : lines) { + std::string_view key, value; + if (!SplitStringOnce(line, &key, &value, '=') || !equalsNoCase(StripSpaces(key), "BOOT")) { + continue; + } + value = StripSpaces(value); + const size_t slash = value.find_last_of("\\:/"); + if (slash != std::string_view::npos) { + value = value.substr(slash + 1); + } + value = value.substr(0, value.find(';')); + std::string id; + for (char c : value) { + if (isalnum((unsigned char)c)) { + id.push_back((char)toupper((unsigned char)c)); + } + } + if (id.size() == 9) { + discID_ = id; + } + break; + } +} + +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_; + auto write32 = [&](u32 offset, u32 value) { + *(u32_le *)(psar + offset) = value; + }; + const u32 end = PSAR_DATA_OFFSET + numBlocks_ * BLOCK_SIZE; + memcpy(psar, "PSISOIMG0000", 12); + write32(0x0C, end); + const std::string gameID = "_" + discID_.substr(0, 4) + "_" + discID_.substr(4); + memcpy(psar + 0x400, gameID.data(), std::min(gameID.size(), (size_t)0x20)); + + // The TOC, as Q subchannel entries of 10 bytes (control/ADR, TNO, POINT, the absolute time, zero, then + // POINT's): the first track (and the disc type, 0x20 being XA), the last one, the lead-out, then each + // track with its pregap's start and its own. + const Track &first = tracks_.front(); + const Track &last = tracks_.back(); + auto control = [](const Track &t) -> u8 { return t.audio ? 0x01 : 0x41; }; + u8 *toc = psar + 0x800; + toc[0] = control(first); toc[2] = 0xA0; toc[7] = ToBCD(first.number); toc[8] = 0x20; + toc += 10; + toc[0] = control(last); toc[2] = 0xA1; toc[7] = ToBCD(last.number); + toc += 10; + toc[0] = control(last); toc[2] = 0xA2; LBAToMSF(numSectors_, toc + 7); + toc += 10; + for (const Track &t : tracks_) { + if (toc + 10 > psar + 0xBFC) { + break; + } + toc[0] = control(t); + toc[2] = ToBCD(t.number); + LBAToMSF((s32)t.start - (s32)t.pregap, toc + 3); + LBAToMSF(t.start, toc + 7); + toc += 10; + } + write32(0xBFC, PSAR_DATA_OFFSET); + write32(0x1220, end + 0x2D31); + write32(0x1228, 0x7FF); + memcpy(psar + 0x122C, title_.data(), std::min(title_.size(), (size_t)0x7F)); + write32(0x12AC, 3); + + size_ = (s64)psarOffset_ + end; +} + +size_t PS1EbootFileLoader::ReadAt(s64 absolutePos, size_t bytes, size_t count, void *data, Flags flags) { + if (!IsValid() || absolutePos < 0 || bytes == 0) { + return 0; + } + u8 *out = (u8 *)data; + 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)); + 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); + } 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); + } + } + } + out += n; + pos += n; + remaining -= n; + } + return total / bytes; +} + +void PS1EbootFileLoader::Cancel() { + for (auto &file : files_) { + file->Cancel(); + } +} diff --git a/Core/FileLoaders/PS1EbootFileLoader.h b/Core/FileLoaders/PS1EbootFileLoader.h new file mode 100644 index 0000000000..fc8ff6b614 --- /dev/null +++ b/Core/FileLoaders/PS1EbootFileLoader.h @@ -0,0 +1,110 @@ +// Copyright (c) 2012- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#pragma once + +#include +#include +#include + +#include "Common/CommonTypes.h" +#include "Core/Loaders.h" + +// A PS1 disc image (a cue sheet, a CloneCD .ccd, 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 { +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; + void Cancel() override; + std::string LatestError() const override { return error_; } + + const std::string &DiscID() const { return discID_; } + const std::string &Title() const { return title_; } + +private: + struct Track { + int number; + bool audio; + u32 start; // Disc LBA of INDEX 01. + u32 pregap; // Sectors before it that belong to the track (INDEX 00, or a generated PREGAP). + }; + // A run of disc sectors, from a file or (file < 0) generated, all zeroes. + struct Extent { + u32 lba; + u32 count; + int file; + s64 offset; + int sectorSize; // 2352 raw, 2336 without sync and header, 2048 user data only. + }; + + bool ParseCue(const Path &cuePath, const std::string &text); + bool ParseCcd(const Path &ccdPath, const std::string &text); + bool OpenBare(const Path &path); + int AddFile(const Path &path); + void AddExtent(u32 lba, u32 count, int file, s64 offset, int sectorSize); + + 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_; + std::vector tracks_; + u32 numSectors_ = 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; + s64 size_ = 0; +}; + +namespace File { +struct FileInfo; +} + +// Whether a .cue or .ccd describes a disc, by its contents. +bool IsPS1CueOrCcd(FileLoader *fileLoader); + +// The file names a cue sheet's FILE lines give. +std::vector CueSheetFileNames(const Path &cuePath); + +// 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). +void RemoveDescribedDiscImages(std::vector *files); diff --git a/Core/FileSystems/BlobFileSystem.cpp b/Core/FileSystems/BlobFileSystem.cpp index 1060844bf4..7139c2fcad 100644 --- a/Core/FileSystems/BlobFileSystem.cpp +++ b/Core/FileSystems/BlobFileSystem.cpp @@ -15,6 +15,8 @@ // Official git repository and contact information can be found at // https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. +#include "Common/Serialize/Serializer.h" +#include "Common/Serialize/SerializeMap.h" #include "Core/FileSystems/BlobFileSystem.h" BlobFileSystem::BlobFileSystem(IHandleAllocator *hAlloc, FileLoader *fileLoader, std::string alias) @@ -27,7 +29,11 @@ BlobFileSystem::~BlobFileSystem() { } void BlobFileSystem::DoState(PointerWrap &p) { - // Not used in real emulation. + auto s = p.Section("BlobFileSystem", 1); + if (!s) + return; + // The open handles and their positions. The blob is the one booted. + Do(p, entries_); } std::vector BlobFileSystem::GetDirListing(std::string_view path, bool *exists) { diff --git a/Core/FileSystems/BlobFileSystem.h b/Core/FileSystems/BlobFileSystem.h index 1d8f96c84d..88add5666c 100644 --- a/Core/FileSystems/BlobFileSystem.h +++ b/Core/FileSystems/BlobFileSystem.h @@ -17,9 +17,9 @@ #pragma once -// This is used for opening a debug file as a blob, and mounting it. +// This is used for opening a file as a blob, and mounting it. // Importantly, uses a fileLoader for all access, so http:// URLs are supported. -// As of writing, only used by GE dump replay. +// Used by GE dump replay, and for the EBOOT a PS1 disc image is presented as. #include #include diff --git a/Core/HLE/PopsBoot.cpp b/Core/HLE/PopsBoot.cpp index 0a8abcb707..1a606d542a 100644 --- a/Core/HLE/PopsBoot.cpp +++ b/Core/HLE/PopsBoot.cpp @@ -235,6 +235,10 @@ static bool ReadDiscImage(const PBPReader &pbp, size_t size, u8 *out) { // The content ID (DATA.PSP + 0x560, as in the license) and the disc image's PGD header. static bool ReadPBPDrmHeaders(u8 *pgdHeader, u8 *contentId) { + // A converted one has no PGD, and one made from a disc image isn't at its path. + if (g_popsConvertedPBP) { + return false; + } std::unique_ptr loader(ConstructFileLoader(g_popsPBPPath)); PBPReader pbp(loader.get()); return pbp.IsValid() && pbp.ReadSubFile(PBP_EXECUTABLE_PSP, 0x560, 0x30, contentId) && diff --git a/Core/Loaders.cpp b/Core/Loaders.cpp index 8dd58240b6..66f873ec2c 100644 --- a/Core/Loaders.cpp +++ b/Core/Loaders.cpp @@ -25,6 +25,7 @@ #include "Core/FileLoaders/DiskCachingFileLoader.h" #include "Core/FileLoaders/HTTPFileLoader.h" #include "Core/FileLoaders/LocalFileLoader.h" +#include "Core/FileLoaders/PS1EbootFileLoader.h" #include "Core/FileLoaders/RetryingFileLoader.h" #include "Core/FileLoaders/ZipFileLoader.h" #include "Core/FileSystems/MetaFileSystem.h" @@ -111,6 +112,9 @@ 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") { + // A PS1 disc, which boots as a converted EBOOT (see PS1EbootFileLoader). + return IsPS1CueOrCcd(fileLoader) ? IdentifiedFileType::PSX_ISO : IdentifiedFileType::UNKNOWN; } else if (extension == ".ppst") { return IdentifiedFileType::PPSSPP_SAVESTATE; } else if (extension == ".ppdmp") { @@ -302,6 +306,17 @@ FileLoader *ResolveFileLoaderTarget(FileLoader *fileLoader, IdentifiedFileType * // Re-identify the file. *fileType = Identify_File(fileLoader, errorString); } + } else if (*fileType == IdentifiedFileType::PSX_ISO) { + // A PS1 disc image runs on the firmware's POPS, as a converted EBOOT made from it. + PS1EbootFileLoader *eboot = new PS1EbootFileLoader(fileLoader->GetPath()); + if (!eboot->IsValid()) { + *errorString = eboot->LatestError(); + delete eboot; + return fileLoader; + } + delete fileLoader; + *fileType = IdentifiedFileType::PSP_PS1_PBP; + return eboot; } else if (*fileType == IdentifiedFileType::ARCHIVE_ZIP) { // Handle zip files, take automatic action depending on contents. // Can also return nullptr. diff --git a/Core/PSPLoaders.cpp b/Core/PSPLoaders.cpp index 19a834f2e6..7892fd7723 100644 --- a/Core/PSPLoaders.cpp +++ b/Core/PSPLoaders.cpp @@ -35,6 +35,7 @@ #include "Core/ELF/ParamSFO.h" #include "Core/ELF/PBPReader.h" #include "Core/ELF/PrxDecrypter.h" +#include "Core/FileLoaders/PS1EbootFileLoader.h" #include "Core/FileSystems/BlockDevices.h" #include "Core/FileSystems/BlobFileSystem.h" @@ -671,14 +672,20 @@ bool Load_PSP_POPS(FileLoader *fileLoader, std::string *error_string) { return false; } - // Its directory as host0:, as for a homebrew EBOOT. + // Its directory as host0:, as for a homebrew EBOOT. An EBOOT made from a disc image is the only file there. const Path fullPath = fileLoader->GetPath(); - auto fs = std::make_shared(&pspFileSystem, fullPath.NavigateUp(), FileSystemFlags::SIMULATE_FAT32 | FileSystemFlags::CARD); - pspFileSystem.Mount("host0:", fs); + std::string gamePath; + if (dynamic_cast(fileLoader)) { + pspFileSystem.Mount("host0:", std::make_shared(&pspFileSystem, fileLoader, "EBOOT.PBP")); + gamePath = "host0:/EBOOT.PBP"; + } else { + auto fs = std::make_shared(&pspFileSystem, fullPath.NavigateUp(), FileSystemFlags::SIMULATE_FAT32 | FileSystemFlags::CARD); + pspFileSystem.Mount("host0:", fs); + gamePath = "host0:/" + fullPath.GetFilename(); + } pspFileSystem.SetStartingDirectory("host0:/"); __PopsBootInit(); - const std::string gamePath = "host0:/" + fullPath.GetFilename(); INFO_LOG(Log::Loader, "POPS: booting %s through %s", gamePath.c_str(), popsmanPath); if (!__KernelLoadExec(currentMIPS, popsmanPath, 0, error_string)) { return false; diff --git a/Core/System.cpp b/Core/System.cpp index cdd645f989..870b380122 100644 --- a/Core/System.cpp +++ b/Core/System.cpp @@ -288,7 +288,12 @@ static void GetBootError(IdentifiedFileType type, std::string *errorString) { case IdentifiedFileType::ARCHIVE_7Z: *errorString = "7z file detected (Require 7-Zip)"; break; case IdentifiedFileType::PSP_PKG: *errorString = "PKG game updates need to be installed, not booted."; break; - case IdentifiedFileType::PSX_ISO: *errorString = "PSX game image detected."; break; + case IdentifiedFileType::PSX_ISO: + // It only gets here if it couldn't be made into an EBOOT, which says why. + if (errorString->empty()) { + *errorString = "PSX game image detected."; + } + break; case IdentifiedFileType::PS2_ISO: *errorString = "PS2 game image detected."; break; case IdentifiedFileType::PS3_ISO: *errorString = "PS2 game image detected."; break; case IdentifiedFileType::NORMAL_DIRECTORY: *errorString = "Just a directory."; break; diff --git a/UI/GameBrowser.cpp b/UI/GameBrowser.cpp index 2ed4209724..2735accccd 100644 --- a/UI/GameBrowser.cpp +++ b/UI/GameBrowser.cpp @@ -38,6 +38,7 @@ #include "Core/Util/RecentFiles.h" #include "Core/HLE/sceCtrl.h" #include "Core/ELF/PBPReader.h" +#include "Core/FileLoaders/PS1EbootFileLoader.h" #include "Core/MemMap.h" #include "Core/Util/VideoPlayer.h" @@ -1116,7 +1117,9 @@ void GameBrowser::Refresh() { } } else if (!listingPending_) { std::vector fileInfo; - path_.GetListing(fileInfo, "iso:cso:chd:pbp:elf:prx:ppdmp:"); + path_.GetListing(fileInfo, "iso:cso:chd:pbp:elf:prx:ppdmp:cue:ccd: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++) { bool isGame = !fileInfo[i].isDirectory; bool isSaveData = false; diff --git a/UI/GameInfoCache.cpp b/UI/GameInfoCache.cpp index b83d53ad00..006e2814d5 100644 --- a/UI/GameInfoCache.cpp +++ b/UI/GameInfoCache.cpp @@ -33,6 +33,7 @@ #include "Common/System/Request.h" #include "Common/StringUtils.h" #include "Common/TimeUtil.h" +#include "Core/FileLoaders/PS1EbootFileLoader.h" #include "Core/FileSystems/ISOFileSystem.h" #include "Core/FileSystems/DirectoryFileSystem.h" #include "Core/FileSystems/VirtualDiscFileSystem.h" @@ -597,6 +598,8 @@ public: switch (fileType) { case IdentifiedFileType::PSP_PBP: case IdentifiedFileType::PSP_PBP_DIRECTORY: + case IdentifiedFileType::PSP_PS1_PBP: + case IdentifiedFileType::PSX_ISO: { auto pbpLoader = info_->GetFileLoader(); if (fileType == IdentifiedFileType::PSP_PBP_DIRECTORY) { @@ -604,6 +607,9 @@ public: if (ebootPath != gamePath_) { pbpLoader.reset(ConstructFileLoader(ebootPath)); } + } else if (fileType == IdentifiedFileType::PSX_ISO) { + // The EBOOT a PS1 disc image boots as, which has its title and disc ID. + pbpLoader.reset(new PS1EbootFileLoader(gamePath_)); } if (!pbpLoader->Exists()) { diff --git a/UI/GameScreen.cpp b/UI/GameScreen.cpp index 6176b901e1..cd9fe5cdbc 100644 --- a/UI/GameScreen.cpp +++ b/UI/GameScreen.cpp @@ -233,7 +233,6 @@ static bool FileTypeIsPlayable(IdentifiedFileType fileType) { switch (fileType) { case IdentifiedFileType::ERROR_IDENTIFYING: case IdentifiedFileType::UNKNOWN: - case IdentifiedFileType::PSX_ISO: case IdentifiedFileType::PS2_ISO: case IdentifiedFileType::PS3_ISO: case IdentifiedFileType::UNKNOWN_BIN: diff --git a/UWP/CoreUWP/CoreUWP.vcxproj b/UWP/CoreUWP/CoreUWP.vcxproj index 1d33f307f1..bb67906a68 100644 --- a/UWP/CoreUWP/CoreUWP.vcxproj +++ b/UWP/CoreUWP/CoreUWP.vcxproj @@ -146,6 +146,7 @@ + @@ -450,6 +451,7 @@ + diff --git a/UWP/CoreUWP/CoreUWP.vcxproj.filters b/UWP/CoreUWP/CoreUWP.vcxproj.filters index fd6c82b8f7..c62fc9ce84 100644 --- a/UWP/CoreUWP/CoreUWP.vcxproj.filters +++ b/UWP/CoreUWP/CoreUWP.vcxproj.filters @@ -57,6 +57,7 @@ + @@ -477,6 +478,7 @@ + diff --git a/android/jni/Android.mk b/android/jni/Android.mk index ca7af5104b..f61d4c1973 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -690,6 +690,7 @@ EXEC_AND_LIB_FILES := \ $(SRC)/Core/FileLoaders/DiskCachingFileLoader.cpp \ $(SRC)/Core/FileLoaders/HTTPFileLoader.cpp \ $(SRC)/Core/FileLoaders/LocalFileLoader.cpp \ + $(SRC)/Core/FileLoaders/PS1EbootFileLoader.cpp \ $(SRC)/Core/FileLoaders/RamCachingFileLoader.cpp \ $(SRC)/Core/FileLoaders/RetryingFileLoader.cpp \ $(SRC)/Core/FileLoaders/ZipFileLoader.cpp \ @@ -1131,6 +1132,7 @@ ifeq ($(UNITTEST),1) $(SRC)/unittest/TestSplineTessellation.cpp \ $(SRC)/unittest/TestGEMath.cpp \ $(SRC)/unittest/TestZipSlip.cpp \ + $(SRC)/unittest/TestPS1Eboot.cpp \ $(SRC)/unittest/UnitTest.cpp include $(BUILD_EXECUTABLE) diff --git a/docs/POPS.md b/docs/POPS.md index a8340780c9..a236dbebe4 100644 --- a/docs/POPS.md +++ b/docs/POPS.md @@ -58,6 +58,22 @@ A converted EBOOT (popstation and similar) has a plain disc image of DEFLATE blo does, the emulator's decoder calls go to an inflate, its CD audio index check takes the full-length branch, and the two MAC checks (+0A28, +0AB8) pass. +## Disc images + +A PS1 disc image boots as the converted EBOOT it would make (`Core/FileLoaders/PS1EbootFileLoader.cpp`), +which never exists as a whole. It takes a cue sheet (with any number of files, AUDIO tracks, INDEX 00 +and PREGAP), a CloneCD `.ccd` with its `.img`, or a bare raw (2352-byte sector) `.bin`, `.img` or `.iso`, +or a 2048-byte `.iso`, around whose user data Mode 2 Form 1 sectors are made (with no EDC/ECC). A bare +image that a cue sheet next to it names, or that has a `.ccd` of the same name, is read through that. +The disc ID is the one SYSTEM.CNF boots. + +It's laid out as popstation lays out a PSISOIMG: the disc ID at +0x400, the TOC at +0x800, a block +index at +0x4000 and the blocks from +0x100000, each of 16 sectors. The manager doesn't read the disc +image of one whose blocks are its own uncompressed size (0x9300), so each block is a stored DEFLATE +block, which the inflate above decodes: 0x9305 bytes, so reads of the image map straight onto its files. +There are no icons, pictures or DATA.PSP, and none of the STARTDAT that follows a popstation image. The +EBOOT is the only file in `host0:`. + For any EBOOT, also as popcorn does: the manual's name check passes, and an ICON0 the emulator can't use (anything but an 80x80 PNG, such as a missing one or a PSP game's 144x80 one, which crashes it) reads as a blank 80x80 one. @@ -78,5 +94,7 @@ The HOME button opens the emulator's menu. Savestates work. - The anti-LibCrypt word some PAL games need in a converted image (PSISOIMG + 0x12B0). ARK-4's popcorn has a table of them by disc ID, switched off. Converting tools can patch the image instead. - ARK-4's per-game CONFIG.BIN next to the EBOOT, copied over the image's config (PSISOIMG + 0x420). +- Multi-disc games from disc images (PSTITLEIMG, which has up to five PSISOIMGs), and `.mds`/`.mdf`, CHD + and archived images. - A byte popcorn changes in reads of converted images (0x41B, from 0x27 to 0x55 when 0x41B-0x41E read 27 19 22 41) without knowing why. diff --git a/libretro/Makefile.common b/libretro/Makefile.common index 9b9e86a226..5d3fa5cd13 100644 --- a/libretro/Makefile.common +++ b/libretro/Makefile.common @@ -724,6 +724,7 @@ SOURCES_CXX += \ $(COREDIR)/FileLoaders/RetryingFileLoader.cpp \ $(COREDIR)/FileLoaders/RamCachingFileLoader.cpp \ $(COREDIR)/FileLoaders/LocalFileLoader.cpp \ + $(COREDIR)/FileLoaders/PS1EbootFileLoader.cpp \ $(COREDIR)/FileLoaders/ZipFileLoader.cpp \ $(COREDIR)/CoreTiming.cpp \ $(COREDIR)/CwCheat.cpp \ diff --git a/unittest/TestPS1Eboot.cpp b/unittest/TestPS1Eboot.cpp new file mode 100644 index 0000000000..6aafd86d6c --- /dev/null +++ b/unittest/TestPS1Eboot.cpp @@ -0,0 +1,296 @@ +// Copyright (c) 2012- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +// PS1EbootFileLoader: small disc images in a temp directory, read back through the EBOOT made from them. + +#include +#include +#include +#include + +#include "Common/File/DirListing.h" +#include "Common/File/FileUtil.h" +#include "Common/File/Path.h" +#include "Core/ELF/PBPReader.h" +#include "Core/ELF/ParamSFO.h" +#include "Core/FileLoaders/PS1EbootFileLoader.h" +#include "Core/Loaders.h" + +#include "UnitTest.h" + +static const u32 SECTOR = 2352; +static const u32 BLOCK = 16 * SECTOR + 5; +static const u8 SYNC[12] = { 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00 }; + +static void Put32(u8 *p, u32 v) { + p[0] = v & 0xFF; + p[1] = (v >> 8) & 0xFF; + p[2] = (v >> 16) & 0xFF; + p[3] = v >> 24; +} + +static u32 Get32(const u8 *p) { + return p[0] | (p[1] << 8) | (p[2] << 16) | ((u32)p[3] << 24); +} + +static u8 BCD(u32 v) { + return (u8)(((v / 10) << 4) | (v % 10)); +} + +// The user data of a small Mode 2 Form 1 data track: a PVD at 16, its root directory at 18 with one +// SYSTEM.CNF at 20, and a pattern everywhere else. +static std::vector MakeUserData(u32 sectors) { + std::vector data(sectors * 2048); + for (size_t i = 0; i < data.size(); i++) { + data[i] = (u8)(i * 7 + i / 2048); + } + u8 *pvd = &data[16 * 2048]; + memset(pvd, 0, 2048); + memcpy(pvd, "\x01" "CD001", 6); + Put32(pvd + 156 + 2, 18); + Put32(pvd + 156 + 10, 2048); + u8 *dir = &data[18 * 2048]; + memset(dir, 0, 2048); + const char name[] = "SYSTEM.CNF;1"; + dir[0] = (u8)(33 + sizeof(name) - 1 + 1); + Put32(dir + 2, 20); + const std::string cnf = "BOOT = cdrom:\\SLUS_123.45;1\r\nTCB = 4\r\n"; + Put32(dir + 10, (u32)cnf.size()); + dir[32] = (u8)(sizeof(name) - 1); + memcpy(dir + 33, name, sizeof(name) - 1); + memset(&data[20 * 2048], 0, 2048); + memcpy(&data[20 * 2048], cnf.data(), cnf.size()); + return data; +} + +// Raw sectors around user data, as a .bin has them. The EDC and ECC are a pattern, which nothing checks. +static std::vector MakeRawTrack(const std::vector &user) { + const u32 sectors = (u32)(user.size() / 2048); + std::vector raw(sectors * SECTOR); + for (u32 s = 0; s < sectors; s++) { + u8 *p = &raw[s * SECTOR]; + memcpy(p, SYNC, 12); + const u32 f = s + 150; + p[12] = BCD(f / 4500); + p[13] = BCD(f / 75 % 60); + p[14] = BCD(f % 75); + p[15] = 2; + static const u8 subheader[8] = { 0, 0, 8, 0, 0, 0, 8, 0 }; + memcpy(p + 16, subheader, 8); + memcpy(p + 24, &user[s * 2048], 2048); + for (u32 i = 24 + 2048; i < SECTOR; i++) { + p[i] = (u8)(s + i); + } + } + return raw; +} + +static std::vector MakeAudio(u32 sectors) { + std::vector audio(sectors * SECTOR); + for (size_t i = 0; i < audio.size(); i++) { + audio[i] = (u8)(i * 13 + 5); + } + return audio; +} + +static std::vector ReadAll(FileLoader *loader, s64 pos, size_t size) { + std::vector out(size); + out.resize(loader->ReadAt(pos, size, out.data())); + return out; +} + +// The PSAR's disc image as it should be: each block of 16 sectors, stored, after its DEFLATE header. +static std::vector ExpectedBlocks(const std::vector &disc) { + const u32 blocks = (u32)((disc.size() / SECTOR + 15) / 16); + std::vector out; + for (u32 b = 0; b < blocks; b++) { + static const u8 header[5] = { 0x01, 0x00, 0x93, 0xFF, 0x6C }; + out.insert(out.end(), header, header + 5); + for (u32 i = 0; i < 16 * SECTOR; i++) { + const size_t pos = (size_t)b * 16 * SECTOR + i; + out.push_back(pos < disc.size() ? disc[pos] : 0); + } + } + return out; +} + +static bool CheckEboot(PS1EbootFileLoader &eboot, const std::vector &disc, u32 numTracks) { + EXPECT_TRUE(eboot.IsValid()); + EXPECT_EQ_STR(eboot.DiscID(), std::string("SLUS12345")); + + std::string error; + EXPECT_TRUE(Identify_File(&eboot, &error) == IdentifiedFileType::PSP_PS1_PBP); + PBPReader pbp(&eboot); + EXPECT_TRUE(pbp.IsValid()); + std::vector sfoData; + EXPECT_TRUE(pbp.GetSubFile(PBP_PARAM_SFO, &sfoData)); + ParamSFOData sfo; + EXPECT_TRUE(sfo.ReadSFO(sfoData)); + EXPECT_EQ_STR(sfo.GetValueString("CATEGORY"), std::string("ME")); + EXPECT_EQ_STR(sfo.GetValueString("DISC_ID"), std::string("SLUS12345")); + + const std::vector header = ReadAll(&eboot, 0, 0x28); + EXPECT_EQ_INT(header.size(), 0x28); + const u32 psar = Get32(&header[0x24]); + const std::vector psarHeader = ReadAll(&eboot, psar, 0x1300); + EXPECT_EQ_MEM(&psarHeader[0], "PSISOIMG0000", 12); + EXPECT_EQ_MEM(&psarHeader[0x400], "_SLUS_12345", 12); + + // The TOC: the last track, the lead-out (at 00:02:00 plus the disc's length), and the tracks. + const u32 sectors = (u32)(disc.size() / SECTOR); + const u8 *toc = &psarHeader[0x800]; + EXPECT_EQ_INT(toc[0], 0x41); + EXPECT_EQ_INT(toc[2], 0xA0); + EXPECT_EQ_INT(toc[10 + 2], 0xA1); + EXPECT_EQ_INT(toc[10 + 7], BCD(numTracks)); + EXPECT_EQ_INT(toc[20 + 2], 0xA2); + EXPECT_EQ_INT(toc[20 + 8], BCD((sectors + 150) / 75 % 60)); + EXPECT_EQ_INT(toc[20 + 9], BCD((sectors + 150) % 75)); + EXPECT_EQ_INT(toc[30 + 2], 0x01); + EXPECT_EQ_INT(toc[30 + 8], 0x02); + EXPECT_EQ_INT(toc[30 + 9], 0x00); + + // The index, then the blocks: all of them at once, and reads that start and end mid-sector and + // cross blocks, as POPS's 0x200-aligned ones do. + const u32 blocks = (sectors + 15) / 16; + const std::vector index = ReadAll(&eboot, psar + 0x4000, (blocks + 1) * 32); + for (u32 b = 0; b < blocks; b++) { + EXPECT_EQ_INT(Get32(&index[b * 32]), b * BLOCK); + EXPECT_EQ_INT(index[b * 32 + 4] | (index[b * 32 + 5] << 8), BLOCK); + } + EXPECT_EQ_INT(Get32(&index[blocks * 32]), 0); + const std::vector expected = ExpectedBlocks(disc); + const s64 data = psar + 0x100000; + EXPECT_EQ_INT(eboot.FileSize(), data + expected.size()); + const std::vector all = ReadAll(&eboot, data, expected.size() + 100); + EXPECT_EQ_INT(all.size(), expected.size()); + EXPECT_TRUE(all == expected); + const size_t starts[] = { 0, 3, 0xB3, BLOCK - 1, BLOCK + 5 + SECTOR - 7, expected.size() - 0x300 }; + for (size_t start : starts) { + const size_t len = std::min((size_t)0x9400, expected.size() - start); + const std::vector part = ReadAll(&eboot, data + start, len); + EXPECT_EQ_INT(part.size(), len); + EXPECT_TRUE(memcmp(part.data(), &expected[start], len) == 0); + } + return true; +} + +bool TestPS1Eboot() { + const Path dir = UnitTestTempPath("unittest_ps1eboot"); + File::DeleteDirRecursively(dir); + EXPECT_TRUE(File::CreateFullPath(dir)); + + const std::vector user = MakeUserData(40); + const std::vector data = MakeRawTrack(user); + const std::vector audio = MakeAudio(20); + EXPECT_TRUE(File::WriteDataToFile(false, data.data(), data.size(), dir / "Game (Track 1).bin")); + EXPECT_TRUE(File::WriteDataToFile(false, audio.data(), audio.size(), dir / "Game (Track 2).bin")); + + // A data track and an audio track in two files, the audio track's pregap ripped (INDEX 00). + const std::string cue = + "FILE \"Game (Track 1).bin\" BINARY\r\n" + " TRACK 01 MODE2/2352\r\n" + " INDEX 01 00:00:00\r\n" + "FILE \"Game (Track 2).bin\" BINARY\r\n" + " TRACK 02 AUDIO\r\n" + " INDEX 00 00:00:00\r\n" + " INDEX 01 00:00:05\r\n"; + EXPECT_TRUE(File::WriteStringToFile(true, cue, dir / "Game.cue")); + std::vector disc = data; + disc.insert(disc.end(), audio.begin(), audio.end()); + { + PS1EbootFileLoader eboot(dir / "Game.cue"); + if (!CheckEboot(eboot, disc, 2)) { + return false; + } + EXPECT_EQ_STR(eboot.Title(), std::string("Game")); + // Track 2's pregap starts at LBA 40, and the track at 45. + const std::vector toc = ReadAll(&eboot, Get32(&ReadAll(&eboot, 0, 0x28)[0x24]) + 0x800 + 40, 10); + EXPECT_EQ_INT(toc[0], 0x01); + EXPECT_EQ_INT(toc[2], 0x02); + EXPECT_EQ_INT(toc[5], BCD(190 % 75)); + EXPECT_EQ_INT(toc[9], BCD(195 % 75)); + } + // The first file opened on its own: the cue sheet that names it describes it. + { + EXPECT_TRUE(FindCueSheetFor(dir / "Game (Track 1).bin") == dir / "Game.cue"); + PS1EbootFileLoader eboot(dir / "Game (Track 1).bin"); + if (!CheckEboot(eboot, disc, 2)) { + return false; + } + } + + // A PREGAP that isn't in the file: generated silence before the track. + const std::string pregapCue = + "FILE \"Game (Track 1).bin\" BINARY\r\n" + " TRACK 01 MODE2/2352\r\n" + " INDEX 01 00:00:00\r\n" + "FILE \"Game (Track 2).bin\" BINARY\r\n" + " TRACK 02 AUDIO\r\n" + " PREGAP 00:00:03\r\n" + " INDEX 01 00:00:00\r\n"; + EXPECT_TRUE(File::WriteStringToFile(true, pregapCue, dir / "Pregap.cue")); + { + std::vector gapped = data; + gapped.resize(gapped.size() + 3 * SECTOR, 0); + gapped.insert(gapped.end(), audio.begin(), audio.end()); + PS1EbootFileLoader eboot(dir / "Pregap.cue"); + if (!CheckEboot(eboot, gapped, 2)) { + return false; + } + } + + // A CloneCD image, and a bare .iso of only the user data, whose sectors are made around it. + EXPECT_TRUE(File::WriteDataToFile(false, data.data(), data.size(), dir / "Clone.img")); + EXPECT_TRUE(File::WriteStringToFile(true, "[CloneCD]\r\nVersion=3\r\n[TRACK 1]\r\nMODE=2\r\nINDEX 1=0\r\n", dir / "Clone.ccd")); + { + PS1EbootFileLoader eboot(dir / "Clone.ccd"); + if (!CheckEboot(eboot, data, 1)) { + return false; + } + } + EXPECT_TRUE(File::WriteDataToFile(false, user.data(), user.size(), dir / "Cooked.iso")); + { + std::vector cooked = data; + for (u32 s = 0; s < 40; s++) { + memset(&cooked[s * SECTOR + 24 + 2048], 0, SECTOR - 24 - 2048); + } + PS1EbootFileLoader eboot(dir / "Cooked.iso"); + if (!CheckEboot(eboot, cooked, 1)) { + return false; + } + } + + // The browser shows each disc once: by its cue sheet or .ccd, and a .bin of its own only if it + // could be a disc image. + const std::vector loose(3 * SECTOR, 1); + EXPECT_TRUE(File::WriteDataToFile(false, loose.data(), loose.size(), dir / "Loose.bin")); + EXPECT_TRUE(File::WriteDataToFile(false, loose.data(), 100, dir / "Junk.bin")); + std::vector files; + File::GetFilesInDir(dir, &files); + RemoveDescribedDiscImages(&files); + std::vector names; + for (const File::FileInfo &info : files) { + names.push_back(info.name); + } + std::sort(names.begin(), names.end()); + const std::vector expectedNames = { "Clone.ccd", "Cooked.iso", "Game.cue", "Loose.bin", "Pregap.cue" }; + EXPECT_TRUE(names == expectedNames); + + File::DeleteDirRecursively(dir); + return true; +} diff --git a/unittest/UnitTest.cpp b/unittest/UnitTest.cpp index c8637747b3..772d3d67c5 100644 --- a/unittest/UnitTest.cpp +++ b/unittest/UnitTest.cpp @@ -3551,6 +3551,7 @@ bool TestIRPassSimplify(); bool TestThreadManager(); bool TestVFS(); bool TestZipSlip(); +bool TestPS1Eboot(); bool TestLzrc(); bool TestCwCheat(); bool TestMpegCsc(); @@ -4076,6 +4077,7 @@ TestItem availableTests[] = { TEST_ITEM(Lang), TEST_ITEM(CmdLine), TEST_ITEM(ZipSlip), + TEST_ITEM(PS1Eboot), TEST_ITEM(Lzrc), TEST_ITEM(CwCheat), TEST_ITEM(MpegCsc), diff --git a/unittest/UnitTests.vcxproj b/unittest/UnitTests.vcxproj index e8efa4eb2a..098a70bac7 100644 --- a/unittest/UnitTests.vcxproj +++ b/unittest/UnitTests.vcxproj @@ -306,6 +306,7 @@ + true diff --git a/unittest/UnitTests.vcxproj.filters b/unittest/UnitTests.vcxproj.filters index 5db81b7bbb..ae2011e25a 100644 --- a/unittest/UnitTests.vcxproj.filters +++ b/unittest/UnitTests.vcxproj.filters @@ -29,6 +29,7 @@ +