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;