From 23dffa9db3e93a8ef142b89490c3fa5217e5765d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 23 Oct 2026 13:42:56 +0200 Subject: [PATCH 1/7] POPS: Boot PS1 disc images as converted EBOOTs made on the fly A cue sheet, a CloneCD .ccd, or a bare .bin, .img or .iso of a PS1 disc now boots on the firmware's POPS, as the converted EBOOT.PBP popstation would make from it. PS1EbootFileLoader builds the PBP header, PARAM.SFO (the disc ID from SYSTEM.CNF) and the PSISOIMG header, TOC and block index, and serves the disc image from the image's own files. Each block of 16 sectors is a stored DEFLATE block: the manager won't read an image of uncompressed blocks in its own format, but the converted PBP's inflate takes stored ones, and with every block the same size, reads map straight onto the files. Nothing is compressed or written anywhere. Cue sheets can have any number of files, AUDIO tracks, INDEX 00 and PREGAP, and a missing FILE falls back to the cue sheet's own name. A 2048-byte .iso gets Mode 2 Form 1 sectors made around its user data. The EBOOT is mounted as the only file in host0:, through BlobFileSystem, which now saves its open handles in savestates. The game browser lists .cue, .ccd, .bin and .img, leaving out the .bin and .img files a cue sheet or .ccd next to them describes, and a bare image opened directly goes through the cue sheet that names it. GameInfoCache reads the title and ID of these, and of real PS1 EBOOTs, from their PARAM.SFO. Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 1 + Core/CMakeLists.txt | 2 + Core/Core.vcxproj | 2 + Core/Core.vcxproj.filters | 6 + Core/FileLoaders/PS1EbootFileLoader.cpp | 755 ++++++++++++++++++++++++ Core/FileLoaders/PS1EbootFileLoader.h | 110 ++++ Core/FileSystems/BlobFileSystem.cpp | 8 +- Core/FileSystems/BlobFileSystem.h | 4 +- Core/HLE/PopsBoot.cpp | 4 + Core/Loaders.cpp | 15 + Core/PSPLoaders.cpp | 15 +- Core/System.cpp | 7 +- UI/GameBrowser.cpp | 5 +- UI/GameInfoCache.cpp | 6 + UI/GameScreen.cpp | 1 - UWP/CoreUWP/CoreUWP.vcxproj | 2 + UWP/CoreUWP/CoreUWP.vcxproj.filters | 2 + android/jni/Android.mk | 2 + docs/POPS.md | 18 + libretro/Makefile.common | 1 + unittest/TestPS1Eboot.cpp | 296 ++++++++++ unittest/UnitTest.cpp | 2 + unittest/UnitTests.vcxproj | 1 + unittest/UnitTests.vcxproj.filters | 1 + 24 files changed, 1256 insertions(+), 10 deletions(-) create mode 100644 Core/FileLoaders/PS1EbootFileLoader.cpp create mode 100644 Core/FileLoaders/PS1EbootFileLoader.h create mode 100644 unittest/TestPS1Eboot.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 45b4971a69..f4c018c551 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1425,6 +1425,7 @@ if(UNITTEST) unittest/TestVertexJit.h unittest/TestVFS.cpp unittest/TestZipSlip.cpp + unittest/TestPS1Eboot.cpp unittest/TestLzrc.cpp unittest/TestCwCheat.cpp unittest/TestMpegCsc.cpp diff --git a/Core/CMakeLists.txt b/Core/CMakeLists.txt index 9a585939ae..c5d74a0f96 100644 --- a/Core/CMakeLists.txt +++ b/Core/CMakeLists.txt @@ -612,6 +612,8 @@ add_library(Core STATIC FileLoaders/HTTPFileLoader.h FileLoaders/LocalFileLoader.cpp FileLoaders/LocalFileLoader.h + FileLoaders/PS1EbootFileLoader.cpp + FileLoaders/PS1EbootFileLoader.h FileLoaders/RamCachingFileLoader.cpp FileLoaders/RamCachingFileLoader.h FileLoaders/RetryingFileLoader.cpp diff --git a/Core/Core.vcxproj b/Core/Core.vcxproj index 4098967edf..010a5d2ea2 100644 --- a/Core/Core.vcxproj +++ b/Core/Core.vcxproj @@ -587,6 +587,7 @@ + @@ -931,6 +932,7 @@ + diff --git a/Core/Core.vcxproj.filters b/Core/Core.vcxproj.filters index a0b8550a09..4556eb843d 100644 --- a/Core/Core.vcxproj.filters +++ b/Core/Core.vcxproj.filters @@ -619,6 +619,9 @@ FileLoaders + + FileLoaders + FileLoaders @@ -1978,6 +1981,9 @@ FileLoaders + + FileLoaders + FileLoaders diff --git a/Core/FileLoaders/PS1EbootFileLoader.cpp b/Core/FileLoaders/PS1EbootFileLoader.cpp new file mode 100644 index 0000000000..2702737849 --- /dev/null +++ b/Core/FileLoaders/PS1EbootFileLoader.cpp @@ -0,0 +1,755 @@ +// Copyright (c) 2012- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#include +#include +#include + +#include "Common/File/DirListing.h" +#include "Common/Log.h" +#include "Common/StringUtils.h" +#include "Common/Swap.h" +#include "Core/ELF/ParamSFO.h" +#include "Core/FileLoaders/PS1EbootFileLoader.h" + +static const u32 SECTOR_SIZE = 2352; +// The PSAR's disc image is in blocks of 16 sectors, each stored as one uncompressed DEFLATE block: a +// header byte (final, stored), the length, its complement, then the sectors. POPS doesn't take an +// uncompressed block in its own format (0x9300 bytes), but its decoder call goes to an inflate for a +// converted PBP (__PopsDeflateDecompress), and this way every block has the same size. +static const u32 BLOCK_SECTORS = 16; +static const u32 BLOCK_DATA_SIZE = BLOCK_SECTORS * SECTOR_SIZE; +static const u8 STORED_HEADER[5] = { 0x01, BLOCK_DATA_SIZE & 0xFF, BLOCK_DATA_SIZE >> 8, (u8)~BLOCK_DATA_SIZE, (u8)(~BLOCK_DATA_SIZE >> 8) }; +static const u32 BLOCK_SIZE = BLOCK_DATA_SIZE + sizeof(STORED_HEADER); + +// The PSISOIMG header, as popstation writes it: the disc ID at 0x400, the TOC at 0x800, the block index +// (32 bytes a block) from 0x4000, and the blocks from 0x100000. +static const u32 PSAR_HEADER_SIZE = 0x4000; +static const u32 PSAR_INDEX_ENTRY_SIZE = 32; +static const u32 PSAR_DATA_OFFSET = 0x100000; +static const u32 MAX_BLOCKS = (PSAR_DATA_OFFSET - PSAR_HEADER_SIZE) / PSAR_INDEX_ENTRY_SIZE; + +static const u8 SYNC[12] = { 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00 }; + +static std::string AsciiToUpper(std::string_view s) { + std::string out(s); + for (char &c : out) { + c = (char)toupper((unsigned char)c); + } + return out; +} + +static std::string AsciiToLower(std::string_view s) { + std::string out(s); + for (char &c : out) { + c = (char)tolower((unsigned char)c); + } + return out; +} + +// The file name without its extension. +static std::string FileStem(const Path &path) { + const std::string name = path.GetFilename(); + return name.substr(0, name.size() - path.GetFileExtension().size()); +} + +static u8 ToBCD(u32 v) { + return (u8)(((v / 10) << 4) | (v % 10)); +} + +// The absolute time of an LBA (which starts at 00:02:00), in BCD. +static void LBAToMSF(s32 lba, u8 *out) { + const u32 frames = (u32)(lba + 150); + out[0] = ToBCD(frames / (75 * 60)); + out[1] = ToBCD((frames / 75) % 60); + out[2] = ToBCD(frames % 75); +} + +// "mm:ss:ff" to a frame count, -1 if it isn't one. +static int ParseMSF(std::string_view s) { + int m, sec, f; + char c1, c2; + if (sscanf(std::string(s).c_str(), "%d%c%d%c%d", &m, &c1, &sec, &c2, &f) != 5 || c1 != ':' || c2 != ':') { + return -1; + } + return (m * 60 + sec) * 75 + f; +} + +static bool ReadSmallTextFile(const Path &path, std::string *text) { + std::unique_ptr loader(ConstructFileLoader(path)); + if (!loader || !loader->Exists()) { + return false; + } + const s64 size = loader->FileSize(); + if (size <= 0 || size > 64 * 1024) { + return false; + } + text->resize((size_t)size); + text->resize(loader->ReadAt(0, (size_t)size, &(*text)[0])); + if (startsWith(*text, "\xEF\xBB\xBF")) { + text->erase(0, 3); + } + return true; +} + +// The next token of a cue sheet line: a quoted string, or up to the next space. +static std::string_view NextToken(std::string_view *line) { + *line = StripSpaces(*line); + if (line->empty()) { + return {}; + } + size_t end; + std::string_view token; + if ((*line)[0] == '"') { + end = line->find('"', 1); + if (end == std::string_view::npos) { + end = line->size(); + } + token = line->substr(1, end - 1); + end = std::min(end + 1, line->size()); + } else { + end = line->find_first_of(" \t"); + if (end == std::string_view::npos) { + end = line->size(); + } + token = line->substr(0, end); + } + line->remove_prefix(end); + return token; +} + +bool IsPS1CueOrCcd(FileLoader *fileLoader) { + char buf[4096]{}; + const size_t size = fileLoader->ReadAt(0, sizeof(buf) - 1, buf); + const std::string text(buf, size); + const std::string ext = fileLoader->GetFileExtension(); + if (ext == ".ccd") { + return text.find("[CloneCD]") != std::string::npos; + } + if (ext == ".cue") { + const std::string upper = AsciiToUpper(text); + return upper.find("FILE") != std::string::npos && upper.find("TRACK") != std::string::npos; + } + return false; +} + +std::vector CueSheetFileNames(const Path &cuePath) { + std::vector names; + std::string text; + if (!ReadSmallTextFile(cuePath, &text)) { + return names; + } + std::vector lines; + SplitString(text, '\n', lines); + for (std::string_view line : lines) { + if (equalsNoCase(NextToken(&line), "FILE")) { + names.emplace_back(NextToken(&line)); + } + } + return names; +} + +Path FindCueSheetFor(const Path &imagePath) { + if (!imagePath.CanNavigateUp()) { + return Path(); + } + const std::string name = imagePath.GetFilename(); + std::vector cues; + File::GetFilesInDir(imagePath.NavigateUp(), &cues, "cue:"); + for (const File::FileInfo &cue : cues) { + if (cue.isDirectory) { + continue; + } + for (const std::string &file : CueSheetFileNames(cue.fullName)) { + if (equalsNoCase(Path(file).GetFilename(), name)) { + return cue.fullName; + } + } + } + return Path(); +} + +void RemoveDescribedDiscImages(std::vector *files) { + std::set described; + for (const File::FileInfo &info : *files) { + const std::string ext = info.fullName.GetFileExtension(); + if (info.isDirectory || (ext != ".cue" && ext != ".ccd")) { + continue; + } + const std::string stem = AsciiToLower(FileStem(info.fullName)); + described.insert(stem + ".bin"); + described.insert(stem + ".img"); + if (ext == ".cue") { + for (const std::string &file : CueSheetFileNames(info.fullName)) { + described.insert(AsciiToLower(Path(file).GetFilename())); + } + } + } + files->erase(std::remove_if(files->begin(), files->end(), [&](const File::FileInfo &info) { + const std::string ext = info.fullName.GetFileExtension(); + if (info.isDirectory || (ext != ".bin" && ext != ".img")) { + return false; + } + return info.size % SECTOR_SIZE != 0 || described.count(AsciiToLower(info.name)) != 0; + }), files->end()); +} + +PS1EbootFileLoader::PS1EbootFileLoader(const Path &path) : path_(path) { + const std::string ext = path.GetFileExtension(); + std::string text; + bool parsed = false; + if (ext == ".cue" || ext == ".ccd") { + if (!ReadSmallTextFile(path, &text)) { + error_ = "Can't read " + path.GetFilename(); + return; + } + parsed = ext == ".cue" ? ParseCue(path, text) : ParseCcd(path, text); + } else { + // A bare image with a cue sheet or a .ccd next to it is described by that, which has its audio tracks. + Path cue = FindCueSheetFor(path); + if (cue.empty()) { + cue = path.WithReplacedExtension(".cue"); + } + const Path ccd = path.WithReplacedExtension(".ccd"); + if (ReadSmallTextFile(cue, &text) && ParseCue(cue, text)) { + parsed = true; + } else if (ReadSmallTextFile(ccd, &text) && ParseCcd(ccd, text)) { + parsed = true; + } else { + files_.clear(); + extents_.clear(); + tracks_.clear(); + error_.clear(); + parsed = OpenBare(path); + } + } + if (!parsed || tracks_.empty() || extents_.empty()) { + if (error_.empty()) { + error_ = "Not a PS1 disc image: " + path.GetFilename(); + } + return; + } + + // LBA 0 is the first track's INDEX 01. An image that has its pregap starts before it. + const u32 origin = tracks_[0].start; + if (origin != 0) { + std::vector shifted; + for (Extent e : extents_) { + if (e.lba + e.count <= origin) { + continue; + } + if (e.lba < origin) { + const u32 skip = origin - e.lba; + e.offset += (s64)skip * e.sectorSize; + e.count -= skip; + e.lba = origin; + } + e.lba -= origin; + shifted.push_back(e); + } + extents_ = shifted; + for (Track &t : tracks_) { + t.start -= origin; + } + } + tracks_[0].pregap = 150; + numSectors_ = extents_.back().lba + extents_.back().count; + numBlocks_ = (numSectors_ + BLOCK_SECTORS - 1) / BLOCK_SECTORS; + if (numBlocks_ > MAX_BLOCKS) { + error_ = "Disc image too large"; + return; + } + + ReadDiscID(); + title_ = FileStem(path); + BuildHeaders(); + INFO_LOG(Log::Loader, "PS1 disc %s: %s, %d tracks, %d sectors", path.c_str(), discID_.c_str(), (int)tracks_.size(), numSectors_); +} + +PS1EbootFileLoader::~PS1EbootFileLoader() {} + +int PS1EbootFileLoader::AddFile(const Path &path) { + FileLoader *loader = ConstructFileLoader(path); + if (!loader || !loader->Exists()) { + delete loader; + return -1; + } + files_.emplace_back(loader); + return (int)files_.size() - 1; +} + +void PS1EbootFileLoader::AddExtent(u32 lba, u32 count, int file, s64 offset, int sectorSize) { + if (count != 0) { + extents_.push_back(Extent{ lba, count, file, offset, sectorSize }); + } +} + +bool PS1EbootFileLoader::ParseCue(const Path &cuePath, const std::string &text) { + struct CueTrack { + int number; + bool audio; + int index0 = -1; + int index1 = -1; + int pregap = 0; + }; + struct CueFile { + Path path; + int sectorSize = 0; + std::vector tracks; + }; + std::vector cueFiles; + + std::vector lines; + SplitString(text, '\n', lines); + for (std::string_view line : lines) { + const std::string command = AsciiToUpper(NextToken(&line)); + if (command == "FILE") { + const std::string_view name = NextToken(&line); + const std::string type = AsciiToUpper(NextToken(&line)); + if (type != "BINARY" && type != "MOTOROLA") { + error_ = "Unsupported file type in cue sheet: " + type; + return false; + } + CueFile file; + file.path = cuePath.NavigateUp() / name; + cueFiles.push_back(file); + } else if (command == "TRACK") { + if (cueFiles.empty()) { + return false; + } + const int number = atoi(std::string(NextToken(&line)).c_str()); + const std::string mode = AsciiToUpper(NextToken(&line)); + int sectorSize; + if (mode == "AUDIO" || mode == "MODE1/2352" || mode == "MODE2/2352") { + sectorSize = 2352; + } else if (mode == "MODE2/2336") { + sectorSize = 2336; + } else if (mode == "MODE1/2048" || mode == "MODE2/2048") { + sectorSize = 2048; + } else { + error_ = "Unsupported track mode in cue sheet: " + mode; + return false; + } + CueFile &file = cueFiles.back(); + if (file.sectorSize == 0) { + file.sectorSize = sectorSize; + } else if (file.sectorSize != sectorSize) { + error_ = "Tracks with different sector sizes in one file"; + return false; + } + CueTrack track; + track.number = number; + track.audio = mode == "AUDIO"; + file.tracks.push_back(track); + } else if (command == "INDEX" || command == "PREGAP") { + if (cueFiles.empty() || cueFiles.back().tracks.empty()) { + return false; + } + CueTrack &track = cueFiles.back().tracks.back(); + const int index = command == "INDEX" ? atoi(std::string(NextToken(&line)).c_str()) : -1; + const int frames = ParseMSF(NextToken(&line)); + if (frames < 0) { + return false; + } + if (index == -1) { + track.pregap = frames; + } else if (index == 0) { + track.index0 = frames; + } else if (index == 1) { + track.index1 = frames; + } + } + } + + // Lay the files out one after another, with any generated pregap before its track. + u32 lba = 0; + for (CueFile &cueFile : cueFiles) { + int file = AddFile(cueFile.path); + if (file < 0) { + // Renamed images are common: try the cue sheet's own name. + const std::string ext = cueFile.path.GetFileExtension(); + for (const char *alt : { ext.c_str(), ".bin", ".img" }) { + file = AddFile(cuePath.WithReplacedExtension(alt)); + if (file >= 0) { + break; + } + } + if (file < 0 || cueFiles.size() != 1) { + error_ = "Missing file in cue sheet: " + cueFile.path.GetFilename(); + return false; + } + } + if (cueFile.tracks.empty()) { + continue; + } + const u32 fileSectors = (u32)(files_[file]->FileSize() / cueFile.sectorSize); + u32 fileSector = 0; + for (const CueTrack &cueTrack : cueFile.tracks) { + if (cueTrack.index1 < 0) { + error_ = "Track without INDEX 01 in cue sheet"; + return false; + } + const u32 split = (u32)(cueTrack.index0 >= 0 ? cueTrack.index0 : cueTrack.index1); + if (split < fileSector || (u32)cueTrack.index1 < split) { + return false; + } + AddExtent(lba, split - fileSector, file, (s64)fileSector * cueFile.sectorSize, cueFile.sectorSize); + lba += split - fileSector; + AddExtent(lba, cueTrack.pregap, -1, 0, SECTOR_SIZE); + lba += cueTrack.pregap; + fileSector = split; + + Track track; + track.number = cueTrack.number; + track.audio = cueTrack.audio; + track.start = lba + (cueTrack.index1 - split); + track.pregap = cueTrack.pregap + (cueTrack.index1 - split); + tracks_.push_back(track); + } + if (fileSectors > fileSector) { + AddExtent(lba, fileSectors - fileSector, file, (s64)fileSector * cueFile.sectorSize, cueFile.sectorSize); + lba += fileSectors - fileSector; + } + } + return !tracks_.empty(); +} + +bool PS1EbootFileLoader::ParseCcd(const Path &ccdPath, const std::string &text) { + const int file = AddFile(ccdPath.WithReplacedExtension(".img")); + if (file < 0) { + error_ = "Missing " + ccdPath.WithReplacedExtension(".img").GetFilename(); + return false; + } + AddExtent(0, (u32)(files_[file]->FileSize() / SECTOR_SIZE), file, 0, SECTOR_SIZE); + + // [TRACK n] sections: MODE=0 is audio, INDEX 1= is where the track starts, INDEX 0= its pregap. + std::vector lines; + SplitString(text, '\n', lines); + Track *track = nullptr; + int index0 = -1; + auto finishTrack = [&]() { + if (track && index0 >= 0 && (u32)index0 <= track->start) { + track->pregap = track->start - index0; + } + index0 = -1; + }; + for (std::string_view line : lines) { + line = StripSpaces(line); + if (startsWith(line, "[")) { + finishTrack(); + track = nullptr; + if (startsWithNoCase(line, "[TRACK ")) { + tracks_.push_back(Track{ atoi(std::string(line.substr(7)).c_str()), false, 0, 0 }); + track = &tracks_.back(); + } + continue; + } + std::string_view key, value; + if (!track || !SplitStringOnce(line, &key, &value, '=')) { + continue; + } + key = StripSpaces(key); + const int v = atoi(std::string(StripSpaces(value)).c_str()); + if (equalsNoCase(key, "MODE")) { + track->audio = v == 0; + } else if (equalsNoCase(key, "INDEX 1")) { + track->start = v; + } else if (equalsNoCase(key, "INDEX 0")) { + index0 = v; + } + } + finishTrack(); + std::sort(tracks_.begin(), tracks_.end(), [](const Track &a, const Track &b) { return a.number < b.number; }); + if (tracks_.empty()) { + // No tracks listed: one data track. + tracks_.push_back(Track{ 1, false, 0, 150 }); + } + return true; +} + +bool PS1EbootFileLoader::OpenBare(const Path &path) { + const int file = AddFile(path); + if (file < 0) { + return false; + } + FileLoader *loader = files_[file].get(); + const s64 size = loader->FileSize(); + u8 sync[12]{}; + u8 cd001[6]{}; + loader->ReadAt(0, sizeof(sync), sync); + loader->ReadAt(16 * 2048, sizeof(cd001), cd001); + int sectorSize; + if (size % SECTOR_SIZE == 0 && memcmp(sync, SYNC, sizeof(SYNC)) == 0) { + sectorSize = SECTOR_SIZE; + } else if (size % 2048 == 0 && memcmp(cd001, "\x01" "CD001", 6) == 0) { + sectorSize = 2048; + } else { + return false; + } + AddExtent(0, (u32)(size / sectorSize), file, 0, sectorSize); + tracks_.push_back(Track{ 1, false, 0, 150 }); + return true; +} + +bool PS1EbootFileLoader::ReadRawSectors(u32 lba, u32 count, u8 *out) { + bool ok = true; + while (count > 0) { + auto it = std::upper_bound(extents_.begin(), extents_.end(), lba, [](u32 lba, const Extent &e) { return lba < e.lba; }); + if (lba >= numSectors_ || it == extents_.begin()) { + memset(out, 0, (size_t)count * SECTOR_SIZE); + return ok; + } + const Extent &e = *(it - 1); + const u32 run = std::min(count, e.lba + e.count - lba); + const s64 offset = e.offset + (s64)(lba - e.lba) * e.sectorSize; + if (e.file < 0) { + memset(out, 0, (size_t)run * SECTOR_SIZE); + } else if (e.sectorSize == (int)SECTOR_SIZE) { + const size_t want = (size_t)run * SECTOR_SIZE; + const size_t got = files_[e.file]->ReadAt(offset, want, out); + if (got < want) { + memset(out + got, 0, want - got); + ok = false; + } + } else { + // Sectors without their sync and header (2336), or only their user data (2048, which can only + // be Mode 2 Form 1). The EDC and ECC of those stay zero. + for (u32 i = 0; i < run; i++) { + u8 *sector = out + (size_t)i * SECTOR_SIZE; + memset(sector, 0, SECTOR_SIZE); + memcpy(sector, SYNC, sizeof(SYNC)); + LBAToMSF(lba + i, sector + 12); + sector[15] = 2; + u8 *data = sector + 16; + if (e.sectorSize == 2048) { + static const u8 subheader[8] = { 0, 0, 8, 0, 0, 0, 8, 0 }; + memcpy(sector + 16, subheader, sizeof(subheader)); + data = sector + 24; + } + const s64 pos = offset + (s64)i * e.sectorSize; + if (files_[e.file]->ReadAt(pos, e.sectorSize, data) != (size_t)e.sectorSize) { + ok = false; + } + } + } + lba += run; + count -= run; + out += (size_t)run * SECTOR_SIZE; + } + return ok; +} + +bool PS1EbootFileLoader::ReadUserData(u32 lba, u8 *out) { + u8 sector[SECTOR_SIZE]; + if (!ReadRawSectors(lba, 1, sector)) { + return false; + } + memcpy(out, sector + (sector[15] == 1 ? 16 : 24), 2048); + return true; +} + +// The ID the disc boots by, from SYSTEM.CNF's BOOT line ("cdrom:\SLUS_007.13;1" is SLUS00713). +void PS1EbootFileLoader::ReadDiscID() { + discID_ = "SLUS00000"; + u8 pvd[2048]; + if (!ReadUserData(16, pvd) || memcmp(pvd, "\x01" "CD001", 6) != 0) { + return; + } + const u32 rootLBA = pvd[156 + 2] | (pvd[156 + 3] << 8) | (pvd[156 + 4] << 16) | ((u32)pvd[156 + 5] << 24); + const u32 rootSize = pvd[156 + 10] | (pvd[156 + 11] << 8) | (pvd[156 + 12] << 16) | ((u32)pvd[156 + 13] << 24); + std::string cnf; + for (u32 s = 0; s < std::min(rootSize / 2048, 16U) && cnf.empty(); s++) { + u8 dir[2048]; + if (!ReadUserData(rootLBA + s, dir)) { + return; + } + for (u32 pos = 0; pos + 33 < sizeof(dir) && dir[pos] != 0; pos += dir[pos]) { + const u8 nameLen = dir[pos + 32]; + if (pos + 33 + nameLen > sizeof(dir)) { + break; + } + std::string_view name((const char *)&dir[pos + 33], nameLen); + name = name.substr(0, name.find(';')); + if (!equalsNoCase(name, "SYSTEM.CNF")) { + continue; + } + const u32 lba = dir[pos + 2] | (dir[pos + 3] << 8) | (dir[pos + 4] << 16) | ((u32)dir[pos + 5] << 24); + const u32 size = std::min(dir[pos + 10] | (dir[pos + 11] << 8) | (dir[pos + 12] << 16) | ((u32)dir[pos + 13] << 24), 2048U); + u8 data[2048]; + if (ReadUserData(lba, data)) { + cnf.assign((const char *)data, size); + } + break; + } + } + + std::vector lines; + SplitString(cnf, '\n', lines); + for (std::string_view line : lines) { + std::string_view key, value; + if (!SplitStringOnce(line, &key, &value, '=') || !equalsNoCase(StripSpaces(key), "BOOT")) { + continue; + } + value = StripSpaces(value); + const size_t slash = value.find_last_of("\\:/"); + if (slash != std::string_view::npos) { + value = value.substr(slash + 1); + } + value = value.substr(0, value.find(';')); + std::string id; + for (char c : value) { + if (isalnum((unsigned char)c)) { + id.push_back((char)toupper((unsigned char)c)); + } + } + if (id.size() == 9) { + discID_ = id; + } + break; + } +} + +void PS1EbootFileLoader::BuildHeaders() { + ParamSFOData sfo; + sfo.SetValue("BOOTABLE", 1, 4); + sfo.SetValue("CATEGORY", "ME", 4); + sfo.SetValue("DISC_ID", discID_, 16); + sfo.SetValue("DISC_VERSION", "1.00", 8); + sfo.SetValue("PARENTAL_LEVEL", 1, 4); + sfo.SetValue("PSP_SYSTEM_VER", "3.01", 8); + sfo.SetValue("REGION", 0x8000, 4); + sfo.SetValue("TITLE", title_, 128); + u8 *sfoData = nullptr; + size_t sfoSize = 0; + sfo.WriteSFO(&sfoData, &sfoSize); + + // PARAM.SFO, then no icons, pictures, sound or DATA.PSP. + const u32 sfoEnd = 0x28 + (u32)sfoSize; + psarOffset_ = (sfoEnd + 0xFFFF) & ~0xFFFF; + head_.assign(psarOffset_ + PSAR_HEADER_SIZE, 0); + u8 *pbp = head_.data(); + memcpy(pbp, "\0PBP", 4); + u32_le *words = (u32_le *)pbp; + words[1] = 0x00010000; + words[2] = 0x28; + for (int i = 3; i < 9; i++) { + words[i] = sfoEnd; + } + words[9] = psarOffset_; + memcpy(pbp + 0x28, sfoData, sfoSize); + delete[] sfoData; + + u8 *psar = pbp + psarOffset_; + auto write32 = [&](u32 offset, u32 value) { + *(u32_le *)(psar + offset) = value; + }; + const u32 end = PSAR_DATA_OFFSET + numBlocks_ * BLOCK_SIZE; + memcpy(psar, "PSISOIMG0000", 12); + write32(0x0C, end); + const std::string gameID = "_" + discID_.substr(0, 4) + "_" + discID_.substr(4); + memcpy(psar + 0x400, gameID.data(), std::min(gameID.size(), (size_t)0x20)); + + // The TOC, as Q subchannel entries of 10 bytes (control/ADR, TNO, POINT, the absolute time, zero, then + // POINT's): the first track (and the disc type, 0x20 being XA), the last one, the lead-out, then each + // track with its pregap's start and its own. + const Track &first = tracks_.front(); + const Track &last = tracks_.back(); + auto control = [](const Track &t) -> u8 { return t.audio ? 0x01 : 0x41; }; + u8 *toc = psar + 0x800; + toc[0] = control(first); toc[2] = 0xA0; toc[7] = ToBCD(first.number); toc[8] = 0x20; + toc += 10; + toc[0] = control(last); toc[2] = 0xA1; toc[7] = ToBCD(last.number); + toc += 10; + toc[0] = control(last); toc[2] = 0xA2; LBAToMSF(numSectors_, toc + 7); + toc += 10; + for (const Track &t : tracks_) { + if (toc + 10 > psar + 0xBFC) { + break; + } + toc[0] = control(t); + toc[2] = ToBCD(t.number); + LBAToMSF((s32)t.start - (s32)t.pregap, toc + 3); + LBAToMSF(t.start, toc + 7); + toc += 10; + } + write32(0xBFC, PSAR_DATA_OFFSET); + write32(0x1220, end + 0x2D31); + write32(0x1228, 0x7FF); + memcpy(psar + 0x122C, title_.data(), std::min(title_.size(), (size_t)0x7F)); + write32(0x12AC, 3); + + size_ = (s64)psarOffset_ + end; +} + +size_t PS1EbootFileLoader::ReadAt(s64 absolutePos, size_t bytes, size_t count, void *data, Flags flags) { + if (!IsValid() || absolutePos < 0 || bytes == 0) { + return 0; + } + u8 *out = (u8 *)data; + size_t remaining = (size_t)std::min((s64)(bytes * count), std::max(size_ - absolutePos, (s64)0)); + const size_t total = remaining; + s64 pos = absolutePos; + const s64 indexStart = (s64)head_.size(); + const s64 dataStart = (s64)psarOffset_ + PSAR_DATA_OFFSET; + while (remaining > 0) { + size_t n; + if (pos < indexStart) { + n = std::min(remaining, (size_t)(indexStart - pos)); + memcpy(out, head_.data() + pos, n); + } else if (pos < dataStart) { + const u32 entry = (u32)((pos - indexStart) / PSAR_INDEX_ENTRY_SIZE); + const u32 within = (u32)((pos - indexStart) % PSAR_INDEX_ENTRY_SIZE); + u8 buf[PSAR_INDEX_ENTRY_SIZE]{}; + if (entry < numBlocks_) { + *(u32_le *)buf = entry * BLOCK_SIZE; + *(u16_le *)(buf + 4) = (u16)BLOCK_SIZE; + } + n = std::min(remaining, (size_t)(PSAR_INDEX_ENTRY_SIZE - within)); + memcpy(out, buf + within, n); + } else { + const u32 block = (u32)((pos - dataStart) / BLOCK_SIZE); + const u32 within = (u32)((pos - dataStart) % BLOCK_SIZE); + if (within < sizeof(STORED_HEADER)) { + n = std::min(remaining, sizeof(STORED_HEADER) - within); + memcpy(out, STORED_HEADER + within, n); + } else { + const u32 offset = within - sizeof(STORED_HEADER); + n = std::min(remaining, (size_t)(BLOCK_DATA_SIZE - offset)); + const u32 lba = block * BLOCK_SECTORS + offset / SECTOR_SIZE; + if (offset % SECTOR_SIZE == 0 && n % SECTOR_SIZE == 0) { + ReadRawSectors(lba, (u32)(n / SECTOR_SIZE), out); + } else { + const u32 first = offset % SECTOR_SIZE; + const u32 sectors = (u32)((first + n + SECTOR_SIZE - 1) / SECTOR_SIZE); + std::vector buf((size_t)sectors * SECTOR_SIZE); + ReadRawSectors(lba, sectors, buf.data()); + memcpy(out, buf.data() + first, n); + } + } + } + out += n; + pos += n; + remaining -= n; + } + return total / bytes; +} + +void PS1EbootFileLoader::Cancel() { + for (auto &file : files_) { + file->Cancel(); + } +} diff --git a/Core/FileLoaders/PS1EbootFileLoader.h b/Core/FileLoaders/PS1EbootFileLoader.h new file mode 100644 index 0000000000..fc8ff6b614 --- /dev/null +++ b/Core/FileLoaders/PS1EbootFileLoader.h @@ -0,0 +1,110 @@ +// Copyright (c) 2012- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#pragma once + +#include +#include +#include + +#include "Common/CommonTypes.h" +#include "Core/Loaders.h" + +// A PS1 disc image (a cue sheet, a CloneCD .ccd, or a bare .bin/.img/.iso) presented as a converted +// PS1 EBOOT.PBP, the kind popstation makes, for the firmware's POPS to run. Only the headers are kept +// in memory. The disc image is stored uncompressed, as stored DEFLATE blocks, so a read of it goes +// straight to the image's files. See docs/POPS.md. +class PS1EbootFileLoader : public FileLoader { +public: + explicit PS1EbootFileLoader(const Path &path); + ~PS1EbootFileLoader(); + + bool IsValid() const { return error_.empty(); } + + bool Exists() override { return IsValid(); } + bool IsDirectory() override { return false; } + s64 FileSize() override { return size_; } + Path GetPath() const override { return path_; } + std::string GetFileExtension() const override { return ".pbp"; } + size_t ReadAt(s64 absolutePos, size_t bytes, size_t count, void *data, Flags flags = Flags::NONE) override; + void Cancel() override; + std::string LatestError() const override { return error_; } + + const std::string &DiscID() const { return discID_; } + const std::string &Title() const { return title_; } + +private: + struct Track { + int number; + bool audio; + u32 start; // Disc LBA of INDEX 01. + u32 pregap; // Sectors before it that belong to the track (INDEX 00, or a generated PREGAP). + }; + // A run of disc sectors, from a file or (file < 0) generated, all zeroes. + struct Extent { + u32 lba; + u32 count; + int file; + s64 offset; + int sectorSize; // 2352 raw, 2336 without sync and header, 2048 user data only. + }; + + bool ParseCue(const Path &cuePath, const std::string &text); + bool ParseCcd(const Path &ccdPath, const std::string &text); + bool OpenBare(const Path &path); + int AddFile(const Path &path); + void AddExtent(u32 lba, u32 count, int file, s64 offset, int sectorSize); + + bool ReadRawSectors(u32 lba, u32 count, u8 *out); + bool ReadUserData(u32 lba, u8 *out); + void ReadDiscID(); + void BuildHeaders(); + + Path path_; + std::string error_; + std::vector> files_; + std::vector extents_; + std::vector tracks_; + u32 numSectors_ = 0; + + std::string discID_; + std::string title_; + + // The PBP up to the PSAR's block index, then the index and the blocks, which are made on the fly. + std::vector head_; + u32 psarOffset_ = 0; + u32 numBlocks_ = 0; + s64 size_ = 0; +}; + +namespace File { +struct FileInfo; +} + +// Whether a .cue or .ccd describes a disc, by its contents. +bool IsPS1CueOrCcd(FileLoader *fileLoader); + +// The file names a cue sheet's FILE lines give. +std::vector CueSheetFileNames(const Path &cuePath); + +// The cue sheet next to a disc image whose FILE lines name it, or an empty path. +Path FindCueSheetFor(const Path &imagePath); + +// Removes the .bin and .img files from a directory listing that a cue sheet or .ccd in it describes, so +// that each game shows up once, and those that can't be a raw disc image (a size that isn't a whole +// number of sectors). +void RemoveDescribedDiscImages(std::vector *files); diff --git a/Core/FileSystems/BlobFileSystem.cpp b/Core/FileSystems/BlobFileSystem.cpp index 1060844bf4..7139c2fcad 100644 --- a/Core/FileSystems/BlobFileSystem.cpp +++ b/Core/FileSystems/BlobFileSystem.cpp @@ -15,6 +15,8 @@ // Official git repository and contact information can be found at // https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. +#include "Common/Serialize/Serializer.h" +#include "Common/Serialize/SerializeMap.h" #include "Core/FileSystems/BlobFileSystem.h" BlobFileSystem::BlobFileSystem(IHandleAllocator *hAlloc, FileLoader *fileLoader, std::string alias) @@ -27,7 +29,11 @@ BlobFileSystem::~BlobFileSystem() { } void BlobFileSystem::DoState(PointerWrap &p) { - // Not used in real emulation. + auto s = p.Section("BlobFileSystem", 1); + if (!s) + return; + // The open handles and their positions. The blob is the one booted. + Do(p, entries_); } std::vector BlobFileSystem::GetDirListing(std::string_view path, bool *exists) { diff --git a/Core/FileSystems/BlobFileSystem.h b/Core/FileSystems/BlobFileSystem.h index 1d8f96c84d..88add5666c 100644 --- a/Core/FileSystems/BlobFileSystem.h +++ b/Core/FileSystems/BlobFileSystem.h @@ -17,9 +17,9 @@ #pragma once -// This is used for opening a debug file as a blob, and mounting it. +// This is used for opening a file as a blob, and mounting it. // Importantly, uses a fileLoader for all access, so http:// URLs are supported. -// As of writing, only used by GE dump replay. +// Used by GE dump replay, and for the EBOOT a PS1 disc image is presented as. #include #include diff --git a/Core/HLE/PopsBoot.cpp b/Core/HLE/PopsBoot.cpp index 0a8abcb707..1a606d542a 100644 --- a/Core/HLE/PopsBoot.cpp +++ b/Core/HLE/PopsBoot.cpp @@ -235,6 +235,10 @@ static bool ReadDiscImage(const PBPReader &pbp, size_t size, u8 *out) { // The content ID (DATA.PSP + 0x560, as in the license) and the disc image's PGD header. static bool ReadPBPDrmHeaders(u8 *pgdHeader, u8 *contentId) { + // A converted one has no PGD, and one made from a disc image isn't at its path. + if (g_popsConvertedPBP) { + return false; + } std::unique_ptr loader(ConstructFileLoader(g_popsPBPPath)); PBPReader pbp(loader.get()); return pbp.IsValid() && pbp.ReadSubFile(PBP_EXECUTABLE_PSP, 0x560, 0x30, contentId) && diff --git a/Core/Loaders.cpp b/Core/Loaders.cpp index 8dd58240b6..66f873ec2c 100644 --- a/Core/Loaders.cpp +++ b/Core/Loaders.cpp @@ -25,6 +25,7 @@ #include "Core/FileLoaders/DiskCachingFileLoader.h" #include "Core/FileLoaders/HTTPFileLoader.h" #include "Core/FileLoaders/LocalFileLoader.h" +#include "Core/FileLoaders/PS1EbootFileLoader.h" #include "Core/FileLoaders/RetryingFileLoader.h" #include "Core/FileLoaders/ZipFileLoader.h" #include "Core/FileSystems/MetaFileSystem.h" @@ -111,6 +112,9 @@ IdentifiedFileType Identify_File(FileLoader *fileLoader, std::string *errorStrin bool isDiscImage = false; if (extension == ".iso" || extension == ".cso" || extension == ".chd") { isDiscImage = true; + } else if (extension == ".cue" || extension == ".ccd") { + // A PS1 disc, which boots as a converted EBOOT (see PS1EbootFileLoader). + return IsPS1CueOrCcd(fileLoader) ? IdentifiedFileType::PSX_ISO : IdentifiedFileType::UNKNOWN; } else if (extension == ".ppst") { return IdentifiedFileType::PPSSPP_SAVESTATE; } else if (extension == ".ppdmp") { @@ -302,6 +306,17 @@ FileLoader *ResolveFileLoaderTarget(FileLoader *fileLoader, IdentifiedFileType * // Re-identify the file. *fileType = Identify_File(fileLoader, errorString); } + } else if (*fileType == IdentifiedFileType::PSX_ISO) { + // A PS1 disc image runs on the firmware's POPS, as a converted EBOOT made from it. + PS1EbootFileLoader *eboot = new PS1EbootFileLoader(fileLoader->GetPath()); + if (!eboot->IsValid()) { + *errorString = eboot->LatestError(); + delete eboot; + return fileLoader; + } + delete fileLoader; + *fileType = IdentifiedFileType::PSP_PS1_PBP; + return eboot; } else if (*fileType == IdentifiedFileType::ARCHIVE_ZIP) { // Handle zip files, take automatic action depending on contents. // Can also return nullptr. diff --git a/Core/PSPLoaders.cpp b/Core/PSPLoaders.cpp index 19a834f2e6..7892fd7723 100644 --- a/Core/PSPLoaders.cpp +++ b/Core/PSPLoaders.cpp @@ -35,6 +35,7 @@ #include "Core/ELF/ParamSFO.h" #include "Core/ELF/PBPReader.h" #include "Core/ELF/PrxDecrypter.h" +#include "Core/FileLoaders/PS1EbootFileLoader.h" #include "Core/FileSystems/BlockDevices.h" #include "Core/FileSystems/BlobFileSystem.h" @@ -671,14 +672,20 @@ bool Load_PSP_POPS(FileLoader *fileLoader, std::string *error_string) { return false; } - // Its directory as host0:, as for a homebrew EBOOT. + // Its directory as host0:, as for a homebrew EBOOT. An EBOOT made from a disc image is the only file there. const Path fullPath = fileLoader->GetPath(); - auto fs = std::make_shared(&pspFileSystem, fullPath.NavigateUp(), FileSystemFlags::SIMULATE_FAT32 | FileSystemFlags::CARD); - pspFileSystem.Mount("host0:", fs); + std::string gamePath; + if (dynamic_cast(fileLoader)) { + pspFileSystem.Mount("host0:", std::make_shared(&pspFileSystem, fileLoader, "EBOOT.PBP")); + gamePath = "host0:/EBOOT.PBP"; + } else { + auto fs = std::make_shared(&pspFileSystem, fullPath.NavigateUp(), FileSystemFlags::SIMULATE_FAT32 | FileSystemFlags::CARD); + pspFileSystem.Mount("host0:", fs); + gamePath = "host0:/" + fullPath.GetFilename(); + } pspFileSystem.SetStartingDirectory("host0:/"); __PopsBootInit(); - const std::string gamePath = "host0:/" + fullPath.GetFilename(); INFO_LOG(Log::Loader, "POPS: booting %s through %s", gamePath.c_str(), popsmanPath); if (!__KernelLoadExec(currentMIPS, popsmanPath, 0, error_string)) { return false; diff --git a/Core/System.cpp b/Core/System.cpp index cdd645f989..870b380122 100644 --- a/Core/System.cpp +++ b/Core/System.cpp @@ -288,7 +288,12 @@ static void GetBootError(IdentifiedFileType type, std::string *errorString) { case IdentifiedFileType::ARCHIVE_7Z: *errorString = "7z file detected (Require 7-Zip)"; break; case IdentifiedFileType::PSP_PKG: *errorString = "PKG game updates need to be installed, not booted."; break; - case IdentifiedFileType::PSX_ISO: *errorString = "PSX game image detected."; break; + case IdentifiedFileType::PSX_ISO: + // It only gets here if it couldn't be made into an EBOOT, which says why. + if (errorString->empty()) { + *errorString = "PSX game image detected."; + } + break; case IdentifiedFileType::PS2_ISO: *errorString = "PS2 game image detected."; break; case IdentifiedFileType::PS3_ISO: *errorString = "PS2 game image detected."; break; case IdentifiedFileType::NORMAL_DIRECTORY: *errorString = "Just a directory."; break; diff --git a/UI/GameBrowser.cpp b/UI/GameBrowser.cpp index 2ed4209724..2735accccd 100644 --- a/UI/GameBrowser.cpp +++ b/UI/GameBrowser.cpp @@ -38,6 +38,7 @@ #include "Core/Util/RecentFiles.h" #include "Core/HLE/sceCtrl.h" #include "Core/ELF/PBPReader.h" +#include "Core/FileLoaders/PS1EbootFileLoader.h" #include "Core/MemMap.h" #include "Core/Util/VideoPlayer.h" @@ -1116,7 +1117,9 @@ void GameBrowser::Refresh() { } } else if (!listingPending_) { std::vector fileInfo; - path_.GetListing(fileInfo, "iso:cso:chd:pbp:elf:prx:ppdmp:"); + path_.GetListing(fileInfo, "iso:cso:chd:pbp:elf:prx:ppdmp:cue:ccd:bin:img:"); + // PS1 discs: a .bin or .img shows up as its cue sheet or .ccd, when it has one. + RemoveDescribedDiscImages(&fileInfo); for (size_t i = 0; i < fileInfo.size(); i++) { bool isGame = !fileInfo[i].isDirectory; bool isSaveData = false; diff --git a/UI/GameInfoCache.cpp b/UI/GameInfoCache.cpp index b83d53ad00..006e2814d5 100644 --- a/UI/GameInfoCache.cpp +++ b/UI/GameInfoCache.cpp @@ -33,6 +33,7 @@ #include "Common/System/Request.h" #include "Common/StringUtils.h" #include "Common/TimeUtil.h" +#include "Core/FileLoaders/PS1EbootFileLoader.h" #include "Core/FileSystems/ISOFileSystem.h" #include "Core/FileSystems/DirectoryFileSystem.h" #include "Core/FileSystems/VirtualDiscFileSystem.h" @@ -597,6 +598,8 @@ public: switch (fileType) { case IdentifiedFileType::PSP_PBP: case IdentifiedFileType::PSP_PBP_DIRECTORY: + case IdentifiedFileType::PSP_PS1_PBP: + case IdentifiedFileType::PSX_ISO: { auto pbpLoader = info_->GetFileLoader(); if (fileType == IdentifiedFileType::PSP_PBP_DIRECTORY) { @@ -604,6 +607,9 @@ public: if (ebootPath != gamePath_) { pbpLoader.reset(ConstructFileLoader(ebootPath)); } + } else if (fileType == IdentifiedFileType::PSX_ISO) { + // The EBOOT a PS1 disc image boots as, which has its title and disc ID. + pbpLoader.reset(new PS1EbootFileLoader(gamePath_)); } if (!pbpLoader->Exists()) { diff --git a/UI/GameScreen.cpp b/UI/GameScreen.cpp index 6176b901e1..cd9fe5cdbc 100644 --- a/UI/GameScreen.cpp +++ b/UI/GameScreen.cpp @@ -233,7 +233,6 @@ static bool FileTypeIsPlayable(IdentifiedFileType fileType) { switch (fileType) { case IdentifiedFileType::ERROR_IDENTIFYING: case IdentifiedFileType::UNKNOWN: - case IdentifiedFileType::PSX_ISO: case IdentifiedFileType::PS2_ISO: case IdentifiedFileType::PS3_ISO: case IdentifiedFileType::UNKNOWN_BIN: diff --git a/UWP/CoreUWP/CoreUWP.vcxproj b/UWP/CoreUWP/CoreUWP.vcxproj index 1d33f307f1..bb67906a68 100644 --- a/UWP/CoreUWP/CoreUWP.vcxproj +++ b/UWP/CoreUWP/CoreUWP.vcxproj @@ -146,6 +146,7 @@ + @@ -450,6 +451,7 @@ + diff --git a/UWP/CoreUWP/CoreUWP.vcxproj.filters b/UWP/CoreUWP/CoreUWP.vcxproj.filters index fd6c82b8f7..c62fc9ce84 100644 --- a/UWP/CoreUWP/CoreUWP.vcxproj.filters +++ b/UWP/CoreUWP/CoreUWP.vcxproj.filters @@ -57,6 +57,7 @@ + @@ -477,6 +478,7 @@ + diff --git a/android/jni/Android.mk b/android/jni/Android.mk index ca7af5104b..f61d4c1973 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -690,6 +690,7 @@ EXEC_AND_LIB_FILES := \ $(SRC)/Core/FileLoaders/DiskCachingFileLoader.cpp \ $(SRC)/Core/FileLoaders/HTTPFileLoader.cpp \ $(SRC)/Core/FileLoaders/LocalFileLoader.cpp \ + $(SRC)/Core/FileLoaders/PS1EbootFileLoader.cpp \ $(SRC)/Core/FileLoaders/RamCachingFileLoader.cpp \ $(SRC)/Core/FileLoaders/RetryingFileLoader.cpp \ $(SRC)/Core/FileLoaders/ZipFileLoader.cpp \ @@ -1131,6 +1132,7 @@ ifeq ($(UNITTEST),1) $(SRC)/unittest/TestSplineTessellation.cpp \ $(SRC)/unittest/TestGEMath.cpp \ $(SRC)/unittest/TestZipSlip.cpp \ + $(SRC)/unittest/TestPS1Eboot.cpp \ $(SRC)/unittest/UnitTest.cpp include $(BUILD_EXECUTABLE) diff --git a/docs/POPS.md b/docs/POPS.md index a8340780c9..a236dbebe4 100644 --- a/docs/POPS.md +++ b/docs/POPS.md @@ -58,6 +58,22 @@ A converted EBOOT (popstation and similar) has a plain disc image of DEFLATE blo does, the emulator's decoder calls go to an inflate, its CD audio index check takes the full-length branch, and the two MAC checks (+0A28, +0AB8) pass. +## Disc images + +A PS1 disc image boots as the converted EBOOT it would make (`Core/FileLoaders/PS1EbootFileLoader.cpp`), +which never exists as a whole. It takes a cue sheet (with any number of files, AUDIO tracks, INDEX 00 +and PREGAP), a CloneCD `.ccd` with its `.img`, or a bare raw (2352-byte sector) `.bin`, `.img` or `.iso`, +or a 2048-byte `.iso`, around whose user data Mode 2 Form 1 sectors are made (with no EDC/ECC). A bare +image that a cue sheet next to it names, or that has a `.ccd` of the same name, is read through that. +The disc ID is the one SYSTEM.CNF boots. + +It's laid out as popstation lays out a PSISOIMG: the disc ID at +0x400, the TOC at +0x800, a block +index at +0x4000 and the blocks from +0x100000, each of 16 sectors. The manager doesn't read the disc +image of one whose blocks are its own uncompressed size (0x9300), so each block is a stored DEFLATE +block, which the inflate above decodes: 0x9305 bytes, so reads of the image map straight onto its files. +There are no icons, pictures or DATA.PSP, and none of the STARTDAT that follows a popstation image. The +EBOOT is the only file in `host0:`. + For any EBOOT, also as popcorn does: the manual's name check passes, and an ICON0 the emulator can't use (anything but an 80x80 PNG, such as a missing one or a PSP game's 144x80 one, which crashes it) reads as a blank 80x80 one. @@ -78,5 +94,7 @@ The HOME button opens the emulator's menu. Savestates work. - The anti-LibCrypt word some PAL games need in a converted image (PSISOIMG + 0x12B0). ARK-4's popcorn has a table of them by disc ID, switched off. Converting tools can patch the image instead. - ARK-4's per-game CONFIG.BIN next to the EBOOT, copied over the image's config (PSISOIMG + 0x420). +- Multi-disc games from disc images (PSTITLEIMG, which has up to five PSISOIMGs), and `.mds`/`.mdf`, CHD + and archived images. - A byte popcorn changes in reads of converted images (0x41B, from 0x27 to 0x55 when 0x41B-0x41E read 27 19 22 41) without knowing why. diff --git a/libretro/Makefile.common b/libretro/Makefile.common index 9b9e86a226..5d3fa5cd13 100644 --- a/libretro/Makefile.common +++ b/libretro/Makefile.common @@ -724,6 +724,7 @@ SOURCES_CXX += \ $(COREDIR)/FileLoaders/RetryingFileLoader.cpp \ $(COREDIR)/FileLoaders/RamCachingFileLoader.cpp \ $(COREDIR)/FileLoaders/LocalFileLoader.cpp \ + $(COREDIR)/FileLoaders/PS1EbootFileLoader.cpp \ $(COREDIR)/FileLoaders/ZipFileLoader.cpp \ $(COREDIR)/CoreTiming.cpp \ $(COREDIR)/CwCheat.cpp \ diff --git a/unittest/TestPS1Eboot.cpp b/unittest/TestPS1Eboot.cpp new file mode 100644 index 0000000000..6aafd86d6c --- /dev/null +++ b/unittest/TestPS1Eboot.cpp @@ -0,0 +1,296 @@ +// Copyright (c) 2012- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +// PS1EbootFileLoader: small disc images in a temp directory, read back through the EBOOT made from them. + +#include +#include +#include +#include + +#include "Common/File/DirListing.h" +#include "Common/File/FileUtil.h" +#include "Common/File/Path.h" +#include "Core/ELF/PBPReader.h" +#include "Core/ELF/ParamSFO.h" +#include "Core/FileLoaders/PS1EbootFileLoader.h" +#include "Core/Loaders.h" + +#include "UnitTest.h" + +static const u32 SECTOR = 2352; +static const u32 BLOCK = 16 * SECTOR + 5; +static const u8 SYNC[12] = { 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00 }; + +static void Put32(u8 *p, u32 v) { + p[0] = v & 0xFF; + p[1] = (v >> 8) & 0xFF; + p[2] = (v >> 16) & 0xFF; + p[3] = v >> 24; +} + +static u32 Get32(const u8 *p) { + return p[0] | (p[1] << 8) | (p[2] << 16) | ((u32)p[3] << 24); +} + +static u8 BCD(u32 v) { + return (u8)(((v / 10) << 4) | (v % 10)); +} + +// The user data of a small Mode 2 Form 1 data track: a PVD at 16, its root directory at 18 with one +// SYSTEM.CNF at 20, and a pattern everywhere else. +static std::vector MakeUserData(u32 sectors) { + std::vector data(sectors * 2048); + for (size_t i = 0; i < data.size(); i++) { + data[i] = (u8)(i * 7 + i / 2048); + } + u8 *pvd = &data[16 * 2048]; + memset(pvd, 0, 2048); + memcpy(pvd, "\x01" "CD001", 6); + Put32(pvd + 156 + 2, 18); + Put32(pvd + 156 + 10, 2048); + u8 *dir = &data[18 * 2048]; + memset(dir, 0, 2048); + const char name[] = "SYSTEM.CNF;1"; + dir[0] = (u8)(33 + sizeof(name) - 1 + 1); + Put32(dir + 2, 20); + const std::string cnf = "BOOT = cdrom:\\SLUS_123.45;1\r\nTCB = 4\r\n"; + Put32(dir + 10, (u32)cnf.size()); + dir[32] = (u8)(sizeof(name) - 1); + memcpy(dir + 33, name, sizeof(name) - 1); + memset(&data[20 * 2048], 0, 2048); + memcpy(&data[20 * 2048], cnf.data(), cnf.size()); + return data; +} + +// Raw sectors around user data, as a .bin has them. The EDC and ECC are a pattern, which nothing checks. +static std::vector MakeRawTrack(const std::vector &user) { + const u32 sectors = (u32)(user.size() / 2048); + std::vector raw(sectors * SECTOR); + for (u32 s = 0; s < sectors; s++) { + u8 *p = &raw[s * SECTOR]; + memcpy(p, SYNC, 12); + const u32 f = s + 150; + p[12] = BCD(f / 4500); + p[13] = BCD(f / 75 % 60); + p[14] = BCD(f % 75); + p[15] = 2; + static const u8 subheader[8] = { 0, 0, 8, 0, 0, 0, 8, 0 }; + memcpy(p + 16, subheader, 8); + memcpy(p + 24, &user[s * 2048], 2048); + for (u32 i = 24 + 2048; i < SECTOR; i++) { + p[i] = (u8)(s + i); + } + } + return raw; +} + +static std::vector MakeAudio(u32 sectors) { + std::vector audio(sectors * SECTOR); + for (size_t i = 0; i < audio.size(); i++) { + audio[i] = (u8)(i * 13 + 5); + } + return audio; +} + +static std::vector ReadAll(FileLoader *loader, s64 pos, size_t size) { + std::vector out(size); + out.resize(loader->ReadAt(pos, size, out.data())); + return out; +} + +// The PSAR's disc image as it should be: each block of 16 sectors, stored, after its DEFLATE header. +static std::vector ExpectedBlocks(const std::vector &disc) { + const u32 blocks = (u32)((disc.size() / SECTOR + 15) / 16); + std::vector out; + for (u32 b = 0; b < blocks; b++) { + static const u8 header[5] = { 0x01, 0x00, 0x93, 0xFF, 0x6C }; + out.insert(out.end(), header, header + 5); + for (u32 i = 0; i < 16 * SECTOR; i++) { + const size_t pos = (size_t)b * 16 * SECTOR + i; + out.push_back(pos < disc.size() ? disc[pos] : 0); + } + } + return out; +} + +static bool CheckEboot(PS1EbootFileLoader &eboot, const std::vector &disc, u32 numTracks) { + EXPECT_TRUE(eboot.IsValid()); + EXPECT_EQ_STR(eboot.DiscID(), std::string("SLUS12345")); + + std::string error; + EXPECT_TRUE(Identify_File(&eboot, &error) == IdentifiedFileType::PSP_PS1_PBP); + PBPReader pbp(&eboot); + EXPECT_TRUE(pbp.IsValid()); + std::vector sfoData; + EXPECT_TRUE(pbp.GetSubFile(PBP_PARAM_SFO, &sfoData)); + ParamSFOData sfo; + EXPECT_TRUE(sfo.ReadSFO(sfoData)); + EXPECT_EQ_STR(sfo.GetValueString("CATEGORY"), std::string("ME")); + EXPECT_EQ_STR(sfo.GetValueString("DISC_ID"), std::string("SLUS12345")); + + const std::vector header = ReadAll(&eboot, 0, 0x28); + EXPECT_EQ_INT(header.size(), 0x28); + const u32 psar = Get32(&header[0x24]); + const std::vector psarHeader = ReadAll(&eboot, psar, 0x1300); + EXPECT_EQ_MEM(&psarHeader[0], "PSISOIMG0000", 12); + EXPECT_EQ_MEM(&psarHeader[0x400], "_SLUS_12345", 12); + + // The TOC: the last track, the lead-out (at 00:02:00 plus the disc's length), and the tracks. + const u32 sectors = (u32)(disc.size() / SECTOR); + const u8 *toc = &psarHeader[0x800]; + EXPECT_EQ_INT(toc[0], 0x41); + EXPECT_EQ_INT(toc[2], 0xA0); + EXPECT_EQ_INT(toc[10 + 2], 0xA1); + EXPECT_EQ_INT(toc[10 + 7], BCD(numTracks)); + EXPECT_EQ_INT(toc[20 + 2], 0xA2); + EXPECT_EQ_INT(toc[20 + 8], BCD((sectors + 150) / 75 % 60)); + EXPECT_EQ_INT(toc[20 + 9], BCD((sectors + 150) % 75)); + EXPECT_EQ_INT(toc[30 + 2], 0x01); + EXPECT_EQ_INT(toc[30 + 8], 0x02); + EXPECT_EQ_INT(toc[30 + 9], 0x00); + + // The index, then the blocks: all of them at once, and reads that start and end mid-sector and + // cross blocks, as POPS's 0x200-aligned ones do. + const u32 blocks = (sectors + 15) / 16; + const std::vector index = ReadAll(&eboot, psar + 0x4000, (blocks + 1) * 32); + for (u32 b = 0; b < blocks; b++) { + EXPECT_EQ_INT(Get32(&index[b * 32]), b * BLOCK); + EXPECT_EQ_INT(index[b * 32 + 4] | (index[b * 32 + 5] << 8), BLOCK); + } + EXPECT_EQ_INT(Get32(&index[blocks * 32]), 0); + const std::vector expected = ExpectedBlocks(disc); + const s64 data = psar + 0x100000; + EXPECT_EQ_INT(eboot.FileSize(), data + expected.size()); + const std::vector all = ReadAll(&eboot, data, expected.size() + 100); + EXPECT_EQ_INT(all.size(), expected.size()); + EXPECT_TRUE(all == expected); + const size_t starts[] = { 0, 3, 0xB3, BLOCK - 1, BLOCK + 5 + SECTOR - 7, expected.size() - 0x300 }; + for (size_t start : starts) { + const size_t len = std::min((size_t)0x9400, expected.size() - start); + const std::vector part = ReadAll(&eboot, data + start, len); + EXPECT_EQ_INT(part.size(), len); + EXPECT_TRUE(memcmp(part.data(), &expected[start], len) == 0); + } + return true; +} + +bool TestPS1Eboot() { + const Path dir = UnitTestTempPath("unittest_ps1eboot"); + File::DeleteDirRecursively(dir); + EXPECT_TRUE(File::CreateFullPath(dir)); + + const std::vector user = MakeUserData(40); + const std::vector data = MakeRawTrack(user); + const std::vector audio = MakeAudio(20); + EXPECT_TRUE(File::WriteDataToFile(false, data.data(), data.size(), dir / "Game (Track 1).bin")); + EXPECT_TRUE(File::WriteDataToFile(false, audio.data(), audio.size(), dir / "Game (Track 2).bin")); + + // A data track and an audio track in two files, the audio track's pregap ripped (INDEX 00). + const std::string cue = + "FILE \"Game (Track 1).bin\" BINARY\r\n" + " TRACK 01 MODE2/2352\r\n" + " INDEX 01 00:00:00\r\n" + "FILE \"Game (Track 2).bin\" BINARY\r\n" + " TRACK 02 AUDIO\r\n" + " INDEX 00 00:00:00\r\n" + " INDEX 01 00:00:05\r\n"; + EXPECT_TRUE(File::WriteStringToFile(true, cue, dir / "Game.cue")); + std::vector disc = data; + disc.insert(disc.end(), audio.begin(), audio.end()); + { + PS1EbootFileLoader eboot(dir / "Game.cue"); + if (!CheckEboot(eboot, disc, 2)) { + return false; + } + EXPECT_EQ_STR(eboot.Title(), std::string("Game")); + // Track 2's pregap starts at LBA 40, and the track at 45. + const std::vector toc = ReadAll(&eboot, Get32(&ReadAll(&eboot, 0, 0x28)[0x24]) + 0x800 + 40, 10); + EXPECT_EQ_INT(toc[0], 0x01); + EXPECT_EQ_INT(toc[2], 0x02); + EXPECT_EQ_INT(toc[5], BCD(190 % 75)); + EXPECT_EQ_INT(toc[9], BCD(195 % 75)); + } + // The first file opened on its own: the cue sheet that names it describes it. + { + EXPECT_TRUE(FindCueSheetFor(dir / "Game (Track 1).bin") == dir / "Game.cue"); + PS1EbootFileLoader eboot(dir / "Game (Track 1).bin"); + if (!CheckEboot(eboot, disc, 2)) { + return false; + } + } + + // A PREGAP that isn't in the file: generated silence before the track. + const std::string pregapCue = + "FILE \"Game (Track 1).bin\" BINARY\r\n" + " TRACK 01 MODE2/2352\r\n" + " INDEX 01 00:00:00\r\n" + "FILE \"Game (Track 2).bin\" BINARY\r\n" + " TRACK 02 AUDIO\r\n" + " PREGAP 00:00:03\r\n" + " INDEX 01 00:00:00\r\n"; + EXPECT_TRUE(File::WriteStringToFile(true, pregapCue, dir / "Pregap.cue")); + { + std::vector gapped = data; + gapped.resize(gapped.size() + 3 * SECTOR, 0); + gapped.insert(gapped.end(), audio.begin(), audio.end()); + PS1EbootFileLoader eboot(dir / "Pregap.cue"); + if (!CheckEboot(eboot, gapped, 2)) { + return false; + } + } + + // A CloneCD image, and a bare .iso of only the user data, whose sectors are made around it. + EXPECT_TRUE(File::WriteDataToFile(false, data.data(), data.size(), dir / "Clone.img")); + EXPECT_TRUE(File::WriteStringToFile(true, "[CloneCD]\r\nVersion=3\r\n[TRACK 1]\r\nMODE=2\r\nINDEX 1=0\r\n", dir / "Clone.ccd")); + { + PS1EbootFileLoader eboot(dir / "Clone.ccd"); + if (!CheckEboot(eboot, data, 1)) { + return false; + } + } + EXPECT_TRUE(File::WriteDataToFile(false, user.data(), user.size(), dir / "Cooked.iso")); + { + std::vector cooked = data; + for (u32 s = 0; s < 40; s++) { + memset(&cooked[s * SECTOR + 24 + 2048], 0, SECTOR - 24 - 2048); + } + PS1EbootFileLoader eboot(dir / "Cooked.iso"); + if (!CheckEboot(eboot, cooked, 1)) { + return false; + } + } + + // The browser shows each disc once: by its cue sheet or .ccd, and a .bin of its own only if it + // could be a disc image. + const std::vector loose(3 * SECTOR, 1); + EXPECT_TRUE(File::WriteDataToFile(false, loose.data(), loose.size(), dir / "Loose.bin")); + EXPECT_TRUE(File::WriteDataToFile(false, loose.data(), 100, dir / "Junk.bin")); + std::vector files; + File::GetFilesInDir(dir, &files); + RemoveDescribedDiscImages(&files); + std::vector names; + for (const File::FileInfo &info : files) { + names.push_back(info.name); + } + std::sort(names.begin(), names.end()); + const std::vector expectedNames = { "Clone.ccd", "Cooked.iso", "Game.cue", "Loose.bin", "Pregap.cue" }; + EXPECT_TRUE(names == expectedNames); + + File::DeleteDirRecursively(dir); + return true; +} diff --git a/unittest/UnitTest.cpp b/unittest/UnitTest.cpp index c8637747b3..772d3d67c5 100644 --- a/unittest/UnitTest.cpp +++ b/unittest/UnitTest.cpp @@ -3551,6 +3551,7 @@ bool TestIRPassSimplify(); bool TestThreadManager(); bool TestVFS(); bool TestZipSlip(); +bool TestPS1Eboot(); bool TestLzrc(); bool TestCwCheat(); bool TestMpegCsc(); @@ -4076,6 +4077,7 @@ TestItem availableTests[] = { TEST_ITEM(Lang), TEST_ITEM(CmdLine), TEST_ITEM(ZipSlip), + TEST_ITEM(PS1Eboot), TEST_ITEM(Lzrc), TEST_ITEM(CwCheat), TEST_ITEM(MpegCsc), diff --git a/unittest/UnitTests.vcxproj b/unittest/UnitTests.vcxproj index e8efa4eb2a..098a70bac7 100644 --- a/unittest/UnitTests.vcxproj +++ b/unittest/UnitTests.vcxproj @@ -306,6 +306,7 @@ + true diff --git a/unittest/UnitTests.vcxproj.filters b/unittest/UnitTests.vcxproj.filters index 5db81b7bbb..ae2011e25a 100644 --- a/unittest/UnitTests.vcxproj.filters +++ b/unittest/UnitTests.vcxproj.filters @@ -29,6 +29,7 @@ + From 34a0090a186cd21f2f116d9117cff76e42c38ea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 23 Oct 2026 13:50:05 +0200 Subject: [PATCH 2/7] Install PS1 discs from zip and 7z archives, and show them as playable only with a firmware that runs them A zip or 7z with a PS1 disc in it (a cue sheet and the files it names, a .ccd with its .img and .sub, or a lone raw .bin or .img) is now installed like a zipped ISO. A disc of several files goes into a folder of its own, named after the cue sheet or .ccd. An ISO in the archive still takes priority, and a lone .bin or .img only counts when nothing else does. The game screen offers Play for a PS1 disc image only when the installed firmware's POPS can run it: 3.95 to 6.61, with popsman.prx. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/FileLoaders/PS1EbootFileLoader.cpp | 80 ++++++++++- Core/FileLoaders/PS1EbootFileLoader.h | 13 ++ Core/Loaders.cpp | 50 +++++++ Core/Loaders.h | 4 + Core/Util/GameManager.cpp | 170 +++++++++++++++++------- Core/Util/GameManager.h | 3 + Core/Util/PSARUnpack.cpp | 9 ++ Core/Util/PSARUnpack.h | 3 + UI/GameScreen.cpp | 3 + UI/InstallZipScreen.cpp | 4 +- unittest/TestPS1Eboot.cpp | 43 ++++++ 11 files changed, 330 insertions(+), 52 deletions(-) diff --git a/Core/FileLoaders/PS1EbootFileLoader.cpp b/Core/FileLoaders/PS1EbootFileLoader.cpp index 2702737849..08bb50c2bb 100644 --- a/Core/FileLoaders/PS1EbootFileLoader.cpp +++ b/Core/FileLoaders/PS1EbootFileLoader.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include "Common/File/DirListing.h" @@ -148,11 +149,15 @@ bool IsPS1CueOrCcd(FileLoader *fileLoader) { } std::vector CueSheetFileNames(const Path &cuePath) { - std::vector names; std::string text; if (!ReadSmallTextFile(cuePath, &text)) { - return names; + 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) { @@ -208,6 +213,77 @@ void RemoveDescribedDiscImages(std::vector *files) { }), 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 { + 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; diff --git a/Core/FileLoaders/PS1EbootFileLoader.h b/Core/FileLoaders/PS1EbootFileLoader.h index fc8ff6b614..94bbc0286f 100644 --- a/Core/FileLoaders/PS1EbootFileLoader.h +++ b/Core/FileLoaders/PS1EbootFileLoader.h @@ -17,6 +17,7 @@ #pragma once +#include #include #include #include @@ -100,6 +101,18 @@ bool IsPS1CueOrCcd(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), 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); diff --git a/Core/Loaders.cpp b/Core/Loaders.cpp index 66f873ec2c..e468bf9bf5 100644 --- a/Core/Loaders.cpp +++ b/Core/Loaders.cpp @@ -599,6 +599,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; } @@ -633,6 +664,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. @@ -659,6 +691,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; @@ -754,11 +789,23 @@ void DetectZipFileContents(zip_t *z, ZipFileInfo *info) { info->ignoreMetaFiles = false; info->totalFileSize = totalFileSize; + // A PS1 disc: a cue sheet or .ccd 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"; + // 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; @@ -780,6 +827,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/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/UI/GameScreen.cpp b/UI/GameScreen.cpp index cd9fe5cdbc..0d03707e99 100644 --- a/UI/GameScreen.cpp +++ b/UI/GameScreen.cpp @@ -243,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/unittest/TestPS1Eboot.cpp b/unittest/TestPS1Eboot.cpp index 6aafd86d6c..394131605b 100644 --- a/unittest/TestPS1Eboot.cpp +++ b/unittest/TestPS1Eboot.cpp @@ -22,6 +22,8 @@ #include #include +#include "ext/libzip/zip.h" + #include "Common/File/DirListing.h" #include "Common/File/FileUtil.h" #include "Common/File/Path.h" @@ -29,6 +31,7 @@ #include "Core/ELF/ParamSFO.h" #include "Core/FileLoaders/PS1EbootFileLoader.h" #include "Core/Loaders.h" +#include "Core/Util/GameManager.h" #include "UnitTest.h" @@ -291,6 +294,46 @@ bool TestPS1Eboot() { const std::vector expectedNames = { "Clone.ccd", "Cooked.iso", "Game.cue", "Loose.bin", "Pregap.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; } From 9312bf06d17f9204fde1b46a453acb29dc417936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 23 Oct 2026 14:01:39 +0200 Subject: [PATCH 3/7] POPS: Boot PS1 discs from CHD and Alcohol .mds/.mdf images A CD CHD is read through its track metadata: each track's frames are padded to a multiple of four, a pregap is stored only when its type starts with V, and audio is big-endian. A .chd that's a CD is a PS1 disc, PSP discs being DVDs. The FileLoader-backed core_file the PSP CHD block device used is now OpenCHDThroughLoader(), for both. An .mds gives each track's start, pregap and length, and where in the .mdf it's stored, with 96 bytes of subchannel data after each sector. Anything between tracks isn't stored. A bare .mdf goes through its .mds, which the game browser lists, and archives install with both. MODE2/2336 cue sheets, which only had their sector headers made before, are now tested too. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/FileLoaders/PS1EbootFileLoader.cpp | 272 +++++++++++++++++++++--- Core/FileLoaders/PS1EbootFileLoader.h | 32 ++- Core/FileSystems/BlockDevices.cpp | 37 ++-- Core/FileSystems/BlockDevices.h | 6 +- Core/Loaders.cpp | 17 +- UI/GameBrowser.cpp | 2 +- docs/POPS.md | 14 +- unittest/TestPS1Eboot.cpp | 73 ++++++- 8 files changed, 392 insertions(+), 61 deletions(-) diff --git a/Core/FileLoaders/PS1EbootFileLoader.cpp b/Core/FileLoaders/PS1EbootFileLoader.cpp index 08bb50c2bb..36b22bae30 100644 --- a/Core/FileLoaders/PS1EbootFileLoader.cpp +++ b/Core/FileLoaders/PS1EbootFileLoader.cpp @@ -20,12 +20,16 @@ #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 @@ -133,7 +137,7 @@ static std::string_view NextToken(std::string_view *line) { return token; } -bool IsPS1CueOrCcd(FileLoader *fileLoader) { +bool IsPS1DiscSheet(FileLoader *fileLoader) { char buf[4096]{}; const size_t size = fileLoader->ReadAt(0, sizeof(buf) - 1, buf); const std::string text(buf, size); @@ -141,6 +145,9 @@ bool IsPS1CueOrCcd(FileLoader *fileLoader) { 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; @@ -263,6 +270,11 @@ bool FindPS1DiscFiles(const std::vector &entries, const std::funct 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; } @@ -288,12 +300,20 @@ PS1EbootFileLoader::PS1EbootFileLoader(const Path &path) : path_(path) { const std::string ext = path.GetFileExtension(); std::string text; bool parsed = false; - if (ext == ".cue" || ext == ".ccd") { + if (ext == ".cue" || ext == ".ccd" || ext == ".mds") { if (!ReadSmallTextFile(path, &text)) { error_ = "Can't read " + path.GetFilename(); return; } - parsed = ext == ".cue" ? ParseCue(path, text) : ParseCcd(path, text); + 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); @@ -301,10 +321,13 @@ PS1EbootFileLoader::PS1EbootFileLoader(const Path &path) : path_(path) { 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(); @@ -356,7 +379,12 @@ PS1EbootFileLoader::PS1EbootFileLoader(const Path &path) : path_(path) { INFO_LOG(Log::Loader, "PS1 disc %s: %s, %d tracks, %d sectors", path.c_str(), discID_.c_str(), (int)tracks_.size(), numSectors_); } -PS1EbootFileLoader::~PS1EbootFileLoader() {} +PS1EbootFileLoader::~PS1EbootFileLoader() { + // It reads through files_. + if (chd_) { + chd_close(chd_); + } +} int PS1EbootFileLoader::AddFile(const Path &path) { FileLoader *loader = ConstructFileLoader(path); @@ -368,12 +396,168 @@ int PS1EbootFileLoader::AddFile(const Path &path) { return (int)files_.size() - 1; } -void PS1EbootFileLoader::AddExtent(u32 lba, u32 count, int file, s64 offset, int sectorSize) { +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 }); + 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; @@ -485,7 +669,7 @@ bool PS1EbootFileLoader::ParseCue(const Path &cuePath, const std::string &text) } AddExtent(lba, split - fileSector, file, (s64)fileSector * cueFile.sectorSize, cueFile.sectorSize); lba += split - fileSector; - AddExtent(lba, cueTrack.pregap, -1, 0, SECTOR_SIZE); + AddExtent(lba, cueTrack.pregap, GENERATED, 0, SECTOR_SIZE); lba += cueTrack.pregap; fileSector = split; @@ -591,35 +775,46 @@ bool PS1EbootFileLoader::ReadRawSectors(u32 lba, u32 count, u8 *out) { } const Extent &e = *(it - 1); const u32 run = std::min(count, e.lba + e.count - lba); - const s64 offset = e.offset + (s64)(lba - e.lba) * e.sectorSize; - if (e.file < 0) { + const u32 index = lba - e.lba; + if (e.file == GENERATED) { memset(out, 0, (size_t)run * SECTOR_SIZE); - } else if (e.sectorSize == (int)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(offset, want, out); + 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 { - // Sectors without their sync and header (2336), or only their user data (2048, which can only - // be Mode 2 Form 1). The EDC and ECC of those stay zero. + // 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); - memcpy(sector, SYNC, sizeof(SYNC)); - LBAToMSF(lba + i, sector + 12); - sector[15] = 2; - u8 *data = sector + 16; - if (e.sectorSize == 2048) { - static const u8 subheader[8] = { 0, 0, 8, 0, 0, 0, 8, 0 }; - memcpy(sector + 16, subheader, sizeof(subheader)); - data = sector + 24; - } - const s64 pos = offset + (s64)i * e.sectorSize; - if (files_[e.file]->ReadAt(pos, e.sectorSize, data) != (size_t)e.sectorSize) { - ok = false; + 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; @@ -629,6 +824,33 @@ bool PS1EbootFileLoader::ReadRawSectors(u32 lba, u32 count, u8 *out) { 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)) { diff --git a/Core/FileLoaders/PS1EbootFileLoader.h b/Core/FileLoaders/PS1EbootFileLoader.h index 94bbc0286f..df2d71b015 100644 --- a/Core/FileLoaders/PS1EbootFileLoader.h +++ b/Core/FileLoaders/PS1EbootFileLoader.h @@ -19,13 +19,14 @@ #include #include +#include #include #include #include "Common/CommonTypes.h" #include "Core/Loaders.h" -// A PS1 disc image (a cue sheet, a CloneCD .ccd, or a bare .bin/.img/.iso) presented as a converted +// 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. @@ -42,6 +43,7 @@ public: 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_; } @@ -55,21 +57,28 @@ private: u32 start; // Disc LBA of INDEX 01. u32 pregap; // Sectors before it that belong to the track (INDEX 00, or a generated PREGAP). }; - // A run of disc sectors, from a file or (file < 0) generated, all zeroes. + // 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); + 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(); @@ -82,6 +91,13 @@ private: 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_; @@ -96,8 +112,11 @@ namespace File { struct FileInfo; } -// Whether a .cue or .ccd describes a disc, by its contents. -bool IsPS1CueOrCcd(FileLoader *fileLoader); +// 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); @@ -110,7 +129,8 @@ struct ArchiveEntry { }; // 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), or else the largest .bin or .img that's a whole number of sectors. +// 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); 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/Loaders.cpp b/Core/Loaders.cpp index e468bf9bf5..69e161e884 100644 --- a/Core/Loaders.cpp +++ b/Core/Loaders.cpp @@ -109,12 +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") { + } else if (extension == ".cue" || extension == ".ccd" || extension == ".mds") { // A PS1 disc, which boots as a converted EBOOT (see PS1EbootFileLoader). - return IsPS1CueOrCcd(fileLoader) ? IdentifiedFileType::PSX_ISO : IdentifiedFileType::UNKNOWN; + 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") { @@ -789,14 +798,14 @@ void DetectZipFileContents(zip_t *z, ZipFileInfo *info) { info->ignoreMetaFiles = false; info->totalFileSize = totalFileSize; - // A PS1 disc: a cue sheet or .ccd and its files, or a lone raw image, which only counts if nothing else does. + // 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"; + const bool isPS1DiscWithCue = discExt == ".cue" || discExt == ".ccd" || discExt == ".mds"; // Priority ordering for detecting the various kinds of zip file content.s if (isPSPMemstickGame) { diff --git a/UI/GameBrowser.cpp b/UI/GameBrowser.cpp index 2735accccd..f7f1f0dc67 100644 --- a/UI/GameBrowser.cpp +++ b/UI/GameBrowser.cpp @@ -1117,7 +1117,7 @@ void GameBrowser::Refresh() { } } else if (!listingPending_) { std::vector fileInfo; - path_.GetListing(fileInfo, "iso:cso:chd:pbp:elf:prx:ppdmp:cue:ccd:bin:img:"); + 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++) { diff --git a/docs/POPS.md b/docs/POPS.md index a236dbebe4..b1ff7f0938 100644 --- a/docs/POPS.md +++ b/docs/POPS.md @@ -62,10 +62,12 @@ and the two MAC checks (+0A28, +0AB8) pass. A PS1 disc image boots as the converted EBOOT it would make (`Core/FileLoaders/PS1EbootFileLoader.cpp`), which never exists as a whole. It takes a cue sheet (with any number of files, AUDIO tracks, INDEX 00 -and PREGAP), a CloneCD `.ccd` with its `.img`, or a bare raw (2352-byte sector) `.bin`, `.img` or `.iso`, -or a 2048-byte `.iso`, around whose user data Mode 2 Form 1 sectors are made (with no EDC/ECC). A bare -image that a cue sheet next to it names, or that has a `.ccd` of the same name, is read through that. -The disc ID is the one SYSTEM.CNF boots. +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 @@ -94,7 +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 `.mds`/`.mdf`, CHD - and archived images. +- 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/unittest/TestPS1Eboot.cpp b/unittest/TestPS1Eboot.cpp index 394131605b..5d2ed9c653 100644 --- a/unittest/TestPS1Eboot.cpp +++ b/unittest/TestPS1Eboot.cpp @@ -257,6 +257,20 @@ bool TestPS1Eboot() { } } + // 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")); @@ -278,6 +292,63 @@ bool TestPS1Eboot() { } } + // 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); @@ -291,7 +362,7 @@ bool TestPS1Eboot() { names.push_back(info.name); } std::sort(names.begin(), names.end()); - const std::vector expectedNames = { "Clone.ccd", "Cooked.iso", "Game.cue", "Loose.bin", "Pregap.cue" }; + 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. From 4775d235ab2a99171b969dd5dfbe0f6296b50648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 23 Oct 2026 15:48:01 +0200 Subject: [PATCH 4/7] UI: PS1 games get a region and the PS1 icon, and list views show the ID of games without an icon DetectGameRegionFromID() only knew PSP IDs, so a PS1 one (SLUS, SCES, SLPS and so on) came out as homebrew. A PS1 disc image or EBOOT with no icon of its own showed the app icon in the game browser, and the list views left out the ID of any game drawn without a picture. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/ELF/ParamSFO.cpp | 10 ++++++++++ UI/GameBrowser.cpp | 15 +++++++++++++-- unittest/TestPS1Eboot.cpp | 7 +++++++ 3 files changed, 30 insertions(+), 2 deletions(-) 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/UI/GameBrowser.cpp b/UI/GameBrowser.cpp index f7f1f0dc67..f21ed1ea52 100644 --- a/UI/GameBrowser.cpp +++ b/UI/GameBrowser.cpp @@ -78,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); @@ -122,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; @@ -424,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 { @@ -550,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); diff --git a/unittest/TestPS1Eboot.cpp b/unittest/TestPS1Eboot.cpp index 5d2ed9c653..95d5ffa8eb 100644 --- a/unittest/TestPS1Eboot.cpp +++ b/unittest/TestPS1Eboot.cpp @@ -193,6 +193,13 @@ static bool CheckEboot(PS1EbootFileLoader &eboot, const std::vector &disc, u } 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)); From f4f68d37cc1aecabe18cd1958b1e0b4b04bcd489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 23 Oct 2026 15:50:28 +0200 Subject: [PATCH 5/7] File dialogs: Offer PS1 disc images (.cue, .ccd, .mds, .mdf, .bin, .img) Windows also gets a filter of its own for them. iOS's picker takes any file and Android's doesn't filter by extension. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/Util/DarwinFileSystemServices.mm | 2 +- SDL/SDLMain.cpp | 4 ++-- UWP/PPSSPP_UWPMain.cpp | 2 +- Windows/main.cpp | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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/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/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: From 4f1298982b02db789558801b6ca8168f5923c911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 23 Oct 2026 16:05:00 +0200 Subject: [PATCH 6/7] POPS: Warn that PS1 support is still experimental when a PS1 game starts Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/PSPLoaders.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Core/PSPLoaders.cpp b/Core/PSPLoaders.cpp index 7892fd7723..571b7acdd5 100644 --- a/Core/PSPLoaders.cpp +++ b/Core/PSPLoaders.cpp @@ -694,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; } From ad75804c31741942f391446056eaa7643bfa506e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 23 Oct 2026 16:06:10 +0200 Subject: [PATCH 7/7] Translations: PS1 support is still experimental Co-Authored-By: Claude Opus 5.5 (1M context) --- assets/lang/ar_AE.ini | 1 + assets/lang/az_AZ.ini | 1 + assets/lang/be_BY.ini | 1 + assets/lang/bg_BG.ini | 1 + assets/lang/ca_ES.ini | 1 + assets/lang/cz_CZ.ini | 1 + assets/lang/da_DK.ini | 1 + assets/lang/de_DE.ini | 1 + assets/lang/dr_ID.ini | 1 + assets/lang/en_US.ini | 1 + assets/lang/es_ES.ini | 1 + assets/lang/es_LA.ini | 1 + assets/lang/fa_IR.ini | 1 + assets/lang/fi_FI.ini | 1 + assets/lang/fr_FR.ini | 1 + assets/lang/gl_ES.ini | 1 + assets/lang/gr_EL.ini | 1 + assets/lang/he_IL.ini | 1 + assets/lang/hr_HR.ini | 1 + assets/lang/hu_HU.ini | 1 + assets/lang/id_ID.ini | 1 + assets/lang/it_IT.ini | 1 + assets/lang/ja_JP.ini | 1 + assets/lang/jv_ID.ini | 1 + assets/lang/km_KH.ini | 1 + assets/lang/ko_KR.ini | 1 + assets/lang/ku_SO.ini | 1 + assets/lang/lo_LA.ini | 1 + assets/lang/lt-LT.ini | 1 + assets/lang/ms_MY.ini | 1 + assets/lang/nl_NL.ini | 1 + assets/lang/nn_NO.ini | 1 + assets/lang/no_NO.ini | 1 + assets/lang/pl_PL.ini | 1 + assets/lang/pt_BR.ini | 1 + assets/lang/pt_PT.ini | 1 + assets/lang/ro_RO.ini | 1 + assets/lang/ru_RU.ini | 1 + assets/lang/sv_SE.ini | 1 + assets/lang/tg_PH.ini | 1 + assets/lang/th_TH.ini | 1 + assets/lang/tr_TR.ini | 1 + assets/lang/uk_UA.ini | 1 + assets/lang/vi_VN.ini | 1 + assets/lang/zh_CN.ini | 1 + assets/lang/zh_TW.ini | 1 + 46 files changed, 46 insertions(+) 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 設定