// MassBuilderSaveTool // Copyright (C) 2021 Guillaume Jacquemin // // 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, either version 3 of the License, or // (at your option) any later version. // // 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 for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . #include "SaveTool.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "../FontAwesome/IconsFontAwesome5.h" #include "../FontAwesome/IconsFontAwesome5Brands.h" extern const ImVec2 center_pivot = {0.5f, 0.5f}; #ifdef SAVETOOL_DEBUG_BUILD #include #define tw CORRADE_TWEAKABLE Utility::Tweakable tweak; #endif SaveTool::SaveTool(const Arguments& arguments): Platform::Sdl2Application{arguments, Configuration{}.setTitle("M.A.S.S. Builder Save Tool " SAVETOOL_VERSION " (\"" SAVETOOL_CODENAME "\")") .setSize({960, 720})} { #ifdef SAVETOOL_DEBUG_BUILD tweak.enable("", "../../"); #endif if(SDL_VERSION_ATLEAST(2, 0, 5)) { if(SDL_SetHint(SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH, "1") == SDL_TRUE) { Utility::Debug{} << "Clickthrough is available."; } else { Utility::Warning{} << "Clickthrough is not available (hint couldn't be set)."; } } else { Utility::Warning{} << "Clickthrough is not available (SDL2 is too old)."; } GL::Renderer::enable(GL::Renderer::Feature::Blending); GL::Renderer::enable(GL::Renderer::Feature::ScissorTest); GL::Renderer::disable(GL::Renderer::Feature::FaceCulling); GL::Renderer::disable(GL::Renderer::Feature::DepthTest); GL::Renderer::setBlendFunction(GL::Renderer::BlendFunction::SourceAlpha, GL::Renderer::BlendFunction::OneMinusSourceAlpha); GL::Renderer::setBlendEquation(GL::Renderer::BlendEquation::Add, GL::Renderer::BlendEquation::Add); initialiseGui(); if((_initEventId = SDL_RegisterEvents(1)) == UnsignedInt(-1)) { SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error", "SDL_RegisterEvents failed in SaveTool::SaveTool(). Exiting...", window()); exit(EXIT_FAILURE); } _backupsDir = Utility::Directory::join(Utility::Directory::path(Utility::Directory::executableLocation()), "backups"); if(!Utility::Directory::exists(_backupsDir)) { Utility::Directory::mkpath(_backupsDir); } if(!findGameDataDirectory()) { SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error initialising the app", _lastError.c_str(), window()); exit(EXIT_FAILURE); } _configDir = Utility::Directory::join(_gameDataDir, "Saved/Config/WindowsNoEditor"); _saveDir = Utility::Directory::join(_gameDataDir, "Saved/SaveGames"); _screenshotsDir = Utility::Directory::join(_gameDataDir, "Saved/Screenshots/WindowsNoEditor"); if(SDL_InitSubSystem(SDL_INIT_TIMER) != 0) { SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error initialising the app", SDL_GetError(), window()); exit(EXIT_FAILURE); } checkGameState(); _gameCheckTimerId = SDL_AddTimer(2000, [](UnsignedInt interval, void* param)->UnsignedInt{ static_cast(param)->checkGameState(); return interval; }, this); if(_gameCheckTimerId == 0) { SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error", SDL_GetError(), window()); exit(EXIT_FAILURE); } } SaveTool::~SaveTool() { SDL_RemoveTimer(_gameCheckTimerId); } void SaveTool::handleFileAction(efsw::WatchID watch_id, const std::string&, const std::string& filename, efsw::Action action, std::string old_filename) { if(watch_id == _watchIDs[StagingDir] && Utility::String::endsWith(filename, ".sav")) { _massManager->refreshStagedMasses(); return; } if(Utility::String::endsWith(filename, "Config.sav")) { return; } switch(action) { case efsw::Actions::Add: if(Utility::String::endsWith(filename, _currentProfile->steamId() + ".sav")) { if(Utility::String::beginsWith(filename, Utility::formatString("{}Unit", _currentProfile->type() == ProfileType::Demo ? "Demo" : ""))) { int index = ((filename[_currentProfile->type() == ProfileType::Demo ? 8 : 4] - 0x30) * 10) + (filename[_currentProfile->type() == ProfileType::Demo ? 9 : 5] - 0x30); _massManager->refreshHangar(index); } } break; case efsw::Actions::Delete: if(Utility::String::endsWith(filename, _currentProfile->steamId() + ".sav")) { if(Utility::String::beginsWith(filename, Utility::formatString("{}Unit", _currentProfile->type() == ProfileType::Demo ? "Demo" : ""))) { int index = ((filename[_currentProfile->type() == ProfileType::Demo ? 8 : 4] - 0x30) * 10) + (filename[_currentProfile->type() == ProfileType::Demo ? 9 : 5] - 0x30); _massManager->refreshHangar(index); } } break; case efsw::Actions::Modified: if(filename == _currentProfile->filename()) { _currentProfile->refreshValues(); } else if(Utility::String::endsWith(filename, _currentProfile->steamId() + ".sav")) { if(Utility::String::beginsWith(filename, Utility::formatString("{}Unit", _currentProfile->type() == ProfileType::Demo ? "Demo" : ""))) { int index = ((filename[_currentProfile->type() == ProfileType::Demo ? 8 : 4] - 0x30) * 10) + (filename[_currentProfile->type() == ProfileType::Demo ? 9 : 5] - 0x30); _massManager->refreshHangar(index); } } break; case efsw::Actions::Moved: if(Utility::String::endsWith(filename, _currentProfile->steamId() + ".sav")) { if(Utility::String::beginsWith(filename, Utility::formatString("{}Unit", _currentProfile->type() == ProfileType::Demo ? "Demo" : ""))) { int index = ((filename[_currentProfile->type() == ProfileType::Demo ? 8 : 4] - 0x30) * 10) + (filename[_currentProfile->type() == ProfileType::Demo ? 9 : 5] - 0x30); _massManager->refreshHangar(index); int old_index = ((old_filename[_currentProfile->type() == ProfileType::Demo ? 8 : 4] - 0x30) * 10) + (old_filename[_currentProfile->type() == ProfileType::Demo ? 9 : 5] - 0x30); _massManager->refreshHangar(old_index); } } break; default: SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error", "Unknown file watcher action type.", window()); break; } } void SaveTool::drawEvent() { #ifdef SAVETOOL_DEBUG_BUILD tweak.update(); #endif GL::defaultFramebuffer.clear(GL::FramebufferClear::Color); drawImGui(); swapBuffers(); redraw(); } void SaveTool::viewportEvent(ViewportEvent& event) { GL::defaultFramebuffer.setViewport({{}, event.framebufferSize()}); _imgui.relayout(event.windowSize()); } void SaveTool::keyPressEvent(KeyEvent& event) { if(_imgui.handleKeyPressEvent(event)) return; } void SaveTool::keyReleaseEvent(KeyEvent& event) { if(_imgui.handleKeyReleaseEvent(event)) return; } void SaveTool::mousePressEvent(MouseEvent& event) { if(_imgui.handleMousePressEvent(event)) return; } void SaveTool::mouseReleaseEvent(MouseEvent& event) { if(_imgui.handleMouseReleaseEvent(event)) return; } void SaveTool::mouseMoveEvent(MouseMoveEvent& event) { if(_imgui.handleMouseMoveEvent(event)) return; } void SaveTool::mouseScrollEvent(MouseScrollEvent& event) { if(_imgui.handleMouseScrollEvent(event)) { event.setAccepted(); return; } } void SaveTool::textInputEvent(TextInputEvent& event) { if(_imgui.handleTextInputEvent(event)) return; } void SaveTool::anyEvent(SDL_Event& event) { if(event.type == _initEventId) { initEvent(event); } } void SaveTool::initEvent(SDL_Event& event) { _thread.join(); switch(event.user.code) { case InitSuccess: _uiState = UiState::ProfileManager; ImGui::CloseCurrentPopup(); break; case ProfileManagerFailure: SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error initialising ProfileManager", _profileManager->lastError().c_str(), window()); exit(EXIT_FAILURE); break; default: break; } } void SaveTool::initialiseGui() { ImGui::CreateContext(); ImGuiIO& io = ImGui::GetIO(); auto reg_font = _rs.getRaw("SourceSansPro-Regular.ttf"); ImFontConfig font_config; font_config.FontDataOwnedByAtlas = false; std::strcpy(font_config.Name, "Source Sans Pro"); io.Fonts->AddFontFromMemoryTTF(const_cast(reg_font.data()), reg_font.size(), 20.0f, &font_config); auto icon_font = _rs.getRaw(FONT_ICON_FILE_NAME_FAS); static const ImWchar icon_range[] = { ICON_MIN_FA, ICON_MAX_FA, 0 }; ImFontConfig icon_config; icon_config.FontDataOwnedByAtlas = false; icon_config.MergeMode = true; icon_config.PixelSnapH = true; icon_config.OversampleH = icon_config.OversampleV = 1; icon_config.GlyphMinAdvanceX = 18.0f; io.Fonts->AddFontFromMemoryTTF(const_cast(icon_font.data()), icon_font.size(), 16.0f, &icon_config, icon_range); auto brand_font = _rs.getRaw(FONT_ICON_FILE_NAME_FAB); static const ImWchar brand_range[] = { ICON_MIN_FAB, ICON_MAX_FAB, 0 }; io.Fonts->AddFontFromMemoryTTF(const_cast(brand_font.data()), brand_font.size(), 16.0f, &icon_config, brand_range); auto mono_font = _rs.getRaw("SourceCodePro-Regular.ttf"); ImVector range; ImFontGlyphRangesBuilder builder; builder.AddRanges(io.Fonts->GetGlyphRangesDefault()); builder.AddChar(u'š'); // This allows displaying Vladimír Vondruš' name in Corrade's and Magnum's licences. builder.BuildRanges(&range); io.Fonts->AddFontFromMemoryTTF(const_cast(mono_font.data()), mono_font.size(), 18.0f, &font_config, range.Data); _imgui = ImGuiIntegration::Context(*ImGui::GetCurrentContext(), windowSize()); io.IniFilename = nullptr; ImGuiStyle& style = ImGui::GetStyle(); style.WindowTitleAlign = {0.5f, 0.5f}; style.FrameRounding = 3.2f; style.Colors[ImGuiCol_WindowBg] = ImColor(0xff1f1f1f); } void SaveTool::initialiseManager() { SDL_Event event; SDL_zero(event); event.type = _initEventId; _profileManager.emplace(_saveDir, _backupsDir); if(!_profileManager->ready()) { event.user.code = ProfileManagerFailure; SDL_PushEvent(&event); return; } event.user.code = InitSuccess; SDL_PushEvent(&event); } auto SaveTool::findGameDataDirectory() -> bool { wchar_t* localappdata_path = nullptr; Containers::ScopeGuard guard{localappdata_path, CoTaskMemFree}; if(SHGetKnownFolderPath(FOLDERID_LocalAppData, KF_FLAG_NO_APPCONTAINER_REDIRECTION, nullptr, &localappdata_path) != S_OK) { _lastError = "SHGetKnownFolderPath() failed in SaveTool::findGameDataDirectory()"; return false; } _gameDataDir = Utility::Directory::join(Utility::Directory::fromNativeSeparators(Utility::Unicode::narrow(localappdata_path)), "MASS_Builder"); if(!Utility::Directory::exists(_gameDataDir)) { _lastError = _gameDataDir + " wasn't found. Make sure to play the game at least once."; return false; } return true; } void SaveTool::initialiseMassManager() { _currentProfile->refreshValues(); _massManager.emplace(_saveDir, _currentProfile->steamId(), _currentProfile->type() == ProfileType::Demo); initialiseFileWatcher(); } void SaveTool::initialiseFileWatcher() { _fileWatcher.emplace(); _watchIDs[SaveDir] = _fileWatcher->addWatch(_saveDir, this, false); _watchIDs[StagingDir] = _fileWatcher->addWatch(_massManager->stagingAreaDirectory(), this, false); _fileWatcher->watch(); } void SaveTool::drawImGui() { _imgui.newFrame(); if(ImGui::GetIO().WantTextInput && !isTextInputActive()) { startTextInput(); } else if(!ImGui::GetIO().WantTextInput && isTextInputActive()) { stopTextInput(); } drawGui(); _imgui.updateApplicationCursor(*this); _imgui.drawFrame(); } void SaveTool::drawGui() { drawMainMenu(); switch(_uiState) { case UiState::Disclaimer: drawDisclaimer(); break; case UiState::Initialising: drawInitialisation(); break; case UiState::ProfileManager: drawProfileManager(); break; case UiState::MainManager: drawManager(); break; } if(_aboutPopup) { drawAbout(); } #ifdef SAVETOOL_DEBUG_BUILD if(_demoWindow) { ImGui::ShowDemoWindow(&_demoWindow); } if(_styleEditor) { ImGui::ShowStyleEditor(&ImGui::GetStyle()); } if(_metricsWindow) { ImGui::ShowMetricsWindow(&_metricsWindow); } #endif } void SaveTool::drawDisclaimer() { ImGui::SetNextWindowPos(ImVec2{Vector2{windowSize() / 2.0f}}, ImGuiCond_Always, center_pivot); if(ImGui::Begin("Disclaimer##DisclaimerWindow", nullptr, ImGuiWindowFlags_NoCollapse|ImGuiWindowFlags_NoMove| ImGuiWindowFlags_NoResize|ImGuiWindowFlags_NoTitleBar|ImGuiWindowFlags_MenuBar)) { if(ImGui::BeginMenuBar()) { ImGui::TextUnformatted("Disclaimer"); ImGui::EndMenuBar(); } ImGui::TextUnformatted("Before you start using the app, there are a few things you should know:"); ImGui::PushTextWrapPos(windowSize().x() * 0.67f); ImGui::Bullet(); ImGui::SameLine(); ImGui::TextUnformatted("For this application to work properly, it is recommended to disable Steam Cloud syncing for the game. To disable it, right-click the game in your Steam library, click \"Properties\", go to the \"General\" tab, and uncheck \"Keep game saves in the Steam Cloud for M.A.S.S. Builder\"."); ImGui::Bullet(); ImGui::SameLine(); ImGui::TextUnformatted("The developer of this application (Guillaume Jacquemin) isn't associated with Vermillion Digital, and both parties cannot be held responsible for data loss or corruption this app might cause. PLEASE USE AT YOUR OWN RISK!"); ImGui::Bullet(); ImGui::SameLine(); ImGui::TextUnformatted("This application is released under the terms of the GNU General Public Licence version 3. Please see the COPYING file for more details, or the About screen if you somehow didn't get that file with your download of the program."); ImGui::Bullet(); ImGui::SameLine(); ImGui::TextUnformatted("This version of the application was tested on M.A.S.S. Builder early access version " SUPPORTED_GAME_VERSION ". It may or may not work with other versions of the game."); ImGui::PopTextWrapPos(); if(ImGui::BeginTable("##DisclaimerLayoutTable", 3)) { ImGui::TableSetupColumn("##Empty1", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("##Button", ImGuiTableColumnFlags_WidthFixed); ImGui::TableSetupColumn("##Empty2", ImGuiTableColumnFlags_WidthStretch); ImGui::TableNextRow(); ImGui::TableSetColumnIndex(1); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {24.0f, 12.0f}); if(ImGui::Button("I understand the risks")) { _uiState = UiState::Initialising; _thread = std::thread{[this]{ initialiseManager(); }}; } ImGui::PopStyleVar(); ImGui::EndTable(); } } ImGui::End(); } void SaveTool::drawInitialisation() { ImGui::SetNextWindowPos(ImVec2{Vector2{windowSize() / 2.0f}}, ImGuiCond_Always, center_pivot); if(ImGui::BeginPopupModal("##InitPopup", nullptr, ImGuiWindowFlags_AlwaysAutoResize|ImGuiWindowFlags_NoTitleBar)) { ImGui::TextUnformatted("Initialising the manager. Please wait..."); ImGui::EndPopup(); } ImGui::OpenPopup("##InitPopup"); } void SaveTool::drawGameState() { ImGui::TextUnformatted("Game state:"); ImGui::SameLine(); { switch(_gameState) { case GameState::Unknown: ImGui::TextColored(ImColor{0xff00a5ff}, ICON_FA_CIRCLE); drawTooltip("unknown"); break; case GameState::NotRunning: ImGui::TextColored(ImColor{0xff32cd32}, ICON_FA_CIRCLE); drawTooltip("not running"); break; case GameState::Running: ImGui::TextColored(ImColor{0xff0000ff}, ICON_FA_CIRCLE); drawTooltip("running"); break; } } } void SaveTool::drawHelpMarker(const char* text, Float wrap_pos) { ImGui::TextUnformatted(ICON_FA_QUESTION_CIRCLE); drawTooltip(text, wrap_pos); } void SaveTool::drawTooltip(const char* text, Float wrap_pos) { if(ImGui::IsItemHovered()){ ImGui::BeginTooltip(); if(wrap_pos > 0.0f) { ImGui::PushTextWrapPos(wrap_pos); } ImGui::TextUnformatted(text); if(wrap_pos > 0.0f) { ImGui::PopTextWrapPos(); } ImGui::EndTooltip(); } } void SaveTool::openUri(const std::string& uri) { ShellExecuteW(nullptr, nullptr, Utility::Unicode::widen(uri).c_str(), nullptr, nullptr, SW_SHOW); } void SaveTool::checkGameState() { WTS_PROCESS_INFOW* process_infos = nullptr; unsigned long process_count = 0; if(WTSEnumerateProcessesW(WTS_CURRENT_SERVER_HANDLE, 0, 1, &process_infos, &process_count)) { Containers::ScopeGuard guard{process_infos, WTSFreeMemory}; for(unsigned long i = 0; i < process_count; ++i) { if(std::wcscmp(process_infos[i].pProcessName, L"MASS_Builder-Win64-Shipping.exe") == 0) { _gameState = GameState::Running; break; } else { _gameState = GameState::NotRunning; } } } else { _gameState = GameState::Unknown; } }