mirror of
https://github.com/hrydgard/ppsspp.git
synced 2026-10-31 19:47:06 +01:00
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) <noreply@anthropic.com>
297 lines
11 KiB
C++
297 lines
11 KiB
C++
// 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 <algorithm>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#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<u8> MakeUserData(u32 sectors) {
|
|
std::vector<u8> 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<u8> MakeRawTrack(const std::vector<u8> &user) {
|
|
const u32 sectors = (u32)(user.size() / 2048);
|
|
std::vector<u8> 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<u8> MakeAudio(u32 sectors) {
|
|
std::vector<u8> audio(sectors * SECTOR);
|
|
for (size_t i = 0; i < audio.size(); i++) {
|
|
audio[i] = (u8)(i * 13 + 5);
|
|
}
|
|
return audio;
|
|
}
|
|
|
|
static std::vector<u8> ReadAll(FileLoader *loader, s64 pos, size_t size) {
|
|
std::vector<u8> 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<u8> ExpectedBlocks(const std::vector<u8> &disc) {
|
|
const u32 blocks = (u32)((disc.size() / SECTOR + 15) / 16);
|
|
std::vector<u8> 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<u8> &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<u8> 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<u8> header = ReadAll(&eboot, 0, 0x28);
|
|
EXPECT_EQ_INT(header.size(), 0x28);
|
|
const u32 psar = Get32(&header[0x24]);
|
|
const std::vector<u8> 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<u8> 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<u8> expected = ExpectedBlocks(disc);
|
|
const s64 data = psar + 0x100000;
|
|
EXPECT_EQ_INT(eboot.FileSize(), data + expected.size());
|
|
const std::vector<u8> 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<u8> 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<u8> user = MakeUserData(40);
|
|
const std::vector<u8> data = MakeRawTrack(user);
|
|
const std::vector<u8> 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<u8> 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<u8> 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<u8> 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<u8> 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<u8> 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<File::FileInfo> files;
|
|
File::GetFilesInDir(dir, &files);
|
|
RemoveDescribedDiscImages(&files);
|
|
std::vector<std::string> names;
|
|
for (const File::FileInfo &info : files) {
|
|
names.push_back(info.name);
|
|
}
|
|
std::sort(names.begin(), names.end());
|
|
const std::vector<std::string> expectedNames = { "Clone.ccd", "Cooked.iso", "Game.cue", "Loose.bin", "Pregap.cue" };
|
|
EXPECT_TRUE(names == expectedNames);
|
|
|
|
File::DeleteDirRecursively(dir);
|
|
return true;
|
|
}
|