From 23dffa9db3e93a8ef142b89490c3fa5217e5765d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 23 Oct 2026 13:42:56 +0200 Subject: [PATCH] POPS: Boot PS1 disc images as converted EBOOTs made on the fly A cue sheet, a CloneCD .ccd, or a bare .bin, .img or .iso of a PS1 disc now boots on the firmware's POPS, as the converted EBOOT.PBP popstation would make from it. PS1EbootFileLoader builds the PBP header, PARAM.SFO (the disc ID from SYSTEM.CNF) and the PSISOIMG header, TOC and block index, and serves the disc image from the image's own files. Each block of 16 sectors is a stored DEFLATE block: the manager won't read an image of uncompressed blocks in its own format, but the converted PBP's inflate takes stored ones, and with every block the same size, reads map straight onto the files. Nothing is compressed or written anywhere. Cue sheets can have any number of files, AUDIO tracks, INDEX 00 and PREGAP, and a missing FILE falls back to the cue sheet's own name. A 2048-byte .iso gets Mode 2 Form 1 sectors made around its user data. The EBOOT is mounted as the only file in host0:, through BlobFileSystem, which now saves its open handles in savestates. The game browser lists .cue, .ccd, .bin and .img, leaving out the .bin and .img files a cue sheet or .ccd next to them describes, and a bare image opened directly goes through the cue sheet that names it. GameInfoCache reads the title and ID of these, and of real PS1 EBOOTs, from their PARAM.SFO. Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 1 + Core/CMakeLists.txt | 2 + Core/Core.vcxproj | 2 + Core/Core.vcxproj.filters | 6 + Core/FileLoaders/PS1EbootFileLoader.cpp | 755 ++++++++++++++++++++++++ Core/FileLoaders/PS1EbootFileLoader.h | 110 ++++ Core/FileSystems/BlobFileSystem.cpp | 8 +- Core/FileSystems/BlobFileSystem.h | 4 +- Core/HLE/PopsBoot.cpp | 4 + Core/Loaders.cpp | 15 + Core/PSPLoaders.cpp | 15 +- Core/System.cpp | 7 +- UI/GameBrowser.cpp | 5 +- UI/GameInfoCache.cpp | 6 + UI/GameScreen.cpp | 1 - UWP/CoreUWP/CoreUWP.vcxproj | 2 + UWP/CoreUWP/CoreUWP.vcxproj.filters | 2 + android/jni/Android.mk | 2 + docs/POPS.md | 18 + libretro/Makefile.common | 1 + unittest/TestPS1Eboot.cpp | 296 ++++++++++ unittest/UnitTest.cpp | 2 + unittest/UnitTests.vcxproj | 1 + unittest/UnitTests.vcxproj.filters | 1 + 24 files changed, 1256 insertions(+), 10 deletions(-) create mode 100644 Core/FileLoaders/PS1EbootFileLoader.cpp create mode 100644 Core/FileLoaders/PS1EbootFileLoader.h create mode 100644 unittest/TestPS1Eboot.cpp 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 @@ +