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