diff --git a/src/core/core.props b/src/core/core.props index 7fbaca20b..b02f06f57 100644 --- a/src/core/core.props +++ b/src/core/core.props @@ -21,6 +21,11 @@ XBYAK_NO_EXCEPTION=1;%(PreprocessorDefinitions) %(AdditionalIncludeDirectories);$(SolutionDir)dep\vixl\include + + %(AdditionalIncludeDirectories);$(LOCALAPPDATA)\Programs\Python\Python311\include + + %(AdditionalLibraryDirectories);$(LOCALAPPDATA)\Programs\Python\Python311\libs + diff --git a/src/core/core.vcxproj b/src/core/core.vcxproj index 86f529acc..ae30f62ce 100644 --- a/src/core/core.vcxproj +++ b/src/core/core.vcxproj @@ -78,6 +78,7 @@ + @@ -158,6 +159,7 @@ + diff --git a/src/core/core.vcxproj.filters b/src/core/core.vcxproj.filters index b02bcfd85..b76eefcd1 100644 --- a/src/core/core.vcxproj.filters +++ b/src/core/core.vcxproj.filters @@ -68,6 +68,7 @@ + @@ -140,7 +141,11 @@ +<<<<<<< Updated upstream +======= + +>>>>>>> Stashed changes \ No newline at end of file diff --git a/src/core/scriptengine.cpp b/src/core/scriptengine.cpp new file mode 100644 index 000000000..2c68a5de9 --- /dev/null +++ b/src/core/scriptengine.cpp @@ -0,0 +1,532 @@ +#include "scriptengine.h" +#include "cpu_core.h" +#include "fullscreen_ui.h" +#include "host.h" +#include "system.h" + +#include "common/error.h" +#include "common/log.h" +#include "common/small_string.h" + +#include "fmt/format.h" + +#ifdef _DEBUG +#undef _DEBUG +#include "Python.h" +#define _DEBUG +#else +#include "Python.h" +#endif + +#include + +Log_SetChannel(ScriptEngine); + +// TODO: File loading +// TODO: Expose imgui +// TODO: hexdump, assembler, disassembler +// TODO: save/load state +// TODO: controller inputs +// TODO: Intepreter reset + +namespace ScriptEngine { +static void SetErrorFromStatus(Error* error, const PyStatus& status, std::string_view prefix); +static void SetPyErrFromError(Error* error); +static bool RedirectOutput(Error* error); +static void WriteOutput(std::string_view message); +static bool CheckVMValid(); + +static PyObject* output_redirector_write(PyObject* self, PyObject* args); + +static PyObject* dspy_inittab(); + +static PyObject* dspy_exit(PyObject* self, PyObject* args); + +static PyObject* dspy_vm_valid(PyObject* self, PyObject* args); +static PyObject* dspy_vm_start(PyObject* self, PyObject* args, PyObject* kwargs); +static PyObject* dspy_vm_pause(PyObject* self, PyObject* args); +static PyObject* dspy_vm_resume(PyObject* self, PyObject* args); +static PyObject* dspy_vm_reset(PyObject* self, PyObject* args); +static PyObject* dspy_vm_shutdown(PyObject* self, PyObject* args); + +static PyMethodDef s_vm_methods[] = { + {"valid", dspy_vm_valid, METH_NOARGS, "Returns true if a virtual machine is active."}, + {"start", reinterpret_cast(dspy_vm_start), METH_VARARGS | METH_KEYWORDS, + "Starts a new virtual machine with the specified arguments."}, + {"pause", dspy_vm_pause, METH_NOARGS, "Pauses VM if it is not currently running."}, + {"resume", dspy_vm_resume, METH_NOARGS, "Resumes VM if it is currently paused."}, + {"reset", dspy_vm_reset, METH_NOARGS, "Resets current VM."}, + {"shutdown", dspy_vm_shutdown, METH_NOARGS, "Shuts down current VM."}, + {}, +}; + +template +static PyObject* dspy_mem_readT(PyObject* self, PyObject* args); +template +static PyObject* dspy_mem_writeT(PyObject* self, PyObject* args); + +static PyMethodDef s_mem_methods[] = { + {"read8", dspy_mem_readT, METH_VARARGS, "Reads a byte from the specified address."}, + {"reads8", dspy_mem_readT, METH_VARARGS, "Reads a signed byte from the specified address."}, + {"read16", dspy_mem_readT, METH_VARARGS, "Reads a halfword from the specified address."}, + {"reads16", dspy_mem_readT, METH_VARARGS, "Reads a signed halfword from the specified address."}, + {"read32", dspy_mem_readT, METH_VARARGS, "Reads a word from the specified address."}, + {"reads32", dspy_mem_readT, METH_VARARGS, "Reads a word from the specified address."}, + {"write8", dspy_mem_writeT, METH_VARARGS, "Reads a byte from the specified address."}, + {"write16", dspy_mem_writeT, METH_VARARGS, "Reads a halfword from the specified address."}, + {"write32", dspy_mem_writeT, METH_VARARGS, "Reads a word from the specified address."}, + {}, +}; + +template +ALWAYS_INLINE static void WriteOutput(fmt::format_string fmt, T&&... args) +{ + SmallString message; + fmt::vformat_to(std::back_inserter(message), fmt, fmt::make_format_args(args...)); + WriteOutput(message); +} + +static std::mutex s_output_mutex; +static OutputCallback s_output_callback; +static void* s_output_callback_userdata; + +static const char* INITIALIZATION_SCRIPT = "import dspy;" + "from dspy import vm;" + "from dspy import mem;"; + +} // namespace ScriptEngine + +void ScriptEngine::SetErrorFromStatus(Error* error, const PyStatus& status, std::string_view prefix) +{ + Error::SetStringFmt(error, "func={} err_msg={} exitcode={}", prefix, status.func ? status.func : "", + status.err_msg ? status.err_msg : "", status.exitcode); +} + +void ScriptEngine::SetPyErrFromError(Error* error) +{ + PyErr_SetString(PyExc_RuntimeError, error ? error->GetDescription().c_str() : "unknown error"); +} + +bool ScriptEngine::Initialize(Error* error) +{ + PyPreConfig pre_config; + PyPreConfig_InitIsolatedConfig(&pre_config); + pre_config.utf8_mode = true; + + PyStatus status = Py_PreInitialize(&pre_config); + if (PyStatus_IsError(status)) [[unlikely]] + { + SetErrorFromStatus(error, status, "Py_PreInitialize() failed: "); + Shutdown(); + return false; + } + + if (const int istatus = PyImport_AppendInittab("dspy", &dspy_inittab); istatus != 0) + { + Error::SetStringFmt(error, "PyImport_AppendInittab() failed: {}", istatus); + Shutdown(); + return false; + } + + PyConfig config; + PyConfig_InitIsolatedConfig(&config); + config.pythonpath_env = Py_DecodeLocale("C:\\Users\\Me\\AppData\\Local\\Programs\\Python\\Python311\\Lib", nullptr); + + status = Py_InitializeFromConfig(&config); + + PyMem_RawFree(config.pythonpath_env); + + if (PyStatus_IsError(status)) [[unlikely]] + { + SetErrorFromStatus(error, status, "Py_InitializeFromConfig() failed: "); + Shutdown(); + return false; + } + + if (!RedirectOutput(error)) [[unlikely]] + { + Error::AddPrefix(error, "Failed to redirect output: "); + Shutdown(); + return false; + } + + if (PyRun_SimpleString(INITIALIZATION_SCRIPT) < 0) + { + PyErr_Print(); + Error::SetStringFmt(error, "Failed to run initialization script."); + Shutdown(); + return false; + } + + return true; +} + +void ScriptEngine::Shutdown() +{ + if (const int ret = Py_FinalizeEx(); ret != 0) + { + ERROR_LOG("Py_FinalizeEx() returned {}", ret); + } +} + +void ScriptEngine::SetOutputCallback(OutputCallback callback, void* userdata) +{ + std::unique_lock lock(s_output_mutex); + s_output_callback = callback; + s_output_callback_userdata = userdata; +} + +bool ScriptEngine::RedirectOutput(Error* error) +{ + PyObject* dspy_module = PyImport_ImportModule("dspy"); + if (!dspy_module) + { + Error::SetStringView(error, "PyImport_ImportModule(dspy) failed"); + return false; + } + + PyObject* module_dict = PyModule_GetDict(dspy_module); + if (!module_dict) + { + Error::SetStringView(error, "PyModule_GetDict() failed"); + Py_DECREF(dspy_module); + return false; + } + + PyObject* output_redirector_class = PyDict_GetItemString(module_dict, "output_redirector"); + Py_DECREF(dspy_module); + if (!output_redirector_class) + { + Error::SetStringView(error, "PyDict_GetItemString() failed"); + return false; + } + + PyObject* output_redirector; + if (!PyCallable_Check(output_redirector_class) || + !(output_redirector = PyObject_CallObject(output_redirector_class, nullptr))) + { + Error::SetStringView(error, "PyObject_CallObject() failed"); + Py_DECREF(output_redirector_class); + return false; + } + + Py_DECREF(output_redirector_class); + + PyObject* sys_module = PyImport_ImportModule("sys"); + if (!sys_module) + { + Error::SetStringView(error, "PyImport_ImportModule(sys) failed"); + Py_DECREF(output_redirector); + return false; + } + + module_dict = PyModule_GetDict(sys_module); + if (!module_dict) + { + Error::SetStringView(error, "PyModule_GetDict(sys) failed"); + Py_DECREF(sys_module); + Py_DECREF(output_redirector); + return false; + } + + if (PyDict_SetItemString(module_dict, "stdout", output_redirector) < 0 || + PyDict_SetItemString(module_dict, "stderr", output_redirector) < 0) + { + Error::SetStringView(error, "PyDict_SetItemString() failed"); + Py_DECREF(sys_module); + Py_DECREF(output_redirector); + return false; + } + + Py_DECREF(sys_module); + Py_DECREF(output_redirector); + return true; +} + +void ScriptEngine::WriteOutput(std::string_view message) +{ + INFO_LOG("Python: {}", message); + + if (s_output_callback) + { + std::unique_lock lock(s_output_mutex); + s_output_callback(message, s_output_callback_userdata); + } +} + +void ScriptEngine::EvalString(const char* str) +{ + WriteOutput(">>> {}\n", str); + + const int res = PyRun_SimpleString(str); + if (res == 0) + return; + + WriteOutput("PyRun_SimpleString() returned {}\n", res); + PyErr_Print(); +} + +#define PYBOOL(b) ((b) ? Py_NewRef(Py_True) : Py_NewRef(Py_False)) + +PyObject* ScriptEngine::output_redirector_write(PyObject* self, PyObject* args) +{ + const char* msg; + if (!PyArg_ParseTuple(args, "s", &msg)) + return nullptr; + + WriteOutput(msg); + Py_RETURN_NONE; +} + +PyObject* ScriptEngine::dspy_inittab() +{ + static PyMethodDef root_methods[] = { + {"exit", dspy_exit, METH_NOARGS, "Exits the hosting application."}, + {}, + }; + static PyModuleDef root_module_def = { + PyModuleDef_HEAD_INIT, "dspy", nullptr, -1, root_methods, nullptr, nullptr, nullptr, nullptr}; + + static PyModuleDef vm_module_def = { + PyModuleDef_HEAD_INIT, "vm", nullptr, -1, s_vm_methods, nullptr, nullptr, nullptr, nullptr}; + + static PyModuleDef mem_module_def = { + PyModuleDef_HEAD_INIT, "mem", nullptr, -1, s_mem_methods, nullptr, nullptr, nullptr, nullptr}; + + static PyMethodDef output_redirectory_methods[] = { + {"write", output_redirector_write, METH_VARARGS, "Writes to script console."}, + {}, + }; + + static PyTypeObject output_redirector = { + .ob_base = PyVarObject_HEAD_INIT(nullptr, 0).tp_name = "dspy.output_redirector", + .tp_basicsize = sizeof(PyObject), + .tp_itemsize = 0, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR("Output Redirector"), + .tp_methods = output_redirectory_methods, + .tp_new = PyType_GenericNew, + }; + + PyObject* root_module = PyModule_Create(&root_module_def); + if (!root_module) + return nullptr; + + PyObject* vm_module = PyModule_Create(&vm_module_def); + if (!vm_module) + { + Py_DECREF(root_module); + return nullptr; + } + + PyObject* mem_module = PyModule_Create(&mem_module_def); + if (!vm_module) + { + Py_DECREF(vm_module); + Py_DECREF(root_module); + return nullptr; + } + + if (PyType_Ready(&output_redirector) < 0 || PyModule_AddObjectRef(root_module, "vm", vm_module) < 0 || + PyModule_AddObjectRef(root_module, "mem", mem_module) < 0 || + PyModule_AddObjectRef(root_module, "output_redirector", reinterpret_cast(&output_redirector)) < 0) + { + Py_DECREF(mem_module); + Py_DECREF(vm_module); + Py_DECREF(root_module); + return nullptr; + } + + Py_DECREF(mem_module); + Py_DECREF(vm_module); + return root_module; +} + +PyObject* ScriptEngine::dspy_exit(PyObject* self, PyObject* args) +{ + Host::RequestExitApplication(false); + Py_RETURN_NONE; +} + +bool ScriptEngine::CheckVMValid() +{ + if (System::IsValid()) + { + return true; + } + else + { + PyErr_SetString(PyExc_RuntimeError, "VM has not been started."); + return false; + } +} + +PyObject* ScriptEngine::dspy_vm_valid(PyObject* self, PyObject* args) +{ + return PYBOOL(System::IsValid()); +} + +PyObject* ScriptEngine::dspy_vm_start(PyObject* self, PyObject* args, PyObject* kwargs) +{ + static constexpr const char* kwlist[] = { + "path", "savestate", "exe", "override_fastboot", "override_slowboot", "start_fullscreen", "start_paused", nullptr}; + + if (System::GetState() != System::State::Shutdown) + { + PyErr_SetString(PyExc_RuntimeError, "VM has already been started."); + return nullptr; + } + + const char* path = nullptr; + const char* savestate = nullptr; + const char* override_exe = nullptr; + int override_fastboot = 0; + int override_slowboot = 0; + int start_fullscreen = 0; + int start_paused = 0; + if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|$ssspppp", const_cast(kwlist), &path, &savestate, + &override_exe, &override_fastboot, &override_slowboot, &start_fullscreen, + &start_paused)) + { + return nullptr; + } + + SystemBootParameters params; + if (path) + params.filename = path; + if (savestate) + params.save_state = savestate; + if (override_exe) + params.override_exe = override_exe; + if (override_fastboot) + params.override_fast_boot = true; + else if (override_slowboot) + params.override_fast_boot = false; + if (start_fullscreen) + params.override_fullscreen = true; + if (start_paused) + params.override_start_paused = true; + + WriteOutput("Starting system with path={}\n", params.filename); + + Error error; + if (!System::BootSystem(std::move(params), &error)) + { + WriteOutput("Starting system failed: {}\n", error.GetDescription()); + SetPyErrFromError(&error); + return nullptr; + } + + Py_RETURN_NONE; +} + +PyObject* ScriptEngine::dspy_vm_pause(PyObject* self, PyObject* args) +{ + if (!CheckVMValid()) + return nullptr; + + System::PauseSystem(true); + Py_RETURN_NONE; +} + +PyObject* ScriptEngine::dspy_vm_resume(PyObject* self, PyObject* args) +{ + if (!CheckVMValid()) + return nullptr; + + System::PauseSystem(false); + Py_RETURN_NONE; +} + +PyObject* ScriptEngine::dspy_vm_reset(PyObject* self, PyObject* args) +{ + if (!CheckVMValid()) + return nullptr; + + System::ResetSystem(); + Py_RETURN_NONE; +} + +PyObject* ScriptEngine::dspy_vm_shutdown(PyObject* self, PyObject* args) +{ + if (!CheckVMValid()) + return nullptr; + + System::ShutdownSystem(false); + Py_RETURN_NONE; +} + +template +PyObject* ScriptEngine::dspy_mem_readT(PyObject* self, PyObject* args) +{ + if (!CheckVMValid()) + return nullptr; + + unsigned int address; + if (!PyArg_ParseTuple(args, "I", &address)) + return nullptr; + + if constexpr (std::is_same_v || std::is_same_v) + { + u8 result; + if (CPU::SafeReadMemoryByte(address, &result)) [[likely]] + return std::is_signed_v ? PyLong_FromLong(static_cast(result)) : PyLong_FromUnsignedLong(result); + } + else if constexpr (std::is_same_v || std::is_same_v) + { + u16 result; + if (CPU::SafeReadMemoryHalfWord(address, &result)) [[likely]] + return std::is_signed_v ? PyLong_FromLong(static_cast(result)) : PyLong_FromUnsignedLong(result); + } + else if constexpr (std::is_same_v || std::is_same_v) + { + u32 result; + if (CPU::SafeReadMemoryWord(address, &result)) [[likely]] + return std::is_signed_v ? PyLong_FromLong(static_cast(result)) : PyLong_FromUnsignedLong(result); + } + + PyErr_SetString(PyExc_RuntimeError, "Address was not valid."); + return nullptr; +} + +template PyObject* ScriptEngine::dspy_mem_readT(PyObject*, PyObject*); +template PyObject* ScriptEngine::dspy_mem_readT(PyObject*, PyObject*); +template PyObject* ScriptEngine::dspy_mem_readT(PyObject*, PyObject*); +template PyObject* ScriptEngine::dspy_mem_readT(PyObject*, PyObject*); +template PyObject* ScriptEngine::dspy_mem_readT(PyObject*, PyObject*); +template PyObject* ScriptEngine::dspy_mem_readT(PyObject*, PyObject*); + +template +PyObject* ScriptEngine::dspy_mem_writeT(PyObject* self, PyObject* args) +{ + if (!CheckVMValid()) + return nullptr; + + unsigned int address; + long long value; + if (!PyArg_ParseTuple(args, "IL", &address, &value)) + return nullptr; + + if constexpr (std::is_same_v) + { + if (CPU::SafeWriteMemoryByte(address, static_cast(value))) [[likely]] + Py_RETURN_NONE; + } + else if constexpr (std::is_same_v) + { + if (CPU::SafeWriteMemoryHalfWord(address, static_cast(value))) [[likely]] + Py_RETURN_NONE; + } + else + { + if (CPU::SafeWriteMemoryWord(address, static_cast(value))) [[likely]] + Py_RETURN_NONE; + } + + PyErr_SetString(PyExc_RuntimeError, "Address was not valid."); + return nullptr; +} + +template PyObject* ScriptEngine::dspy_mem_writeT(PyObject*, PyObject*); +template PyObject* ScriptEngine::dspy_mem_writeT(PyObject*, PyObject*); +template PyObject* ScriptEngine::dspy_mem_writeT(PyObject*, PyObject*); diff --git a/src/core/scriptengine.h b/src/core/scriptengine.h new file mode 100644 index 000000000..1abd3dfa3 --- /dev/null +++ b/src/core/scriptengine.h @@ -0,0 +1,19 @@ +#pragma once + +#include "types.h" + +#include + +class Error; + +namespace ScriptEngine { + +bool Initialize(Error* error); +void Shutdown(); + +using OutputCallback = void (*)(std::string_view, void*); +void SetOutputCallback(OutputCallback callback, void* userdata); + +void EvalString(const char* str); + +} // namespace ScriptEngine \ No newline at end of file diff --git a/src/duckstation-qt/duckstation-qt.vcxproj b/src/duckstation-qt/duckstation-qt.vcxproj index 1696c6390..35f02254f 100644 --- a/src/duckstation-qt/duckstation-qt.vcxproj +++ b/src/duckstation-qt/duckstation-qt.vcxproj @@ -48,6 +48,7 @@ + @@ -89,6 +90,7 @@ + @@ -257,6 +259,7 @@ + diff --git a/src/duckstation-qt/duckstation-qt.vcxproj.filters b/src/duckstation-qt/duckstation-qt.vcxproj.filters index cfe5d458f..16c6c6f9f 100644 --- a/src/duckstation-qt/duckstation-qt.vcxproj.filters +++ b/src/duckstation-qt/duckstation-qt.vcxproj.filters @@ -179,6 +179,10 @@ moc + + + moc + @@ -242,6 +246,7 @@ + diff --git a/src/duckstation-qt/mainwindow.cpp b/src/duckstation-qt/mainwindow.cpp index e5e8925cf..b341266c9 100644 --- a/src/duckstation-qt/mainwindow.cpp +++ b/src/duckstation-qt/mainwindow.cpp @@ -20,6 +20,7 @@ #include "selectdiscdialog.h" #include "settingswindow.h" #include "settingwidgetbinder.h" +#include "scriptconsole.h" #include "core/achievements.h" #include "core/game_list.h" @@ -181,6 +182,8 @@ void MainWindow::initialize() CocoaTools::AddThemeChangeHandler(this, [](void* ctx) { QtHost::RunOnUIThread([] { g_main_window->updateTheme(); }); }); #endif + + ScriptConsole::updateSettings(); } void MainWindow::reportError(const QString& title, const QString& message) @@ -770,6 +773,7 @@ void MainWindow::destroySubWindows() SettingsWindow::closeGamePropertiesDialogs(); + ScriptConsole::destroy(); LogWindow::destroy(); } diff --git a/src/duckstation-qt/qthost.cpp b/src/duckstation-qt/qthost.cpp index 307170a1b..8e114686a 100644 --- a/src/duckstation-qt/qthost.cpp +++ b/src/duckstation-qt/qthost.cpp @@ -21,6 +21,7 @@ #include "core/host.h" #include "core/imgui_overlays.h" #include "core/memory_card.h" +#include "core/scriptengine.h" #include "core/spu.h" #include "core/system.h" @@ -1735,6 +1736,8 @@ void EmuThread::run() createBackgroundControllerPollTimer(); startBackgroundControllerPollTimer(); + ScriptEngine::Initialize(nullptr); + // main loop while (!m_shutdown_flag) { diff --git a/src/duckstation-qt/scriptconsole.cpp b/src/duckstation-qt/scriptconsole.cpp new file mode 100644 index 000000000..1d6855cad --- /dev/null +++ b/src/duckstation-qt/scriptconsole.cpp @@ -0,0 +1,272 @@ +// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin +// SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0) + +#include "scriptconsole.h" +#include "mainwindow.h" +#include "qthost.h" +#include "settingwidgetbinder.h" + +#include "core/scriptengine.h" +#include "util/host.h" + +#include +#include +#include +#include +#include + +// TODO: Since log callbacks are synchronized, no mutex is needed here. +// But once I get rid of that, there will be. +ScriptConsole* g_script_console; + +static constexpr const char* INITIAL_TEXT = + R"(---------------------------------------------------------------------------------- +This is the DuckStation script console. + +You can run commands, or evaluate expressions using the text box below, +and click Execute, or press Enter. + +The vm and mem modules have already been imported into the main namespace for you. +---------------------------------------------------------------------------------- + +)"; + +ScriptConsole::ScriptConsole() : QMainWindow() +{ + restoreSize(); + createUi(); + + ScriptEngine::SetOutputCallback(&ScriptConsole::outputCallback, this); +} + +ScriptConsole::~ScriptConsole() = default; + +void ScriptConsole::updateSettings() +{ + const bool new_enabled = true; // Host::GetBaseBoolSettingValue("Logging", "LogToWindow", false); + const bool curr_enabled = (g_script_console != nullptr); + if (new_enabled == curr_enabled) + return; + + if (new_enabled) + { + g_script_console = new ScriptConsole(); + g_script_console->show(); + } + else if (g_script_console) + { + g_script_console->m_destroying = true; + g_script_console->close(); + g_script_console->deleteLater(); + g_script_console = nullptr; + } +} + +void ScriptConsole::destroy() +{ + if (!g_script_console) + return; + + g_script_console->m_destroying = true; + g_script_console->close(); + g_script_console->deleteLater(); + g_script_console = nullptr; +} + +void ScriptConsole::createUi() +{ + QIcon icon; + icon.addFile(QString::fromUtf8(":/icons/duck.png"), QSize(), QIcon::Normal, QIcon::Off); + setWindowIcon(icon); + setWindowFlag(Qt::WindowCloseButtonHint, false); + setWindowTitle(tr("Script Console")); + + QAction* action; + + QMenuBar* menu = new QMenuBar(this); + setMenuBar(menu); + + QMenu* log_menu = menu->addMenu("&Log"); + action = log_menu->addAction(tr("&Clear")); + connect(action, &QAction::triggered, this, &ScriptConsole::onClearTriggered); + action = log_menu->addAction(tr("&Save...")); + connect(action, &QAction::triggered, this, &ScriptConsole::onSaveTriggered); + + log_menu->addSeparator(); + + action = log_menu->addAction(tr("Cl&ose")); + connect(action, &QAction::triggered, this, &ScriptConsole::close); + + QWidget* main_widget = new QWidget(this); + QVBoxLayout* main_layout = new QVBoxLayout(main_widget); + + m_text = new QPlainTextEdit(main_widget); + m_text->setReadOnly(true); + m_text->setUndoRedoEnabled(false); + m_text->setTextInteractionFlags(Qt::TextSelectableByKeyboard); + m_text->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); + +#if defined(_WIN32) + QFont font("Consolas"); + font.setPointSize(10); +#elif defined(__APPLE__) + QFont font("Monaco"); + font.setPointSize(11); +#else + QFont font("Monospace"); + font.setStyleHint(QFont::TypeWriter); +#endif + m_text->setFont(font); + main_layout->addWidget(m_text, 1); + + QHBoxLayout* command_layout = new QHBoxLayout(); + + m_command = new QLineEdit(main_widget); + command_layout->addWidget(m_command, 1); + + m_execute = new QPushButton(tr("&Execute"), main_widget); + m_execute->setEnabled(false); + connect(m_execute, &QPushButton::clicked, this, &ScriptConsole::executeClicked); + command_layout->addWidget(m_execute); + + main_layout->addLayout(command_layout); + + setCentralWidget(main_widget); + + m_command->setFocus(); + connect(m_command, &QLineEdit::textChanged, this, &ScriptConsole::commandChanged); + connect(m_command, &QLineEdit::returnPressed, this, &ScriptConsole::executeClicked); + + appendMessage(QString::fromUtf8(INITIAL_TEXT)); +} + +void ScriptConsole::onClearTriggered() +{ + m_text->clear(); +} + +void ScriptConsole::onSaveTriggered() +{ + const QString path = QFileDialog::getSaveFileName(this, tr("Select Log File"), QString(), tr("Log Files (*.txt)")); + if (path.isEmpty()) + return; + + QFile file(path); + if (!file.open(QFile::WriteOnly | QFile::Text)) + { + QMessageBox::critical(this, tr("Error"), tr("Failed to open file for writing.")); + return; + } + + file.write(m_text->toPlainText().toUtf8()); + file.close(); + + appendMessage(tr("Log was written to %1.\n").arg(path)); +} + +void ScriptConsole::outputCallback(std::string_view message, void* userdata) +{ + if (message.empty()) + return; + + ScriptConsole* this_ptr = static_cast(userdata); + + // TODO: Split message based on lines. + // I don't like the memory allocations here either... + + QString qmessage = QtUtils::StringViewToQString(message); + + DebugAssert(!g_emu_thread->isOnUIThread()); + QMetaObject::invokeMethod(this_ptr, "appendMessage", Qt::QueuedConnection, Q_ARG(const QString&, qmessage)); +} + +void ScriptConsole::closeEvent(QCloseEvent* event) +{ + if (!m_destroying) + { + event->ignore(); + return; + } + + ScriptEngine::SetOutputCallback(nullptr, nullptr); + + saveSize(); + + QMainWindow::closeEvent(event); +} + +void ScriptConsole::appendMessage(const QString& message) +{ + QTextCursor temp_cursor = m_text->textCursor(); + QScrollBar* scrollbar = m_text->verticalScrollBar(); + const bool cursor_at_end = temp_cursor.atEnd(); + const bool scroll_at_end = scrollbar->sliderPosition() == scrollbar->maximum(); + + temp_cursor.movePosition(QTextCursor::End); + + { + QTextCharFormat format = temp_cursor.charFormat(); + + format.setForeground(QBrush(QColor(0xCC, 0xCC, 0xCC))); + temp_cursor.setCharFormat(format); + temp_cursor.insertText(message); + } + + if (cursor_at_end) + { + if (scroll_at_end) + { + m_text->setTextCursor(temp_cursor); + scrollbar->setSliderPosition(scrollbar->maximum()); + } + else + { + // Can't let changing the cursor affect the scroll bar... + const int pos = scrollbar->sliderPosition(); + m_text->setTextCursor(temp_cursor); + scrollbar->setSliderPosition(pos); + } + } +} + +void ScriptConsole::commandChanged(const QString& text) +{ + m_execute->setEnabled(!text.isEmpty()); +} + +void ScriptConsole::executeClicked() +{ + const QString text = m_command->text(); + if (text.isEmpty()) + return; + + Host::RunOnCPUThread([code = text.toStdString()]() { ScriptEngine::EvalString(code.c_str()); }); + m_command->clear(); +} + +void ScriptConsole::saveSize() +{ + const QByteArray geometry = saveGeometry(); + const QByteArray geometry_b64 = geometry.toBase64(); + const std::string old_geometry_b64 = Host::GetBaseStringSettingValue("UI", "ScriptConsoleGeometry"); + if (old_geometry_b64 != geometry_b64.constData()) + { + Host::SetBaseStringSettingValue("UI", "ScriptConsoleGeometry", geometry_b64.constData()); + Host::CommitBaseSettingChanges(); + } +} + +void ScriptConsole::restoreSize() +{ + const std::string geometry_b64 = Host::GetBaseStringSettingValue("UI", "ScriptConsoleGeometry"); + const QByteArray geometry = QByteArray::fromBase64(QByteArray::fromStdString(geometry_b64)); + if (!geometry.isEmpty()) + { + restoreGeometry(geometry); + } + else + { + // default size + resize(DEFAULT_WIDTH, DEFAULT_WIDTH); + } +} diff --git a/src/duckstation-qt/scriptconsole.h b/src/duckstation-qt/scriptconsole.h new file mode 100644 index 000000000..a438e9d89 --- /dev/null +++ b/src/duckstation-qt/scriptconsole.h @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin +// SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0) + +#pragma once + +#include "common/log.h" + +#include +#include +#include +#include +#include + +class ScriptConsole : public QMainWindow +{ + Q_OBJECT + +public: + ScriptConsole(); + ~ScriptConsole(); + + static void updateSettings(); + static void destroy(); + +private: + void createUi(); + + static void outputCallback(std::string_view message, void* userdata); + +protected: + void closeEvent(QCloseEvent* event); + +private Q_SLOTS: + void onClearTriggered(); + void onSaveTriggered(); + void appendMessage(const QString& message); + void commandChanged(const QString& text); + void executeClicked(); + +private: + static constexpr int DEFAULT_WIDTH = 750; + static constexpr int DEFAULT_HEIGHT = 400; + + void saveSize(); + void restoreSize(); + + QPlainTextEdit* m_text; + QLineEdit* m_command; + QPushButton* m_execute; + + bool m_destroying = false; +}; + +extern ScriptConsole* g_script_console;