mirror of
https://github.com/hrydgard/ppsspp.git
synced 2026-10-20 17:59:08 +02:00
"Compress game" in the game screen's ... menu, for ISO, CSO, CHD and PSN EBOOT.PBP games. It opens a list of the CSO presets (and Extract to ISO for anything that's not an ISO already): - Best: libdeflate level 12, 64K blocks - PSP Compatible: zlib level 9, 2K blocks, readable by custom firmware These match maxcso's presets, and the CSOs come out byte-identical to its. The writer, Core/FileSystems/CSOCompress, reads the input through PPSSPP's own block devices, so recompressing is a single pass with no temporary file. The original is deleted when done, unless "Keep the original file" is checked (off by default). When the new name is taken, by a kept original or another file, a number is added to it. The work runs on a thread using every core, with a progress bar and time left at the bottom of the game screen. It keeps going when the screen is left, and the ... menu offers Cancel meanwhile. On the game list the game's icon is covered by "Compressing...", and clicking it opens the game screen instead of playing. When done, recents follow the new file and the game screen and lists refresh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CCxekYqPnzNr5d8Tp1q7aZ
479 lines
15 KiB
C++
479 lines
15 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/.
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <memory>
|
|
#include <thread>
|
|
|
|
#include "Common/Data/Color/RGBAUtil.h"
|
|
#include "Common/Data/Text/I18n.h"
|
|
#include "Common/Data/Text/Parsers.h"
|
|
#include "Common/File/FileUtil.h"
|
|
#include "Common/Log.h"
|
|
#include "Common/StringUtils.h"
|
|
#include "Common/System/OSD.h"
|
|
#include "Common/Thread/ThreadUtil.h"
|
|
#include "Common/TimeUtil.h"
|
|
#include "Common/UI/Context.h"
|
|
|
|
#include "Core/Config.h"
|
|
#include "Core/System.h"
|
|
#include "Core/Util/RecentFiles.h"
|
|
|
|
#include "UI/BackgroundAudio.h"
|
|
#include "UI/GameInfoCache.h"
|
|
#include "UI/GamePacker.h"
|
|
|
|
namespace GamePacker {
|
|
namespace {
|
|
|
|
struct Job {
|
|
Path gamePath; // As the game list knows it, maybe a PSP/GAME/<folder>.
|
|
Path file; // The image itself.
|
|
Path finalOutput; // Where the converted game ends up.
|
|
Path writeOutput; // What gets written: finalOutput, or a temporary name when that's the original's.
|
|
bool replace = false; // Delete the original when done.
|
|
bool toISO = false;
|
|
CSOCompress::Preset preset = CSOCompress::Preset::Best;
|
|
|
|
std::atomic<float> progress{0.0f};
|
|
double startTime = 0.0;
|
|
std::atomic<bool> cancel{false};
|
|
std::atomic<bool> done{false};
|
|
bool success = false; // Set before done.
|
|
bool cancelled = false; // Set before done.
|
|
std::string error; // Set before done.
|
|
|
|
std::thread thread;
|
|
};
|
|
|
|
// Only touched on the UI thread. The worker gets the raw pointer, and the job lives until it's joined.
|
|
std::unique_ptr<Job> g_job;
|
|
int g_generation = 0;
|
|
Result g_lastResult;
|
|
|
|
void RunJob(Job *job) {
|
|
SetCurrentThreadName("GamePacker");
|
|
INFO_LOG(Log::Loader, "GamePacker: %s %s -> %s", job->toISO ? "Extracting" : "Compressing", job->file.c_str(), job->writeOutput.c_str());
|
|
|
|
auto progress = [job](float p) {
|
|
job->progress = p;
|
|
return !job->cancel;
|
|
};
|
|
std::string error;
|
|
bool cancelled = false;
|
|
bool success;
|
|
if (job->toISO) {
|
|
success = CSOCompress::Extract(job->file, job->writeOutput, progress, &error, &cancelled);
|
|
} else {
|
|
success = CSOCompress::Compress(job->file, job->writeOutput, job->preset, progress, &error, &cancelled);
|
|
}
|
|
if (!success && !cancelled) {
|
|
ERROR_LOG(Log::Loader, "GamePacker: Failed on %s: %s", job->file.c_str(), error.c_str());
|
|
}
|
|
job->success = success;
|
|
job->cancelled = cancelled;
|
|
job->error = error;
|
|
job->done = true;
|
|
}
|
|
|
|
// "<name>" for "<name>.<ext>", or for an EBOOT.PBP the game's disc id.
|
|
std::string OutputBaseName(const Path &gamePath, const Path &file) {
|
|
std::string name = file.GetFilename();
|
|
const std::string ext = file.GetFileExtension();
|
|
if (ext.size() <= name.size()) {
|
|
name.resize(name.size() - ext.size());
|
|
}
|
|
if (equalsNoCase(file.GetFilename(), "EBOOT.PBP")) {
|
|
std::shared_ptr<GameInfo> info = g_gameInfoCache->GetInfo(nullptr, gamePath, GameInfoFlags::PARAM_SFO);
|
|
if (info->Ready(GameInfoFlags::PARAM_SFO)) {
|
|
const std::string id = SanitizeString(info->id, StringRestriction::FileName);
|
|
if (!id.empty()) {
|
|
return id;
|
|
}
|
|
}
|
|
}
|
|
return name;
|
|
}
|
|
|
|
// Where an option puts its result, before looking at what's already there.
|
|
Path FinalOutput(const Path &gamePath, const Path &file, Container from, const Option &option) {
|
|
const Container to = option.toISO ? Container::ISO : Container::CSO;
|
|
if (to == from) {
|
|
return file;
|
|
}
|
|
return file.NavigateUp() / (OutputBaseName(gamePath, file) + (option.toISO ? ".iso" : ".cso"));
|
|
}
|
|
|
|
// "<name> (2).<ext>", "<name> (3).<ext>"... for a name that's taken.
|
|
Path NumberedName(const Path &path) {
|
|
const std::string ext = path.GetFileExtension();
|
|
std::string name = path.GetFilename();
|
|
name.resize(name.size() - ext.size());
|
|
for (int i = 2; i < 1000; i++) {
|
|
Path candidate = path.NavigateUp() / StringFromFormat("%s (%d)%s", name.c_str(), i, ext.c_str());
|
|
if (!File::Exists(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return Path();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
Container DetectContainer(const Path &gamePath, Path *file) {
|
|
// Content URIs are fine too (Android's storage), only remote games aren't.
|
|
if (gamePath.Type() == PathType::HTTP) {
|
|
return Container::None;
|
|
}
|
|
Path image = gamePath;
|
|
if (File::IsDirectory(gamePath)) {
|
|
image = gamePath / "EBOOT.PBP";
|
|
if (!File::Exists(image)) {
|
|
return Container::None;
|
|
}
|
|
}
|
|
const std::string ext = image.GetFileExtension();
|
|
Container container = Container::None;
|
|
if (ext == ".iso") {
|
|
container = Container::ISO;
|
|
} else if (ext == ".cso") {
|
|
container = Container::CSO;
|
|
} else if (ext == ".chd") {
|
|
container = Container::CHD;
|
|
} else if (ext == ".pbp") {
|
|
container = Container::PBP;
|
|
}
|
|
if (container != Container::None && file) {
|
|
*file = image;
|
|
}
|
|
return container;
|
|
}
|
|
|
|
std::vector<Option> ListOptions(Container from) {
|
|
auto ga = GetI18NCategory(I18NCat::GAME);
|
|
|
|
std::vector<Option> options;
|
|
if (from == Container::None) {
|
|
return options;
|
|
}
|
|
if (from != Container::ISO) {
|
|
Option option;
|
|
option.toISO = true;
|
|
option.title = std::string(ga->T("Extract to ISO"));
|
|
option.description = std::string(ga->T("PackDescISO", "Uncompressed disc image"));
|
|
options.push_back(option);
|
|
}
|
|
auto add = [&](CSOCompress::Preset preset, std::string_view name, std::string_view description) {
|
|
Option option;
|
|
option.preset = preset;
|
|
option.title = "CSO - " + std::string(name);
|
|
option.description = description;
|
|
options.push_back(option);
|
|
};
|
|
add(CSOCompress::Preset::Best, ga->T("Best"), ga->T("PackDescCSOBest", "libdeflate, 64K blocks. Small and fast."));
|
|
add(CSOCompress::Preset::PSPCompatible, ga->T("PSP Compatible"), ga->T("PackDescCSOPSP", "zlib, 2K blocks. Readable by PSP custom firmware."));
|
|
return options;
|
|
}
|
|
|
|
bool CanStart() {
|
|
return !IsBusy() && !PSP_IsInited();
|
|
}
|
|
|
|
bool Start(const Path &gamePath, const Option &option, bool keepOriginal) {
|
|
auto ga = GetI18NCategory(I18NCat::GAME);
|
|
if (!CanStart()) {
|
|
return false;
|
|
}
|
|
|
|
auto job = std::make_unique<Job>();
|
|
job->gamePath = gamePath;
|
|
const Container from = DetectContainer(gamePath, &job->file);
|
|
if (from == Container::None || (option.toISO && from == Container::ISO)) {
|
|
return false;
|
|
}
|
|
|
|
job->toISO = option.toISO;
|
|
job->preset = option.preset;
|
|
job->replace = !keepOriginal;
|
|
job->finalOutput = FinalOutput(gamePath, job->file, from, option);
|
|
if (job->finalOutput == job->file && job->replace) {
|
|
// Recompressing a CSO in place: write beside it, and swap it in when done.
|
|
const std::string ext = job->file.GetFileExtension();
|
|
std::string name = job->file.GetFilename();
|
|
name.resize(name.size() - ext.size());
|
|
job->writeOutput = job->file.NavigateUp() / (name + ".ppsspp-tmp" + ext);
|
|
// Our own leftover, from a crash or a kill. The compressor never overwrites.
|
|
if (File::Exists(job->writeOutput)) {
|
|
File::Delete(job->writeOutput);
|
|
}
|
|
} else {
|
|
// The kept original's name, or another file that's in the way.
|
|
if (job->finalOutput == job->file || File::Exists(job->finalOutput)) {
|
|
const Path taken = job->finalOutput;
|
|
job->finalOutput = NumberedName(taken);
|
|
if (job->finalOutput.empty()) {
|
|
g_OSD.Show(OSDType::MESSAGE_ERROR, std::string(ga->T("PackOutputExists", "Output file already exists")) + ": " + taken.GetFilename(), 5.0f);
|
|
return false;
|
|
}
|
|
}
|
|
job->writeOutput = job->finalOutput;
|
|
}
|
|
|
|
INFO_LOG(Log::Loader, "GamePacker: %s -> %s%s", job->file.c_str(), job->finalOutput.c_str(), job->replace ? " (replacing it)" : "");
|
|
Job *raw = job.get();
|
|
g_job = std::move(job);
|
|
g_job->startTime = time_now_d();
|
|
g_job->thread = std::thread(RunJob, raw);
|
|
return true;
|
|
}
|
|
|
|
bool IsBusy() {
|
|
return g_job != nullptr;
|
|
}
|
|
|
|
bool IsPacking(const Path &gamePath) {
|
|
return g_job && g_job->gamePath == gamePath;
|
|
}
|
|
|
|
void Abort() {
|
|
if (g_job) {
|
|
g_job->cancel = true;
|
|
}
|
|
}
|
|
|
|
bool GetStatus(Status *status) {
|
|
if (!g_job) {
|
|
return false;
|
|
}
|
|
auto ga = GetI18NCategory(I18NCat::GAME);
|
|
const Job &job = *g_job;
|
|
|
|
status->gamePath = job.gamePath;
|
|
status->label = std::string(job.toISO ? ga->T("Extracting") : ga->T("Compressing")) + " " + job.file.GetFilename();
|
|
status->progress = job.done ? 1.0f : job.progress.load();
|
|
status->aborting = job.cancel;
|
|
|
|
const double elapsed = time_now_d() - job.startTime;
|
|
status->etaSeconds = -1.0;
|
|
// The first moments say little.
|
|
if (status->progress > 0.01f && elapsed > 2.0) {
|
|
status->etaSeconds = elapsed * (1.0 - status->progress) / status->progress;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
int Generation() {
|
|
return g_generation;
|
|
}
|
|
|
|
Result LastResult() {
|
|
return g_lastResult;
|
|
}
|
|
|
|
void UpdateOnUIThread() {
|
|
if (!g_job || !g_job->done) {
|
|
return;
|
|
}
|
|
g_job->thread.join();
|
|
std::unique_ptr<Job> job = std::move(g_job);
|
|
|
|
auto ga = GetI18NCategory(I18NCat::GAME);
|
|
auto sy = GetI18NCategory(I18NCat::SYSTEM);
|
|
|
|
Result result;
|
|
result.oldPath = job->gamePath;
|
|
result.newPath = job->gamePath;
|
|
result.success = job->success;
|
|
|
|
if (job->success) {
|
|
const uint64_t before = File::GetFileSize(job->file);
|
|
const uint64_t after = File::GetFileSize(job->writeOutput);
|
|
std::string problem;
|
|
Path written = job->finalOutput; // What the new file is called in the end.
|
|
|
|
if (job->replace) {
|
|
// Let go of everything that might hold the original open, or Windows won't delete it.
|
|
g_BackgroundAudio.SetGame(Path());
|
|
g_gameInfoCache->Clear();
|
|
|
|
if (File::Delete(job->file)) {
|
|
if (job->writeOutput != job->finalOutput && !File::Rename(job->writeOutput, job->finalOutput)) {
|
|
// The original is gone, so keep the converted game under its temporary name.
|
|
problem = std::string(ga->T("PackRenameFailed", "Could not rename the new file"));
|
|
result.newPath = job->writeOutput;
|
|
written = job->writeOutput;
|
|
} else {
|
|
result.newPath = job->finalOutput;
|
|
}
|
|
} else if (job->writeOutput != job->finalOutput) {
|
|
// Same name as the original, which is still there.
|
|
File::Delete(job->writeOutput);
|
|
problem = std::string(ga->T("PackReplaceFailed", "Could not replace the original"));
|
|
result.success = false;
|
|
} else {
|
|
problem = std::string(ga->T("PackDeleteFailed", "Could not delete the original"));
|
|
result.newPath = job->finalOutput;
|
|
}
|
|
|
|
if (result.newPath != result.oldPath && g_recentFiles.ContainsFile(result.oldPath.ToString())) {
|
|
g_recentFiles.Remove(result.oldPath.ToString());
|
|
g_recentFiles.Add(result.newPath.ToString());
|
|
}
|
|
}
|
|
|
|
if (result.success) {
|
|
const std::string text = std::string(sy->T("Done")) + ": " + written.GetFilename();
|
|
std::string sizes = NiceSizeFormat(before) + " -> " + NiceSizeFormat(after);
|
|
if (before > 0) {
|
|
sizes += StringFromFormat(" (%.2f%%)", 100.0 * (double)after / (double)before);
|
|
}
|
|
if (!problem.empty()) {
|
|
sizes += "\n" + problem;
|
|
}
|
|
g_OSD.Show(problem.empty() ? OSDType::MESSAGE_SUCCESS : OSDType::MESSAGE_WARNING, text, sizes, 6.0f);
|
|
} else {
|
|
g_OSD.Show(OSDType::MESSAGE_ERROR, problem, 6.0f);
|
|
}
|
|
} else if (job->cancelled) {
|
|
g_OSD.Show(OSDType::MESSAGE_INFO, ga->T("Compression cancelled"), 3.0f);
|
|
} else {
|
|
g_OSD.Show(OSDType::MESSAGE_ERROR, std::string(sy->T("Failed")) + ": " + job->file.GetFilename(), job->error, 8.0f);
|
|
}
|
|
|
|
g_lastResult = result;
|
|
g_generation++;
|
|
}
|
|
|
|
void Shutdown() {
|
|
if (!g_job) {
|
|
return;
|
|
}
|
|
g_job->cancel = true;
|
|
if (g_job->thread.joinable()) {
|
|
g_job->thread.join();
|
|
}
|
|
g_job.reset();
|
|
}
|
|
|
|
} // namespace GamePacker
|
|
|
|
namespace {
|
|
|
|
// A preset in the list: a radio mark, the name, and a line about it.
|
|
class PackOptionView : public UI::ClickableItem {
|
|
public:
|
|
PackOptionView(std::string_view title, std::string_view description, UI::LayoutParams *layoutParams = nullptr)
|
|
: UI::ClickableItem(layoutParams), title_(title), description_(description) {}
|
|
|
|
void GetContentDimensions(const UIContext &dc, float &w, float &h) const override {
|
|
w = 300.0f;
|
|
h = 70.0f;
|
|
}
|
|
|
|
void Draw(UIContext &dc) override {
|
|
UI::Style style = dc.GetTheme().itemStyle;
|
|
if (HasFocus()) style = dc.GetTheme().itemFocusedStyle;
|
|
if (down_) style = dc.GetTheme().itemDownStyle;
|
|
if (!IsEnabled()) style = dc.GetTheme().itemDisabledStyle;
|
|
DrawBG(dc, style);
|
|
|
|
const float iconX = bounds_.x + 30.0f;
|
|
dc.Draw()->DrawImage(ImageID(checked_ ? "I_RADIO_SELECTED" : "I_RADIO_EMPTY"), iconX, bounds_.centerY(), 1.0f, style.fgColor, ALIGN_CENTER);
|
|
|
|
const float textX = bounds_.x + 60.0f;
|
|
const float textW = bounds_.w - 70.0f;
|
|
dc.SetFontStyle(dc.GetTheme().uiFont);
|
|
dc.DrawTextRectSqueeze(title_, Bounds(textX, bounds_.y + 6.0f, textW, 32.0f), style.fgColor, ALIGN_VCENTER);
|
|
dc.SetFontScale(0.7f, 0.7f);
|
|
dc.DrawTextRectSqueeze(description_, Bounds(textX, bounds_.y + 36.0f, textW, 28.0f), alphaMul(style.fgColor, 0.75f), ALIGN_VCENTER);
|
|
dc.SetFontScale(1.0f, 1.0f);
|
|
}
|
|
|
|
std::string DescribeText() const override {
|
|
return title_ + ", " + description_;
|
|
}
|
|
|
|
void SetChecked(bool checked) { checked_ = checked; }
|
|
|
|
private:
|
|
std::string title_;
|
|
std::string description_;
|
|
bool checked_ = false;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
GamePackPopupScreen::GamePackPopupScreen(const Path &gamePath, GamePacker::Container from)
|
|
: UI::PopupScreen(GetI18NCategory(I18NCat::GAME)->T("Compress game"), GetI18NCategory(I18NCat::DIALOG)->T("Start"), GetI18NCategory(I18NCat::DIALOG)->T("Cancel")), gamePath_(gamePath) {
|
|
options_ = GamePacker::ListOptions(from);
|
|
|
|
// Start from the preset used last. Extracting is listed first.
|
|
if (from == GamePacker::Container::ISO) {
|
|
for (size_t i = 0; i < options_.size(); i++) {
|
|
if (!options_[i].toISO && (int)options_[i].preset == g_Config.iCSOPreset) {
|
|
selected_ = (int)i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
keepOriginal_ = g_Config.bPackKeepOriginal;
|
|
}
|
|
|
|
void GamePackPopupScreen::CreatePopupContents(UI::ViewGroup *parent) {
|
|
using namespace UI;
|
|
|
|
auto ga = GetI18NCategory(I18NCat::GAME);
|
|
|
|
choices_.clear();
|
|
LinearLayout *list = parent->Add(new LinearLayout(ORIENT_VERTICAL, new LinearLayoutParams(FILL_PARENT, WRAP_CONTENT)));
|
|
list->SetSpacing(0.0f);
|
|
for (size_t i = 0; i < options_.size(); i++) {
|
|
PackOptionView *view = list->Add(new PackOptionView(options_[i].title, options_[i].description));
|
|
view->OnClick.Add([this, i](UI::EventParams &) {
|
|
Select((int)i);
|
|
});
|
|
choices_.push_back(view);
|
|
}
|
|
|
|
parent->Add(new CheckBox(&keepOriginal_, ga->T("Keep the original file")));
|
|
|
|
if (!options_.empty()) {
|
|
Select(std::clamp(selected_, 0, (int)options_.size() - 1));
|
|
}
|
|
}
|
|
|
|
void GamePackPopupScreen::Select(int index) {
|
|
selected_ = index;
|
|
for (size_t i = 0; i < choices_.size(); i++) {
|
|
static_cast<PackOptionView *>(choices_[i])->SetChecked((int)i == index);
|
|
}
|
|
}
|
|
|
|
void GamePackPopupScreen::OnCompleted(DialogResult result) {
|
|
if (result != DR_OK || selected_ < 0 || selected_ >= (int)options_.size()) {
|
|
return;
|
|
}
|
|
const GamePacker::Option &option = options_[selected_];
|
|
if (!option.toISO) {
|
|
g_Config.iCSOPreset = (int)option.preset;
|
|
}
|
|
g_Config.bPackKeepOriginal = keepOriginal_;
|
|
GamePacker::Start(gamePath_, option, keepOriginal_);
|
|
}
|