diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f8f4ccf4..cc2000d9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -945,7 +945,8 @@ jobs: -DCMAKE_C_FLAGS="--coverage -fprofile-arcs -ftest-coverage -O0 -DCOVERAGE_BUILD" \ -DCMAKE_EXE_LINKER_FLAGS="--coverage" \ -DBUILD_QT_MESH_EDITOR=OFF \ - -DENABLE_SENTRY=OFF + -DENABLE_SENTRY=OFF \ + -DENABLE_PS1_RIP=ON - name: Run build-wrapper env: diff --git a/CLAUDE.md b/CLAUDE.md index 80dd03ff..8acc1991 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -195,6 +195,11 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **ScanEngine** (`src/ScanEngine.h/cpp`): Directory scanner for 3D asset linting. Loads every asset through `MeshImporterExporter` (the editor's own loader) and walks the resulting Ogre scene with `CLIPipeline::extractMeshInfo` — the same extractor `MeshInfoOverlay` uses, so the scan, the CLI `info` subcommand and the in-app overlay all report identical counts for the same asset. Redundant-keyframe analysis (and the `--fix` write-back since slice C4) goes through `AnimationMerger::analyzeRedundantKeyframes` / `simplifyAnimation`, the same code path as `qtmesh anim --simplify` and the Inspector "Simplify" button. The fix path re-exports via `MeshImporterExporter::exporter` for every supported format (FBX/glTF/glb/DAE/OBJ/PLY/STL/.mesh) — no `Assimp::Exporter`. ACMR is folded into the same Ogre walk so each file is loaded once per scan. Assimp's only remaining role is a no-process `ReadFile` to enumerate `aiMaterial::GetTexture` references that Ogre's TUS-name walk wouldn't see when a referenced texture file is missing on disk (needed for `require_textures_exist`). Quality rules driven by the Ogre walk: `max_texture_resolution` (largest texture dimension cap), `require_uv_channels` (per-submesh UV-set minimum), `detect_zero_weight_bones` (Mixamo bloat — bones with no vertex weights), `detect_overlapping_uvs_pct` (UV0 AABB sweep — lightmap quality), `detect_non_manifold_edges_pct` (edges shared by != 2 faces — boolean / printing safety). Enumerates files via glob patterns, evaluates configurable rules, produces text/JSON/SARIF reports. Per-file cleanup happens in `clearOgreSceneForScanImport` which destroys scene nodes and flushes MeshManager / SkeletonManager so a 1000-asset scan doesn't accumulate state. - **ScanConfig** (`src/ScanConfig.h/cpp`): Config loader for `qtmesh.yml`/`.json`. Includes a minimal YAML parser for the specific config schema (scalars, inline/block lists, one level of section nesting). Supports scan paths, rule configuration, fix behavior, and report output settings. +### 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`. + ### Mesh Import/Export - **MeshImporterExporter** (`src/MeshImporterExporter.h/cpp`): Static methods. Supports .mesh, .obj, .dae, .gltf, .fbx via custom Assimp processors in `src/Assimp/`. Also provides `sceneExporter()`/`sceneImporter()` for saving/loading entire scenes (multiple entities with transforms, materials, skeletons, and animations) as glTF files. Multi-entity scenes use entity-name-prefixed bones to avoid cross-entity skeleton contamination when Assimp merges skins. **Auto-scales sub-unit meshes**: assets with bounding-box max-extent below 0.01 (mm-scale FBX, photogrammetry, etc.) get their parent SceneNode scaled by `1/maxExtent` so the largest dim lands at ~1 unit — without this they sit inside the camera near-clip plane and never render. `configureCamera()` reads `getWorldBoundingBox(derive=true)` so the camera distance accounts for the auto-scale. diff --git a/CMakeLists.txt b/CMakeLists.txt index 041587a0..fdaf278b 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -264,6 +264,15 @@ if(ENABLE_STABLE_DIFFUSION) message(STATUS "AI texture generation enabled with stable-diffusion.cpp") endif() ############################################################## +# PS1 runtime geometry extraction (experimental) +############################################################## +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)") +endif() +############################################################## # Sentry crash reporting and analytics ############################################################## option(ENABLE_SENTRY "Enable Sentry crash reporting and analytics" ON) diff --git a/src/PS1/CMakeLists.txt b/src/PS1/CMakeLists.txt index 9c9709ff..ef61e8a6 100644 --- a/src/PS1/CMakeLists.txt +++ b/src/PS1/CMakeLists.txt @@ -9,7 +9,6 @@ ${CMAKE_CURRENT_SOURCE_DIR}/PS1PLY.cpp ${CMAKE_CURRENT_SOURCE_DIR}/PS1RSD.cpp ${CMAKE_CURRENT_SOURCE_DIR}/PS1TMD.cpp ${CMAKE_CURRENT_SOURCE_DIR}/PS1TIM.cpp -PARENT_SCOPE ) set(HEADER_FILES @@ -19,5 +18,18 @@ ${CMAKE_CURRENT_SOURCE_DIR}/PS1PLY.h ${CMAKE_CURRENT_SOURCE_DIR}/PS1RSD.h ${CMAKE_CURRENT_SOURCE_DIR}/PS1TMD.h ${CMAKE_CURRENT_SOURCE_DIR}/PS1TIM.h -PARENT_SCOPE ) + +if(ENABLE_PS1_RIP) + list(APPEND SRC_FILES + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/PS1RipManager.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/PS1RipWorker.cpp + ) + list(APPEND HEADER_FILES + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/PS1RipManager.h + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/PS1RipWorker.h + ) +endif() + +set(SRC_FILES ${SRC_FILES} PARENT_SCOPE) +set(HEADER_FILES ${HEADER_FILES} PARENT_SCOPE) diff --git a/src/PS1/PS1_RIP_DESIGN.md b/src/PS1/PS1_RIP_DESIGN.md new file mode 100644 index 00000000..0849e62b --- /dev/null +++ b/src/PS1/PS1_RIP_DESIGN.md @@ -0,0 +1,61 @@ +# PS1 Runtime Geometry Extraction — Design + +Parent epic: [GitHub #412](https://github.com/fernandotonon/QtMeshEditor/issues/412) + +## Goal + +Extract geometry, textures, UVs, and vertex colors from PlayStation 1 games **at runtime** by embedding an emulator and intercepting GPU/GTE commands, instead of parsing every proprietary per-game format. + +Static parsers under `src/PS1/` (`PS1TMD`, `PS1TIM`, `PS1RSD`, `PS1PLY`, `PS1MAT`) remain for known file formats. + +## Emulator core comparison (Phase 0 — #413) + +| Criterion | mednafen-psx | PCSX-Redux | duckstation libcore | +|-----------|--------------|------------|---------------------| +| License | GPLv2 | GPLv3 | GPLv3 core; app CC-BY-NC-ND | +| QtMeshEditor main binary | Must **not** link — plugin only | Plugin only | Plugin only | +| Maturity / accuracy | High | High; strong GPU debugger | High | +| Headless / hook surface | Good; documented sources | Lua GPU debugger (reference UX) | libcore hooks possible | +| Distribution risk | Low if isolated `.so` | Low if isolated | NC-ND blocks app bundle use | + +### Recommendation + +**Primary: mednafen-psx (GPLv2) in a dynamically loaded `EmuCore` plugin** (separate build artifact, not linked into the main QtMeshEditor binary). + +**Rationale:** + +1. **License** — GPLv2 is compatible with plugin isolation; duckstation’s NC-ND application terms are unsuitable for redistribution with QtMeshEditor. +2. **Hookability** — Mature, readable C++ core; GPU command stream is small and well-documented (nocash psx-spx). +3. **Headless** — Supports automation for unit tests and future `qtmesh ps1` CLI (#431). + +PCSX-Redux remains a **reference** for GPU-debugger UX (#425–#426), not a linked dependency. + +## Architecture (summary) + +``` +PS1RipManager (main thread, singleton) + ├── PS1RipWorker (QThread) ──► EmuCore plugin (future) + └── CaptureBuffer / Reconstructor (future phases) +``` + +- Captured data finalized under `/ps1_rip/captures//`. +- Reconstruction runs on the main thread on demand (no `BlockingQueuedConnection`). +- Sentry breadcrumbs: category `ps1.rip.*`. + +## Build flag + +```cmake +option(ENABLE_PS1_RIP "Enable experimental PS1 runtime geometry extraction" OFF) +``` + +- OFF by default for release binaries. +- CI enables ON for Linux test jobs only. + +## Milestones + +See epic #412 for phased issues (#413–#431). + +## Open questions + +- Exact plugin ABI for `EmuCore` (#415). +- BIOS / ISO first-run legality dialog copy (#417) — requires legal review before release. diff --git a/src/PS1/runtime/PS1RipManager.cpp b/src/PS1/runtime/PS1RipManager.cpp new file mode 100644 index 00000000..c7a4c04e --- /dev/null +++ b/src/PS1/runtime/PS1RipManager.cpp @@ -0,0 +1,175 @@ +#include "PS1RipManager.h" +#include "PS1RipWorker.h" +#include "SentryReporter.h" + +#include +#include + +PS1RipManager *PS1RipManager::s_instance = nullptr; + +PS1RipManager *PS1RipManager::getSingleton() +{ + if (!s_instance) + s_instance = new PS1RipManager(); + return s_instance; +} + +PS1RipManager *PS1RipManager::getSingletonPtr() +{ + return s_instance; +} + +void PS1RipManager::kill() +{ + delete s_instance; + s_instance = nullptr; +} + +PS1RipManager::PS1RipManager(QObject *parent) + : QObject(parent) +{ + initializeWorkerThread(); +} + +PS1RipManager::~PS1RipManager() +{ + shutdownWorkerThread(); +} + +void PS1RipManager::initializeWorkerThread() +{ + m_workerThread = new QThread(this); + m_worker = new PS1RipWorker(); + m_worker->moveToThread(m_workerThread); + + connect(m_workerThread, &QThread::finished, m_worker, &QObject::deleteLater); + + m_workerThread->start(); +} + +void PS1RipManager::shutdownWorkerThread() +{ + if (m_worker) { + QMetaObject::invokeMethod(m_worker, &PS1RipWorker::stopEmulation, Qt::QueuedConnection); + } + if (m_workerThread) { + m_workerThread->quit(); + m_workerThread->wait(); + } + m_worker = nullptr; +} + +void PS1RipManager::reportError(const QString &message) +{ + SentryReporter::addBreadcrumb(QStringLiteral("ps1.rip"), message, QStringLiteral("error")); + emit error(message); +} + +bool PS1RipManager::loadIso(const QString &path) +{ + const QFileInfo info(path); + if (!info.exists() || !info.isFile()) { + reportError(tr("ISO file not found: %1").arg(path)); + return false; + } + + if (m_sessionActive) + stop(); + + m_isoPath = info.absoluteFilePath(); + SentryReporter::addBreadcrumb(QStringLiteral("ps1.rip"), + QStringLiteral("loadIso: %1").arg(m_isoPath)); + return true; +} + +bool PS1RipManager::start() +{ + if (m_isoPath.isEmpty()) { + reportError(tr("No ISO loaded")); + return false; + } + if (m_sessionActive) + return true; + + SentryReporter::addBreadcrumb(QStringLiteral("ps1.rip"), QStringLiteral("start (stub)")); + reportError(tr("PS1 emulator core not integrated yet")); + return false; +} + +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(); + return true; +} + +bool PS1RipManager::pause() +{ + if (!m_sessionActive) { + reportError(tr("No active PS1 session")); + return false; + } + reportError(tr("PS1 emulator core not integrated yet")); + return false; +} + +bool PS1RipManager::step() +{ + if (!m_sessionActive) { + reportError(tr("No active PS1 session")); + return false; + } + reportError(tr("PS1 emulator core not integrated yet")); + return false; +} + +bool PS1RipManager::armCapture(bool armed) +{ + m_captureArmed = armed; + SentryReporter::addBreadcrumb(QStringLiteral("ps1.rip"), + armed ? QStringLiteral("armCapture") : QStringLiteral("disarmCapture")); + return true; +} + +bool PS1RipManager::captureFrame() +{ + if (!m_captureArmed) { + reportError(tr("Capture is not armed")); + return false; + } + reportError(tr("Capture pipeline not implemented yet")); + return false; +} + +bool PS1RipManager::captureScene(int seconds) +{ + if (seconds <= 0) { + reportError(tr("Scene capture duration must be positive")); + return false; + } + if (!m_captureArmed) { + reportError(tr("Capture is not armed")); + return false; + } + reportError(tr("Scene capture pipeline not implemented yet")); + return false; +} + +bool PS1RipManager::dumpVRAM() +{ + if (!m_sessionActive) { + reportError(tr("No active PS1 session")); + return false; + } + reportError(tr("VRAM dump not implemented yet")); + return false; +} diff --git a/src/PS1/runtime/PS1RipManager.h b/src/PS1/runtime/PS1RipManager.h new file mode 100644 index 00000000..f2ee95e4 --- /dev/null +++ b/src/PS1/runtime/PS1RipManager.h @@ -0,0 +1,65 @@ +#ifndef PS1RIPMANAGER_H +#define PS1RIPMANAGER_H + +#include +#include +#include + +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 +{ + Q_OBJECT + +public: + static PS1RipManager *getSingleton(); + static PS1RipManager *getSingletonPtr(); + static void kill(); + + bool hasIso() const { return !m_isoPath.isEmpty(); } + bool isSessionActive() const { return m_sessionActive; } + bool isCaptureArmed() const { return m_captureArmed; } + QString isoPath() const { return m_isoPath; } + + bool loadIso(const QString &path); + bool start(); + bool stop(); + bool pause(); + bool step(); + bool armCapture(bool armed = true); + bool captureFrame(); + bool captureScene(int seconds); + bool dumpVRAM(); + +signals: + void sessionStarted(); + void sessionStopped(); + void frameCaptured(const QString &captureId); + void sceneCaptured(const QString &captureId); + void vramDumped(const QString &captureId); + void error(const QString &message); + +private: + explicit PS1RipManager(QObject *parent = nullptr); + ~PS1RipManager() override; + + void initializeWorkerThread(); + void shutdownWorkerThread(); + void reportError(const QString &message); + + static PS1RipManager *s_instance; + + QString m_isoPath; + bool m_sessionActive = false; + bool m_captureArmed = false; + + QThread *m_workerThread = nullptr; + PS1RipWorker *m_worker = nullptr; +}; + +#endif // PS1RIPMANAGER_H diff --git a/src/PS1/runtime/PS1RipManager_test.cpp b/src/PS1/runtime/PS1RipManager_test.cpp new file mode 100644 index 00000000..8d2360af --- /dev/null +++ b/src/PS1/runtime/PS1RipManager_test.cpp @@ -0,0 +1,83 @@ +#ifdef ENABLE_PS1_RIP + +#include +#include +#include +#include +#include +#include + +#include "PS1/runtime/PS1RipManager.h" + +class PS1RipManagerTest : public ::testing::Test +{ +protected: + void SetUp() override + { + app = qobject_cast(QCoreApplication::instance()); + ASSERT_NE(app, nullptr); + PS1RipManager::kill(); + manager = PS1RipManager::getSingleton(); + ASSERT_NE(manager, nullptr); + } + + void TearDown() override + { + PS1RipManager::kill(); + } + + QApplication *app = nullptr; + PS1RipManager *manager = nullptr; +}; + +TEST_F(PS1RipManagerTest, SingletonLifecycle) +{ + PS1RipManager *a = PS1RipManager::getSingleton(); + PS1RipManager *b = PS1RipManager::getSingletonPtr(); + EXPECT_EQ(a, b); + EXPECT_EQ(a, manager); + + PS1RipManager::kill(); + EXPECT_EQ(PS1RipManager::getSingletonPtr(), nullptr); + + PS1RipManager *c = PS1RipManager::getSingleton(); + EXPECT_NE(c, nullptr); + EXPECT_EQ(PS1RipManager::getSingletonPtr(), c); +} + +TEST_F(PS1RipManagerTest, StartWithoutIsoReturnsFalse) +{ + QSignalSpy errorSpy(manager, &PS1RipManager::error); + EXPECT_FALSE(manager->start()); + EXPECT_TRUE(manager->isoPath().isEmpty()); + EXPECT_GE(errorSpy.count(), 1); +} + +TEST_F(PS1RipManagerTest, LoadIsoThenStartStillStub) +{ + 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()); + EXPECT_FALSE(manager->start()); + EXPECT_FALSE(manager->isSessionActive()); +} + +TEST_F(PS1RipManagerTest, ArmCaptureWithoutSession) +{ + EXPECT_TRUE(manager->armCapture(true)); + EXPECT_TRUE(manager->isCaptureArmed()); + 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/PS1RipWorker.cpp b/src/PS1/runtime/PS1RipWorker.cpp new file mode 100644 index 00000000..9912e7fe --- /dev/null +++ b/src/PS1/runtime/PS1RipWorker.cpp @@ -0,0 +1,37 @@ +#include "PS1RipWorker.h" + +PS1RipWorker::PS1RipWorker(QObject *parent) + : QObject(parent) +{ +} + +void PS1RipWorker::startEmulation() +{ + m_running = true; + m_paused = false; + emit emulationStarted(); +} + +void PS1RipWorker::stopEmulation() +{ + if (!m_running) + return; + m_running = false; + m_paused = false; + emit emulationStopped(); +} + +void PS1RipWorker::pauseEmulation() +{ + if (!m_running) + return; + m_paused = !m_paused; +} + +void PS1RipWorker::stepFrame() +{ + if (!m_running) + return; + ++m_frameIndex; + emit frameAdvanced(m_frameIndex); +} diff --git a/src/PS1/runtime/PS1RipWorker.h b/src/PS1/runtime/PS1RipWorker.h new file mode 100644 index 00000000..39b868c0 --- /dev/null +++ b/src/PS1/runtime/PS1RipWorker.h @@ -0,0 +1,36 @@ +#ifndef PS1RIPWORKER_H +#define PS1RIPWORKER_H + +#include +#include +#include + +/** + * Worker-thread stub for the PS1 emulator core (Phase 1+). + * Lives on a dedicated QThread owned by PS1RipManager. + */ +class PS1RipWorker : public QObject +{ + Q_OBJECT + +public: + explicit PS1RipWorker(QObject *parent = nullptr); + +public slots: + void startEmulation(); + void stopEmulation(); + void pauseEmulation(); + void stepFrame(); + +signals: + void emulationStarted(); + void emulationStopped(); + void frameAdvanced(quint64 frameIndex); + +private: + 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 c4fff7a7..3c39e586 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -2164,6 +2164,24 @@ void MainWindow::initToolBar() // Initialize LLMManager LLMManager::instance(); + +#ifdef ENABLE_PS1_RIP + QMenu *toolsMenu = menuBar()->addMenu(tr("&Tools")); + toolsMenu->setObjectName(QStringLiteral("menuTools")); + QMenu *experimentalMenu = toolsMenu->addMenu(tr("Experimental")); + QAction *ps1RipAction = experimentalMenu->addAction(tr("PS1 Runtime Ripper…")); + ps1RipAction->setObjectName(QStringLiteral("actionPS1RuntimeRipper")); + 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.")); + }); +#endif } const QPalette &MainWindow::darkPalette()