diff --git a/CHANGELOG.md b/CHANGELOG.md index 708260a8..caa6909b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,11 @@ == CHANGELOG == +* Added an experimental new spell for batch processing multiple NIF files, casting any from a selection of currently 7 spells. The files to be processed can be selected with a file dialog, and are overwritten with the modified data (it is recommended to back up the original NIFs). +* Converting Starfield geometry to external mesh files can now use a custom sub-directory under 'geometries'. This can be configured in the general settings under NIF, and if the name is not empty, exported mesh paths will be in the format 'geometries/SUBDIR/SHA1.mesh', and the full hash (40 characters) will be used as the base name of the file. +* The default startup NIF version has been changed from Oblivion to Skyrim: Special Edition. * Fixed setting the NIF version in new windows from the startup defaults. * Fixed issue reading version 22 Fallout 76 BGEM files due to unknown new fGlassBlurScaleFactor setting. +* Fixed exporting Starfield mesh files with no meshlet data. #### NifSkope-2.0.dev9-20241017 diff --git a/src/model/nifmodel.h b/src/model/nifmodel.h index 37de9e5c..299298cb 100644 --- a/src/model/nifmodel.h +++ b/src/model/nifmodel.h @@ -731,7 +731,7 @@ public slots: QList rootLinks; bool lockUpdates; - bool batchProcessingMode; + bool batchProcessingMode = false; enum UpdateType { diff --git a/src/nifskope.cpp b/src/nifskope.cpp index 872f9c72..a2ea6281 100644 --- a/src/nifskope.cpp +++ b/src/nifskope.cpp @@ -1469,6 +1469,7 @@ bool NifSkope::batchProcessFiles( dlg.setResult( QDialog::Accepted ); dlg.show(); + NifModel * tmpNif = nullptr; bool noErrors = true; for ( qsizetype i = 0; i < n; i++ ) { const QString & filePath = fileList[i]; @@ -1478,26 +1479,42 @@ bool NifSkope::batchProcessFiles( if ( dlg.result() == QDialog::Rejected ) return false; - loadFile( filePath ); + QString fileName( QDir::fromNativeSeparators( filePath ) ); + tmpNif = new NifModel(); + tmpNif->setBatchProcessingMode( true ); + { + QFile f( fileName ); + if ( !f.open( QIODeviceBase::ReadOnly ) ) + throw FO76UtilsError( "error opening file" ); + std::string tmp( fileName.toStdString() ); + tmpNif->load( f, tmp.c_str() ); + } + QCoreApplication::processEvents(); if ( dlg.result() == QDialog::Rejected ) return false; - nif->setBatchProcessingMode( true ); - bool saveFlag = processFunc( nif, processFuncData ); - nif->setBatchProcessingMode( false ); + bool saveFlag = processFunc( tmpNif, processFuncData ); + tmpNif->setBatchProcessingMode( false ); + QCoreApplication::processEvents(); if ( dlg.result() == QDialog::Rejected ) return false; if ( saveFlag ) { - saveFile( filePath ); - QCoreApplication::processEvents(); - if ( dlg.result() == QDialog::Rejected ) - return false; + QFile f( fileName ); + if ( !f.open( QIODeviceBase::WriteOnly ) ) + throw FO76UtilsError( "error opening file" ); + tmpNif->save( f ); } + + delete tmpNif; + tmpNif = nullptr; } catch ( std::exception & e ) { - nif->setBatchProcessingMode( false ); + if ( tmpNif ) { + delete tmpNif; + tmpNif = nullptr; + } if ( QMessageBox::critical( this, "NifSkope error", QString( "Error processing '%1': %2. Continue?" ).arg( filePath ).arg( e.what() ), QMessageBox::Yes | QMessageBox::No ) != QMessageBox::Yes ) { diff --git a/src/spells/fileextract.cpp b/src/spells/fileextract.cpp index 47078910..a834d2bd 100644 --- a/src/spells/fileextract.cpp +++ b/src/spells/fileextract.cpp @@ -113,7 +113,7 @@ std::string spResourceFileExtract::getOutputDirectory( const NifModel * nif ) QString key = QString( "Spells//Extract File/Last File Path" ); QString dstPath( settings.value( key ).toString() ); if ( !( nif && nif->getBatchProcessingMode() ) ) { - QFileDialog dialog; + QFileDialog dialog( nullptr, "Select Export Data Path" ); dialog.setFileMode( QFileDialog::Directory ); if ( !dstPath.isEmpty() ) dialog.setDirectory( dstPath ); @@ -417,11 +417,12 @@ class spMeshFileExport final : public Spell return ( item->name() == "BSGeometry" && ( nif->get(item, "Flags") & 0x0200 ) != 0 ); } - bool processItem( NifModel * nif, NifItem * item, const std::string & outputDirectory ); + bool processItem( NifModel * nif, NifItem * item, const std::string & outputDirectory, const QString & meshDir ); QModelIndex cast( NifModel * nif, const QModelIndex & index ) override final; }; -bool spMeshFileExport::processItem( NifModel * nif, NifItem * item, const std::string & outputDirectory ) +bool spMeshFileExport::processItem( + NifModel * nif, NifItem * item, const std::string & outputDirectory, const QString & meshDir ) { quint32 flags; if ( !( item && item->name() == "BSGeometry" && ( (flags = nif->get(item, "Flags")) & 0x0200 ) != 0 ) ) @@ -441,22 +442,28 @@ bool spMeshFileExport::processItem( NifModel * nif, NifItem * item, const std::s continue; haveMeshes = true; - QBuffer meshBuf; - meshBuf.open( QIODevice::WriteOnly ); + QByteArray meshBuf; { - NifOStream nifStream( nif, &meshBuf ); + QBuffer tmpBuf( &meshBuf ); + tmpBuf.open( QIODevice::WriteOnly ); + NifOStream nifStream( nif, &tmpBuf ); nif->saveItem( nif->getItem( meshData, false ), nifStream ); } + if ( !( nif->get( meshData, "Num Meshlets" ) | nif->get( meshData, "Num Cull Data" ) ) ) + meshBuf.chop( 8 ); // end of file after LODs if there are no meshlets QCryptographicHash h( QCryptographicHash::Sha1 ); - h.addData( meshBuf.data() ); + h.addData( meshBuf ); meshPaths[l] = h.result().toHex(); - meshPaths[l].insert( 20, QChar('\\') ); + if ( meshDir.isEmpty() ) + meshPaths[l].insert( 20, QChar('\\') ); + else + meshPaths[l].insert( 0, meshDir ); std::string fullPath( outputDirectory ); fullPath += Game::GameManager::get_full_path( meshPaths[l], "geometries/", ".mesh" ); try { - spResourceFileExtract::writeFileWithPath( fullPath, meshBuf.data().data(), meshBuf.data().size() ); + spResourceFileExtract::writeFileWithPath( fullPath, meshBuf.data(), meshBuf.size() ); } catch ( std::exception & e ) { QMessageBox::critical( nullptr, "NifSkope error", QString("Error extracting file: %1" ).arg( e.what() ) ); } @@ -491,19 +498,28 @@ QModelIndex spMeshFileExport::cast( NifModel * nif, const QModelIndex & index ) if ( outputDirectory.empty() ) return index; + QString meshDir; + { + QSettings settings; + meshDir = settings.value( "Settings/Nif/Mesh Export Dir", QString() ).toString().trimmed().toLower(); + } + meshDir.replace( QChar('/'), QChar('\\') ); + while ( meshDir.endsWith( QChar('\\') ) ) + meshDir.chop( 1 ); + while ( meshDir.startsWith( QChar('\\') ) ) + meshDir.remove( 0, 1 ); + if ( !meshDir.isEmpty() ) + meshDir.append( QChar('\\') ); + bool meshesConverted = false; if ( item ) { - meshesConverted = processItem( nif, item, outputDirectory ); + meshesConverted = processItem( nif, item, outputDirectory, meshDir ); } else { for ( int b = 0; b < nif->getBlockCount(); b++ ) - meshesConverted |= processItem( nif, nif->getBlockItem( qint32(b) ), outputDirectory ); - } - if ( meshesConverted ) { -#ifdef QT_NO_DEBUG - if ( !nif->getBatchProcessingMode() ) -#endif - Game::GameManager::close_resources(); + meshesConverted |= processItem( nif, nif->getBlockItem( qint32(b) ), outputDirectory, meshDir ); } + if ( meshesConverted && !nif->getBatchProcessingMode() ) + Game::GameManager::close_resources(); return index; } @@ -555,7 +571,10 @@ bool spMeshFileImport::processItem( NifModel * nif, NifItem * item ) if ( meshPath.isEmpty() ) continue; if ( !nif->getResourceFile( meshData[l], meshPath, "geometries/", ".mesh" ) ) { - QMessageBox::critical( nullptr, "NifSkope error", QString("Failed to load mesh file '%1'" ).arg( meshPath ) ); + if ( nif->getBatchProcessingMode() ) + throw FO76UtilsError( "failed to load mesh file '%s'", meshPath.toStdString().c_str() ); + else + QMessageBox::critical( nullptr, "NifSkope error", QString("Failed to load mesh file '%1'" ).arg( meshPath ) ); return false; } } @@ -624,6 +643,7 @@ class spBatchProcessFiles final : public Spell { return QIcon(); } + bool constant() const override final { return true; } bool instant() const override final { return true; } bool isApplicable( [[maybe_unused]] const NifModel * nif, const QModelIndex & index ) override final @@ -644,6 +664,36 @@ class spBatchProcessFiles final : public Spell QModelIndex cast( NifModel * nif, const QModelIndex & index ) override final; }; +class spRemoveUnusedStrings +{ +public: + static QModelIndex cast_Static( NifModel * nif, const QModelIndex & index ); +}; + +class spSimplifySFMesh +{ +public: + static QModelIndex cast_Static( NifModel * nif, const QModelIndex & index ); +}; + +class spAddAllTangentSpaces +{ +public: + static QModelIndex cast_Static( NifModel * nif, const QModelIndex & index ); +}; + +class spGenerateMeshlets +{ +public: + static QModelIndex cast_Static( NifModel * nif, const QModelIndex & index ); +}; + +class spUpdateAllBounds +{ +public: + static QModelIndex cast_Static( NifModel * nif, const QModelIndex & index ); +}; + bool spBatchProcessFiles::processFile( NifModel * nif, void * p ) { int spellMask = *( reinterpret_cast< int * >( p ) ); @@ -655,27 +705,27 @@ bool spBatchProcessFiles::processFile( NifModel * nif, void * p ) } if ( spellMask & spellFlagRemoveUnusedStrings ) { - // TODO + spRemoveUnusedStrings::cast_Static( nif, QModelIndex() ); fileChanged = true; } if ( ( spellMask & spellFlagLODGen ) && nif->getBSVersion() >= 170 ) { - // TODO + spSimplifySFMesh::cast_Static( nif, QModelIndex() ); fileChanged = true; } if ( spellMask & spellFlagTangentSpace ) { - // TODO + spAddAllTangentSpaces::cast_Static( nif, QModelIndex() ); fileChanged = true; } if ( ( spellMask & spellFlagMeshlets ) && nif->getBSVersion() >= 170 ) { - // TODO + spGenerateMeshlets::cast_Static( nif, QModelIndex() ); fileChanged = true; } if ( spellMask & spellFlagUpdateBounds ) { - // TODO + spUpdateAllBounds::cast_Static( nif, QModelIndex() ); fileChanged = true; } @@ -697,7 +747,7 @@ QModelIndex spBatchProcessFiles::cast( [[maybe_unused]] NifModel * nif, const QM { QDialog dlg; QLabel * lb = new QLabel( &dlg ); - lb->setText( "Warning: this spell closes the current model, and selected files will be overwritten" ); + lb->setText( "Batch process multiple models, overwriting the original NIF files" ); QLabel * lb2 = new QLabel( "Select spells to be cast, in the order listed:", &dlg ); QCheckBox * checkInternalGeom = new QCheckBox( "Convert to Internal Geometry", &dlg ); QCheckBox * checkRemoveUnusedStrings = new QCheckBox( "Remove Unused Strings", &dlg ); @@ -765,10 +815,8 @@ QModelIndex spBatchProcessFiles::cast( [[maybe_unused]] NifModel * nif, const QM NifSkope * w = dynamic_cast< NifSkope * >( nif->getWindow() ); if ( w ) { w->batchProcessFiles( fileList, &processFile, &spellMask ); -#ifdef QT_NO_DEBUG if ( spellMask & spellFlagExternalGeom ) Game::GameManager::close_resources(); -#endif } return index; diff --git a/src/spells/mesh.cpp b/src/spells/mesh.cpp index cd8a44b6..d642187a 100644 --- a/src/spells/mesh.cpp +++ b/src/spells/mesh.cpp @@ -1640,8 +1640,18 @@ class spUpdateAllBounds final : public Spell return QModelIndex(); } + + static QModelIndex cast_Static( NifModel * nif, const QModelIndex & index ); }; +QModelIndex spUpdateAllBounds::cast_Static( NifModel * nif, const QModelIndex & index ) +{ + spUpdateAllBounds tmp; + if ( tmp.isApplicable( nif, index ) ) + return tmp.cast( nif, index ); + return index; +} + REGISTER_SPELL( spUpdateAllBounds ) @@ -1831,6 +1841,14 @@ QModelIndex spGenerateMeshlets::cast( NifModel * nif, const QModelIndex & index return spUpdateBounds::cast_Starfield( nif, index ); } +QModelIndex spGenerateMeshlets::cast_Static( NifModel * nif, const QModelIndex & index ) +{ + spGenerateMeshlets tmp; + if ( tmp.isApplicable( nif, index ) ) + return tmp.cast( nif, index ); + return index; +} + REGISTER_SPELL( spGenerateMeshlets ) diff --git a/src/spells/mesh.h b/src/spells/mesh.h index fc4b98e8..1e8cf7dc 100644 --- a/src/spells/mesh.h +++ b/src/spells/mesh.h @@ -69,6 +69,7 @@ class spGenerateMeshlets final : public Spell static void clearMeshlets( NifModel * nif, const QModelIndex & iMeshData ); static void updateMeshlets( NifModel * nif, const QPersistentModelIndex & iMeshData, const MeshFile & meshFile ); QModelIndex cast( NifModel * nif, const QModelIndex & index ) override final; + static QModelIndex cast_Static( NifModel * nif, const QModelIndex & index ); }; //! Removes unused vertices diff --git a/src/spells/optimize.cpp b/src/spells/optimize.cpp index 323be998..5ee27e11 100644 --- a/src/spells/optimize.cpp +++ b/src/spells/optimize.cpp @@ -573,16 +573,27 @@ class spRemoveUnusedStrings final : public Spell for ( const auto & s : newStrings ) originalStrings.removeAll( s ); - QString msg; - if ( originalStrings.size() ) - msg = "Removed:\r\n" + QStringList::fromVector( originalStrings ).join( "\r\n" ); + if ( !nif->getBatchProcessingMode() ) { + QString msg; + if ( originalStrings.size() ) + msg = "Removed:\r\n" + QStringList::fromVector( originalStrings ).join( "\r\n" ); - Message::info( nullptr, Spell::tr( "Strings Removed: %1. New string table has %2 entries." ) - .arg( originalStrings.size() ).arg( newSize ), msg - ); + Message::info( nullptr, Spell::tr( "Strings Removed: %1. New string table has %2 entries." ) + .arg( originalStrings.size() ).arg( newSize ), msg ); + } return QModelIndex(); } + + static QModelIndex cast_Static( NifModel * nif, const QModelIndex & index ); }; +QModelIndex spRemoveUnusedStrings::cast_Static( NifModel * nif, const QModelIndex & index ) +{ + spRemoveUnusedStrings tmp; + if ( tmp.isApplicable( nif, index ) ) + return tmp.cast( nif, index ); + return index; +} + REGISTER_SPELL( spRemoveUnusedStrings ) diff --git a/src/spells/simplify.cpp b/src/spells/simplify.cpp index 4bfbef21..2be1197c 100644 --- a/src/spells/simplify.cpp +++ b/src/spells/simplify.cpp @@ -36,7 +36,7 @@ class spSimplifySFMesh final : public Spell } static void getTransform( Transform & t, const NifModel * nif, const QModelIndex & index ); void loadGeometryData( const NifModel * nif, const QModelIndex & index ); - void simplifyMeshes(); + void simplifyMeshes( bool noMessages = false ); int vertexBlockNum( unsigned int v ) const; void saveGeometryData( NifModel * nif ) const; }; @@ -51,6 +51,8 @@ class spSimplifySFMesh final : public Spell } QModelIndex cast( NifModel * nif, const QModelIndex & index ) override final; + + static QModelIndex cast_Static( NifModel * nif, const QModelIndex & index ); }; void spSimplifySFMesh::Meshes::getTransform( Transform & t, const NifModel * nif, const QModelIndex & index ) @@ -152,7 +154,7 @@ void spSimplifySFMesh::Meshes::loadGeometryData( const NifModel * nif, const QMo blockVertexRanges.push_back( (unsigned int) totalVertices ); } -void spSimplifySFMesh::Meshes::simplifyMeshes() +void spSimplifySFMesh::Meshes::simplifyMeshes( bool noMessages ) { if ( blockNumbers.empty() || !( totalIndices >= 3 && totalVertices >= 1 ) ) return; @@ -190,6 +192,8 @@ void spSimplifySFMesh::Meshes::simplifyMeshes() newIndices[l].resize( newIndicesCnt ); } + if ( noMessages ) + return; QString msg = QString( "LOD0: %1 triangles" ).arg( numTriangles ); for ( int l = 0; l < 3; l++ ) msg.append( QString("\nLOD%1: %2 triangles, error = %3").arg(l + 1).arg(newIndices[l].size() / 3).arg(err[l]) ); @@ -372,12 +376,20 @@ QModelIndex spSimplifySFMesh::cast( NifModel * nif, const QModelIndex & index ) Meshes m; nif->setState( BaseModel::Processing ); m.loadGeometryData( nif, index ); - m.simplifyMeshes(); + m.simplifyMeshes( nif->getBatchProcessingMode() ); m.saveGeometryData( nif ); nif->restoreState(); return index; } +QModelIndex spSimplifySFMesh::cast_Static( NifModel * nif, const QModelIndex & index ) +{ + spSimplifySFMesh tmp; + if ( tmp.isApplicable( nif, index ) ) + return tmp.cast( nif, index ); + return index; +} + REGISTER_SPELL( spSimplifySFMesh ) diff --git a/src/spells/tangentspace.cpp b/src/spells/tangentspace.cpp index f64bc545..de6405cc 100644 --- a/src/spells/tangentspace.cpp +++ b/src/spells/tangentspace.cpp @@ -539,7 +539,17 @@ class spAddAllTangentSpaces final : public Spell return QModelIndex(); } + + static QModelIndex cast_Static( NifModel * nif, const QModelIndex & index ); }; +QModelIndex spAddAllTangentSpaces::cast_Static( NifModel * nif, const QModelIndex & index ) +{ + spAddAllTangentSpaces tmp; + if ( tmp.isApplicable( nif, index ) ) + return tmp.cast( nif, index ); + return index; +} + REGISTER_SPELL( spAddAllTangentSpaces )