Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 14 additions & 2 deletions src/PS1/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
61 changes: 61 additions & 0 deletions src/PS1/PS1_RIP_DESIGN.md
Original file line number Diff line number Diff line change
@@ -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 `<AppData>/ps1_rip/captures/<sessionId>/`.
- 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.
175 changes: 175 additions & 0 deletions src/PS1/runtime/PS1RipManager.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#include "PS1RipManager.h"
#include "PS1RipWorker.h"
#include "SentryReporter.h"

#include <QFileInfo>
#include <QMetaObject>

PS1RipManager *PS1RipManager::s_instance = nullptr;

PS1RipManager *PS1RipManager::getSingleton()
{
if (!s_instance)
s_instance = new PS1RipManager();

Check failure on line 13 in src/PS1/runtime/PS1RipManager.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace the use of "new" with an operation that automatically manages the memory.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ42PhDFcT_hZbQTn1E3&open=AZ42PhDFcT_hZbQTn1E3&pullRequest=574
return s_instance;
}

PS1RipManager *PS1RipManager::getSingletonPtr()
{
return s_instance;
}

void PS1RipManager::kill()
{
delete s_instance;

Check failure on line 24 in src/PS1/runtime/PS1RipManager.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rewrite the code so that you no longer need this "delete".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ42PhDFcT_hZbQTn1E4&open=AZ42PhDFcT_hZbQTn1E4&pullRequest=574
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();

Check failure on line 42 in src/PS1/runtime/PS1RipManager.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace the use of "new" with an operation that automatically manages the memory.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ42PhDFcT_hZbQTn1E5&open=AZ42PhDFcT_hZbQTn1E5&pullRequest=574
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()

Check warning on line 125 in src/PS1/runtime/PS1RipManager.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Update this method so that its implementation is not identical to pause.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ42PhDFcT_hZbQTn1E2&open=AZ42PhDFcT_hZbQTn1E2&pullRequest=574
{
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;
}
65 changes: 65 additions & 0 deletions src/PS1/runtime/PS1RipManager.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#ifndef PS1RIPMANAGER_H
#define PS1RIPMANAGER_H

#include <QObject>
#include <QString>
#include <QThread>

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
Loading
Loading