Files
FEX-Emu--FEX/Source/Tools/FEXConfig/main.qml
T
2025-10-16 08:53:44 +02:00

911 lines
32 KiB
QML

// SPDX-License-Identifier: MIT
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import FEX.ConfigModel 1.0
import FEX.HostLibsModel 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 loadedDefaults: 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
onConfigDirtyChanged: {
if (!configDirty) {
// We either just saved or loaded a file
loadedDefaults = false
}
}
function refreshUI() {
refreshCache = !refreshCache
}
function urlToLocalFile(theurl: url): string {
var str = theurl.toString()
if (str.startsWith("file://")) {
return decodeURIComponent(str.substring(7))
}
if (str.startsWith("file:")) {
return decodeURIComponent(str.substring(5))
}
return str;
}
FileDialog {
id: openFileDialog
property bool isSaving: false
title: isSaving ? qsTr("Save FEX configuration") : qsTr("Open FEX configuration")
nameFilters: [ qsTr("Config files(*.json)"), qsTr("All files(*)") ]
selectExisting: !isSaving
property var onNextAccept: null
// Prompts the user for an existing file and calls the callback on completion
function loadAndThen(callback) {
isSaving = false
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 saveAndThen(callback) {
isSaving = true
console.assert(!onNextAccept, "Tried to open dialog multiple times")
onNextAccept = callback
open()
}
onAccepted: {
if (!isSaving) {
root.selectedConfigFile(selectedFile)
}
configFilename = selectedFile
configDirty = false
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.saveAndThen(() => {
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.saveAndThen(() => {
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.loadAndThen(() => {})
}
Action {
text: qsTr("&Save")
shortcut: StandardKey.Save
onTriggered: root.save("")
}
Action {
text: qsTr("Save &as...")
shortcut: StandardKey.SaveAs
onTriggered: {
openFileDialog.saveAndThen(() => {
root.save(configFilename)
});
}
}
MenuSeparator {}
Action {
text: qsTr("&Quit")
shortcut: StandardKey.Quit
onTriggered: close()
}
}
}
header: TabBar {
id: tabBar
currentIndex: 0
readonly property int advancedIndex: 4
TabButton {
text: qsTr("General")
}
TabButton {
text: qsTr("Emulation")
}
TabButton {
text: qsTr("CPU")
}
TabButton {
text: qsTr("Libraries")
}
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
editable: true
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.fill: parent
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("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 {
text: qsTr("... for vector instructions")
tooltip: qsTr("Controls TSO emulation on vector load/store instructions")
config: "VectorTSOEnabled"
}
ConfigCheckBox {
text: qsTr("... for memcpy instructions")
tooltip: qsTr("Controls TSO emulation on memcpy/memset instructions")
config: "MemcpySetTSOEnabled"
}
ConfigCheckBox {
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)
}
}
ConfigCheckBox {
topPadding: 4
text: qsTr("Enable non-tearing split-lock atomics")
config: "StrictInProcessSplitLocks"
}
ConfigCheckBox {
topPadding: 4
text: qsTr("Use PE volatile metadata for ARM64EC")
config: "VolatileMetadata"
}
}
}
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
}
}
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"
}
}
// Libraries settings
ScrollablePage {
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("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 {
id: hostLibsGroupBox
title: qsTr("Use host library for:")
width: parent.width - parent.padding * 2
ColumnLayout {
width: hostLibsGroupBox.width - hostLibsGroupBox.padding * 2
ScrollView {
Layout.fillWidth: true
Layout.maximumHeight: 200
clip: true
Column {
property string selectedItem
property string explicitEntry
spacing: 4
Component.onCompleted: {
// root.refreshCacheChanged.connect(initState)
}
Repeater {
model: HostLibsModel
delegate: CheckBox {
text: model.display
visible: text !== "fex_thunk_test" // Hide test library
checked: (root.refreshCache, model.checked)
onToggled: {
configDirty = true
model.checked = checked
}
}
}
}
}
}
}
}
// 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 === tabBar.advancedIndex ? 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: loadedDefaults
? qsTr("Config.json not found — loaded defaults")
: qsTr("Editing %1").arg(urlToLocalFile(configFilename))
}
}
}
}