1 // Copyright 2020 Dolphin Emulator Project
2 // Licensed under GPLv2+
3 // Refer to the license.txt file included.
4
5 #include "DolphinQt/ConvertDialog.h"
6
7 #include <algorithm>
8 #include <functional>
9 #include <future>
10 #include <memory>
11 #include <utility>
12
13 #include <QCheckBox>
14 #include <QComboBox>
15 #include <QFileDialog>
16 #include <QGridLayout>
17 #include <QGroupBox>
18 #include <QLabel>
19 #include <QList>
20 #include <QMessageBox>
21 #include <QPushButton>
22 #include <QString>
23 #include <QVBoxLayout>
24
25 #include "Common/Assert.h"
26 #include "Common/Logging/Log.h"
27 #include "DiscIO/Blob.h"
28 #include "DiscIO/ScrubbedBlob.h"
29 #include "DiscIO/WIABlob.h"
30 #include "DolphinQt/QtUtils/ModalMessageBox.h"
31 #include "DolphinQt/QtUtils/ParallelProgressDialog.h"
32 #include "UICommon/GameFile.h"
33 #include "UICommon/UICommon.h"
34
ConvertDialog(QList<std::shared_ptr<const UICommon::GameFile>> files,QWidget * parent)35 ConvertDialog::ConvertDialog(QList<std::shared_ptr<const UICommon::GameFile>> files,
36 QWidget* parent)
37 : QDialog(parent), m_files(std::move(files))
38 {
39 ASSERT(!m_files.empty());
40
41 setWindowTitle(tr("Convert"));
42 setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
43
44 QGridLayout* grid_layout = new QGridLayout;
45 grid_layout->setColumnStretch(1, 1);
46
47 m_format = new QComboBox;
48 m_format->addItem(QStringLiteral("ISO"), static_cast<int>(DiscIO::BlobType::PLAIN));
49 m_format->addItem(QStringLiteral("GCZ"), static_cast<int>(DiscIO::BlobType::GCZ));
50 m_format->addItem(QStringLiteral("WIA"), static_cast<int>(DiscIO::BlobType::WIA));
51 m_format->addItem(QStringLiteral("RVZ"), static_cast<int>(DiscIO::BlobType::RVZ));
52 if (std::all_of(m_files.begin(), m_files.end(),
53 [](const auto& file) { return file->GetBlobType() == DiscIO::BlobType::PLAIN; }))
54 {
55 m_format->setCurrentIndex(m_format->count() - 1);
56 }
57 grid_layout->addWidget(new QLabel(tr("Format:")), 0, 0);
58 grid_layout->addWidget(m_format, 0, 1);
59
60 m_block_size = new QComboBox;
61 grid_layout->addWidget(new QLabel(tr("Block Size:")), 1, 0);
62 grid_layout->addWidget(m_block_size, 1, 1);
63
64 m_compression = new QComboBox;
65 grid_layout->addWidget(new QLabel(tr("Compression:")), 2, 0);
66 grid_layout->addWidget(m_compression, 2, 1);
67
68 m_compression_level = new QComboBox;
69 grid_layout->addWidget(new QLabel(tr("Compression Level:")), 3, 0);
70 grid_layout->addWidget(m_compression_level, 3, 1);
71
72 m_scrub = new QCheckBox;
73 grid_layout->addWidget(new QLabel(tr("Remove Junk Data (Irreversible):")), 4, 0);
74 grid_layout->addWidget(m_scrub, 4, 1);
75
76 QPushButton* convert_button = new QPushButton(tr("Convert..."));
77
78 QVBoxLayout* options_layout = new QVBoxLayout;
79 options_layout->addLayout(grid_layout);
80 options_layout->addWidget(convert_button);
81 QGroupBox* options_group = new QGroupBox(tr("Options"));
82 options_group->setLayout(options_layout);
83
84 QLabel* info_text = new QLabel(
85 tr("ISO: A simple and robust format which is supported by many programs. It takes up more "
86 "space than any other format.\n\n"
87 "GCZ: A basic compressed format which is compatible with most versions of Dolphin and "
88 "some other programs. It can't efficiently compress junk data (unless removed) or "
89 "encrypted Wii data.\n\n"
90 "WIA: An advanced compressed format which is compatible with Dolphin 5.0-12188 and later, "
91 "and a few other programs. It can efficiently compress encrypted Wii data, but not junk "
92 "data (unless removed).\n\n"
93 "RVZ: An advanced compressed format which is compatible with Dolphin 5.0-12188 and later. "
94 "It can efficiently compress both junk data and encrypted Wii data."));
95 info_text->setWordWrap(true);
96
97 QVBoxLayout* info_layout = new QVBoxLayout;
98 info_layout->addWidget(info_text);
99 QGroupBox* info_group = new QGroupBox(tr("Info"));
100 info_group->setLayout(info_layout);
101
102 QVBoxLayout* main_layout = new QVBoxLayout;
103 main_layout->addWidget(options_group);
104 main_layout->addWidget(info_group);
105
106 setLayout(main_layout);
107
108 connect(m_format, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
109 &ConvertDialog::OnFormatChanged);
110 connect(m_compression, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
111 &ConvertDialog::OnCompressionChanged);
112 connect(convert_button, &QPushButton::clicked, this, &ConvertDialog::Convert);
113
114 OnFormatChanged();
115 OnCompressionChanged();
116 }
117
AddToBlockSizeComboBox(int size)118 void ConvertDialog::AddToBlockSizeComboBox(int size)
119 {
120 m_block_size->addItem(QString::fromStdString(UICommon::FormatSize(size, 0)), size);
121
122 // Select 128 KiB by default, or if it is not available, the size closest to it.
123 // This code assumes that sizes get added to the combo box in increasing order.
124 constexpr int DEFAULT_SIZE = 0x20000;
125 if (size <= DEFAULT_SIZE)
126 m_block_size->setCurrentIndex(m_block_size->count() - 1);
127 }
128
AddToCompressionComboBox(const QString & name,DiscIO::WIARVZCompressionType type)129 void ConvertDialog::AddToCompressionComboBox(const QString& name,
130 DiscIO::WIARVZCompressionType type)
131 {
132 m_compression->addItem(name, static_cast<int>(type));
133 }
134
AddToCompressionLevelComboBox(int level)135 void ConvertDialog::AddToCompressionLevelComboBox(int level)
136 {
137 m_compression_level->addItem(QString::number(level), level);
138 }
139
OnFormatChanged()140 void ConvertDialog::OnFormatChanged()
141 {
142 // Because DVD timings are emulated as if we can't read less than an entire ECC block at once
143 // (32 KiB - 0x8000), there is little reason to use a block size smaller than that.
144 constexpr int MIN_BLOCK_SIZE = 0x8000;
145
146 // For performance reasons, blocks shouldn't be too large.
147 // 2 MiB (0x200000) was picked because it is the smallest block size supported by WIA.
148 constexpr int MAX_BLOCK_SIZE = 0x200000;
149
150 const DiscIO::BlobType format = static_cast<DiscIO::BlobType>(m_format->currentData().toInt());
151
152 m_block_size->clear();
153 m_compression->clear();
154
155 // Populate m_block_size
156 switch (format)
157 {
158 case DiscIO::BlobType::GCZ:
159 {
160 // In order for versions of Dolphin prior to 5.0-11893 to be able to convert a GCZ file
161 // to ISO without messing up the final part of the file in some way, the file size
162 // must be an integer multiple of the block size (fixed in 3aa463c) and must not be
163 // an integer multiple of the block size multiplied by 32 (fixed in 26b21e3).
164
165 const auto block_size_ok = [this](int block_size) {
166 return std::all_of(m_files.begin(), m_files.end(), [block_size](const auto& file) {
167 constexpr u64 BLOCKS_PER_BUFFER = 32;
168 const u64 file_size = file->GetVolumeSize();
169 return file_size % block_size == 0 && file_size % (block_size * BLOCKS_PER_BUFFER) != 0;
170 });
171 };
172
173 // Add all block sizes in the normal range that do not cause problems
174 for (int block_size = MIN_BLOCK_SIZE; block_size <= MAX_BLOCK_SIZE; block_size *= 2)
175 {
176 if (block_size_ok(block_size))
177 AddToBlockSizeComboBox(block_size);
178 }
179
180 // If we didn't find a good block size, pick the block size which was hardcoded
181 // in older versions of Dolphin. That way, at least we're not worse than older versions.
182 if (m_block_size->count() == 0)
183 {
184 constexpr int FALLBACK_BLOCK_SIZE = 0x4000;
185 if (!block_size_ok(FALLBACK_BLOCK_SIZE))
186 {
187 ERROR_LOG(MASTER_LOG, "Failed to find a block size which does not cause problems "
188 "when decompressing using an old version of Dolphin");
189 }
190 AddToBlockSizeComboBox(FALLBACK_BLOCK_SIZE);
191 }
192
193 break;
194 }
195 case DiscIO::BlobType::WIA:
196 m_block_size->setEnabled(true);
197
198 // This is the smallest block size supported by WIA. For performance, larger sizes are avoided.
199 AddToBlockSizeComboBox(0x200000);
200
201 break;
202 case DiscIO::BlobType::RVZ:
203 m_block_size->setEnabled(true);
204
205 for (int block_size = MIN_BLOCK_SIZE; block_size <= MAX_BLOCK_SIZE; block_size *= 2)
206 AddToBlockSizeComboBox(block_size);
207
208 break;
209 default:
210 break;
211 }
212
213 // Populate m_compression
214 switch (format)
215 {
216 case DiscIO::BlobType::GCZ:
217 m_compression->setEnabled(true);
218 AddToCompressionComboBox(QStringLiteral("Deflate"), DiscIO::WIARVZCompressionType::None);
219 break;
220 case DiscIO::BlobType::WIA:
221 case DiscIO::BlobType::RVZ:
222 {
223 m_compression->setEnabled(true);
224
225 // i18n: %1 is the name of a compression method (e.g. LZMA)
226 const QString slow = tr("%1 (slow)");
227
228 AddToCompressionComboBox(tr("No Compression"), DiscIO::WIARVZCompressionType::None);
229
230 if (format == DiscIO::BlobType::WIA)
231 AddToCompressionComboBox(QStringLiteral("Purge"), DiscIO::WIARVZCompressionType::Purge);
232
233 AddToCompressionComboBox(slow.arg(QStringLiteral("bzip2")),
234 DiscIO::WIARVZCompressionType::Bzip2);
235
236 AddToCompressionComboBox(slow.arg(QStringLiteral("LZMA")), DiscIO::WIARVZCompressionType::LZMA);
237
238 AddToCompressionComboBox(slow.arg(QStringLiteral("LZMA2")),
239 DiscIO::WIARVZCompressionType::LZMA2);
240
241 if (format == DiscIO::BlobType::RVZ)
242 {
243 // i18n: %1 is the name of a compression method (e.g. Zstandard)
244 const QString recommended = tr("%1 (recommended)");
245
246 AddToCompressionComboBox(recommended.arg(QStringLiteral("Zstandard")),
247 DiscIO::WIARVZCompressionType::Zstd);
248 m_compression->setCurrentIndex(m_compression->count() - 1);
249 }
250
251 break;
252 }
253 default:
254 m_compression->setEnabled(false);
255 break;
256 }
257
258 m_block_size->setEnabled(m_block_size->count() > 1);
259 m_compression->setEnabled(m_compression->count() > 1);
260
261 const bool scrubbing_allowed =
262 format != DiscIO::BlobType::RVZ &&
263 std::none_of(m_files.begin(), m_files.end(), std::mem_fn(&UICommon::GameFile::IsDatelDisc));
264
265 m_scrub->setEnabled(scrubbing_allowed);
266 if (!scrubbing_allowed)
267 m_scrub->setChecked(false);
268 }
269
OnCompressionChanged()270 void ConvertDialog::OnCompressionChanged()
271 {
272 m_compression_level->clear();
273
274 const auto compression_type =
275 static_cast<DiscIO::WIARVZCompressionType>(m_compression->currentData().toInt());
276
277 const std::pair<int, int> range = DiscIO::GetAllowedCompressionLevels(compression_type);
278
279 for (int i = range.first; i <= range.second; ++i)
280 {
281 AddToCompressionLevelComboBox(i);
282 if (i == 5)
283 m_compression_level->setCurrentIndex(m_compression_level->count() - 1);
284 }
285
286 m_compression_level->setEnabled(m_compression_level->count() > 1);
287 }
288
ShowAreYouSureDialog(const QString & text)289 bool ConvertDialog::ShowAreYouSureDialog(const QString& text)
290 {
291 ModalMessageBox warning(this);
292 warning.setIcon(QMessageBox::Warning);
293 warning.setWindowTitle(tr("Confirm"));
294 warning.setText(tr("Are you sure?"));
295 warning.setInformativeText(text);
296 warning.setStandardButtons(QMessageBox::Yes | QMessageBox::No);
297
298 return warning.exec() == QMessageBox::Yes;
299 }
300
Convert()301 void ConvertDialog::Convert()
302 {
303 const DiscIO::BlobType format = static_cast<DiscIO::BlobType>(m_format->currentData().toInt());
304 const int block_size = m_block_size->currentData().toInt();
305 const DiscIO::WIARVZCompressionType compression =
306 static_cast<DiscIO::WIARVZCompressionType>(m_compression->currentData().toInt());
307 const int compression_level = m_compression_level->currentData().toInt();
308 const bool scrub = m_scrub->isChecked();
309
310 if (scrub && format == DiscIO::BlobType::PLAIN)
311 {
312 if (!ShowAreYouSureDialog(tr("Removing junk data does not save any space when converting to "
313 "ISO (unless you package the ISO file in a compressed file format "
314 "such as ZIP afterwards). Do you want to continue anyway?")))
315 {
316 return;
317 }
318 }
319
320 if (!scrub && format == DiscIO::BlobType::GCZ &&
321 std::any_of(m_files.begin(), m_files.end(), [](const auto& file) {
322 return file->GetPlatform() == DiscIO::Platform::WiiDisc && !file->IsDatelDisc();
323 }))
324 {
325 if (!ShowAreYouSureDialog(tr("Converting Wii disc images to GCZ without removing junk data "
326 "does not save any noticeable amount of space compared to "
327 "converting to ISO. Do you want to continue anyway?")))
328 {
329 return;
330 }
331 }
332
333 QString extension;
334 QString filter;
335 switch (format)
336 {
337 case DiscIO::BlobType::PLAIN:
338 extension = QStringLiteral(".iso");
339 filter = tr("Uncompressed GC/Wii images (*.iso *.gcm)");
340 break;
341 case DiscIO::BlobType::GCZ:
342 extension = QStringLiteral(".gcz");
343 filter = tr("GCZ GC/Wii images (*.gcz)");
344 break;
345 case DiscIO::BlobType::WIA:
346 extension = QStringLiteral(".wia");
347 filter = tr("WIA GC/Wii images (*.wia)");
348 break;
349 case DiscIO::BlobType::RVZ:
350 extension = QStringLiteral(".rvz");
351 filter = tr("RVZ GC/Wii images (*.rvz)");
352 break;
353 default:
354 ASSERT(false);
355 return;
356 }
357
358 QString dst_dir;
359 QString dst_path;
360
361 if (m_files.size() > 1)
362 {
363 dst_dir = QFileDialog::getExistingDirectory(
364 this, tr("Select where you want to save the converted images"),
365 QFileInfo(QString::fromStdString(m_files[0]->GetFilePath())).dir().absolutePath());
366
367 if (dst_dir.isEmpty())
368 return;
369 }
370 else
371 {
372 dst_path = QFileDialog::getSaveFileName(
373 this, tr("Select where you want to save the converted image"),
374 QFileInfo(QString::fromStdString(m_files[0]->GetFilePath()))
375 .dir()
376 .absoluteFilePath(
377 QFileInfo(QString::fromStdString(m_files[0]->GetFilePath())).completeBaseName())
378 .append(extension),
379 filter);
380
381 if (dst_path.isEmpty())
382 return;
383 }
384
385 for (const auto& file : m_files)
386 {
387 const auto original_path = file->GetFilePath();
388 if (m_files.size() > 1)
389 {
390 dst_path =
391 QDir(dst_dir)
392 .absoluteFilePath(QFileInfo(QString::fromStdString(original_path)).completeBaseName())
393 .append(extension);
394 QFileInfo dst_info = QFileInfo(dst_path);
395 if (dst_info.exists())
396 {
397 ModalMessageBox confirm_replace(this);
398 confirm_replace.setIcon(QMessageBox::Warning);
399 confirm_replace.setWindowTitle(tr("Confirm"));
400 confirm_replace.setText(tr("The file %1 already exists.\n"
401 "Do you wish to replace it?")
402 .arg(dst_info.fileName()));
403 confirm_replace.setStandardButtons(QMessageBox::Yes | QMessageBox::No);
404
405 if (confirm_replace.exec() == QMessageBox::No)
406 continue;
407 }
408 }
409
410 ParallelProgressDialog progress_dialog(tr("Converting..."), tr("Abort"), 0, 100, this);
411 progress_dialog.GetRaw()->setWindowModality(Qt::WindowModal);
412 progress_dialog.GetRaw()->setWindowTitle(tr("Progress"));
413
414 if (m_files.size() > 1)
415 {
416 progress_dialog.GetRaw()->setLabelText(
417 tr("Converting...") + QLatin1Char{'\n'} +
418 QFileInfo(QString::fromStdString(original_path)).fileName());
419 }
420
421 std::unique_ptr<DiscIO::BlobReader> blob_reader;
422 bool scrub_current_file = scrub;
423
424 if (scrub_current_file)
425 {
426 blob_reader = DiscIO::ScrubbedBlob::Create(original_path);
427 if (!blob_reader)
428 {
429 const int result =
430 ModalMessageBox::warning(this, tr("Question"),
431 tr("Failed to remove junk data from file \"%1\".\n\n"
432 "Would you like to convert it without removing junk data?")
433 .arg(QString::fromStdString(original_path)),
434 QMessageBox::Ok | QMessageBox::Abort);
435
436 if (result == QMessageBox::Ok)
437 scrub_current_file = false;
438 else
439 return;
440 }
441 }
442
443 if (!scrub_current_file)
444 blob_reader = DiscIO::CreateBlobReader(original_path);
445
446 if (!blob_reader)
447 {
448 ModalMessageBox::critical(
449 this, tr("Error"),
450 tr("Failed to open the input file \"%1\".").arg(QString::fromStdString(original_path)));
451 return;
452 }
453 else
454 {
455 const auto callback = [&progress_dialog](const std::string& text, float percent) {
456 progress_dialog.SetValue(percent * 100);
457 return !progress_dialog.WasCanceled();
458 };
459
460 std::future<bool> success;
461
462 switch (format)
463 {
464 case DiscIO::BlobType::PLAIN:
465 success = std::async(std::launch::async, [&] {
466 const bool good = DiscIO::ConvertToPlain(blob_reader.get(), original_path,
467 dst_path.toStdString(), callback);
468 progress_dialog.Reset();
469 return good;
470 });
471 break;
472
473 case DiscIO::BlobType::GCZ:
474 success = std::async(std::launch::async, [&] {
475 const bool good = DiscIO::ConvertToGCZ(
476 blob_reader.get(), original_path, dst_path.toStdString(),
477 file->GetPlatform() == DiscIO::Platform::WiiDisc ? 1 : 0, block_size, callback);
478 progress_dialog.Reset();
479 return good;
480 });
481 break;
482
483 case DiscIO::BlobType::WIA:
484 case DiscIO::BlobType::RVZ:
485 success = std::async(std::launch::async, [&] {
486 const bool good =
487 DiscIO::ConvertToWIAOrRVZ(blob_reader.get(), original_path, dst_path.toStdString(),
488 format == DiscIO::BlobType::RVZ, compression,
489 compression_level, block_size, callback);
490 progress_dialog.Reset();
491 return good;
492 });
493 break;
494
495 default:
496 ASSERT(false);
497 break;
498 }
499
500 progress_dialog.GetRaw()->exec();
501 if (!success.get())
502 {
503 ModalMessageBox::critical(this, tr("Error"),
504 tr("Dolphin failed to complete the requested action."));
505 return;
506 }
507 }
508 }
509
510 ModalMessageBox::information(this, tr("Success"),
511 tr("Successfully converted %n image(s).", "", m_files.size()));
512
513 close();
514 }
515