diff --git a/CMakeLists.txt b/CMakeLists.txt index 09b1906f7a..a0076d2af5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,7 +7,7 @@ CHECK_INCLUDE_FILES ("gdb/jit-reader.h" HAVE_GDB_JIT_READER_H) option(BUILD_TESTS "Build unit tests to ensure sanity" TRUE) option(BUILD_FEX_LINUX_TESTS "Build FEXLinuxTests, requires x86 compiler" FALSE) option(BUILD_THUNKS "Build thunks" FALSE) -option(BUILD_FEXCONFIG "Build FEXConfig, requires SDL2 and X11" TRUE) +set(USE_FEXCONFIG_TOOLKIT "imgui" CACHE STRING "If set, build FEXConfig (qt or imgui)") option(ENABLE_CLANG_THUNKS "Build thunks with clang" FALSE) option(ENABLE_IWYU "Enables include what you use program" FALSE) option(ENABLE_LTO "Enable LTO with compilation" TRUE) @@ -304,8 +304,10 @@ endif() set(FMT_INSTALL OFF) add_subdirectory(External/fmt/) -add_subdirectory(External/imgui/) -include_directories(External/imgui/) +if (USE_FEXCONFIG_TOOLKIT STREQUAL "imgui") + add_subdirectory(External/imgui/) + include_directories(External/imgui/) +endif() add_subdirectory(External/tiny-json/) include_directories(External/tiny-json/) diff --git a/Source/Tools/CMakeLists.txt b/Source/Tools/CMakeLists.txt index 3acb0ca665..2b41cb8b10 100644 --- a/Source/Tools/CMakeLists.txt +++ b/Source/Tools/CMakeLists.txt @@ -1,10 +1,16 @@ add_subdirectory(CommonTools) if (NOT MINGW_BUILD) - if (BUILD_FEXCONFIG) + if (USE_FEXCONFIG_TOOLKIT STREQUAL "imgui") add_subdirectory(FEXConfig/) - endif() + elseif (USE_FEXCONFIG_TOOLKIT STREQUAL "qt") + find_package(Qt6 COMPONENTS Qml Quick Widgets) + if (NOT Qt6_FOUND) + find_package(Qt5 COMPONENTS Qml Quick Widgets REQUIRED) + endif() + add_subdirectory(FEXQonfig/) + endif() if (ENABLE_GDB_SYMBOLS) add_subdirectory(FEXGDBReader/) diff --git a/Source/Tools/FEXQonfig/CMakeLists.txt b/Source/Tools/FEXQonfig/CMakeLists.txt new file mode 100644 index 0000000000..145a016347 --- /dev/null +++ b/Source/Tools/FEXQonfig/CMakeLists.txt @@ -0,0 +1,28 @@ +set(CMAKE_AUTOMOC ON) + +add_executable(FEXConfig) +target_sources(FEXConfig PRIVATE Main.cpp Main.h) +target_include_directories(FEXConfig PRIVATE ${CMAKE_SOURCE_DIR}/Source/) +target_link_libraries(FEXConfig PRIVATE Common) +if (Qt6_FOUND) + qt_add_resources(QT_RESOURCES qml6.qrc) + target_link_libraries(FEXConfig PRIVATE Qt6::Qml Qt6::Quick Qt6::Widgets) +else() + qt_add_resources(QT_RESOURCES qml5.qrc) + target_link_libraries(FEXConfig PRIVATE Qt5::Qml Qt5::Quick Qt5::Widgets) +endif() +target_sources(FEXConfig PRIVATE ${QT_RESOURCES}) + +if (CMAKE_BUILD_TYPE MATCHES "RELEASE") + target_link_options(FEXConfig + PRIVATE + "LINKER:--gc-sections" + "LINKER:--strip-all" + "LINKER:--as-needed" + ) +endif() + +install(TARGETS FEXConfig + RUNTIME + DESTINATION bin + COMPONENT runtime) diff --git a/Source/Tools/FEXQonfig/Main.cpp b/Source/Tools/FEXQonfig/Main.cpp new file mode 100644 index 0000000000..008991431b --- /dev/null +++ b/Source/Tools/FEXQonfig/Main.cpp @@ -0,0 +1,368 @@ +#include "Main.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +namespace fextl { +// Helper to convert a std::filesystem::path to a fextl::string. +inline fextl::string string_from_path(const std::filesystem::path& Path) { + return Path.string().c_str(); +} +} // namespace fextl + +static fextl::unique_ptr LoadedConfig {}; +static fextl::map> ConfigToNameLookup; +static fextl::map NameToConfigLookup; + +ConfigModel::ConfigModel() { + setItemRoleNames(QHash {{Qt::DisplayRole, "display"}, {Qt::UserRole + 1, "optionType"}, {Qt::UserRole + 2, "optionValue"}}); + Reload(); +} + +void ConfigModel::Reload() { + auto Options = LoadedConfig->GetOptionMap(); + + beginResetModel(); + removeRows(0, rowCount()); + for (auto& Option : Options) { + if (!LoadedConfig->OptionExists(Option.first)) { + continue; + } + + auto& [Name, TypeId] = ConfigToNameLookup.find(Option.first)->second; + auto Item = new QStandardItem(QString::fromStdString(Name)); + + const char* OptionType = TypeId.data(); + Item->setData(OptionType, Qt::UserRole + 1); + Item->setData(QString::fromStdString(Option.second.front().c_str()), Qt::UserRole + 2); + appendRow(Item); + } + endResetModel(); +} + +bool ConfigModel::has(const QString& Name, bool) const { + auto Options = LoadedConfig->GetOptionMap(); + return LoadedConfig->OptionExists(NameToConfigLookup.at(Name.toStdString())); +} + +void ConfigModel::erase(const QString& Name) { + assert(has(Name, false)); + auto Options = LoadedConfig->GetOptionMap(); + LoadedConfig->Erase(NameToConfigLookup.at(Name.toStdString())); + Reload(); +} + +bool ConfigModel::getBool(const QString& Name, bool) const { + auto Options = LoadedConfig->GetOptionMap(); + + auto ret = LoadedConfig->Get(NameToConfigLookup.at(Name.toStdString())); + if (!ret || !*ret) { + throw std::runtime_error("Could not find setting"); + } + return **ret == "1"; +} + +void ConfigModel::setBool(const QString& Name, bool Value) { + auto Options = LoadedConfig->GetOptionMap(); + LoadedConfig->EraseSet(NameToConfigLookup.at(Name.toStdString()), Value ? "1" : "0"); + Reload(); +} + +void ConfigModel::setString(const QString& Name, const QString& Value) { + auto Options = LoadedConfig->GetOptionMap(); + LoadedConfig->EraseSet(NameToConfigLookup.at(Name.toStdString()), Value.toStdString()); + Reload(); +} + +void ConfigModel::setStringList(const QString& Name, const QStringList& Values) { + auto Options = LoadedConfig->GetOptionMap(); + + const auto& Option = NameToConfigLookup.at(Name.toStdString()); + LoadedConfig->Erase(Option); + for (auto& Value : Values) { + LoadedConfig->Set(Option, Value.toStdString().c_str()); + } + Reload(); +} + +void ConfigModel::setInt(const QString& Name, int Value) { + auto Options = LoadedConfig->GetOptionMap(); + LoadedConfig->EraseSet(NameToConfigLookup.at(Name.toStdString()), std::to_string(Value)); + Reload(); +} + +QString ConfigModel::getString(const QString& Name, bool) const { + auto Options = LoadedConfig->GetOptionMap(); + + auto ret = LoadedConfig->Get(NameToConfigLookup.at(Name.toStdString())); + if (!ret || !*ret) { + throw std::runtime_error("Could not find setting"); + } + return QString::fromUtf8((*ret)->c_str()); +} + +QStringList ConfigModel::getStringList(const QString& Name, bool) const { + auto Options = LoadedConfig->GetOptionMap(); + + auto Values = LoadedConfig->All(NameToConfigLookup.at(Name.toStdString())); + if (!Values || !*Values) { + return {}; + } + QStringList Ret; + for (auto& Value : **Values) { + Ret.append(Value.c_str()); + } + return Ret; +} + +int ConfigModel::getInt(const QString& Name, bool) const { + auto Options = LoadedConfig->GetOptionMap(); + + auto ret = LoadedConfig->Get(NameToConfigLookup.at(Name.toStdString())); + if (!ret || !*ret) { + throw std::runtime_error("Could not find setting"); + } + int value; + auto res = std::from_chars(&*(*ret)->begin(), &*(*ret)->end(), value); + if (res.ptr != &*(*ret)->end()) { + throw std::runtime_error("Could not parse integer"); + } + return value; +} + +static void LoadDefaultSettings() { + LoadedConfig = fextl::make_unique(); +#define OPT_BASE(type, group, enum, json, default) LoadedConfig->Set(FEXCore::Config::ConfigOption::CONFIG_##enum, std::to_string(default)); +#define OPT_STR(group, enum, json, default) LoadedConfig->Set(FEXCore::Config::ConfigOption::CONFIG_##enum, default); +#define OPT_STRARRAY(group, enum, json, default) // Do nothing +#define OPT_STRENUM(group, enum, json, default) \ + LoadedConfig->Set(FEXCore::Config::ConfigOption::CONFIG_##enum, std::to_string(FEXCore::ToUnderlying(default))); +#include + + // Erase unnamed options which shouldn't be set + LoadedConfig->Erase(FEXCore::Config::ConfigOption::CONFIG_IS_INTERPRETER); + LoadedConfig->Erase(FEXCore::Config::ConfigOption::CONFIG_INTERPRETER_INSTALLED); + LoadedConfig->Erase(FEXCore::Config::ConfigOption::CONFIG_APP_FILENAME); + LoadedConfig->Erase(FEXCore::Config::ConfigOption::CONFIG_APP_CONFIG_NAME); + LoadedConfig->Erase(FEXCore::Config::ConfigOption::CONFIG_IS64BIT_MODE); +} + +static void ConfigInit(fextl::string ConfigFilename) { +#define OPT_BASE(type, group, enum, json, default) \ + ConfigToNameLookup[FEXCore::Config::ConfigOption::CONFIG_##enum].first = #json; \ + ConfigToNameLookup[FEXCore::Config::ConfigOption::CONFIG_##enum].second = #type; \ + NameToConfigLookup[#json] = FEXCore::Config::ConfigOption::CONFIG_##enum; +#include +#undef OPT_BASE + + // Ensure config and RootFS directories exist + std::error_code ec {}; + fextl::string Dirs[] = {FHU::Filesystem::ParentPath(ConfigFilename), FEXCore::Config::GetDataDirectory() + "RootFS/"}; + for (auto& Dir : Dirs) { + bool created = std::filesystem::create_directories(Dir, ec); + if (created) { + qInfo() << "Created folder" << Dir.c_str(); + } + if (ec) { + QMessageBox err(QMessageBox::Critical, "Failed to create directory", QString("Failed to create \"%1\" folder").arg(Dir.c_str()), + QMessageBox::Ok); + err.exec(); + std::exit(EXIT_FAILURE); + return; + } + } +} + +RootFSModel::RootFSModel() { + INotifyFD = inotify_init1(IN_NONBLOCK | IN_CLOEXEC); + + fextl::string RootFS = FEXCore::Config::GetDataDirectory() + "RootFS/"; + FolderFD = inotify_add_watch(INotifyFD, RootFS.c_str(), IN_CREATE | IN_DELETE); + if (FolderFD != -1) { + Thread = std::thread {&RootFSModel::INotifyThreadFunc, this}; + } else { + qWarning() << "Could not set up inotify. RootFS folder won't be monitored for changes."; + } + + // Load initial data + Reload(); +} + +RootFSModel::~RootFSModel() { + close(INotifyFD); + INotifyFD = -1; + + ExitRequest.count_down(); + Thread.join(); +} + +void RootFSModel::Reload() { + beginResetModel(); + removeRows(0, rowCount()); + + fextl::string RootFS = FEXCore::Config::GetDataDirectory() + "RootFS/"; + std::vector NamedRootFS {}; + for (auto& it : std::filesystem::directory_iterator(RootFS)) { + if (it.is_directory()) { + NamedRootFS.push_back(QString::fromStdString(it.path().filename())); + } else if (it.is_regular_file()) { + // If it is a regular file then we need to check if it is a valid archive + if (it.path().extension() == ".sqsh" && FEX::FormatCheck::IsSquashFS(fextl::string_from_path(it.path()))) { + NamedRootFS.push_back(QString::fromStdString(it.path().filename())); + } else if (it.path().extension() == ".ero" && FEX::FormatCheck::IsEroFS(fextl::string_from_path(it.path()))) { + NamedRootFS.push_back(QString::fromStdString(it.path().filename())); + } + } + } + std::sort(NamedRootFS.begin(), NamedRootFS.end(), [](const QString& a, const QString& b) { return QString::localeAwareCompare(a, b) < 0; }); + for (auto& Entry : NamedRootFS) { + appendRow(new QStandardItem(Entry)); + } + + endResetModel(); +} + +bool RootFSModel::hasItem(const QString& Name) const { + return !findItems(Name, Qt::MatchExactly).empty(); +} + +QUrl RootFSModel::getBaseUrl() const { + return QUrl::fromLocalFile(QString::fromStdString(FEXCore::Config::GetDataDirectory().c_str()) + "RootFS/"); +} + +void RootFSModel::INotifyThreadFunc() { + while (!ExitRequest.try_wait()) { + constexpr size_t DATA_SIZE = (16 * (sizeof(struct inotify_event) + NAME_MAX + 1)); + char buf[DATA_SIZE]; + int Ret {}; + do { + fd_set Set {}; + FD_ZERO(&Set); + FD_SET(INotifyFD, &Set); + struct timeval tv {}; + // 50 ms + tv.tv_usec = 50000; + Ret = select(INotifyFD + 1, &Set, nullptr, nullptr, &tv); + } while (Ret == 0 && INotifyFD != -1); + + if (Ret == -1 || INotifyFD == -1) { + // Just return on error + return; + } + + // Spin through the events, we don't actually care what they are + while (read(INotifyFD, buf, DATA_SIZE) > 0) + ; + + // Queue update to the data model + QMetaObject::invokeMethod(this, "Reload"); + } +} + +// Returns true on success +static bool OpenFile(fextl::string Filename) { + std::error_code ec {}; + if (!std::filesystem::exists(Filename, ec)) { + return false; + } + + LoadedConfig = FEX::Config::CreateMainLayer(&Filename); + LoadedConfig->Load(); + + // Load default options and only overwrite only if the option didn't exist +#define OPT_BASE(type, group, enum, json, default) \ + if (!LoadedConfig->OptionExists(FEXCore::Config::ConfigOption::CONFIG_##enum)) { \ + LoadedConfig->EraseSet(FEXCore::Config::ConfigOption::CONFIG_##enum, std::to_string(default)); \ + } +#define OPT_STR(group, enum, json, default) \ + if (!LoadedConfig->OptionExists(FEXCore::Config::ConfigOption::CONFIG_##enum)) { \ + LoadedConfig->EraseSet(FEXCore::Config::ConfigOption::CONFIG_##enum, default); \ + } +#define OPT_STRARRAY(group, enum, json, default) // Do nothing +#define OPT_STRENUM(group, enum, json, default) \ + if (!LoadedConfig->OptionExists(FEXCore::Config::ConfigOption::CONFIG_##enum)) { \ + LoadedConfig->EraseSet(FEXCore::Config::ConfigOption::CONFIG_##enum, std::to_string(FEXCore::ToUnderlying(default))); \ + } +#include + + // Erase unnamed options which shouldn't be set + LoadedConfig->Erase(FEXCore::Config::ConfigOption::CONFIG_IS_INTERPRETER); + LoadedConfig->Erase(FEXCore::Config::ConfigOption::CONFIG_INTERPRETER_INSTALLED); + LoadedConfig->Erase(FEXCore::Config::ConfigOption::CONFIG_APP_FILENAME); + LoadedConfig->Erase(FEXCore::Config::ConfigOption::CONFIG_APP_CONFIG_NAME); + LoadedConfig->Erase(FEXCore::Config::ConfigOption::CONFIG_IS64BIT_MODE); + + return true; +} + +ConfigRuntime::ConfigRuntime(const QString& ConfigFilename) { + qmlRegisterSingletonInstance("FEX.ConfigModel", 1, 0, "ConfigModel", &ConfigModelInst); + qmlRegisterSingletonInstance("FEX.RootFSModel", 1, 0, "RootFSModel", &RootFSList); + Engine.load(QUrl("qrc:/main.qml")); + + Window = qobject_cast(Engine.rootObjects().first()); + if (!ConfigFilename.isEmpty()) { + Window->setProperty("configFilename", QUrl::fromLocalFile(ConfigFilename)); + } else { + Window->setProperty("configDirty", true); + } + + ConfigRuntime::connect(Window, SIGNAL(selectedConfigFile(const QUrl&)), this, SLOT(onLoad(const QUrl&))); + ConfigRuntime::connect(Window, SIGNAL(triggeredSave(const QUrl&)), this, SLOT(onSave(const QUrl&))); + ConfigRuntime::connect(&ConfigModelInst, SIGNAL(modelReset()), Window, SLOT(refreshUI())); +} + +void ConfigRuntime::onSave(const QUrl& Filename) { + qInfo() << "Saving to" << Filename.toLocalFile().toStdString().c_str(); + FEX::Config::SaveLayerToJSON(Filename.toLocalFile().toStdString().c_str(), LoadedConfig.get()); +} + +void ConfigRuntime::onLoad(const QUrl& Filename) { + // TODO: Distinguish between "load" and "overlay". + // Currently, the new configuration is overlaid on top of the previous one. + + if (!OpenFile(Filename.toLocalFile().toStdString().c_str())) { + // This basically never happens because OpenFile performs no actual syntax checks. + // Treat as fatal since the UI state wouldn't be consistent after ignoring the error. + QMessageBox err(QMessageBox::Critical, tr("Could not load config file"), tr("Failed to load \"%1\"").arg(Filename.toLocalFile()), + QMessageBox::Ok); + err.exec(); + QApplication::exit(); + return; + } + + ConfigModelInst.Reload(); + RootFSList.Reload(); + + QMetaObject::invokeMethod(Window, "refreshUI"); +} + +int main(int Argc, char** Argv) { + QApplication App(Argc, Argv); + + FEX::Config::InitializeConfigs(); + fextl::string ConfigFilename = Argc > 1 ? Argv[1] : FEXCore::Config::GetConfigFileLocation(); + ConfigInit(ConfigFilename); + + qInfo() << "Opening" << ConfigFilename.c_str(); + if (!OpenFile(ConfigFilename)) { + // Load defaults if not found + ConfigFilename.clear(); + LoadDefaultSettings(); + } + + ConfigRuntime Runtime(ConfigFilename.c_str()); + + return App.exec(); +} diff --git a/Source/Tools/FEXQonfig/Main.h b/Source/Tools/FEXQonfig/Main.h new file mode 100644 index 0000000000..3f22571ab4 --- /dev/null +++ b/Source/Tools/FEXQonfig/Main.h @@ -0,0 +1,75 @@ +#include +#include + +#include +#include + +class QQuickWindow; + +class ConfigModel : public QStandardItemModel { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + +public: + ConfigModel(); + + void Reload(); + +public slots: + bool has(const QString&, bool unused) const; + void erase(const QString&); + + bool getBool(const QString&, bool unused) const; + QString getString(const QString&, bool unused) const; + QStringList getStringList(const QString&, bool unused) const; + int getInt(const QString&, bool unused) const; + + void setBool(const QString&, bool); + void setString(const QString&, const QString&); + void setStringList(const QString&, const QStringList&); + void setInt(const QString&, int value); +}; + +class RootFSModel : public QStandardItemModel { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + std::thread Thread; + std::latch ExitRequest {1}; + + int INotifyFD; + int FolderFD; + + void INotifyThreadFunc(); + +public: + RootFSModel(); + ~RootFSModel(); + +public slots: + void Reload(); + + bool hasItem(const QString&) const; + + QUrl getBaseUrl() const; +}; + +#include + +class ConfigRuntime : public QObject { + Q_OBJECT + + QQmlApplicationEngine Engine; + QQuickWindow* Window = nullptr; + RootFSModel RootFSList; + ConfigModel ConfigModelInst; + +public: + ConfigRuntime(const QString& ConfigFilename); + +public slots: + void onSave(const QUrl&); + void onLoad(const QUrl&); +}; diff --git a/Source/Tools/FEXQonfig/main.qml b/Source/Tools/FEXQonfig/main.qml new file mode 100644 index 0000000000..72674d5232 --- /dev/null +++ b/Source/Tools/FEXQonfig/main.qml @@ -0,0 +1,903 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import FEX.ConfigModel 1.0 +import FEX.RootFSModel 1.0 + +// Qt 6 changed the API of the Dialogs module slightly. +// The differences are abstracted away in this import: +import "qrc:/dialogs" + +ApplicationWindow { + id: root + + visible: true + width: 540 + height: 585 + minimumWidth: 500 + minimumHeight: 450 + title: configDirty ? qsTr("FEX configuration *") : qsTr("FEX configuration") + + property url configFilename + + property bool configDirty: false + property bool closeConfirmed: false + + signal selectedConfigFile(name: url) + signal triggeredSave(name: url) + + // Property used to force reloading any elements that read ConfigModel + property bool refreshCache: false + + function refreshUI() { + refreshCache = !refreshCache + } + + function urlToLocalFile(theurl: url): string { + var str = theurl.toString() + if (str.startsWith("file://")) { + return decodeURIComponent(str.substring(7)) + } + + return str; + } + + FileDialog { + id: openFileDialog + title: qsTr("Open FEX configuration") + nameFilters: [ qsTr("Config files(*.json)"), qsTr("All files(*)") ] + + property var onNextAccept: null + + // Prompts the user for an existing file and calls the callback on completion + function openAndThen(callback) { + this.selectExisting = true + console.assert(!onNextAccept, "Tried to open dialog multiple times") + onNextAccept = callback + open() + } + + // Prompts the user for a new or existing file and calls the callback on completion + function openNewAndThen(callback) { + this.selectExisting = false + console.assert(!onNextAccept, "Tried to open dialog multiple times") + onNextAccept = callback + open() + } + + onAccepted: { + root.selectedConfigFile(selectedFile) + configFilename = selectedFile + if (onNextAccept) { + onNextAccept() + onNextAccept = null + } + } + + onRejected: onNextAccept = null + } + + MessageDialog { + id: confirmCloseDialog + title: qsTr("Save changes") + text: configFilename.toString() === "" ? qsTr("Save changes before quitting?") : qsTr("Save changes to %1 before quitting?").arg(urlToLocalFile(configFilename)) + buttons: buttonSave | buttonDiscard | buttonCancel + + onButtonClicked: (button) => { + switch (button) { + case buttonSave: + if (configFilename.toString() === "") { + // Filename not yet set => trigger "Save As" dialog + openFileDialog.openNewAndThen(() => { + save(configFilename) + root.close() + }); + return + } + save(configFilename) + root.close() + break + + case buttonDiscard: + closeConfirmed = true + root.close() + break + } + } + } + + onClosing: (close) => { + if (configDirty) { + close.accepted = closeConfirmed + onTriggered: confirmCloseDialog.open() + } + } + + function save(filename: url) { + if (filename.toString() === "") { + filename = configFilename + } + + if (filename.toString() === "") { + // Filename not yet set => trigger "Save As" dialog + openFileDialog.openNewAndThen(() => { + save(configFilename) + }); + return + } + + triggeredSave(filename) + configDirty = false + } + + menuBar: MenuBar { + Menu { + title: qsTr("&File") + Action { + text: qsTr("&Open...") + shortcut: StandardKey.Open + // TODO: Ask to discard pending changes first + onTriggered: openFileDialog.openAndThen(() => {}) + } + Action { + text: qsTr("&Save") + shortcut: StandardKey.Save + onTriggered: root.save("") + } + Action { + text: qsTr("Save &as...") + shortcut: StandardKey.SaveAs + onTriggered: { + openFileDialog.openNewAndThen(() => { + root.save(configFilename) + }); + } + } + MenuSeparator {} + Action { + text: qsTr("&Quit") + shortcut: StandardKey.Quit + onTriggered: close() + } + } + } + + header: TabBar { + id: tabBar + currentIndex: 0 + + TabButton { + text: qsTr("General") + } + TabButton { + text: qsTr("Emulation") + } + TabButton { + text: qsTr("CPU") + } + TabButton { + text: qsTr("Advanced") + } + } + + component ConfigCheckBox: CheckBox { + property string config + property string tooltip + property bool invert: false + + ToolTip.visible: (visualFocus || hovered) && tooltip !== "" + ToolTip.text: tooltip + + onToggled: { + configDirty = true + ConfigModel.setBool(config, checked ^ invert) + } + + checkState: config === "" ? Qt.PartiallyChecked + : !ConfigModel.has(config, refreshCache) ? Qt.PartiallyChecked + : (ConfigModel.getBool(config, refreshCache) ^ invert) ? Qt.Checked + : Qt.Unchecked + } + + component ConfigSpinBox: SpinBox { + property string config + + textFromValue: (val) => { + if (valueFromConfig === "") { + return qsTr("(not set)"); + } + + return val.toString() + } + + onValueModified: { + configDirty = true + ConfigModel.setInt(config, value) + } + + property string valueFromConfig: config === "" ? 0 : ConfigModel.has(config, refreshCache) ? ConfigModel.getInt(config, refreshCache).toString() : "" + + value: valueFromConfig + from: 0 + to: 1 << 30 + } + + component ConfigTextField: TextField { + property string config + property bool hasData: config !== "" && ConfigModel.has(config, refreshCache) + text: hasData ? ConfigModel.getString(config, refreshCache) : "(none set)" + enabled: hasData + + onTextEdited: { + configDirty = true + ConfigModel.setString(config, text) + } + } + + component ConfigTextFieldForPath: RowLayout { + property string text + property string config + + property var dialog: FileDialog {} + + FileDialog { id: fileSelectorDialog } + + Label { text: parent.text } + ConfigTextField { + Layout.fillWidth: true + config: parent.config + readOnly: true + } + + Button { + icon.name: "search" + onClicked: dialog.open() + } + + Component.onCompleted: { + dialog.accepted.connect(() => { + var selectedPath = (dialog instanceof FileDialog ? dialog.selectedFile : dialog.selectedFolder) + + configDirty = true + ConfigModel.setString(config, urlToLocalFile(selectedPath)) + }) + } + } + + StackLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: tabBar.bottom + anchors.bottom: parent.bottom + + currentIndex: tabBar.currentIndex + + component ScrollablePage: ScrollView { + id: outer + + readonly property var visibleScrollbarWidth: ScrollBar.vertical.visible ? ScrollBar.vertical.width : 0 + + // Children given by the user will be moved into the inner Column + default property alias content: inner.children + + property alias itemSpacing: inner.spacing + + Column { + id: inner + + spacing: 8 + padding: 8 + + // This must be explicitly set via the id, since parent doesn't seem to be recognized within Column + width: outer.width - outer.visibleScrollbarWidth + } + } + + // Environment settings + ScrollablePage { + GroupBox { + id: rootfsGroupBox + title: qsTr("RootFS:") + width: parent.width - parent.padding * 2 + + ColumnLayout { + width: rootfsGroupBox.width - rootfsGroupBox.padding * 2 + ScrollView { + Layout.fillWidth: true + Layout.maximumHeight: 150 + clip: true + + Column { + id: rootfsList + + property string selectedItem + property string explicitEntry + + spacing: 4 + + Component.onCompleted: { + var initState = (ref) => { + selectedItem = ConfigModel.has("RootFS", ref) ? ConfigModel.getString("RootFS", ref) : "" + + // RootFSModel only lists entries in the $FEX_HOME/RootFS/ folder. + // If a custom path is selected, add it as a dedicated entry + if (selectedItem !== "" && !RootFSModel.hasItem(selectedItem)) { + explicitEntry = selectedItem + + // Make visible once needed. + // Conversely, if the user selects something else after, keep the old option visible to allow easy undoing + fallbackRootfsEntry.visible = true + } + } + + initState(false) + root.refreshCacheChanged.connect(initState) + } + + function updateRootFS(fileOrFolder: url) { + configDirty = true + var base = urlToLocalFile(RootFSModel.getBaseUrl()) + var file = urlToLocalFile(fileOrFolder) + if (file.startsWith(base)) { + file = file.substring(base.length) + } + + ConfigModel.setString("RootFS", file) + refreshUI() + } + + component RootFSRadioDelegate: RadioButton { + property var name + + text: name + checked: rootfsList.selectedItem === name + + onToggled: { + configDirty = true; + ConfigModel.setString("RootFS", name) + } + } + + RootFSRadioDelegate { + id: fallbackRootfsEntry + visible: false + name: rootfsList.explicitEntry + } + Repeater { + model: RootFSModel + delegate: RootFSRadioDelegate { name: model.display } + } + } + } + RowLayout { + FileDialog { + id: addRootfsFileDialog + title: qsTr("Select RootFS file") + nameFilters: [ qsTr("SquashFS and EroFS (*.sqsh *.ero)"), qsTr("All files(*)") ] + currentFolder: RootFSModel.getBaseUrl() + onAccepted: rootfsList.updateRootFS(fileUrl) + } + + FolderDialog { + id: addRootfsFolderDialog + title: qsTr("Select RootFS folder") + currentFolder: RootFSModel.getBaseUrl() + onAccepted: rootfsList.updateRootFS(selectedFolder) + } + + Button { + text: qsTr("Add archive") + icon.name: "document-open" + onClicked: addRootfsFileDialog.open() + } + Button { + text: qsTr("Add folder") + icon.name: "folder" + onClicked: addRootfsFolderDialog.open() + } + } + } + } + + GroupBox { + title: qsTr("Library forwarding:") + width: parent.width - parent.padding * 2 + + ColumnLayout { + anchors.left: parent ? parent.left : undefined + anchors.right: parent ? parent.right : undefined + + id: libfwdConfig + + property url configDir: (() => { + var configPath = urlToLocalFile(configFilename) + var slashIndex = configPath.lastIndexOf('/') + if (slashIndex === -1) { + return "" + } + return "file://" + configPath.substr(0, slashIndex) + })() + + ConfigTextFieldForPath { + text: qsTr("Configuration:") + config: "ThunkConfig" + dialog: FileDialog { + title: qsTr("Select library forwarding configuration") + nameFilters: [ qsTr("JSON files (*.json)"), qsTr("All files(*)") ] + currentFolder: libfwdConfig.configDir + } + } + ConfigTextFieldForPath { + text: qsTr("Host library folder:") + config: "ThunkHostLibs" + dialog: FolderDialog { + title: qsTr("Select path for host libraries") + currentFolder: libfwdConfig.configDir + } + } + ConfigTextFieldForPath { + text: qsTr("Guest library folder:") + config: "ThunkGuestLibs" + dialog: FolderDialog { + title: qsTr("Select path for guest libraries") + currentFolder: libfwdConfig.configDir + } + } + } + } + + GroupBox { + title: qsTr("Logging:") + width: parent.width - parent.padding * 2 + + label: ConfigCheckBox { + id: loggingEnabledCheckBox + config: "SilentLog" + text: qsTr("Logging:") + invert: true + } + + ColumnLayout { + enabled: loggingEnabledCheckBox.checked + + anchors.left: parent ? parent.left : undefined + anchors.right: parent ? parent.right : undefined + + RowLayout { + Label { text: qsTr("Log to:") } + + ComboBox { + id: loggingComboBox + property string configValue: ConfigModel.has("OutputLog", refreshCache) ? ConfigModel.getString("OutputLog", refreshCache) : "" + + currentIndex: configValue === "" ? -1 : configValue == "server" ? 0 : configValue == "stderr" ? 1 : configValue == "stdout" ? 2 : 3 + + onActivated: { + configDirty = true + var configNames = [ "server", "stderr", "stdout" ] + if (currentIndex != -1 && currentIndex < 3) { + ConfigModel.setString("OutputLog", configNames[currentIndex]) + } else { + // Set by text field below + } + } + + model: ListModel { + ListElement { text: "FEXServer" } + ListElement { text: "stderr" } + ListElement { text: "stdout" } + ListElement { text: qsTr("File...") } + } + } + + ConfigTextFieldForPath { + visible: loggingComboBox.currentIndex === 3 + config: "OutputLog" + } + } + } + } + } + + // Emulation settings + ScrollablePage { + RowLayout { + Label { text: qsTr("SMC detection:") } + ComboBox { + currentIndex: ConfigModel.has("SMCChecks", refreshCache) ? ConfigModel.getInt("SMCChecks", refreshCache) : -1 + + onActivated: { + configDirty = true + ConfigModel.setInt("SMCChecks", currentIndex) + } + + model: ListModel { + ListElement { text: qsTr("None") } + ListElement { text: qsTr("MTrack") } + ListElement { text: qsTr("Full") } + } + } + } + + GroupBox { + title: qsTr("Memory model:") + width: parent.width - parent.padding * 2 + + ColumnLayout { + anchors.left: parent ? parent.left : undefined + anchors.right: parent ? parent.right : undefined + + ButtonGroup { + id: tsoButtonGroup + buttons: [tso1, tso2, tso3] + // Trying to be too clever here will trigger property binding loops, + // so require both TSOEnabled and ParanoidTSO to be listed in the config. + // If they are not, the state will be displayed as undetermined. + checkedButton: !(ConfigModel.has("TSOEnabled", refreshCache) && (ConfigModel.has("ParanoidTSO", refreshCache))) ? null + : ConfigModel.getBool("ParanoidTSO", refreshCache) ? tso3 + : ConfigModel.getBool("TSOEnabled", refreshCache) ? tso2 : tso1 + + property int pendingItemChange: -1 + + function onClickedButton(index: int) { + pendingItemChange = index; + + configDirty = true; + + var newIndex = pendingItemChange + var TSOEnabled = newIndex === 1 + var ParanoidTSO = newIndex === 2 + ConfigModel.setBool("ParanoidTSO", ParanoidTSO) + ConfigModel.setBool("TSOEnabled", TSOEnabled) + + pendingItemChange = -1; + } + + onClicked: { + if (pendingItemChange !== -1) { + return; + } + pendingItemChange = tso1.checked ? 0 : tso2.checked ? 1 : tso3.checked ? 2 : -1; + if (pendingItemChange) { + // Undetermined state, leave as is + return; + } + + var newIndex = pendingItemChange + var TSOEnabled = newIndex === 1 + var ParanoidTSO = newIndex === 2 + ConfigModel.setBool("ParanoidTSO", ParanoidTSO) + ConfigModel.setBool("TSOEnabled", TSOEnabled) + + pendingItemChange = -1; + } + } + + ColumnLayout { + RadioButton { + id: tso1 + text: qsTr("Inaccurate") + onToggled: tsoButtonGroup.onClickedButton(0) + } + + ColumnLayout { + RadioButton { + id: tso2 + text: qsTr("Accurate (TSO)") + onToggled: tsoButtonGroup.onClickedButton(1) + } + + ColumnLayout { + visible: tso2.checked + + ConfigCheckBox { + leftPadding: 24 + text: qsTr("... for vector instructions") + tooltip: qsTr("Controls TSO emulation on vector load/store instructions") + config: "VectorTSOEnabled" + } + ConfigCheckBox { + leftPadding: 24 + text: qsTr("... for memcpy instructions") + tooltip: qsTr("Controls TSO emulation on memcpy/memset instructions") + config: "MemcpySetTSOEnabled" + } + ConfigCheckBox { + leftPadding: 24 + text: qsTr("... for unaligned half-barriers") + tooltip: qsTr("Controls half-barrier TSO emulation on unaligned load/store instructions") + config: "HalfBarrierTSOEnabled" + } + } + } + + RadioButton { + id: tso3 + text: qsTr("Overly accurate (paranoid TSO)") + onToggled: tsoButtonGroup.onClickedButton(2) + } + } + } + } + + component EnvVarList: GroupBox { + width: parent.width - parent.padding * 2 + + property bool ofHost: false + + ColumnLayout { + anchors.left: parent ? parent.left : undefined + anchors.right: parent ? parent.right : undefined + + spacing: 0 + + id: envGroup + property var values: ConfigModel.getStringList(ofHost ? "HostEnv" : "Env", refreshCache) + + property int editedIndex: -1 + Repeater { + model: parent.values + Layout.fillWidth: true + + RowLayout { + property bool isEditing: envGroup.editedIndex === index + + ItemDelegate { + text: modelData; + visible: !parent.isEditing + onClicked: envGroup.editedIndex = index + + } + TextField { + id: envVarEditTextField + visible: parent.isEditing; + text: modelData + + onEditingFinished: { + envGroup.editedIndex = -1 + if (text === modelData) { + return + } + + var newValues = envGroup.values + newValues[model.index] = text + configDirty = true + ConfigModel.setStringList(ofHost ? "HostEnv" : "Env", newValues) + } + } + Button { + visible: parent.isEditing + icon.name: "list-remove" + onClicked: { + envGroup.editedIndex = -1 + var newValues = [] + for (var i = 0; i < envGroup.values.length; ++i) { + if (i != index) { + newValues.push(envGroup.values[i]) + } + } + + configDirty = true + ConfigModel.setStringList(ofHost ? "HostEnv" : "Env", newValues) + } + } + } + } + + RowLayout { + TextField { + id: envVarTextField + Layout.fillWidth: true + + onAccepted: { + var newValues = envGroup.values + newValues.push(envVarTextField.text) + configDirty = true + ConfigModel.setStringList(ofHost ? "HostEnv" : "Env", newValues) + text = "" + } + } + Button { + icon.name : "list-add" + enabled: envVarTextField.text !== "" + onClicked: envVarTextField.onAccepted() + } + } + } + } + + EnvVarList { + title: qsTr("Guest environment variables:") + } + + EnvVarList { + title: qsTr("Host environment variables:") + ofHost: true + } + } + + // CPU settings + ScrollablePage { + ConfigCheckBox { + text: qsTr("Multiblock") + config: "Multiblock" + } + + RowLayout { + Layout.fillWidth: true + + Label { text: qsTr("Block size:") } + ConfigSpinBox { + config: "MaxInst" + from: 0 + to: 1 << 30 + } + } + + GroupBox { + title: qsTr("JIT caches:") + width: parent.width - parent.padding * 2 + + ColumnLayout { + anchors.left: parent ? parent.left : undefined + anchors.right: parent ? parent.right : undefined + + ConfigCheckBox { + text: qsTr("Generate AOT") + config: "AOTIRGenerate" + } + ConfigCheckBox { + text: qsTr("Capture AOT") + config: "AOTIRCapture" + } + ConfigCheckBox { + text: qsTr("Load AOT") + config: "AOTIRLoad" + } + + RowLayout { + Label { text: qsTr("Cache object code:") } + + ButtonGroup { + buttons: cacheObjCodeRadios.children + + checkedButton: ConfigModel.has("CacheObjectCodeCompilation", refreshCache) + ? cacheObjCodeRadios.children[ConfigModel.getInt("CacheObjectCodeCompilation", refreshCache)] + : null + + onClicked: (button) => { + configDirty = true + for (var idx in buttons) { + if (button === buttons[idx]) { + ConfigModel.setInt("CacheObjectCodeCompilation", idx) + return + } + } + } + } + + RowLayout { + id: cacheObjCodeRadios + RadioButton { + text: qsTr("Off") + } + RadioButton { + text: qsTr("Read-only") + } + RadioButton { + text: qsTr("Read & write") + } + } + } + } + } + + ConfigCheckBox { + text: qsTr("Reduced x87 precision") + config: "X87ReducedPrecision" + } + + ConfigCheckBox { + text: qsTr("Unsafe local flags optimization") + config: "ABILocalFlags" + } + + ConfigCheckBox { + text: qsTr("Disable JIT optimization passes") + config: "O0" + } + } + + // Advanced settings + // NOTE: This is wrapped in a Loader that dynamically instantiates/destroys the page contents whenever the tab is selected. + // This avoids costly UI updates for its UI elements. + // TODO: Options contained multiple times in JSON aren't listed (neither are they in old FEXConfig though) + Loader { sourceComponent: tabBar.currentIndex === 3 ? advancedSettingsPage : null } + Component { + id: advancedSettingsPage + ScrollablePage { + itemSpacing: 0 + Frame { + width: parent.width - parent.padding * 2 + id: frame + Column { + Repeater { + model: ConfigModel + delegate: RowLayout { + width: frame.width - frame.padding * 2 + + Label { + id: label + text: display + } + + ConfigCheckBox { + visible: optionType == "bool" + config: visible ? label.text : "" + } + + ConfigTextField { + Layout.fillWidth: true + visible: optionType == "fextl::string" + config: visible ? label.text : "" + } + + ConfigSpinBox { + visible: optionType.startsWith("int") || optionType.startsWith("uint") + config: visible ? label.text : "" + from: 0 + to: 1 << 30 + } + + // Spacing + Item { + Layout.fillWidth: true + } + + Button { + icon.name: "list-remove" + onClicked: { + ConfigModel.erase(label.text) + } + } + } + } + } + } + } + } + } + + footer: Pane { + anchors.left: parent.left + anchors.right: parent.right + + padding: 0 + + ColumnLayout { + anchors.left: parent.left + anchors.right: parent.right + spacing: 0 + + ToolSeparator { + Layout.fillWidth: true + orientation: Qt.Horizontal + + // Override padding from theme. + // Some themes use verticalPadding, others topPadding/bottomPadding, so we set them all. + verticalPadding: 0 + bottomPadding: 0 + topPadding: 0 + } + + Label { + Layout.alignment: Qt.AlignHCenter + enabled: false + text: configFilename.toString() === "" + ? qsTr("Config.json not found — loaded defaults") + : qsTr("Editing %1").arg(urlToLocalFile(configFilename)) + } + } + } +} diff --git a/Source/Tools/FEXQonfig/qml5.qrc b/Source/Tools/FEXQonfig/qml5.qrc new file mode 100644 index 0000000000..99fed556f8 --- /dev/null +++ b/Source/Tools/FEXQonfig/qml5.qrc @@ -0,0 +1,10 @@ + + + main.qml + + + qt5/FileDialog.qml + qt5/FolderDialog.qml + qt5/MessageDialog.qml + + diff --git a/Source/Tools/FEXQonfig/qml6.qrc b/Source/Tools/FEXQonfig/qml6.qrc new file mode 100644 index 0000000000..45ee7b9cf7 --- /dev/null +++ b/Source/Tools/FEXQonfig/qml6.qrc @@ -0,0 +1,10 @@ + + + main.qml + + + qt6/FileDialog.qml + qt6/FolderDialog.qml + qt6/MessageDialog.qml + + diff --git a/Source/Tools/FEXQonfig/qt5/FileDialog.qml b/Source/Tools/FEXQonfig/qt5/FileDialog.qml new file mode 100644 index 0000000000..6860b1f45a --- /dev/null +++ b/Source/Tools/FEXQonfig/qt5/FileDialog.qml @@ -0,0 +1,9 @@ +import QtQuick.Dialogs 1.3 as FromQt + +FromQt.FileDialog { + property url selectedFile + property url currentFolder + + folder: currentFolder + onAccepted: selectedFile = fileUrl +} diff --git a/Source/Tools/FEXQonfig/qt5/FolderDialog.qml b/Source/Tools/FEXQonfig/qt5/FolderDialog.qml new file mode 100644 index 0000000000..8866bdd1f5 --- /dev/null +++ b/Source/Tools/FEXQonfig/qt5/FolderDialog.qml @@ -0,0 +1,12 @@ +import QtQuick.Dialogs 1.3 as FromQt + +FromQt.FileDialog { + property url currentFolder + property url selectedFolder + + folder: currentFolder + + selectFolder: true + + onAccepted: selectedFolder = fileUrl +} diff --git a/Source/Tools/FEXQonfig/qt5/MessageDialog.qml b/Source/Tools/FEXQonfig/qt5/MessageDialog.qml new file mode 100644 index 0000000000..a5c8624b75 --- /dev/null +++ b/Source/Tools/FEXQonfig/qt5/MessageDialog.qml @@ -0,0 +1,49 @@ +import QtQuick 2.15 +import QtQuick.Dialogs 1.3 as FromQt + +Item { + id: dialogParent + property alias text: child.text + property alias title: child.title + + readonly property int buttonSave: FromQt.Dialog.Save + readonly property int buttonDiscard: FromQt.Dialog.Discard + readonly property int buttonCancel: FromQt.Dialog.Cancel + + property int buttons + + signal buttonClicked(button: int) + + property bool pendingResult: false + + function open() { + // Workaround for QTBUG-91650, due to which signals may get emitted twice + pendingResult = true + child.open() + } + + FromQt.MessageDialog { + id: child + + standardButtons: buttons + + onAccepted: { + if (pendingResult) { + dialogParent.buttonClicked(buttonSave) + pendingResult = false + } + } + onDiscard: { + if (pendingResult) { + dialogParent.buttonClicked(buttonDiscard) + pendingResult = false + } + } + onRejected: { + if (pendingResult) { + dialogParent.buttonClicked(buttonCancel) + pendingResult = false + } + } + } +} diff --git a/Source/Tools/FEXQonfig/qt6/FileDialog.qml b/Source/Tools/FEXQonfig/qt6/FileDialog.qml new file mode 100644 index 0000000000..c4deba5e45 --- /dev/null +++ b/Source/Tools/FEXQonfig/qt6/FileDialog.qml @@ -0,0 +1,7 @@ +import QtQuick.Dialogs as FromQt + +FromQt.FileDialog { + property bool selectExisting: true + property bool selectMultiple: false + fileMode: selectMultiple ? FileDialog.OpenFiles : selectExisting ? FileDialog.OpenFile : FileDialog.SaveFile +} diff --git a/Source/Tools/FEXQonfig/qt6/FolderDialog.qml b/Source/Tools/FEXQonfig/qt6/FolderDialog.qml new file mode 100644 index 0000000000..f58a348b87 --- /dev/null +++ b/Source/Tools/FEXQonfig/qt6/FolderDialog.qml @@ -0,0 +1,4 @@ +import QtQuick.Dialogs as FromQt + +FromQt.FolderDialog { +} diff --git a/Source/Tools/FEXQonfig/qt6/MessageDialog.qml b/Source/Tools/FEXQonfig/qt6/MessageDialog.qml new file mode 100644 index 0000000000..0afae7957d --- /dev/null +++ b/Source/Tools/FEXQonfig/qt6/MessageDialog.qml @@ -0,0 +1,7 @@ +import QtQuick.Dialogs as FromQt + +FromQt.MessageDialog { + readonly property int buttonSave: MessageDialog.Save + readonly property int buttonDiscard: MessageDialog.Discard + readonly property int buttonCancel: MessageDialog.Cancel +}