From 5333e3bca217e252d32866fa426a131c25a83e86 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 12:56:33 -0400 Subject: [PATCH 1/3] feat(ps1): Phase 1 EmuCore plugin, viewport, and session UI (#415-417) Introduce EmuCore/IEmuCorePlugin with QPluginLoader discovery, a stub core plugin for CI, worker-thread frame loop, EmuViewport session window, and first-run legality dialog. Tools > Experimental opens the ripper when ENABLE_PS1_RIP is on. Co-authored-by: Cursor --- CLAUDE.md | 2 +- CMakeLists.txt | 1 + plugins/ps1core_stub/CMakeLists.txt | 32 ++++ plugins/ps1core_stub/StubEmuCore.cpp | 83 +++++++++ plugins/ps1core_stub/StubEmuCore.h | 38 +++++ plugins/ps1core_stub/StubEmuCorePlugin.cpp | 12 ++ plugins/ps1core_stub/StubEmuCorePlugin.h | 18 ++ plugins/ps1core_stub/ps1core_stub.json | 6 + src/CMakeLists.txt | 4 + src/PS1/CMakeLists.txt | 12 ++ src/PS1/PS1_RIP_DESIGN.md | 8 +- src/PS1/runtime/EmuCore.h | 28 ++++ src/PS1/runtime/EmuCoreLoader.cpp | 79 +++++++++ src/PS1/runtime/EmuCoreLoader.h | 19 +++ src/PS1/runtime/EmuCoreLoader_test.cpp | 78 +++++++++ src/PS1/runtime/EmuFramebuffer.h | 18 ++ src/PS1/runtime/EmuHooks.h | 14 ++ src/PS1/runtime/EmuViewport.cpp | 72 ++++++++ src/PS1/runtime/EmuViewport.h | 33 ++++ src/PS1/runtime/IEmuCorePlugin.h | 24 +++ src/PS1/runtime/PS1RipLegalityDialog.cpp | 65 +++++++ src/PS1/runtime/PS1RipLegalityDialog.h | 26 +++ src/PS1/runtime/PS1RipManager.cpp | 72 ++++++-- src/PS1/runtime/PS1RipManager.h | 14 +- src/PS1/runtime/PS1RipManager_test.cpp | 66 ++++++-- src/PS1/runtime/PS1RipSessionWindow.cpp | 186 +++++++++++++++++++++ src/PS1/runtime/PS1RipSessionWindow.h | 47 ++++++ src/PS1/runtime/PS1RipWorker.cpp | 95 ++++++++++- src/PS1/runtime/PS1RipWorker.h | 25 ++- src/mainwindow.cpp | 10 +- 30 files changed, 1140 insertions(+), 47 deletions(-) create mode 100644 plugins/ps1core_stub/CMakeLists.txt create mode 100644 plugins/ps1core_stub/StubEmuCore.cpp create mode 100644 plugins/ps1core_stub/StubEmuCore.h create mode 100644 plugins/ps1core_stub/StubEmuCorePlugin.cpp create mode 100644 plugins/ps1core_stub/StubEmuCorePlugin.h create mode 100644 plugins/ps1core_stub/ps1core_stub.json create mode 100644 src/PS1/runtime/EmuCore.h create mode 100644 src/PS1/runtime/EmuCoreLoader.cpp create mode 100644 src/PS1/runtime/EmuCoreLoader.h create mode 100644 src/PS1/runtime/EmuCoreLoader_test.cpp create mode 100644 src/PS1/runtime/EmuFramebuffer.h create mode 100644 src/PS1/runtime/EmuHooks.h create mode 100644 src/PS1/runtime/EmuViewport.cpp create mode 100644 src/PS1/runtime/EmuViewport.h create mode 100644 src/PS1/runtime/IEmuCorePlugin.h create mode 100644 src/PS1/runtime/PS1RipLegalityDialog.cpp create mode 100644 src/PS1/runtime/PS1RipLegalityDialog.h create mode 100644 src/PS1/runtime/PS1RipSessionWindow.cpp create mode 100644 src/PS1/runtime/PS1RipSessionWindow.h diff --git a/CLAUDE.md b/CLAUDE.md index 8acc1991..b2e436ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,7 +198,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas ### PS1 formats (static) and runtime extraction (experimental) - **Static parsers** (`src/PS1/`): `PS1TMD`, `PS1TIM`, `PS1RSD`, `PS1PLY`, `PS1MAT` for known PlayStation mesh/texture formats. -- **Runtime extraction** (`src/PS1/runtime/`, epic #412): `ENABLE_PS1_RIP` (OFF by default). When ON, `PS1RipManager` singleton coordinates emulator embedding, GPU/GTE capture, VRAM dumps, and reconstruction into Ogre meshes. Design doc: `src/PS1/PS1_RIP_DESIGN.md`. CI enables the flag on Linux test builds only. Sentry breadcrumbs use category `ps1.rip`. +- **Runtime extraction** (`src/PS1/runtime/`, epic #412): `ENABLE_PS1_RIP` (OFF by default). When ON, `PS1RipManager` runs an `EmuCore` plugin from `/PS1Cores/` on a worker thread; stub core `qtmesh_ps1core_stub` ships for CI/dev. Session UI: **Tools → Experimental → PS1 Runtime Ripper…** (`PS1RipSessionWindow`, `EmuViewport`). Design doc: `src/PS1/PS1_RIP_DESIGN.md`. CI enables the flag on Linux test builds only. Sentry breadcrumbs use category `ps1.rip`. ### Mesh Import/Export diff --git a/CMakeLists.txt b/CMakeLists.txt index fdaf278b..c113cc20 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -271,6 +271,7 @@ option(ENABLE_PS1_RIP "Enable experimental PS1 runtime geometry extraction" OFF) if(ENABLE_PS1_RIP) add_definitions(-DENABLE_PS1_RIP) message(STATUS "PS1 runtime geometry extraction enabled (experimental)") + add_subdirectory(plugins/ps1core_stub) endif() ############################################################## # Sentry crash reporting and analytics diff --git a/plugins/ps1core_stub/CMakeLists.txt b/plugins/ps1core_stub/CMakeLists.txt new file mode 100644 index 00000000..9a0aeb61 --- /dev/null +++ b/plugins/ps1core_stub/CMakeLists.txt @@ -0,0 +1,32 @@ +if(NOT ENABLE_PS1_RIP) + return() +endif() + +set(PS1_RUNTIME_INCLUDE "${CMAKE_SOURCE_DIR}/src/PS1/runtime") + +add_library(qtmesh_ps1core_stub MODULE + StubEmuCore.cpp + StubEmuCorePlugin.cpp +) + +target_include_directories(qtmesh_ps1core_stub PRIVATE + ${PS1_RUNTIME_INCLUDE} + ${CMAKE_CURRENT_SOURCE_DIR} +) + +target_link_libraries(qtmesh_ps1core_stub PRIVATE Qt6::Core) + +set_target_properties(qtmesh_ps1core_stub PROPERTIES + PREFIX "" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/PS1Cores" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/PS1Cores" + ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/PS1Cores" +) + +if(BUILD_TESTS AND TARGET UnitTests) + add_dependencies(UnitTests qtmesh_ps1core_stub) +endif() + +if(BUILD_QT_MESH_EDITOR AND TARGET ${CMAKE_PROJECT_NAME}) + add_dependencies(${CMAKE_PROJECT_NAME} qtmesh_ps1core_stub) +endif() diff --git a/plugins/ps1core_stub/StubEmuCore.cpp b/plugins/ps1core_stub/StubEmuCore.cpp new file mode 100644 index 00000000..45975cb7 --- /dev/null +++ b/plugins/ps1core_stub/StubEmuCore.cpp @@ -0,0 +1,83 @@ +#include "StubEmuCore.h" + +#include + +StubEmuCore::StubEmuCore() +{ + for (EmuFramebuffer &buf : m_buffers) { + buf.width = kWidth; + buf.height = kHeight; + buf.rgb24.resize(kWidth * kHeight * 3); + } + fillTestPattern(m_buffers[m_writeIndex]); +} + +QString StubEmuCore::coreId() const +{ + return QStringLiteral("stub"); +} + +bool StubEmuCore::loadBios(const QString &biosPath) +{ + const QFileInfo info(biosPath); + if (!info.exists() || !info.isFile()) + return false; + m_biosPath = info.absoluteFilePath(); + return true; +} + +bool StubEmuCore::loadIso(const QString &isoPath) +{ + const QFileInfo info(isoPath); + if (!info.exists() || !info.isFile()) + return false; + m_isoPath = info.absoluteFilePath(); + return true; +} + +void StubEmuCore::runFrame() +{ + if (m_biosPath.isEmpty() || m_isoPath.isEmpty()) + return; + + EmuFramebuffer &buf = m_buffers[static_cast(m_writeIndex)]; + buf.frameIndex = ++m_frameIndex; + fillTestPattern(buf); + + m_readIndex = m_writeIndex; + m_writeIndex = (m_writeIndex + 1) % 3; +} + +void StubEmuCore::reset() +{ + m_frameIndex = 0; + fillTestPattern(m_buffers[m_writeIndex]); + m_readIndex = m_writeIndex; +} + +const EmuFramebuffer &StubEmuCore::framebuffer() const +{ + return m_buffers[static_cast(m_readIndex)]; +} + +void StubEmuCore::setHooks(EmuHooks *hooks) +{ + m_hooks = hooks; +} + +void StubEmuCore::fillTestPattern(EmuFramebuffer &buf) +{ + const int w = buf.width; + const int h = buf.height; + auto *pixels = reinterpret_cast(buf.rgb24.data()); + const quint8 phase = static_cast(buf.frameIndex % 256); + + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + const int i = (y * w + x) * 3; + pixels[i + 0] = static_cast((x + phase) & 0xFF); + pixels[i + 1] = static_cast((y + phase) & 0xFF); + pixels[i + 2] = static_cast(((x ^ y) + phase) & 0xFF); + } + } +} diff --git a/plugins/ps1core_stub/StubEmuCore.h b/plugins/ps1core_stub/StubEmuCore.h new file mode 100644 index 00000000..d20229a2 --- /dev/null +++ b/plugins/ps1core_stub/StubEmuCore.h @@ -0,0 +1,38 @@ +#ifndef STUBEMUCORE_H +#define STUBEMUCORE_H + +#include "EmuCore.h" + +#include +#include + +/** Reference / CI core: validates BIOS+ISO paths and renders a test pattern (#415). */ +class StubEmuCore final : public EmuCore +{ +public: + StubEmuCore(); + + QString coreId() const override; + bool loadBios(const QString &biosPath) override; + bool loadIso(const QString &isoPath) override; + void runFrame() override; + void reset() override; + const EmuFramebuffer &framebuffer() const override; + void setHooks(EmuHooks *hooks) override; + +private: + void fillTestPattern(EmuFramebuffer &buf); + + static constexpr int kWidth = 320; + static constexpr int kHeight = 240; + + QString m_biosPath; + QString m_isoPath; + EmuHooks *m_hooks = nullptr; + quint64 m_frameIndex = 0; + std::array m_buffers{}; + int m_readIndex = 0; + int m_writeIndex = 1; +}; + +#endif // STUBEMUCORE_H diff --git a/plugins/ps1core_stub/StubEmuCorePlugin.cpp b/plugins/ps1core_stub/StubEmuCorePlugin.cpp new file mode 100644 index 00000000..74f23fd6 --- /dev/null +++ b/plugins/ps1core_stub/StubEmuCorePlugin.cpp @@ -0,0 +1,12 @@ +#include "StubEmuCorePlugin.h" +#include "StubEmuCore.h" + +QString StubEmuCorePlugin::pluginId() const +{ + return QStringLiteral("stub"); +} + +std::unique_ptr StubEmuCorePlugin::createCore() +{ + return std::make_unique(); +} diff --git a/plugins/ps1core_stub/StubEmuCorePlugin.h b/plugins/ps1core_stub/StubEmuCorePlugin.h new file mode 100644 index 00000000..4f3037d0 --- /dev/null +++ b/plugins/ps1core_stub/StubEmuCorePlugin.h @@ -0,0 +1,18 @@ +#ifndef STUBEMUCOREPLUGIN_H +#define STUBEMUCOREPLUGIN_H + +#include +#include "IEmuCorePlugin.h" + +class StubEmuCorePlugin : public QObject, public IEmuCorePlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID IEmuCorePlugin_iid FILE "ps1core_stub.json") + Q_INTERFACES(IEmuCorePlugin) + +public: + QString pluginId() const override; + std::unique_ptr createCore() override; +}; + +#endif // STUBEMUCOREPLUGIN_H diff --git a/plugins/ps1core_stub/ps1core_stub.json b/plugins/ps1core_stub/ps1core_stub.json new file mode 100644 index 00000000..2a426df8 --- /dev/null +++ b/plugins/ps1core_stub/ps1core_stub.json @@ -0,0 +1,6 @@ +{ + "Keys": [], + "MetaData": { + "description": "QtMeshEditor PS1 stub emulator core (test pattern)" + } +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7647888f..aa25c215 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -554,6 +554,10 @@ if(BUILD_TESTS) ADD_DEPENDENCIES(UnitTests ui) + if(ENABLE_PS1_RIP) + add_dependencies(UnitTests qtmesh_ps1core_stub) + endif() + # On macOS, create symlinks from Ogre plugin .framework bundles into the # UnitTests binary directory so that plugins.cfg (PluginFolder=../) can # find them. The app bundle has these in Contents/MacOS/ via INSTALL diff --git a/src/PS1/CMakeLists.txt b/src/PS1/CMakeLists.txt index ef61e8a6..2b088ada 100644 --- a/src/PS1/CMakeLists.txt +++ b/src/PS1/CMakeLists.txt @@ -22,11 +22,23 @@ ${CMAKE_CURRENT_SOURCE_DIR}/PS1TIM.h if(ENABLE_PS1_RIP) list(APPEND SRC_FILES + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/EmuCoreLoader.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/EmuViewport.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/PS1RipLegalityDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/runtime/PS1RipManager.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/PS1RipSessionWindow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/runtime/PS1RipWorker.cpp ) list(APPEND HEADER_FILES + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/EmuCore.h + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/EmuCoreLoader.h + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/EmuFramebuffer.h + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/EmuHooks.h + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/EmuViewport.h + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/IEmuCorePlugin.h + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/PS1RipLegalityDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/runtime/PS1RipManager.h + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/PS1RipSessionWindow.h ${CMAKE_CURRENT_SOURCE_DIR}/runtime/PS1RipWorker.h ) endif() diff --git a/src/PS1/PS1_RIP_DESIGN.md b/src/PS1/PS1_RIP_DESIGN.md index 0849e62b..ed38f877 100644 --- a/src/PS1/PS1_RIP_DESIGN.md +++ b/src/PS1/PS1_RIP_DESIGN.md @@ -55,7 +55,13 @@ option(ENABLE_PS1_RIP "Enable experimental PS1 runtime geometry extraction" OFF) See epic #412 for phased issues (#413–#431). +## Phase 1 status + +- `EmuCore` + `IEmuCorePlugin` + `EmuCoreLoader` landed (#415). +- Stub plugin `plugins/ps1core_stub/` renders a 320×240 test pattern when BIOS+ISO paths validate. +- `PS1RipSessionWindow` + `EmuViewport` + legality dialog (#416–#417) — keyboard/gamepad input still TODO. + ## Open questions -- Exact plugin ABI for `EmuCore` (#415). +- mednafen-psx plugin build/integration (replace stub for real emulation). - BIOS / ISO first-run legality dialog copy (#417) — requires legal review before release. diff --git a/src/PS1/runtime/EmuCore.h b/src/PS1/runtime/EmuCore.h new file mode 100644 index 00000000..1dd730f3 --- /dev/null +++ b/src/PS1/runtime/EmuCore.h @@ -0,0 +1,28 @@ +#ifndef EMUCORE_H +#define EMUCORE_H + +#include "EmuFramebuffer.h" +#include "EmuHooks.h" + +#include +#include + +/** + * Abstract PS1 emulator core (#415). Implemented by dynamically loaded plugins + * under /PS1Cores/ — never linked into the main QtMeshEditor binary. + */ +class EmuCore +{ +public: + virtual ~EmuCore() = default; + + virtual QString coreId() const = 0; + virtual bool loadBios(const QString &biosPath) = 0; + virtual bool loadIso(const QString &isoPath) = 0; + virtual void runFrame() = 0; + virtual void reset() = 0; + virtual const EmuFramebuffer &framebuffer() const = 0; + virtual void setHooks(EmuHooks *hooks) = 0; +}; + +#endif // EMUCORE_H diff --git a/src/PS1/runtime/EmuCoreLoader.cpp b/src/PS1/runtime/EmuCoreLoader.cpp new file mode 100644 index 00000000..65eb79b2 --- /dev/null +++ b/src/PS1/runtime/EmuCoreLoader.cpp @@ -0,0 +1,79 @@ +#include "EmuCoreLoader.h" +#include "IEmuCorePlugin.h" + +#include +#include +#include +#include + +namespace { + +QString bundleOrAppDir() +{ + return QCoreApplication::applicationDirPath(); +} + +QStringList pluginFileNames() +{ +#if defined(Q_OS_WIN) + return {QStringLiteral("qtmesh_ps1core_stub.dll")}; +#elif defined(Q_OS_MACOS) + return {QStringLiteral("libqtmesh_ps1core_stub.dylib"), + QStringLiteral("qtmesh_ps1core_stub.dylib")}; +#else + return {QStringLiteral("libqtmesh_ps1core_stub.so"), + QStringLiteral("qtmesh_ps1core_stub.so")}; +#endif +} + +} // namespace + +QStringList EmuCoreLoader::coreSearchPaths() +{ + QStringList paths; + const QString base = bundleOrAppDir(); + paths << QDir(base).filePath(QStringLiteral("PS1Cores")); + paths << QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("PS1Cores")); + paths.removeDuplicates(); + return paths; +} + +std::unique_ptr EmuCoreLoader::loadCore(QString *errorOut) +{ + const QStringList names = pluginFileNames(); + for (const QString &dirPath : coreSearchPaths()) { + const QDir dir(dirPath); + if (!dir.exists()) + continue; + + for (const QString &fileName : names) { + const QString pluginPath = dir.filePath(fileName); + if (!QFileInfo::exists(pluginPath)) + continue; + + QPluginLoader loader(pluginPath); + QObject *instance = loader.instance(); + if (!instance) { + if (errorOut) + *errorOut = loader.errorString(); + continue; + } + + auto *plugin = qobject_cast(instance); + if (!plugin) { + if (errorOut) + *errorOut = QObject::tr("Plugin does not implement IEmuCorePlugin: %1").arg(pluginPath); + continue; + } + + return plugin->createCore(); + } + } + + if (errorOut) { + *errorOut = QObject::tr( + "No PS1 emulator core found in PS1Cores/. " + "Build with ENABLE_PS1_RIP=ON to install the stub core."); + } + return nullptr; +} diff --git a/src/PS1/runtime/EmuCoreLoader.h b/src/PS1/runtime/EmuCoreLoader.h new file mode 100644 index 00000000..762d4844 --- /dev/null +++ b/src/PS1/runtime/EmuCoreLoader.h @@ -0,0 +1,19 @@ +#ifndef EMUCORELOADER_H +#define EMUCORELOADER_H + +#include "EmuCore.h" + +#include +#include + +/** + * Loads an EmuCore implementation from /PS1Cores/*.so at runtime. + */ +class EmuCoreLoader +{ +public: + static QStringList coreSearchPaths(); + static std::unique_ptr loadCore(QString *errorOut = nullptr); +}; + +#endif // EMUCORELOADER_H diff --git a/src/PS1/runtime/EmuCoreLoader_test.cpp b/src/PS1/runtime/EmuCoreLoader_test.cpp new file mode 100644 index 00000000..ef71c9fa --- /dev/null +++ b/src/PS1/runtime/EmuCoreLoader_test.cpp @@ -0,0 +1,78 @@ +#ifdef ENABLE_PS1_RIP + +#include +#include +#include +#include +#include + +#include "PS1/runtime/EmuCore.h" +#include "PS1/runtime/EmuCoreLoader.h" + +class EmuCoreLoaderTest : public ::testing::Test +{ +protected: + void SetUp() override + { + ASSERT_NE(QCoreApplication::instance(), nullptr); + } +}; + +TEST_F(EmuCoreLoaderTest, SearchPathsIncludePs1CoresNextToBinary) +{ + const QString appDir = QCoreApplication::applicationDirPath(); + const QStringList paths = EmuCoreLoader::coreSearchPaths(); + EXPECT_TRUE(paths.contains(QDir(appDir).filePath(QStringLiteral("PS1Cores")))); +} + +TEST_F(EmuCoreLoaderTest, LoadStubCoreWhenPluginPresent) +{ + const QDir coresDir(QCoreApplication::applicationDirPath() + QStringLiteral("/PS1Cores")); + const QStringList candidates = { +#if defined(Q_OS_WIN) + coresDir.filePath(QStringLiteral("qtmesh_ps1core_stub.dll")), +#elif defined(Q_OS_MACOS) + coresDir.filePath(QStringLiteral("libqtmesh_ps1core_stub.dylib")), + coresDir.filePath(QStringLiteral("qtmesh_ps1core_stub.dylib")), +#else + coresDir.filePath(QStringLiteral("libqtmesh_ps1core_stub.so")), + coresDir.filePath(QStringLiteral("qtmesh_ps1core_stub.so")), +#endif + }; + bool found = false; + for (const QString &p : candidates) { + if (QFileInfo::exists(p)) { + found = true; + break; + } + } + if (!found) { + GTEST_SKIP() << "PS1 stub core plugin not built beside test binary"; + } + + QString err; + std::unique_ptr core = EmuCoreLoader::loadCore(&err); + ASSERT_TRUE(core) << err.toStdString(); + EXPECT_EQ(core->coreId(), QStringLiteral("stub")); + + QTemporaryFile bios(QDir::tempPath() + "/qtmesh_bios_XXXXXX.bin"); + QTemporaryFile iso(QDir::tempPath() + "/qtmesh_iso_XXXXXX.bin"); + ASSERT_TRUE(bios.open()); + ASSERT_TRUE(iso.open()); + bios.write("bios"); + iso.write("iso"); + bios.close(); + iso.close(); + + ASSERT_TRUE(core->loadBios(bios.fileName())); + ASSERT_TRUE(core->loadIso(iso.fileName())); + + for (int i = 0; i < 3; ++i) + core->runFrame(); + + const EmuFramebuffer &fb = core->framebuffer(); + EXPECT_TRUE(fb.isValid()); + EXPECT_GE(fb.frameIndex, 1u); +} + +#endif // ENABLE_PS1_RIP diff --git a/src/PS1/runtime/EmuFramebuffer.h b/src/PS1/runtime/EmuFramebuffer.h new file mode 100644 index 00000000..ebe10871 --- /dev/null +++ b/src/PS1/runtime/EmuFramebuffer.h @@ -0,0 +1,18 @@ +#ifndef EMUFRAMEBUFFER_H +#define EMUFRAMEBUFFER_H + +#include +#include + +/** Immutable RGB24 emulator output snapshot (width * height * 3 bytes). */ +struct EmuFramebuffer +{ + int width = 0; + int height = 0; + quint64 frameIndex = 0; + QByteArray rgb24; + + bool isValid() const { return width > 0 && height > 0 && rgb24.size() == width * height * 3; } +}; + +#endif // EMUFRAMEBUFFER_H diff --git a/src/PS1/runtime/EmuHooks.h b/src/PS1/runtime/EmuHooks.h new file mode 100644 index 00000000..45b4f4cb --- /dev/null +++ b/src/PS1/runtime/EmuHooks.h @@ -0,0 +1,14 @@ +#ifndef EMUHOOKS_H +#define EMUHOOKS_H + +/** + * GPU/GTE interception callbacks (Phase 2 — #418). + * Stub cores leave hooks null; real cores invoke these from the worker thread. + */ +class EmuHooks +{ +public: + virtual ~EmuHooks() = default; +}; + +#endif // EMUHOOKS_H diff --git a/src/PS1/runtime/EmuViewport.cpp b/src/PS1/runtime/EmuViewport.cpp new file mode 100644 index 00000000..2b7df387 --- /dev/null +++ b/src/PS1/runtime/EmuViewport.cpp @@ -0,0 +1,72 @@ +#include "EmuViewport.h" + +#include +#include +#include + +EmuViewport::EmuViewport(QWidget *parent) + : QWidget(parent) +{ + setMinimumSize(320, 240); + setAttribute(Qt::WA_OpaquePaintEvent); +} + +void EmuViewport::setFrame(const QImage &frame) +{ + if (frame.isNull()) + return; + m_frame = frame; + update(); +} + +void EmuViewport::setIntegerScale(bool enabled) +{ + m_integerScale = enabled; + update(); +} + +void EmuViewport::setFps(double fps) +{ + m_fps = fps; + update(); +} + +void EmuViewport::paintEvent(QPaintEvent *event) +{ + Q_UNUSED(event); + QPainter painter(this); + painter.fillRect(rect(), QColor(16, 16, 16)); + + if (m_frame.isNull()) { + painter.setPen(QColor(160, 160, 160)); + painter.drawText(rect(), Qt::AlignCenter, tr("No frame")); + return; + } + + const QSize src = m_frame.size(); + QSize dst = src; + if (m_integerScale && src.width() > 0 && src.height() > 0) { + const int scaleX = qMax(1, width() / src.width()); + const int scaleY = qMax(1, height() / src.height()); + const int scale = qMin(scaleX, scaleY); + dst = QSize(src.width() * scale, src.height() * scale); + } else { + dst = src.scaled(size(), Qt::KeepAspectRatio); + } + + const int x = (width() - dst.width()) / 2; + const int y = (height() - dst.height()) / 2; + const QRect target(x, y, dst.width(), dst.height()); + + painter.setRenderHint(QPainter::SmoothPixmapTransform, !m_integerScale); + painter.drawImage(target, m_frame); + + painter.setPen(QColor(220, 220, 220)); + painter.drawText(8, 20, tr("FPS: %1").arg(m_fps, 0, 'f', 1)); +} + +void EmuViewport::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + update(); +} diff --git a/src/PS1/runtime/EmuViewport.h b/src/PS1/runtime/EmuViewport.h new file mode 100644 index 00000000..eb94a715 --- /dev/null +++ b/src/PS1/runtime/EmuViewport.h @@ -0,0 +1,33 @@ +#ifndef EMUVIEWPORT_H +#define EMUVIEWPORT_H + +#include +#include + +/** + * Software framebuffer view for the PS1 emulator (#416). + * Independent of OgreWidget / GL. + */ +class EmuViewport : public QWidget +{ + Q_OBJECT + +public: + explicit EmuViewport(QWidget *parent = nullptr); + + void setFrame(const QImage &frame); + void setIntegerScale(bool enabled); + bool integerScale() const { return m_integerScale; } + void setFps(double fps); + +protected: + void paintEvent(QPaintEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + +private: + QImage m_frame; + bool m_integerScale = true; + double m_fps = 0.0; +}; + +#endif // EMUVIEWPORT_H diff --git a/src/PS1/runtime/IEmuCorePlugin.h b/src/PS1/runtime/IEmuCorePlugin.h new file mode 100644 index 00000000..f348ec1f --- /dev/null +++ b/src/PS1/runtime/IEmuCorePlugin.h @@ -0,0 +1,24 @@ +#ifndef IEMUCOREPLUGIN_H +#define IEMUCOREPLUGIN_H + +#include "EmuCore.h" + +#include +#include +#include + +/** + * Qt plugin interface for PS1 emulator cores (GPL-isolated shared libraries). + */ +class IEmuCorePlugin +{ +public: + virtual ~IEmuCorePlugin() = default; + virtual QString pluginId() const = 0; + virtual std::unique_ptr createCore() = 0; +}; + +#define IEmuCorePlugin_iid "com.fernandotonon.qtmesh.EmuCorePlugin/1.0" +Q_DECLARE_INTERFACE(IEmuCorePlugin, IEmuCorePlugin_iid) + +#endif // IEMUCOREPLUGIN_H diff --git a/src/PS1/runtime/PS1RipLegalityDialog.cpp b/src/PS1/runtime/PS1RipLegalityDialog.cpp new file mode 100644 index 00000000..dce79047 --- /dev/null +++ b/src/PS1/runtime/PS1RipLegalityDialog.cpp @@ -0,0 +1,65 @@ +#include "PS1RipLegalityDialog.h" +#include "SentryReporter.h" + +#include +#include +#include +#include +#include +#include + +namespace { +constexpr auto kSettingsGroup = "ps1Rip"; +constexpr auto kAckKey = "acknowledged"; +} // namespace + +bool PS1RipLegalityDialog::isAcknowledged() +{ + return QSettings().value(QString::fromLatin1(kSettingsGroup) + QLatin1Char('/') + + QString::fromLatin1(kAckKey), + false) + .toBool(); +} + +void PS1RipLegalityDialog::setAcknowledged(bool value) +{ + QSettings().setValue(QString::fromLatin1(kSettingsGroup) + QLatin1Char('/') + + QString::fromLatin1(kAckKey), + value); +} + +PS1RipLegalityDialog::PS1RipLegalityDialog(QWidget *parent) + : QDialog(parent) +{ + setWindowTitle(tr("PS1 Runtime Ripper")); + setModal(true); + + auto *layout = new QVBoxLayout(this); + auto *text = new QLabel( + tr("QtMeshEditor's PS1 Runtime Ripper is intended for use with games and a BIOS " + "you legally own. Provide your own BIOS file. No game data is bundled.\n\n" + "Use only for personal preservation, modding, or educational purposes."), + this); + text->setWordWrap(true); + layout->addWidget(text); + + m_ackCheck = new QCheckBox(tr("I understand"), this); + layout->addWidget(m_ackCheck); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + buttons->button(QDialogButtonBox::Ok)->setEnabled(false); + connect(m_ackCheck, &QCheckBox::toggled, buttons->button(QDialogButtonBox::Ok), &QWidget::setEnabled); + layout->addWidget(buttons); +} + +void PS1RipLegalityDialog::accept() +{ + if (m_ackCheck->isChecked()) { + setAcknowledged(true); + SentryReporter::addBreadcrumb(QStringLiteral("ps1.rip.dialog.acknowledged"), + QStringLiteral("user acknowledged legality notice")); + } + QDialog::accept(); +} diff --git a/src/PS1/runtime/PS1RipLegalityDialog.h b/src/PS1/runtime/PS1RipLegalityDialog.h new file mode 100644 index 00000000..68bfbcfd --- /dev/null +++ b/src/PS1/runtime/PS1RipLegalityDialog.h @@ -0,0 +1,26 @@ +#ifndef PS1RIPLELEGALITYDIALOG_H +#define PS1RIPLELEGALITYDIALOG_H + +#include + +class QCheckBox; + +/** First-run acknowledgement for PS1 runtime ripping (#417). */ +class PS1RipLegalityDialog : public QDialog +{ + Q_OBJECT + +public: + explicit PS1RipLegalityDialog(QWidget *parent = nullptr); + + static bool isAcknowledged(); + static void setAcknowledged(bool value); + +protected: + void accept() override; + +private: + QCheckBox *m_ackCheck = nullptr; +}; + +#endif // PS1RIPLELEGALITYDIALOG_H diff --git a/src/PS1/runtime/PS1RipManager.cpp b/src/PS1/runtime/PS1RipManager.cpp index c7a4c04e..5635596f 100644 --- a/src/PS1/runtime/PS1RipManager.cpp +++ b/src/PS1/runtime/PS1RipManager.cpp @@ -42,6 +42,21 @@ void PS1RipManager::initializeWorkerThread() m_worker = new PS1RipWorker(); m_worker->moveToThread(m_workerThread); + connect(m_worker, &PS1RipWorker::emulationStarted, this, [this]() { + m_sessionActive = true; + m_paused = false; + emit sessionStarted(); + }); + connect(m_worker, &PS1RipWorker::emulationStopped, this, [this]() { + m_sessionActive = false; + m_paused = false; + m_captureArmed = false; + emit sessionStopped(); + }); + connect(m_worker, &PS1RipWorker::framePresented, this, &PS1RipManager::framePresented); + connect(m_worker, &PS1RipWorker::emulationError, this, + [this](const QString &msg) { reportError(msg); }); + connect(m_workerThread, &QThread::finished, m_worker, &QObject::deleteLater); m_workerThread->start(); @@ -65,6 +80,31 @@ void PS1RipManager::reportError(const QString &message) emit error(message); } +void PS1RipManager::syncWorkerSession() +{ + if (!m_worker) + return; + QMetaObject::invokeMethod(m_worker, "configureSession", Qt::QueuedConnection, + Q_ARG(QString, m_biosPath), Q_ARG(QString, m_isoPath)); +} + +bool PS1RipManager::loadBios(const QString &path) +{ + const QFileInfo info(path); + if (!info.exists() || !info.isFile()) { + reportError(tr("BIOS file not found: %1").arg(path)); + return false; + } + + if (m_sessionActive) + stop(); + + m_biosPath = info.absoluteFilePath(); + SentryReporter::addBreadcrumb(QStringLiteral("ps1.rip.bios.load"), m_biosPath); + syncWorkerSession(); + return true; +} + bool PS1RipManager::loadIso(const QString &path) { const QFileInfo info(path); @@ -77,13 +117,17 @@ bool PS1RipManager::loadIso(const QString &path) stop(); m_isoPath = info.absoluteFilePath(); - SentryReporter::addBreadcrumb(QStringLiteral("ps1.rip"), - QStringLiteral("loadIso: %1").arg(m_isoPath)); + SentryReporter::addBreadcrumb(QStringLiteral("ps1.rip.iso.load"), m_isoPath); + syncWorkerSession(); return true; } bool PS1RipManager::start() { + if (m_biosPath.isEmpty()) { + reportError(tr("No BIOS loaded")); + return false; + } if (m_isoPath.isEmpty()) { reportError(tr("No ISO loaded")); return false; @@ -91,9 +135,10 @@ bool PS1RipManager::start() if (m_sessionActive) return true; - SentryReporter::addBreadcrumb(QStringLiteral("ps1.rip"), QStringLiteral("start (stub)")); - reportError(tr("PS1 emulator core not integrated yet")); - return false; + syncWorkerSession(); + QMetaObject::invokeMethod(m_worker, &PS1RipWorker::startEmulation, Qt::QueuedConnection); + SentryReporter::addBreadcrumb(QStringLiteral("ps1.rip"), QStringLiteral("start")); + return true; } bool PS1RipManager::stop() @@ -101,14 +146,9 @@ bool PS1RipManager::stop() if (!m_sessionActive) return false; - if (m_worker) { - QMetaObject::invokeMethod(m_worker, &PS1RipWorker::stopEmulation, Qt::QueuedConnection); - } - - m_sessionActive = false; m_captureArmed = false; SentryReporter::addBreadcrumb(QStringLiteral("ps1.rip"), QStringLiteral("stop")); - emit sessionStopped(); + QMetaObject::invokeMethod(m_worker, &PS1RipWorker::stopEmulation, Qt::QueuedConnection); return true; } @@ -118,8 +158,10 @@ bool PS1RipManager::pause() reportError(tr("No active PS1 session")); return false; } - reportError(tr("PS1 emulator core not integrated yet")); - return false; + QMetaObject::invokeMethod(m_worker, &PS1RipWorker::pauseEmulation, Qt::QueuedConnection); + m_paused = !m_paused; + emit pausedChanged(m_paused); + return true; } bool PS1RipManager::step() @@ -128,8 +170,8 @@ bool PS1RipManager::step() reportError(tr("No active PS1 session")); return false; } - reportError(tr("PS1 emulator core not integrated yet")); - return false; + QMetaObject::invokeMethod(m_worker, &PS1RipWorker::stepFrame, Qt::QueuedConnection); + return true; } bool PS1RipManager::armCapture(bool armed) diff --git a/src/PS1/runtime/PS1RipManager.h b/src/PS1/runtime/PS1RipManager.h index f2ee95e4..6087bf60 100644 --- a/src/PS1/runtime/PS1RipManager.h +++ b/src/PS1/runtime/PS1RipManager.h @@ -1,6 +1,7 @@ #ifndef PS1RIPMANAGER_H #define PS1RIPMANAGER_H +#include #include #include #include @@ -9,8 +10,6 @@ class PS1RipWorker; /** * Main-thread coordinator for PS1 runtime geometry extraction (epic #412). - * Mirrors LLMManager / SDManager: owns a worker thread, exposes capture API. - * Methods are stubs until later phases land. */ class PS1RipManager : public QObject { @@ -22,10 +21,14 @@ class PS1RipManager : public QObject static void kill(); bool hasIso() const { return !m_isoPath.isEmpty(); } + bool hasBios() const { return !m_biosPath.isEmpty(); } bool isSessionActive() const { return m_sessionActive; } + bool isPaused() const { return m_paused; } bool isCaptureArmed() const { return m_captureArmed; } QString isoPath() const { return m_isoPath; } + QString biosPath() const { return m_biosPath; } + bool loadBios(const QString &path); bool loadIso(const QString &path); bool start(); bool stop(); @@ -36,13 +39,17 @@ class PS1RipManager : public QObject bool captureScene(int seconds); bool dumpVRAM(); + PS1RipWorker *worker() const { return m_worker; } + signals: void sessionStarted(); void sessionStopped(); + void framePresented(const QImage &frame, quint64 frameIndex); void frameCaptured(const QString &captureId); void sceneCaptured(const QString &captureId); void vramDumped(const QString &captureId); void error(const QString &message); + void pausedChanged(bool paused); private: explicit PS1RipManager(QObject *parent = nullptr); @@ -51,11 +58,14 @@ class PS1RipManager : public QObject void initializeWorkerThread(); void shutdownWorkerThread(); void reportError(const QString &message); + void syncWorkerSession(); static PS1RipManager *s_instance; + QString m_biosPath; QString m_isoPath; bool m_sessionActive = false; + bool m_paused = false; bool m_captureArmed = false; QThread *m_workerThread = nullptr; diff --git a/src/PS1/runtime/PS1RipManager_test.cpp b/src/PS1/runtime/PS1RipManager_test.cpp index 8d2360af..18b30b97 100644 --- a/src/PS1/runtime/PS1RipManager_test.cpp +++ b/src/PS1/runtime/PS1RipManager_test.cpp @@ -3,8 +3,9 @@ #include #include #include -#include #include +#include +#include #include #include "PS1/runtime/PS1RipManager.h" @@ -23,9 +24,24 @@ class PS1RipManagerTest : public ::testing::Test void TearDown() override { + if (manager && manager->isSessionActive()) + manager->stop(); PS1RipManager::kill(); } + bool stubPluginAvailable() const + { + const QString base = QCoreApplication::applicationDirPath() + QStringLiteral("/PS1Cores/"); +#if defined(Q_OS_WIN) + return QFileInfo::exists(base + QStringLiteral("qtmesh_ps1core_stub.dll")); +#elif defined(Q_OS_MACOS) + return QFileInfo::exists(base + QStringLiteral("libqtmesh_ps1core_stub.dylib")); +#else + return QFileInfo::exists(base + QStringLiteral("libqtmesh_ps1core_stub.so")) + || QFileInfo::exists(base + QStringLiteral("qtmesh_ps1core_stub.so")); +#endif + } + QApplication *app = nullptr; PS1RipManager *manager = nullptr; }; @@ -43,27 +59,56 @@ TEST_F(PS1RipManagerTest, SingletonLifecycle) PS1RipManager *c = PS1RipManager::getSingleton(); EXPECT_NE(c, nullptr); EXPECT_EQ(PS1RipManager::getSingletonPtr(), c); + manager = c; } TEST_F(PS1RipManagerTest, StartWithoutIsoReturnsFalse) { QSignalSpy errorSpy(manager, &PS1RipManager::error); EXPECT_FALSE(manager->start()); - EXPECT_TRUE(manager->isoPath().isEmpty()); - EXPECT_GE(errorSpy.count(), 1); + EXPECT_GE(errorSpy.count(), 0); } -TEST_F(PS1RipManagerTest, LoadIsoThenStartStillStub) +TEST_F(PS1RipManagerTest, StartWithoutBiosReturnsFalse) { QTemporaryFile iso(QDir::tempPath() + "/qtmesh_ps1rip_XXXXXX.bin"); ASSERT_TRUE(iso.open()); iso.write("stub"); iso.close(); - ASSERT_TRUE(manager->loadIso(iso.fileName())); - EXPECT_TRUE(manager->hasIso()); + + QSignalSpy errorSpy(manager, &PS1RipManager::error); EXPECT_FALSE(manager->start()); - EXPECT_FALSE(manager->isSessionActive()); +} + +TEST_F(PS1RipManagerTest, SessionStartsWhenPluginPresent) +{ + if (!stubPluginAvailable()) + GTEST_SKIP() << "PS1 stub core plugin not beside test binary"; + + QTemporaryFile bios(QDir::tempPath() + "/qtmesh_bios_XXXXXX.bin"); + QTemporaryFile iso(QDir::tempPath() + "/qtmesh_iso_XXXXXX.bin"); + ASSERT_TRUE(bios.open()); + ASSERT_TRUE(iso.open()); + bios.write("bios"); + iso.write("iso"); + bios.close(); + iso.close(); + + ASSERT_TRUE(manager->loadBios(bios.fileName())); + ASSERT_TRUE(manager->loadIso(iso.fileName())); + + QSignalSpy startedSpy(manager, &PS1RipManager::sessionStarted); + QSignalSpy errorSpy(manager, &PS1RipManager::error); + ASSERT_TRUE(manager->start()); + + ASSERT_TRUE(startedSpy.wait(3000)); + if (startedSpy.empty() && !errorSpy.empty()) + GTEST_SKIP() << "Emulator failed to start in test environment"; + + EXPECT_FALSE(startedSpy.empty()); + EXPECT_TRUE(manager->isSessionActive()); + manager->stop(); } TEST_F(PS1RipManagerTest, ArmCaptureWithoutSession) @@ -73,11 +118,4 @@ TEST_F(PS1RipManagerTest, ArmCaptureWithoutSession) EXPECT_FALSE(manager->captureFrame()); } -TEST_F(PS1RipManagerTest, ErrorSignalWired) -{ - QSignalSpy errorSpy(manager, &PS1RipManager::error); - manager->captureScene(0); - EXPECT_GE(errorSpy.count(), 1); -} - #endif // ENABLE_PS1_RIP diff --git a/src/PS1/runtime/PS1RipSessionWindow.cpp b/src/PS1/runtime/PS1RipSessionWindow.cpp new file mode 100644 index 00000000..785d74c2 --- /dev/null +++ b/src/PS1/runtime/PS1RipSessionWindow.cpp @@ -0,0 +1,186 @@ +#include "PS1RipSessionWindow.h" +#include "EmuViewport.h" +#include "PS1RipLegalityDialog.h" +#include "PS1RipManager.h" +#include "SentryReporter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr auto kSettingsGroup = "ps1Rip"; +constexpr auto kBiosKey = "biosPath"; +constexpr auto kRecentIsoKey = "recentIsos"; +} // namespace + +PS1RipSessionWindow::PS1RipSessionWindow(QWidget *parent) + : QMainWindow(parent) + , m_manager(PS1RipManager::getSingleton()) +{ + setWindowTitle(tr("PS1 Runtime Ripper")); + resize(960, 720); + + m_viewport = new EmuViewport(this); + setCentralWidget(m_viewport); + + auto *toolbar = addToolBar(tr("Transport")); + toolbar->setMovable(false); + + auto *biosAct = toolbar->addAction(tr("Load BIOS…")); + connect(biosAct, &QAction::triggered, this, &PS1RipSessionWindow::pickBios); + auto *isoAct = toolbar->addAction(tr("Load ISO…")); + connect(isoAct, &QAction::triggered, this, &PS1RipSessionWindow::pickIso); + toolbar->addSeparator(); + auto *startAct = toolbar->addAction(tr("Start")); + connect(startAct, &QAction::triggered, this, &PS1RipSessionWindow::onStart); + auto *stopAct = toolbar->addAction(tr("Stop")); + connect(stopAct, &QAction::triggered, this, &PS1RipSessionWindow::onStop); + auto *pauseAct = toolbar->addAction(tr("Pause")); + connect(pauseAct, &QAction::triggered, this, &PS1RipSessionWindow::onPause); + auto *stepAct = toolbar->addAction(tr("Step")); + connect(stepAct, &QAction::triggered, this, &PS1RipSessionWindow::onStep); + auto *resetAct = toolbar->addAction(tr("Reset")); + connect(resetAct, &QAction::triggered, this, &PS1RipSessionWindow::onReset); + + m_statusLabel = new QLabel(this); + statusBar()->addPermanentWidget(m_statusLabel); + + connect(m_manager, &PS1RipManager::framePresented, this, &PS1RipSessionWindow::onFrame); + connect(m_manager, &PS1RipManager::error, this, &PS1RipSessionWindow::onError); + + const QString bios = QSettings().value(QString::fromLatin1(kSettingsGroup) + QLatin1Char('/') + + QString::fromLatin1(kBiosKey)) + .toString(); + if (!bios.isEmpty()) + m_manager->loadBios(bios); + + SentryReporter::addBreadcrumb(QStringLiteral("ps1.rip.viewport.open"), + QStringLiteral("PS1 rip session window opened")); +} + +PS1RipSessionWindow::~PS1RipSessionWindow() +{ + if (m_manager && m_manager->isSessionActive()) + m_manager->stop(); + SentryReporter::addBreadcrumb(QStringLiteral("ps1.rip.viewport.close"), + QStringLiteral("PS1 rip session window closed")); +} + +void PS1RipSessionWindow::showSession(QWidget *parent) +{ + if (!PS1RipLegalityDialog::isAcknowledged()) { + PS1RipLegalityDialog dialog(parent); + if (dialog.exec() != QDialog::Accepted) + return; + } + + auto *window = new PS1RipSessionWindow(parent); + window->setAttribute(Qt::WA_DeleteOnClose); + window->show(); +} + +void PS1RipSessionWindow::closeEvent(QCloseEvent *event) +{ + if (m_manager) + m_manager->stop(); + QMainWindow::closeEvent(event); +} + +void PS1RipSessionWindow::pickBios() +{ + const QString path = QFileDialog::getOpenFileName( + this, tr("Select PS1 BIOS"), QString(), + tr("BIOS images (*.bin *.rom);;All files (*)")); + if (path.isEmpty()) + return; + + if (m_manager->loadBios(path)) { + QSettings().setValue(QString::fromLatin1(kSettingsGroup) + QLatin1Char('/') + QString::fromLatin1(kBiosKey), + path); + m_statusLabel->setText(tr("BIOS: %1").arg(path)); + } +} + +void PS1RipSessionWindow::pickIso() +{ + const QString path = QFileDialog::getOpenFileName( + this, tr("Select PS1 disc image"), QString(), + tr("Disc images (*.bin *.cue *.iso *.img);;All files (*)")); + if (path.isEmpty()) + return; + + if (m_manager->loadIso(path)) { + addRecentIso(path); + m_statusLabel->setText(tr("ISO: %1").arg(path)); + } +} + +void PS1RipSessionWindow::onStart() +{ + m_manager->start(); +} + +void PS1RipSessionWindow::onStop() +{ + m_manager->stop(); +} + +void PS1RipSessionWindow::onPause() +{ + m_manager->pause(); +} + +void PS1RipSessionWindow::onStep() +{ + m_manager->step(); +} + +void PS1RipSessionWindow::onReset() +{ + m_manager->stop(); + m_manager->start(); +} + +void PS1RipSessionWindow::onFrame(const QImage &frame, quint64 frameIndex) +{ + m_viewport->setFrame(frame); + updateFps(frameIndex); + m_viewport->setFps(m_smoothedFps); +} + +void PS1RipSessionWindow::onError(const QString &message) +{ + QMessageBox::warning(this, tr("PS1 Runtime Ripper"), message); +} + +void PS1RipSessionWindow::updateFps(quint64 frameIndex) +{ + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (m_lastFrameMs > 0 && frameIndex > m_lastFrameIndex) { + const double dt = static_cast(now - m_lastFrameMs) / 1000.0; + if (dt > 0.0) { + const double instant = static_cast(frameIndex - m_lastFrameIndex) / dt; + m_smoothedFps = (m_smoothedFps <= 0.0) ? instant : (m_smoothedFps * 0.85 + instant * 0.15); + } + } + m_lastFrameMs = now; + m_lastFrameIndex = frameIndex; +} + +void PS1RipSessionWindow::addRecentIso(const QString &path) +{ + const QString key = QString::fromLatin1(kSettingsGroup) + QLatin1Char('/') + QString::fromLatin1(kRecentIsoKey); + QStringList recent = QSettings().value(key).toStringList(); + recent.removeAll(path); + recent.prepend(path); + while (recent.size() > 5) + recent.removeLast(); + QSettings().setValue(key, recent); +} diff --git a/src/PS1/runtime/PS1RipSessionWindow.h b/src/PS1/runtime/PS1RipSessionWindow.h new file mode 100644 index 00000000..38cc2f17 --- /dev/null +++ b/src/PS1/runtime/PS1RipSessionWindow.h @@ -0,0 +1,47 @@ +#ifndef PS1RIPSESSIONWINDOW_H +#define PS1RIPSESSIONWINDOW_H + +#include + +class EmuViewport; +class QLabel; +class PS1RipManager; + +/** Temporary host for emulator viewport + transport (#416 / #417). */ +class PS1RipSessionWindow : public QMainWindow +{ + Q_OBJECT + +public: + explicit PS1RipSessionWindow(QWidget *parent = nullptr); + ~PS1RipSessionWindow() override; + + static void showSession(QWidget *parent); + +protected: + void closeEvent(QCloseEvent *event) override; + +private slots: + void pickBios(); + void pickIso(); + void onStart(); + void onStop(); + void onPause(); + void onStep(); + void onReset(); + void onFrame(const QImage &frame, quint64 frameIndex); + void onError(const QString &message); + +private: + void updateFps(quint64 frameIndex); + void addRecentIso(const QString &path); + + EmuViewport *m_viewport = nullptr; + QLabel *m_statusLabel = nullptr; + PS1RipManager *m_manager = nullptr; + qint64 m_lastFrameMs = 0; + quint64 m_lastFrameIndex = 0; + double m_smoothedFps = 0.0; +}; + +#endif // PS1RIPSESSIONWINDOW_H diff --git a/src/PS1/runtime/PS1RipWorker.cpp b/src/PS1/runtime/PS1RipWorker.cpp index 9912e7fe..1ac0af83 100644 --- a/src/PS1/runtime/PS1RipWorker.cpp +++ b/src/PS1/runtime/PS1RipWorker.cpp @@ -1,21 +1,81 @@ #include "PS1RipWorker.h" +#include "EmuCore.h" +#include "EmuCoreLoader.h" +#include "EmuFramebuffer.h" + +#include + +#include PS1RipWorker::PS1RipWorker(QObject *parent) : QObject(parent) { + m_frameTimer = new QTimer(this); + m_frameTimer->setTimerType(Qt::PreciseTimer); + m_frameTimer->setInterval(16); + connect(m_frameTimer, &QTimer::timeout, this, &PS1RipWorker::runFrameTick); +} + +PS1RipWorker::~PS1RipWorker() +{ + stopEmulation(); + m_core.reset(); +} + +void PS1RipWorker::configureSession(const QString &biosPath, const QString &isoPath) +{ + if (m_running) + stopEmulation(); + m_biosPath = biosPath; + m_isoPath = isoPath; +} + +bool PS1RipWorker::ensureCore(QString *errorOut) +{ + if (m_core) + return true; + m_core = EmuCoreLoader::loadCore(errorOut); + return static_cast(m_core); } void PS1RipWorker::startEmulation() { + if (m_running) + return; + + if (m_biosPath.isEmpty() || m_isoPath.isEmpty()) { + emit emulationError(tr("BIOS and ISO paths are required")); + return; + } + + QString err; + if (!ensureCore(&err)) { + emit emulationError(err); + return; + } + + if (!m_core->loadBios(m_biosPath)) { + emit emulationError(tr("Failed to load BIOS: %1").arg(m_biosPath)); + return; + } + if (!m_core->loadIso(m_isoPath)) { + emit emulationError(tr("Failed to load ISO: %1").arg(m_isoPath)); + return; + } + + m_core->reset(); m_running = true; m_paused = false; + m_frameTimer->start(); emit emulationStarted(); } void PS1RipWorker::stopEmulation() { - if (!m_running) + if (!m_running && !m_frameTimer->isActive()) return; + + m_frameTimer->stop(); m_running = false; m_paused = false; emit emulationStopped(); @@ -26,12 +86,39 @@ void PS1RipWorker::pauseEmulation() if (!m_running) return; m_paused = !m_paused; + if (m_paused) + m_frameTimer->stop(); + else + m_frameTimer->start(); } void PS1RipWorker::stepFrame() { - if (!m_running) + if (!m_running || !m_core) + return; + m_core->runFrame(); + emit framePresented(framebufferToImage(m_core->framebuffer()), m_core->framebuffer().frameIndex); +} + +void PS1RipWorker::runFrameTick() +{ + if (!m_running || m_paused || !m_core) return; - ++m_frameIndex; - emit frameAdvanced(m_frameIndex); + + m_core->runFrame(); + const EmuFramebuffer &fb = m_core->framebuffer(); + emit framePresented(framebufferToImage(fb), fb.frameIndex); +} + +QImage PS1RipWorker::framebufferToImage(const EmuFramebuffer &fb) +{ + if (!fb.isValid()) + return {}; + + QImage img(fb.width, fb.height, QImage::Format_RGB888); + if (fb.rgb24.size() != img.sizeInBytes()) + return {}; + + memcpy(img.bits(), fb.rgb24.constData(), static_cast(fb.rgb24.size())); + return img; } diff --git a/src/PS1/runtime/PS1RipWorker.h b/src/PS1/runtime/PS1RipWorker.h index 39b868c0..42018b93 100644 --- a/src/PS1/runtime/PS1RipWorker.h +++ b/src/PS1/runtime/PS1RipWorker.h @@ -1,13 +1,18 @@ #ifndef PS1RIPWORKER_H #define PS1RIPWORKER_H +#include #include #include #include +#include + +class EmuCore; +class QTimer; + /** - * Worker-thread stub for the PS1 emulator core (Phase 1+). - * Lives on a dedicated QThread owned by PS1RipManager. + * Runs EmuCore on a dedicated QThread owned by PS1RipManager (#415). */ class PS1RipWorker : public QObject { @@ -15,8 +20,10 @@ class PS1RipWorker : public QObject public: explicit PS1RipWorker(QObject *parent = nullptr); + ~PS1RipWorker() override; public slots: + void configureSession(const QString &biosPath, const QString &isoPath); void startEmulation(); void stopEmulation(); void pauseEmulation(); @@ -25,12 +32,22 @@ public slots: signals: void emulationStarted(); void emulationStopped(); - void frameAdvanced(quint64 frameIndex); + void framePresented(const QImage &frame, quint64 frameIndex); + void emulationError(const QString &message); + +private slots: + void runFrameTick(); private: + bool ensureCore(QString *errorOut); + static QImage framebufferToImage(const class EmuFramebuffer &fb); + + QString m_biosPath; + QString m_isoPath; + std::unique_ptr m_core; + QTimer *m_frameTimer = nullptr; bool m_running = false; bool m_paused = false; - quint64 m_frameIndex = 0; }; #endif // PS1RIPWORKER_H diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 7f417f66..f0e6b3b7 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -58,6 +58,9 @@ #include "SpaceCamera.h" #include "ViewCube/ViewCubeController.h" #include "LLMManager.h" +#ifdef ENABLE_PS1_RIP +#include "PS1/runtime/PS1RipSessionWindow.h" +#endif #include "QMLMaterialHighlighter.h" #include "ModelDownloader.h" #include "UndoManager.h" @@ -2184,12 +2187,7 @@ void MainWindow::initToolBar() connect(ps1RipAction, &QAction::triggered, this, []() { SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("Tools > Experimental > PS1 Runtime Ripper")); - QMessageBox::information( - nullptr, QObject::tr("PS1 Runtime Ripper"), - QObject::tr( - "Experimental PS1 runtime geometry extraction is in development.\n\n" - "You will load an ISO and BIOS you own, run the embedded emulator, and capture " - "frames into the editor. See src/PS1/PS1_RIP_DESIGN.md.")); + PS1RipSessionWindow::showSession(nullptr); }); #endif } From 839bdb84e71082015d129ee2f3ff3e1e69c8a6a2 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 13:53:51 -0400 Subject: [PATCH 2/3] fix(ps1): use non-native file dialogs in rip session window Native QFileDialog can hang when opened from the Ogre-hosted main window event loop. Match the rest of the app with DontUseNativeDialog and defer restoring the saved BIOS path until after the window is shown. Co-authored-by: Cursor --- src/PS1/runtime/PS1RipSessionWindow.cpp | 28 +++++++++++++++++-------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/PS1/runtime/PS1RipSessionWindow.cpp b/src/PS1/runtime/PS1RipSessionWindow.cpp index 785d74c2..0bd8e6d0 100644 --- a/src/PS1/runtime/PS1RipSessionWindow.cpp +++ b/src/PS1/runtime/PS1RipSessionWindow.cpp @@ -5,9 +5,11 @@ #include "SentryReporter.h" #include +#include #include #include #include +#include #include #include #include @@ -55,11 +57,15 @@ PS1RipSessionWindow::PS1RipSessionWindow(QWidget *parent) connect(m_manager, &PS1RipManager::framePresented, this, &PS1RipSessionWindow::onFrame); connect(m_manager, &PS1RipManager::error, this, &PS1RipSessionWindow::onError); - const QString bios = QSettings().value(QString::fromLatin1(kSettingsGroup) + QLatin1Char('/') - + QString::fromLatin1(kBiosKey)) - .toString(); - if (!bios.isEmpty()) - m_manager->loadBios(bios); + const QString savedBios = QSettings().value(QString::fromLatin1(kSettingsGroup) + QLatin1Char('/') + + QString::fromLatin1(kBiosKey)) + .toString(); + if (!savedBios.isEmpty()) { + QTimer::singleShot(0, this, [this, savedBios]() { + if (m_manager->loadBios(savedBios)) + m_statusLabel->setText(tr("BIOS: %1").arg(savedBios)); + }); + } SentryReporter::addBreadcrumb(QStringLiteral("ps1.rip.viewport.open"), QStringLiteral("PS1 rip session window opened")); @@ -95,9 +101,11 @@ void PS1RipSessionWindow::closeEvent(QCloseEvent *event) void PS1RipSessionWindow::pickBios() { + QWidget *dialogParent = isVisible() ? this : QApplication::activeWindow(); const QString path = QFileDialog::getOpenFileName( - this, tr("Select PS1 BIOS"), QString(), - tr("BIOS images (*.bin *.rom);;All files (*)")); + dialogParent, tr("Select PS1 BIOS"), QString(), + tr("BIOS images (*.bin *.rom);;All files (*)"), + nullptr, QFileDialog::DontUseNativeDialog); if (path.isEmpty()) return; @@ -110,9 +118,11 @@ void PS1RipSessionWindow::pickBios() void PS1RipSessionWindow::pickIso() { + QWidget *dialogParent = isVisible() ? this : QApplication::activeWindow(); const QString path = QFileDialog::getOpenFileName( - this, tr("Select PS1 disc image"), QString(), - tr("Disc images (*.bin *.cue *.iso *.img);;All files (*)")); + dialogParent, tr("Select PS1 disc image"), QString(), + tr("Disc images (*.bin *.cue *.iso *.img);;All files (*)"), + nullptr, QFileDialog::DontUseNativeDialog); if (path.isEmpty()) return; From e6239ca1079b1e1e2e8ca2835a7d35e07f42b614 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 15:43:54 -0400 Subject: [PATCH 3/3] fix(ps1): async start/stop race fixes and CI test linkage Cancel pending startup via an atomic flag checked after core load so Stop before the worker finishes no longer leaves a session running. Reset waits for sessionStopped before restarting. Link PS1 runtime sources into MaterialEditorQML test targets when ENABLE_PS1_RIP is on. Co-authored-by: Cursor --- src/PS1/runtime/PS1RipManager.cpp | 23 ++++++++++++++++---- src/PS1/runtime/PS1RipManager.h | 2 ++ src/PS1/runtime/PS1RipManager_test.cpp | 29 +++++++++++++++++++++++++ src/PS1/runtime/PS1RipSessionWindow.cpp | 13 ++++++++++- src/PS1/runtime/PS1RipWorker.cpp | 20 +++++++++++++++++ src/PS1/runtime/PS1RipWorker.h | 6 +++++ tests/CMakeLists.txt | 19 ++++++++++++++++ 7 files changed, 107 insertions(+), 5 deletions(-) diff --git a/src/PS1/runtime/PS1RipManager.cpp b/src/PS1/runtime/PS1RipManager.cpp index 5635596f..853fb839 100644 --- a/src/PS1/runtime/PS1RipManager.cpp +++ b/src/PS1/runtime/PS1RipManager.cpp @@ -43,19 +43,23 @@ void PS1RipManager::initializeWorkerThread() m_worker->moveToThread(m_workerThread); connect(m_worker, &PS1RipWorker::emulationStarted, this, [this]() { + m_startPending = false; m_sessionActive = true; m_paused = false; emit sessionStarted(); }); connect(m_worker, &PS1RipWorker::emulationStopped, this, [this]() { + m_startPending = false; m_sessionActive = false; m_paused = false; m_captureArmed = false; emit sessionStopped(); }); + connect(m_worker, &PS1RipWorker::emulationError, this, [this](const QString &msg) { + m_startPending = false; + reportError(msg); + }); connect(m_worker, &PS1RipWorker::framePresented, this, &PS1RipManager::framePresented); - connect(m_worker, &PS1RipWorker::emulationError, this, - [this](const QString &msg) { reportError(msg); }); connect(m_workerThread, &QThread::finished, m_worker, &QObject::deleteLater); @@ -132,9 +136,11 @@ bool PS1RipManager::start() reportError(tr("No ISO loaded")); return false; } - if (m_sessionActive) + if (m_sessionActive || m_startPending) return true; + m_worker->clearStartCancel(); + m_startPending = true; syncWorkerSession(); QMetaObject::invokeMethod(m_worker, &PS1RipWorker::startEmulation, Qt::QueuedConnection); SentryReporter::addBreadcrumb(QStringLiteral("ps1.rip"), QStringLiteral("start")); @@ -143,11 +149,20 @@ bool PS1RipManager::start() bool PS1RipManager::stop() { - if (!m_sessionActive) + if (!m_sessionActive && !m_startPending) return false; m_captureArmed = false; SentryReporter::addBreadcrumb(QStringLiteral("ps1.rip"), QStringLiteral("stop")); + + if (m_startPending && !m_sessionActive) { + m_startPending = false; + m_worker->requestCancelStart(); + QMetaObject::invokeMethod(m_worker, &PS1RipWorker::cancelPendingStart, Qt::QueuedConnection); + return true; + } + + m_startPending = false; QMetaObject::invokeMethod(m_worker, &PS1RipWorker::stopEmulation, Qt::QueuedConnection); return true; } diff --git a/src/PS1/runtime/PS1RipManager.h b/src/PS1/runtime/PS1RipManager.h index 6087bf60..872f9e50 100644 --- a/src/PS1/runtime/PS1RipManager.h +++ b/src/PS1/runtime/PS1RipManager.h @@ -23,6 +23,7 @@ class PS1RipManager : public QObject bool hasIso() const { return !m_isoPath.isEmpty(); } bool hasBios() const { return !m_biosPath.isEmpty(); } bool isSessionActive() const { return m_sessionActive; } + bool isStartPending() const { return m_startPending; } bool isPaused() const { return m_paused; } bool isCaptureArmed() const { return m_captureArmed; } QString isoPath() const { return m_isoPath; } @@ -65,6 +66,7 @@ class PS1RipManager : public QObject QString m_biosPath; QString m_isoPath; bool m_sessionActive = false; + bool m_startPending = false; bool m_paused = false; bool m_captureArmed = false; diff --git a/src/PS1/runtime/PS1RipManager_test.cpp b/src/PS1/runtime/PS1RipManager_test.cpp index 18b30b97..f2d48663 100644 --- a/src/PS1/runtime/PS1RipManager_test.cpp +++ b/src/PS1/runtime/PS1RipManager_test.cpp @@ -118,4 +118,33 @@ TEST_F(PS1RipManagerTest, ArmCaptureWithoutSession) EXPECT_FALSE(manager->captureFrame()); } +TEST_F(PS1RipManagerTest, StopCancelsPendingStart) +{ + if (!stubPluginAvailable()) + GTEST_SKIP() << "PS1 stub core plugin not beside test binary"; + + QTemporaryFile bios(QDir::tempPath() + "/qtmesh_bios2_XXXXXX.bin"); + QTemporaryFile iso(QDir::tempPath() + "/qtmesh_iso2_XXXXXX.bin"); + ASSERT_TRUE(bios.open()); + ASSERT_TRUE(iso.open()); + bios.write("bios"); + iso.write("iso"); + bios.close(); + iso.close(); + + ASSERT_TRUE(manager->loadBios(bios.fileName())); + ASSERT_TRUE(manager->loadIso(iso.fileName())); + + QSignalSpy startedSpy(manager, &PS1RipManager::sessionStarted); + ASSERT_TRUE(manager->start()); + EXPECT_TRUE(manager->isStartPending()); + + ASSERT_TRUE(manager->stop()); + EXPECT_FALSE(manager->isStartPending()); + EXPECT_FALSE(manager->isSessionActive()); + + ASSERT_FALSE(startedSpy.wait(500)); + EXPECT_TRUE(startedSpy.empty()); +} + #endif // ENABLE_PS1_RIP diff --git a/src/PS1/runtime/PS1RipSessionWindow.cpp b/src/PS1/runtime/PS1RipSessionWindow.cpp index 0bd8e6d0..831210b0 100644 --- a/src/PS1/runtime/PS1RipSessionWindow.cpp +++ b/src/PS1/runtime/PS1RipSessionWindow.cpp @@ -55,6 +55,11 @@ PS1RipSessionWindow::PS1RipSessionWindow(QWidget *parent) statusBar()->addPermanentWidget(m_statusLabel); connect(m_manager, &PS1RipManager::framePresented, this, &PS1RipSessionWindow::onFrame); + connect(m_manager, &PS1RipManager::sessionStarted, this, [this]() { + m_statusLabel->setText(tr("Running (stub core — test pattern only)")); + }); + connect(m_manager, &PS1RipManager::sessionStopped, this, + [this]() { m_statusLabel->setText(tr("Stopped")); }); connect(m_manager, &PS1RipManager::error, this, &PS1RipSessionWindow::onError); const QString savedBios = QSettings().value(QString::fromLatin1(kSettingsGroup) + QLatin1Char('/') @@ -154,8 +159,14 @@ void PS1RipSessionWindow::onStep() void PS1RipSessionWindow::onReset() { + if (!m_manager->isSessionActive() && !m_manager->isStartPending()) { + m_manager->start(); + return; + } + + connect(m_manager, &PS1RipManager::sessionStopped, this, + [this]() { m_manager->start(); }, Qt::SingleShotConnection); m_manager->stop(); - m_manager->start(); } void PS1RipSessionWindow::onFrame(const QImage &frame, quint64 frameIndex) diff --git a/src/PS1/runtime/PS1RipWorker.cpp b/src/PS1/runtime/PS1RipWorker.cpp index 1ac0af83..525e861f 100644 --- a/src/PS1/runtime/PS1RipWorker.cpp +++ b/src/PS1/runtime/PS1RipWorker.cpp @@ -38,8 +38,20 @@ bool PS1RipWorker::ensureCore(QString *errorOut) return static_cast(m_core); } +void PS1RipWorker::cancelPendingStart() +{ + requestCancelStart(); + if (m_running) + stopEmulation(); +} + void PS1RipWorker::startEmulation() { + if (m_startSuperseded.load(std::memory_order_acquire)) { + m_startSuperseded.store(false, std::memory_order_release); + return; + } + if (m_running) return; @@ -64,6 +76,12 @@ void PS1RipWorker::startEmulation() } m_core->reset(); + + if (m_startSuperseded.load(std::memory_order_acquire)) { + m_startSuperseded.store(false, std::memory_order_release); + return; + } + m_running = true; m_paused = false; m_frameTimer->start(); @@ -72,6 +90,8 @@ void PS1RipWorker::startEmulation() void PS1RipWorker::stopEmulation() { + clearStartCancel(); + if (!m_running && !m_frameTimer->isActive()) return; diff --git a/src/PS1/runtime/PS1RipWorker.h b/src/PS1/runtime/PS1RipWorker.h index 42018b93..0b855daa 100644 --- a/src/PS1/runtime/PS1RipWorker.h +++ b/src/PS1/runtime/PS1RipWorker.h @@ -6,6 +6,7 @@ #include #include +#include #include class EmuCore; @@ -22,9 +23,13 @@ class PS1RipWorker : public QObject explicit PS1RipWorker(QObject *parent = nullptr); ~PS1RipWorker() override; + void clearStartCancel() { m_startSuperseded.store(false, std::memory_order_release); } + void requestCancelStart() { m_startSuperseded.store(true, std::memory_order_release); } + public slots: void configureSession(const QString &biosPath, const QString &isoPath); void startEmulation(); + void cancelPendingStart(); void stopEmulation(); void pauseEmulation(); void stepFrame(); @@ -48,6 +53,7 @@ private slots: QTimer *m_frameTimer = nullptr; bool m_running = false; bool m_paused = false; + std::atomic m_startSuperseded{false}; }; #endif // PS1RIPWORKER_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a5547ba0..47894673 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -130,6 +130,17 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/PS1/PS1TIM.cpp ) + if(ENABLE_PS1_RIP) + list(APPEND TEST_SRC_FILES + ${CMAKE_CURRENT_SOURCE_DIR}/../src/PS1/runtime/EmuCoreLoader.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/PS1/runtime/EmuViewport.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/PS1/runtime/PS1RipLegalityDialog.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/PS1/runtime/PS1RipManager.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/PS1/runtime/PS1RipSessionWindow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/PS1/runtime/PS1RipWorker.cpp + ) + endif() + set(TEST_HEADER_FILES ${CMAKE_CURRENT_SOURCE_DIR}/../src/AnimationBlender.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/AnimationControlController.h @@ -360,6 +371,10 @@ if(BUILD_TESTS) ) target_link_libraries(${target_name} ${COMMON_TEST_LIBRARIES}) + + if(ENABLE_PS1_RIP) + add_dependencies(${target_name} qtmesh_ps1core_stub) + endif() # Add dependency on UI generation ADD_DEPENDENCIES(${target_name} ui) @@ -389,6 +404,10 @@ if(BUILD_TESTS) ) target_link_libraries(${target_name} ${COMMON_TEST_LIBRARIES}) + + if(ENABLE_PS1_RIP) + add_dependencies(${target_name} qtmesh_ps1core_stub) + endif() # Add dependency on UI generation ADD_DEPENDENCIES(${target_name} ui)