From 35c2eb85ab6f7c7807426d8c836dc40f4f7488f7 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 5 May 2026 09:23:03 -0400 Subject: [PATCH 1/7] feat(ps1): import TMD meshes and TIM textures Add PlayStation TMD support across UI + CLI, including textured triangle/quad decoding, TIM decoding, and auto-loading sibling .TIM onto per-import TMD materials. Update Material Editor to browse/load TIM textures and show previews. Co-authored-by: Cursor --- qml/TexturePropertiesPanel.qml | 6 +- src/AssetBrowserController.cpp | 2 +- src/AssetBrowserController_test.cpp | 19 + src/CLIPipeline.cpp | 3 +- src/CLIPipeline_test.cpp | 6 + src/CMakeLists.txt | 1 + src/MCPServer.cpp | 2 +- src/Manager.cpp | 2 +- src/Manager_test.cpp | 5 + src/MaterialEditorQML.cpp | 121 +++- src/MaterialEditorQML.h | 1 + src/MeshImporterExporter.cpp | 30 +- src/MeshImporterExporter_test.cpp | 11 +- src/PS1/CMakeLists.txt | 17 + src/PS1/PS1TIM.cpp | 281 +++++++++ src/PS1/PS1TIM.h | 35 ++ src/PS1/PS1TMD.cpp | 927 ++++++++++++++++++++++++++++ src/PS1/PS1TMD.h | 56 ++ src/PS1/PS1TMD_test.cpp | 529 ++++++++++++++++ src/PrimitiveObject.cpp | 2 +- src/WelcomeDialog.cpp | 2 +- 21 files changed, 2034 insertions(+), 24 deletions(-) create mode 100644 src/PS1/CMakeLists.txt create mode 100644 src/PS1/PS1TIM.cpp create mode 100644 src/PS1/PS1TIM.h create mode 100644 src/PS1/PS1TMD.cpp create mode 100644 src/PS1/PS1TMD.h create mode 100644 src/PS1/PS1TMD_test.cpp diff --git a/qml/TexturePropertiesPanel.qml b/qml/TexturePropertiesPanel.qml index 9a2901ac8..6bf6b3e91 100644 --- a/qml/TexturePropertiesPanel.qml +++ b/qml/TexturePropertiesPanel.qml @@ -84,9 +84,9 @@ GroupBox { ThemedButton { text: "Browse..." onClicked: { - var selectedFileName = MaterialEditorQML.openFileDialog() - if (selectedFileName !== "") { - MaterialEditorQML.setTextureName(selectedFileName) + var selectedPath = MaterialEditorQML.openFileDialog() + if (selectedPath !== "") { + MaterialEditorQML.loadTextureFile(selectedPath) } } } diff --git a/src/AssetBrowserController.cpp b/src/AssetBrowserController.cpp index aa191e3aa..68081ce1a 100644 --- a/src/AssetBrowserController.cpp +++ b/src/AssetBrowserController.cpp @@ -15,7 +15,7 @@ const QStringList AssetBrowserController::s_meshExtensions = { "fbx", "gltf", "glb", "gltf2", "vrm", "obj", "dae", "stl", "mesh", "3ds", "blend", "ply", "x", "x3d", "lwo", "lws", "ac", "ms3d", "cob", "scn", "bvh", "irrmesh", "irr", "mdl", "md2", "md3", "md5mesh", "smd", "ogex", "b3d", "q3d", "nff", "off", - "raw", "ter", "hmp", "assbin", "mesh.xml" + "raw", "ter", "hmp", "assbin", "mesh.xml", "tmd" }; const QStringList AssetBrowserController::s_textureExtensions = { diff --git a/src/AssetBrowserController_test.cpp b/src/AssetBrowserController_test.cpp index cd38a8a6b..3f9f7c9b0 100644 --- a/src/AssetBrowserController_test.cpp +++ b/src/AssetBrowserController_test.cpp @@ -228,6 +228,8 @@ TEST_F(AssetBrowserControllerTests, FileTypeClassification) { EXPECT_EQ(abc->fileTypeForPath("/foo/bar.gltf"), "mesh"); EXPECT_EQ(abc->fileTypeForPath("/foo/bar.vrm"), "mesh"); EXPECT_EQ(abc->fileTypeForPath("/foo/bar.obj"), "mesh"); + EXPECT_EQ(abc->fileTypeForPath("/foo/bar.tmd"), "mesh"); + EXPECT_EQ(abc->fileTypeForPath("/foo/bar.TMD"), "mesh"); EXPECT_EQ(abc->fileTypeForPath("/foo/bar.png"), "texture"); EXPECT_EQ(abc->fileTypeForPath("/foo/bar.jpg"), "texture"); EXPECT_EQ(abc->fileTypeForPath("/foo/bar.tga"), "texture"); @@ -252,6 +254,23 @@ TEST_F(AssetBrowserControllerTests, OpenFileMeshEmitsImportSignal) { EXPECT_EQ(paths.at(0), meshPath); } +TEST_F(AssetBrowserControllerTests, OpenFileTmdEmitsImportSignal) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + const QString meshPath = tmpDir.path() + "/CAR.TMD"; + QFile(meshPath).open(QIODevice::WriteOnly); + + auto* abc = AssetBrowserController::instance(); + QSignalSpy spy(abc, &AssetBrowserController::importMeshRequested); + + abc->openFile(meshPath); + EXPECT_EQ(spy.count(), 1); + const QStringList paths = spy.at(0).at(0).toStringList(); + EXPECT_EQ(paths.size(), 1); + EXPECT_EQ(paths.at(0), meshPath); +} + TEST_F(AssetBrowserControllerTests, OpenFileNonexistentDoesNothing) { auto* abc = AssetBrowserController::instance(); QSignalSpy spy(abc, &AssetBrowserController::importMeshRequested); diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index b6c9e1e15..7ce96c9db 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -618,7 +618,8 @@ QString CLIPipeline::formatForExtension(const QString& path) {".x", "X (*.x)"}, {".mesh.xml", "Ogre XML (*.mesh.xml)"}, {".mesh", "Ogre Mesh (*.mesh)"}, - {".assbin", "Assimp Binary (*.assbin)"} + {".assbin", "Assimp Binary (*.assbin)"}, + {".tmd", "PlayStation TMD (*.tmd *.TMD)"} }; for (const ExtensionFormat& entry : extensionFormats) { diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index 8438ceaab..dcdee9bd4 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -485,6 +485,12 @@ TEST(CLIPipelineFormatForExtension, Assbin) EXPECT_EQ(CLIPipeline::formatForExtension("model.assbin"), "Assimp Binary (*.assbin)"); } +TEST(CLIPipelineFormatForExtension, Tmd) +{ + EXPECT_EQ(CLIPipeline::formatForExtension("model.tmd"), "PlayStation TMD (*.tmd *.TMD)"); + EXPECT_EQ(CLIPipeline::formatForExtension("MODEL.TMD"), "PlayStation TMD (*.tmd *.TMD)"); +} + TEST(CLIPipelineFormatForExtension, UnknownDefaultsToMesh) { EXPECT_EQ(CLIPipeline::formatForExtension("model.xyz"), "Ogre Mesh (*.mesh)"); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b3af02584..fc129538c 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -157,6 +157,7 @@ set(TEST_SOURCES "") ADD_SUBDIRECTORY("${CMAKE_CURRENT_SOURCE_DIR}/OgreXML") ADD_SUBDIRECTORY("${CMAKE_CURRENT_SOURCE_DIR}/Assimp") ADD_SUBDIRECTORY("${CMAKE_CURRENT_SOURCE_DIR}/FBX") +ADD_SUBDIRECTORY("${CMAKE_CURRENT_SOURCE_DIR}/PS1") #file(GLOB UI_FILES ./ui_files/*.ui) # if we don't include this CMake will not include ui headers properly: diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 5044c7fe2..655188ba7 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -3397,7 +3397,7 @@ QJsonArray MCPServer::buildToolsList() "'Ogre XML (*.mesh.xml)', 'Collada (*.dae)', 'X (*.x)', 'OBJ (*.obj)', " "'OBJ without MTL (*.objnomtl)', 'STL (*.stl)', 'PLY (*.ply)', '3DS (*.3ds)', " "'glTF 2.0 (*.gltf2)', 'glTF 2.0 Binary (*.glb2)', 'Assimp Binary (*.assbin)', " - "'FBX Binary (*.fbx)'. " + "'FBX Binary (*.fbx)', 'PlayStation TMD (*.tmd *.TMD)'. " "Default: 'Ogre Mesh (*.mesh)'"}}; inputSchema["properties"] = properties; inputSchema["required"] = QJsonArray{"path"}; diff --git a/src/Manager.cpp b/src/Manager.cpp index d1ae10a60..781118a41 100755 --- a/src/Manager.cpp +++ b/src/Manager.cpp @@ -80,7 +80,7 @@ Manager* Manager:: m_pSingleton = nullptr; QString Manager::mValidFileExtention = ".mesh .dae .blend .3ds .ase .obj .ifc .xgl .zgl .ply .dxf .lwo "\ ".lws .lxo .stl .x .ac .ms3d .cob .scn .bvh .csm .xml .irrmesh .irr .mdl .md2 .md3 "\ - ".pk3 .mdc .md5 .txt .smd .vta .m3 .3d .b3d .q3d .q3s .nff .nff .off .raw .ter .mdl .hmp .ndo .fbx .glb .gltf .vrm"; + ".pk3 .mdc .md5 .txt .smd .vta .m3 .3d .b3d .q3d .q3s .nff .nff .off .raw .ter .mdl .hmp .ndo .fbx .glb .gltf .vrm .tmd .TMD"; //////////////////////////////////////// /// Static Member to build & destroy diff --git a/src/Manager_test.cpp b/src/Manager_test.cpp index ebe1b6adf..9a38d883a 100644 --- a/src/Manager_test.cpp +++ b/src/Manager_test.cpp @@ -265,6 +265,9 @@ TEST_F(ManagerHeadlessTest, IsValidFileExtention) QString stlFile = "print.stl"; EXPECT_TRUE(mgr->isValidFileExtention(stlFile)); + QString tmdFile = "model.tmd"; + EXPECT_TRUE(mgr->isValidFileExtention(tmdFile)); + // Invalid extensions QString docFile = "readme.doc"; EXPECT_FALSE(mgr->isValidFileExtention(docFile)); @@ -284,6 +287,8 @@ TEST_F(ManagerHeadlessTest, IsValidFileExtention) EXPECT_TRUE(validExts.contains(".mesh")); EXPECT_TRUE(validExts.contains(".fbx")); EXPECT_TRUE(validExts.contains(".vrm")); + EXPECT_TRUE(validExts.contains(".tmd")); + EXPECT_TRUE(validExts.contains(".TMD")); } TEST_F(ManagerHeadlessTest, CreateEmptyScene) diff --git a/src/MaterialEditorQML.cpp b/src/MaterialEditorQML.cpp index 3e4bac913..a62e96aab 100644 --- a/src/MaterialEditorQML.cpp +++ b/src/MaterialEditorQML.cpp @@ -9,6 +9,7 @@ #include "QMLMaterialHighlighter.h" #include "ModelDownloader.h" #include "RTShaderHelper.h" +#include "PS1/PS1TIM.h" #include #include #include @@ -28,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -866,6 +868,17 @@ void MaterialEditorQML::setTextureName(const QString &name) if (textureUnit && !name.isEmpty() && name != "*Select a texture*") { // Only set non-empty, valid texture names to avoid OGRE crashes textureUnit->setTextureName(name.toStdString()); + + // If this is a per-import PS1 TMD material, default to unlit single-pass once an image is present. + if (m_ogreMaterial) { + const std::string matName = m_ogreMaterial->getName(); + if (matName.rfind("TMD/", 0) == 0) { + if (Ogre::Technique* tech = getCurrentTechnique()) { + while (tech->getNumPasses() > 1) + tech->removePass(1); + } + } + } updateMaterialText(); } @@ -966,7 +979,7 @@ void MaterialEditorQML::selectTexture() nullptr, tr("Select a texture"), QStandardPaths::writableLocation(QStandardPaths::PicturesLocation), - tr("Image File (*.bmp *.jpg *.gif *.raw *.png *.tga *.dds)")); + tr("Texture File (*.bmp *.jpg *.jpeg *.gif *.raw *.png *.tga *.dds *.tim)")); if (filePath.isEmpty()) return; @@ -984,17 +997,28 @@ void MaterialEditorQML::selectTexture() try { // Try to get existing texture Ogre::TextureManager::getSingleton().getByName( - file.fileName().toStdString(), file.path().toStdString()); + file.fileName().toStdString(), Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); } catch (...) { // Load new texture + // Always register user-picked textures into the default group ("General") so materials can find them. + // Creating ad-hoc groups per directory makes TextureUnitState name resolution fail (yellow/black fallback). Ogre::ResourceGroupManager::getSingleton().addResourceLocation( - file.path().toStdString(), "FileSystem", file.path().toStdString()); - Ogre::ResourceGroupManager::getSingleton().initialiseAllResourceGroups(); + file.path().toStdString(), "FileSystem", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + Ogre::ResourceGroupManager::getSingleton().initialiseResourceGroup( + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); Ogre::Image image; - image.load(file.fileName().toStdString(), file.path().toStdString()); + if (file.suffix().compare("tim", Qt::CaseInsensitive) == 0) { + QString err; + if (!PS1TIM::loadTimToOgreImage(filePath, image, &err)) { + emit errorOccurred(QString("Failed to load TIM: %1").arg(err)); + return; + } + } else { + image.load(file.fileName().toStdString(), Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + } Ogre::TextureManager::getSingleton().loadImage( - file.fileName().toStdString(), file.path().toStdString(), image); + file.fileName().toStdString(), Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, image); } setTextureName(file.fileName()); @@ -1643,7 +1667,27 @@ QString MaterialEditorQML::getTexturePreviewPath() const // Check origin (absolute path) QString origin = QString::fromStdString(texPtr->getOrigin()); if (!origin.isEmpty() && QFileInfo::exists(origin)) { - return QUrl::fromLocalFile(QFileInfo(origin).absoluteFilePath()).toString(); + // QML Image cannot display .tim; for those, generate a PNG preview. + const QString ext = QFileInfo(origin).suffix().toLower(); + if (ext != "tim") { + return QUrl::fromLocalFile(QFileInfo(origin).absoluteFilePath()).toString(); + } + } + + // If we can't return a directly viewable file (e.g., TIM), generate a PNG preview from the GPU texture. + try { + Ogre::Image img; + texPtr->convertToImage(img, true); + const QString dataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + const QString outDir = QDir(dataPath).filePath("texture_previews"); + QDir().mkpath(outDir); + const QString outPath = QDir(outDir).filePath(texName + ".png"); + QFile::remove(outPath); // ensure stale previews don't linger + img.save(outPath.toStdString()); + if (QFileInfo::exists(outPath)) { + return QUrl::fromLocalFile(QFileInfo(outPath).absoluteFilePath()).toString(); + } + } catch (...) { } // The resource group name is often the directory the model was loaded from @@ -2498,18 +2542,77 @@ QString MaterialEditorQML::openFileDialog() QApplication::activeWindow(), "Select Texture File", startDir, - "Image files (*.jpg *.jpeg *.png *.dds *.tga *.bmp);;All files (*)", + "Texture files (*.jpg *.jpeg *.png *.dds *.tga *.bmp *.tim);;All files (*)", nullptr, QFileDialog::DontUseNativeDialog | QFileDialog::DontUseCustomDirectoryIcons ); if (!selectedFile.isEmpty()) { - return QFileInfo(selectedFile).fileName(); + return selectedFile; } else { return QString(); } } +bool MaterialEditorQML::loadTextureFile(const QString &filePath) +{ + if (filePath.isEmpty()) + return false; + + Ogre::TextureUnitState* textureUnit = getCurrentTextureUnit(); + if (!textureUnit) { + emit errorOccurred("No texture unit selected."); + return false; + } + + QFileInfo file(filePath); + if (!file.exists()) { + emit errorOccurred("Texture file does not exist."); + return false; + } + if (file.fileName().isEmpty()) { + emit errorOccurred("Selected file has an empty name."); + return false; + } + + const std::string texName = file.fileName().toStdString(); + const std::string group = Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME; + + try { + if (Ogre::TextureManager::getSingleton().getByName(texName, group)) { + setTextureName(QString::fromStdString(texName)); + return true; + } + } catch (...) { + } + + try { + Ogre::ResourceGroupManager::getSingleton().addResourceLocation( + file.path().toStdString(), "FileSystem", group); + Ogre::ResourceGroupManager::getSingleton().initialiseResourceGroup(group); + + Ogre::Image image; + if (file.suffix().compare("tim", Qt::CaseInsensitive) == 0) { + QString err; + if (!PS1TIM::loadTimToOgreImage(filePath, image, &err)) { + emit errorOccurred(QString("Failed to load TIM: %1").arg(err)); + return false; + } + } else { + image.load(texName, group); + } + Ogre::TextureManager::getSingleton().loadImage(texName, group, image); + setTextureName(QString::fromStdString(texName)); + return true; + } catch (const std::exception& e) { + emit errorOccurred(QString("Texture load failed: %1").arg(e.what())); + return false; + } catch (...) { + emit errorOccurred("Texture load failed."); + return false; + } +} + QString MaterialEditorQML::openMaterialImportDialog() { QString materialsPath = "./media/materials/scripts"; diff --git a/src/MaterialEditorQML.h b/src/MaterialEditorQML.h index b8982db3d..6de12e22b 100644 --- a/src/MaterialEditorQML.h +++ b/src/MaterialEditorQML.h @@ -377,6 +377,7 @@ public slots: Q_INVOKABLE QString getFileSizeString(const QString &path); Q_INVOKABLE bool pathExists(const QString &path); Q_INVOKABLE QString openFileDialog(); + Q_INVOKABLE bool loadTextureFile(const QString &filePath); Q_INVOKABLE QString openMaterialImportDialog(); Q_INVOKABLE QString openMaterialExportDialog(const QString &materialName = ""); Q_INVOKABLE QString showNativeFileDialog(QObject *parentWindow); diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 2ed305d03..a430588dc 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -52,6 +52,7 @@ THE SOFTWARE. #include "Assimp/BoneProcessor.h" #include "Assimp/AnimationProcessor.h" #include "CLIPipeline.h" +#include "PS1/PS1TMD.h" #include "EditableMesh.h" #include "EditModeController.h" #include @@ -79,7 +80,8 @@ const QMap MeshImporterExporter::exportFormats = { {"glTF 2.0 (*.gltf)", ".gltf"}, {"glTF 2.0 Binary (*.glb)", ".glb"}, {"Assimp Binary (*.assbin)", ".assbin"}, - {"FBX Binary (*.fbx)", ".fbx"} + {"FBX Binary (*.fbx)", ".fbx"}, + {"PlayStation TMD (*.tmd *.TMD)", ".tmd"} }; void MeshImporterExporter::configureCamera(const Ogre::Entity *en) @@ -1203,6 +1205,27 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad AnimationMerger::registerSkeletonUpAxis(en->getMesh()->getSkeleton()->getName(), 1); applyNormalMapsToEntity(en); } + else if (!file.suffix().compare(QStringLiteral("tmd"), Qt::CaseInsensitive)) + { + const std::string meshName = (file.baseName() + QStringLiteral("_tmd")).toStdString(); + Ogre::MeshPtr mesh = PS1TMD::importTmd(file.filePath(), meshName); + if (!mesh) { + QMessageBox::warning( + nullptr, + QStringLiteral("PlayStation TMD"), + QStringLiteral("Could not import %1 — invalid file or unsupported primitive types.") + .arg(file.fileName())); + continue; + } + + SentryReporter::addBreadcrumb( + QStringLiteral("file.import"), + QStringLiteral("Imported PlayStation TMD: %1").arg(file.fileName())); + + sn = Manager::getSingleton()->addSceneNode(file.baseName()); + en = Manager::getSingleton()->createEntity(sn, mesh); + applyNormalMapsToEntity(en); + } else { AssimpToOgreImporter importer; @@ -1435,6 +1458,11 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u // .material and extracted image files next to the FBX. if (!ok) return -1; + } else if (_format == QStringLiteral("PlayStation TMD (*.tmd *.TMD)")) { + if (!PS1TMD::exportEntity(e, _uri)) + return -1; + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("Exported PlayStation TMD: %1").arg(_uri)); } else { // Export using Assimp — build aiScene directly from Ogre mesh data try { diff --git a/src/MeshImporterExporter_test.cpp b/src/MeshImporterExporter_test.cpp index 0184d71f7..5e788aff0 100644 --- a/src/MeshImporterExporter_test.cpp +++ b/src/MeshImporterExporter_test.cpp @@ -178,7 +178,7 @@ TEST(MeshImporterExporterStandaloneTest, FormatFileURI_UnknownFormat_ReturnsURIW } TEST(MeshImporterExporterStandaloneTest, ExportFileDialogFilter_ReturnsFilterString) { - QString expected = "3DS (*.3ds);;Assimp Binary (*.assbin);;Collada (*.dae);;FBX Binary (*.fbx);;OBJ (*.obj);;OBJ without MTL (*.objnomtl);;Ogre Mesh (*.mesh);;Ogre Mesh v1.0+(*.mesh);;Ogre Mesh v1.10+(*.mesh);;Ogre Mesh v1.4+(*.mesh);;Ogre Mesh v1.7+(*.mesh);;Ogre Mesh v1.8+(*.mesh);;Ogre XML (*.mesh.xml);;PLY (*.ply);;STL (*.stl);;X (*.x);;glTF 2.0 (*.gltf);;glTF 2.0 Binary (*.glb)"; + QString expected = "3DS (*.3ds);;Assimp Binary (*.assbin);;Collada (*.dae);;FBX Binary (*.fbx);;OBJ (*.obj);;OBJ without MTL (*.objnomtl);;Ogre Mesh (*.mesh);;Ogre Mesh v1.0+(*.mesh);;Ogre Mesh v1.10+(*.mesh);;Ogre Mesh v1.4+(*.mesh);;Ogre Mesh v1.7+(*.mesh);;Ogre Mesh v1.8+(*.mesh);;Ogre XML (*.mesh.xml);;PLY (*.ply);;PlayStation TMD (*.tmd *.TMD);;STL (*.stl);;X (*.x);;glTF 2.0 (*.gltf);;glTF 2.0 Binary (*.glb)"; QString result = MeshImporterExporter::exportFileDialogFilter(); @@ -548,11 +548,11 @@ TEST_F(MeshImporterExporterTest, SceneExporter_MixedEmptyAndEntityNodesOnlyCount // ── Standalone tests: export filter and format coverage ────────── -TEST(MeshImporterExporterStandaloneTest, ExportFileDialogFilter_ContainsAll18Formats) { +TEST(MeshImporterExporterStandaloneTest, ExportFileDialogFilter_ContainsAllFormats) { QString filter = MeshImporterExporter::exportFileDialogFilter(); - // 18 formats means 17 ";;" separators - EXPECT_EQ(filter.count(";;"), 17); - // Spot-check all format keys + // One ";;" between each format entry (N formats => N-1 separators) + EXPECT_EQ(filter.count(";;"), 18); + // Spot-check format keys EXPECT_TRUE(filter.contains("3DS (*.3ds)")); EXPECT_TRUE(filter.contains("Assimp Binary (*.assbin)")); EXPECT_TRUE(filter.contains("Collada (*.dae)")); @@ -567,6 +567,7 @@ TEST(MeshImporterExporterStandaloneTest, ExportFileDialogFilter_ContainsAll18For EXPECT_TRUE(filter.contains("Ogre Mesh v1.8+(*.mesh)")); EXPECT_TRUE(filter.contains("Ogre XML (*.mesh.xml)")); EXPECT_TRUE(filter.contains("PLY (*.ply)")); + EXPECT_TRUE(filter.contains("PlayStation TMD (*.tmd *.TMD)")); EXPECT_TRUE(filter.contains("STL (*.stl)")); EXPECT_TRUE(filter.contains("X (*.x)")); EXPECT_TRUE(filter.contains("glTF 2.0 (*.gltf)")); diff --git a/src/PS1/CMakeLists.txt b/src/PS1/CMakeLists.txt new file mode 100644 index 000000000..fb6f4d1a2 --- /dev/null +++ b/src/PS1/CMakeLists.txt @@ -0,0 +1,17 @@ +############################################################## +# PlayStation mesh formats (TMD, etc.) +############################################################## + +set(SRC_FILES +${SRC_FILES} +${CMAKE_CURRENT_SOURCE_DIR}/PS1TMD.cpp +${CMAKE_CURRENT_SOURCE_DIR}/PS1TIM.cpp +PARENT_SCOPE +) + +set(HEADER_FILES +${HEADER_FILES} +${CMAKE_CURRENT_SOURCE_DIR}/PS1TMD.h +${CMAKE_CURRENT_SOURCE_DIR}/PS1TIM.h +PARENT_SCOPE +) diff --git a/src/PS1/PS1TIM.cpp b/src/PS1/PS1TIM.cpp new file mode 100644 index 000000000..d712994fb --- /dev/null +++ b/src/PS1/PS1TIM.cpp @@ -0,0 +1,281 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License +----------------------------------------------------------------------------------- +*/ +#include "PS1/PS1TIM.h" + +#include + +#include +#include +#include + +namespace { + +inline uint16_t readU16le(const uint8_t* p) +{ + return uint16_t(p[0]) | (uint16_t(p[1]) << 8); +} + +inline uint32_t readU32le(const uint8_t* p) +{ + return uint32_t(p[0]) | (uint32_t(p[1]) << 8) | (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24); +} + +static void psxBgr555ToRgba(uint16_t c, uint8_t& r, uint8_t& g, uint8_t& b, uint8_t& a) +{ + // Bits: 0..4 R, 5..9 G, 10..14 B, 15 STP (semi-transparency flag in GPU) + const uint8_t rr = uint8_t(c & 0x1F); + const uint8_t gg = uint8_t((c >> 5) & 0x1F); + const uint8_t bb = uint8_t((c >> 10) & 0x1F); + r = uint8_t((rr * 255 + 15) / 31); + g = uint8_t((gg * 255 + 15) / 31); + b = uint8_t((bb * 255 + 15) / 31); + // Convention: 0 is transparent in many TIMs; otherwise opaque. + a = (c == 0) ? 0 : 255; +} + +struct TimImageHeader { + uint16_t x{}; + uint16_t y{}; + uint16_t wWords{}; + uint16_t h{}; +}; + +static bool readTimBlockHeader(const uint8_t* data, size_t size, size_t& p, uint32_t& outLenBytes, TimImageHeader& outHdr, QString* err) +{ + if (p + 12 > size) { + if (err) *err = "TIM truncated (block header)"; + return false; + } + outLenBytes = readU32le(data + p); + outHdr.x = readU16le(data + p + 4); + outHdr.y = readU16le(data + p + 6); + outHdr.wWords = readU16le(data + p + 8); + outHdr.h = readU16le(data + p + 10); + if (outLenBytes < 12) { + if (err) *err = "TIM invalid block length"; + return false; + } + if (p + outLenBytes > size) { + if (err) *err = "TIM truncated (block payload)"; + return false; + } + return true; +} + +} // namespace + +namespace PS1TIM { + +bool loadTimToOgreImage(const QString& timPath, Ogre::Image& outImage, QString* outError) +{ + QFile f(timPath); + if (!f.open(QIODevice::ReadOnly)) { + if (outError) *outError = "Failed to open TIM"; + return false; + } + const QByteArray raw = f.readAll(); + f.close(); + const uint8_t* data = reinterpret_cast(raw.constData()); + const size_t size = static_cast(raw.size()); + if (size < 8) { + if (outError) *outError = "TIM too small"; + return false; + } + + const uint32_t magic = readU32le(data); + if (magic != 0x10u) { + if (outError) *outError = "Not a TIM (bad magic)"; + return false; + } + const uint32_t flags = readU32le(data + 4); + const uint32_t bppMode = (flags & 0x7u); + const bool hasClut = (flags & 0x8u) != 0; + + if (!(bppMode == 0 || bppMode == 1 || bppMode == 2)) { + if (outError) *outError = "Unsupported TIM bpp mode"; + return false; + } + if ((bppMode == 0 || bppMode == 1) && !hasClut) { + if (outError) *outError = "Indexed TIM missing CLUT"; + return false; + } + + size_t p = 8; + std::vector clut; + uint16_t clutW = 0; + uint16_t clutH = 0; + + if (hasClut) { + uint32_t lenBytes = 0; + TimImageHeader ch; + if (!readTimBlockHeader(data, size, p, lenBytes, ch, outError)) + return false; + const size_t clutDataBytes = size_t(lenBytes) - 12u; + clutW = ch.wWords; + clutH = ch.h; + if (clutDataBytes % 2u != 0) { + if (outError) *outError = "TIM CLUT has odd byte size"; + return false; + } + const size_t nColors = clutDataBytes / 2u; + clut.resize(nColors); + const uint8_t* cp = data + p + 12; + for (size_t i = 0; i < nColors; ++i) + clut[i] = readU16le(cp + i * 2); + p += lenBytes; + } + + // Image block + uint32_t imgLenBytes = 0; + TimImageHeader ih; + if (!readTimBlockHeader(data, size, p, imgLenBytes, ih, outError)) + return false; + const uint8_t* imgData = data + p + 12; + const size_t imgDataBytes = size_t(imgLenBytes) - 12u; + + const int height = int(ih.h); + int widthPx = 0; + if (bppMode == 0) widthPx = int(ih.wWords) * 4; // 4bpp: 4 pixels per 16-bit word + else if (bppMode == 1) widthPx = int(ih.wWords) * 2; // 8bpp: 2 pixels per 16-bit word + else widthPx = int(ih.wWords); // 16bpp: 1 pixel per 16-bit word + + if (widthPx <= 0 || height <= 0) { + if (outError) *outError = "TIM invalid dimensions"; + return false; + } + + const size_t expectedWords = size_t(ih.wWords) * size_t(ih.h); + if (imgDataBytes < expectedWords * 2u) { + if (outError) *outError = "TIM image data truncated"; + return false; + } + + std::vector rgba(size_t(widthPx) * size_t(height) * 4u, 0); + + auto writePx = [&](int x, int y, uint16_t c16) { + uint8_t r, g, b, a; + psxBgr555ToRgba(c16, r, g, b, a); + const size_t idx = (size_t(y) * size_t(widthPx) + size_t(x)) * 4u; + rgba[idx + 0] = r; + rgba[idx + 1] = g; + rgba[idx + 2] = b; + rgba[idx + 3] = a; + }; + + if (bppMode == 2) { + // Direct 16bpp + for (int y = 0; y < height; ++y) { + for (int x = 0; x < widthPx; ++x) { + const size_t wi = size_t(y) * size_t(ih.wWords) + size_t(x); + const uint16_t c = readU16le(imgData + wi * 2); + writePx(x, y, c); + } + } + } else { + // Indexed + const size_t clutRowStride = size_t(clutW); + const size_t clutRow0 = 0; // first CLUT row + if (clut.empty() || clutW == 0 || clutH == 0) { + if (outError) *outError = "TIM missing CLUT data"; + return false; + } + if (bppMode == 0 && clutW < 16) { + if (outError) *outError = "TIM 4bpp CLUT too small"; + return false; + } + if (bppMode == 1 && clutW < 256) { + // Some TIMs store multiple 16-color CLUTs for 4bpp only; 8bpp should be 256. + if (outError) *outError = "TIM 8bpp CLUT too small"; + return false; + } + + for (int y = 0; y < height; ++y) { + for (int w = 0; w < int(ih.wWords); ++w) { + const uint16_t word = readU16le(imgData + (size_t(y) * size_t(ih.wWords) + size_t(w)) * 2); + if (bppMode == 1) { + // low byte then high byte + const uint8_t i0 = uint8_t(word & 0xFF); + const uint8_t i1 = uint8_t((word >> 8) & 0xFF); + const uint16_t c0 = clut[clutRow0 * clutRowStride + i0]; + const uint16_t c1 = clut[clutRow0 * clutRowStride + i1]; + const int x0 = w * 2 + 0; + const int x1 = w * 2 + 1; + if (x0 < widthPx) writePx(x0, y, c0); + if (x1 < widthPx) writePx(x1, y, c1); + } else { + // 4bpp: 4 nibbles, low->high + const uint8_t i0 = uint8_t((word >> 0) & 0xF); + const uint8_t i1 = uint8_t((word >> 4) & 0xF); + const uint8_t i2 = uint8_t((word >> 8) & 0xF); + const uint8_t i3 = uint8_t((word >> 12) & 0xF); + const uint16_t c0 = clut[clutRow0 * clutRowStride + i0]; + const uint16_t c1 = clut[clutRow0 * clutRowStride + i1]; + const uint16_t c2 = clut[clutRow0 * clutRowStride + i2]; + const uint16_t c3 = clut[clutRow0 * clutRowStride + i3]; + const int x0 = w * 4 + 0; + const int x1 = w * 4 + 1; + const int x2 = w * 4 + 2; + const int x3 = w * 4 + 3; + if (x0 < widthPx) writePx(x0, y, c0); + if (x1 < widthPx) writePx(x1, y, c1); + if (x2 < widthPx) writePx(x2, y, c2); + if (x3 < widthPx) writePx(x3, y, c3); + } + } + } + } + + // TIM pixels are in PSX VRAM space and TMD UV bytes are authored in 256×256 "page texels". + // To make TMD mapping work without requiring material scale/scroll, embed the decoded TIM bitmap + // into a 256×256 canvas at its VRAM-local offset (x,y), then return that canvas. + // + // TIM header X is in 16-bit VRAM pixels (words). Convert to texel offset depending on bpp: + // - 4bpp: 1 word = 4 texels + // - 8bpp: 1 word = 2 texels + // - 16bpp: 1 word = 1 texel + // + // We only keep the offset within a single 256×256 page. + constexpr int kPageW = 256; + constexpr int kPageH = 256; + int xTex = 0; + if (bppMode == 0) xTex = int(ih.x) * 4; + else if (bppMode == 1) xTex = int(ih.x) * 2; + else xTex = int(ih.x); + int yTex = int(ih.y); + xTex = ((xTex % kPageW) + kPageW) % kPageW; + yTex = ((yTex % kPageH) + kPageH) % kPageH; + + std::vector canvas(size_t(kPageW) * size_t(kPageH) * 4u, 0); + for (int y = 0; y < height; ++y) { + const int dy = yTex + y; + if (dy < 0 || dy >= kPageH) + continue; + for (int x = 0; x < widthPx; ++x) { + const int dx = xTex + x; + if (dx < 0 || dx >= kPageW) + continue; + const size_t src = (size_t(y) * size_t(widthPx) + size_t(x)) * 4u; + const size_t dst = (size_t(dy) * size_t(kPageW) + size_t(dx)) * 4u; + canvas[dst + 0] = rgba[src + 0]; + canvas[dst + 1] = rgba[src + 1]; + canvas[dst + 2] = rgba[src + 2]; + canvas[dst + 3] = rgba[src + 3]; + } + } + + // Ogre::Image will take ownership of the buffer when autoDelete=true. + auto* heap = OGRE_ALLOC_T(uint8_t, canvas.size(), Ogre::MEMCATEGORY_GENERAL); + std::copy(canvas.begin(), canvas.end(), heap); + outImage.loadDynamicImage(heap, (size_t)kPageW, (size_t)kPageH, 1, Ogre::PF_BYTE_RGBA, true); + return true; +} + +} // namespace PS1TIM + diff --git a/src/PS1/PS1TIM.h b/src/PS1/PS1TIM.h new file mode 100644 index 000000000..d4521e20f --- /dev/null +++ b/src/PS1/PS1TIM.h @@ -0,0 +1,35 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License +----------------------------------------------------------------------------------- +*/ +#ifndef PS1TIM_H +#define PS1TIM_H + +#include +#include + +namespace PS1TIM { + +/** + * Decode a PlayStation TIM file to an Ogre::Image (PF_BYTE_RGBA). + * + * Supports: + * - 4bpp indexed (with CLUT) + * - 8bpp indexed (with CLUT) + * - 16bpp direct color + * + * Notes: + * - TIM image header width is stored in 16-bit words; pixel width depends on bpp. + * - For indexed modes, the first CLUT row is used. + */ +bool loadTimToOgreImage(const QString& timPath, Ogre::Image& outImage, QString* outError = nullptr); + +} // namespace PS1TIM + +#endif + diff --git a/src/PS1/PS1TMD.cpp b/src/PS1/PS1TMD.cpp new file mode 100644 index 000000000..007d71824 --- /dev/null +++ b/src/PS1/PS1TMD.cpp @@ -0,0 +1,927 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License +----------------------------------------------------------------------------------- +*/ + +#include "PS1/PS1TMD.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "PS1/PS1TIM.h" + +#include +#include +#include +#include + +namespace { + +constexpr uint32_t kTmdId = 0x41u; +constexpr size_t kTmdHeaderSize = 12u; +constexpr size_t kObjHeaderSize = 28u; + +inline uint32_t readU32le(const uint8_t* p) +{ + return uint32_t(p[0]) | (uint32_t(p[1]) << 8) | (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24); +} + +inline uint16_t readU16le(const uint8_t* p) +{ + return uint16_t(p[0]) | (uint16_t(p[1]) << 8); +} + +inline int16_t readI16le(const uint8_t* p) +{ + return static_cast(readU16le(p)); +} + +inline void writeU32le(uint8_t* p, uint32_t v) +{ + p[0] = uint8_t(v & 0xFF); + p[1] = uint8_t((v >> 8) & 0xFF); + p[2] = uint8_t((v >> 16) & 0xFF); + p[3] = uint8_t((v >> 24) & 0xFF); +} + +inline void writeU16le(uint8_t* p, uint16_t v) +{ + p[0] = uint8_t(v & 0xFF); + p[1] = uint8_t((v >> 8) & 0xFF); +} + +inline int16_t clampI16(int v) +{ + if (v > 32767) + return 32767; + if (v < -32768) + return -32768; + return static_cast(v); +} + +/** + * PS1 8-bit U/V are texel indices in the active 256×256 texture page. + * Map to 0..1 with texel-center bias. Like most PC APIs here, increasing V moves down the image (same as PSX + * VRAM Y), so no V flip — matches typical PNG/JPEG row order with Ogre’s 2D textures. + * Texture page / CLUT (cba/tsb) are not baked into UVs; use a bitmap cropped to the same page. + */ +static float decodePs1TexU(uint8_t uByte) +{ + return (float(uByte) + 0.5f) / 256.0f; +} + +static float decodePs1TexV(uint8_t vByte) +{ + return (float(vByte) + 0.5f) / 256.0f; +} + +/** After fixed-point step: scale for editor, then 180° about +Z (right-handed: x,y → −x, −y). */ +static void applyTmdImportWorldTransform(Ogre::Vector3& p) +{ + p *= PS1TMD::kTmdEditorUniformScale; + p.x = -p.x; + p.y = -p.y; +} + +/** Same as vertex rotation: 180° about +Z (fixed-point vector from file, then unitized). */ +static void applyTmdImportWorldTransformNormal(Ogre::Vector3& n) +{ + if (n.isZeroLength()) + return; + n.normalise(); + n.x = -n.x; + n.y = -n.y; +} + +/** Offsets in object headers are relative to byte 12 (post file header). */ +inline size_t absFromStored(uint32_t stored, size_t fileSize) +{ + const size_t a = size_t(12u) + size_t(stored); + return a < fileSize ? a : SIZE_MAX; +} + +struct TriSoup { + std::vector pos; + std::vector nrm; + std::vector uv; + bool hasUv{false}; +}; + +/** Unit normal from triangle positions (no-light TMD prims have no normal indices). */ +static Ogre::Vector3 flatNormalFromTri(const Ogre::Vector3& p0, const Ogre::Vector3& p1, const Ogre::Vector3& p2) +{ + const Ogre::Vector3 e1 = p1 - p0; + const Ogre::Vector3 e2 = p2 - p0; + Ogre::Vector3 n = e1.crossProduct(e2); + if (n.squaredLength() < 1e-24f) + return Ogre::Vector3::UNIT_Y; + n.normalise(); + return n; +} + +static void appendTri(TriSoup& out, const Ogre::Vector3& p0, const Ogre::Vector3& p1, const Ogre::Vector3& p2, + const Ogre::Vector3& n0, const Ogre::Vector3& n1, const Ogre::Vector3& n2, + const Ogre::Vector2& uv0, const Ogre::Vector2& uv1, const Ogre::Vector2& uv2, bool withUv) +{ + out.pos.push_back(p0); + out.pos.push_back(p1); + out.pos.push_back(p2); + out.nrm.push_back(n0); + out.nrm.push_back(n1); + out.nrm.push_back(n2); + if (withUv) { + out.uv.push_back(uv0); + out.uv.push_back(uv1); + out.uv.push_back(uv2); + out.hasUv = true; + } +} + +static bool parseTmdObject(const uint8_t* data, size_t fileSize, uint32_t storedVertOff, uint32_t nVert, + uint32_t storedNormOff, uint32_t nNorm, uint32_t storedPrimOff, uint32_t nPrim, + float step, TriSoup& out, Ogre::LogManager& log) +{ + const size_t vBase = absFromStored(storedVertOff, fileSize); + const size_t nBase = absFromStored(storedNormOff, fileSize); + const size_t pBase = absFromStored(storedPrimOff, fileSize); + if (vBase == SIZE_MAX || nBase == SIZE_MAX || pBase == SIZE_MAX) + return false; + if (vBase + size_t(nVert) * 8u > fileSize || nBase + size_t(nNorm) * 8u > fileSize) + return false; + + std::vector verts(nVert); + std::vector norms(nNorm); + for (uint32_t i = 0; i < nVert; ++i) { + const uint8_t* v = data + vBase + i * 8u; + const int16_t x = readI16le(v); + const int16_t y = readI16le(v + 2); + const int16_t z = readI16le(v + 4); + verts[i] = Ogre::Vector3(float(x) * step, float(y) * step, float(z) * step); + applyTmdImportWorldTransform(verts[i]); + } + for (uint32_t i = 0; i < nNorm; ++i) { + const uint8_t* v = data + nBase + i * 8u; + const int16_t x = readI16le(v); + const int16_t y = readI16le(v + 2); + const int16_t z = readI16le(v + 4); + Ogre::Vector3 n{float(x), float(y), float(z)}; + if (!n.isZeroLength()) + n.normalise(); + applyTmdImportWorldTransformNormal(n); + norms[i] = n; + } + + size_t p = pBase; + uint32_t consumed = 0; + while (consumed < nPrim && p + 4 <= fileSize) { + const uint8_t olen = data[p]; + const uint8_t ilen = data[p + 1]; + const uint8_t flag = data[p + 2]; + const uint8_t mode = data[p + 3]; + const size_t payload = size_t(ilen) * 4u; + if (p + 4 + payload > fileSize) { + log.logMessage("PS1TMD: truncated primitive stream", Ogre::LML_WARNING); + break; + } + const uint8_t* d = data + p + 4; + p += 4 + payload; + ++consumed; + (void)olen; + + if (mode == 0x20 && flag == 0 && ilen == 3) { + const uint16_t ni = readU16le(d + 4); + const uint16_t i0 = readU16le(d + 6); + const uint16_t i1 = readU16le(d + 8); + const uint16_t i2 = readU16le(d + 10); + if (i0 < nVert && i1 < nVert && i2 < nVert && ni < nNorm) { + const Ogre::Vector3& np = norms[ni]; + // Swap v1/v2 so front-face winding matches Ogre (CCW) vs PSX packet order. + appendTri(out, verts[i0], verts[i2], verts[i1], np, np, np, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, + Ogre::Vector2::ZERO, false); + } + continue; + } + if (mode == 0x30 && flag == 0 && ilen == 4) { + const uint16_t n0 = readU16le(d + 4); + const uint16_t v0 = readU16le(d + 6); + const uint16_t n1 = readU16le(d + 8); + const uint16_t v1 = readU16le(d + 10); + const uint16_t n2 = readU16le(d + 12); + const uint16_t v2 = readU16le(d + 14); + if (v0 < nVert && v1 < nVert && v2 < nVert && n0 < nNorm && n1 < nNorm && n2 < nNorm) + appendTri(out, verts[v0], verts[v2], verts[v1], norms[n0], norms[n2], norms[n1], Ogre::Vector2::ZERO, + Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false); + continue; + } + if (mode == 0x24 && flag == 0 && ilen == 5) { + const uint16_t ni = readU16le(d + 12); + const uint16_t i0 = readU16le(d + 14); + const uint16_t i1 = readU16le(d + 16); + const uint16_t i2 = readU16le(d + 18); + if (i0 < nVert && i1 < nVert && i2 < nVert && ni < nNorm) { + const Ogre::Vector3& np = norms[ni]; + const float u0 = decodePs1TexU(d[0]); + const float v0 = decodePs1TexV(d[1]); + const float u1 = decodePs1TexU(d[4]); + const float v1 = decodePs1TexV(d[5]); + const float u2 = decodePs1TexU(d[8]); + const float v2 = decodePs1TexV(d[9]); + appendTri(out, verts[i0], verts[i2], verts[i1], np, np, np, Ogre::Vector2(u0, v0), Ogre::Vector2(u2, v2), + Ogre::Vector2(u1, v1), true); + } + continue; + } + if (mode == 0x34 && flag == 0 && ilen == 6) { + const uint16_t n0 = readU16le(d + 12); + const uint16_t v0 = readU16le(d + 14); + const uint16_t n1 = readU16le(d + 16); + const uint16_t v1 = readU16le(d + 18); + const uint16_t n2 = readU16le(d + 20); + const uint16_t v2 = readU16le(d + 22); + if (v0 < nVert && v1 < nVert && v2 < nVert && n0 < nNorm && n1 < nNorm && n2 < nNorm) { + const float u0 = decodePs1TexU(d[0]); + const float v0 = decodePs1TexV(d[1]); + const float u1 = decodePs1TexU(d[4]); + const float v1 = decodePs1TexV(d[5]); + const float u2 = decodePs1TexU(d[8]); + const float v2 = decodePs1TexV(d[9]); + appendTri(out, verts[v0], verts[v2], verts[v1], norms[n0], norms[n2], norms[n1], Ogre::Vector2(u0, v0), + Ogre::Vector2(u2, v2), Ogre::Vector2(u1, v1), true); + } + continue; + } + if (mode == 0x28 && flag == 0 && ilen == 4) { + const uint16_t ni = readU16le(d + 4); + const uint16_t i0 = readU16le(d + 6); + const uint16_t i1 = readU16le(d + 8); + const uint16_t i2 = readU16le(d + 10); + const uint16_t i3 = readU16le(d + 12); + if (i0 < nVert && i1 < nVert && i2 < nVert && i3 < nVert && ni < nNorm) { + const Ogre::Vector3& np = norms[ni]; + appendTri(out, verts[i0], verts[i2], verts[i1], np, np, np, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, + Ogre::Vector2::ZERO, false); + appendTri(out, verts[i0], verts[i3], verts[i2], np, np, np, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, + Ogre::Vector2::ZERO, false); + } + continue; + } + // Net Yaroze: Mode 0x25 && Flag 1 — Textured triangle, no light (RGB @ 12–14, verts @ 16–20). + if (mode == 0x25 && flag == 1 && ilen == 6) { + const float u0 = decodePs1TexU(d[0]); + const float v0 = decodePs1TexV(d[1]); + const float u1 = decodePs1TexU(d[4]); + const float v1 = decodePs1TexV(d[5]); + const float u2 = decodePs1TexU(d[8]); + const float v2 = decodePs1TexV(d[9]); + const uint16_t i0 = readU16le(d + 16); + const uint16_t i1 = readU16le(d + 18); + const uint16_t i2 = readU16le(d + 20); + if (i0 < nVert && i1 < nVert && i2 < nVert) { + const Ogre::Vector3& p0 = verts[i0]; + const Ogre::Vector3& p1 = verts[i1]; + const Ogre::Vector3& p2 = verts[i2]; + const Ogre::Vector3 np = flatNormalFromTri(p0, p2, p1); + appendTri(out, p0, p2, p1, np, np, np, Ogre::Vector2(u0, v0), Ogre::Vector2(u2, v2), + Ogre::Vector2(u1, v1), true); + } + continue; + } + // Net Yaroze: Mode 0x35 && Flag 1 — Gouraud textured triangle, no light (per-vert RGB 12–23, verts @ 24–28). + if (mode == 0x35 && flag == 1 && ilen == 8) { + const float u0 = decodePs1TexU(d[0]); + const float v0 = decodePs1TexV(d[1]); + const float u1 = decodePs1TexU(d[4]); + const float v1 = decodePs1TexV(d[5]); + const float u2 = decodePs1TexU(d[8]); + const float v2 = decodePs1TexV(d[9]); + const uint16_t i0 = readU16le(d + 24); + const uint16_t i1 = readU16le(d + 26); + const uint16_t i2 = readU16le(d + 28); + if (i0 < nVert && i1 < nVert && i2 < nVert) { + const Ogre::Vector3& p0 = verts[i0]; + const Ogre::Vector3& p1 = verts[i1]; + const Ogre::Vector3& p2 = verts[i2]; + const Ogre::Vector3 np = flatNormalFromTri(p0, p2, p1); + appendTri(out, p0, p2, p1, np, np, np, Ogre::Vector2(u0, v0), Ogre::Vector2(u2, v2), + Ogre::Vector2(u1, v1), true); + } + continue; + } + // Sony tmd.h TMD_F_4T — lit textured quad (one normal), ilen 7. + if (mode == 0x2c && flag == 0 && ilen == 7) { + const float u0 = decodePs1TexU(d[0]); + const float v0 = decodePs1TexV(d[1]); + const float u1 = decodePs1TexU(d[4]); + const float v1 = decodePs1TexV(d[5]); + const float u2 = decodePs1TexU(d[8]); + const float v2 = decodePs1TexV(d[9]); + const float u3 = decodePs1TexU(d[12]); + const float v3 = decodePs1TexV(d[13]); + const uint16_t ni = readU16le(d + 16); + const uint16_t i0 = readU16le(d + 18); + const uint16_t i1 = readU16le(d + 20); + const uint16_t i2 = readU16le(d + 22); + const uint16_t i3 = readU16le(d + 24); + if (i0 < nVert && i1 < nVert && i2 < nVert && i3 < nVert && ni < nNorm) { + const Ogre::Vector3& np = norms[ni]; + appendTri(out, verts[i0], verts[i2], verts[i1], np, np, np, Ogre::Vector2(u0, v0), + Ogre::Vector2(u2, v2), Ogre::Vector2(u1, v1), true); + appendTri(out, verts[i0], verts[i3], verts[i2], np, np, np, Ogre::Vector2(u0, v0), + Ogre::Vector2(u3, v3), Ogre::Vector2(u2, v2), true); + } + continue; + } + // TMD_F_4T_NL — textured quad, no light (RGB @ 16–18), verts @ 20–26. + if (mode == 0x2d && flag == 1 && ilen == 7) { + const float u0 = decodePs1TexU(d[0]); + const float v0 = decodePs1TexV(d[1]); + const float u1 = decodePs1TexU(d[4]); + const float v1 = decodePs1TexV(d[5]); + const float u2 = decodePs1TexU(d[8]); + const float v2 = decodePs1TexV(d[9]); + const float u3 = decodePs1TexU(d[12]); + const float v3 = decodePs1TexV(d[13]); + const uint16_t i0 = readU16le(d + 20); + const uint16_t i1 = readU16le(d + 22); + const uint16_t i2 = readU16le(d + 24); + const uint16_t i3 = readU16le(d + 26); + if (i0 < nVert && i1 < nVert && i2 < nVert && i3 < nVert) { + const Ogre::Vector3& p0 = verts[i0]; + const Ogre::Vector3& p1 = verts[i1]; + const Ogre::Vector3& p2 = verts[i2]; + const Ogre::Vector3& p3 = verts[i3]; + Ogre::Vector3 np = flatNormalFromTri(p0, p2, p1); + appendTri(out, p0, p2, p1, np, np, np, Ogre::Vector2(u0, v0), Ogre::Vector2(u2, v2), + Ogre::Vector2(u1, v1), true); + np = flatNormalFromTri(p0, p3, p2); + appendTri(out, p0, p3, p2, np, np, np, Ogre::Vector2(u0, v0), Ogre::Vector2(u3, v3), + Ogre::Vector2(u2, v2), true); + } + continue; + } + // TMD_G_4T — Gouraud textured quad, ilen 8. + if (mode == 0x3c && flag == 0 && ilen == 8) { + const float u0 = decodePs1TexU(d[0]); + const float v0 = decodePs1TexV(d[1]); + const float u1 = decodePs1TexU(d[4]); + const float v1 = decodePs1TexV(d[5]); + const float u2 = decodePs1TexU(d[8]); + const float v2 = decodePs1TexV(d[9]); + const float u3 = decodePs1TexU(d[12]); + const float v3 = decodePs1TexV(d[13]); + const uint16_t n0 = readU16le(d + 16); + const uint16_t v0i = readU16le(d + 18); + const uint16_t n1 = readU16le(d + 20); + const uint16_t v1i = readU16le(d + 22); + const uint16_t n2 = readU16le(d + 24); + const uint16_t v2i = readU16le(d + 26); + const uint16_t n3 = readU16le(d + 28); + const uint16_t v3i = readU16le(d + 30); + if (v0i < nVert && v1i < nVert && v2i < nVert && v3i < nVert && n0 < nNorm && n1 < nNorm + && n2 < nNorm && n3 < nNorm) { + appendTri(out, verts[v0i], verts[v2i], verts[v1i], norms[n0], norms[n2], norms[n1], + Ogre::Vector2(u0, v0), Ogre::Vector2(u2, v2), Ogre::Vector2(u1, v1), true); + appendTri(out, verts[v0i], verts[v3i], verts[v2i], norms[n0], norms[n3], norms[n2], + Ogre::Vector2(u0, v0), Ogre::Vector2(u3, v3), Ogre::Vector2(u2, v2), true); + } + continue; + } + // TMD_G_4T_NL — Gouraud textured quad, no light, ilen 10. + if (mode == 0x3d && flag == 1 && ilen == 10) { + const float u0 = decodePs1TexU(d[0]); + const float v0 = decodePs1TexV(d[1]); + const float u1 = decodePs1TexU(d[4]); + const float v1 = decodePs1TexV(d[5]); + const float u2 = decodePs1TexU(d[8]); + const float v2 = decodePs1TexV(d[9]); + const float u3 = decodePs1TexU(d[12]); + const float v3 = decodePs1TexV(d[13]); + const uint16_t i0 = readU16le(d + 32); + const uint16_t i1 = readU16le(d + 34); + const uint16_t i2 = readU16le(d + 36); + const uint16_t i3 = readU16le(d + 38); + if (i0 < nVert && i1 < nVert && i2 < nVert && i3 < nVert) { + const Ogre::Vector3& p0 = verts[i0]; + const Ogre::Vector3& p1 = verts[i1]; + const Ogre::Vector3& p2 = verts[i2]; + const Ogre::Vector3& p3 = verts[i3]; + Ogre::Vector3 np = flatNormalFromTri(p0, p2, p1); + appendTri(out, p0, p2, p1, np, np, np, Ogre::Vector2(u0, v0), Ogre::Vector2(u2, v2), + Ogre::Vector2(u1, v1), true); + np = flatNormalFromTri(p0, p3, p2); + appendTri(out, p0, p3, p2, np, np, np, Ogre::Vector2(u0, v0), Ogre::Vector2(u3, v3), + Ogre::Vector2(u2, v2), true); + } + continue; + } + } + return !out.pos.empty(); +} + +static Ogre::MeshPtr buildMeshFromSoup(const std::string& meshName, const TriSoup& soup) +{ + if (soup.pos.empty() || soup.pos.size() % 3u != 0) + return {}; + + if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName)) + Ogre::MeshManager::getSingleton().remove(old); + + Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createManual( + meshName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + Ogre::SubMesh* sm = mesh->createSubMesh(); + // Use a unique per-import material so texture assignment doesn't mutate BaseOutlined globally. + // This also lets us apply TMD-specific texture scaling heuristics later (see MaterialEditorQML). + const std::string tmdMatName = std::string("TMD/") + meshName; + try { + if (!Ogre::MaterialManager::getSingleton().getByName(tmdMatName)) { + if (auto base = Ogre::MaterialManager::getSingleton().getByName("BaseOutlined")) { + base->clone(tmdMatName); + } + } + } catch (...) { + // If materials aren't available yet, fall back to BaseOutlined. + } + sm->setMaterialName(Ogre::MaterialManager::getSingleton().getByName(tmdMatName) ? tmdMatName : "BaseOutlined"); + sm->useSharedVertices = false; + + const size_t nVert = soup.pos.size(); + sm->vertexData = new Ogre::VertexData(); + sm->vertexData->vertexCount = static_cast(nVert); + auto* decl = sm->vertexData->vertexDeclaration; + auto* bind = sm->vertexData->vertexBufferBinding; + size_t off = 0; + decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); + off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + const bool hasUv = soup.hasUv && soup.uv.size() == nVert; + if (hasUv) { + decl->addElement(0, off, Ogre::VET_FLOAT2, Ogre::VES_TEXTURE_COORDINATES); + off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT2); + } + const size_t vsize = decl->getVertexSize(0); + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + vsize, nVert, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + uint8_t* dst = static_cast(vbuf->lock(Ogre::HardwareBuffer::HBL_DISCARD)); + for (size_t i = 0; i < nVert; ++i) { + uint8_t* row = dst + i * vsize; + float* p = nullptr; + decl->findElementBySemantic(Ogre::VES_POSITION)->baseVertexPointerToElement(row, &p); + p[0] = soup.pos[i].x; + p[1] = soup.pos[i].y; + p[2] = soup.pos[i].z; + decl->findElementBySemantic(Ogre::VES_NORMAL)->baseVertexPointerToElement(row, &p); + p[0] = soup.nrm[i].x; + p[1] = soup.nrm[i].y; + p[2] = soup.nrm[i].z; + if (hasUv) { + decl->findElementBySemantic(Ogre::VES_TEXTURE_COORDINATES)->baseVertexPointerToElement(row, &p); + p[0] = soup.uv[i].x; + p[1] = soup.uv[i].y; + } + } + vbuf->unlock(); + bind->setBinding(0, vbuf); + + const size_t nTri = nVert / 3; + const bool use32 = nVert > 65535; + auto ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( + use32 ? Ogre::HardwareIndexBuffer::IT_32BIT : Ogre::HardwareIndexBuffer::IT_16BIT, nTri * 3, + Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + if (use32) { + auto* ip = static_cast(ibuf->lock(Ogre::HardwareBuffer::HBL_DISCARD)); + for (size_t i = 0; i < nVert; ++i) + ip[i] = static_cast(i); + ibuf->unlock(); + } else { + auto* ip = static_cast(ibuf->lock(Ogre::HardwareBuffer::HBL_DISCARD)); + for (size_t i = 0; i < nVert; ++i) + ip[i] = static_cast(i); + ibuf->unlock(); + } + sm->indexData->indexBuffer = ibuf; + sm->indexData->indexCount = static_cast(nTri * 3); + sm->indexData->indexStart = 0; + + Ogre::AxisAlignedBox bounds; + for (const auto& v : soup.pos) + bounds.merge(v); + mesh->_setBounds(bounds); + mesh->_setBoundingSphereRadius(bounds.getHalfSize().length()); + mesh->load(); + return mesh; +} + +static bool submeshHasDiffuseTexture(const Ogre::MaterialPtr& mat) +{ + if (!mat) + return false; + try { + if (!mat->isLoaded()) + mat->load(); + } catch (...) { + return false; + } + if (mat->getNumTechniques() == 0) + return false; + Ogre::Technique* tech = mat->getTechnique(0); + if (!tech || tech->getNumPasses() == 0) + return false; + Ogre::Pass* pass = tech->getPass(0); + if (!pass) + return false; + for (unsigned short i = 0; i < pass->getNumTextureUnitStates(); ++i) { + auto* tus = pass->getTextureUnitState(i); + if (tus->getContentType() != Ogre::TextureUnitState::CONTENT_NAMED) + continue; + const std::string& nm = tus->getName(); + if (nm == "normal_map" || nm == "NormalMap") + continue; + if (!tus->getTextureName().empty()) + return true; + } + return false; +} + +static void writeVertex8(int16_t x, int16_t y, int16_t z, uint8_t* out8) +{ + writeU16le(out8 + 0, static_cast(x)); + writeU16le(out8 + 2, static_cast(y)); + writeU16le(out8 + 4, static_cast(z)); + writeU16le(out8 + 6, 0); +} + +static void appendG3(std::vector& pb, uint16_t i0, uint16_t i1, uint16_t i2, uint16_t n0, uint16_t n1, uint16_t n2) +{ + const size_t start = pb.size(); + pb.resize(start + 4 + 16); + uint8_t* pkt = pb.data() + start; + pkt[0] = 6; + pkt[1] = 4; + pkt[2] = 0; + pkt[3] = 0x30; + pkt[4] = 200; + pkt[5] = 200; + pkt[6] = 200; + pkt[7] = 0x30; + writeU16le(pkt + 8, n0); + writeU16le(pkt + 10, i0); + writeU16le(pkt + 12, n1); + writeU16le(pkt + 14, i1); + writeU16le(pkt + 16, n2); + writeU16le(pkt + 18, i2); +} + +static void appendFt3(std::vector& pb, uint16_t i0, uint16_t i1, uint16_t i2, uint16_t ni, const Ogre::Vector2& t0, + const Ogre::Vector2& t1, const Ogre::Vector2& t2) +{ + const size_t start = pb.size(); + pb.resize(start + 4 + 20); + uint8_t* pkt = pb.data() + start; + pkt[0] = 7; + pkt[1] = 5; + pkt[2] = 0; + pkt[3] = 0x24; + auto encU = [](float u) -> uint8_t { + return static_cast(std::clamp(int(std::lround(u * 256.0f - 0.5f)), 0, 255)); + }; + pkt[4] = encU(t0.x); + pkt[5] = encU(t0.y); + writeU16le(pkt + 6, 0); + pkt[8] = encU(t1.x); + pkt[9] = encU(t1.y); + writeU16le(pkt + 10, 0); + pkt[12] = encU(t2.x); + pkt[13] = encU(t2.y); + writeU16le(pkt + 14, 0); + writeU16le(pkt + 16, ni); + writeU16le(pkt + 18, i0); + writeU16le(pkt + 20, i1); + writeU16le(pkt + 22, i2); +} + +static uint32_t countPrimPackets(const std::vector& prims) +{ + uint32_t n = 0; + for (size_t i = 0; i < prims.size();) { + if (i + 4 > prims.size()) + break; + const uint8_t ilen = prims[i + 1]; + const size_t step = 4 + size_t(ilen) * 4u; + if (i + step > prims.size()) + break; + i += step; + ++n; + } + return n; +} + +} // namespace + +namespace PS1TMD { + +Ogre::MeshPtr importTmd(const QString& filePath, const std::string& meshName, float ogreUnitsPerTmdStep) +{ + QFile f(filePath); + if (!f.open(QIODevice::ReadOnly)) + return {}; + const QByteArray raw = f.readAll(); + f.close(); + const uint8_t* data = reinterpret_cast(raw.constData()); + const size_t fileSize = static_cast(raw.size()); + auto& log = Ogre::LogManager::getSingleton(); + + if (fileSize < kTmdHeaderSize) + return {}; + if (readU32le(data) != kTmdId) { + log.logMessage("PS1TMD: bad ID (expected 0x41)", Ogre::LML_WARNING); + return {}; + } + const uint32_t numObj = readU32le(data + 8); + if (numObj == 0 || numObj > 4096u) + return {}; + if (fileSize < kTmdHeaderSize + numObj * kObjHeaderSize) + return {}; + + TriSoup merged; + for (uint32_t oi = 0; oi < numObj; ++oi) { + const uint8_t* oh = data + 12 + oi * kObjHeaderSize; + const uint32_t vOff = readU32le(oh); + const uint32_t nV = readU32le(oh + 4); + const uint32_t nOff = readU32le(oh + 8); + const uint32_t nN = readU32le(oh + 12); + const uint32_t pOff = readU32le(oh + 16); + const uint32_t nP = readU32le(oh + 20); + TriSoup part; + if (!parseTmdObject(data, fileSize, vOff, nV, nOff, nN, pOff, nP, ogreUnitsPerTmdStep, part, log)) + continue; + merged.pos.insert(merged.pos.end(), part.pos.begin(), part.pos.end()); + merged.nrm.insert(merged.nrm.end(), part.nrm.begin(), part.nrm.end()); + if (part.hasUv) { + merged.hasUv = true; + merged.uv.insert(merged.uv.end(), part.uv.begin(), part.uv.end()); + } else if (merged.hasUv) { + for (size_t k = 0; k < part.pos.size(); ++k) + merged.uv.push_back(Ogre::Vector2::ZERO); + } + } + if (merged.pos.empty()) + return {}; + if (merged.hasUv && merged.uv.size() != merged.pos.size()) { + merged.uv.clear(); + merged.hasUv = false; + } + + Ogre::MeshPtr mesh = buildMeshFromSoup(meshName, merged); + if (!mesh) + return {}; + + // Auto-apply a sibling .TIM texture when: + // - the TMD actually has UVs (textured primitives), and + // - there is a TIM file next to the TMD with the same basename (e.g., CAR.TMD -> CAR.TIM). + if (merged.hasUv) { + const QFileInfo tmdFi(filePath); + const QString base = tmdFi.completeBaseName(); + const QString dir = tmdFi.absolutePath(); + const QString timUpper = QDir(dir).filePath(base + ".TIM"); + const QString timLower = QDir(dir).filePath(base + ".tim"); + const QString timPath = QFileInfo::exists(timUpper) ? timUpper : (QFileInfo::exists(timLower) ? timLower : QString()); + + if (!timPath.isEmpty()) { + const std::string matName = std::string("TMD/") + meshName; + try { + auto mat = Ogre::MaterialManager::getSingleton().getByName(matName); + if (mat) { + if (!mat->isLoaded()) + mat->load(); + if (mat->getNumTechniques() > 0 && mat->getTechnique(0)->getNumPasses() > 0) { + Ogre::Pass* pass0 = mat->getTechnique(0)->getPass(0); + if (pass0) { + Ogre::TextureUnitState* tus = nullptr; + if (pass0->getNumTextureUnitStates() > 0) { + tus = pass0->getTextureUnitState(0); + } else { + tus = pass0->createTextureUnitState(); + } + + if (tus) { + Ogre::Image img; + QString err; + if (PS1TIM::loadTimToOgreImage(timPath, img, &err)) { + const QString timFileName = QFileInfo(timPath).fileName(); + const std::string texName = timFileName.toStdString(); + const std::string group = Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME; + Ogre::TextureManager::getSingleton().loadImage(texName, group, img); + tus->setTextureName(texName); + + // Simplify: single pass (remove outline/wireframe) on these per-import materials. + Ogre::Technique* tech0 = mat->getTechnique(0); + while (tech0 && tech0->getNumPasses() > 1) { + tech0->removePass(1); + } + } else { + log.logMessage(QString("PS1TMD: TIM decode failed (%1): %2").arg(timPath, err).toStdString(), + Ogre::LML_WARNING); + } + } + } + } + } + } catch (...) { + // Best-effort: if material/texture setup fails, keep the mesh usable. + } + } + } + + return mesh; +} + +bool exportEntity(const Ogre::Entity* entity, const QString& filePath, float ogreUnitsPerTmdStep) +{ + if (!entity || !entity->getMesh()) + return false; + const float invStep = 1.0f / ogreUnitsPerTmdStep; + const float invEditorScale = 1.0f / PS1TMD::kTmdEditorUniformScale; + Ogre::Mesh* mesh = entity->getMesh().get(); + const unsigned numSub = mesh->getNumSubMeshes(); + if (numSub == 0) + return false; + + struct ObjBlob { + std::vector verts; + std::vector norms; + std::vector prims; + }; + std::vector objects(numSub); + + for (unsigned si = 0; si < numSub; ++si) { + Ogre::SubMesh* sm = mesh->getSubMesh(si); + Ogre::VertexData* vd = sm->useSharedVertices ? mesh->sharedVertexData : sm->vertexData; + if (!vd || !sm->indexData || sm->indexData->indexCount < 3) + continue; + + const auto* posEl = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_POSITION); + const auto* nrmEl = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_NORMAL); + const auto* uvEl = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_TEXTURE_COORDINATES); + if (!posEl || !nrmEl) + continue; + + auto* subEnt = entity->getSubEntity(si); + const bool textured = subEnt && submeshHasDiffuseTexture(subEnt->getMaterial()); + const bool hasUv = textured && uvEl; + + auto posBuf = vd->vertexBufferBinding->getBuffer(posEl->getSource()); + auto nrmBuf = vd->vertexBufferBinding->getBuffer(nrmEl->getSource()); + Ogre::HardwareVertexBufferSharedPtr uvBuf; + if (hasUv) + uvBuf = vd->vertexBufferBinding->getBuffer(uvEl->getSource()); + const size_t posStride = posBuf->getVertexSize(); + const size_t nrmStride = nrmBuf->getVertexSize(); + const size_t uvStride = uvBuf ? uvBuf->getVertexSize() : 0; + + const uint32_t vCount = vd->vertexCount; + objects[si].verts.resize(size_t(vCount) * 8u, 0); + objects[si].norms.resize(size_t(vCount) * 8u, 0); + + const uint8_t* posBase = static_cast(posBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + const uint8_t* nrmBase = static_cast(nrmBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + const uint8_t* uvBase = uvBuf ? static_cast(uvBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)) : nullptr; + + for (uint32_t vi = 0; vi < vCount; ++vi) { + const uint8_t* prow = posBase + vi * posStride; + const uint8_t* nrow = nrmBase + vi * nrmStride; + Ogre::Real* pf = nullptr; + Ogre::Real* nf = nullptr; + posEl->baseVertexPointerToElement(const_cast(prow), &pf); + nrmEl->baseVertexPointerToElement(const_cast(nrow), &nf); + // Inverse of import: undo 180° Z then divide by editor scale, then TMD fixed-point. + const float tmdX = -pf[0] * invEditorScale; + const float tmdY = -pf[1] * invEditorScale; + const float tmdZ = pf[2] * invEditorScale; + const int16_t px = clampI16(static_cast(std::lround(tmdX * invStep))); + const int16_t py = clampI16(static_cast(std::lround(tmdY * invStep))); + const int16_t pz = clampI16(static_cast(std::lround(tmdZ * invStep))); + // Inverse of R_z on normals (same rotation as vertex positions in mesh space). + const int16_t nx = clampI16(static_cast(std::lround(-nf[0] * 4096.0f))); + const int16_t ny = clampI16(static_cast(std::lround(-nf[1] * 4096.0f))); + const int16_t nz = clampI16(static_cast(std::lround(nf[2] * 4096.0f))); + writeVertex8(px, py, pz, objects[si].verts.data() + vi * 8); + writeVertex8(nx, ny, nz, objects[si].norms.data() + vi * 8); + } + posBuf->unlock(); + nrmBuf->unlock(); + if (uvBuf) + uvBuf->unlock(); + + auto ibuf = sm->indexData->indexBuffer; + const uint8_t* ib = static_cast(ibuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + const bool i32 = ibuf->getType() == Ogre::HardwareIndexBuffer::IT_32BIT; + const size_t triCount = sm->indexData->indexCount / 3; + const unsigned ist = sm->indexData->indexStart; + + for (size_t t = 0; t < triCount; ++t) { + uint32_t i0, i1, i2; + if (i32) { + const auto* ip = reinterpret_cast(ib); + i0 = ip[ist + t * 3 + 0]; + i1 = ip[ist + t * 3 + 1]; + i2 = ip[ist + t * 3 + 2]; + } else { + const auto* ip = reinterpret_cast(ib); + i0 = ip[ist + t * 3 + 0]; + i1 = ip[ist + t * 3 + 1]; + i2 = ip[ist + t * 3 + 2]; + } + if (i0 >= vCount || i1 >= vCount || i2 >= vCount) + continue; + if (hasUv && uvEl && uvBase) { + Ogre::Real* tf = nullptr; + uvEl->baseVertexPointerToElement(const_cast(const_cast(uvBase + i0 * uvStride)), &tf); + const Ogre::Vector2 tv0(tf[0], tf[1]); + uvEl->baseVertexPointerToElement(const_cast(const_cast(uvBase + i1 * uvStride)), &tf); + const Ogre::Vector2 tv1(tf[0], tf[1]); + uvEl->baseVertexPointerToElement(const_cast(const_cast(uvBase + i2 * uvStride)), &tf); + const Ogre::Vector2 tv2(tf[0], tf[1]); + // Undo import winding swap so .tmd primitive order matches PSX convention on disk. + appendFt3(objects[si].prims, static_cast(i0), static_cast(i2), static_cast(i1), + static_cast(i0), tv0, tv2, tv1); + } else if (textured) { + appendFt3(objects[si].prims, static_cast(i0), static_cast(i2), static_cast(i1), + static_cast(i0), Ogre::Vector2(0, 0), Ogre::Vector2(0, 0), Ogre::Vector2(0, 0)); + } else { + appendG3(objects[si].prims, static_cast(i0), static_cast(i2), static_cast(i1), + static_cast(i0), static_cast(i2), static_cast(i1)); + } + } + ibuf->unlock(); + } + + bool anyGeometry = false; + for (unsigned si = 0; si < numSub; ++si) { + if (!objects[si].verts.empty() && !objects[si].prims.empty()) { + anyGeometry = true; + break; + } + } + if (!anyGeometry) + return false; + + std::vector file; + const size_t headBytes = kTmdHeaderSize + numSub * kObjHeaderSize; + file.resize(headBytes); + writeU32le(file.data(), kTmdId); + writeU32le(file.data() + 4, 0); + writeU32le(file.data() + 8, numSub); + + for (unsigned si = 0; si < numSub; ++si) { + uint8_t* oh = file.data() + 12 + si * kObjHeaderSize; + if (objects[si].verts.empty() || objects[si].prims.empty()) { + std::memset(oh, 0, kObjHeaderSize); + continue; + } + const uint32_t vOff = static_cast(file.size() - 12u); + file.insert(file.end(), objects[si].verts.begin(), objects[si].verts.end()); + const uint32_t nVert = static_cast(objects[si].verts.size() / 8u); + const uint32_t nOff = static_cast(file.size() - 12u); + file.insert(file.end(), objects[si].norms.begin(), objects[si].norms.end()); + const uint32_t nNorm = static_cast(objects[si].norms.size() / 8u); + const uint32_t pOff = static_cast(file.size() - 12u); + const uint32_t pktCount = countPrimPackets(objects[si].prims); + file.insert(file.end(), objects[si].prims.begin(), objects[si].prims.end()); + + writeU32le(oh, vOff); + writeU32le(oh + 4, nVert); + writeU32le(oh + 8, nOff); + writeU32le(oh + 12, nNorm); + writeU32le(oh + 16, pOff); + writeU32le(oh + 20, pktCount); + writeU32le(oh + 24, 0); + } + + QFile out(filePath); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return false; + out.write(reinterpret_cast(file.data()), static_cast(file.size())); + out.close(); + return true; +} + +} // namespace PS1TMD diff --git a/src/PS1/PS1TMD.h b/src/PS1/PS1TMD.h new file mode 100644 index 000000000..c31adc835 --- /dev/null +++ b/src/PS1/PS1TMD.h @@ -0,0 +1,56 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License +----------------------------------------------------------------------------------- +*/ + +#ifndef PS1TMD_H +#define PS1TMD_H + +#include +#include +#include + +/** + * Sony PlayStation TMD (Timed / 3D Model Data) import and export. + * + * Layout follows the Net Yaroze / libgs documentation: 12-byte file header, + * 28-byte object headers, 8-byte vertices/normals (int16 x,y,z + int16 pad), + * primitive packets (olen, ilen, flag, mode + ilen*4 payload). + * + * Supported modes include lit polygons (flag 0) and “no light” textured + * triangles (mode 0x25 / 0x35, flag 1 per Net Yaroze). Texture UVs refer to + * PSX VRAM layout (cba/tsb select CLUT and texture page); bitmaps live in .TIM files. + * UV import maps 8-bit page texels with a texel-center bias (no V flip; PSX and Ogre both treat + * increasing V as downward in image space). Full VRAM page offsets are not baked in — match your texture + * to the page. + * + * Coordinates are converted using a fixed-point scale (default 1/4096 world units + * per TMD integer step). On import, an extra editor transform is applied: uniform + * scale by kTmdEditorUniformScale and a 180° rotation about Z (x,y → −x, −y); normals use the same + * rotation. Triangle vertex order is swapped (v1 ↔ v2) on import so CCW front faces match Ogre; + * export swaps back for on-disk PSX order. See issue #357. + */ +namespace PS1TMD { + +/// Default: Ogre world units per one TMD fixed-point step (PSX-style 12.4). +constexpr float kDefaultOgreUnitsPerTmdStep = 1.0f / 4096.0f; + +/// Extra scale applied when importing (removed on export) so typical TMDs are a usable size in the editor. +constexpr float kTmdEditorUniformScale = 10.0f; + +/** Import a .tmd file into a new manual Ogre::Mesh in group "General". */ +Ogre::MeshPtr importTmd(const QString& filePath, const std::string& meshName, + float ogreUnitsPerTmdStep = kDefaultOgreUnitsPerTmdStep); + +/** Export one entity (all submeshes → one TMD object each). Static mesh only. */ +bool exportEntity(const Ogre::Entity* entity, const QString& filePath, + float ogreUnitsPerTmdStep = kDefaultOgreUnitsPerTmdStep); + +} // namespace PS1TMD + +#endif diff --git a/src/PS1/PS1TMD_test.cpp b/src/PS1/PS1TMD_test.cpp new file mode 100644 index 000000000..7e8cbd4e3 --- /dev/null +++ b/src/PS1/PS1TMD_test.cpp @@ -0,0 +1,529 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include "PS1/PS1TMD.h" +#include "Manager.h" +#include "SelectionSet.h" +#include "TestHelpers.h" + +namespace { + +constexpr unsigned long kSingletonSettleMs = 30; + +static void writeU32le(uint8_t* p, uint32_t v) +{ + p[0] = uint8_t(v & 0xFF); + p[1] = uint8_t((v >> 8) & 0xFF); + p[2] = uint8_t((v >> 16) & 0xFF); + p[3] = uint8_t((v >> 24) & 0xFF); +} + +static void writeU16le(uint8_t* p, uint16_t v) +{ + p[0] = uint8_t(v & 0xFF); + p[1] = uint8_t((v >> 8) & 0xFF); +} + +static void writeVertex8(int16_t x, int16_t y, int16_t z, uint8_t* out8) +{ + writeU16le(out8 + 0, static_cast(x)); + writeU16le(out8 + 2, static_cast(y)); + writeU16le(out8 + 4, static_cast(z)); + writeU16le(out8 + 6, 0); +} + +/** Minimal TMD: one object, three vertices, three normals, one G3 triangle (matches PS1TMD::appendG3). */ +static QByteArray makeMinimalG3Tmd() +{ + constexpr uint32_t kTmdId = 0x41u; + constexpr size_t kHead = 12u; + constexpr size_t kObjH = 28u; + const size_t vAbs = kHead + kObjH; + const size_t nAbs = vAbs + 3u * 8u; + const size_t pAbs = nAbs + 3u * 8u; + const uint32_t vOff = static_cast(vAbs - 12u); + const uint32_t nOff = static_cast(nAbs - 12u); + const uint32_t pOff = static_cast(pAbs - 12u); + + QByteArray buf(static_cast(pAbs + 20u), '\0'); + uint8_t* d = reinterpret_cast(buf.data()); + + writeU32le(d, kTmdId); + writeU32le(d + 4, 0); + writeU32le(d + 8, 1); + + uint8_t* oh = d + kHead; + writeU32le(oh, vOff); + writeU32le(oh + 4, 3); + writeU32le(oh + 8, nOff); + writeU32le(oh + 12, 3); + writeU32le(oh + 16, pOff); + writeU32le(oh + 20, 1); + writeU32le(oh + 24, 0); + + writeVertex8(0, 0, 0, d + vAbs); + writeVertex8(4096, 0, 0, d + vAbs + 8); + writeVertex8(0, 4096, 0, d + vAbs + 16); + + writeVertex8(0, 0, 4096, d + nAbs); + writeVertex8(0, 0, 4096, d + nAbs + 8); + writeVertex8(0, 0, 4096, d + nAbs + 16); + + uint8_t* pkt = d + pAbs; + pkt[0] = 6; + pkt[1] = 4; + pkt[2] = 0; + pkt[3] = 0x30; + pkt[4] = 200; + pkt[5] = 200; + pkt[6] = 200; + pkt[7] = 0x30; + writeU16le(pkt + 8, 0); + writeU16le(pkt + 10, 0); + writeU16le(pkt + 12, 1); + writeU16le(pkt + 14, 1); + writeU16le(pkt + 16, 2); + writeU16le(pkt + 18, 2); + + return buf; +} + +/** One textured tri, no-light (mode 0x25, flag 1, ilen 6) — Net Yaroze layout. */ +static QByteArray makeMinimal25NoLightTmd() +{ + constexpr uint32_t kTmdId = 0x41u; + constexpr size_t kHead = 12u; + constexpr size_t kObjH = 28u; + const size_t vAbs = kHead + kObjH; + const size_t nAbs = vAbs + 3u * 8u; + const size_t pAbs = nAbs + 1u * 8u; + const uint32_t vOff = static_cast(vAbs - 12u); + const uint32_t nOff = static_cast(nAbs - 12u); + const uint32_t pOff = static_cast(pAbs - 12u); + + QByteArray buf(static_cast(pAbs + 4u + 24u), '\0'); + uint8_t* d = reinterpret_cast(buf.data()); + + writeU32le(d, kTmdId); + writeU32le(d + 4, 0); + writeU32le(d + 8, 1); + + uint8_t* oh = d + kHead; + writeU32le(oh, vOff); + writeU32le(oh + 4, 3); + writeU32le(oh + 8, nOff); + writeU32le(oh + 12, 1); + writeU32le(oh + 16, pOff); + writeU32le(oh + 20, 1); + writeU32le(oh + 24, 0); + + writeVertex8(0, 0, 0, d + vAbs); + writeVertex8(4096, 0, 0, d + vAbs + 8); + writeVertex8(0, 4096, 0, d + vAbs + 16); + + writeVertex8(0, 0, 4096, d + nAbs); + + uint8_t* pkt = d + pAbs; + pkt[0] = 7; + pkt[1] = 6; + pkt[2] = 1; + pkt[3] = 0x25; + uint8_t* pay = pkt + 4; + pay[0] = 10; + pay[1] = 20; + writeU16le(pay + 2, 0); + pay[4] = 30; + pay[5] = 40; + writeU16le(pay + 6, 0); + pay[8] = 50; + pay[9] = 60; + pay[10] = 0; + pay[11] = 0; + pay[12] = 40; + pay[13] = 40; + pay[14] = 40; + pay[15] = 0; + writeU16le(pay + 16, 0); + writeU16le(pay + 18, 1); + writeU16le(pay + 20, 2); + + return buf; +} + +/** One Gouraud-textured tri, no-light (mode 0x35, flag 1, ilen 8). */ +static QByteArray makeMinimal35NoLightTmd() +{ + constexpr uint32_t kTmdId = 0x41u; + constexpr size_t kHead = 12u; + constexpr size_t kObjH = 28u; + const size_t vAbs = kHead + kObjH; + const size_t nAbs = vAbs + 3u * 8u; + const size_t pAbs = nAbs + 1u * 8u; + const uint32_t vOff = static_cast(vAbs - 12u); + const uint32_t nOff = static_cast(nAbs - 12u); + const uint32_t pOff = static_cast(pAbs - 12u); + + QByteArray buf(static_cast(pAbs + 4u + 32u), '\0'); + uint8_t* d = reinterpret_cast(buf.data()); + + writeU32le(d, kTmdId); + writeU32le(d + 4, 0); + writeU32le(d + 8, 1); + + uint8_t* oh = d + kHead; + writeU32le(oh, vOff); + writeU32le(oh + 4, 3); + writeU32le(oh + 8, nOff); + writeU32le(oh + 12, 1); + writeU32le(oh + 16, pOff); + writeU32le(oh + 20, 1); + writeU32le(oh + 24, 0); + + writeVertex8(0, 0, 0, d + vAbs); + writeVertex8(4096, 0, 0, d + vAbs + 8); + writeVertex8(0, 4096, 0, d + vAbs + 16); + + writeVertex8(0, 0, 4096, d + nAbs); + + uint8_t* pkt = d + pAbs; + pkt[0] = 9; + pkt[1] = 8; + pkt[2] = 1; + pkt[3] = 0x35; + uint8_t* pay = pkt + 4; + pay[0] = 10; + pay[1] = 20; + writeU16le(pay + 2, 0); + pay[4] = 30; + pay[5] = 40; + writeU16le(pay + 6, 0); + pay[8] = 50; + pay[9] = 60; + pay[10] = 0; + pay[11] = 0; + // RGB triplets + pads (12 bytes) + for (int i = 0; i < 12; ++i) + pay[12 + i] = static_cast(i + 1); + writeU16le(pay + 24, 0); + writeU16le(pay + 26, 1); + writeU16le(pay + 28, 2); + + return buf; +} + +static Ogre::MeshPtr createSingleTriMesh(const std::string& name) +{ + if (auto old = Ogre::MeshManager::getSingleton().getByName(name)) + Ogre::MeshManager::getSingleton().remove(old); + + Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + Ogre::SubMesh* sm = mesh->createSubMesh(); + sm->setMaterialName("BaseOutlined"); + sm->useSharedVertices = false; + + Ogre::VertexData* vd = new Ogre::VertexData(); + sm->vertexData = vd; + vd->vertexCount = 3; + Ogre::VertexDeclaration* decl = vd->vertexDeclaration; + Ogre::VertexBufferBinding* bind = vd->vertexBufferBinding; + size_t off = 0; + decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); + off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + const size_t vsize = decl->getVertexSize(0); + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + vsize, 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + uint8_t* dst = static_cast(vbuf->lock(Ogre::HardwareBuffer::HBL_DISCARD)); + const float tri[][6] = { + {0.f, 0.f, 0.f, 0.f, 0.f, 1.f}, + {1.f, 0.f, 0.f, 0.f, 0.f, 1.f}, + {0.f, 1.f, 0.f, 0.f, 0.f, 1.f}, + }; + for (int i = 0; i < 3; ++i) { + uint8_t* row = dst + i * vsize; + float* p = nullptr; + decl->findElementBySemantic(Ogre::VES_POSITION)->baseVertexPointerToElement(row, &p); + p[0] = tri[i][0]; + p[1] = tri[i][1]; + p[2] = tri[i][2]; + decl->findElementBySemantic(Ogre::VES_NORMAL)->baseVertexPointerToElement(row, &p); + p[0] = tri[i][3]; + p[1] = tri[i][4]; + p[2] = tri[i][5]; + } + vbuf->unlock(); + bind->setBinding(0, vbuf); + + auto ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( + Ogre::HardwareIndexBuffer::IT_16BIT, 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + uint16_t idx[] = {0, 1, 2}; + ibuf->writeData(0, sizeof(idx), idx); + sm->indexData->indexBuffer = ibuf; + sm->indexData->indexCount = 3; + sm->indexData->indexStart = 0; + + mesh->_setBounds(Ogre::AxisAlignedBox(0, 0, 0, 1, 1, 0)); + mesh->_setBoundingSphereRadius(2.0f); + mesh->load(); + return mesh; +} + +} // namespace + +class PS1TMDTest : public ::testing::Test { +protected: + QApplication* app = nullptr; + + void SetUp() override + { + SelectionSet::kill(); + Manager::kill(); + QThread::msleep(kSingletonSettleMs); + + app = qobject_cast(QCoreApplication::instance()); + ASSERT_NE(app, nullptr); + + ASSERT_TRUE(tryInitOgre()) << "Ogre init failed"; + createStandardOgreMaterials(); + } + + void TearDown() override + { + if (Manager::getSingletonPtr()) + SelectionSet::getSingleton()->clear(); + SelectionSet::kill(); + Manager::kill(); + if (app) + app->processEvents(); + QThread::msleep(kSingletonSettleMs); + } +}; + +TEST_F(PS1TMDTest, ImportMode25NoLightTexturedTriangle) +{ + QTemporaryFile tmp(QDir::tempPath() + "/qtmesh_ps1tmd_25_XXXXXX.tmd"); + tmp.setAutoRemove(true); + ASSERT_TRUE(tmp.open()); + const QByteArray blob = makeMinimal25NoLightTmd(); + ASSERT_EQ(tmp.write(blob), blob.size()); + tmp.flush(); + + const std::string meshName = "PS1Tmd25Mesh"; + if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName)) + Ogre::MeshManager::getSingleton().remove(old); + + Ogre::MeshPtr mesh = PS1TMD::importTmd(tmp.fileName(), meshName); + ASSERT_TRUE(mesh); + ASSERT_EQ(mesh->getNumSubMeshes(), 1u); + Ogre::SubMesh* sm = mesh->getSubMesh(0); + EXPECT_EQ(sm->vertexData->vertexCount, 3u); + + Ogre::VertexData* vd = sm->vertexData; + const auto* uvEl = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_TEXTURE_COORDINATES); + ASSERT_NE(uvEl, nullptr); + auto uvBuf = vd->vertexBufferBinding->getBuffer(uvEl->getSource()); + const uint8_t* ubase = static_cast(uvBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + const size_t stride = uvBuf->getVertexSize(); + float* pf = nullptr; + uvEl->baseVertexPointerToElement(const_cast(ubase), &pf); + // pay[0]=10, pay[1]=20 → (10.5/256, 20.5/256) after import (first corner, winding permuted to slot 0) + EXPECT_NEAR(pf[0], 10.5f / 256.0f, 1e-5f); + EXPECT_NEAR(pf[1], 20.5f / 256.0f, 1e-5f); + uvBuf->unlock(); +} + +/** Lit textured quad (mode 0x2c, flag 0, ilen 7) — Sony tmd.h TMD_F_4T. */ +static QByteArray makeMinimal2cTexturedQuadTmd() +{ + constexpr uint32_t kTmdId = 0x41u; + constexpr size_t kHead = 12u; + constexpr size_t kObjH = 28u; + const size_t vAbs = kHead + kObjH; + const size_t nAbs = vAbs + 4u * 8u; + const size_t pAbs = nAbs + 1u * 8u; + const uint32_t vOff = static_cast(vAbs - 12u); + const uint32_t nOff = static_cast(nAbs - 12u); + const uint32_t pOff = static_cast(pAbs - 12u); + + QByteArray buf(static_cast(pAbs + 4u + 28u), '\0'); + uint8_t* d = reinterpret_cast(buf.data()); + + writeU32le(d, kTmdId); + writeU32le(d + 4, 0); + writeU32le(d + 8, 1); + + uint8_t* oh = d + kHead; + writeU32le(oh, vOff); + writeU32le(oh + 4, 4); + writeU32le(oh + 8, nOff); + writeU32le(oh + 12, 1); + writeU32le(oh + 16, pOff); + writeU32le(oh + 20, 1); + writeU32le(oh + 24, 0); + + writeVertex8(0, 0, 0, d + vAbs); + writeVertex8(4096, 0, 0, d + vAbs + 8); + writeVertex8(4096, 4096, 0, d + vAbs + 16); + writeVertex8(0, 4096, 0, d + vAbs + 24); + + writeVertex8(0, 0, 4096, d + nAbs); + + uint8_t* pkt = d + pAbs; + pkt[0] = 9; + pkt[1] = 7; + pkt[2] = 0; + pkt[3] = 0x2c; + uint8_t* pay = pkt + 4; + pay[0] = 0; + pay[1] = 0; + writeU16le(pay + 2, 0); + pay[4] = 255; + pay[5] = 0; + writeU16le(pay + 6, 0); + pay[8] = 255; + pay[9] = 255; + writeU16le(pay + 10, 0); + pay[12] = 0; + pay[13] = 255; + writeU16le(pay + 14, 0); + writeU16le(pay + 16, 0); + writeU16le(pay + 18, 0); + writeU16le(pay + 20, 1); + writeU16le(pay + 22, 2); + writeU16le(pay + 24, 3); + writeU16le(pay + 26, 0); + + return buf; +} + +TEST_F(PS1TMDTest, ImportMode2cLitTexturedQuad) +{ + QTemporaryFile tmp(QDir::tempPath() + "/qtmesh_ps1tmd_2c_XXXXXX.tmd"); + tmp.setAutoRemove(true); + ASSERT_TRUE(tmp.open()); + const QByteArray blob = makeMinimal2cTexturedQuadTmd(); + ASSERT_EQ(tmp.write(blob), blob.size()); + tmp.flush(); + + const std::string meshName = "PS1Tmd2cMesh"; + if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName)) + Ogre::MeshManager::getSingleton().remove(old); + + Ogre::MeshPtr mesh = PS1TMD::importTmd(tmp.fileName(), meshName); + ASSERT_TRUE(mesh); + ASSERT_EQ(mesh->getNumSubMeshes(), 1u); + EXPECT_EQ(mesh->getSubMesh(0)->vertexData->vertexCount, 6u); +} + +TEST_F(PS1TMDTest, ImportMode35NoLightGouraudTexturedTriangle) +{ + QTemporaryFile tmp(QDir::tempPath() + "/qtmesh_ps1tmd_35_XXXXXX.tmd"); + tmp.setAutoRemove(true); + ASSERT_TRUE(tmp.open()); + const QByteArray blob = makeMinimal35NoLightTmd(); + ASSERT_EQ(tmp.write(blob), blob.size()); + tmp.flush(); + + const std::string meshName = "PS1Tmd35Mesh"; + if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName)) + Ogre::MeshManager::getSingleton().remove(old); + + Ogre::MeshPtr mesh = PS1TMD::importTmd(tmp.fileName(), meshName); + ASSERT_TRUE(mesh); + ASSERT_EQ(mesh->getNumSubMeshes(), 1u); + EXPECT_EQ(mesh->getSubMesh(0)->vertexData->vertexCount, 3u); +} + +TEST_F(PS1TMDTest, ImportMinimalG3Triangle) +{ + QTemporaryFile tmp(QDir::tempPath() + "/qtmesh_ps1tmd_XXXXXX.tmd"); + tmp.setAutoRemove(true); + ASSERT_TRUE(tmp.open()); + const QByteArray blob = makeMinimalG3Tmd(); + ASSERT_EQ(tmp.write(blob), blob.size()); + tmp.flush(); + + const std::string meshName = "PS1TmdImportTestMesh"; + if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName)) + Ogre::MeshManager::getSingleton().remove(old); + + Ogre::MeshPtr mesh = PS1TMD::importTmd(tmp.fileName(), meshName); + ASSERT_TRUE(mesh); + ASSERT_EQ(mesh->getNumSubMeshes(), 1u); + Ogre::SubMesh* sm = mesh->getSubMesh(0); + ASSERT_TRUE(sm->vertexData); + EXPECT_EQ(sm->vertexData->vertexCount, 3u); + EXPECT_EQ(sm->indexData->indexCount, 3u); + + // File verts (0,0,0), (4096,0,0), (0,4096,0) → 10× then 180° about Z: (0,0,0), (-10,0,0), (0,-10,0) + Ogre::VertexData* vd = sm->vertexData; + const auto* posEl = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_POSITION); + ASSERT_NE(posEl, nullptr); + auto posBuf = vd->vertexBufferBinding->getBuffer(posEl->getSource()); + const uint8_t* vbase = static_cast(posBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + const size_t stride = posBuf->getVertexSize(); + float* pf = nullptr; + posEl->baseVertexPointerToElement(const_cast(vbase), &pf); + EXPECT_NEAR(pf[0], 0.f, 1e-4f); + EXPECT_NEAR(pf[1], 0.f, 1e-4f); + EXPECT_NEAR(pf[2], 0.f, 1e-4f); + posEl->baseVertexPointerToElement(const_cast(vbase + stride), &pf); + EXPECT_NEAR(pf[0], -10.f, 1e-3f); + EXPECT_NEAR(pf[1], 0.f, 1e-4f); + EXPECT_NEAR(pf[2], 0.f, 1e-4f); + posEl->baseVertexPointerToElement(const_cast(vbase + 2 * stride), &pf); + EXPECT_NEAR(pf[0], 0.f, 1e-4f); + EXPECT_NEAR(pf[1], -10.f, 1e-3f); + EXPECT_NEAR(pf[2], 0.f, 1e-4f); + posBuf->unlock(); +} + +TEST_F(PS1TMDTest, ExportImportRoundTripSingleTriangle) +{ + ASSERT_TRUE(canLoadMeshFiles()); + + const std::string meshName = "PS1TmdRtMesh"; + Ogre::MeshPtr mesh = createSingleTriMesh(meshName); + ASSERT_TRUE(mesh); + + auto* mgr = Manager::getSingleton(); + Ogre::SceneNode* node = mgr->addSceneNode("PS1TmdRtNode"); + ASSERT_NE(node, nullptr); + Ogre::Entity* ent = mgr->createEntity(node, mesh); + ASSERT_NE(ent, nullptr); + + QTemporaryFile outTmd(QDir::tempPath() + "/qtmesh_ps1tmd_rt_XXXXXX.tmd"); + outTmd.setAutoRemove(true); + ASSERT_TRUE(outTmd.open()); + outTmd.close(); + const QString path = outTmd.fileName(); + + ASSERT_TRUE(PS1TMD::exportEntity(ent, path)); + + mgr->destroySceneNode(QStringLiteral("PS1TmdRtNode")); + Ogre::MeshManager::getSingleton().remove(meshName); + + const std::string reName = "PS1TmdRtMeshReimport"; + if (auto old = Ogre::MeshManager::getSingleton().getByName(reName)) + Ogre::MeshManager::getSingleton().remove(old); + + Ogre::MeshPtr re = PS1TMD::importTmd(path, reName); + ASSERT_TRUE(re); + ASSERT_EQ(re->getNumSubMeshes(), 1u); + EXPECT_EQ(re->getSubMesh(0)->vertexData->vertexCount, 3u); +} diff --git a/src/PrimitiveObject.cpp b/src/PrimitiveObject.cpp index 471092808..97f7666dd 100755 --- a/src/PrimitiveObject.cpp +++ b/src/PrimitiveObject.cpp @@ -593,7 +593,7 @@ Ogre::SceneNode* PrimitiveObject::createPrimitive() { Ogre::Entity* ent = Manager::getSingleton()->createEntity(mSceneNode,mp); - ent->setMaterialName("BaseWhite"); + ent->setMaterialName("BaseOutlined"); mSceneNode->setPosition(0,0,0); return mSceneNode; } diff --git a/src/WelcomeDialog.cpp b/src/WelcomeDialog.cpp index baef5d965..89ed99381 100644 --- a/src/WelcomeDialog.cpp +++ b/src/WelcomeDialog.cpp @@ -68,7 +68,7 @@ WelcomeDialog::WelcomeDialog(QWidget* parent) connect(openFileBtn, &QPushButton::clicked, this, [this]() { QString file = QFileDialog::getOpenFileName( this, "Open 3D File", QString(), - "3D Files (*.fbx *.gltf *.glb *.vrm *.obj *.dae *.stl *.mesh *.3ds *.x);;All Files (*)"); + "3D Files (*.fbx *.gltf *.glb *.vrm *.obj *.dae *.stl *.mesh *.3ds *.x *.tmd *.TMD);;All Files (*)"); if (!file.isEmpty()) { m_action = OpenFile; m_selectedFile = file; From bf5fd1f435dca482314d47fc6aa8ee4379e74fa9 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 5 May 2026 12:30:21 -0400 Subject: [PATCH 2/7] ps1: improve TMD/TIM import and materials Co-authored-by: Cursor --- src/PS1/PS1TMD.cpp | 173 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 161 insertions(+), 12 deletions(-) diff --git a/src/PS1/PS1TMD.cpp b/src/PS1/PS1TMD.cpp index 007d71824..4ae5c087f 100644 --- a/src/PS1/PS1TMD.cpp +++ b/src/PS1/PS1TMD.cpp @@ -15,6 +15,7 @@ The MIT License #include #include #include +#include #include #include #include @@ -122,7 +123,9 @@ struct TriSoup { std::vector pos; std::vector nrm; std::vector uv; + std::vector col; bool hasUv{false}; + bool hasCol{false}; }; /** Unit normal from triangle positions (no-light TMD prims have no normal indices). */ @@ -137,9 +140,23 @@ static Ogre::Vector3 flatNormalFromTri(const Ogre::Vector3& p0, const Ogre::Vect return n; } +static bool triMatchesRefNormal(const Ogre::Vector3& p0, const Ogre::Vector3& p1, const Ogre::Vector3& p2, + const Ogre::Vector3& ref) +{ + Ogre::Vector3 n = (p1 - p0).crossProduct(p2 - p0); + // If degenerate or ref is zero, don't try to flip. + if (n.squaredLength() < 1e-24f || ref.isZeroLength()) + return true; + return n.dotProduct(ref) >= 0.0f; +} + static void appendTri(TriSoup& out, const Ogre::Vector3& p0, const Ogre::Vector3& p1, const Ogre::Vector3& p2, const Ogre::Vector3& n0, const Ogre::Vector3& n1, const Ogre::Vector3& n2, - const Ogre::Vector2& uv0, const Ogre::Vector2& uv1, const Ogre::Vector2& uv2, bool withUv) + const Ogre::Vector2& uv0, const Ogre::Vector2& uv1, const Ogre::Vector2& uv2, bool withUv, + const Ogre::ColourValue& c0 = Ogre::ColourValue::White, + const Ogre::ColourValue& c1 = Ogre::ColourValue::White, + const Ogre::ColourValue& c2 = Ogre::ColourValue::White, + bool withCol = false) { out.pos.push_back(p0); out.pos.push_back(p1); @@ -153,6 +170,27 @@ static void appendTri(TriSoup& out, const Ogre::Vector3& p0, const Ogre::Vector3 out.uv.push_back(uv2); out.hasUv = true; } + if (withCol) { + out.col.push_back(c0); + out.col.push_back(c1); + out.col.push_back(c2); + out.hasCol = true; + } +} + +static void appendTriMatchWinding(TriSoup& out, + const Ogre::Vector3& p0, const Ogre::Vector3& p1, const Ogre::Vector3& p2, + const Ogre::Vector3& n0, const Ogre::Vector3& n1, const Ogre::Vector3& n2, + const Ogre::Vector2& uv0, const Ogre::Vector2& uv1, const Ogre::Vector2& uv2, bool withUv, + const Ogre::ColourValue& c0, const Ogre::ColourValue& c1, const Ogre::ColourValue& c2, bool withCol, + const Ogre::Vector3& refNormal) +{ + if (triMatchesRefNormal(p0, p1, p2, refNormal)) { + appendTri(out, p0, p1, p2, n0, n1, n2, uv0, uv1, uv2, withUv, c0, c1, c2, withCol); + } else { + // Flip winding + associated per-vertex data for v1/v2. + appendTri(out, p0, p2, p1, n0, n2, n1, uv0, uv2, uv1, withUv, c0, c2, c1, withCol); + } } static bool parseTmdObject(const uint8_t* data, size_t fileSize, uint32_t storedVertOff, uint32_t nVert, @@ -207,6 +245,7 @@ static bool parseTmdObject(const uint8_t* data, size_t fileSize, uint32_t stored (void)olen; if (mode == 0x20 && flag == 0 && ilen == 3) { + const Ogre::ColourValue c0(float(d[0]) / 255.0f, float(d[1]) / 255.0f, float(d[2]) / 255.0f, 1.0f); const uint16_t ni = readU16le(d + 4); const uint16_t i0 = readU16le(d + 6); const uint16_t i1 = readU16le(d + 8); @@ -215,11 +254,12 @@ static bool parseTmdObject(const uint8_t* data, size_t fileSize, uint32_t stored const Ogre::Vector3& np = norms[ni]; // Swap v1/v2 so front-face winding matches Ogre (CCW) vs PSX packet order. appendTri(out, verts[i0], verts[i2], verts[i1], np, np, np, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, - Ogre::Vector2::ZERO, false); + Ogre::Vector2::ZERO, false, c0, c0, c0, true); } continue; } if (mode == 0x30 && flag == 0 && ilen == 4) { + const Ogre::ColourValue c0(float(d[0]) / 255.0f, float(d[1]) / 255.0f, float(d[2]) / 255.0f, 1.0f); const uint16_t n0 = readU16le(d + 4); const uint16_t v0 = readU16le(d + 6); const uint16_t n1 = readU16le(d + 8); @@ -228,7 +268,7 @@ static bool parseTmdObject(const uint8_t* data, size_t fileSize, uint32_t stored const uint16_t v2 = readU16le(d + 14); if (v0 < nVert && v1 < nVert && v2 < nVert && n0 < nNorm && n1 < nNorm && n2 < nNorm) appendTri(out, verts[v0], verts[v2], verts[v1], norms[n0], norms[n2], norms[n1], Ogre::Vector2::ZERO, - Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false); + Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false, c0, c0, c0, true); continue; } if (mode == 0x24 && flag == 0 && ilen == 5) { @@ -269,6 +309,7 @@ static bool parseTmdObject(const uint8_t* data, size_t fileSize, uint32_t stored continue; } if (mode == 0x28 && flag == 0 && ilen == 4) { + const Ogre::ColourValue c0(float(d[0]) / 255.0f, float(d[1]) / 255.0f, float(d[2]) / 255.0f, 1.0f); const uint16_t ni = readU16le(d + 4); const uint16_t i0 = readU16le(d + 6); const uint16_t i1 = readU16le(d + 8); @@ -276,10 +317,49 @@ static bool parseTmdObject(const uint8_t* data, size_t fileSize, uint32_t stored const uint16_t i3 = readU16le(d + 12); if (i0 < nVert && i1 < nVert && i2 < nVert && i3 < nVert && ni < nNorm) { const Ogre::Vector3& np = norms[ni]; - appendTri(out, verts[i0], verts[i2], verts[i1], np, np, np, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, - Ogre::Vector2::ZERO, false); - appendTri(out, verts[i0], verts[i3], verts[i2], np, np, np, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, - Ogre::Vector2::ZERO, false); + appendTriMatchWinding(out, verts[i0], verts[i2], verts[i1], np, np, np, + Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false, + c0, c0, c0, true, np); + appendTriMatchWinding(out, verts[i0], verts[i3], verts[i2], np, np, np, + Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false, + c0, c0, c0, true, np); + } + continue; + } + // Net Yaroze / tmd.h: Mode 0x28 && Flag 4 — Gradated quad (per-vertex RGB), ilen 7. + // Use per-vertex RGB (one normal). + if (mode == 0x28 && flag == 4 && ilen == 7) { + const Ogre::ColourValue c0(float(d[0]) / 255.0f, float(d[1]) / 255.0f, float(d[2]) / 255.0f, 1.0f); + const Ogre::ColourValue c1(float(d[4]) / 255.0f, float(d[5]) / 255.0f, float(d[6]) / 255.0f, 1.0f); + const Ogre::ColourValue c2(float(d[8]) / 255.0f, float(d[9]) / 255.0f, float(d[10]) / 255.0f, 1.0f); + const Ogre::ColourValue c3(float(d[12]) / 255.0f, float(d[13]) / 255.0f, float(d[14]) / 255.0f, 1.0f); + const uint16_t ni = readU16le(d + 16); + const uint16_t i0 = readU16le(d + 18); + const uint16_t i1 = readU16le(d + 20); + const uint16_t i2 = readU16le(d + 22); + const uint16_t i3 = readU16le(d + 24); + if (i0 < nVert && i1 < nVert && i2 < nVert && i3 < nVert && ni < nNorm) { + const Ogre::Vector3& np = norms[ni]; + // GRID.TMD uses this primitive to represent a checkerboard. Gouraud interpolation makes + // the triangulation diagonal very visible, so for checker-like quads (c0==c2,c1==c3) + // we treat the quad as a *flat-colored* face using c0. + const bool isCheckerLike = (c0 == c2) && (c1 == c3); + if (isCheckerLike) { + appendTriMatchWinding(out, verts[i0], verts[i2], verts[i1], np, np, np, + Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false, + c0, c0, c0, true, np); + appendTriMatchWinding(out, verts[i0], verts[i3], verts[i2], np, np, np, + Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false, + c0, c0, c0, true, np); + } else { + // Default: keep per-vertex colors. + appendTriMatchWinding(out, verts[i0], verts[i2], verts[i1], np, np, np, + Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false, + c0, c2, c1, true, np); + appendTriMatchWinding(out, verts[i0], verts[i3], verts[i2], np, np, np, + Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false, + c0, c3, c2, true, np); + } } continue; } @@ -447,19 +527,19 @@ static Ogre::MeshPtr buildMeshFromSoup(const std::string& meshName, const TriSou Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createManual( meshName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); Ogre::SubMesh* sm = mesh->createSubMesh(); - // Use a unique per-import material so texture assignment doesn't mutate BaseOutlined globally. - // This also lets us apply TMD-specific texture scaling heuristics later (see MaterialEditorQML). + // Use a unique per-import material so texture assignment doesn't mutate shared defaults. const std::string tmdMatName = std::string("TMD/") + meshName; try { if (!Ogre::MaterialManager::getSingleton().getByName(tmdMatName)) { - if (auto base = Ogre::MaterialManager::getSingleton().getByName("BaseOutlined")) { + // Default to a simple "empty" single-pass base. Other code may add texture units later. + if (auto base = Ogre::MaterialManager::getSingleton().getByName("BaseMaterial")) { base->clone(tmdMatName); } } } catch (...) { - // If materials aren't available yet, fall back to BaseOutlined. + // If materials aren't available yet, fall back to BaseMaterial. } - sm->setMaterialName(Ogre::MaterialManager::getSingleton().getByName(tmdMatName) ? tmdMatName : "BaseOutlined"); + sm->setMaterialName(Ogre::MaterialManager::getSingleton().getByName(tmdMatName) ? tmdMatName : "BaseMaterial"); sm->useSharedVertices = false; const size_t nVert = soup.pos.size(); @@ -477,6 +557,11 @@ static Ogre::MeshPtr buildMeshFromSoup(const std::string& meshName, const TriSou decl->addElement(0, off, Ogre::VET_FLOAT2, Ogre::VES_TEXTURE_COORDINATES); off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT2); } + const bool hasCol = soup.hasCol && soup.col.size() == nVert; + if (hasCol) { + decl->addElement(0, off, Ogre::VET_COLOUR_ARGB, Ogre::VES_DIFFUSE); + off += Ogre::VertexElement::getTypeSize(Ogre::VET_COLOUR_ARGB); + } const size_t vsize = decl->getVertexSize(0); auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( vsize, nVert, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); @@ -497,10 +582,63 @@ static Ogre::MeshPtr buildMeshFromSoup(const std::string& meshName, const TriSou p[0] = soup.uv[i].x; p[1] = soup.uv[i].y; } + if (hasCol) { + Ogre::RGBA* c = nullptr; + decl->findElementBySemantic(Ogre::VES_DIFFUSE)->baseVertexPointerToElement(row, &c); + Ogre::Root::getSingleton().convertColourValue(soup.col[i], c); + } } vbuf->unlock(); bind->setBinding(0, vbuf); + // If we imported vertex colors, make sure the cloned material uses them. + try { + auto mat = Ogre::MaterialManager::getSingleton().getByName(tmdMatName); + if (mat) { + if (!mat->isLoaded()) + mat->load(); + if (mat->getNumTechniques() > 0 && mat->getTechnique(0)->getNumPasses() > 0) { + Ogre::Pass* p0 = mat->getTechnique(0)->getPass(0); + if (p0) { + // Keep imported TMD materials "blank" by default (TMD primitives don't carry real material params). + // If the mesh has vertex colors, we track them to diffuse; otherwise track nothing. + p0->setAmbient(0.0f, 0.0f, 0.0f); + // Default diffuse should remain white so non-vertex-colored meshes aren't forced black. + p0->setDiffuse(1.0f, 1.0f, 1.0f, 1.0f); + p0->setEmissive(0.0f, 0.0f, 0.0f); + p0->setVertexColourTracking(hasCol ? Ogre::TVC_DIFFUSE : Ogre::TVC_NONE); + } + } + } + } catch (...) { + } + + if (hasCol) { + try { + auto mat = Ogre::MaterialManager::getSingleton().getByName(tmdMatName); + if (mat) { + if (!mat->isLoaded()) + mat->load(); + if (mat->getNumTechniques() > 0 && mat->getTechnique(0)->getNumPasses() > 0) { + Ogre::Pass* p0 = mat->getTechnique(0)->getPass(0); + if (p0) { + // Vertex-color-only meshes (like GRID.TMD) should render unlit by default to avoid + // triangle shading artifacts. Textured meshes will keep lighting settings. + if (!hasUv) { + // Keep the material "blank": no baked ambient/diffuse/emissive contribution. + // The visible color comes from vertex color tracking below. + p0->setAmbient(0.0f, 0.0f, 0.0f); + p0->setDiffuse(0.0f, 0.0f, 0.0f, 1.0f); + p0->setEmissive(0.0f, 0.0f, 0.0f); + } + p0->setVertexColourTracking(Ogre::TVC_DIFFUSE); + } + } + } + } catch (...) { + } + } + const size_t nTri = nVert / 3; const bool use32 = nVert > 65535; auto ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( @@ -682,6 +820,13 @@ Ogre::MeshPtr importTmd(const QString& filePath, const std::string& meshName, fl for (size_t k = 0; k < part.pos.size(); ++k) merged.uv.push_back(Ogre::Vector2::ZERO); } + if (part.hasCol) { + merged.hasCol = true; + merged.col.insert(merged.col.end(), part.col.begin(), part.col.end()); + } else if (merged.hasCol) { + for (size_t k = 0; k < part.pos.size(); ++k) + merged.col.push_back(Ogre::ColourValue::White); + } } if (merged.pos.empty()) return {}; @@ -689,6 +834,10 @@ Ogre::MeshPtr importTmd(const QString& filePath, const std::string& meshName, fl merged.uv.clear(); merged.hasUv = false; } + if (merged.hasCol && merged.col.size() != merged.pos.size()) { + merged.col.clear(); + merged.hasCol = false; + } Ogre::MeshPtr mesh = buildMeshFromSoup(meshName, merged); if (!mesh) From 53544712d1b7f1a546df46590512eb53233e139d Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 5 May 2026 13:53:32 -0400 Subject: [PATCH 3/7] ps1: fix TMD quad triangulation + tests Co-authored-by: Cursor --- src/CMakeLists.txt | 3 +- src/PS1/PS1TMD.cpp | 17 ++-- src/PS1/PS1TMD_test.cpp | 189 ++++++++++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 6 +- 4 files changed, 203 insertions(+), 12 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index fc129538c..6cfb7f2ae 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -499,7 +499,8 @@ if(BUILD_TESTS) # UnitTests binary directory so that plugins.cfg (PluginFolder=../) can # find them. The app bundle has these in Contents/MacOS/ via INSTALL # commands, but UnitTests is a plain executable — not a bundle. - gtest_discover_tests(UnitTests) + # Discover tests at ctest time (not build time) so CI can provide Xvfb/GL env. + gtest_discover_tests(UnitTests DISCOVERY_MODE PRE_TEST) # Copy RTSS shader resources to the UnitTests binary directory so that # RTSSResourcesTest can find them without requiring cmake --install. diff --git a/src/PS1/PS1TMD.cpp b/src/PS1/PS1TMD.cpp index 4ae5c087f..196820c54 100644 --- a/src/PS1/PS1TMD.cpp +++ b/src/PS1/PS1TMD.cpp @@ -317,10 +317,11 @@ static bool parseTmdObject(const uint8_t* data, size_t fileSize, uint32_t stored const uint16_t i3 = readU16le(d + 12); if (i0 < nVert && i1 < nVert && i2 < nVert && i3 < nVert && ni < nNorm) { const Ogre::Vector3& np = norms[ni]; - appendTriMatchWinding(out, verts[i0], verts[i2], verts[i1], np, np, np, + // Triangulate as requested: (v0,v1,v2) + (v1,v2,v3). + appendTriMatchWinding(out, verts[i0], verts[i1], verts[i2], np, np, np, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false, c0, c0, c0, true, np); - appendTriMatchWinding(out, verts[i0], verts[i3], verts[i2], np, np, np, + appendTriMatchWinding(out, verts[i1], verts[i2], verts[i3], np, np, np, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false, c0, c0, c0, true, np); } @@ -345,20 +346,20 @@ static bool parseTmdObject(const uint8_t* data, size_t fileSize, uint32_t stored // we treat the quad as a *flat-colored* face using c0. const bool isCheckerLike = (c0 == c2) && (c1 == c3); if (isCheckerLike) { - appendTriMatchWinding(out, verts[i0], verts[i2], verts[i1], np, np, np, + appendTriMatchWinding(out, verts[i0], verts[i1], verts[i2], np, np, np, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false, c0, c0, c0, true, np); - appendTriMatchWinding(out, verts[i0], verts[i3], verts[i2], np, np, np, + appendTriMatchWinding(out, verts[i1], verts[i2], verts[i3], np, np, np, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false, c0, c0, c0, true, np); } else { // Default: keep per-vertex colors. - appendTriMatchWinding(out, verts[i0], verts[i2], verts[i1], np, np, np, + appendTriMatchWinding(out, verts[i0], verts[i1], verts[i2], np, np, np, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false, - c0, c2, c1, true, np); - appendTriMatchWinding(out, verts[i0], verts[i3], verts[i2], np, np, np, + c0, c1, c2, true, np); + appendTriMatchWinding(out, verts[i1], verts[i2], verts[i3], np, np, np, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false, - c0, c3, c2, true, np); + c1, c2, c3, true, np); } } continue; diff --git a/src/PS1/PS1TMD_test.cpp b/src/PS1/PS1TMD_test.cpp index 7e8cbd4e3..3bdb2ef4e 100644 --- a/src/PS1/PS1TMD_test.cpp +++ b/src/PS1/PS1TMD_test.cpp @@ -224,6 +224,123 @@ static QByteArray makeMinimal35NoLightTmd() return buf; } +/** One flat-colored quad (mode 0x28, flag 0, ilen 4). */ +static QByteArray makeMinimal28FlatQuadTmd() +{ + constexpr uint32_t kTmdId = 0x41u; + constexpr size_t kHead = 12u; + constexpr size_t kObjH = 28u; + const size_t vAbs = kHead + kObjH; + const size_t nAbs = vAbs + 4u * 8u; + const size_t pAbs = nAbs + 1u * 8u; + const uint32_t vOff = static_cast(vAbs - 12u); + const uint32_t nOff = static_cast(nAbs - 12u); + const uint32_t pOff = static_cast(pAbs - 12u); + + QByteArray buf(static_cast(pAbs + 4u + 16u), '\0'); + uint8_t* d = reinterpret_cast(buf.data()); + + writeU32le(d, kTmdId); + writeU32le(d + 4, 0); + writeU32le(d + 8, 1); + + uint8_t* oh = d + kHead; + writeU32le(oh, vOff); + writeU32le(oh + 4, 4); + writeU32le(oh + 8, nOff); + writeU32le(oh + 12, 1); + writeU32le(oh + 16, pOff); + writeU32le(oh + 20, 1); + writeU32le(oh + 24, 0); + + // Unit square in XY plane. + writeVertex8(0, 0, 0, d + vAbs); + writeVertex8(4096, 0, 0, d + vAbs + 8); + writeVertex8(4096, 4096, 0, d + vAbs + 16); + writeVertex8(0, 4096, 0, d + vAbs + 24); + + // +Z normal. + writeVertex8(0, 0, 4096, d + nAbs); + + uint8_t* pkt = d + pAbs; + pkt[0] = 6; + pkt[1] = 4; + pkt[2] = 0; + pkt[3] = 0x28; + uint8_t* pay = pkt + 4; + pay[0] = 128; + pay[1] = 128; + pay[2] = 128; + pay[3] = 0; + writeU16le(pay + 4, 0); // normal index + writeU16le(pay + 6, 0); + writeU16le(pay + 8, 1); + writeU16le(pay + 10, 2); + writeU16le(pay + 12, 3); + writeU16le(pay + 14, 0); + + return buf; +} + +/** One gradated quad (mode 0x28, flag 4, ilen 7). */ +static QByteArray makeMinimal28GradQuadTmd() +{ + constexpr uint32_t kTmdId = 0x41u; + constexpr size_t kHead = 12u; + constexpr size_t kObjH = 28u; + const size_t vAbs = kHead + kObjH; + const size_t nAbs = vAbs + 4u * 8u; + const size_t pAbs = nAbs + 1u * 8u; + const uint32_t vOff = static_cast(vAbs - 12u); + const uint32_t nOff = static_cast(nAbs - 12u); + const uint32_t pOff = static_cast(pAbs - 12u); + + QByteArray buf(static_cast(pAbs + 4u + 28u), '\0'); + uint8_t* d = reinterpret_cast(buf.data()); + + writeU32le(d, kTmdId); + writeU32le(d + 4, 0); + writeU32le(d + 8, 1); + + uint8_t* oh = d + kHead; + writeU32le(oh, vOff); + writeU32le(oh + 4, 4); + writeU32le(oh + 8, nOff); + writeU32le(oh + 12, 1); + writeU32le(oh + 16, pOff); + writeU32le(oh + 20, 1); + writeU32le(oh + 24, 0); + + // Unit square in XY plane. + writeVertex8(0, 0, 0, d + vAbs); + writeVertex8(4096, 0, 0, d + vAbs + 8); + writeVertex8(4096, 4096, 0, d + vAbs + 16); + writeVertex8(0, 4096, 0, d + vAbs + 24); + + // +Z normal. + writeVertex8(0, 0, 4096, d + nAbs); + + uint8_t* pkt = d + pAbs; + pkt[0] = 8; + pkt[1] = 7; + pkt[2] = 4; + pkt[3] = 0x28; + uint8_t* pay = pkt + 4; + // Colors (RGB + pad) * 4 + pay[0] = 255; pay[1] = 0; pay[2] = 0; pay[3] = 0; + pay[4] = 0; pay[5] = 255; pay[6] = 0; pay[7] = 0; + pay[8] = 0; pay[9] = 0; pay[10] = 255; pay[11] = 0; + pay[12] = 255; pay[13] = 255; pay[14] = 0; pay[15] = 0; + writeU16le(pay + 16, 0); // normal index + writeU16le(pay + 18, 0); + writeU16le(pay + 20, 1); + writeU16le(pay + 22, 2); + writeU16le(pay + 24, 3); + writeU16le(pay + 26, 0); + + return buf; +} + static Ogre::MeshPtr createSingleTriMesh(const std::string& name) { if (auto old = Ogre::MeshManager::getSingleton().getByName(name)) @@ -430,6 +547,78 @@ TEST_F(PS1TMDTest, ImportMode2cLitTexturedQuad) EXPECT_EQ(mesh->getSubMesh(0)->vertexData->vertexCount, 6u); } +static bool posNear(const Ogre::Vector3& a, const Ogre::Vector3& b, float eps = 1e-3f) +{ + return std::abs(a.x - b.x) < eps && std::abs(a.y - b.y) < eps && std::abs(a.z - b.z) < eps; +} + +static bool triContainsAll(const Ogre::Vector3& p0, const Ogre::Vector3& p1, const Ogre::Vector3& p2, + const Ogre::Vector3& a, const Ogre::Vector3& b, const Ogre::Vector3& c) +{ + const Ogre::Vector3 tri[3] = {p0, p1, p2}; + const Ogre::Vector3 exp[3] = {a, b, c}; + for (const Ogre::Vector3& e : exp) { + bool found = false; + for (const Ogre::Vector3& t : tri) { + if (posNear(t, e)) { + found = true; + break; + } + } + if (!found) + return false; + } + return true; +} + +TEST_F(PS1TMDTest, ImportMode28QuadsUseRequestedTriangulation) +{ + auto run = [&](const QByteArray& blob, const std::string& meshName) { + QTemporaryFile tmp(QDir::tempPath() + "/qtmesh_ps1tmd_28_XXXXXX.tmd"); + tmp.setAutoRemove(true); + ASSERT_TRUE(tmp.open()); + ASSERT_EQ(tmp.write(blob), blob.size()); + tmp.flush(); + + if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName)) + Ogre::MeshManager::getSingleton().remove(old); + + Ogre::MeshPtr mesh = PS1TMD::importTmd(tmp.fileName(), meshName); + ASSERT_TRUE(mesh); + ASSERT_EQ(mesh->getNumSubMeshes(), 1u); + Ogre::SubMesh* sm = mesh->getSubMesh(0); + ASSERT_TRUE(sm->vertexData); + ASSERT_EQ(sm->vertexData->vertexCount, 6u); + + // Expected positions after import transform: 10× then 180° about Z. + const Ogre::Vector3 v0(0.f, 0.f, 0.f); + const Ogre::Vector3 v1(-10.f, 0.f, 0.f); + const Ogre::Vector3 v2(-10.f, -10.f, 0.f); + const Ogre::Vector3 v3(0.f, -10.f, 0.f); + + Ogre::VertexData* vd = sm->vertexData; + const auto* posEl = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_POSITION); + ASSERT_NE(posEl, nullptr); + auto posBuf = vd->vertexBufferBinding->getBuffer(posEl->getSource()); + const uint8_t* vbase = static_cast(posBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + const size_t stride = posBuf->getVertexSize(); + float* pf = nullptr; + Ogre::Vector3 p[6]; + for (int i = 0; i < 6; ++i) { + posEl->baseVertexPointerToElement(const_cast(vbase + size_t(i) * stride), &pf); + p[i] = Ogre::Vector3(pf[0], pf[1], pf[2]); + } + posBuf->unlock(); + + // Triangulation should be (v0,v1,v2) + (v1,v2,v3) (order may flip per-tri). + EXPECT_TRUE(triContainsAll(p[0], p[1], p[2], v0, v1, v2)); + EXPECT_TRUE(triContainsAll(p[3], p[4], p[5], v1, v2, v3)); + }; + + run(makeMinimal28FlatQuadTmd(), "PS1Tmd28FlatQuad"); + run(makeMinimal28GradQuadTmd(), "PS1Tmd28GradQuad"); +} + TEST_F(PS1TMDTest, ImportMode35NoLightGouraudTexturedTriangle) { QTemporaryFile tmp(QDir::tempPath() + "/qtmesh_ps1tmd_35_XXXXXX.tmd"); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5e3397370..d4063ceb8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -389,15 +389,15 @@ if(BUILD_TESTS) # Discover individual Google Test cases for better reporting # Skip auto-discovery for QML tests in CI environments to avoid Qt platform issues if(TARGET MaterialEditorQML_test) - gtest_discover_tests(MaterialEditorQML_test) + gtest_discover_tests(MaterialEditorQML_test DISCOVERY_MODE PRE_TEST) endif() if(TARGET MaterialEditorQML_qml_test) - gtest_discover_tests(MaterialEditorQML_qml_test) + gtest_discover_tests(MaterialEditorQML_qml_test DISCOVERY_MODE PRE_TEST) endif() if(TARGET MaterialEditorQML_perf_test) - gtest_discover_tests(MaterialEditorQML_perf_test) + gtest_discover_tests(MaterialEditorQML_perf_test DISCOVERY_MODE PRE_TEST) endif() if(TARGET MaterialEditorQML_qml_test_runner) From be6cea4c8f9ce63a7005fbf246bfa713fe6a9862 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 5 May 2026 15:39:48 -0400 Subject: [PATCH 4/7] ps1: export vertex colors and TIM sidecars Co-authored-by: Cursor --- src/PS1/PS1TIM.cpp | 117 +++++++++++++++++++++++++++ src/PS1/PS1TIM.h | 8 ++ src/PS1/PS1TIM_test.cpp | 49 ++++++++++++ src/PS1/PS1TMD.cpp | 170 +++++++++++++++++++++++++++++++++++----- src/PS1/PS1TMD_test.cpp | 75 ++++++++++++++++++ tests/CMakeLists.txt | 10 +++ 6 files changed, 411 insertions(+), 18 deletions(-) create mode 100644 src/PS1/PS1TIM_test.cpp diff --git a/src/PS1/PS1TIM.cpp b/src/PS1/PS1TIM.cpp index d712994fb..9bb1b777f 100644 --- a/src/PS1/PS1TIM.cpp +++ b/src/PS1/PS1TIM.cpp @@ -11,6 +11,8 @@ The MIT License #include +#include + #include #include #include @@ -27,6 +29,20 @@ inline uint32_t readU32le(const uint8_t* p) return uint32_t(p[0]) | (uint32_t(p[1]) << 8) | (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24); } +inline void writeU16le(uint8_t* p, uint16_t v) +{ + p[0] = uint8_t(v & 0xFF); + p[1] = uint8_t((v >> 8) & 0xFF); +} + +inline void writeU32le(uint8_t* p, uint32_t v) +{ + p[0] = uint8_t(v & 0xFF); + p[1] = uint8_t((v >> 8) & 0xFF); + p[2] = uint8_t((v >> 16) & 0xFF); + p[3] = uint8_t((v >> 24) & 0xFF); +} + static void psxBgr555ToRgba(uint16_t c, uint8_t& r, uint8_t& g, uint8_t& b, uint8_t& a) { // Bits: 0..4 R, 5..9 G, 10..14 B, 15 STP (semi-transparency flag in GPU) @@ -40,6 +56,15 @@ static void psxBgr555ToRgba(uint16_t c, uint8_t& r, uint8_t& g, uint8_t& b, uint a = (c == 0) ? 0 : 255; } +static uint16_t rgbaToPsxBgr555(uint8_t r, uint8_t g, uint8_t b, uint8_t a) +{ + const uint16_t r5 = uint16_t(r >> 3); + const uint16_t g5 = uint16_t(g >> 3); + const uint16_t b5 = uint16_t(b >> 3); + const uint16_t stp = (a < 128) ? 1u : 0u; + return uint16_t((stp << 15) | (b5 << 10) | (g5 << 5) | (r5 << 0)); +} + struct TimImageHeader { uint16_t x{}; uint16_t y{}; @@ -277,5 +302,97 @@ bool loadTimToOgreImage(const QString& timPath, Ogre::Image& outImage, QString* return true; } +bool saveOgreImageToTim16(const Ogre::Image& image, const QString& timPath, QString* outError) +{ + // Convert to RGBA8 if needed (Ogre 14 Image has no convert()). + Ogre::Image img = image; + std::vector converted; + if (img.getFormat() != Ogre::PF_BYTE_RGBA) { + try { + const size_t w = img.getWidth(); + const size_t h = img.getHeight(); + const Ogre::PixelBox srcBox = img.getPixelBox(); + converted.resize(w * h * 4u); + Ogre::PixelBox dstBox(w, h, 1, Ogre::PF_BYTE_RGBA, converted.data()); + Ogre::PixelUtil::bulkPixelConversion(srcBox, dstBox); + // Wrap as a temporary image view (no ownership). + img.loadDynamicImage(converted.data(), w, h, 1, Ogre::PF_BYTE_RGBA, false); + } catch (...) { + if (outError) *outError = "Failed to convert image to RGBA8"; + return false; + } + } + + constexpr int kPageW = 256; + constexpr int kPageH = 256; + + const int w = int(img.getWidth()); + const int h = int(img.getHeight()); + if (w <= 0 || h <= 0) { + if (outError) *outError = "Invalid image dimensions"; + return false; + } + + // Embed into a 256x256 RGBA canvas. + std::vector canvas(size_t(kPageW) * size_t(kPageH) * 4u, 0); + const uint8_t* src = img.getData(); + const int copyW = std::min(w, kPageW); + const int copyH = std::min(h, kPageH); + for (int y = 0; y < copyH; ++y) { + for (int x = 0; x < copyW; ++x) { + const size_t si = (size_t(y) * size_t(w) + size_t(x)) * 4u; + const size_t di = (size_t(y) * size_t(kPageW) + size_t(x)) * 4u; + canvas[di + 0] = src[si + 0]; + canvas[di + 1] = src[si + 1]; + canvas[di + 2] = src[si + 2]; + canvas[di + 3] = src[si + 3]; + } + } + + // TIM 16bpp (no CLUT): + // - header: magic 0x10, flags bppMode=2 (0x02) + // - image block: len, x, y, wWords, h, data (BGR555) + const uint32_t flags = 0x02u; + const uint16_t xVram = 0; + const uint16_t yVram = 0; + const uint16_t wWords = kPageW; // 16bpp: 1 word per pixel + const uint16_t hWords = kPageH; + const uint32_t imgDataBytes = uint32_t(kPageW * kPageH * 2); + const uint32_t blockLen = 12u + imgDataBytes; + + QByteArray out; + out.resize(int(8 + blockLen)); + uint8_t* d = reinterpret_cast(out.data()); + writeU32le(d + 0, 0x10u); + writeU32le(d + 4, flags); + writeU32le(d + 8, blockLen); + writeU16le(d + 12, xVram); + writeU16le(d + 14, yVram); + writeU16le(d + 16, wWords); + writeU16le(d + 18, hWords); + + uint8_t* px = d + 20; + for (int y = 0; y < kPageH; ++y) { + for (int x = 0; x < kPageW; ++x) { + const size_t ci = (size_t(y) * size_t(kPageW) + size_t(x)) * 4u; + const uint16_t c16 = rgbaToPsxBgr555(canvas[ci + 0], canvas[ci + 1], canvas[ci + 2], canvas[ci + 3]); + writeU16le(px + (size_t(y) * size_t(kPageW) + size_t(x)) * 2u, c16); + } + } + + QFile f(timPath); + if (!f.open(QIODevice::WriteOnly)) { + if (outError) *outError = "Failed to open output TIM for write"; + return false; + } + const qint64 wrote = f.write(out); + f.close(); + if (wrote != out.size()) { + if (outError) *outError = "Failed to write TIM"; + return false; + } + return true; +} + } // namespace PS1TIM diff --git a/src/PS1/PS1TIM.h b/src/PS1/PS1TIM.h index d4521e20f..4009cd434 100644 --- a/src/PS1/PS1TIM.h +++ b/src/PS1/PS1TIM.h @@ -29,6 +29,14 @@ namespace PS1TIM { */ bool loadTimToOgreImage(const QString& timPath, Ogre::Image& outImage, QString* outError = nullptr); +/** + * Encode an Ogre::Image (PF_BYTE_RGBA preferred) into a 16bpp PlayStation TIM file. + * + * Writes a no-CLUT TIM (bppMode=2) with x=y=0 and embeds the image into a 256×256 page, + * top-left aligned. This matches how TMD UV bytes are authored (256×256 page texels). + */ +bool saveOgreImageToTim16(const Ogre::Image& image, const QString& timPath, QString* outError = nullptr); + } // namespace PS1TIM #endif diff --git a/src/PS1/PS1TIM_test.cpp b/src/PS1/PS1TIM_test.cpp new file mode 100644 index 000000000..3b4d04d5b --- /dev/null +++ b/src/PS1/PS1TIM_test.cpp @@ -0,0 +1,49 @@ +#include + +#include +#include + +#include +#include + +#include "PS1/PS1TIM.h" + +TEST(PS1TIMTest, SaveThenLoadTim16RoundTrip) +{ + // 2x2 RGBA image with distinct colors. + std::vector rgba = { + 255, 0, 0, 255, 0, 255, 0, 255, + 0, 0, 255, 255, 255, 255, 255, 255 + }; + + Ogre::Image img; + img.loadDynamicImage(rgba.data(), 2, 2, 1, Ogre::PF_BYTE_RGBA, false); + + QTemporaryFile tmp(QDir::tempPath() + "/qtmesh_ps1tim_XXXXXX.tim"); + tmp.setAutoRemove(true); + ASSERT_TRUE(tmp.open()); + const QString path = tmp.fileName(); + tmp.close(); + + QString err; + ASSERT_TRUE(PS1TIM::saveOgreImageToTim16(img, path, &err)) << err.toStdString(); + + Ogre::Image decoded; + ASSERT_TRUE(PS1TIM::loadTimToOgreImage(path, decoded, &err)) << err.toStdString(); + ASSERT_EQ(decoded.getFormat(), Ogre::PF_BYTE_RGBA); + ASSERT_EQ(decoded.getWidth(), 256u); + ASSERT_EQ(decoded.getHeight(), 256u); + + // Top-left should match (within 5-bit quantization). + const uint8_t* d = decoded.getData(); + auto px = [&](int x, int y) { + const size_t i = (size_t(y) * 256u + size_t(x)) * 4u; + return std::array{d[i + 0], d[i + 1], d[i + 2], d[i + 3]}; + }; + const auto p00 = px(0, 0); + EXPECT_GE(p00[0], 240); + EXPECT_LE(p00[1], 16); + EXPECT_LE(p00[2], 16); + EXPECT_EQ(p00[3], 255); +} + diff --git a/src/PS1/PS1TMD.cpp b/src/PS1/PS1TMD.cpp index 196820c54..bee2d9ad0 100644 --- a/src/PS1/PS1TMD.cpp +++ b/src/PS1/PS1TMD.cpp @@ -271,6 +271,22 @@ static bool parseTmdObject(const uint8_t* data, size_t fileSize, uint32_t stored Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false, c0, c0, c0, true); continue; } + if (mode == 0x30 && flag == 0 && ilen == 6) { + const Ogre::ColourValue c0(float(d[0]) / 255.0f, float(d[1]) / 255.0f, float(d[2]) / 255.0f, 1.0f); + const Ogre::ColourValue c1(float(d[4]) / 255.0f, float(d[5]) / 255.0f, float(d[6]) / 255.0f, 1.0f); + const Ogre::ColourValue c2(float(d[8]) / 255.0f, float(d[9]) / 255.0f, float(d[10]) / 255.0f, 1.0f); + const uint16_t n0 = readU16le(d + 12); + const uint16_t v0 = readU16le(d + 14); + const uint16_t n1 = readU16le(d + 16); + const uint16_t v1 = readU16le(d + 18); + const uint16_t n2 = readU16le(d + 20); + const uint16_t v2 = readU16le(d + 22); + if (v0 < nVert && v1 < nVert && v2 < nVert && n0 < nNorm && n1 < nNorm && n2 < nNorm) + appendTri(out, verts[v0], verts[v2], verts[v1], norms[n0], norms[n2], norms[n1], + Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, Ogre::Vector2::ZERO, false, + c0, c2, c1, true); + continue; + } if (mode == 0x24 && flag == 0 && ilen == 5) { const uint16_t ni = readU16le(d + 12); const uint16_t i0 = readU16le(d + 14); @@ -729,6 +745,29 @@ static void appendG3(std::vector& pb, uint16_t i0, uint16_t i1, uint16_ writeU16le(pkt + 18, i2); } +// Gouraud-shaded triangle with per-vertex colors (GP0 0x30 packet with 3 colors). +// Layout: cmd+RGB0, RGB1, RGB2, then (n0,v0)(n1,v1)(n2,v2). +static void appendG3C(std::vector& pb, + uint16_t i0, uint16_t i1, uint16_t i2, + uint16_t n0, uint16_t n1, uint16_t n2, + const Ogre::ColourValue& c0, const Ogre::ColourValue& c1, const Ogre::ColourValue& c2) +{ + const size_t start = pb.size(); + pb.resize(start + 4 + 24); + uint8_t* pkt = pb.data() + start; + pkt[0] = 8; + pkt[1] = 6; + pkt[2] = 0; + pkt[3] = 0x30; + auto enc = [](float v) -> uint8_t { return static_cast(std::clamp(int(std::lround(v * 255.0f)), 0, 255)); }; + pkt[4] = enc(c0.r); pkt[5] = enc(c0.g); pkt[6] = enc(c0.b); pkt[7] = 0x30; + pkt[8] = enc(c1.r); pkt[9] = enc(c1.g); pkt[10] = enc(c1.b); pkt[11] = 0; + pkt[12] = enc(c2.r); pkt[13] = enc(c2.g); pkt[14] = enc(c2.b); pkt[15] = 0; + writeU16le(pkt + 16, n0); writeU16le(pkt + 18, i0); + writeU16le(pkt + 20, n1); writeU16le(pkt + 22, i1); + writeU16le(pkt + 24, n2); writeU16le(pkt + 26, i2); +} + static void appendFt3(std::vector& pb, uint16_t i0, uint16_t i1, uint16_t i2, uint16_t ni, const Ogre::Vector2& t0, const Ogre::Vector2& t1, const Ogre::Vector2& t2) { @@ -922,6 +961,38 @@ bool exportEntity(const Ogre::Entity* entity, const QString& filePath, float ogr }; std::vector objects(numSub); + // If the mesh is textured, export a sibling TIM next to the TMD (best-effort). + try { + Ogre::MaterialPtr mat; + for (unsigned si = 0; si < numSub; ++si) { + if (auto* se = entity->getSubEntity(si)) { + mat = se->getMaterial(); + if (submeshHasDiffuseTexture(mat)) + break; + mat.reset(); + } + } + if (mat && mat->getNumTechniques() > 0 && mat->getTechnique(0)->getNumPasses() > 0) { + Ogre::Pass* p0 = mat->getTechnique(0)->getPass(0); + if (p0 && p0->getNumTextureUnitStates() > 0) { + Ogre::TextureUnitState* tus = p0->getTextureUnitState(0); + if (tus && !tus->getTextureName().empty()) { + const QString timPath = QFileInfo(filePath).absolutePath() + "/" + QFileInfo(filePath).completeBaseName() + ".tim"; + auto tex = Ogre::TextureManager::getSingleton().getByName(tus->getTextureName()); + if (tex) { + if (!tex->isLoaded()) + tex->load(); + Ogre::Image img; + tex->convertToImage(img); + QString err; + (void)PS1TIM::saveOgreImageToTim16(img, timPath, &err); + } + } + } + } + } catch (...) { + } + for (unsigned si = 0; si < numSub; ++si) { Ogre::SubMesh* sm = mesh->getSubMesh(si); Ogre::VertexData* vd = sm->useSharedVertices ? mesh->sharedVertexData : sm->vertexData; @@ -931,6 +1002,7 @@ bool exportEntity(const Ogre::Entity* entity, const QString& filePath, float ogr const auto* posEl = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_POSITION); const auto* nrmEl = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_NORMAL); const auto* uvEl = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_TEXTURE_COORDINATES); + const auto* colEl = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_DIFFUSE); if (!posEl || !nrmEl) continue; @@ -938,22 +1010,61 @@ bool exportEntity(const Ogre::Entity* entity, const QString& filePath, float ogr const bool textured = subEnt && submeshHasDiffuseTexture(subEnt->getMaterial()); const bool hasUv = textured && uvEl; - auto posBuf = vd->vertexBufferBinding->getBuffer(posEl->getSource()); - auto nrmBuf = vd->vertexBufferBinding->getBuffer(nrmEl->getSource()); + const unsigned short posSrc = posEl->getSource(); + const unsigned short nrmSrc = nrmEl->getSource(); + const unsigned short uvSrc = (hasUv && uvEl) ? uvEl->getSource() : 0; + + auto posBuf = vd->vertexBufferBinding->getBuffer(posSrc); + auto nrmBuf = vd->vertexBufferBinding->getBuffer(nrmSrc); + Ogre::HardwareVertexBufferSharedPtr colBuf; + const unsigned short colSrc = colEl ? colEl->getSource() : 0; + if (colEl) + colBuf = vd->vertexBufferBinding->getBuffer(colSrc); Ogre::HardwareVertexBufferSharedPtr uvBuf; - if (hasUv) - uvBuf = vd->vertexBufferBinding->getBuffer(uvEl->getSource()); + if (hasUv && uvEl) + uvBuf = vd->vertexBufferBinding->getBuffer(uvSrc); + const size_t posStride = posBuf->getVertexSize(); const size_t nrmStride = nrmBuf->getVertexSize(); const size_t uvStride = uvBuf ? uvBuf->getVertexSize() : 0; + const size_t colStride = colBuf ? colBuf->getVertexSize() : 0; const uint32_t vCount = vd->vertexCount; objects[si].verts.resize(size_t(vCount) * 8u, 0); objects[si].norms.resize(size_t(vCount) * 8u, 0); + // Lock each unique source buffer only once (pos/nrm often share a buffer). const uint8_t* posBase = static_cast(posBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); - const uint8_t* nrmBase = static_cast(nrmBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); - const uint8_t* uvBase = uvBuf ? static_cast(uvBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)) : nullptr; + const uint8_t* nrmBase = nullptr; + const uint8_t* uvBase = nullptr; + const uint8_t* colBase = nullptr; + + if (nrmSrc == posSrc) { + nrmBase = posBase; + } else { + nrmBase = static_cast(nrmBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + } + + if (uvBuf) { + if (uvSrc == posSrc) { + uvBase = posBase; + } else if (uvSrc == nrmSrc && nrmSrc != posSrc) { + uvBase = nrmBase; + } else { + uvBase = static_cast(uvBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + } + } + if (colBuf) { + if (colSrc == posSrc) { + colBase = posBase; + } else if (colSrc == nrmSrc && nrmSrc != posSrc) { + colBase = nrmBase; + } else if (uvBuf && colSrc == uvSrc && uvSrc != posSrc && !(uvSrc == nrmSrc && nrmSrc != posSrc)) { + colBase = uvBase; + } else { + colBase = static_cast(colBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + } + } for (uint32_t vi = 0; vi < vCount; ++vi) { const uint8_t* prow = posBase + vi * posStride; @@ -976,10 +1087,6 @@ bool exportEntity(const Ogre::Entity* entity, const QString& filePath, float ogr writeVertex8(px, py, pz, objects[si].verts.data() + vi * 8); writeVertex8(nx, ny, nz, objects[si].norms.data() + vi * 8); } - posBuf->unlock(); - nrmBuf->unlock(); - if (uvBuf) - uvBuf->unlock(); auto ibuf = sm->indexData->indexBuffer; const uint8_t* ib = static_cast(ibuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); @@ -1004,11 +1111,11 @@ bool exportEntity(const Ogre::Entity* entity, const QString& filePath, float ogr continue; if (hasUv && uvEl && uvBase) { Ogre::Real* tf = nullptr; - uvEl->baseVertexPointerToElement(const_cast(const_cast(uvBase + i0 * uvStride)), &tf); + uvEl->baseVertexPointerToElement(const_cast(uvBase + i0 * uvStride), &tf); const Ogre::Vector2 tv0(tf[0], tf[1]); - uvEl->baseVertexPointerToElement(const_cast(const_cast(uvBase + i1 * uvStride)), &tf); + uvEl->baseVertexPointerToElement(const_cast(uvBase + i1 * uvStride), &tf); const Ogre::Vector2 tv1(tf[0], tf[1]); - uvEl->baseVertexPointerToElement(const_cast(const_cast(uvBase + i2 * uvStride)), &tf); + uvEl->baseVertexPointerToElement(const_cast(uvBase + i2 * uvStride), &tf); const Ogre::Vector2 tv2(tf[0], tf[1]); // Undo import winding swap so .tmd primitive order matches PSX convention on disk. appendFt3(objects[si].prims, static_cast(i0), static_cast(i2), static_cast(i1), @@ -1017,11 +1124,35 @@ bool exportEntity(const Ogre::Entity* entity, const QString& filePath, float ogr appendFt3(objects[si].prims, static_cast(i0), static_cast(i2), static_cast(i1), static_cast(i0), Ogre::Vector2(0, 0), Ogre::Vector2(0, 0), Ogre::Vector2(0, 0)); } else { - appendG3(objects[si].prims, static_cast(i0), static_cast(i2), static_cast(i1), - static_cast(i0), static_cast(i2), static_cast(i1)); + if (colEl && colBase) { + Ogre::RGBA* c = nullptr; + colEl->baseVertexPointerToElement(const_cast(colBase + i0 * colStride), &c); + Ogre::ColourValue cv0; cv0.setAsARGB(*c); + colEl->baseVertexPointerToElement(const_cast(colBase + i1 * colStride), &c); + Ogre::ColourValue cv1; cv1.setAsARGB(*c); + colEl->baseVertexPointerToElement(const_cast(colBase + i2 * colStride), &c); + Ogre::ColourValue cv2; cv2.setAsARGB(*c); + + appendG3C(objects[si].prims, + static_cast(i0), static_cast(i2), static_cast(i1), + static_cast(i0), static_cast(i2), static_cast(i1), + cv0, cv2, cv1); + } else { + appendG3(objects[si].prims, static_cast(i0), static_cast(i2), static_cast(i1), + static_cast(i0), static_cast(i2), static_cast(i1)); + } } } ibuf->unlock(); + + // Unlock vertex buffers after all consumers are done. + if (colBuf && colSrc != posSrc && colSrc != nrmSrc && !(uvBuf && colSrc == uvSrc && uvSrc != posSrc && !(uvSrc == nrmSrc && nrmSrc != posSrc))) + colBuf->unlock(); + if (uvBuf && uvSrc != posSrc && !(uvSrc == nrmSrc && nrmSrc != posSrc)) + uvBuf->unlock(); + if (nrmSrc != posSrc) + nrmBuf->unlock(); + posBuf->unlock(); } bool anyGeometry = false; @@ -1042,9 +1173,9 @@ bool exportEntity(const Ogre::Entity* entity, const QString& filePath, float ogr writeU32le(file.data() + 8, numSub); for (unsigned si = 0; si < numSub; ++si) { - uint8_t* oh = file.data() + 12 + si * kObjHeaderSize; + const size_t ohOff = 12u + size_t(si) * kObjHeaderSize; if (objects[si].verts.empty() || objects[si].prims.empty()) { - std::memset(oh, 0, kObjHeaderSize); + std::memset(file.data() + ohOff, 0, kObjHeaderSize); continue; } const uint32_t vOff = static_cast(file.size() - 12u); @@ -1057,7 +1188,10 @@ bool exportEntity(const Ogre::Entity* entity, const QString& filePath, float ogr const uint32_t pktCount = countPrimPackets(objects[si].prims); file.insert(file.end(), objects[si].prims.begin(), objects[si].prims.end()); - writeU32le(oh, vOff); + // IMPORTANT: `file.insert` may reallocate, invalidating raw pointers. + // Reacquire the header pointer after appending payloads. + uint8_t* oh = file.data() + ohOff; + writeU32le(oh + 0, vOff); writeU32le(oh + 4, nVert); writeU32le(oh + 8, nOff); writeU32le(oh + 12, nNorm); diff --git a/src/PS1/PS1TMD_test.cpp b/src/PS1/PS1TMD_test.cpp index 3bdb2ef4e..ab3725d6c 100644 --- a/src/PS1/PS1TMD_test.cpp +++ b/src/PS1/PS1TMD_test.cpp @@ -101,6 +101,60 @@ static QByteArray makeMinimalG3Tmd() return buf; } +/** Minimal TMD: one object, three vertices, three normals, one G3 triangle with per-vertex colors (mode 0x30, ilen 6). */ +static QByteArray makeMinimalG3ColorTmd() +{ + constexpr uint32_t kTmdId = 0x41u; + constexpr size_t kHead = 12u; + constexpr size_t kObjH = 28u; + const size_t vAbs = kHead + kObjH; + const size_t nAbs = vAbs + 3u * 8u; + const size_t pAbs = nAbs + 3u * 8u; + const uint32_t vOff = static_cast(vAbs - 12u); + const uint32_t nOff = static_cast(nAbs - 12u); + const uint32_t pOff = static_cast(pAbs - 12u); + + QByteArray buf(static_cast(pAbs + 4u + 24u), '\0'); + uint8_t* d = reinterpret_cast(buf.data()); + + writeU32le(d, kTmdId); + writeU32le(d + 4, 0); + writeU32le(d + 8, 1); + + uint8_t* oh = d + kHead; + writeU32le(oh, vOff); + writeU32le(oh + 4, 3); + writeU32le(oh + 8, nOff); + writeU32le(oh + 12, 3); + writeU32le(oh + 16, pOff); + writeU32le(oh + 20, 1); + writeU32le(oh + 24, 0); + + writeVertex8(0, 0, 0, d + vAbs); + writeVertex8(4096, 0, 0, d + vAbs + 8); + writeVertex8(0, 4096, 0, d + vAbs + 16); + + writeVertex8(0, 0, 4096, d + nAbs); + writeVertex8(0, 0, 4096, d + nAbs + 8); + writeVertex8(0, 0, 4096, d + nAbs + 16); + + uint8_t* pkt = d + pAbs; + pkt[0] = 8; + pkt[1] = 6; + pkt[2] = 0; + pkt[3] = 0x30; + uint8_t* pay = pkt + 4; + // RGB0, RGB1, RGB2 (with pad bytes). + pay[0] = 255; pay[1] = 0; pay[2] = 0; pay[3] = 0; + pay[4] = 0; pay[5] = 255; pay[6] = 0; pay[7] = 0; + pay[8] = 0; pay[9] = 0; pay[10] = 255; pay[11] = 0; + writeU16le(pay + 12, 0); writeU16le(pay + 14, 0); + writeU16le(pay + 16, 1); writeU16le(pay + 18, 1); + writeU16le(pay + 20, 2); writeU16le(pay + 22, 2); + + return buf; +} + /** One textured tri, no-light (mode 0x25, flag 1, ilen 6) — Net Yaroze layout. */ static QByteArray makeMinimal25NoLightTmd() { @@ -682,6 +736,27 @@ TEST_F(PS1TMDTest, ImportMinimalG3Triangle) posBuf->unlock(); } +TEST_F(PS1TMDTest, ImportMode30GouraudTriangleWithVertexColors) +{ + QTemporaryFile tmp(QDir::tempPath() + "/qtmesh_ps1tmd_g3c_XXXXXX.tmd"); + tmp.setAutoRemove(true); + ASSERT_TRUE(tmp.open()); + const QByteArray blob = makeMinimalG3ColorTmd(); + ASSERT_EQ(tmp.write(blob), blob.size()); + tmp.flush(); + + const std::string meshName = "PS1TmdG3ColorMesh"; + if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName)) + Ogre::MeshManager::getSingleton().remove(old); + + Ogre::MeshPtr mesh = PS1TMD::importTmd(tmp.fileName(), meshName); + ASSERT_TRUE(mesh); + Ogre::SubMesh* sm = mesh->getSubMesh(0); + ASSERT_TRUE(sm && sm->vertexData); + const auto* colEl = sm->vertexData->vertexDeclaration->findElementBySemantic(Ogre::VES_DIFFUSE); + ASSERT_NE(colEl, nullptr); +} + TEST_F(PS1TMDTest, ExportImportRoundTripSingleTriangle) { ASSERT_TRUE(canLoadMeshFiles()); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9be7a7849..a65e48d1f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -92,6 +92,11 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditableMesh.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditModeController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/HalfEdgeMesh.cpp + + # PS1 importers are referenced by core code (MaterialEditorQML, MeshImporterExporter). + # Include them so the MaterialEditorQML test executables link successfully. + ${CMAKE_CURRENT_SOURCE_DIR}/../src/PS1/PS1TMD.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/PS1/PS1TIM.cpp ) set(TEST_HEADER_FILES @@ -295,6 +300,11 @@ if(BUILD_TESTS) if(ENABLE_LOCAL_LLM) list(APPEND COMMON_TEST_LIBRARIES llama ggml) endif() + + # Link Sentry for tests if enabled (SentryReporter is part of TEST_SRC_FILES). + if(ENABLE_SENTRY) + list(APPEND COMMON_TEST_LIBRARIES sentry) + endif() # Helper function to create test executables function(create_test_executable target_name test_source_file) From 4be2d528b952c6e1c20329c0ee6ca51c25e86d53 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 5 May 2026 16:44:11 -0400 Subject: [PATCH 5/7] ps1: alpha-cutout for TMD TIM textures (palette 0x0000) Co-authored-by: Cursor --- src/MaterialEditorQML.cpp | 3 +++ src/PS1/PS1TMD.cpp | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/src/MaterialEditorQML.cpp b/src/MaterialEditorQML.cpp index a62e96aab..2bd2a4195 100644 --- a/src/MaterialEditorQML.cpp +++ b/src/MaterialEditorQML.cpp @@ -877,6 +877,9 @@ void MaterialEditorQML::setTextureName(const QString &name) while (tech->getNumPasses() > 1) tech->removePass(1); } + // PS1 .TIM often uses color 0x0000 for transparent texels (see PS1TIM::loadTimToOgreImage). + if (Ogre::Pass* pass = getCurrentPass()) + pass->setAlphaRejectSettings(Ogre::CMPF_GREATER_EQUAL, 1); } } updateMaterialText(); diff --git a/src/PS1/PS1TMD.cpp b/src/PS1/PS1TMD.cpp index bee2d9ad0..753429943 100644 --- a/src/PS1/PS1TMD.cpp +++ b/src/PS1/PS1TMD.cpp @@ -921,6 +921,10 @@ Ogre::MeshPtr importTmd(const QString& filePath, const std::string& meshName, fl Ogre::TextureManager::getSingleton().loadImage(texName, group, img); tus->setTextureName(texName); + // TIM / PS1 convention: palette entry 0x0000 decodes to alpha 0 (transparent). + // Without alpha rejection or blending, those fragments show as black; use a cutout mask. + pass0->setAlphaRejectSettings(Ogre::CMPF_GREATER_EQUAL, 1); + // Simplify: single pass (remove outline/wireframe) on these per-import materials. Ogre::Technique* tech0 = mat->getTechnique(0); while (tech0 && tech0->getNumPasses() > 1) { From 58ad6549610666f9f5a120b50a69ae735592d0cd Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 5 May 2026 17:00:36 -0400 Subject: [PATCH 6/7] test(ps1): fix G3 import vertex order expectations Co-authored-by: Cursor --- src/PS1/PS1TMD_test.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/PS1/PS1TMD_test.cpp b/src/PS1/PS1TMD_test.cpp index ab3725d6c..6e6314048 100644 --- a/src/PS1/PS1TMD_test.cpp +++ b/src/PS1/PS1TMD_test.cpp @@ -713,7 +713,7 @@ TEST_F(PS1TMDTest, ImportMinimalG3Triangle) EXPECT_EQ(sm->vertexData->vertexCount, 3u); EXPECT_EQ(sm->indexData->indexCount, 3u); - // File verts (0,0,0), (4096,0,0), (0,4096,0) → 10× then 180° about Z: (0,0,0), (-10,0,0), (0,-10,0) + // After transform: v0=(0,0,0), v1=(-10,0,0), v2=(0,-10,0). Importer emits v0,v2,v1 for winding. Ogre::VertexData* vd = sm->vertexData; const auto* posEl = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_POSITION); ASSERT_NE(posEl, nullptr); @@ -726,12 +726,12 @@ TEST_F(PS1TMDTest, ImportMinimalG3Triangle) EXPECT_NEAR(pf[1], 0.f, 1e-4f); EXPECT_NEAR(pf[2], 0.f, 1e-4f); posEl->baseVertexPointerToElement(const_cast(vbase + stride), &pf); - EXPECT_NEAR(pf[0], -10.f, 1e-3f); - EXPECT_NEAR(pf[1], 0.f, 1e-4f); + EXPECT_NEAR(pf[0], 0.f, 1e-3f); + EXPECT_NEAR(pf[1], -10.f, 1e-3f); EXPECT_NEAR(pf[2], 0.f, 1e-4f); posEl->baseVertexPointerToElement(const_cast(vbase + 2 * stride), &pf); - EXPECT_NEAR(pf[0], 0.f, 1e-4f); - EXPECT_NEAR(pf[1], -10.f, 1e-3f); + EXPECT_NEAR(pf[0], -10.f, 1e-3f); + EXPECT_NEAR(pf[1], 0.f, 1e-4f); EXPECT_NEAR(pf[2], 0.f, 1e-4f); posBuf->unlock(); } From e40bfa943e3f549b5a904ecdf772311d1b76991a Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 5 May 2026 18:03:56 -0400 Subject: [PATCH 7/7] ux(ps1): use lowercase .tmd/.tim in filters; normalize TIM resource names Co-authored-by: Cursor --- src/AssetBrowserController_test.cpp | 2 +- src/CLIPipeline.cpp | 2 +- src/CLIPipeline_test.cpp | 4 +-- src/MCPServer.cpp | 2 +- src/Manager.cpp | 2 +- src/Manager_test.cpp | 2 +- src/MaterialEditorQML.cpp | 31 ++++++++++++--------- src/MeshImporterExporter.cpp | 4 +-- src/MeshImporterExporter_test.cpp | 4 +-- src/PS1/PS1TMD.cpp | 42 ++++++++++++++++++++--------- src/PS1/PS1TMD.h | 2 +- src/WelcomeDialog.cpp | 2 +- 12 files changed, 60 insertions(+), 39 deletions(-) diff --git a/src/AssetBrowserController_test.cpp b/src/AssetBrowserController_test.cpp index 3f9f7c9b0..18c74c6ce 100644 --- a/src/AssetBrowserController_test.cpp +++ b/src/AssetBrowserController_test.cpp @@ -258,7 +258,7 @@ TEST_F(AssetBrowserControllerTests, OpenFileTmdEmitsImportSignal) { QTemporaryDir tmpDir; ASSERT_TRUE(tmpDir.isValid()); - const QString meshPath = tmpDir.path() + "/CAR.TMD"; + const QString meshPath = tmpDir.path() + "/car.tmd"; QFile(meshPath).open(QIODevice::WriteOnly); auto* abc = AssetBrowserController::instance(); diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 7ce96c9db..d06350a66 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -619,7 +619,7 @@ QString CLIPipeline::formatForExtension(const QString& path) {".mesh.xml", "Ogre XML (*.mesh.xml)"}, {".mesh", "Ogre Mesh (*.mesh)"}, {".assbin", "Assimp Binary (*.assbin)"}, - {".tmd", "PlayStation TMD (*.tmd *.TMD)"} + {".tmd", "PlayStation TMD (*.tmd)"} }; for (const ExtensionFormat& entry : extensionFormats) { diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index dcdee9bd4..5dea73c23 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -487,8 +487,8 @@ TEST(CLIPipelineFormatForExtension, Assbin) TEST(CLIPipelineFormatForExtension, Tmd) { - EXPECT_EQ(CLIPipeline::formatForExtension("model.tmd"), "PlayStation TMD (*.tmd *.TMD)"); - EXPECT_EQ(CLIPipeline::formatForExtension("MODEL.TMD"), "PlayStation TMD (*.tmd *.TMD)"); + EXPECT_EQ(CLIPipeline::formatForExtension("model.tmd"), "PlayStation TMD (*.tmd)"); + EXPECT_EQ(CLIPipeline::formatForExtension("MODEL.TMD"), "PlayStation TMD (*.tmd)"); } TEST(CLIPipelineFormatForExtension, UnknownDefaultsToMesh) diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 655188ba7..12579f6cb 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -3397,7 +3397,7 @@ QJsonArray MCPServer::buildToolsList() "'Ogre XML (*.mesh.xml)', 'Collada (*.dae)', 'X (*.x)', 'OBJ (*.obj)', " "'OBJ without MTL (*.objnomtl)', 'STL (*.stl)', 'PLY (*.ply)', '3DS (*.3ds)', " "'glTF 2.0 (*.gltf2)', 'glTF 2.0 Binary (*.glb2)', 'Assimp Binary (*.assbin)', " - "'FBX Binary (*.fbx)', 'PlayStation TMD (*.tmd *.TMD)'. " + "'FBX Binary (*.fbx)', 'PlayStation TMD (*.tmd)'. " "Default: 'Ogre Mesh (*.mesh)'"}}; inputSchema["properties"] = properties; inputSchema["required"] = QJsonArray{"path"}; diff --git a/src/Manager.cpp b/src/Manager.cpp index 781118a41..060022b55 100755 --- a/src/Manager.cpp +++ b/src/Manager.cpp @@ -80,7 +80,7 @@ Manager* Manager:: m_pSingleton = nullptr; QString Manager::mValidFileExtention = ".mesh .dae .blend .3ds .ase .obj .ifc .xgl .zgl .ply .dxf .lwo "\ ".lws .lxo .stl .x .ac .ms3d .cob .scn .bvh .csm .xml .irrmesh .irr .mdl .md2 .md3 "\ - ".pk3 .mdc .md5 .txt .smd .vta .m3 .3d .b3d .q3d .q3s .nff .nff .off .raw .ter .mdl .hmp .ndo .fbx .glb .gltf .vrm .tmd .TMD"; + ".pk3 .mdc .md5 .txt .smd .vta .m3 .3d .b3d .q3d .q3s .nff .nff .off .raw .ter .mdl .hmp .ndo .fbx .glb .gltf .vrm .tmd"; //////////////////////////////////////// /// Static Member to build & destroy diff --git a/src/Manager_test.cpp b/src/Manager_test.cpp index 9a38d883a..ba91769c5 100644 --- a/src/Manager_test.cpp +++ b/src/Manager_test.cpp @@ -267,6 +267,7 @@ TEST_F(ManagerHeadlessTest, IsValidFileExtention) QString tmdFile = "model.tmd"; EXPECT_TRUE(mgr->isValidFileExtention(tmdFile)); + EXPECT_TRUE(mgr->isValidFileExtention(QStringLiteral("CAR.TMD"))); // Invalid extensions QString docFile = "readme.doc"; @@ -288,7 +289,6 @@ TEST_F(ManagerHeadlessTest, IsValidFileExtention) EXPECT_TRUE(validExts.contains(".fbx")); EXPECT_TRUE(validExts.contains(".vrm")); EXPECT_TRUE(validExts.contains(".tmd")); - EXPECT_TRUE(validExts.contains(".TMD")); } TEST_F(ManagerHeadlessTest, CreateEmptyScene) diff --git a/src/MaterialEditorQML.cpp b/src/MaterialEditorQML.cpp index 2bd2a4195..f46c49702 100644 --- a/src/MaterialEditorQML.cpp +++ b/src/MaterialEditorQML.cpp @@ -877,7 +877,7 @@ void MaterialEditorQML::setTextureName(const QString &name) while (tech->getNumPasses() > 1) tech->removePass(1); } - // PS1 .TIM often uses color 0x0000 for transparent texels (see PS1TIM::loadTimToOgreImage). + // PS1 .tim often uses color 0x0000 for transparent texels (see PS1TIM::loadTimToOgreImage). if (Ogre::Pass* pass = getCurrentPass()) pass->setAlphaRejectSettings(Ogre::CMPF_GREATER_EQUAL, 1); } @@ -997,10 +997,13 @@ void MaterialEditorQML::selectTexture() return; } + const bool isTim = file.suffix().compare(QStringLiteral("tim"), Qt::CaseInsensitive) == 0; + const QString ogreTexName = isTim ? (file.completeBaseName() + QStringLiteral(".tim")) : file.fileName(); + try { // Try to get existing texture Ogre::TextureManager::getSingleton().getByName( - file.fileName().toStdString(), Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + ogreTexName.toStdString(), Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); } catch (...) { // Load new texture // Always register user-picked textures into the default group ("General") so materials can find them. @@ -1011,7 +1014,7 @@ void MaterialEditorQML::selectTexture() Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); Ogre::Image image; - if (file.suffix().compare("tim", Qt::CaseInsensitive) == 0) { + if (isTim) { QString err; if (!PS1TIM::loadTimToOgreImage(filePath, image, &err)) { emit errorOccurred(QString("Failed to load TIM: %1").arg(err)); @@ -1021,10 +1024,10 @@ void MaterialEditorQML::selectTexture() image.load(file.fileName().toStdString(), Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); } Ogre::TextureManager::getSingleton().loadImage( - file.fileName().toStdString(), Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, image); + ogreTexName.toStdString(), Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, image); } - setTextureName(file.fileName()); + setTextureName(ogreTexName); } // LCOV_EXCL_STOP @@ -1677,7 +1680,7 @@ QString MaterialEditorQML::getTexturePreviewPath() const } } - // If we can't return a directly viewable file (e.g., TIM), generate a PNG preview from the GPU texture. + // If we can't return a directly viewable file (e.g., .tim), generate a PNG preview from the GPU texture. try { Ogre::Image img; texPtr->convertToImage(img, true); @@ -2578,12 +2581,14 @@ bool MaterialEditorQML::loadTextureFile(const QString &filePath) return false; } - const std::string texName = file.fileName().toStdString(); + const bool isTim = file.suffix().compare(QStringLiteral("tim"), Qt::CaseInsensitive) == 0; + const QString ogreTexName = isTim ? (file.completeBaseName() + QStringLiteral(".tim")) : file.fileName(); + const std::string texNameStd = ogreTexName.toStdString(); const std::string group = Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME; try { - if (Ogre::TextureManager::getSingleton().getByName(texName, group)) { - setTextureName(QString::fromStdString(texName)); + if (Ogre::TextureManager::getSingleton().getByName(texNameStd, group)) { + setTextureName(ogreTexName); return true; } } catch (...) { @@ -2595,17 +2600,17 @@ bool MaterialEditorQML::loadTextureFile(const QString &filePath) Ogre::ResourceGroupManager::getSingleton().initialiseResourceGroup(group); Ogre::Image image; - if (file.suffix().compare("tim", Qt::CaseInsensitive) == 0) { + if (isTim) { QString err; if (!PS1TIM::loadTimToOgreImage(filePath, image, &err)) { emit errorOccurred(QString("Failed to load TIM: %1").arg(err)); return false; } } else { - image.load(texName, group); + image.load(file.fileName().toStdString(), group); } - Ogre::TextureManager::getSingleton().loadImage(texName, group, image); - setTextureName(QString::fromStdString(texName)); + Ogre::TextureManager::getSingleton().loadImage(texNameStd, group, image); + setTextureName(ogreTexName); return true; } catch (const std::exception& e) { emit errorOccurred(QString("Texture load failed: %1").arg(e.what())); diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index a430588dc..26d5d1300 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -81,7 +81,7 @@ const QMap MeshImporterExporter::exportFormats = { {"glTF 2.0 Binary (*.glb)", ".glb"}, {"Assimp Binary (*.assbin)", ".assbin"}, {"FBX Binary (*.fbx)", ".fbx"}, - {"PlayStation TMD (*.tmd *.TMD)", ".tmd"} + {"PlayStation TMD (*.tmd)", ".tmd"} }; void MeshImporterExporter::configureCamera(const Ogre::Entity *en) @@ -1458,7 +1458,7 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u // .material and extracted image files next to the FBX. if (!ok) return -1; - } else if (_format == QStringLiteral("PlayStation TMD (*.tmd *.TMD)")) { + } else if (_format == QStringLiteral("PlayStation TMD (*.tmd)")) { if (!PS1TMD::exportEntity(e, _uri)) return -1; SentryReporter::addBreadcrumb(QStringLiteral("file.export"), diff --git a/src/MeshImporterExporter_test.cpp b/src/MeshImporterExporter_test.cpp index 5e788aff0..76ea11478 100644 --- a/src/MeshImporterExporter_test.cpp +++ b/src/MeshImporterExporter_test.cpp @@ -178,7 +178,7 @@ TEST(MeshImporterExporterStandaloneTest, FormatFileURI_UnknownFormat_ReturnsURIW } TEST(MeshImporterExporterStandaloneTest, ExportFileDialogFilter_ReturnsFilterString) { - QString expected = "3DS (*.3ds);;Assimp Binary (*.assbin);;Collada (*.dae);;FBX Binary (*.fbx);;OBJ (*.obj);;OBJ without MTL (*.objnomtl);;Ogre Mesh (*.mesh);;Ogre Mesh v1.0+(*.mesh);;Ogre Mesh v1.10+(*.mesh);;Ogre Mesh v1.4+(*.mesh);;Ogre Mesh v1.7+(*.mesh);;Ogre Mesh v1.8+(*.mesh);;Ogre XML (*.mesh.xml);;PLY (*.ply);;PlayStation TMD (*.tmd *.TMD);;STL (*.stl);;X (*.x);;glTF 2.0 (*.gltf);;glTF 2.0 Binary (*.glb)"; + QString expected = "3DS (*.3ds);;Assimp Binary (*.assbin);;Collada (*.dae);;FBX Binary (*.fbx);;OBJ (*.obj);;OBJ without MTL (*.objnomtl);;Ogre Mesh (*.mesh);;Ogre Mesh v1.0+(*.mesh);;Ogre Mesh v1.10+(*.mesh);;Ogre Mesh v1.4+(*.mesh);;Ogre Mesh v1.7+(*.mesh);;Ogre Mesh v1.8+(*.mesh);;Ogre XML (*.mesh.xml);;PLY (*.ply);;PlayStation TMD (*.tmd);;STL (*.stl);;X (*.x);;glTF 2.0 (*.gltf);;glTF 2.0 Binary (*.glb)"; QString result = MeshImporterExporter::exportFileDialogFilter(); @@ -567,7 +567,7 @@ TEST(MeshImporterExporterStandaloneTest, ExportFileDialogFilter_ContainsAllForma EXPECT_TRUE(filter.contains("Ogre Mesh v1.8+(*.mesh)")); EXPECT_TRUE(filter.contains("Ogre XML (*.mesh.xml)")); EXPECT_TRUE(filter.contains("PLY (*.ply)")); - EXPECT_TRUE(filter.contains("PlayStation TMD (*.tmd *.TMD)")); + EXPECT_TRUE(filter.contains("PlayStation TMD (*.tmd)")); EXPECT_TRUE(filter.contains("STL (*.stl)")); EXPECT_TRUE(filter.contains("X (*.x)")); EXPECT_TRUE(filter.contains("glTF 2.0 (*.gltf)")); diff --git a/src/PS1/PS1TMD.cpp b/src/PS1/PS1TMD.cpp index 753429943..df475bb5a 100644 --- a/src/PS1/PS1TMD.cpp +++ b/src/PS1/PS1TMD.cpp @@ -78,6 +78,30 @@ inline int16_t clampI16(int v) return static_cast(v); } +static QString canonicalTimResourceName(const QString& timPathOnDisk) +{ + return QFileInfo(timPathOnDisk).completeBaseName() + QStringLiteral(".tim"); +} + +/** Prefer `basename.tim`; scan directory case-insensitively for legacy uppercase `.TIM` on disk. */ +static QString findSiblingTimForTmd(const QString& tmdFilePath) +{ + const QFileInfo tmdFi(tmdFilePath); + const QString base = tmdFi.completeBaseName(); + const QString dir = tmdFi.absolutePath(); + const QString primary = QDir(dir).filePath(base + QStringLiteral(".tim")); + if (QFileInfo::exists(primary)) + return primary; + const QFileInfoList files = QDir(dir).entryInfoList(QDir::Files); + for (const QFileInfo& fi : files) { + if (fi.suffix().compare(QStringLiteral("tim"), Qt::CaseInsensitive) != 0) + continue; + if (fi.completeBaseName().compare(base, Qt::CaseInsensitive) == 0) + return fi.absoluteFilePath(); + } + return {}; +} + /** * PS1 8-bit U/V are texel indices in the active 256×256 texture page. * Map to 0..1 with texel-center bias. Like most PC APIs here, increasing V moves down the image (same as PSX @@ -357,7 +381,7 @@ static bool parseTmdObject(const uint8_t* data, size_t fileSize, uint32_t stored const uint16_t i3 = readU16le(d + 24); if (i0 < nVert && i1 < nVert && i2 < nVert && i3 < nVert && ni < nNorm) { const Ogre::Vector3& np = norms[ni]; - // GRID.TMD uses this primitive to represent a checkerboard. Gouraud interpolation makes + // grid.tmd-style assets use this primitive for a checkerboard. Gouraud interpolation makes // the triangulation diagonal very visible, so for checker-like quads (c0==c2,c1==c3) // we treat the quad as a *flat-colored* face using c0. const bool isCheckerLike = (c0 == c2) && (c1 == c3); @@ -639,7 +663,7 @@ static Ogre::MeshPtr buildMeshFromSoup(const std::string& meshName, const TriSou if (mat->getNumTechniques() > 0 && mat->getTechnique(0)->getNumPasses() > 0) { Ogre::Pass* p0 = mat->getTechnique(0)->getPass(0); if (p0) { - // Vertex-color-only meshes (like GRID.TMD) should render unlit by default to avoid + // Vertex-color-only meshes (e.g. grid-style quads) should render unlit by default to avoid // triangle shading artifacts. Textured meshes will keep lighting settings. if (!hasUv) { // Keep the material "blank": no baked ambient/diffuse/emissive contribution. @@ -883,16 +907,9 @@ Ogre::MeshPtr importTmd(const QString& filePath, const std::string& meshName, fl if (!mesh) return {}; - // Auto-apply a sibling .TIM texture when: - // - the TMD actually has UVs (textured primitives), and - // - there is a TIM file next to the TMD with the same basename (e.g., CAR.TMD -> CAR.TIM). + // Auto-apply a sibling .tim texture when the TMD has UVs and a matching TIM exists beside it. if (merged.hasUv) { - const QFileInfo tmdFi(filePath); - const QString base = tmdFi.completeBaseName(); - const QString dir = tmdFi.absolutePath(); - const QString timUpper = QDir(dir).filePath(base + ".TIM"); - const QString timLower = QDir(dir).filePath(base + ".tim"); - const QString timPath = QFileInfo::exists(timUpper) ? timUpper : (QFileInfo::exists(timLower) ? timLower : QString()); + const QString timPath = findSiblingTimForTmd(filePath); if (!timPath.isEmpty()) { const std::string matName = std::string("TMD/") + meshName; @@ -915,8 +932,7 @@ Ogre::MeshPtr importTmd(const QString& filePath, const std::string& meshName, fl Ogre::Image img; QString err; if (PS1TIM::loadTimToOgreImage(timPath, img, &err)) { - const QString timFileName = QFileInfo(timPath).fileName(); - const std::string texName = timFileName.toStdString(); + const std::string texName = canonicalTimResourceName(timPath).toStdString(); const std::string group = Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME; Ogre::TextureManager::getSingleton().loadImage(texName, group, img); tus->setTextureName(texName); diff --git a/src/PS1/PS1TMD.h b/src/PS1/PS1TMD.h index c31adc835..06b6d7a5b 100644 --- a/src/PS1/PS1TMD.h +++ b/src/PS1/PS1TMD.h @@ -24,7 +24,7 @@ The MIT License * * Supported modes include lit polygons (flag 0) and “no light” textured * triangles (mode 0x25 / 0x35, flag 1 per Net Yaroze). Texture UVs refer to - * PSX VRAM layout (cba/tsb select CLUT and texture page); bitmaps live in .TIM files. + * PSX VRAM layout (cba/tsb select CLUT and texture page); bitmaps live in .tim files. * UV import maps 8-bit page texels with a texel-center bias (no V flip; PSX and Ogre both treat * increasing V as downward in image space). Full VRAM page offsets are not baked in — match your texture * to the page. diff --git a/src/WelcomeDialog.cpp b/src/WelcomeDialog.cpp index 89ed99381..eb81488a4 100644 --- a/src/WelcomeDialog.cpp +++ b/src/WelcomeDialog.cpp @@ -68,7 +68,7 @@ WelcomeDialog::WelcomeDialog(QWidget* parent) connect(openFileBtn, &QPushButton::clicked, this, [this]() { QString file = QFileDialog::getOpenFileName( this, "Open 3D File", QString(), - "3D Files (*.fbx *.gltf *.glb *.vrm *.obj *.dae *.stl *.mesh *.3ds *.x *.tmd *.TMD);;All Files (*)"); + "3D Files (*.fbx *.gltf *.glb *.vrm *.obj *.dae *.stl *.mesh *.3ds *.x *.tmd);;All Files (*)"); if (!file.isEmpty()) { m_action = OpenFile; m_selectedFile = file;