diff --git a/CMakeLists.txt b/CMakeLists.txt index 0ef4d2a2..fafc59aa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,3 +49,4 @@ add_subdirectory("ps2xRuntime") add_subdirectory("ps2xAnalyzer") add_subdirectory("ps2xTest") +add_subdirectory("ps2xStudio") diff --git a/ps2xAnalyzer/include/ps2recomp/elf_analyzer.h b/ps2xAnalyzer/include/ps2recomp/elf_analyzer.h index 25175c36..10839e9f 100644 --- a/ps2xAnalyzer/include/ps2recomp/elf_analyzer.h +++ b/ps2xAnalyzer/include/ps2recomp/elf_analyzer.h @@ -33,6 +33,8 @@ namespace ps2recomp bool analyze(); bool generateToml(const std::string &outputPath); + bool importGhidraMap(const std::string &csvPath); + const std::vector& getFunctions() const; bool isLibrarySymbolNameForHeuristics(const std::string &name) const; static bool isReliableSymbolNameForHeuristics(const std::string &name); static bool isSystemSymbolNameForHeuristics(const std::string &name); diff --git a/ps2xAnalyzer/src/elf_analyzer.cpp b/ps2xAnalyzer/src/elf_analyzer.cpp index df7d40d5..5597e87f 100644 --- a/ps2xAnalyzer/src/elf_analyzer.cpp +++ b/ps2xAnalyzer/src/elf_analyzer.cpp @@ -2492,4 +2492,88 @@ namespace ps2recomp return false; } } + + bool ElfAnalyzer::importGhidraMap(const std::string &csvPath) + { + std::ifstream file(csvPath); + if (!file) + { + std::cerr << "Failed to open Ghidra CSV file: " << csvPath << std::endl; + return false; + } + + std::string line; + int lineNum = 0; + int importedCount = 0; + + while (std::getline(file, line)) + { + lineNum++; + + // Skip header line + if (lineNum == 1 && line.find("Name") != std::string::npos) + { + continue; + } + + // Parse CSV line: Name,Start,End,Size + std::stringstream ss(line); + std::string name, startStr, endStr, sizeStr; + + if (!std::getline(ss, name, ',') || + !std::getline(ss, startStr, ',') || + !std::getline(ss, endStr, ',')) + { + continue; // Skip malformed lines + } + + // Parse hex addresses (e.g., "0x00123456") + uint32_t startAddr = 0; + uint32_t endAddr = 0; + + try + { + startAddr = std::stoul(startStr, nullptr, 16); + endAddr = std::stoul(endStr, nullptr, 16); + } + catch (...) + { + continue; // Skip lines with invalid addresses + } + + // Update existing function or create new one + bool found = false; + for (auto &func : m_functions) + { + if (func.start == startAddr) + { + // Update with Ghidra's more accurate boundaries + func.name = name; + func.end = endAddr; + found = true; + importedCount++; + break; + } + } + + // If not found, create new function from Ghidra data + if (!found) + { + Function newFunc; + newFunc.name = name; + newFunc.start = startAddr; + newFunc.end = endAddr; + m_functions.push_back(newFunc); + importedCount++; + } + } + + std::cout << "Imported " << importedCount << " functions from Ghidra CSV: " << csvPath << std::endl; + return true; + } + + const std::vector& ElfAnalyzer::getFunctions() const + { + return m_functions; + } } diff --git a/ps2xStudio/CMakeLists.txt b/ps2xStudio/CMakeLists.txt new file mode 100644 index 00000000..f662322b --- /dev/null +++ b/ps2xStudio/CMakeLists.txt @@ -0,0 +1,130 @@ +# Blackline Interactive + +cmake_minimum_required(VERSION 3.21) +project(ps2xStudio) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +include(FetchContent) + +FetchContent_Declare( + imgui + GIT_REPOSITORY https://github.com/ocornut/imgui.git + GIT_TAG docking + GIT_SHALLOW TRUE +) + +FetchContent_Declare( + imgui_colortextedit + GIT_REPOSITORY https://github.com/BalazsJako/ImGuiColorTextEdit.git + GIT_TAG master + GIT_SHALLOW TRUE +) + +FetchContent_Declare( + imgui_club + GIT_REPOSITORY https://github.com/ocornut/imgui_club.git + GIT_TAG main + GIT_SHALLOW TRUE +) + +FetchContent_Declare( + imgui_file_dialog + GIT_REPOSITORY https://github.com/aiekick/ImGuiFileDialog.git + GIT_TAG master + GIT_SHALLOW TRUE +) + +FetchContent_Declare( + SDL2 + GIT_REPOSITORY https://github.com/libsdl-org/SDL.git + GIT_TAG release-2.30.9 + GIT_SHALLOW TRUE +) + +set(SDL_SHARED ON CACHE BOOL "" FORCE) +set(SDL_STATIC OFF CACHE BOOL "" FORCE) + +FetchContent_MakeAvailable(imgui imgui_colortextedit imgui_club SDL2) + +FetchContent_GetProperties(imgui_file_dialog) +if(NOT imgui_file_dialog_POPULATED) + FetchContent_Populate(imgui_file_dialog) +endif() + +find_package(OpenGL REQUIRED) + +set(IMGUI_SOURCES + ${imgui_SOURCE_DIR}/imgui.cpp + ${imgui_SOURCE_DIR}/imgui_draw.cpp + ${imgui_SOURCE_DIR}/imgui_tables.cpp + ${imgui_SOURCE_DIR}/imgui_widgets.cpp + ${imgui_SOURCE_DIR}/imgui_demo.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_sdl2.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp +) + +add_library(imgui_lib STATIC ${IMGUI_SOURCES}) + +target_include_directories(imgui_lib PUBLIC + ${imgui_SOURCE_DIR} + ${imgui_SOURCE_DIR}/backends +) + +target_link_libraries(imgui_lib PUBLIC SDL2::SDL2 OpenGL::GL) + +add_library(imgui_colortextedit_lib STATIC + ${imgui_colortextedit_SOURCE_DIR}/TextEditor.cpp +) + +target_include_directories(imgui_colortextedit_lib PUBLIC ${imgui_colortextedit_SOURCE_DIR}) + +target_link_libraries(imgui_colortextedit_lib PUBLIC imgui_lib) + +add_library(imgui_memory_editor_lib INTERFACE) + +target_include_directories(imgui_memory_editor_lib INTERFACE + ${imgui_club_SOURCE_DIR}/imgui_memory_editor +) + +target_link_libraries(imgui_memory_editor_lib INTERFACE imgui_lib) + +add_library(imgui_filedialog_lib STATIC + ${imgui_file_dialog_SOURCE_DIR}/ImGuiFileDialog.cpp +) + +target_include_directories(imgui_filedialog_lib PUBLIC + ${imgui_file_dialog_SOURCE_DIR} + ${imgui_SOURCE_DIR} +) + +target_link_libraries(imgui_filedialog_lib PUBLIC imgui_lib) + +file(GLOB_RECURSE STUDIO_SOURCES "src/*.cpp") +file(GLOB_RECURSE STUDIO_HEADERS "include/*.hpp") + +add_executable(ps2xStudio ${STUDIO_SOURCES} ${STUDIO_HEADERS}) + +target_include_directories(ps2xStudio PRIVATE + include + ${CMAKE_CURRENT_SOURCE_DIR}/../ps2xAnalyzer/include + ${CMAKE_CURRENT_SOURCE_DIR}/../ps2xRecomp/include + ${CMAKE_CURRENT_SOURCE_DIR}/../ps2xTest/include +) + +target_link_libraries(ps2xStudio PRIVATE + imgui_lib + imgui_colortextedit_lib + imgui_memory_editor_lib + imgui_filedialog_lib + ps2_recomp_lib + ps2_analyzer_lib + ps2_test_lib + SDL2::SDL2 + OpenGL::GL +) + +if(WIN32) + target_link_libraries(ps2xStudio PRIVATE SDL2::SDL2main) +endif() diff --git a/ps2xStudio/external/Font/Font_1.ttf b/ps2xStudio/external/Font/Font_1.ttf new file mode 100644 index 00000000..e31b51e3 Binary files /dev/null and b/ps2xStudio/external/Font/Font_1.ttf differ diff --git a/ps2xStudio/external/Font/Font_2.ttf b/ps2xStudio/external/Font/Font_2.ttf new file mode 100644 index 00000000..8b7bb2a4 Binary files /dev/null and b/ps2xStudio/external/Font/Font_2.ttf differ diff --git a/ps2xStudio/include/GUI.hpp b/ps2xStudio/include/GUI.hpp new file mode 100644 index 00000000..b4864cb3 --- /dev/null +++ b/ps2xStudio/include/GUI.hpp @@ -0,0 +1,16 @@ +#pragma once +#include "StudioState.hpp" + +namespace GUI { + void DrawStudio(StudioState& state); + void ApplySettings(StudioState& state); + + // Called from main loop BEFORE NewFrame when fonts need rebuilding + void RebuildFontsIfNeeded(StudioState& state); + + // Notify GUI that config editor should resync + void SyncConfigEditor(StudioState& state); + + // Returns true when user requested exit (File > Exit) + bool WantsQuit(); +} \ No newline at end of file diff --git a/ps2xStudio/include/StudioState.hpp b/ps2xStudio/include/StudioState.hpp new file mode 100644 index 00000000..dcfa4444 --- /dev/null +++ b/ps2xStudio/include/StudioState.hpp @@ -0,0 +1,570 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ps2recomp/elf_analyzer.h" +#include "ps2recomp/config_manager.h" +#include "ps2recomp/ps2_recompiler.h" +#include "ps2recomp/types.h" + +#include "../../ps2xTest/include/MiniTest.h" +extern void register_code_generator_tests(); +extern void register_r5900_decoder_tests(); + +enum class OverrideStatus { + Default, Stub, Skip, ForceRecompile +}; + +enum class ThemeMode { + Dark, Light, Custom +}; + +struct AppSettings { + ThemeMode theme = ThemeMode::Dark; + float fontSize = 15.0f; + float uiScale = 1.0f; + std::string selectedFont = "Font_1.ttf"; + int windowWidth = 1280; + int windowHeight = 720; + bool maximized = true; + + // Custom theme colors + float customBgBase[4] = {0.08f, 0.08f, 0.08f, 1.00f}; + float customAccent[4] = {0.00f, 0.48f, 0.80f, 1.00f}; +}; + +class StreamRedirector : public std::stringbuf { +public: + StreamRedirector(std::vector& target, std::mutex& mtx, std::atomic& ver) + : logs(target), mutex(mtx), version(ver) {} + int sync() override { + std::lock_guard lock(mutex); + std::string line = this->str(); + if (!line.empty() && line.back() == '\n') line.pop_back(); + if (!line.empty()) { + logs.push_back(line); + version.fetch_add(1); + } + this->str(""); + return 0; + } +private: + std::vector& logs; + std::mutex& mutex; + std::atomic& version; +}; + +struct UIState { + std::string elfPath; + std::string outputPath = "output"; + std::string customOutputPath; + std::string ghidraCSVPath; + std::string ghidraCSVContent; + std::string configTomlContent; + std::vector rawElfData; + std::map funcOverrides; + std::shared_ptr analyzer; + bool isAnalysisComplete = false; + + // NEW: Support for multiple imported CSV files + std::vector importedCSVFiles; + int selectedCSVIndex = -1; +}; + +class StudioState { +public: + UIState data; + AppSettings settings; + int selectedFuncIndex = -1; + std::atomic isBusy = false; + std::vector logs; + std::mutex stateMutex; + std::streambuf* oldCout = nullptr; + std::streambuf* oldCerr = nullptr; + std::unique_ptr redirector; + std::future workerThread; + std::vector availableFonts; + + // Deferred font rebuild flag - fonts are rebuilt in the main loop BEFORE NewFrame + std::atomic pendingFontRebuild{false}; + + // Resolved absolute path to config.toml + std::string configTomlPath; + + // Track log changes for efficient UI updates + std::atomic logVersion{0}; + + // Thread-safe status message + void SetStatus(const std::string& msg) { + std::lock_guard lock(statusMutex_); + statusMessage_ = msg; + } + std::string GetStatus() const { + std::lock_guard lock(statusMutex_); + return statusMessage_; + } + + StudioState() { + redirector = std::make_unique(logs, stateMutex, logVersion); + oldCout = std::cout.rdbuf(redirector.get()); + oldCerr = std::cerr.rdbuf(redirector.get()); + + try { + if (!std::filesystem::exists("output")) { + std::filesystem::create_directory("output"); + } + data.outputPath = std::filesystem::absolute("output").string(); + } catch(...) {} + + LoadSettings(); + ScanFonts(); + + // Load config.toml immediately and resolve its path + LoadConfigToml(); + } + + ~StudioState() { + std::cout.rdbuf(oldCout); + std::cerr.rdbuf(oldCerr); + SaveSettings(); + } + + void Log(const std::string& msg) { + std::lock_guard lock(stateMutex); + logs.push_back("[Studio] " + msg); + logVersion.fetch_add(1); + } + + void LogRaw(const std::string& msg) { + std::lock_guard lock(stateMutex); + logs.push_back(msg); + logVersion.fetch_add(1); + } + + void ScanFonts() { + availableFonts.clear(); + try { + std::filesystem::path fontDir = "external/Font"; + if (std::filesystem::exists(fontDir)) { + for (const auto& entry : std::filesystem::directory_iterator(fontDir)) { + if (entry.is_regular_file()) { + std::string ext = entry.path().extension().string(); + if (ext == ".ttf" || ext == ".TTF") { + availableFonts.push_back(entry.path().filename().string()); + } + } + } + } + } catch(...) {} + + if (availableFonts.empty()) { + availableFonts.push_back("Default"); + } + } + + void LoadSettings() { + try { + std::ifstream file("studio_settings.ini"); + if (file) { + std::string line; + while (std::getline(file, line)) { + size_t pos = line.find('='); + if (pos != std::string::npos) { + std::string key = line.substr(0, pos); + std::string value = line.substr(pos + 1); + + if (key == "theme") { + int t = std::stoi(value); + settings.theme = static_cast(t); + } else if (key == "fontSize") { + float newSize = std::stof(value); + // Validate font size + if (newSize >= 10.0f && newSize <= 48.0f) { + settings.fontSize = newSize; + } + } else if (key == "uiScale") { + float newScale = std::stof(value); + if (newScale >= 0.5f && newScale <= 3.0f) { + settings.uiScale = newScale; + } + } else if (key == "selectedFont") { + settings.selectedFont = value; + } else if (key == "windowWidth") { + settings.windowWidth = std::stoi(value); + } else if (key == "windowHeight") { + settings.windowHeight = std::stoi(value); + } else if (key == "maximized") { + settings.maximized = (value == "1"); + } else if (key == "customOutputPath") { + data.customOutputPath = value; + } + } + } + } + } catch(...) {} + } + + void SaveSettings() { + try { + std::ofstream file("studio_settings.ini"); + file << "theme=" << static_cast(settings.theme) << "\n"; + file << "fontSize=" << settings.fontSize << "\n"; + file << "uiScale=" << settings.uiScale << "\n"; + file << "selectedFont=" << settings.selectedFont << "\n"; + file << "windowWidth=" << settings.windowWidth << "\n"; + file << "windowHeight=" << settings.windowHeight << "\n"; + file << "maximized=" << (settings.maximized ? "1" : "0") << "\n"; + if (!data.customOutputPath.empty()) { + file << "customOutputPath=" << data.customOutputPath << "\n"; + } + } catch(...) {} + } + + void LoadELF(const std::string& path) { + data.elfPath = path; + data.isAnalysisComplete = false; + data.analyzer.reset(); + data.funcOverrides.clear(); + + try { + std::ifstream file(path, std::ios::binary); + if (file) { + data.rawElfData.assign((std::istreambuf_iterator(file)), + (std::istreambuf_iterator())); + } else { + data.rawElfData.clear(); + } + } catch (...) { + data.rawElfData.clear(); + } + + std::string filename = std::filesystem::path(path).filename().string(); + SetStatus("Loaded: " + filename); + Log("File loaded: " + path); + } + + void ImportGhidraCSV(const std::string& csvPath) { + if (!data.analyzer) { + Log("Error: Analyze ELF first before importing symbols."); + return; + } + + data.ghidraCSVPath = csvPath; + + try { + std::ifstream file(csvPath); + if (file) { + std::stringstream buffer; + buffer << file.rdbuf(); + data.ghidraCSVContent = buffer.str(); + } + } catch(...) {} + + data.analyzer->importGhidraMap(csvPath); + Log("Ghidra symbols imported successfully from: " + csvPath); + } + + void SetOutputDir(const std::string& path) { + if (path.empty()) return; + + try { + data.customOutputPath = std::filesystem::absolute(path).string(); + if (!std::filesystem::exists(data.customOutputPath)) { + std::filesystem::create_directories(data.customOutputPath); + } + Log("Custom output directory set to: " + data.customOutputPath); + } catch (const std::exception& e) { + Log(std::string("Error setting output dir: ") + e.what()); + data.customOutputPath = std::filesystem::absolute("output").string(); + } + } + + void StartAnalysis(std::string path = "") { + if (isBusy) return; + if (!path.empty()) data.elfPath = path; + if (data.elfPath.empty()) { + Log("Error: No file selected."); + return; + } + + isBusy = true; + SetStatus("Analyzing..."); + std::string currentPath = data.elfPath; + + workerThread = std::async(std::launch::async, [this, currentPath]() { + try { + auto newAnalyzer = std::make_shared(currentPath); + if (newAnalyzer->analyze()) { + std::lock_guard lock(stateMutex); + data.analyzer = newAnalyzer; + data.isAnalysisComplete = true; + SetStatus("Analysis Complete"); + logVersion.fetch_add(1); + } else { + SetStatus("Analysis Failed"); + Log("Analysis failed for: " + currentPath); + } + } catch (const std::exception& e) { + SetStatus("Error"); + Log(std::string("Analysis exception: ") + e.what()); + } + isBusy = false; + }); + } + + void MoveOutputFiles() { + if (data.customOutputPath.empty() || data.customOutputPath == data.outputPath) { + return; + } + + try { + std::filesystem::path srcDir = data.outputPath; + std::filesystem::path dstDir = data.customOutputPath; + + if (!std::filesystem::exists(dstDir)) { + std::filesystem::create_directories(dstDir); + } + + for (const auto& entry : std::filesystem::directory_iterator(srcDir)) { + if (entry.is_regular_file()) { + std::filesystem::path dst = dstDir / entry.path().filename(); + std::filesystem::copy_file(entry.path(), dst, + std::filesystem::copy_options::overwrite_existing); + Log("Moved: " + entry.path().filename().string()); + } + } + + Log("All output files moved to: " + data.customOutputPath); + } catch (const std::exception& e) { + Log(std::string("Error moving files: ") + e.what()); + } + } + + void StartRecompilation() { + if (isBusy || !data.isAnalysisComplete) return; + + // Set the effective output path BEFORE saving config + std::string effectiveOutputPath = data.customOutputPath.empty() + ? data.outputPath : data.customOutputPath; + + if (effectiveOutputPath.empty()) { + SetOutputDir("output"); + effectiveOutputPath = data.outputPath; + } + + // Ensure output directory exists + try { + if (!std::filesystem::exists(effectiveOutputPath)) { + std::filesystem::create_directories(effectiveOutputPath); + } + } catch (const std::exception& e) { + Log(std::string("Error creating output dir: ") + e.what()); + return; + } + + SaveConfigTOML(); + isBusy = true; + SetStatus("Recompiling..."); + + // Use the resolved config path + std::string resolvedConfigPath = configTomlPath.empty() ? "config.toml" : configTomlPath; + + workerThread = std::async(std::launch::async, [this, resolvedConfigPath]() { + try { + ps2recomp::PS2Recompiler recompiler(resolvedConfigPath); + if (recompiler.initialize() && recompiler.recompile()) { + recompiler.generateOutput(); + SetStatus("Recompilation Success"); + Log("Recompilation completed successfully"); + LoadConfigToml(); + } else { + SetStatus("Recompilation Failed"); + Log("Recompilation failed"); + } + } catch (const std::exception& e) { + SetStatus("Recompilation Error"); + Log(std::string("Recompilation error: ") + e.what()); + } + isBusy = false; + }); + } + + void RunDiagnostics() { + if (isBusy) return; + isBusy = true; + SetStatus("Running Diagnostics..."); + + workerThread = std::async(std::launch::async, [this]() { + try { + register_code_generator_tests(); + register_r5900_decoder_tests(); + std::cout << "\n--- DIAGNOSTICS ---\n" << std::endl; + int failed = MiniTest::Run(); + if (failed == 0) { + SetStatus("System Healthy"); + std::cout << "\n[SUCCESS] All systems operational." << std::endl; + } else { + SetStatus("Issues Found"); + std::cerr << "\n[WARNING] " << failed << " tests failed!" << std::endl; + } + } catch (const std::exception& e) { + SetStatus("Diagnostics Error"); + std::cerr << "Critical failure: " << e.what() << std::endl; + } + isBusy = false; + }); + } + + // Resolve and load config.toml with proper path tracking + void LoadConfigToml() { + bool loaded = false; + + // Build search paths - prioritize custom output path and previously resolved path + std::vector configPaths; + if (!configTomlPath.empty()) { + configPaths.push_back(configTomlPath); // previously resolved + } + configPaths.push_back("config.toml"); + if (!data.customOutputPath.empty()) { + configPaths.push_back(data.customOutputPath + "/config.toml"); + } + if (!data.outputPath.empty()) { + configPaths.push_back(data.outputPath + "/config.toml"); + } + configPaths.push_back("../config.toml"); + + for (const auto& path : configPaths) { + if (path.empty()) continue; + + try { + std::ifstream file(path); + if (file && file.good()) { + std::stringstream buffer; + buffer << file.rdbuf(); + std::string content = buffer.str(); + if (!content.empty()) { + data.configTomlContent = content; + configTomlPath = std::filesystem::absolute(path).string(); + Log("Config loaded from: " + configTomlPath); + loaded = true; + break; + } + } + } catch(...) { + continue; + } + } + + if (!loaded) { + // Create default config in CWD + configTomlPath = std::filesystem::absolute("config.toml").string(); + CreateDefaultConfig(); + } + } + + // NEW: Create default config if none exists + void CreateDefaultConfig() { + try { + std::string defaultConfig = R"(# PS2Recomp Configuration File +# Generated by PS2Recomp Studio + +[input] +path = "" + +[output] +path = "output" +single_file = false + +[functions] +stub = [] +skip = [] +force_recompile = [] + +[settings] +optimize = true +debug_info = false +)"; + + std::string savePath = configTomlPath.empty() ? "config.toml" : configTomlPath; + std::ofstream file(savePath); + if (file) { + file << defaultConfig; + file.flush(); + data.configTomlContent = defaultConfig; + if (configTomlPath.empty()) { + configTomlPath = std::filesystem::absolute(savePath).string(); + } + Log("Created default config at: " + configTomlPath); + } + } catch (const std::exception& e) { + Log(std::string("Error creating default config: ") + e.what()); + } + } + + void SaveConfigTOML() { + if (!data.isAnalysisComplete) return; + + try { + ps2recomp::RecompilerConfig config; + config.inputPath = data.elfPath; + // Use the effective output path (custom if set, default otherwise) + config.outputPath = data.customOutputPath.empty() + ? data.outputPath : data.customOutputPath; + config.singleFileOutput = false; + + for (const auto& [name, status] : data.funcOverrides) { + if (status == OverrideStatus::Stub) { + config.stubImplementations.push_back(name); + } else if (status == OverrideStatus::Skip) { + config.skipFunctions.push_back(name); + } + } + + std::string savePath = configTomlPath.empty() ? "config.toml" : configTomlPath; + ps2recomp::ConfigManager mgr(savePath); + mgr.saveConfig(config); + Log("Config saved to " + savePath); + + LoadConfigToml(); + } catch (const std::exception& e) { + Log(std::string("Error saving config: ") + e.what()); + } + } + + void SaveConfigTomlFromEditor(const std::string& newContent) { + try { + std::string savePath = configTomlPath.empty() ? "config.toml" : configTomlPath; + std::ofstream file(savePath); + if (file) { + file << newContent; + file.flush(); + data.configTomlContent = newContent; + Log("config.toml saved to: " + savePath); + } else { + Log("Error: Cannot open " + savePath + " for writing"); + } + } catch (const std::exception& e) { + Log(std::string("Error saving config.toml: ") + e.what()); + } + } + + // Get the effective output directory + std::string GetEffectiveOutputPath() const { + if (!data.customOutputPath.empty()) return data.customOutputPath; + return data.outputPath; + } + +private: + mutable std::mutex statusMutex_; + std::string statusMessage_ = "Ready"; +}; diff --git a/ps2xStudio/include/ui/StyleManager.hpp b/ps2xStudio/include/ui/StyleManager.hpp new file mode 100644 index 00000000..59e14428 --- /dev/null +++ b/ps2xStudio/include/ui/StyleManager.hpp @@ -0,0 +1,11 @@ +#pragma once +#include "imgui.h" +#include "../StudioState.hpp" + +namespace StyleManager { + void ApplyTheme(ThemeMode mode, AppSettings& settings); + void ApplyDarkTheme(); + void ApplyLightTheme(); + void ApplyCustomTheme(AppSettings& settings); + void SetupFonts(AppSettings& settings); +} \ No newline at end of file diff --git a/ps2xStudio/src/GUI.cpp b/ps2xStudio/src/GUI.cpp new file mode 100644 index 00000000..2132022c --- /dev/null +++ b/ps2xStudio/src/GUI.cpp @@ -0,0 +1,742 @@ +#include "GUI.hpp" +#include "ui/StyleManager.hpp" +#include "imgui.h" +#include "imgui_internal.h" +#include "imgui_memory_editor.h" +#include "TextEditor.h" +#include "ImGuiFileDialog.h" +#include +#include +#include +#include + +static MemoryEditor mem_edit; +static TextEditor code_editor; +static TextEditor config_editor; +static TextEditor ghidra_editor; +static TextEditor log_editor; // TextEditor for logs - supports text selection +static bool editors_initialized = false; +static bool show_settings_window = false; +static bool config_editor_needs_sync = false; +static size_t last_log_version = 0; +static bool s_wantsQuit = false; + +// HEX view color highlighting +struct HexHighlightRange { + size_t start; + size_t end; + ImU32 color; + std::string label; +}; +static std::vector hex_highlight_ranges; + +// Global pointer for BgColorFn callback +static std::vector* g_hex_highlights = &hex_highlight_ranges; + +std::string FormatHex(uint32_t val) { + std::stringstream ss; + ss << "0x" << std::hex << std::setw(8) << std::setfill('0') << val; + return ss.str(); +} + +// BgColorFn callback for MemoryEditor - provides per-byte background color +static ImU32 HexBgColorCallback(const ImU8* /*mem*/, size_t off, void* /*user_data*/) { + if (g_hex_highlights) { + for (const auto& hl : *g_hex_highlights) { + if (off >= hl.start && off < hl.end) { + return hl.color; + } + } + } + return 0; // no color (transparent) +} + +void GUI::ApplySettings(StudioState& state) { + StyleManager::SetupFonts(state.settings); + StyleManager::ApplyTheme(state.settings.theme, state.settings); +} + +void GUI::RebuildFontsIfNeeded(StudioState& state) { + if (state.pendingFontRebuild.exchange(false)) { + StyleManager::SetupFonts(state.settings); + } +} + +void GUI::SyncConfigEditor(StudioState& state) { + config_editor_needs_sync = true; +} + +bool GUI::WantsQuit() { + return s_wantsQuit; +} + +// ---- Settings Window ---- +static void DrawSettingsWindow(StudioState& state) { + if (!show_settings_window) return; + + ImGui::SetNextWindowSize(ImVec2(600, 500), ImGuiCond_FirstUseEver); + if (ImGui::Begin("Settings", &show_settings_window)) { + + if (ImGui::CollapsingHeader("Theme", ImGuiTreeNodeFlags_DefaultOpen)) { + const char* themes[] = { "Dark", "Light", "Custom" }; + int current = static_cast(state.settings.theme); + if (ImGui::Combo("Theme Mode", ¤t, themes, 3)) { + state.settings.theme = static_cast(current); + StyleManager::ApplyTheme(state.settings.theme, state.settings); + } + + if (state.settings.theme == ThemeMode::Custom) { + ImGui::Spacing(); + ImGui::ColorEdit4("Background", state.settings.customBgBase); + ImGui::ColorEdit4("Accent", state.settings.customAccent); + if (ImGui::Button("Apply Custom Colors")) { + StyleManager::ApplyTheme(state.settings.theme, state.settings); + } + } + } + + if (ImGui::CollapsingHeader("Font & Size", ImGuiTreeNodeFlags_DefaultOpen)) { + if (ImGui::BeginCombo("Font", state.settings.selectedFont.c_str())) { + for (const auto& font : state.availableFonts) { + bool selected = (state.settings.selectedFont == font); + if (ImGui::Selectable(font.c_str(), selected)) { + state.settings.selectedFont = font; + // Defer font rebuild to main loop (prevents crash) + state.pendingFontRebuild = true; + } + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + // Font size slider - changes are deferred to prevent crash + if (ImGui::SliderFloat("Font Size", &state.settings.fontSize, 10.0f, 24.0f, "%.1f")) { + // Clamp to safe range + state.settings.fontSize = std::clamp(state.settings.fontSize, 10.0f, 48.0f); + // Defer font rebuild to main loop - NOT during frame rendering + state.pendingFontRebuild = true; + } + } + + if (ImGui::CollapsingHeader("UI Scale", ImGuiTreeNodeFlags_DefaultOpen)) { + if (ImGui::SliderFloat("Scale", &state.settings.uiScale, 0.5f, 2.0f, "%.2f")) { + state.settings.uiScale = std::clamp(state.settings.uiScale, 0.5f, 3.0f); + StyleManager::ApplyTheme(state.settings.theme, state.settings); + } + } + + if (ImGui::CollapsingHeader("Window", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Checkbox("Start Maximized", &state.settings.maximized); + ImGui::SliderInt("Default Width", &state.settings.windowWidth, 800, 3840); + ImGui::SliderInt("Default Height", &state.settings.windowHeight, 600, 2160); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + if (ImGui::Button("Rescan Fonts", ImVec2(150, 0))) { + state.ScanFonts(); + } + ImGui::SameLine(); + if (ImGui::Button("Reset to Defaults", ImVec2(150, 0))) { + state.settings = AppSettings(); + state.pendingFontRebuild = true; + } + + ImGui::SameLine(); + if (ImGui::Button("Close", ImVec2(150, 0))) { + show_settings_window = false; + } + } + ImGui::End(); +} + +// ---- Main Draw ---- +void GUI::DrawStudio(StudioState& state) { + if (!editors_initialized) { + code_editor.SetLanguageDefinition(TextEditor::LanguageDefinition::CPlusPlus()); + code_editor.SetPalette(TextEditor::GetDarkPalette()); + code_editor.SetReadOnly(true); + + config_editor.SetLanguageDefinition(TextEditor::LanguageDefinition::CPlusPlus()); + config_editor.SetPalette(TextEditor::GetDarkPalette()); + config_editor.SetReadOnly(false); + config_editor.SetText(state.data.configTomlContent); + + ghidra_editor.SetLanguageDefinition(TextEditor::LanguageDefinition::CPlusPlus()); + ghidra_editor.SetPalette(TextEditor::GetDarkPalette()); + ghidra_editor.SetReadOnly(true); + + // Log editor: read-only TextEditor that supports text selection (Ctrl+A, Ctrl+C, mouse drag) + log_editor.SetPalette(TextEditor::GetDarkPalette()); + log_editor.SetReadOnly(true); + log_editor.SetShowWhitespaces(false); + + mem_edit.ReadOnly = true; + mem_edit.OptShowAscii = true; + mem_edit.OptGreyOutZeroes = true; + mem_edit.OptUpperCaseHex = false; + // Set BgColorFn for highlighting selected functions in hex view + mem_edit.BgColorFn = HexBgColorCallback; + mem_edit.UserData = nullptr; + + editors_initialized = true; + } + + // Sync config editor if requested + if (config_editor_needs_sync) { + config_editor.SetText(state.data.configTomlContent); + config_editor_needs_sync = false; + } + + // Update log editor when logs change + { + size_t currentVersion = state.logVersion.load(); + if (currentVersion != last_log_version) { + std::lock_guard lock(state.stateMutex); + std::stringstream ss; + for (const auto& logLine : state.logs) { + ss << logLine << "\n"; + } + log_editor.SetText(ss.str()); + + // Set error markers for lines containing Error/Failed + TextEditor::ErrorMarkers markers; + for (size_t i = 0; i < state.logs.size(); i++) { + const auto& line = state.logs[i]; + if (line.find("Error") != std::string::npos || + line.find("Failed") != std::string::npos || + line.find("error") != std::string::npos) { + markers.insert(std::make_pair(static_cast(i + 1), line)); + } + } + log_editor.SetErrorMarkers(markers); + + last_log_version = currentVersion; + } + } + + // Dockspace + ImGuiID dockspace_id = ImGui::GetID("StudioDock"); + ImGui::DockSpaceOverViewport(dockspace_id, ImGui::GetMainViewport()); + + static bool first_run = true; + if (first_run) { + first_run = false; + ImGui::DockBuilderRemoveNode(dockspace_id); + ImGui::DockBuilderAddNode(dockspace_id, ImGuiDockNodeFlags_DockSpace); + ImGui::DockBuilderSetNodeSize(dockspace_id, ImGui::GetMainViewport()->Size); + + ImGuiID dock_left = ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Left, 0.22f, nullptr, &dockspace_id); + ImGuiID dock_right = ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Right, 0.28f, nullptr, &dockspace_id); + ImGuiID dock_down = ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Down, 0.25f, nullptr, &dockspace_id); + + ImGui::DockBuilderDockWindow("Explorer", dock_left); + ImGui::DockBuilderDockWindow("Inspector", dock_right); + ImGui::DockBuilderDockWindow("Logs", dock_down); + ImGui::DockBuilderDockWindow("Workspace", dockspace_id); + ImGui::DockBuilderFinish(dockspace_id); + } + + // File dialogs + ImGui::SetNextWindowSize(ImVec2(900, 600), ImGuiCond_FirstUseEver); + + if (ImGuiFileDialog::Instance()->Display("ChooseFileDlgKey")) { + if (ImGuiFileDialog::Instance()->IsOk()) { + state.LoadELF(ImGuiFileDialog::Instance()->GetFilePathName()); + } + ImGuiFileDialog::Instance()->Close(); + } + + if (ImGuiFileDialog::Instance()->Display("ImportGhidraKey")) { + if (ImGuiFileDialog::Instance()->IsOk()) { + std::string csvPath = ImGuiFileDialog::Instance()->GetFilePathName(); + state.ImportGhidraCSV(csvPath); + // Add to imported CSV list (avoid duplicates) + bool alreadyImported = false; + for (const auto& existing : state.data.importedCSVFiles) { + if (existing == csvPath) { alreadyImported = true; break; } + } + if (!alreadyImported) { + state.data.importedCSVFiles.push_back(csvPath); + } + state.data.selectedCSVIndex = static_cast(state.data.importedCSVFiles.size()) - 1; + ghidra_editor.SetText(state.data.ghidraCSVContent); + } + ImGuiFileDialog::Instance()->Close(); + } + + if (ImGuiFileDialog::Instance()->Display("ChooseDirDlgKey")) { + if (ImGuiFileDialog::Instance()->IsOk()) { + state.SetOutputDir(ImGuiFileDialog::Instance()->GetFilePathName()); + } + ImGuiFileDialog::Instance()->Close(); + } + + // ---- Main Menu Bar ---- + if (ImGui::BeginMainMenuBar()) { + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("Open ELF...", "Ctrl+O")) { + IGFD::FileDialogConfig config; + config.path = "."; + ImGuiFileDialog::Instance()->OpenDialog("ChooseFileDlgKey", "Choose File", ".*,.elf", config); + } + if (ImGui::MenuItem("Import Ghidra CSV...")) { + IGFD::FileDialogConfig config; + config.path = "."; + ImGuiFileDialog::Instance()->OpenDialog("ImportGhidraKey", "Choose CSV", ".csv", config); + } + if (ImGui::MenuItem("Set Output Directory...")) { + IGFD::FileDialogConfig config; + config.path = "."; + ImGuiFileDialog::Instance()->OpenDialog("ChooseDirDlgKey", "Choose Output Dir", nullptr, config); + } + ImGui::Separator(); + if (ImGui::MenuItem("Save Config", "Ctrl+S", false, state.data.isAnalysisComplete)) { + state.SaveConfigTOML(); + config_editor_needs_sync = true; + } + if (ImGui::MenuItem("Reload Config from Disk")) { + state.LoadConfigToml(); + config_editor_needs_sync = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Exit")) s_wantsQuit = true; + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Tools")) { + bool busy = state.isBusy.load(); + if (ImGui::MenuItem("Analyze", "F5", false, !busy && !state.data.elfPath.empty())) { + state.StartAnalysis(); + } + if (ImGui::MenuItem("Recompile", "F7", false, !busy && state.data.isAnalysisComplete)) { + state.StartRecompilation(); + } + ImGui::Separator(); + if (ImGui::MenuItem("Run System Diagnostics", nullptr, false, !busy)) { + state.RunDiagnostics(); + } + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Settings")) { + if (ImGui::MenuItem("Open Settings Window")) { + show_settings_window = true; + } + ImGui::EndMenu(); + } + + // Show output path in menu bar + std::string pathDisplay = state.GetEffectiveOutputPath(); + float pathWidth = ImGui::CalcTextSize(pathDisplay.c_str()).x; + if (ImGui::GetWindowWidth() > pathWidth + 300) { + ImGui::SameLine(ImGui::GetWindowWidth() - pathWidth - 20); + ImGui::TextDisabled("Output: %s", pathDisplay.c_str()); + } + + ImGui::EndMainMenuBar(); + } + + // ---- Keyboard Shortcuts ---- + { + ImGuiIO& io = ImGui::GetIO(); + bool noPopup = !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId); + if (noPopup) { + if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_O)) { + IGFD::FileDialogConfig config; + config.path = "."; + ImGuiFileDialog::Instance()->OpenDialog("ChooseFileDlgKey", "Choose File", ".*,.elf", config); + } + if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_S) && state.data.isAnalysisComplete) { + state.SaveConfigTOML(); + config_editor_needs_sync = true; + } + bool busy = state.isBusy.load(); + if (ImGui::IsKeyPressed(ImGuiKey_F5) && !busy && !state.data.elfPath.empty()) { + state.StartAnalysis(); + } + if (ImGui::IsKeyPressed(ImGuiKey_F7) && !busy && state.data.isAnalysisComplete) { + state.StartRecompilation(); + } + } + } + + DrawSettingsWindow(state); + + // ---- Explorer Panel ---- + ImGui::Begin("Explorer"); + ImGui::SetNextItemWidth(-1); + static char filterBuf[128] = ""; + ImGui::InputTextWithHint("##Search", "Search functions...", filterBuf, 128); + ImGui::Separator(); + + if (state.data.isAnalysisComplete && state.data.analyzer) { + ImGui::BeginChild("FuncList", ImVec2(0, 0), false); + const auto& funcs = state.data.analyzer->getFunctions(); + + // Build filtered index list so clipper works correctly + std::string filterStr(filterBuf); + static std::vector filteredIndices; + filteredIndices.clear(); + filteredIndices.reserve(funcs.size()); + for (int idx = 0; idx < static_cast(funcs.size()); idx++) { + if (filterStr.empty() || funcs[idx].name.find(filterStr) != std::string::npos) { + filteredIndices.push_back(idx); + } + } + + ImGuiListClipper clipper; + clipper.Begin(static_cast(filteredIndices.size())); + while (clipper.Step()) { + for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; row++) { + if (row < 0 || row >= static_cast(filteredIndices.size())) continue; + int i = filteredIndices[row]; + + const auto& func = funcs[i]; + + OverrideStatus status = OverrideStatus::Default; + auto it = state.data.funcOverrides.find(func.name); + if (it != state.data.funcOverrides.end()) { + status = it->second; + } + + ImVec4 color = ImGui::GetStyle().Colors[ImGuiCol_Text]; + if (status == OverrideStatus::Stub) { + color = ImVec4(1.0f, 0.4f, 0.4f, 1.0f); + } else if (status == OverrideStatus::Skip) { + color = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + } else if (status == OverrideStatus::ForceRecompile) { + color = ImVec4(0.4f, 1.0f, 0.4f, 1.0f); + } + + ImGui::PushStyleColor(ImGuiCol_Text, color); + bool isSelected = state.selectedFuncIndex == i; + if (ImGui::Selectable(func.name.c_str(), isSelected)) { + state.selectedFuncIndex = i; + std::stringstream codeSS; + codeSS << "// Function: " << func.name << "\n"; + codeSS << "// Address: " << FormatHex(func.start) << "\n"; + codeSS << "// Size: " << (func.end - func.start) << " bytes\n\n"; + codeSS << "void " << func.name << "() {\n"; + for (const auto& inst : func.instructions) { + codeSS << " // " << FormatHex(inst.address) + << ": [0x" << std::hex << inst.opcode << "]\n"; + } + codeSS << "}\n"; + code_editor.SetText(codeSS.str()); + + // Update hex view to highlight and jump to this function + if (!state.data.rawElfData.empty() && + func.start < state.data.rawElfData.size()) { + mem_edit.GotoAddrAndHighlight(func.start, + std::min(static_cast(func.end), state.data.rawElfData.size())); + } + } + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Start: %s", FormatHex(func.start).c_str()); + ImGui::Text("Size: %d bytes", (func.end - func.start)); + ImGui::EndTooltip(); + } + } + } + ImGui::EndChild(); + } else { + float winW = ImGui::GetWindowSize().x; + float winH = ImGui::GetWindowSize().y; + if (state.data.elfPath.empty()) { + const char* txt = "No ELF loaded"; + ImGui::SetCursorPos(ImVec2((winW - ImGui::CalcTextSize(txt).x) * 0.5f, winH * 0.4f)); + ImGui::TextDisabled("%s", txt); + } else { + const char* txt = "Ready to Analyze"; + ImGui::SetCursorPos(ImVec2((winW - ImGui::CalcTextSize(txt).x) * 0.5f, winH * 0.4f)); + ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f), "%s", txt); + ImGui::SetCursorPosX((winW - 120) * 0.5f); + if (ImGui::Button("Run Analysis", ImVec2(120, 30))) { + state.StartAnalysis(); + } + } + } + ImGui::End(); + + // ---- Inspector Panel ---- + ImGui::Begin("Inspector"); + if (state.data.analyzer && state.selectedFuncIndex >= 0) { + const auto& funcs = state.data.analyzer->getFunctions(); + if (state.selectedFuncIndex < static_cast(funcs.size())) { + const auto& func = funcs[state.selectedFuncIndex]; + + if (ImGui::GetIO().Fonts->Fonts.Size > 0) { + ImGui::PushFont(ImGui::GetIO().Fonts->Fonts[0]); + ImGui::TextColored(ImVec4(0.0f, 0.6f, 1.0f, 1.0f), "%s", func.name.c_str()); + ImGui::PopFont(); + } else { + ImGui::TextColored(ImVec4(0.0f, 0.6f, 1.0f, 1.0f), "%s", func.name.c_str()); + } + ImGui::Separator(); + ImGui::Spacing(); + + OverrideStatus current = OverrideStatus::Default; + auto it = state.data.funcOverrides.find(func.name); + if (it != state.data.funcOverrides.end()) { + current = it->second; + } + + const char* options[] = { "Auto (Default)", "Stub (Plug)", "Skip (Ignore)", "Force Recompile" }; + int idx = static_cast(current); + ImGui::TextDisabled("Strategy:"); + ImGui::SetNextItemWidth(-1); + if (ImGui::Combo("##Action", &idx, options, 4)) { + state.data.funcOverrides[func.name] = static_cast(idx); + state.Log("Strategy changed for " + func.name); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + if (ImGui::BeginTable("MetaTable", 2)) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextDisabled("Address:"); + ImGui::TableSetColumnIndex(1); + ImGui::Text("%s", FormatHex(func.start).c_str()); + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextDisabled("Size:"); + ImGui::TableSetColumnIndex(1); + ImGui::Text("%d bytes", (func.end - func.start)); + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextDisabled("Instructions:"); + ImGui::TableSetColumnIndex(1); + ImGui::Text("%zu", func.instructions.size()); + + ImGui::EndTable(); + } + } else { + state.selectedFuncIndex = -1; + ImGui::TextDisabled("Select a function to inspect"); + } + } else { + ImGui::TextDisabled("Select a function to inspect"); + } + ImGui::End(); + + // ---- Workspace Panel (Tabs) ---- + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.08f, 1.00f)); + ImGui::Begin("Workspace", nullptr, ImGuiWindowFlags_NoTitleBar); + + if (ImGui::BeginTabBar("Tabs", ImGuiTabBarFlags_Reorderable)) { + // C++ Preview Tab + if (ImGui::BeginTabItem(" C++ Preview ")) { + if (ImGui::BeginPopupContextItem("CppContext")) { + if (ImGui::MenuItem("Copy All")) { + ImGui::SetClipboardText(code_editor.GetText().c_str()); + state.Log("C++ code copied to clipboard"); + } + ImGui::EndPopup(); + } + code_editor.Render("Editor"); + ImGui::EndTabItem(); + } + + // Hex View Tab - with highlighting + if (ImGui::BeginTabItem(" Hex View ")) { + if (state.data.rawElfData.empty()) { + ImGui::TextDisabled("No binary data available (File not loaded or read error)"); + } else { + // Setup hex highlights based on selected function + hex_highlight_ranges.clear(); + if (state.data.analyzer && state.selectedFuncIndex >= 0) { + const auto& funcs = state.data.analyzer->getFunctions(); + if (state.selectedFuncIndex < static_cast(funcs.size())) { + const auto& func = funcs[state.selectedFuncIndex]; + if (func.start < state.data.rawElfData.size()) { + HexHighlightRange hl; + hl.start = func.start; + hl.end = std::min(static_cast(func.end), state.data.rawElfData.size()); + hl.color = IM_COL32(0, 120, 215, 80); + hl.label = "Function: " + func.name; + hex_highlight_ranges.push_back(hl); + } + } + } + + // Also highlight ELF header (first 52 bytes for 32-bit ELF) + if (state.data.rawElfData.size() >= 52) { + HexHighlightRange elfHeader; + elfHeader.start = 0; + elfHeader.end = 52; + elfHeader.color = IM_COL32(100, 180, 80, 40); + elfHeader.label = "ELF Header"; + hex_highlight_ranges.push_back(elfHeader); + } + + mem_edit.DrawContents(state.data.rawElfData.data(), state.data.rawElfData.size()); + } + ImGui::EndTabItem(); + } + + // config.toml Tab + if (ImGui::BeginTabItem(" config.toml ")) { + // Toolbar for config editor + if (ImGui::Button("Save")) { + state.SaveConfigTomlFromEditor(config_editor.GetText()); + } + ImGui::SameLine(); + if (ImGui::Button("Reload from Disk")) { + state.LoadConfigToml(); + config_editor.SetText(state.data.configTomlContent); + state.Log("config.toml reloaded from disk"); + } + ImGui::SameLine(); + if (ImGui::Button("Copy All")) { + ImGui::SetClipboardText(config_editor.GetText().c_str()); + state.Log("config.toml copied to clipboard"); + } + ImGui::SameLine(); + if (!state.configTomlPath.empty()) { + ImGui::TextDisabled("Path: %s", state.configTomlPath.c_str()); + } + ImGui::Separator(); + + config_editor.Render("ConfigEditor"); + ImGui::EndTabItem(); + } + + // Imported CSV Files Tab - ALWAYS visible + if (ImGui::BeginTabItem(" Ghidra CSV ")) { + // Import button always available + if (ImGui::Button("Import CSV...")) { + IGFD::FileDialogConfig config; + config.path = "."; + ImGuiFileDialog::Instance()->OpenDialog("ImportGhidraKey", "Choose CSV", ".csv", config); + } + + if (state.data.importedCSVFiles.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("No CSV files imported yet"); + } else { + ImGui::SameLine(); + ImGui::TextDisabled("(%zu files)", state.data.importedCSVFiles.size()); + } + ImGui::Separator(); + + // File list + if (!state.data.importedCSVFiles.empty()) { + ImGui::BeginChild("CSVList", ImVec2(0, 120), true); + for (size_t i = 0; i < state.data.importedCSVFiles.size(); i++) { + const auto& csvPath = state.data.importedCSVFiles[i]; + std::string filename = std::filesystem::path(csvPath).filename().string(); + + ImGui::PushID(static_cast(i)); + bool isSelected = state.data.selectedCSVIndex == static_cast(i); + if (ImGui::Selectable(filename.c_str(), isSelected)) { + state.data.selectedCSVIndex = static_cast(i); + try { + std::ifstream file(csvPath); + if (file) { + std::stringstream buffer; + buffer << file.rdbuf(); + std::string content = buffer.str(); + ghidra_editor.SetText(content); + state.data.ghidraCSVPath = csvPath; + state.data.ghidraCSVContent = content; + } + } catch (...) { + state.Log("Error reading CSV: " + csvPath); + } + } + + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("%s", csvPath.c_str()); + } + + ImGui::SameLine(); + if (ImGui::SmallButton("X")) { + state.data.importedCSVFiles.erase(state.data.importedCSVFiles.begin() + i); + if (state.data.selectedCSVIndex == static_cast(i)) { + state.data.selectedCSVIndex = -1; + ghidra_editor.SetText(""); + } else if (state.data.selectedCSVIndex > static_cast(i)) { + state.data.selectedCSVIndex--; + } + ImGui::PopID(); + break; // restart iteration next frame + } + ImGui::PopID(); + } + ImGui::EndChild(); + ImGui::Separator(); + + // Show content of selected CSV + if (state.data.selectedCSVIndex >= 0 && + state.data.selectedCSVIndex < static_cast(state.data.importedCSVFiles.size())) { + std::string selFileName = std::filesystem::path( + state.data.importedCSVFiles[state.data.selectedCSVIndex]).filename().string(); + ImGui::TextDisabled("Content: %s", selFileName.c_str()); + ImGui::Separator(); + ghidra_editor.Render("GhidraEditor"); + } + } + + ImGui::EndTabItem(); + } + + ImGui::EndTabBar(); + } + ImGui::End(); + ImGui::PopStyleColor(); + + // ---- Logs Panel ---- + ImGui::Begin("Logs"); + + // Status bar + { + std::string currentStatus = state.GetStatus(); + if (state.isBusy.load()) { + float progress = std::fmod((float)ImGui::GetTime(), 1.0f); + ImGui::ProgressBar(progress, ImVec2(-1, 6), ""); + ImGui::SameLine(); + ImGui::Text("%s", currentStatus.c_str()); + } else { + ImGui::TextDisabled("Status: %s", currentStatus.c_str()); + } + } + + // Log toolbar - right-aligned + { + float btnWidth = ImGui::CalcTextSize("Copy All").x + ImGui::CalcTextSize("Clear").x + + ImGui::GetStyle().FramePadding.x * 4 + ImGui::GetStyle().ItemSpacing.x * 2 + 20; + ImGui::SameLine(std::max(0.0f, ImGui::GetWindowWidth() - btnWidth)); + } + if (ImGui::SmallButton("Copy All")) { + std::lock_guard lock(state.stateMutex); + std::stringstream allLogs; + for (const auto& log : state.logs) { + allLogs << log << "\n"; + } + ImGui::SetClipboardText(allLogs.str().c_str()); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Clear")) { + std::lock_guard lock(state.stateMutex); + state.logs.clear(); + state.logVersion.fetch_add(1); + } + + ImGui::Separator(); + + // Log viewer using TextEditor - supports text selection, Ctrl+A, Ctrl+C, mouse drag + log_editor.Render("LogEditor"); + + ImGui::End(); +} diff --git a/ps2xStudio/src/main.cpp b/ps2xStudio/src/main.cpp new file mode 100644 index 00000000..ee9b6ff7 --- /dev/null +++ b/ps2xStudio/src/main.cpp @@ -0,0 +1,159 @@ +#include "imgui.h" +#include "imgui_impl_sdl2.h" +#include "imgui_impl_opengl3.h" +#include +#include +#include "GUI.hpp" +#include "StudioState.hpp" + +int main(int argc, char** argv) { + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) return -1; + + SDL_DisplayMode displayMode; + SDL_GetCurrentDisplayMode(0, &displayMode); + + int screenWidth = displayMode.w; + int screenHeight = displayMode.h; + + int windowWidth = (int)(screenWidth * 0.8f); + int windowHeight = (int)(screenHeight * 0.8f); + + SDL_WindowFlags window_flags = (SDL_WindowFlags)( + SDL_WINDOW_OPENGL | + SDL_WINDOW_RESIZABLE + ); + + // Pre-load window settings before StudioState construction + AppSettings tempSettings; + try { + std::ifstream file("studio_settings.ini"); + if (file) { + std::string line; + while (std::getline(file, line)) { + size_t pos = line.find('='); + if (pos != std::string::npos) { + std::string key = line.substr(0, pos); + std::string value = line.substr(pos + 1); + + if (key == "maximized") { + tempSettings.maximized = (value == "1"); + } else if (key == "windowWidth") { + tempSettings.windowWidth = std::stoi(value); + } else if (key == "windowHeight") { + tempSettings.windowHeight = std::stoi(value); + } + } + } + } + } catch(...) {} + + if (tempSettings.maximized) { + window_flags = (SDL_WindowFlags)(window_flags | SDL_WINDOW_MAXIMIZED); + } else { + windowWidth = tempSettings.windowWidth; + windowHeight = tempSettings.windowHeight; + } + + SDL_Window* window = SDL_CreateWindow( + "PS2Recomp Studio", + SDL_WINDOWPOS_CENTERED, + SDL_WINDOWPOS_CENTERED, + windowWidth, + windowHeight, + window_flags + ); + + if (!window) { + SDL_Quit(); + return -1; + } + + SDL_GLContext gl_context = SDL_GL_CreateContext(window); + SDL_GL_MakeCurrent(window, gl_context); + SDL_GL_SetSwapInterval(1); + + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGuiIO& io = ImGui::GetIO(); + io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; + + // Create StudioState AFTER ImGui context + StudioState state; + + // Initial font and theme setup + GUI::ApplySettings(state); + + ImGui_ImplSDL2_InitForOpenGL(window, gl_context); + ImGui_ImplOpenGL3_Init("#version 130"); + + if (argc > 1) { + state.StartAnalysis(argv[1]); + } + + bool done = false; + while (!done) { + SDL_Event event; + while (SDL_PollEvent(&event)) { + ImGui_ImplSDL2_ProcessEvent(&event); + if (event.type == SDL_QUIT) { + done = true; + } + if (event.type == SDL_WINDOWEVENT) { + if (event.window.event == SDL_WINDOWEVENT_RESIZED) { + state.settings.windowWidth = event.window.data1; + state.settings.windowHeight = event.window.data2; + } + if (event.window.event == SDL_WINDOWEVENT_MAXIMIZED) { + state.settings.maximized = true; + } + if (event.window.event == SDL_WINDOWEVENT_RESTORED) { + state.settings.maximized = false; + } + } + } + + // Check GUI quit request (File > Exit) + if (GUI::WantsQuit()) { + done = true; + } + + // CRITICAL: Handle deferred font rebuild BEFORE NewFrame + // This is the safe place to modify fonts - outside of the rendering frame + if (state.pendingFontRebuild.load()) { + // Rebuild fonts (clears atlas, adds fonts with new size) + GUI::RebuildFontsIfNeeded(state); + // Build the font atlas; the OpenGL3 backend will detect the + // change and upload the new texture automatically on NewFrame. + io.Fonts->Build(); + } + + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplSDL2_NewFrame(); + ImGui::NewFrame(); + + GUI::DrawStudio(state); + + ImGui::Render(); + glViewport(0, 0, (int)io.DisplaySize.x, (int)io.DisplaySize.y); + glClearColor(0.1f, 0.1f, 0.1f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + SDL_GL_SwapWindow(window); + } + + // Save settings before shutdown + state.SaveSettings(); + + if (state.workerThread.valid()) { + state.workerThread.wait(); + } + + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplSDL2_Shutdown(); + ImGui::DestroyContext(); + SDL_GL_DeleteContext(gl_context); + SDL_DestroyWindow(window); + SDL_Quit(); + + return 0; +} diff --git a/ps2xStudio/src/ui/StyleManager.cpp b/ps2xStudio/src/ui/StyleManager.cpp new file mode 100644 index 00000000..d167ed43 --- /dev/null +++ b/ps2xStudio/src/ui/StyleManager.cpp @@ -0,0 +1,204 @@ +#include "ui/StyleManager.hpp" +#include +#include +#include + +namespace StyleManager { + +void SetupFonts(AppSettings& settings) { + if (ImGui::GetCurrentContext() == nullptr) { + return; + } + + ImGuiIO& io = ImGui::GetIO(); + + // Validate and clamp font size + float safeFontSize = std::clamp(settings.fontSize, 10.0f, 48.0f); + settings.fontSize = safeFontSize; + + // Clear existing fonts + io.Fonts->Clear(); + + std::string fontPath = "external/Font/" + settings.selectedFont; + + ImFont* loadedFont = nullptr; + + if (settings.selectedFont != "Default" && std::filesystem::exists(fontPath)) { + loadedFont = io.Fonts->AddFontFromFileTTF(fontPath.c_str(), safeFontSize); + if (!loadedFont) { + // TTF loading failed, use default + ImFontConfig config; + config.SizePixels = safeFontSize; + loadedFont = io.Fonts->AddFontDefault(&config); + } + } else { + ImFontConfig config; + config.SizePixels = safeFontSize; + loadedFont = io.Fonts->AddFontDefault(&config); + } + + // Font atlas will be built automatically by the backend +} + +void ApplyDarkTheme() { + if (ImGui::GetCurrentContext() == nullptr) return; + + ImGuiStyle& style = ImGui::GetStyle(); + ImVec4* colors = style.Colors; + + const ImVec4 bgBase = ImVec4(0.08f, 0.08f, 0.08f, 1.00f); + const ImVec4 bgPanel = ImVec4(0.13f, 0.13f, 0.14f, 1.00f); + const ImVec4 bgInput = ImVec4(0.18f, 0.18f, 0.19f, 1.00f); + const ImVec4 accentMain = ImVec4(0.00f, 0.48f, 0.80f, 1.00f); + const ImVec4 accentHover = ImVec4(0.10f, 0.55f, 0.90f, 1.00f); + const ImVec4 accentActive = ImVec4(0.00f, 0.40f, 0.70f, 1.00f); + const ImVec4 textBright = ImVec4(1.00f, 1.00f, 1.00f, 1.00f); + const ImVec4 textDim = ImVec4(0.70f, 0.70f, 0.70f, 1.00f); + + colors[ImGuiCol_Text] = textBright; + colors[ImGuiCol_TextDisabled] = textDim; + colors[ImGuiCol_WindowBg] = bgBase; + colors[ImGuiCol_ChildBg] = bgPanel; + colors[ImGuiCol_PopupBg] = ImVec4(0.11f, 0.11f, 0.11f, 0.98f); + colors[ImGuiCol_Border] = ImVec4(0.30f, 0.30f, 0.30f, 0.60f); + colors[ImGuiCol_TitleBg] = ImVec4(0.06f, 0.06f, 0.06f, 1.00f); + colors[ImGuiCol_TitleBgActive] = ImVec4(0.05f, 0.05f, 0.05f, 1.00f); + colors[ImGuiCol_MenuBarBg] = ImVec4(0.15f, 0.15f, 0.16f, 1.00f); + colors[ImGuiCol_ScrollbarBg] = bgBase; + colors[ImGuiCol_ScrollbarGrab] = ImVec4(0.25f, 0.25f, 0.25f, 1.00f); + colors[ImGuiCol_ScrollbarGrabHovered] = ImVec4(0.35f, 0.35f, 0.35f, 1.00f); + colors[ImGuiCol_ScrollbarGrabActive] = ImVec4(0.45f, 0.45f, 0.45f, 1.00f); + colors[ImGuiCol_CheckMark] = accentMain; + colors[ImGuiCol_SliderGrab] = accentMain; + colors[ImGuiCol_SliderGrabActive] = accentActive; + colors[ImGuiCol_Button] = ImVec4(0.24f, 0.24f, 0.25f, 1.00f); + colors[ImGuiCol_ButtonHovered] = accentMain; + colors[ImGuiCol_ButtonActive] = accentActive; + colors[ImGuiCol_Header] = ImVec4(0.24f, 0.24f, 0.25f, 1.00f); + colors[ImGuiCol_HeaderHovered] = ImVec4(0.28f, 0.28f, 0.29f, 1.00f); + colors[ImGuiCol_HeaderActive] = ImVec4(0.20f, 0.20f, 0.21f, 1.00f); + colors[ImGuiCol_FrameBg] = bgInput; + colors[ImGuiCol_FrameBgHovered] = ImVec4(0.22f, 0.22f, 0.23f, 1.00f); + colors[ImGuiCol_FrameBgActive] = ImVec4(0.24f, 0.24f, 0.25f, 1.00f); + colors[ImGuiCol_Tab] = ImVec4(0.08f, 0.08f, 0.08f, 1.00f); + colors[ImGuiCol_TabHovered] = bgPanel; + colors[ImGuiCol_TabActive] = bgPanel; + colors[ImGuiCol_TabUnfocused] = ImVec4(0.08f, 0.08f, 0.08f, 1.00f); + colors[ImGuiCol_TabUnfocusedActive] = bgPanel; + colors[ImGuiCol_DockingPreview] = accentMain; + colors[ImGuiCol_DockingEmptyBg] = bgBase; + colors[ImGuiCol_TextSelectedBg] = ImVec4(0.00f, 0.48f, 0.80f, 0.40f); + colors[ImGuiCol_NavHighlight] = accentMain; +} + +void ApplyLightTheme() { + if (ImGui::GetCurrentContext() == nullptr) return; + + ImGuiStyle& style = ImGui::GetStyle(); + ImVec4* colors = style.Colors; + + const ImVec4 bgBase = ImVec4(0.95f, 0.95f, 0.95f, 1.00f); + const ImVec4 bgPanel = ImVec4(0.90f, 0.90f, 0.90f, 1.00f); + const ImVec4 bgInput = ImVec4(1.00f, 1.00f, 1.00f, 1.00f); + const ImVec4 accentMain = ImVec4(0.26f, 0.59f, 0.98f, 1.00f); + const ImVec4 accentHover = ImVec4(0.26f, 0.59f, 0.98f, 0.80f); + const ImVec4 accentActive = ImVec4(0.06f, 0.53f, 0.98f, 1.00f); + const ImVec4 textBright = ImVec4(0.00f, 0.00f, 0.00f, 1.00f); + const ImVec4 textDim = ImVec4(0.40f, 0.40f, 0.40f, 1.00f); + + colors[ImGuiCol_Text] = textBright; + colors[ImGuiCol_TextDisabled] = textDim; + colors[ImGuiCol_WindowBg] = bgBase; + colors[ImGuiCol_ChildBg] = bgPanel; + colors[ImGuiCol_PopupBg] = ImVec4(1.00f, 1.00f, 1.00f, 0.98f); + colors[ImGuiCol_Border] = ImVec4(0.70f, 0.70f, 0.70f, 0.60f); + colors[ImGuiCol_TitleBg] = ImVec4(0.96f, 0.96f, 0.96f, 1.00f); + colors[ImGuiCol_TitleBgActive] = ImVec4(0.82f, 0.82f, 0.82f, 1.00f); + colors[ImGuiCol_MenuBarBg] = ImVec4(0.86f, 0.86f, 0.86f, 1.00f); + colors[ImGuiCol_ScrollbarBg] = bgBase; + colors[ImGuiCol_ScrollbarGrab] = ImVec4(0.69f, 0.69f, 0.69f, 1.00f); + colors[ImGuiCol_ScrollbarGrabHovered] = ImVec4(0.59f, 0.59f, 0.59f, 1.00f); + colors[ImGuiCol_ScrollbarGrabActive] = ImVec4(0.49f, 0.49f, 0.49f, 1.00f); + colors[ImGuiCol_CheckMark] = accentMain; + colors[ImGuiCol_SliderGrab] = accentMain; + colors[ImGuiCol_SliderGrabActive] = accentActive; + colors[ImGuiCol_Button] = ImVec4(0.26f, 0.59f, 0.98f, 0.40f); + colors[ImGuiCol_ButtonHovered] = ImVec4(0.26f, 0.59f, 0.98f, 1.00f); + colors[ImGuiCol_ButtonActive] = ImVec4(0.06f, 0.53f, 0.98f, 1.00f); + colors[ImGuiCol_Header] = ImVec4(0.26f, 0.59f, 0.98f, 0.31f); + colors[ImGuiCol_HeaderHovered] = ImVec4(0.26f, 0.59f, 0.98f, 0.80f); + colors[ImGuiCol_HeaderActive] = ImVec4(0.26f, 0.59f, 0.98f, 1.00f); + colors[ImGuiCol_FrameBg] = bgInput; + colors[ImGuiCol_FrameBgHovered] = ImVec4(0.26f, 0.59f, 0.98f, 0.40f); + colors[ImGuiCol_FrameBgActive] = ImVec4(0.26f, 0.59f, 0.98f, 0.67f); + colors[ImGuiCol_Tab] = ImVec4(0.76f, 0.80f, 0.84f, 0.93f); + colors[ImGuiCol_TabHovered] = ImVec4(0.26f, 0.59f, 0.98f, 0.80f); + colors[ImGuiCol_TabActive] = ImVec4(0.60f, 0.73f, 0.88f, 1.00f); + colors[ImGuiCol_TabUnfocused] = ImVec4(0.92f, 0.93f, 0.94f, 0.98f); + colors[ImGuiCol_TabUnfocusedActive] = ImVec4(0.74f, 0.82f, 0.91f, 1.00f); + colors[ImGuiCol_DockingPreview] = accentMain; + colors[ImGuiCol_DockingEmptyBg] = ImVec4(0.20f, 0.20f, 0.20f, 1.00f); + colors[ImGuiCol_TextSelectedBg] = ImVec4(0.26f, 0.59f, 0.98f, 0.35f); + colors[ImGuiCol_NavHighlight] = accentMain; +} + +void ApplyCustomTheme(AppSettings& settings) { + if (ImGui::GetCurrentContext() == nullptr) return; + + ImGuiStyle& style = ImGui::GetStyle(); + ImVec4* colors = style.Colors; + + ImVec4 bgBase(settings.customBgBase[0], settings.customBgBase[1], + settings.customBgBase[2], settings.customBgBase[3]); + ImVec4 accent(settings.customAccent[0], settings.customAccent[1], + settings.customAccent[2], settings.customAccent[3]); + + colors[ImGuiCol_WindowBg] = bgBase; + colors[ImGuiCol_Button] = accent; + colors[ImGuiCol_ButtonHovered] = ImVec4(accent.x + 0.1f, accent.y + 0.1f, accent.z + 0.1f, accent.w); + colors[ImGuiCol_ButtonActive] = ImVec4(accent.x - 0.1f, accent.y - 0.1f, accent.z - 0.1f, accent.w); +} + +void ApplyTheme(ThemeMode mode, AppSettings& settings) { + if (ImGui::GetCurrentContext() == nullptr) return; + + ImGuiStyle& style = ImGui::GetStyle(); + + float safeScale = std::clamp(settings.uiScale, 0.5f, 3.0f); + + style.WindowPadding = ImVec2(8 * safeScale, 8 * safeScale); + style.FramePadding = ImVec2(5 * safeScale, 4 * safeScale); + style.CellPadding = ImVec2(4 * safeScale, 4 * safeScale); + style.ItemSpacing = ImVec2(8 * safeScale, 6 * safeScale); + style.ItemInnerSpacing = ImVec2(6 * safeScale, 6 * safeScale); + style.IndentSpacing = 20 * safeScale; + style.ScrollbarSize = 12 * safeScale; + style.GrabMinSize = 10 * safeScale; + style.WindowBorderSize = 1 * safeScale; + style.ChildBorderSize = 1 * safeScale; + style.PopupBorderSize = 1 * safeScale; + style.FrameBorderSize = 0; + style.TabBorderSize = 0; + style.WindowRounding = 5.0f * safeScale; + style.ChildRounding = 5.0f * safeScale; + style.FrameRounding = 3.0f * safeScale; + style.PopupRounding = 5.0f * safeScale; + style.ScrollbarRounding = 9.0f * safeScale; + style.GrabRounding = 3.0f * safeScale; + style.TabRounding = 5.0f * safeScale; + + switch(mode) { + case ThemeMode::Dark: + ApplyDarkTheme(); + break; + case ThemeMode::Light: + ApplyLightTheme(); + break; + case ThemeMode::Custom: + ApplyDarkTheme(); + ApplyCustomTheme(settings); + break; + } +} + +} // namespace StyleManager diff --git a/ps2xTest/CMakeLists.txt b/ps2xTest/CMakeLists.txt index 2cd08bbb..76694aef 100644 --- a/ps2xTest/CMakeLists.txt +++ b/ps2xTest/CMakeLists.txt @@ -5,8 +5,8 @@ project(ps2xTest LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) -add_executable(ps2x_tests - src/main.cpp +# Static library with test logic (no main), used by ps2xStudio +add_library(ps2_test_lib STATIC src/code_generator_tests.cpp src/r5900_decoder_tests.cpp src/elf_analyzer_tests.cpp @@ -14,9 +14,28 @@ add_executable(ps2x_tests src/ps2_recompiler_tests.cpp ) +target_include_directories(ps2_test_lib PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/ps2xRecomp/include + ${CMAKE_SOURCE_DIR}/ps2xAnalyzer/include + ${CMAKE_SOURCE_DIR}/ps2xRuntime/include +) + +target_link_libraries(ps2_test_lib PRIVATE + ps2_recomp_lib + ps2_analyzer_lib + ps2_runtime +) + +add_executable(ps2x_tests + src/main.cpp +) + option(PRINT_GENERATED_CODE "Print generated code in tests" OFF) if(PRINT_GENERATED_CODE) + target_compile_definitions(ps2x_tests PRIVATE PRINT_GENERATED_CODE) + target_compile_definitions(ps2_test_lib PRIVATE PRINT_GENERATED_CODE) endif() target_include_directories(ps2x_tests PRIVATE @@ -27,6 +46,7 @@ target_include_directories(ps2x_tests PRIVATE ) target_link_libraries(ps2x_tests PRIVATE + ps2_test_lib ps2_recomp_lib ps2_analyzer_lib ps2_runtime