From d5cf5f45c3c8c91575cc0a116f010b6c7c3af99a Mon Sep 17 00:00:00 2001 From: KojoZero Date: Fri, 13 Mar 2026 18:00:19 -0700 Subject: [PATCH 1/6] implement basic cli compress/decompress feature --- src/citra_qt/citra_qt.cpp | 158 ++++++++++++++++++++++++++++++++++++++ src/citra_qt/citra_qt.h | 10 ++- 2 files changed, 167 insertions(+), 1 deletion(-) diff --git a/src/citra_qt/citra_qt.cpp b/src/citra_qt/citra_qt.cpp index 242fab411..5f842f1a4 100644 --- a/src/citra_qt/citra_qt.cpp +++ b/src/citra_qt/citra_qt.cpp @@ -336,6 +336,46 @@ GMainWindow::GMainWindow(Core::System& system_) game_path = args[i]; continue; } + + // Compress files in place + if (args[i] == QStringLiteral("--compress") || args[i] == QStringLiteral("-c")) { + if (i >= args.size() - 1 || args[i + 1].startsWith(QChar::fromLatin1('-'))) { + continue; + } + i++; + for (; i < args.size(); i++){ + QFileInfo currPath(args[i]); + if (currPath.isFile()){ + compress_paths.append(args[i]); + } else { + QTextStream(stderr) << "Error: " << args[i] << " is not a file!\n"; + exit(1); + } + } + GMainWindow::OnCompressFileCLI(); + compression_future.waitForFinished(); + exit(0); + } + + // Decompress files in place + if (args[i] == QStringLiteral("--decompress") || args[i] == QStringLiteral("-x")) { + if (i >= args.size() - 1 || args[i + 1].startsWith(QChar::fromLatin1('-'))) { + continue; + } + i++; + for (; i < args.size(); i++){ + QFileInfo currPath(args[i]); + if (currPath.isFile()){ + decompress_paths.append(args[i]); + } else { + QTextStream(stderr) << "Error: " << args[i] << " is not a file!\n"; + exit(1); + } + } + GMainWindow::OnDecompressFileCLI(); + compression_future.waitForFinished(); + exit(0); + } } #ifdef __unix__ @@ -3281,6 +3321,72 @@ void GMainWindow::OnCompressFile() { }); } +void GMainWindow::OnCompressFileCLI() { + // NOTE: Encrypted files SHOULD NEVER be compressed, otherwise the resulting + // compressed file will have very poor compression ratios, due to the high + // entropy caused by encryption. This may cause confusion to the user as they + // will see the files do not compress well and blame the emulator. + // + // This is enforced using the loaders as they already return an error on encryption. + + QString out_path; + QStringList filepaths = compress_paths; + if (compress_paths.isEmpty()) { + return; + } + + bool single_file = filepaths.size() == 1; + + // Set the output directory based on the starting file + QFileInfo startFileInfo(filepaths[0]); + out_path = startFileInfo.absolutePath(); + if (out_path.isEmpty()) { + return; + } + + compression_future = QtConcurrent::run([&, filepaths, out_path] { + bool single_file = filepaths.size() == 1; + QString out_filepath; + bool total_success = true; + + for (const QString& filepath : filepaths) { + QFileInfo filepathInfo(filepath); + QTextStream(stdout) << "Compressing " << filepathInfo.fileName() << "\n"; + std::string in_path = filepath.toStdString(); + + // Identify file type + auto compress_info = GetCompressFileInfo(filepath.toStdString(), true); + if (!compress_info.has_value()) { + total_success = false; + continue; + } + + QFileInfo fileinfo(filepath); + out_filepath = out_path + QStringLiteral(DIR_SEP) + fileinfo.completeBaseName() + + QStringLiteral(".") + + QString::fromStdString( + compress_info.value().first.recommended_compressed_extension); + + std::string out_path = out_filepath.toStdString(); + emit UpdateProgress(0, 0); + const auto progress = [&](std::size_t written, std::size_t total) { + emit UpdateProgress(written, total); + }; + bool success = FileUtil::CompressZ3DSFile(in_path, out_path, + compress_info.value().first.underlying_magic, + compress_info.value().second, progress, + compress_info.value().first.default_metadata); + if (!success) { + total_success = false; + FileUtil::Delete(out_path); + } + } + emit CompressFinished(true, total_success); + }); +} + + + void GMainWindow::OnDecompressFile() { QStringList filepaths = QFileDialog::getOpenFileNames( @@ -3374,6 +3480,58 @@ void GMainWindow::OnDecompressFile() { }); } +void GMainWindow::OnDecompressFileCLI() { + QStringList filepaths = decompress_paths; + QString out_path; + + if (filepaths.isEmpty()) { + return; + } + + bool single_file = filepaths.size() == 1; + QFileInfo startFileInfo(filepaths[0]); + out_path = startFileInfo.absolutePath(); + + compression_future = QtConcurrent::run([&, filepaths, out_path] { + bool single_file = filepaths.size() == 1; + QString out_filepath; + bool total_success = true; + + for (const QString& filepath : filepaths) { + QFileInfo filepathInfo(filepath); + QTextStream(stdout) << "Decompressing " << filepathInfo.fileName() << "\n"; + std::string in_path = filepath.toStdString(); + + // Identify file type + auto compress_info = GetCompressFileInfo(filepath.toStdString(), false); + if (!compress_info.has_value()) { + total_success = false; + continue; + } + + QFileInfo fileinfo(filepath); + + out_filepath = out_path + QStringLiteral(DIR_SEP) + fileinfo.completeBaseName() + + QStringLiteral(".") + + QString::fromStdString( + compress_info.value().first.recommended_uncompressed_extension); + std::string out_path = out_filepath.toStdString(); + emit UpdateProgress(0, 0); + const auto progress = [&](std::size_t written, std::size_t total) { + emit UpdateProgress(written, total); + }; + + // TODO(PabloMK7): What should we do with the metadata? + bool success = FileUtil::DeCompressZ3DSFile(in_path, out_path, progress); + if (!success) { + total_success = false; + FileUtil::Delete(out_path); + } + } + emit CompressFinished(false, total_success); + }); +} + #ifdef _WIN32 void GMainWindow::OnOpenFFmpeg() { auto filename = diff --git a/src/citra_qt/citra_qt.h b/src/citra_qt/citra_qt.h index cdf9eaff6..593c9c569 100644 --- a/src/citra_qt/citra_qt.h +++ b/src/citra_qt/citra_qt.h @@ -7,6 +7,7 @@ #include #include #include +#include #ifdef __unix__ #include #endif @@ -290,7 +291,9 @@ private slots: void OnCaptureScreenshot(); void OnDumpVideo(); void OnCompressFile(); + void OnCompressFileCLI(); void OnDecompressFile(); + void OnDecompressFileCLI(); #ifdef _WIN32 void OnOpenFFmpeg(); #endif @@ -397,6 +400,12 @@ private: // Video dumping bool video_dumping_on_start = false; QString video_dumping_path; + + // Compress/Decompress Paths and Future + QStringList compress_paths; + QStringList decompress_paths; + QFuture compression_future; + // Whether game shutdown is delayed due to video dumping bool game_shutdown_delayed = false; // Whether game was paused due to stopping video dumping @@ -404,7 +413,6 @@ private: QString gl_renderer; std::vector physical_devices; - // Debugger panes ProfilerWidget* profilerWidget; #if MICROPROFILE_ENABLED From dbf39567cfd41053e3c2894721f1f2d36577fd5c Mon Sep 17 00:00:00 2001 From: KojoZero Date: Fri, 13 Mar 2026 19:02:18 -0700 Subject: [PATCH 2/6] filter logs and use existing console --- src/citra_qt/citra_qt.cpp | 11 +++++++++++ src/citra_qt/debugger/console.cpp | 7 +++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/citra_qt/citra_qt.cpp b/src/citra_qt/citra_qt.cpp index 5f842f1a4..2d48c90c3 100644 --- a/src/citra_qt/citra_qt.cpp +++ b/src/citra_qt/citra_qt.cpp @@ -87,6 +87,7 @@ #include "common/literals.h" #include "common/logging/backend.h" #include "common/logging/log.h" +#include "common/logging/filter.h" #include "common/memory_detect.h" #include "common/scm_rev.h" #include "common/scope_exit.h" @@ -342,6 +343,11 @@ GMainWindow::GMainWindow(Core::System& system_) if (i >= args.size() - 1 || args[i + 1].startsWith(QChar::fromLatin1('-'))) { continue; } + UISettings::values.show_console = true; + Debugger::ToggleConsole(); + Common::Log::Filter filter; + filter.ParseFilterString("*:Warning"); + Common::Log::SetGlobalFilter(filter); i++; for (; i < args.size(); i++){ QFileInfo currPath(args[i]); @@ -362,6 +368,11 @@ GMainWindow::GMainWindow(Core::System& system_) if (i >= args.size() - 1 || args[i + 1].startsWith(QChar::fromLatin1('-'))) { continue; } + UISettings::values.show_console = true; + Debugger::ToggleConsole(); + Common::Log::Filter filter; + filter.ParseFilterString("*:Warning"); + Common::Log::SetGlobalFilter(filter); i++; for (; i < args.size(); i++){ QFileInfo currPath(args[i]); diff --git a/src/citra_qt/debugger/console.cpp b/src/citra_qt/debugger/console.cpp index ed7a1cc85..864de466e 100644 --- a/src/citra_qt/debugger/console.cpp +++ b/src/citra_qt/debugger/console.cpp @@ -25,10 +25,13 @@ void ToggleConsole() { #ifdef _WIN32 FILE* temp; if (UISettings::values.show_console) { - BOOL alloc_console_res = AllocConsole(); DWORD last_error = 0; + BOOL alloc_console_res = AttachConsole(ATTACH_PARENT_PROCESS); if (!alloc_console_res) { - last_error = GetLastError(); + alloc_console_res = AllocConsole(); + if (!alloc_console_res) { + last_error = GetLastError(); + } } // If the windows debugger already opened a console, calling AllocConsole again // will cause ERROR_ACCESS_DENIED. If that's the case assume a console is open. From eed160dbe4c722483de6f49ad9fcd1e0758f2a26 Mon Sep 17 00:00:00 2001 From: KojoZero Date: Fri, 13 Mar 2026 20:43:49 -0700 Subject: [PATCH 3/6] minor output changes --- src/citra_qt/citra_qt.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/citra_qt/citra_qt.cpp b/src/citra_qt/citra_qt.cpp index 2d48c90c3..769c45d1a 100644 --- a/src/citra_qt/citra_qt.cpp +++ b/src/citra_qt/citra_qt.cpp @@ -346,7 +346,7 @@ GMainWindow::GMainWindow(Core::System& system_) UISettings::values.show_console = true; Debugger::ToggleConsole(); Common::Log::Filter filter; - filter.ParseFilterString("*:Warning"); + filter.ParseFilterString("*:Error"); Common::Log::SetGlobalFilter(filter); i++; for (; i < args.size(); i++){ @@ -371,7 +371,7 @@ GMainWindow::GMainWindow(Core::System& system_) UISettings::values.show_console = true; Debugger::ToggleConsole(); Common::Log::Filter filter; - filter.ParseFilterString("*:Warning"); + filter.ParseFilterString("*:Error"); Common::Log::SetGlobalFilter(filter); i++; for (; i < args.size(); i++){ @@ -3362,7 +3362,7 @@ void GMainWindow::OnCompressFileCLI() { for (const QString& filepath : filepaths) { QFileInfo filepathInfo(filepath); - QTextStream(stdout) << "Compressing " << filepathInfo.fileName() << "\n"; + QTextStream(stdout) << "Compressing \"" << filepathInfo.fileName() << "\"...\n"; std::string in_path = filepath.toStdString(); // Identify file type @@ -3510,7 +3510,7 @@ void GMainWindow::OnDecompressFileCLI() { for (const QString& filepath : filepaths) { QFileInfo filepathInfo(filepath); - QTextStream(stdout) << "Decompressing " << filepathInfo.fileName() << "\n"; + QTextStream(stdout) << "Decompressing \"" << filepathInfo.fileName() << "\"...\n"; std::string in_path = filepath.toStdString(); // Identify file type From 28e49026c89714134ef61946908f7b0ca0ab4cfd Mon Sep 17 00:00:00 2001 From: KojoZero Date: Fri, 13 Mar 2026 22:41:01 -0700 Subject: [PATCH 4/6] update help --- src/citra_meta/common_strings.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/citra_meta/common_strings.h b/src/citra_meta/common_strings.h index 0276b697f..6f1a98772 100644 --- a/src/citra_meta/common_strings.h +++ b/src/citra_meta/common_strings.h @@ -15,6 +15,8 @@ constexpr char help_string[] = "-g, --gdbport [port] Enable gdb stub on the given port\n" "-h, --help Display this help and exit\n" "-i, --install [path] Install a CIA file at the given path\n" + "-c, --compress [path] Compress the cci/3ds/cxi/app/3dsx/cia file at the given path\n" + "-x, --decompress [path] Decompress zcci/zcxi/z3dsx/zcia file at the given path\n" "-p, --movie-play [path] Play a TAS movie located at the given path\n" "-r, --movie-record [path] Record a TAS movie to the given file path\n" "-a, --movie-record-author [author] Set the author for the recorded TAS movie (to be used " From 91b45e92cc1fcd92c53f2c7147662eb01c01d421 Mon Sep 17 00:00:00 2001 From: KojoZero Date: Fri, 3 Apr 2026 02:31:27 -0700 Subject: [PATCH 5/6] changed functionality, adjusted --help --- src/citra_meta/common_strings.h | 26 ++++++++------- src/citra_qt/citra_qt.cpp | 57 +++++++++++++++++++++++++-------- src/citra_qt/citra_qt.h | 1 + 3 files changed, 58 insertions(+), 26 deletions(-) diff --git a/src/citra_meta/common_strings.h b/src/citra_meta/common_strings.h index 6f1a98772..a809f5e4f 100644 --- a/src/citra_meta/common_strings.h +++ b/src/citra_meta/common_strings.h @@ -10,22 +10,24 @@ namespace Common { constexpr char help_string[] = "Usage: {} [options] \n" - "-d, --dump-video [path] Dump video recording of emulator playback to the given file path\n" - "-f, --fullscreen Start in fullscreen mode\n" - "-g, --gdbport [port] Enable gdb stub on the given port\n" - "-h, --help Display this help and exit\n" - "-i, --install [path] Install a CIA file at the given path\n" - "-c, --compress [path] Compress the cci/3ds/cxi/app/3dsx/cia file at the given path\n" - "-x, --decompress [path] Decompress zcci/zcxi/z3dsx/zcia file at the given path\n" - "-p, --movie-play [path] Play a TAS movie located at the given path\n" - "-r, --movie-record [path] Record a TAS movie to the given file path\n" + "-d, --dump-video [path] Dump video recording of emulator playback to the given file path\n" + "-f, --fullscreen Start in fullscreen mode\n" + "-g, --gdbport [port] Enable gdb stub on the given port\n" + "-h, --help Display this help and exit\n" + "-i, --install [path] Install a CIA file at the given path\n" + "-c, --compress [path]... -o [path] Compress the cci/3ds/cxi/app/3dsx/cia files at the given path\n" + " If \'-o [path]\' isnt used, it compresses in place\n" + "-x, --decompress [path]... -o [path] Decompress zcci/zcxi/z3dsx/zcia files at the given path\n" + " If \'-o [path]\' isnt used, it decompresses in place\n" + "-p, --movie-play [path] Play a TAS movie located at the given path\n" + "-r, --movie-record [path] Record a TAS movie to the given file path\n" "-a, --movie-record-author [author] Set the author for the recorded TAS movie (to be used " "alongside --movie-record)\n" #ifdef ENABLE_ROOM - " --room Utilize dedicated multiplayer room functionality (equivalent to " + " --room Utilize dedicated multiplayer room functionality (equivalent to " "the old citra-room executable)\n" #endif - "-v, --version Output version information and exit\n" - "-w, --windowed Start in windowed mode"; + "-v, --version Output version information and exit\n" + "-w, --windowed Start in windowed mode"; } // namespace Common diff --git a/src/citra_qt/citra_qt.cpp b/src/citra_qt/citra_qt.cpp index 769c45d1a..02bcc1672 100644 --- a/src/citra_qt/citra_qt.cpp +++ b/src/citra_qt/citra_qt.cpp @@ -247,7 +247,12 @@ GMainWindow::GMainWindow(Core::System& system_) } if (args[i] == QStringLiteral("--help") || args[i] == QStringLiteral("-h")) { - ShowCommandOutput("Help", fmt::format(Common::help_string, args[0].toStdString())); + UISettings::values.show_console = true; + Debugger::ToggleConsole(); + Common::Log::Filter filter; + filter.ParseFilterString("*:Error"); + QFileInfo azaharFile(args[0]); + QTextStream(stdout) << QString::fromStdString(fmt::format(Common::help_string, azaharFile.fileName().toStdString())); exit(0); } @@ -350,6 +355,17 @@ GMainWindow::GMainWindow(Core::System& system_) Common::Log::SetGlobalFilter(filter); i++; for (; i < args.size(); i++){ + if (args[i] == QStringLiteral("-o") || args[i] == QStringLiteral("--output")){ + i++; + QFileInfo outputPath(args[i]); + if (outputPath.isDir()){ + cli_out_path = args[i]; + } else { + QTextStream(stderr) << "Error: " << args[i] << " is not a directory!\n"; + exit(1); + } + break; + } QFileInfo currPath(args[i]); if (currPath.isFile()){ compress_paths.append(args[i]); @@ -375,6 +391,17 @@ GMainWindow::GMainWindow(Core::System& system_) Common::Log::SetGlobalFilter(filter); i++; for (; i < args.size(); i++){ + if (args[i] == QStringLiteral("-o") || args[i] == QStringLiteral("--output")){ + i++; + QFileInfo outputPath(args[i]); + if (outputPath.isDir()){ + cli_out_path = args[i]; + } else { + QTextStream(stderr) << "Error: " << args[i] << " is not a directory!\n"; + exit(1); + } + break; + } QFileInfo currPath(args[i]); if (currPath.isFile()){ decompress_paths.append(args[i]); @@ -3340,7 +3367,7 @@ void GMainWindow::OnCompressFileCLI() { // // This is enforced using the loaders as they already return an error on encryption. - QString out_path; + QString out_path = cli_out_path; QStringList filepaths = compress_paths; if (compress_paths.isEmpty()) { return; @@ -3348,13 +3375,6 @@ void GMainWindow::OnCompressFileCLI() { bool single_file = filepaths.size() == 1; - // Set the output directory based on the starting file - QFileInfo startFileInfo(filepaths[0]); - out_path = startFileInfo.absolutePath(); - if (out_path.isEmpty()) { - return; - } - compression_future = QtConcurrent::run([&, filepaths, out_path] { bool single_file = filepaths.size() == 1; QString out_filepath; @@ -3373,7 +3393,13 @@ void GMainWindow::OnCompressFileCLI() { } QFileInfo fileinfo(filepath); - out_filepath = out_path + QStringLiteral(DIR_SEP) + fileinfo.completeBaseName() + + if (out_path.isEmpty()){ + out_filepath = fileinfo.absolutePath(); + } else { + out_filepath = out_path; + } + + out_filepath = out_filepath + QStringLiteral(DIR_SEP) + fileinfo.completeBaseName() + QStringLiteral(".") + QString::fromStdString( compress_info.value().first.recommended_compressed_extension); @@ -3493,15 +3519,13 @@ void GMainWindow::OnDecompressFile() { void GMainWindow::OnDecompressFileCLI() { QStringList filepaths = decompress_paths; - QString out_path; + QString out_path = cli_out_path; if (filepaths.isEmpty()) { return; } bool single_file = filepaths.size() == 1; - QFileInfo startFileInfo(filepaths[0]); - out_path = startFileInfo.absolutePath(); compression_future = QtConcurrent::run([&, filepaths, out_path] { bool single_file = filepaths.size() == 1; @@ -3522,7 +3546,12 @@ void GMainWindow::OnDecompressFileCLI() { QFileInfo fileinfo(filepath); - out_filepath = out_path + QStringLiteral(DIR_SEP) + fileinfo.completeBaseName() + + if (out_path.isEmpty()){ + out_filepath = fileinfo.absolutePath(); + } else { + out_filepath = out_path; + } + out_filepath = out_filepath + QStringLiteral(DIR_SEP) + fileinfo.completeBaseName() + QStringLiteral(".") + QString::fromStdString( compress_info.value().first.recommended_uncompressed_extension); diff --git a/src/citra_qt/citra_qt.h b/src/citra_qt/citra_qt.h index 593c9c569..dbbbb3ed2 100644 --- a/src/citra_qt/citra_qt.h +++ b/src/citra_qt/citra_qt.h @@ -404,6 +404,7 @@ private: // Compress/Decompress Paths and Future QStringList compress_paths; QStringList decompress_paths; + QString cli_out_path; QFuture compression_future; // Whether game shutdown is delayed due to video dumping From 5a3b28356b9fa2afec99c93b033320da9172f4d5 Mon Sep 17 00:00:00 2001 From: KojoZero Date: Fri, 3 Apr 2026 02:48:25 -0700 Subject: [PATCH 6/6] added some error handling --- src/citra_qt/citra_qt.cpp | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/citra_qt/citra_qt.cpp b/src/citra_qt/citra_qt.cpp index 02bcc1672..f10e6c574 100644 --- a/src/citra_qt/citra_qt.cpp +++ b/src/citra_qt/citra_qt.cpp @@ -357,11 +357,16 @@ GMainWindow::GMainWindow(Core::System& system_) for (; i < args.size(); i++){ if (args[i] == QStringLiteral("-o") || args[i] == QStringLiteral("--output")){ i++; - QFileInfo outputPath(args[i]); - if (outputPath.isDir()){ - cli_out_path = args[i]; + if (i < args.size()){ + QFileInfo outputPath(args[i]); + if (outputPath.isDir()){ + cli_out_path = args[i]; + } else { + QTextStream(stderr) << "Error: " << args[i] << " is not a directory!\n"; + exit(1); + } } else { - QTextStream(stderr) << "Error: " << args[i] << " is not a directory!\n"; + QTextStream(stderr) << "Error: No directory specified"; exit(1); } break; @@ -393,11 +398,16 @@ GMainWindow::GMainWindow(Core::System& system_) for (; i < args.size(); i++){ if (args[i] == QStringLiteral("-o") || args[i] == QStringLiteral("--output")){ i++; - QFileInfo outputPath(args[i]); - if (outputPath.isDir()){ - cli_out_path = args[i]; + if (i < args.size()){ + QFileInfo outputPath(args[i]); + if (outputPath.isDir()){ + cli_out_path = args[i]; + } else { + QTextStream(stderr) << "Error: " << args[i] << " is not a directory!\n"; + exit(1); + } } else { - QTextStream(stderr) << "Error: " << args[i] << " is not a directory!\n"; + QTextStream(stderr) << "Error: No directory specified"; exit(1); } break;