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/ELF/ParamSFO.cpp b/Core/ELF/ParamSFO.cpp index d5c010f1b7..3a7745af14 100644 --- a/Core/ELF/ParamSFO.cpp +++ b/Core/ELF/ParamSFO.cpp @@ -473,6 +473,16 @@ GameRegion DetectGameRegionFromID(std::string_view id_full) { * 'X' -> first-party Minis * 'Z' -> third-party Minis */ + } else if (id_letters[0] == 'S' && (id_letters[1] == 'L' || id_letters[1] == 'C' || id_letters[1] == 'I')) { + // PS1 discs: (S)ony, then (L)icensed, (C)opyrighted or (I)nternational, then the region. + switch (id_letters[2]) { + case 'U': return GameRegion::USA; + case 'E': return GameRegion::EUROPE; + case 'P': return GameRegion::JAPAN; + case 'K': return GameRegion::KOREA; + case 'A': return GameRegion::ASIA; + default: break; + } // Misc patterns } else if (id_letters == "UTST") { return GameRegion::TEST; diff --git a/Core/FileLoaders/PS1EbootFileLoader.cpp b/Core/FileLoaders/PS1EbootFileLoader.cpp new file mode 100644 index 0000000000..36b22bae30 --- /dev/null +++ b/Core/FileLoaders/PS1EbootFileLoader.cpp @@ -0,0 +1,1053 @@ +// 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 + +#include "libchdr/cdrom.h" +#include "libchdr/chd.h" + +#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" +#include "Core/FileSystems/BlockDevices.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 IsPS1DiscSheet(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 == ".mds") { + return startsWith(text, "MEDIA DESCRIPTOR"); + } + 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::string text; + if (!ReadSmallTextFile(cuePath, &text)) { + return {}; + } + return CueSheetFileNamesFromText(text); +} + +std::vector CueSheetFileNamesFromText(std::string_view text) { + std::vector 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()); +} + +bool FindPS1DiscFiles(const std::vector &entries, const std::function &readText, std::vector *discFiles) { + std::map byLowerName; + for (const ArchiveEntry &entry : entries) { + byLowerName[AsciiToLower(ReplaceAll(entry.name, "\\", "/"))] = entry.name; + } + auto find = [&](const std::string &name, std::string *found) { + auto it = byLowerName.find(AsciiToLower(ReplaceAll(name, "\\", "/"))); + if (it == byLowerName.end()) { + return false; + } + *found = it->second; + return true; + }; + auto extension = [](const std::string &name) { + const size_t dot = name.find_last_of("./"); + return dot != std::string::npos && name[dot] == '.' ? AsciiToLower(name.substr(dot)) : std::string(); + }; + + for (const ArchiveEntry &entry : entries) { + const std::string ext = extension(entry.name); + const std::string stem = entry.name.substr(0, entry.name.size() - ext.size()); + const size_t slash = entry.name.find_last_of("/\\"); + const std::string dir = slash == std::string::npos ? "" : entry.name.substr(0, slash + 1); + std::vector files = { entry.name }; + std::string found; + if (ext == ".cue") { + std::string text; + if (!readText(entry.name, &text)) { + continue; + } + const std::vector names = CueSheetFileNamesFromText(text); + bool all = !names.empty(); + for (const std::string &name : names) { + if (find(dir + name, &found) || (names.size() == 1 && (find(stem + ".bin", &found) || find(stem + ".img", &found)))) { + files.push_back(found); + } else { + all = false; + } + } + if (!all) { + continue; + } + } else if (ext == ".ccd") { + if (!find(stem + ".img", &found)) { + continue; + } + files.push_back(found); + if (find(stem + ".sub", &found)) { + files.push_back(found); + } + } else if (ext == ".mds") { + if (!find(stem + ".mdf", &found)) { + continue; + } + files.push_back(found); + } else { + continue; + } + *discFiles = files; + return true; + } + + const ArchiveEntry *largest = nullptr; + for (const ArchiveEntry &entry : entries) { + const std::string ext = extension(entry.name); + if ((ext == ".bin" || ext == ".img") && entry.size > 0 && entry.size % SECTOR_SIZE == 0 && (!largest || entry.size > largest->size)) { + largest = &entry; + } + } + if (largest) { + *discFiles = { largest->name }; + return true; + } + return false; +} + +PS1EbootFileLoader::PS1EbootFileLoader(const Path &path) : path_(path) { + const std::string ext = path.GetFileExtension(); + std::string text; + bool parsed = false; + if (ext == ".cue" || ext == ".ccd" || ext == ".mds") { + if (!ReadSmallTextFile(path, &text)) { + error_ = "Can't read " + path.GetFilename(); + return; + } + if (ext == ".cue") { + parsed = ParseCue(path, text); + } else if (ext == ".ccd") { + parsed = ParseCcd(path, text); + } else { + parsed = ParseMds(path, text); + } + } else if (ext == ".chd") { + parsed = OpenCHD(path); + } 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"); + const Path mds = path.WithReplacedExtension(".mds"); + if (ReadSmallTextFile(cue, &text) && ParseCue(cue, text)) { + parsed = true; + } else if (ReadSmallTextFile(ccd, &text) && ParseCcd(ccd, text)) { + parsed = true; + } else if (ReadSmallTextFile(mds, &text) && ParseMds(mds, 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() { + // It reads through files_. + if (chd_) { + chd_close(chd_); + } +} + +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, bool byteSwapped, int stride) { + if (count != 0) { + extents_.push_back(Extent{ lba, count, file, offset, sectorSize, byteSwapped, stride ? stride : sectorSize }); + } +} + +// An Alcohol 120% .mds: a header, sessions (only the first is read) and their track blocks of 0x50 bytes, +// each with an extra block (pregap, length) and the image file's name. A track's length in sectors is +// stored from its offset in the .mdf, from its pregap's start (but never before LBA 0), and anything +// between tracks isn't stored. +bool PS1EbootFileLoader::ParseMds(const Path &mdsPath, const std::string &data) { + const u8 *d = (const u8 *)data.data(); + const size_t size = data.size(); + auto read32 = [&](size_t offset) -> u32 { + return offset + 4 <= size ? (d[offset] | (d[offset + 1] << 8) | (d[offset + 2] << 16) | ((u32)d[offset + 3] << 24)) : 0; + }; + if (size < 0x58 || memcmp(d, "MEDIA DESCRIPTOR", 16) != 0) { + return false; + } + const u32 session = read32(0x50); + if (session + 0x18 > size) { + return false; + } + const u32 numBlocks = d[session + 0x0A]; + const u32 trackBlocks = read32(session + 0x14); + + int file = -1; + u32 lba = 0; + for (u32 i = 0; i < numBlocks; i++) { + const size_t b = trackBlocks + (size_t)i * 0x50; + if (b + 0x50 > size) { + return false; + } + const u8 point = d[b + 4]; + if (point == 0 || point > 99) { + continue; + } + const u32 extra = read32(b + 0x0C); + const int stride = d[b + 0x10] | (d[b + 0x11] << 8); + const u32 start = read32(b + 0x24); + const s64 offset = (s64)read32(b + 0x28) | ((s64)read32(b + 0x2C) << 32); + const u32 pregap = read32(extra); + const u32 length = read32(extra + 4); + const int sectorSize = stride >= (int)SECTOR_SIZE ? (int)SECTOR_SIZE : stride; + if (sectorSize != (int)SECTOR_SIZE && sectorSize != 2336 && sectorSize != 2048) { + error_ = "Unsupported sector size in " + mdsPath.GetFilename(); + return false; + } + + if (file < 0) { + // "*.mdf" is the .mds's own name. + const u32 nameBlock = read32(b + 0x34); + const u32 nameOffset = read32(nameBlock); + std::string name; + for (u32 p = nameOffset; p < size && d[p] != 0; p += d[nameBlock + 4] ? 2 : 1) { + name.push_back((char)d[p]); + } + const Path mdf = startsWith(name, "*") ? mdsPath.WithReplacedExtension(name.size() > 1 ? name.substr(1) : ".mdf") : mdsPath.NavigateUp() / name; + file = AddFile(mdf); + if (file < 0) { + error_ = "Missing " + mdf.GetFilename(); + return false; + } + } + + const u32 first = start > pregap ? start - pregap : 0; + if (first < lba) { + return false; + } + AddExtent(lba, first - lba, GENERATED, 0, SECTOR_SIZE); + AddExtent(first, length, file, offset, sectorSize, false, stride); + lba = first + length; + tracks_.push_back(Track{ point, (d[b] & 0x0F) == 0x09, start, start - first }); + } + return !tracks_.empty(); +} + +static int CHDTrackSectorSize(std::string_view type) { + if (type == "AUDIO" || type == "MODE1_RAW" || type == "MODE2_RAW") { + return 2352; + } else if (type == "MODE2" || type == "MODE2_FORM_MIX") { + return 2336; + } else if (type == "MODE1" || type == "MODE2_FORM1") { + return 2048; + } + return 0; +} + +// A CD CHD's tracks are in its metadata, one entry each, and its frames (2352 bytes of sector and 96 of +// subcode) are in hunks of several. Each track's frames are padded to a multiple of four, and a pregap is +// in them only if its type starts with V. +bool PS1EbootFileLoader::OpenCHD(const Path &path) { + const int file = AddFile(path); + if (file < 0) { + return false; + } + chd_ = OpenCHDThroughLoader(files_[file].get(), &error_); + if (!chd_) { + return false; + } + const chd_header *header = chd_get_header(chd_); + if (header->unitbytes != CD_FRAME_SIZE || header->hunkbytes % CD_FRAME_SIZE != 0) { + error_ = "Not a CD image: " + path.GetFilename(); + return false; + } + framesPerHunk_ = header->hunkbytes / CD_FRAME_SIZE; + hunk_.resize(header->hunkbytes); + + u32 lba = 0; + u32 frame = 0; + for (u32 i = 0; i < 99; i++) { + char meta[256]{}; + uint32_t len = 0; + int number = 0, frames = 0, pregap = 0, postgap = 0; + char type[32]{}, subtype[32]{}, pgtype[32]{}, pgsub[32]{}; + if (chd_get_metadata(chd_, CDROM_TRACK_METADATA2_TAG, i, meta, sizeof(meta) - 1, &len, nullptr, nullptr) == CHDERR_NONE) { + if (sscanf(meta, "TRACK:%d TYPE:%31s SUBTYPE:%31s FRAMES:%d PREGAP:%d PGTYPE:%31s PGSUB:%31s POSTGAP:%d", &number, type, subtype, &frames, &pregap, pgtype, pgsub, &postgap) != 8) { + return false; + } + } else if (chd_get_metadata(chd_, CDROM_TRACK_METADATA_TAG, i, meta, sizeof(meta) - 1, &len, nullptr, nullptr) == CHDERR_NONE) { + if (sscanf(meta, "TRACK:%d TYPE:%31s SUBTYPE:%31s FRAMES:%d", &number, type, subtype, &frames) != 4) { + return false; + } + } else { + break; + } + const int sectorSize = CHDTrackSectorSize(type); + if (sectorSize == 0 || frames < 0 || pregap < 0 || postgap < 0 || pregap > frames + pregap) { + error_ = std::string("Unsupported track type in CHD: ") + type; + return false; + } + const bool audio = strcmp(type, "AUDIO") == 0; + const bool pregapStored = pgtype[0] == 'V'; + if (!pregapStored) { + AddExtent(lba, pregap, GENERATED, 0, SECTOR_SIZE); + lba += pregap; + } + AddExtent(lba, frames, CHD_SOURCE, frame, sectorSize, audio); + tracks_.push_back(Track{ number, audio, pregapStored ? lba + pregap : lba, (u32)pregap }); + lba += frames; + AddExtent(lba, postgap, GENERATED, 0, SECTOR_SIZE); + lba += postgap; + frame += (frames + CD_TRACK_PADDING - 1) / CD_TRACK_PADDING * CD_TRACK_PADDING; + } + if (tracks_.empty()) { + error_ = "No tracks in CHD: " + path.GetFilename(); + return false; + } + return true; +} + +bool IsCDChd(FileLoader *fileLoader) { + std::string error; + chd_file *chd = OpenCHDThroughLoader(fileLoader, &error); + if (!chd) { + return false; + } + const bool cd = chd_get_header(chd)->unitbytes == CD_FRAME_SIZE; + chd_close(chd); + return cd; +} + +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, GENERATED, 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 u32 index = lba - e.lba; + if (e.file == GENERATED) { + memset(out, 0, (size_t)run * SECTOR_SIZE); + } else if (e.file >= 0 && e.sectorSize == (int)SECTOR_SIZE && e.stride == (int)SECTOR_SIZE) { + const size_t want = (size_t)run * SECTOR_SIZE; + const size_t got = files_[e.file]->ReadAt(e.offset + (s64)index * SECTOR_SIZE, want, out); + if (got < want) { + memset(out + got, 0, want - got); + ok = false; + } + } else if (e.file >= 0 && e.sectorSize == (int)SECTOR_SIZE) { + // Raw sectors with subchannel data after each: one read, then without it. + std::vector buf((size_t)run * e.stride); + const size_t got = files_[e.file]->ReadAt(e.offset + (s64)index * e.stride, buf.size(), buf.data()); + if (got < buf.size()) { + memset(buf.data() + got, 0, buf.size() - got); + ok = false; + } + for (u32 i = 0; i < run; i++) { + memcpy(out + (size_t)i * SECTOR_SIZE, buf.data() + (size_t)i * e.stride, SECTOR_SIZE); + } + } else { + // One at a time. Sectors without their sync and header (2336), or only their user data (2048, + // which can only be Mode 2 Form 1), get them made. 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); + u8 *data = sector; + if (e.sectorSize != (int)SECTOR_SIZE) { + memcpy(sector, SYNC, sizeof(SYNC)); + LBAToMSF(lba + i, sector + 12); + sector[15] = 2; + 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; + } + } + ok = ReadSourceSector(e, index + i, data) && ok; + } + } + lba += run; + count -= run; + out += (size_t)run * SECTOR_SIZE; + } + return ok; +} + +// One sector's stored bytes (e.sectorSize of them) from an extent's file or the CHD. +bool PS1EbootFileLoader::ReadSourceSector(const Extent &e, u32 index, u8 *out) { + if (e.file >= 0) { + return files_[e.file]->ReadAt(e.offset + (s64)index * e.stride, e.sectorSize, out) == (size_t)e.sectorSize; + } + const u32 frame = (u32)e.offset + index; + const u32 hunk = frame / framesPerHunk_; + std::lock_guard guard(chdLock_); + if (hunk != cachedHunk_) { + if (chd_read(chd_, hunk, hunk_.data()) != CHDERR_NONE) { + cachedHunk_ = 0xFFFFFFFF; + return false; + } + cachedHunk_ = hunk; + } + const u8 *src = hunk_.data() + (size_t)(frame % framesPerHunk_) * CD_FRAME_SIZE; + if (e.byteSwapped) { + for (int i = 0; i + 1 < e.sectorSize; i += 2) { + out[i] = src[i + 1]; + out[i + 1] = src[i]; + } + } else { + memcpy(out, src, e.sectorSize); + } + return true; +} + +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..df2d71b015 --- /dev/null +++ b/Core/FileLoaders/PS1EbootFileLoader.h @@ -0,0 +1,143 @@ +// 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 +#include + +#include "Common/CommonTypes.h" +#include "Core/Loaders.h" + +// A PS1 disc image (a cue sheet, a CloneCD .ccd, an Alcohol .mds, a CHD, or a bare .bin/.img/.iso) presented as a converted +// PS1 EBOOT.PBP, the kind popstation makes, for the firmware's POPS to run. Only the headers are kept +// in memory. The disc image is stored uncompressed, as stored DEFLATE blocks, so a read of it goes +// straight to the image's files. See docs/POPS.md. +class PS1EbootFileLoader : public FileLoader { +public: + explicit PS1EbootFileLoader(const Path &path); + ~PS1EbootFileLoader(); + + bool IsValid() const { return error_.empty(); } + + bool Exists() override { return IsValid(); } + bool IsDirectory() override { return false; } + s64 FileSize() override { return size_; } + Path GetPath() const override { return path_; } + std::string GetFileExtension() const override { return ".pbp"; } + size_t ReadAt(s64 absolutePos, size_t bytes, size_t count, void *data, Flags flags = Flags::NONE) override; + using FileLoader::ReadAt; + void Cancel() override; + std::string LatestError() const override { return error_; } + + const std::string &DiscID() const { return 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, the CHD (CHD_SOURCE, offset in frames), or (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 byteSwapped; // CD audio in a CHD is big-endian. + int stride; // Bytes from one sector to the next in a file, more than sectorSize with subchannel data. + }; + static const int GENERATED = -1; + static const int CHD_SOURCE = -2; + + bool ParseCue(const Path &cuePath, const std::string &text); + bool ParseCcd(const Path &ccdPath, const std::string &text); + bool OpenBare(const Path &path); + bool OpenCHD(const Path &path); + bool ParseMds(const Path &mdsPath, const std::string &data); + int AddFile(const Path &path); + void AddExtent(u32 lba, u32 count, int file, s64 offset, int sectorSize, bool byteSwapped = false, int stride = 0); + + bool ReadSourceSector(const Extent &e, u32 index, u8 *out); + bool ReadRawSectors(u32 lba, u32 count, u8 *out); + bool ReadUserData(u32 lba, u8 *out); + void ReadDiscID(); + void BuildHeaders(); + + Path path_; + std::string error_; + std::vector> files_; + std::vector extents_; + std::vector tracks_; + u32 numSectors_ = 0; + + // A CHD, read a hunk (several frames) at a time. + struct _chd_file *chd_ = nullptr; + std::mutex chdLock_; + std::vector hunk_; + u32 cachedHunk_ = 0xFFFFFFFF; + u32 framesPerHunk_ = 0; + + std::string discID_; + std::string title_; + + // The PBP up to the PSAR's block index, then the index and the blocks, which are made on the fly. + std::vector head_; + u32 psarOffset_ = 0; + u32 numBlocks_ = 0; + s64 size_ = 0; +}; + +namespace File { +struct FileInfo; +} + +// Whether a .cue, .ccd or .mds describes a disc, by its contents. +bool IsPS1DiscSheet(FileLoader *fileLoader); + +// Whether a CHD is of a CD (rather than a DVD, like a PSP disc). +bool IsCDChd(FileLoader *fileLoader); + +// The file names a cue sheet's FILE lines give. +std::vector CueSheetFileNames(const Path &cuePath); +std::vector CueSheetFileNamesFromText(std::string_view text); + +// A file in an archive: its path there, and its size. +struct ArchiveEntry { + std::string name; + s64 size; +}; + +// The files of a PS1 disc among an archive's, by their paths in it: a cue sheet and the files it names, +// a .ccd with its .img (and .sub), an .mds with its .mdf, or else the largest .bin or .img that's a whole +// number of sectors. +// readText reads a small file from the archive. +bool FindPS1DiscFiles(const std::vector &entries, const std::function &readText, std::vector *discFiles); + +// 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/FileSystems/BlockDevices.cpp b/Core/FileSystems/BlockDevices.cpp index 68d9469860..e87adbaf31 100644 --- a/Core/FileSystems/BlockDevices.cpp +++ b/Core/FileSystems/BlockDevices.cpp @@ -1061,19 +1061,14 @@ struct ExtendedCoreFile { uint64_t seekPos; }; -CHDFileBlockDevice::CHDFileBlockDevice(FileLoader *fileLoader) - : BlockDevice(fileLoader), impl_(new CHDImpl()) { - Path paths[8]; - paths[0] = fileLoader->GetPath(); - int depth = 0; - - core_file_ = new ExtendedCoreFile(); - core_file_->core.argp = fileLoader; - core_file_->core.fsize = [](core_file *file) -> uint64_t { +chd_file *OpenCHDThroughLoader(FileLoader *fileLoader, std::string *error) { + ExtendedCoreFile *coreFile = new ExtendedCoreFile(); + coreFile->core.argp = fileLoader; + coreFile->core.fsize = [](core_file *file) -> uint64_t { FileLoader *loader = (FileLoader *)file->argp; return loader->FileSize(); }; - core_file_->core.fseek = [](core_file *file, int64_t offset, int seekType) -> int { + coreFile->core.fseek = [](core_file *file, int64_t offset, int seekType) -> int { ExtendedCoreFile *coreFile = (ExtendedCoreFile *)file; switch (seekType) { case SEEK_SET: @@ -1093,7 +1088,7 @@ CHDFileBlockDevice::CHDFileBlockDevice(FileLoader *fileLoader) } return 0; }; - core_file_->core.fread = [](void *out_data, size_t size, size_t count, core_file *file) { + coreFile->core.fread = [](void *out_data, size_t size, size_t count, core_file *file) { ExtendedCoreFile *coreFile = (ExtendedCoreFile *)file; FileLoader *loader = (FileLoader *)file->argp; uint64_t totalSize = size * count; @@ -1101,12 +1096,24 @@ CHDFileBlockDevice::CHDFileBlockDevice(FileLoader *fileLoader) coreFile->seekPos += totalSize; return size * count; }; - core_file_->core.fclose = [](core_file *file) { + coreFile->core.fclose = [](core_file *file) { ExtendedCoreFile *coreFile = (ExtendedCoreFile *)file; delete coreFile; return 0; }; + chd_file *chd = nullptr; + chd_error err = chd_open_core_file(&coreFile->core, CHD_OPEN_READ, NULL, &chd); + if (err != CHDERR_NONE) { + *error = StringFromFormat("CHD error: %s: %s", fileLoader->GetPath().c_str(), chd_error_string(err)); + return nullptr; + } + return chd; +} + +CHDFileBlockDevice::CHDFileBlockDevice(FileLoader *fileLoader) + : BlockDevice(fileLoader), impl_(new CHDImpl()) { + /* // TODO: Support parent/child CHD files. @@ -1158,10 +1165,8 @@ CHDFileBlockDevice::CHDFileBlockDevice(FileLoader *fileLoader) } */ - chd_file *file = nullptr; - chd_error err = chd_open_core_file(&core_file_->core, CHD_OPEN_READ, NULL, &file); - if (err != CHDERR_NONE) { - errorString_ = StringFromFormat("CHD error: %s: %s", paths[depth].c_str(), chd_error_string(err)); + chd_file *file = OpenCHDThroughLoader(fileLoader, &errorString_); + if (!file) { return; } diff --git a/Core/FileSystems/BlockDevices.h b/Core/FileSystems/BlockDevices.h index 826134c0b0..ce5bb9aba4 100644 --- a/Core/FileSystems/BlockDevices.h +++ b/Core/FileSystems/BlockDevices.h @@ -196,7 +196,10 @@ private: struct CHDImpl; -struct ExtendedCoreFile; +struct _chd_file; + +// Opens a CHD that's read through fileLoader, which has to outlive it. Null, and *error set, if it can't. +struct _chd_file *OpenCHDThroughLoader(FileLoader *fileLoader, std::string *error); class CHDFileBlockDevice : public BlockDevice { public: @@ -207,7 +210,6 @@ public: u32 GetNumBlocks() const override { return numBlocks; } bool IsDisc() const override { return true; } private: - struct ExtendedCoreFile *core_file_ = nullptr; std::unique_ptr impl_; u8 *readBuffer = nullptr; u32 currentHunk = 0; 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..69e161e884 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" @@ -108,9 +109,21 @@ IdentifiedFileType Identify_File(FileLoader *fileLoader, std::string *errorStrin return IdentifiedFileType::ERROR_IDENTIFYING; } + if (extension == ".chd" && IsCDChd(fileLoader)) { + // PSP discs are DVDs, so a CD is a PS1 disc. + return IdentifiedFileType::PSX_ISO; + } + bool isDiscImage = false; if (extension == ".iso" || extension == ".cso" || extension == ".chd") { isDiscImage = true; + } else if (extension == ".cue" || extension == ".ccd" || extension == ".mds") { + // A PS1 disc, which boots as a converted EBOOT (see PS1EbootFileLoader). + return IsPS1DiscSheet(fileLoader) ? IdentifiedFileType::PSX_ISO : IdentifiedFileType::UNKNOWN; + } else if (extension == ".mdf") { + // Read through its .mds, which says how. + std::unique_ptr mds(ConstructFileLoader(fileLoader->GetPath().WithReplacedExtension(".mds"))); + return mds && mds->Exists() && IsPS1DiscSheet(mds.get()) ? IdentifiedFileType::PSX_ISO : IdentifiedFileType::UNKNOWN; } else if (extension == ".ppst") { return IdentifiedFileType::PPSSPP_SAVESTATE; } else if (extension == ".ppdmp") { @@ -302,6 +315,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. @@ -584,6 +608,37 @@ bool DetectArchiveContents(VFSInterface *vfs, ZipFileInfo *info) { return true; } } + + // A PS1 disc in the root or one level down, as in a zip. + std::vector entries; + for (const File::FileInfo &file : listing) { + if (!file.isDirectory) { + entries.push_back(ArchiveEntry{ file.fullName.ToString(), (s64)file.size }); + continue; + } + std::vector subListing; + vfs->GetFileListing(file.fullName.ToString(), &subListing, nullptr); + for (const File::FileInfo &sub : subListing) { + if (!sub.isDirectory) { + entries.push_back(ArchiveEntry{ sub.fullName.ToString(), (s64)sub.size }); + } + } + } + if (FindPS1DiscFiles(entries, [vfs](const std::string &name, std::string *text) { + size_t size = 0; + uint8_t *data = vfs->ReadFile(name, &size); + if (!data || size > 64 * 1024) { + delete[] data; + return false; + } + text->assign((const char *)data, size); + delete[] data; + return true; + }, &info->discFiles)) { + info->contents = ZipFileContents::PSX_DISC; + info->contentName = info->discFiles[0]; + return true; + } return false; } @@ -618,6 +673,7 @@ void DetectZipFileContents(zip_t *z, ZipFileInfo *info) { bool hasPRX = false; bool hasPluginIni = false; s64 totalFileSize = 0; + std::vector archiveEntries; // In the root or one level down, where a disc image can be. // TODO: It might be cleaner to write separate detection functions, but this big loop doing it all at once // is quite convenient and makes it easy to add shared heuristics. @@ -644,6 +700,9 @@ void DetectZipFileContents(zip_t *z, ZipFileInfo *info) { int prevSlashLocation = -1; int slashCount = countSlashes(lowerCaseName, &prevSlashLocation); + if (slashCount <= 1) { + archiveEntries.push_back(ArchiveEntry{ fileName, (s64)stat.size }); + } if (lowerCaseName.find("eboot.pbp") != std::string::npos) { if (slashCount >= 1 && (!isPSPMemstickGame || prevSlashLocation < stripChars + 1)) { stripChars = prevSlashLocation + 1; @@ -739,11 +798,23 @@ void DetectZipFileContents(zip_t *z, ZipFileInfo *info) { info->ignoreMetaFiles = false; info->totalFileSize = totalFileSize; + // A PS1 disc: a cue sheet, .ccd or .mds and its files, or a lone raw image, which only counts if nothing else does. + const bool isPS1Disc = FindPS1DiscFiles(archiveEntries, [z](const std::string &name, std::string *text) { + const zip_int64_t index = zip_name_locate(z, name.c_str(), 0); + zip_stat_t stat{}; + return index >= 0 && zip_stat_index(z, index, 0, &stat) >= 0 && stat.size < 64 * 1024 && ZipExtractFileToMemory(z, (int)index, text); + }, &info->discFiles); + const std::string discExt = isPS1Disc ? Path(info->discFiles[0]).GetFileExtension() : ""; + const bool isPS1DiscWithCue = discExt == ".cue" || discExt == ".ccd" || discExt == ".mds"; + // Priority ordering for detecting the various kinds of zip file content.s if (isPSPMemstickGame) { info->contents = ZipFileContents::PSP_GAME_DIR; } else if (isZippedISO) { info->contents = ZipFileContents::ISO_FILE; + } else if (isPS1DiscWithCue) { + info->contents = ZipFileContents::PSX_DISC; + info->contentName = info->discFiles[0]; } else if (isTexturePack) { info->stripChars = stripCharsTexturePack; info->ignoreMetaFiles = true; @@ -765,6 +836,9 @@ void DetectZipFileContents(zip_t *z, ZipFileInfo *info) { info->contents = ZipFileContents::EXTRACTED_GAME; } else if (hasPluginIni && hasPRX) { info->contents = ZipFileContents::PRX_PLUGIN; + } else if (isPS1Disc) { + info->contents = ZipFileContents::PSX_DISC; + info->contentName = info->discFiles[0]; } else { info->contents = ZipFileContents::UNKNOWN; } diff --git a/Core/Loaders.h b/Core/Loaders.h index c4311629d0..223703a603 100644 --- a/Core/Loaders.h +++ b/Core/Loaders.h @@ -19,6 +19,7 @@ #include #include +#include #include "Common/CommonTypes.h" #include "Common/File/Path.h" @@ -192,6 +193,8 @@ enum class ZipFileContents { EXTRACTED_GAME, SAVE_STATES, PRX_PLUGIN, + // A PS1 disc image, which can be one file or several (a cue sheet and what it names). + PSX_DISC, }; // TODO: Rename to ArchiveContentsInfo or something. @@ -211,6 +214,7 @@ struct ZipFileInfo { std::string mTime; std::string iniContents; std::string isoFilename; // Used by SevenZ only right now. + std::vector discFiles; // For PSX_DISC, the disc's files in the archive. s64 totalFileSize = 0; std::string contentName; diff --git a/Core/PSPLoaders.cpp b/Core/PSPLoaders.cpp index 19a834f2e6..571b7acdd5 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; @@ -687,6 +694,8 @@ bool Load_PSP_POPS(FileLoader *fileLoader, std::string *error_string) { if (!__PopsBootStartInit(fileLoader, gamePath, error_string)) { return false; } + auto sy = GetI18NCategory(I18NCat::SYSTEM); + g_OSD.Show(OSDType::MESSAGE_WARNING, sy->T("PS1 support is still experimental. The game might not work, or there may be glitches."), 6.0f); return true; } 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/Core/Util/DarwinFileSystemServices.mm b/Core/Util/DarwinFileSystemServices.mm index 0baa929c1c..e7e80de19e 100644 --- a/Core/Util/DarwinFileSystemServices.mm +++ b/Core/Util/DarwinFileSystemServices.mm @@ -240,7 +240,7 @@ void DarwinFileSystemServices::presentDirectoryPanel( panel.canChooseDirectories = allowDirectories; switch (fileType) { case BrowseFileType::BOOTABLE: - [panel setAllowedFileTypes:[NSArray arrayWithObjects:@"iso", @"cso", @"chd", @"pbp", @"elf", @"zip", @"ppdmp", @"prx", nil]]; + [panel setAllowedFileTypes:[NSArray arrayWithObjects:@"iso", @"cso", @"chd", @"pbp", @"elf", @"zip", @"ppdmp", @"prx", @"cue", @"ccd", @"mds", @"mdf", @"bin", @"img", nil]]; break; case BrowseFileType::SAVE_STATE: [panel setAllowedFileTypes:[NSArray arrayWithObjects:@"ppst", nil]]; diff --git a/Core/Util/GameManager.cpp b/Core/Util/GameManager.cpp index 9a9b956866..56938ecb7d 100644 --- a/Core/Util/GameManager.cpp +++ b/Core/Util/GameManager.cpp @@ -234,6 +234,19 @@ static std::string ZipReadFileByIndex(struct zip *z, int file_index) { return buffer; } +// Where a PS1 disc's files go: a folder of their own, named after the cue sheet or .ccd, when there's +// more than one. +static Path PS1DiscInstallDir(const Path &destDir, const std::vector &files) { + if (files.size() <= 1) { + return destDir; + } + const Path first(files[0]); + const std::string name = first.GetFilename(); + const Path dir = destDir / name.substr(0, name.size() - first.GetFileExtension().size()); + File::CreateFullPath(dir); + return dir; +} + // Parameters need to be by value, since this is a thread func. void GameManager::InstallZipContents(ZipFileTask task) { SetCurrentThreadName("InstallZipContents"); @@ -288,68 +301,81 @@ void GameManager::InstallZipContents(ZipFileTask task) { if (task.zipFileInfo && task.zipFileInfo->archiveType == ArchiveType::SevenZ) { Path destPath = task.destination; g_OSD.SetProgressBar("install", di->T("Installing..."), 0.0f, 0.0f, 0.0f, 0.1f); - Path fn(task.zipFileInfo->isoFilename); - Path destFile = destPath / fn.GetFilename(); + // An ISO, or a PS1 disc's files. + std::vector names; + if (task.zipFileInfo->contents == ZipFileContents::PSX_DISC) { + names = task.zipFileInfo->discFiles; + destPath = PS1DiscInstallDir(destPath, names); + } else { + names.push_back(task.zipFileInfo->isoFilename); + } const size_t blockSize = 1024 * 128; SevenZipFileReader *reader = SevenZipFileReader::Create(task.fileName, "", true); - VFSFileReference *vfsRef = nullptr; - VFSOpenFile *vfsFile = nullptr; - FILE *f = nullptr; - bool success = false; + bool success = reader != nullptr; bool writeFailed = false; bool readFailed = false; - size_t totalSize = 0; - size_t bytesCopied = 0; + std::vector destFiles; + for (size_t n = 0; n < names.size() && success; n++) { + const std::string &name = names[n]; + const Path destFile = destPath / Path(name).GetFilename(); + VFSFileReference *vfsRef = reader->GetFile(name); + VFSOpenFile *vfsFile = nullptr; + FILE *f = nullptr; + size_t totalSize = 0; + size_t bytesCopied = 0; + success = false; - if (reader) { - vfsRef = reader->GetFile(task.zipFileInfo->isoFilename); - } - if (vfsRef) { - vfsFile = reader->OpenFileForRead(vfsRef, &totalSize); - } - if (vfsFile) { - f = File::OpenCFile(destFile, "wb"); - } - - if (f) { - std::vector buffer(blockSize); - while (bytesCopied < totalSize) { - size_t readSize = std::min(blockSize, totalSize - bytesCopied); - size_t bytesRead = reader->Read(vfsFile, buffer.data(), readSize); - if (bytesRead == 0) { - ERROR_LOG(Log::HLE, "Failed to stream extract 7z ISO '%s'", task.zipFileInfo->isoFilename.c_str()); - readFailed = true; - break; - } - - size_t written = fwrite(buffer.data(), 1, bytesRead, f); - if (written != bytesRead) { - ERROR_LOG(Log::HLE, "Wrote %d bytes out of %d - Disk full?", (int)written, (int)bytesRead); - writeFailed = true; - break; - } - - bytesCopied += bytesRead; - installProgress_ = totalSize > 0 ? (float)bytesCopied / (float)totalSize : 1.0f; - g_OSD.SetProgressBar("install", di->T("Installing..."), 0.0f, 1.0f, installProgress_, 0.1f); + if (vfsRef) { + vfsFile = reader->OpenFileForRead(vfsRef, &totalSize); + } + if (vfsFile) { + f = File::OpenCFile(destFile, "wb"); } - success = bytesCopied == totalSize; - fclose(f); - f = nullptr; - } + if (f) { + destFiles.push_back(destFile); + std::vector buffer(blockSize); + while (bytesCopied < totalSize) { + size_t readSize = std::min(blockSize, totalSize - bytesCopied); + size_t bytesRead = reader->Read(vfsFile, buffer.data(), readSize); + if (bytesRead == 0) { + ERROR_LOG(Log::HLE, "Failed to stream extract 7z file '%s'", name.c_str()); + readFailed = true; + break; + } - if (vfsFile) { - reader->CloseFile(vfsFile); - } - if (vfsRef) { - reader->ReleaseFile(vfsRef); + size_t written = fwrite(buffer.data(), 1, bytesRead, f); + if (written != bytesRead) { + ERROR_LOG(Log::HLE, "Wrote %d bytes out of %d - Disk full?", (int)written, (int)bytesRead); + writeFailed = true; + break; + } + + bytesCopied += bytesRead; + const float fileProgress = totalSize > 0 ? (float)bytesCopied / (float)totalSize : 1.0f; + installProgress_ = ((float)n + fileProgress) / (float)names.size(); + g_OSD.SetProgressBar("install", di->T("Installing..."), 0.0f, 1.0f, installProgress_, 0.1f); + } + + success = bytesCopied == totalSize; + fclose(f); + f = nullptr; + } + + if (vfsFile) { + reader->CloseFile(vfsFile); + } + if (vfsRef) { + reader->ReleaseFile(vfsRef); + } } delete reader; if (!success) { - File::Delete(destFile); + for (const Path &destFile : destFiles) { + File::Delete(destFile); + } if (writeFailed) { SetInstallError(sy->T("Storage full")); } else if (readFailed) { @@ -406,6 +432,12 @@ void GameManager::InstallZipContents(ZipFileTask task) { success = InstallZippedISO(z, zipInfo.isoFileIndex, task.destination); break; } + case ZipFileContents::PSX_DISC: + { + INFO_LOG(Log::HLE, "Installing PS1 disc '%s' into '%s'", task.fileName.c_str(), task.destination.c_str()); + success = InstallZippedPS1Disc(z, zipInfo.discFiles, task.destination); + break; + } case ZipFileContents::TEXTURE_PACK: { Path dest; @@ -921,6 +953,46 @@ bool GameManager::InstallZippedISO(struct zip *z, int isoFileIndex, const Path & return true; } +bool GameManager::InstallZippedPS1Disc(struct zip *z, const std::vector &files, const Path &destDir) { + Path dir = destDir.empty() ? Path(g_Config.currentDirectory) : destDir; + dir = PS1DiscInstallDir(dir, files); + + int64_t allBytes = 1; + std::vector indices; + for (const std::string &file : files) { + const zip_int64_t index = zip_name_locate(z, file.c_str(), 0); + zip_stat_t zstat; + zip_stat_init(&zstat); + if (index < 0 || zip_stat_index(z, index, 0, &zstat) < 0) { + auto iz = GetI18NCategory(I18NCat::INSTALLZIP); + SetInstallError(iz->T("Zip archive corrupt")); + return false; + } + allBytes += zstat.size; + indices.push_back(index); + } + + auto di = GetI18NCategory(I18NCat::DIALOG); + g_OSD.SetProgressBar("install", di->T("Installing..."), 0.0f, 0.0f, 0.0f, 0.1f); + int64_t bytesCopied = 0; + bool success = true; + std::vector written; + for (size_t i = 0; i < files.size() && success; i++) { + const Path outFile = dir / Path(files[i]).GetFilename(); + success = ExtractFile(z, (int)indices[i], outFile, &bytesCopied, allBytes); + written.push_back(outFile); + } + if (!success) { + for (const Path &file : written) { + File::Delete(file); + } + } else { + INFO_LOG(Log::IO, "Unzipped PS1 disc to '%s'", dir.c_str()); + } + // The caller finishes the install. + return success; +} + bool GameManager::InstallZipOnThread(ZipFileTask task) { if (InstallInProgress() || installDonePending_) { return false; diff --git a/Core/Util/GameManager.h b/Core/Util/GameManager.h index 556e5c30eb..3d3df87837 100644 --- a/Core/Util/GameManager.h +++ b/Core/Util/GameManager.h @@ -95,6 +95,9 @@ public: // Extracts the contents of an open zip archive into dest. Exposed for testing. // maxTotalSize limits the total decompressed size (zip bomb protection). bool ExtractZipContents(struct zip *z, const Path &dest, const ZipFileInfo &info, bool allowRoot, int64_t maxTotalSize = 0x100000000ULL); + // Extracts a PS1 disc's files (ZipFileInfo::discFiles), into a folder of their own if there's more + // than one. Exposed for testing. + bool InstallZippedPS1Disc(struct zip *z, const std::vector &files, const Path &destDir); private: void InstallZipContents(ZipFileTask task); diff --git a/Core/Util/PSARUnpack.cpp b/Core/Util/PSARUnpack.cpp index 91962f0dde..d62e5a34b1 100644 --- a/Core/Util/PSARUnpack.cpp +++ b/Core/Util/PSARUnpack.cpp @@ -1365,6 +1365,15 @@ bool FirmwareVersionPlaysUmdVideo(std::string_view version) { return FirmwareVersionSupportsVSH(version) && FirmwareVersionToInt(version) >= FirmwareVersionToInt(UMD_VIDEO_MIN_FIRMWARE); } +bool InstalledFirmwareRunsPS1Discs() { + // The POPS managers PopsBoot knows for converted EBOOTs: 3.95 to 6.61. A version in between that it + // doesn't know fails the boot with a message saying so. + InstalledFirmwareInfo info; + ReadInstalledFirmwareInfo(g_Config.nandRootDirectory, &info, false); + const int version = FirmwareVersionToInt(info.version); + return version >= 395 && version <= 661 && File::Exists(g_Config.nandRootDirectory / "flash0/kd/popsman.prx"); +} + Path GetUmdVideoVSHPath() { InstalledFirmwareInfo info; ReadInstalledFirmwareInfo(g_Config.nandRootDirectory, &info, false); diff --git a/Core/Util/PSARUnpack.h b/Core/Util/PSARUnpack.h index 6662ba489e..5ad9390644 100644 --- a/Core/Util/PSARUnpack.h +++ b/Core/Util/PSARUnpack.h @@ -192,6 +192,9 @@ extern const char *const UMD_VIDEO_MIN_FIRMWARE; // vshmain.prx, if the installed firmware's XMB can play UMD Video discs. Empty otherwise. Path GetUmdVideoVSHPath(); +// Whether the installed firmware's POPS can run a PS1 disc image, as the converted EBOOT made from it. +bool InstalledFirmwareRunsPS1Discs(); + // Installs a firmware into the NAND directory. Two firmwares can't be merged - a file the new one // doesn't have would linger and still get loaded - so this replaces rather than overlays. // diff --git a/SDL/SDLMain.cpp b/SDL/SDLMain.cpp index 19d6bb1ffb..806c4d38bf 100644 --- a/SDL/SDLMain.cpp +++ b/SDL/SDLMain.cpp @@ -743,8 +743,8 @@ AudioBackend *System_CreateAudioBackend() { static void InitializeFilters(std::vector &filters, BrowseFileType type) { switch (type) { case BrowseFileType::BOOTABLE: - filters.push_back("All supported file types (*.iso *.cso *.chd *.pbp *.elf *.prx *.zip *.ppdmp)"); - filters.push_back("*.pbp *.elf *.iso *.cso *.chd *.prx *.zip *.ppdmp"); + filters.push_back("All supported file types (*.iso *.cso *.chd *.pbp *.elf *.prx *.zip *.ppdmp *.cue *.ccd *.mds *.mdf *.bin *.img)"); + filters.push_back("*.pbp *.elf *.iso *.cso *.chd *.prx *.zip *.ppdmp *.cue *.ccd *.mds *.mdf *.bin *.img"); break; case BrowseFileType::SAVE_STATE: filters.push_back("Save state files (*.ppst)"); diff --git a/UI/GameBrowser.cpp b/UI/GameBrowser.cpp index 2ed4209724..f21ed1ea52 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" @@ -77,7 +78,8 @@ static float ListTextRightMargin(GameBrowserViewMode viewMode) { return 0.0f; } -static void DrawIconWithText(UIContext &dc, ImageID image, std::string_view text, const Bounds &bounds, GameBrowserViewMode viewMode, const UI::Style &style) { +// In the list views, rightText (like a game ID) goes at the right edge, as for a game with a picture. +static void DrawIconWithText(UIContext &dc, ImageID image, std::string_view text, const Bounds &bounds, GameBrowserViewMode viewMode, const UI::Style &style, std::string_view rightText = {}) { const bool gridStyle = viewMode == GameBrowserViewMode::GRID; float tw, th; dc.MeasureText(dc.GetFontStyle(), gridStyle ? g_Config.fGameGridScale : 1.0, gridStyle ? g_Config.fGameGridScale : 1.0, text, &tw, &th, 0); @@ -121,6 +123,14 @@ static void DrawIconWithText(UIContext &dc, ImageID image, std::string_view text float tx = textX; int availableWidth = bounds.w - textX - ListTextRightMargin(viewMode); + if (!rightText.empty()) { + float vw, vh; + dc.MeasureText(dc.GetFontStyle(), 0.7f, 0.7f, rightText, &vw, &vh, 0); + availableWidth -= vw + 20; + dc.SetFontScale(0.7f, 0.7f); + dc.DrawText(rightText, bounds.x + availableWidth + textX + 10, bounds.centerY(), style.fgColor, ALIGN_VCENTER); + dc.SetFontScale(1.0f, 1.0f); + } float sineWidth = std::max(0.0f, (tw - availableWidth)) / 2.0f; if (availableWidth < tw) { tx -= (1.0f + sin(time_now_d() * 1.5f)) * sineWidth; @@ -423,6 +433,8 @@ void GameButton::Draw(UIContext &dc) { // No icon, but drawBackground is set. Let's show a plain icon depending on type. if (ginfo->fileType == IdentifiedFileType::PSP_ISO || ginfo->fileType == IdentifiedFileType::PSP_ISO_NP) { imageIcon = ImageID("I_UMD"); + } else if (ginfo->fileType == IdentifiedFileType::PSX_ISO || ginfo->fileType == IdentifiedFileType::PSP_PS1_PBP) { + imageIcon = ImageID("I_PSX_ISO"); } else if (gamePath_.Type() == PathType::HTTP) { imageIcon = ginfo->fileType == IdentifiedFileType::UNKNOWN ? ImageID("I_LINK_OUT_QUESTION") : ImageID("I_LINK_OUT"); } else { @@ -549,7 +561,7 @@ void GameButton::Draw(UIContext &dc) { if (down_) style = dc.GetTheme().itemDownStyle; if (!IsEnabled()) style = dc.GetTheme().itemDisabledStyle; - DrawIconWithText(dc, imageIcon, ginfo->GetTitle(), bounds_, viewMode_, style); + DrawIconWithText(dc, imageIcon, ginfo->GetTitle(), bounds_, viewMode_, style, g_Config.bShowIDOnGameIcon ? std::string_view(ginfo->id_version) : std::string_view()); if (overlayColor) { dc.FillRect(Drawable(overlayColor), overlayBounds); @@ -1116,7 +1128,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:mds: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..0d03707e99 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: @@ -244,6 +243,9 @@ static bool FileTypeIsPlayable(IdentifiedFileType fileType) { case IdentifiedFileType::PSP_UMD_VIDEO_ISO: // Reverse logic. return false; + case IdentifiedFileType::PSX_ISO: + // Through the firmware's POPS. + return InstalledFirmwareRunsPS1Discs(); default: return true; } diff --git a/UI/InstallZipScreen.cpp b/UI/InstallZipScreen.cpp index 66c859edc6..4cec563d72 100644 --- a/UI/InstallZipScreen.cpp +++ b/UI/InstallZipScreen.cpp @@ -84,6 +84,7 @@ void InstallZipScreen::CreateSettingsViews(UI::ViewGroup *parent) { switch (zipFileInfo_.contents) { case ZipFileContents::ISO_FILE: case ZipFileContents::PSP_GAME_DIR: + case ZipFileContents::PSX_DISC: installChoice_ = parent->Add(new Choice(iz->T("Install"), ImageID("I_FOLDER_UPLOAD"))); installChoice_->OnClick.Handle(this, &InstallZipScreen::OnInstall); @@ -146,6 +147,7 @@ void InstallZipScreen::CreateContentViews(UI::ViewGroup *parent) { switch (zipFileInfo_.contents) { case ZipFileContents::ISO_FILE: case ZipFileContents::PSP_GAME_DIR: + case ZipFileContents::PSX_DISC: { std::string_view question = iz->T("Install game from ZIP file?"); @@ -155,7 +157,7 @@ void InstallZipScreen::CreateContentViews(UI::ViewGroup *parent) { leftColumn->Add(new TextView(zipFileInfo_.contentName)); } - if (zipFileInfo_.contents == ZipFileContents::ISO_FILE) { + if (zipFileInfo_.contents == ZipFileContents::ISO_FILE || zipFileInfo_.contents == ZipFileContents::PSX_DISC) { const bool isInDownloads = File::IsProbablyInDownloadsFolder(zipPath_); Path parent; if (!isInDownloads && zipPath_.CanNavigateUp()) { 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/UWP/PPSSPP_UWPMain.cpp b/UWP/PPSSPP_UWPMain.cpp index 30dfe8ab98..0db5b8247b 100644 --- a/UWP/PPSSPP_UWPMain.cpp +++ b/UWP/PPSSPP_UWPMain.cpp @@ -548,7 +548,7 @@ bool System_MakeRequest(SystemRequestType type, int requestId, const std::string std::vector supportedExtensions = {}; switch ((BrowseFileType)param3) { case BrowseFileType::BOOTABLE: - supportedExtensions = { ".cso", ".iso", ".chd", ".elf", ".pbp", ".zip", ".prx", ".bin" }; // should .bin even be here? + supportedExtensions = { ".cso", ".iso", ".chd", ".elf", ".pbp", ".zip", ".prx", ".bin", ".cue", ".ccd", ".mds", ".mdf", ".img" }; break; case BrowseFileType::SAVE_STATE: supportedExtensions = { ".ppst" }; diff --git a/Windows/main.cpp b/Windows/main.cpp index 91284155e5..17e84ad7c5 100644 --- a/Windows/main.cpp +++ b/Windows/main.cpp @@ -637,7 +637,7 @@ static std::wstring FinalizeFilter(std::wstring filter) { static std::wstring MakeWindowsFilter(BrowseFileType type) { switch (type) { case BrowseFileType::BOOTABLE: - return FinalizeFilter(L"All supported file types (*.iso *.cso *.chd *.pbp *.elf *.prx *.zip *.ppdmp)|*.pbp;*.elf;*.iso;*.cso;*.chd;*.prx;*.zip;*.ppdmp|PSP ROMs (*.iso *.cso *.chd *.pbp *.elf *.prx)|*.pbp;*.elf;*.iso;*.cso;*.chd;*.prx|Homebrew/Demos installers (*.zip)|*.zip|All files (*.*)|*.*||"); + return FinalizeFilter(L"All supported file types (*.iso *.cso *.chd *.pbp *.elf *.prx *.zip *.ppdmp *.cue *.ccd *.mds *.mdf *.bin *.img)|*.pbp;*.elf;*.iso;*.cso;*.chd;*.prx;*.zip;*.ppdmp;*.cue;*.ccd;*.mds;*.mdf;*.bin;*.img|PSP ROMs (*.iso *.cso *.chd *.pbp *.elf *.prx)|*.pbp;*.elf;*.iso;*.cso;*.chd;*.prx|PS1 disc images (*.cue *.ccd *.mds *.mdf *.chd *.bin *.img *.iso *.pbp)|*.cue;*.ccd;*.mds;*.mdf;*.chd;*.bin;*.img;*.iso;*.pbp|Homebrew/Demos installers (*.zip)|*.zip|All files (*.*)|*.*||"); case BrowseFileType::SAVE_STATE: return FinalizeFilter(L"Save state files (*.ppst)|*.ppst|All files (*.*)|*.*||"); case BrowseFileType::INI: 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/assets/lang/ar_AE.ini b/assets/lang/ar_AE.ini index a0d7ce3b94..95dc0071b7 100644 --- a/assets/lang/ar_AE.ini +++ b/assets/lang/ar_AE.ini @@ -1503,6 +1503,7 @@ Only JPG and PNG images are supported = يتم دعم صور JPG و PNG فقط # Path does not exist! = المسار غير موجود! # AI translated Pause when not focused = ‎&إيقاف مؤقت حينما لا يكون مفعلاً Plugins = الإضافات # AI translated +PS1 support is still experimental. The game might not work, or there may be glitches. = دعم PS1 لا يزال تجريبيًا. قد لا تعمل اللعبة، أو قد تظهر بعض الأخطاء. # AI translated PSP Memory Stick = شريحة ذاكرة PSP # AI translated PSP Model = ‎PSP موديل PSP Settings = ‎PSP إعدادات diff --git a/assets/lang/az_AZ.ini b/assets/lang/az_AZ.ini index deb8a5ca84..4ef98d8626 100644 --- a/assets/lang/az_AZ.ini +++ b/assets/lang/az_AZ.ini @@ -1491,6 +1491,7 @@ Only JPG and PNG images are supported = Ancaq JPG və PNG şəkillər dəstəkl Path does not exist! = Yol yoxdur! Pause when not focused = Odaqlı deyilkən ara ver Plugins = Qoşmalar +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1 dəstəyi hələ də eksperimentaldır. Oyun işləməyə bilər və ya xətalar ola bilər. # AI translated PSP Memory Stick = PSP Yaddaş Çubuğu PSP Model = PSP modeli PSP Settings = PSP quruluşları diff --git a/assets/lang/be_BY.ini b/assets/lang/be_BY.ini index 694098e01f..f285a7df1b 100644 --- a/assets/lang/be_BY.ini +++ b/assets/lang/be_BY.ini @@ -1482,6 +1482,7 @@ Only JPG and PNG images are supported = Толькі выявы JPG і PNG Path does not exist! = Шляху не існуе! Pause when not focused = Паўза пры расфокусе Plugins = Плагіны +PS1 support is still experimental. The game might not work, or there may be glitches. = Падтрымка PS1 пакуль эксперыментальная. Гульня можа не працаваць або мець памылкі. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = Мадэль PSP PSP Settings = Налады PSP diff --git a/assets/lang/bg_BG.ini b/assets/lang/bg_BG.ini index 4ef84af9c3..fe32a53537 100644 --- a/assets/lang/bg_BG.ini +++ b/assets/lang/bg_BG.ini @@ -1503,6 +1503,7 @@ Only JPG and PNG images are supported = Поддържат се само JPG и Path does not exist! = Пътят не съществува! Pause when not focused = Пауза, когато не фокусиран Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = Поддръжката на PS1 все още е експериментална. Играта може да не работи или да има проблеми. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = PSP модел PSP Settings = PSP настройки diff --git a/assets/lang/ca_ES.ini b/assets/lang/ca_ES.ini index 6ba5e46371..1efb8ee2ce 100644 --- a/assets/lang/ca_ES.ini +++ b/assets/lang/ca_ES.ini @@ -1503,6 +1503,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Path does not exist! Pause when not focused = &Pausar quan es canviï de finestra Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = El suport de PS1 encara és experimental. És possible que el joc no funcioni o que tingui errors. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = PSP model PSP Settings = PSP settings diff --git a/assets/lang/cz_CZ.ini b/assets/lang/cz_CZ.ini index 2ad707ebf0..389868df2f 100644 --- a/assets/lang/cz_CZ.ini +++ b/assets/lang/cz_CZ.ini @@ -1503,6 +1503,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Path does not exist! Pause when not focused = &Pozastavit pokud okno není zaměřeno Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = Podpora PS1 je zatím experimentální. Hra nemusí fungovat nebo se mohou objevit chyby. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = Model PSP PSP Settings = Nastavení PSP diff --git a/assets/lang/da_DK.ini b/assets/lang/da_DK.ini index 28219034f9..8963a161cc 100644 --- a/assets/lang/da_DK.ini +++ b/assets/lang/da_DK.ini @@ -1503,6 +1503,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Path does not exist! Pause when not focused = P&ause når ikke i fokus Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1-understøttelse er stadig eksperimentel. Spillet virker måske ikke, eller der kan være fejl. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = PSP model PSP Settings = PSP indstillinger diff --git a/assets/lang/de_DE.ini b/assets/lang/de_DE.ini index 5f54215a9c..e96e6ab8a3 100644 --- a/assets/lang/de_DE.ini +++ b/assets/lang/de_DE.ini @@ -1502,6 +1502,7 @@ Only JPG and PNG images are supported = Es werden nur JPG- und PNG-Bilder unters Path does not exist! = Pfad existiert nicht! Pause when not focused = Pausieren im Hintergrund Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = Die PS1-Unterstützung ist noch experimentell. Das Spiel funktioniert eventuell nicht, oder es können Fehler auftreten. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = PSP-Modell PSP Settings = PSP-Einstellungen diff --git a/assets/lang/dr_ID.ini b/assets/lang/dr_ID.ini index 7d29bb0e20..8a7e9ce4ee 100644 --- a/assets/lang/dr_ID.ini +++ b/assets/lang/dr_ID.ini @@ -1490,6 +1490,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Path does not exist! Pause when not focused = Pause when not focused Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1 support is still experimental. The game might not work, or there may be glitches. PSP Memory Stick = PSP Memory Stick PSP Model = PSP model PSP Settings = Pangngaturan PSP diff --git a/assets/lang/en_US.ini b/assets/lang/en_US.ini index cacfc09372..07752a3763 100644 --- a/assets/lang/en_US.ini +++ b/assets/lang/en_US.ini @@ -1522,6 +1522,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Path does not exist! Pause when not focused = Pause when not focused Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1 support is still experimental. The game might not work, or there may be glitches. PSP Memory Stick = PSP Memory Stick PSP Model = PSP model PSP Settings = PSP settings diff --git a/assets/lang/es_ES.ini b/assets/lang/es_ES.ini index be3377f492..866e309e0e 100644 --- a/assets/lang/es_ES.ini +++ b/assets/lang/es_ES.ini @@ -1504,6 +1504,7 @@ Only JPG and PNG images are supported = Solo se admiten imágenes JPG y PNG Path does not exist! = ¡La ruta no existe! Pause when not focused = Pausar cuando la aplicación no está seleccionada Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = La compatibilidad con PS1 aún es experimental. Puede que el juego no funcione o que tenga fallos. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = Modelo de PSP PSP Settings = Ajustes de PSP diff --git a/assets/lang/es_LA.ini b/assets/lang/es_LA.ini index 4746ce66e3..5d25610771 100644 --- a/assets/lang/es_LA.ini +++ b/assets/lang/es_LA.ini @@ -1505,6 +1505,7 @@ Only JPG and PNG images are supported = Sólo se admiten imágenes JPG y PNG Path does not exist! = La ruta no existe. Pause when not focused = &Pausar al cambiar de ventana Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = La compatibilidad con PS1 aún es experimental. Es posible que el juego no funcione o que tenga fallas. # AI translated PSP Memory Stick = Memory Stick de PSP PSP Model = Modelo de PSP PSP Settings = Ajustes de PSP diff --git a/assets/lang/fa_IR.ini b/assets/lang/fa_IR.ini index 2a458e72f6..48953d18a0 100644 --- a/assets/lang/fa_IR.ini +++ b/assets/lang/fa_IR.ini @@ -1492,6 +1492,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = مسیر فایل پیدا نشد Pause when not focused = ‎هنگامی که پنجره فعال نباشد بازی متوقف شود Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = پشتیبانی از PS1 هنوز آزمایشی است. ممکن است بازی اجرا نشود یا مشکلاتی داشته باشد. # AI translated PSP Memory Stick = حافظه psp PSP Model = ‎PSP مدل PSP Settings = ‎PSP تنظیمات diff --git a/assets/lang/fi_FI.ini b/assets/lang/fi_FI.ini index ab3b45ca48..eb524864c1 100644 --- a/assets/lang/fi_FI.ini +++ b/assets/lang/fi_FI.ini @@ -1503,6 +1503,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Polkua ei ole olemassa! Pause when not focused = Pause when not focused Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1-tuki on vielä kokeellinen. Peli ei välttämättä toimi, tai siinä voi olla virheitä. # AI translated PSP Memory Stick = PSP-muistikortti PSP Model = PSP-malli PSP Settings = PSP-asetukset diff --git a/assets/lang/fr_FR.ini b/assets/lang/fr_FR.ini index 8bba48b1a4..a20c14af6b 100644 --- a/assets/lang/fr_FR.ini +++ b/assets/lang/fr_FR.ini @@ -1522,6 +1522,7 @@ Only JPG and PNG images are supported = Seules les images au format JPG et PNG s Path does not exist! = Le chemin d'accès n'existe pas ! Pause when not focused = Mettre en pa&use si pas au premier plan Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = La prise en charge de la PS1 est encore expérimentale. Le jeu peut ne pas fonctionner ou présenter des bugs. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = Modèle de PSP PSP Settings = Paramètres de la PSP diff --git a/assets/lang/gl_ES.ini b/assets/lang/gl_ES.ini index befbe35a89..8ab7c7fc17 100644 --- a/assets/lang/gl_ES.ini +++ b/assets/lang/gl_ES.ini @@ -1502,6 +1502,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Path does not exist! Pause when not focused = &Pausar ó cambiar de ventá Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = A compatibilidade con PS1 aínda é experimental. É posible que o xogo non funcione ou que teña erros. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = Modelo de PSP PSP Settings = Axustes de PSP diff --git a/assets/lang/gr_EL.ini b/assets/lang/gr_EL.ini index ff09dccf1b..7ec1dc952d 100644 --- a/assets/lang/gr_EL.ini +++ b/assets/lang/gr_EL.ini @@ -1502,6 +1502,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Path does not exist! Pause when not focused = Παύση σε κατάσταση παρασκηνίου Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = Η υποστήριξη PS1 είναι ακόμα πειραματική. Το παιχνίδι μπορεί να μη λειτουργεί ή να έχει σφάλματα. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = Μοντέλο PSP PSP Settings = Ρυθμίσεις PSP diff --git a/assets/lang/he_IL.ini b/assets/lang/he_IL.ini index 47c5f35440..62879eb4f0 100644 --- a/assets/lang/he_IL.ini +++ b/assets/lang/he_IL.ini @@ -1492,6 +1492,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Path does not exist! Pause when not focused = Pause when not focused Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = התמיכה ב-PS1 עדיין ניסיונית. ייתכן שהמשחק לא יעבוד או שיופיעו תקלות. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = PSP model PSP Settings = הגדרות PSP diff --git a/assets/lang/hr_HR.ini b/assets/lang/hr_HR.ini index 4d6a502826..30def7f2d6 100644 --- a/assets/lang/hr_HR.ini +++ b/assets/lang/hr_HR.ini @@ -1503,6 +1503,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Path does not exist! Pause when not focused = &Pauziraj kad nije fokusirano Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = Podrška za PS1 još je eksperimentalna. Igra možda neće raditi ili može imati greške. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = PSP model PSP Settings = PSP opcije diff --git a/assets/lang/hu_HU.ini b/assets/lang/hu_HU.ini index 782ce517b5..bfaf86ac9c 100644 --- a/assets/lang/hu_HU.ini +++ b/assets/lang/hu_HU.ini @@ -1503,6 +1503,7 @@ Only JPG and PNG images are supported = Csak JPG és PNG képek támogatottak. Path does not exist! = Az elérési út nem létezik. Pause when not focused = Szüneteltetés, ha nincs fókuszban Plugins = Bővítmények +PS1 support is still experimental. The game might not work, or there may be glitches. = A PS1-támogatás még kísérleti. Előfordulhat, hogy a játék nem működik, vagy hibák jelentkeznek. # AI translated PSP Memory Stick = PSP Memóriakártya PSP Model = PSP modell PSP Settings = PSP beállítások diff --git a/assets/lang/id_ID.ini b/assets/lang/id_ID.ini index b97b790a5d..d0ff52c733 100644 --- a/assets/lang/id_ID.ini +++ b/assets/lang/id_ID.ini @@ -1503,6 +1503,7 @@ Only JPG and PNG images are supported = Hanya gambar JPG dan PNG yang didukung Path does not exist! = Jalur/path tidak ditemukan! Pause when not focused = Jeda ketika tidak fokus Plugins = Plugin +PS1 support is still experimental. The game might not work, or there may be glitches. = Dukungan PS1 masih eksperimental. Game mungkin tidak berjalan, atau mungkin ada gangguan. # AI translated PSP Memory Stick = Memory Stick PSP PSP Model = Model PSP PSP Settings = Pengaturan PSP diff --git a/assets/lang/it_IT.ini b/assets/lang/it_IT.ini index 0b71538ee1..e89dd14a2e 100644 --- a/assets/lang/it_IT.ini +++ b/assets/lang/it_IT.ini @@ -1490,6 +1490,7 @@ Only JPG and PNG images are supported = Sono supportate solo immagini JPG e PNG Path does not exist! = Il percorso non esiste! Pause when not focused = Pausa quando non attivo Plugins = Plugin +PS1 support is still experimental. The game might not work, or there may be glitches. = Il supporto PS1 è ancora sperimentale. Il gioco potrebbe non funzionare o presentare problemi. # AI translated PSP Memory Stick = Memory Stick PSP Raw game image = Immagine grezza del gioco Recent games = Giochi recenti diff --git a/assets/lang/ja_JP.ini b/assets/lang/ja_JP.ini index 8fec12ffc3..d2c1478190 100644 --- a/assets/lang/ja_JP.ini +++ b/assets/lang/ja_JP.ini @@ -1503,6 +1503,7 @@ Only JPG and PNG images are supported = JPGとPNG画像のみ対応していま Path does not exist! = パスが存在しません Pause when not focused = 非フォーカス時は一時停止する Plugins = プラグイン +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1 のサポートはまだ実験的です。ゲームが動作しない、または不具合が発生する可能性があります。 # AI translated PSP Memory Stick = メモリースティックの設定 PSP Model = PSPのモデル PSP Settings = PSPの設定 diff --git a/assets/lang/jv_ID.ini b/assets/lang/jv_ID.ini index 03b89bdafb..0b3e0117fe 100644 --- a/assets/lang/jv_ID.ini +++ b/assets/lang/jv_ID.ini @@ -1490,6 +1490,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Path does not exist! Pause when not focused = &Ngaso Nalika Ora Fokus Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1 support is still experimental. The game might not work, or there may be glitches. PSP Memory Stick = PSP Memory Stick PSP Model = PSP model PSP Settings = Setelan PSP diff --git a/assets/lang/km_KH.ini b/assets/lang/km_KH.ini index ee25352d37..e01747d705 100644 --- a/assets/lang/km_KH.ini +++ b/assets/lang/km_KH.ini @@ -1480,6 +1480,7 @@ Only JPG and PNG images are supported =មានតែរូបភាព JPG ន Path does not exist! =ផ្លូវអត់មានទេ! Pause when not focused =ផ្អាកនៅពេលមិនផ្តោតអារម្មណ៍ Plugins =កម្មវិធីជំនួយ +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1 support is still experimental. The game might not work, or there may be glitches. PSP Memory Stick =PSP Memory Stick PSP Model =ម៉ូដែល PSP PSP Settings =ការកំណត់ PSP diff --git a/assets/lang/ko_KR.ini b/assets/lang/ko_KR.ini index 4df505e43b..6830f670cc 100644 --- a/assets/lang/ko_KR.ini +++ b/assets/lang/ko_KR.ini @@ -1495,6 +1495,7 @@ Only JPG and PNG images are supported = JPG 및 PNG 이미지만 지원됩니다 Path does not exist! = 경로가 존재하지 않습니다! Pause when not focused = 포커싱되지 않았을 때 일시 정지(&P) Plugins = 플러그인 +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1 지원은 아직 실험 단계입니다. 게임이 작동하지 않거나 오류가 발생할 수 있습니다. # AI translated PSP Memory Stick = PSP 메모리 스틱 PSP Model = PSP 모델 PSP Settings = PSP 설정 diff --git a/assets/lang/ku_SO.ini b/assets/lang/ku_SO.ini index 7647757552..deb9fbd28a 100644 --- a/assets/lang/ku_SO.ini +++ b/assets/lang/ku_SO.ini @@ -1496,6 +1496,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Path does not exist! Pause when not focused = Pause when not focused Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1 support is still experimental. The game might not work, or there may be glitches. PSP Memory Stick = PSP Memory Stick PSP Model = PSP مۆدێلی PSP Settings = PSP settings diff --git a/assets/lang/lo_LA.ini b/assets/lang/lo_LA.ini index f2cbaa5818..5c27bdb675 100644 --- a/assets/lang/lo_LA.ini +++ b/assets/lang/lo_LA.ini @@ -1490,6 +1490,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Path does not exist! Pause when not focused = &ຢຸດຊົ່ວຄາວເມື່ອບໍ່ໄດ້ຫຼິ້ນ Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1 support is still experimental. The game might not work, or there may be glitches. PSP Memory Stick = PSP Memory Stick PSP Model = ໂມເດລ PSP PSP Settings = ຕັ້ງຄ່າ PSP diff --git a/assets/lang/lt-LT.ini b/assets/lang/lt-LT.ini index 29bb8c2128..47c04aae33 100644 --- a/assets/lang/lt-LT.ini +++ b/assets/lang/lt-LT.ini @@ -1492,6 +1492,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Path does not exist! Pause when not focused = &Stabdyti kai nefokusuota Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1 palaikymas dar eksperimentinis. Žaidimas gali neveikti arba gali būti klaidų. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = Emuliuojamo PSP modelis PSP Settings = PSP parametrai diff --git a/assets/lang/ms_MY.ini b/assets/lang/ms_MY.ini index 84240924b9..08287baaf3 100644 --- a/assets/lang/ms_MY.ini +++ b/assets/lang/ms_MY.ini @@ -1503,6 +1503,7 @@ Only JPG and PNG images are supported = Hanya imej JPG dan PNG sahaja disokong Path does not exist! = Laluan tidak wujud! Pause when not focused = Jeda apabila tidak difokuskan Plugins = Pemalam +PS1 support is still experimental. The game might not work, or there may be glitches. = Sokongan PS1 masih dalam percubaan. Permainan mungkin tidak berfungsi, atau mungkin terdapat gangguan. # AI translated PSP Memory Stick = Memory Stick PSP PSP Model = Model PSP PSP Settings = Tetapan PSP diff --git a/assets/lang/nl_NL.ini b/assets/lang/nl_NL.ini index df6823ccde..091586d92a 100644 --- a/assets/lang/nl_NL.ini +++ b/assets/lang/nl_NL.ini @@ -1503,6 +1503,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Path does not exist! Pause when not focused = &Pauzeren wanneer venster inactief is Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1-ondersteuning is nog experimenteel. Het spel werkt mogelijk niet, of er kunnen fouten optreden. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = PSP-model PSP Settings = PSP-instellingen diff --git a/assets/lang/nn_NO.ini b/assets/lang/nn_NO.ini index 87e9cd42dc..3f3123c8a6 100644 --- a/assets/lang/nn_NO.ini +++ b/assets/lang/nn_NO.ini @@ -1520,6 +1520,7 @@ Only JPG and PNG images are supported = Berre JPG- og PNG-bilete er støtta Path does not exist! = Stien finst ikkje! Pause when not focused = Pause når ikkje i fokus Plugins = Programtillegg +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1-støtta er framleis eksperimentell. Spelet verkar kanskje ikkje, eller det kan vere feil. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = PSP-modell PSP Settings = PSP-innstillingar diff --git a/assets/lang/no_NO.ini b/assets/lang/no_NO.ini index 1ee42dadea..14878c59a6 100644 --- a/assets/lang/no_NO.ini +++ b/assets/lang/no_NO.ini @@ -1520,6 +1520,7 @@ Only JPG and PNG images are supported = Bare JPG- og PNG-bilder støttes Path does not exist! = Banen finnes ikke! Pause when not focused = Pause When Not Focused Plugins = Programtillegg +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1-støtte er fortsatt eksperimentell. Spillet fungerer kanskje ikke, eller det kan være feil. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = PSP-modell PSP Settings = PSP-innstillinger diff --git a/assets/lang/pl_PL.ini b/assets/lang/pl_PL.ini index a3434ed6f8..af3595f422 100644 --- a/assets/lang/pl_PL.ini +++ b/assets/lang/pl_PL.ini @@ -1511,6 +1511,7 @@ Only JPG and PNG images are supported = Tylko pliki JPG i PNG są wspierane Path does not exist! = Ta ścieżka nie istnieje! Pause when not focused = Pauzuj, gdy okno nieaktywne Plugins = Wtyczki +PS1 support is still experimental. The game might not work, or there may be glitches. = Obsługa PS1 jest wciąż eksperymentalna. Gra może nie działać lub mogą występować błędy. # AI translated PSP Memory Stick = Karta Pamięci PSP PSP Model = Model PSP PSP Settings = Ustawienia PSP diff --git a/assets/lang/pt_BR.ini b/assets/lang/pt_BR.ini index 2d6920bcc5..2d4fe5835d 100644 --- a/assets/lang/pt_BR.ini +++ b/assets/lang/pt_BR.ini @@ -1519,6 +1519,7 @@ Only JPG and PNG images are supported = Só imagens JPGs e PNGs são suportadas Path does not exist! = O caminho não existe! Pause when not focused = Pausar quando não está focado Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = O suporte ao PS1 ainda é experimental. O jogo pode não funcionar, ou pode ter falhas. # AI translated PSP Memory Stick = Cartão de Memória do PSP PSP Model = Modelo do PSP PSP Settings = Configurações do PSP diff --git a/assets/lang/pt_PT.ini b/assets/lang/pt_PT.ini index d7fa47d883..3c165c2e6d 100644 --- a/assets/lang/pt_PT.ini +++ b/assets/lang/pt_PT.ini @@ -1520,6 +1520,7 @@ Only JPG and PNG images are supported = Apenas imagens JPG e PNG são compatíve Path does not exist! = O diretório não existe! Pause when not focused = Pausar quando a janela não estiver em foco Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = O suporte à PS1 ainda é experimental. O jogo pode não funcionar, ou pode ter falhas. # AI translated PSP Memory Stick = Cartão de memória da PSP PSP Model = Modelo da PSP PSP Settings = Definições da PSP diff --git a/assets/lang/ro_RO.ini b/assets/lang/ro_RO.ini index e4b9e7ad99..5b747cdae1 100644 --- a/assets/lang/ro_RO.ini +++ b/assets/lang/ro_RO.ini @@ -1503,6 +1503,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Path does not exist! Pause when not focused = Pause when not focused Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = Suportul pentru PS1 este încă experimental. Este posibil ca jocul să nu funcționeze sau să aibă erori. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = Model PSP PSP Settings = Setări PSP diff --git a/assets/lang/ru_RU.ini b/assets/lang/ru_RU.ini index 00909abad4..83eeaf435b 100644 --- a/assets/lang/ru_RU.ini +++ b/assets/lang/ru_RU.ini @@ -1503,6 +1503,7 @@ Only JPG and PNG images are supported = Поддерживаются тольк Path does not exist! = Пути не существует! Pause when not focused = Пауза, когда окно не в фокусе Plugins = Плагины +PS1 support is still experimental. The game might not work, or there may be glitches. = Поддержка PS1 пока экспериментальная. Игра может не работать или работать с ошибками. # AI translated PSP Memory Stick = Карта памяти PSP PSP Model = Модель PSP PSP Settings = Настройки PSP diff --git a/assets/lang/sv_SE.ini b/assets/lang/sv_SE.ini index 36107a585e..efa387bfdb 100644 --- a/assets/lang/sv_SE.ini +++ b/assets/lang/sv_SE.ini @@ -1506,6 +1506,7 @@ Only JPG and PNG images are supported = Endast JPG och PNG-bilder stöds Path does not exist! = Sökvägen finns inte! Pause when not focused = Pausa när fönstret inte har fokus Plugins = Insticksmoduler +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1-stödet är fortfarande experimentellt. Spelet kanske inte fungerar, eller så kan det finnas fel. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = PSP-modell PSP Settings = PSP-inställningar diff --git a/assets/lang/tg_PH.ini b/assets/lang/tg_PH.ini index 15ff8d6ad6..aa25410da3 100644 --- a/assets/lang/tg_PH.ini +++ b/assets/lang/tg_PH.ini @@ -1494,6 +1494,7 @@ Only JPG and PNG images are supported = Tanging ang mga JPG at PNG na mga imahe Path does not exist! = Hindi matagpuan ang sinasabing 'path'! Pause when not focused = Ihinto kapag hindi naka pokus Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1 support is still experimental. The game might not work, or there may be glitches. PSP Memory Stick = PSP Memory Stick PSP Model = Modelo ng PSP PSP Settings = Settings ng PSP diff --git a/assets/lang/th_TH.ini b/assets/lang/th_TH.ini index 9f6efae40d..a145e198e5 100644 --- a/assets/lang/th_TH.ini +++ b/assets/lang/th_TH.ini @@ -1544,6 +1544,7 @@ Only JPG and PNG images are supported = รองรับเฉพาะไฟ Path does not exist! = ไม่มีเส้นทางนี้อยู่! Pause when not focused = หยุดชั่วคราวเมื่อไม่ได้เล่น Plugins = ปลั๊กอิน +PS1 support is still experimental. The game might not work, or there may be glitches. = การรองรับ PS1 ยังอยู่ในขั้นทดลอง เกมอาจไม่ทำงาน หรืออาจมีข้อผิดพลาด # AI translated PSP Memory Stick = แหล่งเก็บข้อมูล PSP PSP Model = โมเดลของ PSP PSP Settings = การตั้งค่า PSP diff --git a/assets/lang/tr_TR.ini b/assets/lang/tr_TR.ini index 33ad27424d..14c8f5a431 100644 --- a/assets/lang/tr_TR.ini +++ b/assets/lang/tr_TR.ini @@ -1504,6 +1504,7 @@ Only JPG and PNG images are supported = Sadece JPG ve PNG resimleri desteklenir Path does not exist! = Yol mevcut değil! Pause when not focused = Odakdan Çıkınca Duraklat Plugins = Eklentiler +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1 desteği hâlâ deneysel. Oyun çalışmayabilir veya hatalar olabilir. # AI translated PSP Memory Stick = PSP Hafıza Kartı PSP Model = PSP Modeli PSP Settings = PSP Ayarları diff --git a/assets/lang/uk_UA.ini b/assets/lang/uk_UA.ini index 48ba43afa6..ae8fc4f7d3 100644 --- a/assets/lang/uk_UA.ini +++ b/assets/lang/uk_UA.ini @@ -1502,6 +1502,7 @@ Only JPG and PNG images are supported = Підтримуються лише зо Path does not exist! = Шляху не існує! Pause when not focused = &Пауза коли вікно не в фокусі Plugins = Плагіни +PS1 support is still experimental. The game might not work, or there may be glitches. = Підтримка PS1 поки експериментальна. Гра може не працювати або працювати з помилками. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = Модель PSP PSP Settings = Налаштування PSP diff --git a/assets/lang/vi_VN.ini b/assets/lang/vi_VN.ini index 817a91eb79..d0a0e61c07 100644 --- a/assets/lang/vi_VN.ini +++ b/assets/lang/vi_VN.ini @@ -1502,6 +1502,7 @@ Only JPG and PNG images are supported = Only JPG and PNG images are supported Path does not exist! = Path does not exist! Pause when not focused = Dừng trò chơi khi "ko tập trung" Plugins = Plugins +PS1 support is still experimental. The game might not work, or there may be glitches. = Hỗ trợ PS1 vẫn đang thử nghiệm. Trò chơi có thể không hoạt động hoặc có thể gặp lỗi. # AI translated PSP Memory Stick = PSP Memory Stick PSP Model = PSP model PSP Settings = Thiết lập PSP diff --git a/assets/lang/zh_CN.ini b/assets/lang/zh_CN.ini index 3da88a6cfd..5001ffdd30 100644 --- a/assets/lang/zh_CN.ini +++ b/assets/lang/zh_CN.ini @@ -1447,6 +1447,7 @@ Notification screen position = 通知界面位置 Only JPG and PNG images are supported = 只支持JPG和PNG图片 Pause when not focused = 后台运行时自动暂停 Plugins = 插件 +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1 支持仍处于实验阶段。游戏可能无法运行,或可能出现异常。 # AI translated Raw game image = 原始渲染图像 Recording = 录制 Report free storage space accurately = 准确报告可用存储空间 # AI translated diff --git a/assets/lang/zh_TW.ini b/assets/lang/zh_TW.ini index d1a2145b37..f1e08980f9 100644 --- a/assets/lang/zh_TW.ini +++ b/assets/lang/zh_TW.ini @@ -1495,6 +1495,7 @@ Only JPG and PNG images are supported = 僅支援 JPG 和 PNG 影像 Path does not exist! = 路徑不存在! Pause when not focused = 未聚焦時暫停(&P) Plugins = 外掛程式 +PS1 support is still experimental. The game might not work, or there may be glitches. = PS1 支援仍處於實驗階段。遊戲可能無法執行,或可能出現異常。 # AI translated PSP Memory Stick = PSP 記憶棒 PSP Model = PSP 型號 PSP Settings = PSP 設定 diff --git a/docs/POPS.md b/docs/POPS.md index a8340780c9..b1ff7f0938 100644 --- a/docs/POPS.md +++ b/docs/POPS.md @@ -58,6 +58,24 @@ 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`, an Alcohol 120% `.mds` with its `.mdf` (subchannel data +skipped), a CD CHD (whose audio is big-endian), 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` or `.mds` of the same name, is read +through that. The disc ID is the one SYSTEM.CNF boots. The game browser shows each disc once, and a zip +or 7z with one in it installs it (several files into a folder of their own). + +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 +96,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 booting an image + straight from an archive. - 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..95d5ffa8eb --- /dev/null +++ b/unittest/TestPS1Eboot.cpp @@ -0,0 +1,417 @@ +// 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 "ext/libzip/zip.h" + +#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 "Core/Util/GameManager.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() { + // PS1 disc IDs have a region, as PSP ones do. + EXPECT_TRUE(DetectGameRegionFromID("SLUS00292") == GameRegion::USA); + EXPECT_TRUE(DetectGameRegionFromID("SCES00003") == GameRegion::EUROPE); + EXPECT_TRUE(DetectGameRegionFromID("SLPS00444") == GameRegion::JAPAN); + EXPECT_TRUE(DetectGameRegionFromID("SCKA20001") == GameRegion::KOREA); + EXPECT_TRUE(DetectGameRegionFromID("ULUS10041") == GameRegion::USA); + + 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; + } + } + + // MODE2/2336: sectors without their sync and header, which are made again. + { + std::vector stripped; + for (u32 s = 0; s < 40; s++) { + stripped.insert(stripped.end(), data.begin() + s * SECTOR + 16, data.begin() + (s + 1) * SECTOR); + } + EXPECT_TRUE(File::WriteDataToFile(false, stripped.data(), stripped.size(), dir / "Stripped.bin")); + EXPECT_TRUE(File::WriteStringToFile(true, "FILE \"Stripped.bin\" BINARY\n TRACK 01 MODE2/2336\n INDEX 01 00:00:00\n", dir / "Stripped.cue")); + PS1EbootFileLoader eboot(dir / "Stripped.cue"); + if (!CheckEboot(eboot, data, 1)) { + 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; + } + } + + // An Alcohol .mds and .mdf: sectors with 96 bytes of subchannel data after each, and an audio track + // whose 5 sectors of pregap are stored. + { + const u32 stride = SECTOR + 96; + const std::vector audio25 = MakeAudio(25); + std::vector mdf; + for (const std::vector *track : { &data, &audio25 }) { + for (size_t s = 0; s < track->size() / SECTOR; s++) { + mdf.insert(mdf.end(), track->begin() + s * SECTOR, track->begin() + (s + 1) * SECTOR); + mdf.resize(mdf.size() + 96, 0xAA); + } + } + EXPECT_TRUE(File::WriteDataToFile(false, mdf.data(), mdf.size(), dir / "Alcohol.mdf")); + std::vector mds(0x230, 0); + memcpy(&mds[0], "MEDIA DESCRIPTOR", 16); + Put32(&mds[0x50], 0x58); + mds[0x58 + 0x0A] = 5; + mds[0x58 + 0x0B] = 3; + Put32(&mds[0x58 + 0x14], 0x70); + const u8 points[5] = { 0xA0, 0xA1, 0xA2, 1, 2 }; + for (int i = 0; i < 5; i++) { + u8 *b = &mds[0x70 + i * 0x50]; + b[4] = points[i]; + if (points[i] > 99) { + continue; + } + const bool audioTrack = points[i] == 2; + b[0] = audioTrack ? 0xA9 : 0xEC; + b[1] = 0x08; + Put32(b + 0x0C, audioTrack ? 0x208 : 0x200); + b[0x10] = stride & 0xFF; + b[0x11] = stride >> 8; + Put32(b + 0x24, audioTrack ? 45 : 0); + Put32(b + 0x28, audioTrack ? 40 * stride : 0); + Put32(b + 0x30, 1); + Put32(b + 0x34, 0x210); + } + Put32(&mds[0x200], 150); + Put32(&mds[0x204], 40); + Put32(&mds[0x208], 5); + Put32(&mds[0x20C], 25); + Put32(&mds[0x210], 0x220); + memcpy(&mds[0x220], "*.mdf", 6); + EXPECT_TRUE(File::WriteDataToFile(false, mds.data(), mds.size(), dir / "Alcohol.mds")); + + std::vector mdsDisc = data; + mdsDisc.insert(mdsDisc.end(), audio25.begin(), audio25.end()); + PS1EbootFileLoader eboot(dir / "Alcohol.mds"); + if (!CheckEboot(eboot, mdsDisc, 2)) { + return false; + } + PS1EbootFileLoader fromMdf(dir / "Alcohol.mdf"); + if (!CheckEboot(fromMdf, mdsDisc, 2)) { + 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 = { "Alcohol.mdf", "Alcohol.mds", "Clone.ccd", "Cooked.iso", "Game.cue", "Loose.bin", "Pregap.cue", "Stripped.cue" }; + EXPECT_TRUE(names == expectedNames); + + // Installing from a zip: the cue sheet and both its files, into a folder of their own. + const Path zipPath = dir / "Game.zip"; + { + int errorp = 0; + zip_t *z = zip_open(zipPath.c_str(), ZIP_CREATE | ZIP_TRUNCATE, &errorp); + EXPECT_TRUE(z != nullptr); + const std::pair *> entries[] = { + { "Game/Game (Track 1).bin", &data }, + { "Game/Game (Track 2).bin", &audio }, + }; + for (const auto &entry : entries) { + zip_source_t *source = zip_source_buffer(z, entry.second->data(), entry.second->size(), 0); + EXPECT_TRUE(zip_file_add(z, entry.first, source, ZIP_FL_ENC_UTF_8) >= 0); + } + zip_source_t *cueSource = zip_source_buffer(z, cue.data(), cue.size(), 0); + EXPECT_TRUE(zip_file_add(z, "Game/Game.cue", cueSource, ZIP_FL_ENC_UTF_8) >= 0); + zip_source_t *readme = zip_source_buffer(z, "hi", 2, 0); + EXPECT_TRUE(zip_file_add(z, "readme.txt", readme, ZIP_FL_ENC_UTF_8) >= 0); + EXPECT_TRUE(zip_close(z) == 0); + } + { + int errorp = 0; + zip_t *z = zip_open(zipPath.c_str(), 0, &errorp); + EXPECT_TRUE(z != nullptr); + ZipFileInfo info{}; + DetectZipFileContents(z, &info); + EXPECT_TRUE(info.contents == ZipFileContents::PSX_DISC); + EXPECT_EQ_INT(info.discFiles.size(), 3); + EXPECT_EQ_STR(info.discFiles[0], std::string("Game/Game.cue")); + const Path installDir = dir / "installed"; + EXPECT_TRUE(File::CreateFullPath(installDir)); + GameManager manager; + EXPECT_TRUE(manager.InstallZippedPS1Disc(z, info.discFiles, installDir)); + zip_close(z); + PS1EbootFileLoader eboot(installDir / "Game" / "Game.cue"); + if (!CheckEboot(eboot, disc, 2)) { + return false; + } + } + + 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 @@ +