Pārlūkot izejas kodu

Merge pull request #10195 from aws-lumberyard-dev/AssetProcessor_FeatureWork_IntermediateAssets

Asset Processor - Intermediate Assets
amzn-mike 3 gadi atpakaļ
vecāks
revīzija
f334b627b6
55 mainītis faili ar 3601 papildinājumiem un 1056 dzēšanām
  1. 1 0
      AutomatedTesting/Gem/Code/enabled_gems.cmake
  2. 46 34
      Code/Framework/AzToolsFramework/AzToolsFramework/AssetDatabase/AssetDatabaseConnection.cpp
  3. 7 7
      Code/Framework/AzToolsFramework/AzToolsFramework/AssetDatabase/AssetDatabaseConnection.h
  4. 1 1
      Code/Framework/AzToolsFramework/AzToolsFramework/ToolsFileUtils/ToolsFileUtils_generic.cpp
  5. 17 10
      Code/Tools/AssetProcessor/AssetBuilderSDK/AssetBuilderSDK/AssetBuilderSDK.cpp
  6. 26 0
      Code/Tools/AssetProcessor/AssetBuilderSDK/AssetBuilderSDK/AssetBuilderSDK.h
  7. 2 0
      Code/Tools/AssetProcessor/assetprocessor_static_files.cmake
  8. 4 0
      Code/Tools/AssetProcessor/assetprocessor_test_files.cmake
  9. 52 8
      Code/Tools/AssetProcessor/native/AssetDatabase/AssetDatabase.cpp
  10. 13 12
      Code/Tools/AssetProcessor/native/AssetDatabase/AssetDatabase.h
  11. 22 16
      Code/Tools/AssetProcessor/native/AssetManager/AssetCatalog.cpp
  12. 6 8
      Code/Tools/AssetProcessor/native/AssetManager/AssetCatalog.h
  13. 188 0
      Code/Tools/AssetProcessor/native/AssetManager/ProductAsset.cpp
  14. 53 0
      Code/Tools/AssetProcessor/native/AssetManager/ProductAsset.h
  15. 468 201
      Code/Tools/AssetProcessor/native/AssetManager/assetProcessorManager.cpp
  16. 39 3
      Code/Tools/AssetProcessor/native/AssetManager/assetProcessorManager.h
  17. 2 11
      Code/Tools/AssetProcessor/native/AssetManager/assetScannerWorker.cpp
  18. 6 5
      Code/Tools/AssetProcessor/native/FileProcessor/FileProcessor.cpp
  19. 7 3
      Code/Tools/AssetProcessor/native/assetprocessor.h
  20. 0 20
      Code/Tools/AssetProcessor/native/resourcecompiler/JobsModel.cpp
  21. 0 1
      Code/Tools/AssetProcessor/native/resourcecompiler/JobsModel.h
  22. 187 35
      Code/Tools/AssetProcessor/native/resourcecompiler/rcjob.cpp
  23. 51 5
      Code/Tools/AssetProcessor/native/resourcecompiler/rcjob.h
  24. 87 53
      Code/Tools/AssetProcessor/native/tests/FileProcessor/FileProcessorTests.cpp
  25. 19 28
      Code/Tools/AssetProcessor/native/tests/FileProcessor/FileProcessorTests.h
  26. 85 26
      Code/Tools/AssetProcessor/native/tests/UnitTestUtilities.cpp
  27. 41 11
      Code/Tools/AssetProcessor/native/tests/UnitTestUtilities.h
  28. 9 2
      Code/Tools/AssetProcessor/native/tests/assetdatabase/AssetDatabaseTest.cpp
  29. 193 0
      Code/Tools/AssetProcessor/native/tests/assetmanager/AssetManagerTestingBase.cpp
  30. 128 0
      Code/Tools/AssetProcessor/native/tests/assetmanager/AssetManagerTestingBase.h
  31. 75 141
      Code/Tools/AssetProcessor/native/tests/assetmanager/AssetProcessorManagerTest.cpp
  32. 4 29
      Code/Tools/AssetProcessor/native/tests/assetmanager/AssetProcessorManagerTest.h
  33. 722 0
      Code/Tools/AssetProcessor/native/tests/assetmanager/IntermediateAssetTests.cpp
  34. 62 0
      Code/Tools/AssetProcessor/native/tests/assetmanager/IntermediateAssetTests.h
  35. 14 112
      Code/Tools/AssetProcessor/native/tests/assetmanager/JobDependencySubIdTests.cpp
  36. 3 51
      Code/Tools/AssetProcessor/native/tests/assetmanager/JobDependencySubIdTests.h
  37. 28 31
      Code/Tools/AssetProcessor/native/tests/assetmanager/ModtimeScanningTests.cpp
  38. 1 1
      Code/Tools/AssetProcessor/native/tests/assetmanager/ModtimeScanningTests.h
  39. 50 46
      Code/Tools/AssetProcessor/native/tests/platformconfiguration/platformconfigurationtests.cpp
  40. 0 2
      Code/Tools/AssetProcessor/native/tests/platformconfiguration/platformconfigurationtests.h
  41. 21 13
      Code/Tools/AssetProcessor/native/tests/resourcecompiler/RCJobTest.cpp
  42. 0 33
      Code/Tools/AssetProcessor/native/tests/utilities/JobModelTest.cpp
  43. 0 1
      Code/Tools/AssetProcessor/native/ui/MainWindow.cpp
  44. 145 80
      Code/Tools/AssetProcessor/native/unittests/AssetProcessorManagerUnitTests.cpp
  45. 3 0
      Code/Tools/AssetProcessor/native/unittests/AssetScannerUnitTests.cpp
  46. 3 3
      Code/Tools/AssetProcessor/native/unittests/FileWatcherUnitTests.cpp
  47. 29 5
      Code/Tools/AssetProcessor/native/utilities/ApplicationManagerBase.cpp
  48. 103 1
      Code/Tools/AssetProcessor/native/utilities/PlatformConfiguration.cpp
  49. 10 3
      Code/Tools/AssetProcessor/native/utilities/PlatformConfiguration.h
  50. 190 2
      Code/Tools/AssetProcessor/native/utilities/assetUtils.cpp
  51. 48 2
      Code/Tools/AssetProcessor/native/utilities/assetUtils.h
  52. 273 0
      Gems/TestAssetBuilder/Code/Source/Builder/TestIntermediateAssetBuilderComponent.cpp
  53. 53 0
      Gems/TestAssetBuilder/Code/Source/Builder/TestIntermediateAssetBuilderComponent.h
  54. 2 0
      Gems/TestAssetBuilder/Code/Source/TestAssetBuilderModule.cpp
  55. 2 0
      Gems/TestAssetBuilder/Code/testassetbuilder_files.cmake

+ 1 - 0
AutomatedTesting/Gem/Code/enabled_gems.cmake

@@ -58,6 +58,7 @@ set(ENABLED_GEMS
     Terrain
     Profiler
     Multiplayer
+    TestAssetBuilder
     DevTextures
     PrimitiveAssets
     Stars

+ 46 - 34
Code/Framework/AzToolsFramework/AzToolsFramework/AssetDatabase/AssetDatabaseConnection.cpp

@@ -217,6 +217,20 @@ namespace AzToolsFramework
             static const auto s_querySourceLikeSourcename = MakeSqlQuery(QUERY_SOURCE_LIKE_SOURCENAME, QUERY_SOURCE_LIKE_SOURCENAME_STATEMENT, LOG_NAME,
                     SqlParam<const char*>(":sourcename"));
 
+            static const char* QUERY_SOURCE_LIKE_SOURCENAME_SCANFOLDER =
+                "AzToolsFramework::AssetDatabase::QuerySourceLikeSourceNameScanfolder";
+            static const char* QUERY_SOURCE_LIKE_SOURCENAME_SCANFOLDER_STATEMENT =
+                "SELECT * FROM Sources WHERE "
+                "SourceName LIKE :sourcename ESCAPE '|' "
+                "AND ScanFolderPK = :scanfolder ";
+
+            static const auto s_querySourceLikeSourcenameScanfolder = MakeSqlQuery(
+                QUERY_SOURCE_LIKE_SOURCENAME_SCANFOLDER,
+                QUERY_SOURCE_LIKE_SOURCENAME_SCANFOLDER_STATEMENT,
+                LOG_NAME,
+                SqlParam<const char*>(":sourcename"),
+                SqlParam<AZ::s64>(":scanfolder"));
+
             // lookup by primary key
             static const char* QUERY_JOB_BY_JOBID = "AzToolsFramework::AssetDatabase::QueryJobByJobID";
             static const char* QUERY_JOB_BY_JOBID_STATEMENT =
@@ -905,13 +919,18 @@ namespace AzToolsFramework
                     SqlParam<const char*>(":filename")
                 );
 
-            static const char* QUERY_FILES_LIKE_FILENAME = "AzToolsFramework::AssetDatabase::QueryFilesLikeFileName";
-            static const char* QUERY_FILES_LIKE_FILENAME_STATEMENT =
+            static const char* QUERY_FILES_LIKE_FILENAME_SCANFOLDERID = "AzToolsFramework::AssetDatabase::QueryFilesLikeFileNameScanFolderID";
+            static const char* QUERY_FILES_LIKE_FILENAME_SCANFOLDERID_STATEMENT =
                 "SELECT * FROM Files WHERE "
-                "FileName LIKE :filename ESCAPE '|';";
+                "FileName LIKE :filename ESCAPE '|' "
+                "AND ScanFolderPK = :scanfolderid;";
 
-            static const auto s_queryFilesLikeFileName = MakeSqlQuery(QUERY_FILES_LIKE_FILENAME, QUERY_FILES_LIKE_FILENAME_STATEMENT, LOG_NAME,
-                    SqlParam<const char*>(":filename"));
+            static const auto s_queryFilesLikeFileNameScanfolderid = MakeSqlQuery(
+                QUERY_FILES_LIKE_FILENAME_SCANFOLDERID,
+                QUERY_FILES_LIKE_FILENAME_SCANFOLDERID_STATEMENT,
+                LOG_NAME,
+                    SqlParam<const char*>(":filename"),
+                SqlParam<AZ::s64>(":scanfolderid"));
 
             static const char* QUERY_FILES_BY_SCANFOLDERID = "AzToolsFramework::AssetDatabase::QueryFilesByScanFolderID";
             static const char* QUERY_FILES_BY_SCANFOLDERID_STATEMENT =
@@ -1338,13 +1357,14 @@ namespace AzToolsFramework
         //////////////////////////////////////////////////////////////////////////
         //ProductDatabaseEntry
         ProductDatabaseEntry::ProductDatabaseEntry(AZ::s64 productID, AZ::s64 jobPK, AZ::u32 subID, const char* productName,
-            AZ::Data::AssetType assetType, AZ::Uuid legacyGuid, AZ::u64 hash)
+            AZ::Data::AssetType assetType, AZ::Uuid legacyGuid, AZ::u64 hash, AZStd::bitset<64> flags)
             : m_productID(productID)
             , m_jobPK(jobPK)
             , m_subID(subID)
             , m_assetType(assetType)
             , m_legacyGuid(legacyGuid)
             , m_hash(hash)
+            , m_flags(flags)
         {
             if (productName)
             {
@@ -1353,12 +1373,13 @@ namespace AzToolsFramework
         }
 
         ProductDatabaseEntry::ProductDatabaseEntry(AZ::s64 jobPK, AZ::u32 subID, const char* productName,
-            AZ::Data::AssetType assetType, AZ::Uuid legacyGuid, AZ::u64 hash)
+            AZ::Data::AssetType assetType, AZ::Uuid legacyGuid, AZ::u64 hash, AZStd::bitset<64> flags)
             : m_jobPK(jobPK)
             , m_subID(subID)
             , m_assetType(assetType)
             , m_legacyGuid(legacyGuid)
             , m_hash(hash)
+            , m_flags(flags)
         {
             if (productName)
             {
@@ -1366,26 +1387,6 @@ namespace AzToolsFramework
             }
         }
 
-        ProductDatabaseEntry::ProductDatabaseEntry(ProductDatabaseEntry&& other)
-        {
-            *this = AZStd::move(other);
-        }
-
-        ProductDatabaseEntry& ProductDatabaseEntry::operator=(ProductDatabaseEntry&& other)
-        {
-            if (this != &other)
-            {
-                m_productID = other.m_productID;
-                m_jobPK = other.m_jobPK;
-                m_subID = other.m_subID;
-                m_productName = AZStd::move(other.m_productName);
-                m_assetType = other.m_assetType;
-                m_legacyGuid = other.m_legacyGuid;
-                m_hash = other.m_hash;
-            }
-            return *this;
-        }
-
         bool ProductDatabaseEntry::operator==(const ProductDatabaseEntry& other) const
         {
             //equivalence is when everything but the id is the same
@@ -1393,13 +1394,14 @@ namespace AzToolsFramework
                    m_subID == other.m_subID &&
                    m_assetType == other.m_assetType &&
                    m_hash == other.m_hash &&
-                   AzFramework::StringFunc::Equal(m_productName.c_str(), other.m_productName.c_str());//don't compare legacy guid
+                   AzFramework::StringFunc::Equal(m_productName.c_str(), other.m_productName.c_str()) &&
+                   m_flags == other.m_flags;//don't compare legacy guid
         }
 
         AZStd::string ProductDatabaseEntry::ToString() const
         {
-            return AZStd::string::format("ProductDatabaseEntry id:%" PRId64 " jobpk: %" PRId64 " subid: %i productname: %s assettype: %s hash: %" PRId64,
-                                         static_cast<int64_t>(m_productID), static_cast<int64_t>(m_jobPK), m_subID, m_productName.c_str(), m_assetType.ToString<AZStd::string>().c_str(), static_cast<int64_t>(m_hash));
+            return AZStd::string::format("ProductDatabaseEntry id:%" PRId64 " jobpk: %" PRId64 " subid: %i productname: %s assettype: %s hash: %" PRId64 " flags: %" PRId64,
+                                         static_cast<int64_t>(m_productID), static_cast<int64_t>(m_jobPK), m_subID, m_productName.c_str(), m_assetType.ToString<AZStd::string>().c_str(), static_cast<int64_t>(m_hash), static_cast<int64_t>(m_flags.to_ullong()));
         }
 
         auto ProductDatabaseEntry::GetColumns()
@@ -1411,7 +1413,8 @@ namespace AzToolsFramework
                 SQLite::MakeColumn("SubID", m_subID),
                 SQLite::MakeColumn("AssetType", m_assetType),
                 SQLite::MakeColumn("LegacyGuid", m_legacyGuid),
-                SQLite::MakeColumn("Hash", m_hash)
+                SQLite::MakeColumn("Hash", m_hash),
+                SQLite::MakeColumn("Flags", m_flags)
             );
         }
 
@@ -1788,6 +1791,7 @@ namespace AzToolsFramework
             AddStatement(m_databaseConnection, s_querySourceBySourcename);
             AddStatement(m_databaseConnection, s_querySourceBySourcenameScanfolderid);
             AddStatement(m_databaseConnection, s_querySourceLikeSourcename);
+            AddStatement(m_databaseConnection, s_querySourceLikeSourcenameScanfolder);
             AddStatement(m_databaseConnection, s_querySourceAnalysisFingerprint);
             AddStatement(m_databaseConnection, s_querySourcesAndScanfolders);
 
@@ -1870,7 +1874,7 @@ namespace AzToolsFramework
 
             AddStatement(m_databaseConnection, s_queryFileByFileid);
             AddStatement(m_databaseConnection, s_queryFilesByFileName);
-            AddStatement(m_databaseConnection, s_queryFilesLikeFileName);
+            AddStatement(m_databaseConnection, s_queryFilesLikeFileNameScanfolderid);
             AddStatement(m_databaseConnection, s_queryFilesByScanfolderid);
             AddStatement(m_databaseConnection, s_queryFileByFileNameScanfolderid);
 
@@ -2111,6 +2115,14 @@ namespace AzToolsFramework
             return s_querySourceLikeSourcename.BindAndQuery(*m_databaseConnection, handler, &GetSourceResult, actualSearchTerm.c_str());
         }
 
+        bool AssetDatabaseConnection::QuerySourceLikeSourceNameScanFolderID(
+            const char* likeSourceName, AZ::s64 scanFolderID, LikeType likeType, sourceHandler handler)
+        {
+            AZStd::string actualSearchTerm = GetLikeActualSearchTerm(likeSourceName, likeType);
+
+            return s_querySourceLikeSourcenameScanfolder.BindAndQuery(*m_databaseConnection, handler, &GetSourceResult, actualSearchTerm.c_str(), scanFolderID);
+        }
+
         bool AssetDatabaseConnection::QueryJobByJobID(AZ::s64 jobid, jobHandler handler)
         {
             return s_queryJobByJobid.BindAndQuery(*m_databaseConnection, handler, &GetJobResultSimple, jobid);
@@ -2612,11 +2624,11 @@ namespace AzToolsFramework
             return s_queryFilesByFileName.BindAndQuery(*m_databaseConnection, handler, &GetFileResult, scanFolderID, fileName);
         }
 
-        bool AssetDatabaseConnection::QueryFilesLikeFileName(const char* likeFileName, LikeType likeType, fileHandler handler)
+        bool AssetDatabaseConnection::QueryFilesLikeFileNameAndScanFolderID(const char* likeFileName, LikeType likeType, AZ::s64 scanFolderID, fileHandler handler)
         {
             AZStd::string actualSearchTerm = GetLikeActualSearchTerm(likeFileName, likeType);
 
-            return s_queryFilesLikeFileName.BindAndQuery(*m_databaseConnection, handler, &GetFileResult, actualSearchTerm.c_str());
+            return s_queryFilesLikeFileNameScanfolderid.BindAndQuery(*m_databaseConnection, handler, &GetFileResult, actualSearchTerm.c_str(), scanFolderID);
         }
 
         bool AssetDatabaseConnection::QueryFilesByScanFolderID(AZ::s64 scanFolderID, fileHandler handler)

+ 7 - 7
Code/Framework/AzToolsFramework/AzToolsFramework/AssetDatabase/AssetDatabaseConnection.h

@@ -67,6 +67,7 @@ namespace AzToolsFramework
             RemoveOutputPrefixFromScanFolders,
             AddedSourceIndexForSourceDependencyTable,
             AddedSourceDependencySubIdsAndProductHashes,
+            AddedFlagsColumnToProductTable,
             //Add all new versions before this
             DatabaseVersionCount,
             LatestVersion = DatabaseVersionCount - 1
@@ -232,16 +233,13 @@ namespace AzToolsFramework
         public:
             ProductDatabaseEntry() = default;
             ProductDatabaseEntry(AZ::s64 productID, AZ::s64 jobPK,  AZ::u32 subID, const char* productName,
-                AZ::Data::AssetType assetType, AZ::Uuid legacyGuid = AZ::Uuid::CreateNull(), AZ::u64 hash = 0);
+                AZ::Data::AssetType assetType, AZ::Uuid legacyGuid = AZ::Uuid::CreateNull(), AZ::u64 hash = 0, AZStd::bitset<64> flags = 0);
             ProductDatabaseEntry(AZ::s64 jobPK, AZ::u32 subID, const char* productName,
-                AZ::Data::AssetType assetType, AZ::Uuid legacyGuid = AZ::Uuid::CreateNull(), AZ::u64 hash = 0);
-            ProductDatabaseEntry(ProductDatabaseEntry&& other);
+                AZ::Data::AssetType assetType, AZ::Uuid legacyGuid = AZ::Uuid::CreateNull(), AZ::u64 hash = 0, AZStd::bitset<64> flags = 0);
+            AZ_DEFAULT_COPY_MOVE(ProductDatabaseEntry);
 
-            ProductDatabaseEntry& operator=(ProductDatabaseEntry&& other);
             bool operator==(const ProductDatabaseEntry& other) const;
 
-            AZ_DEFAULT_COPY(ProductDatabaseEntry);
-
             AZStd::string ToString() const;
             auto GetColumns();
 
@@ -252,6 +250,7 @@ namespace AzToolsFramework
             AZ::Data::AssetType m_assetType = AZ::Data::AssetType::CreateNull();
             AZ::Uuid m_legacyGuid = AZ::Uuid::CreateNull();//used only for backward compatibility with old product guid, is generated based on product name
             AZ::u64 m_hash = 0;
+            AZStd::bitset<64> m_flags = 0;
         };
         typedef AZStd::vector<ProductDatabaseEntry> ProductDatabaseEntryContainer;
 
@@ -533,6 +532,7 @@ namespace AzToolsFramework
             bool QuerySourceBySourceName(const char* exactSourceName, sourceHandler handler);
             bool QuerySourceBySourceNameScanFolderID(const char* exactSourceName, AZ::s64 scanFolderID, sourceHandler handler);
             bool QuerySourceLikeSourceName(const char* likeSourceName, LikeType likeType, sourceHandler handler);
+            bool QuerySourceLikeSourceNameScanFolderID(const char* likeSourceName, AZ::s64 scanFolderID, LikeType likeType, sourceHandler handler);
             bool QuerySourceAnalysisFingerprint(const char* exactSourceName, AZ::s64 scanFolderID, AZStd::string& result);
             bool QuerySourceAndScanfolder(combinedSourceScanFolderHandler handler);
 
@@ -632,7 +632,7 @@ namespace AzToolsFramework
             //FileInfo
             bool QueryFileByFileID(AZ::s64 fileID, fileHandler handler);
             bool QueryFilesByFileNameAndScanFolderID(const char* fileName, AZ::s64 scanfolderID, fileHandler handler);
-            bool QueryFilesLikeFileName(const char* likeFileName, LikeType likeType, fileHandler handler);
+            bool QueryFilesLikeFileNameAndScanFolderID(const char* likeFileName, LikeType likeType, AZ::s64 scanfolderID, fileHandler handler);
             bool QueryFilesByScanFolderID(AZ::s64 scanFolderID, fileHandler handler);
             bool QueryFileByFileNameScanFolderID(const char* fileName, AZ::s64 scanFolderID, fileHandler handler);
             //////////////////////////////////////////////////////////////////////////

+ 1 - 1
Code/Framework/AzToolsFramework/AzToolsFramework/ToolsFileUtils/ToolsFileUtils_generic.cpp

@@ -39,7 +39,7 @@ namespace AzToolsFramework
         bool GetFreeDiskSpace(const QString& path, qint64& outFreeDiskSpace)
         {
             QStorageInfo storageInfo(path);
-            outFreeDiskSpace = storageInfo.bytesFree();
+            outFreeDiskSpace = storageInfo.bytesAvailable();
 
             return outFreeDiskSpace >= 0;
         }

+ 17 - 10
Code/Tools/AssetProcessor/AssetBuilderSDK/AssetBuilderSDK/AssetBuilderSDK.cpp

@@ -1059,15 +1059,18 @@ namespace AssetBuilderSDK
     {
         if (AZ::SerializeContext* serializeContext = azrtti_cast<AZ::SerializeContext*>(context))
         {
-            serializeContext->Class<JobProduct>()->
-                Version(6)->
-                Field("Product File Name", &JobProduct::m_productFileName)->
-                Field("Product Asset Type", &JobProduct::m_productAssetType)->
-                Field("Product Sub Id", &JobProduct::m_productSubID)->
-                Field("Legacy Sub Ids", &JobProduct::m_legacySubIDs)->
-                Field("Dependencies", &JobProduct::m_dependencies)->
-                Field("Relative Path Dependencies", &JobProduct::m_pathDependencies)->
-                Field("Dependencies Handled", &JobProduct::m_dependenciesHandled);
+            serializeContext->Class<JobProduct>()
+                ->Version(7)
+                ->Field("Product File Name", &JobProduct::m_productFileName)
+                ->Field("Product Asset Type", &JobProduct::m_productAssetType)
+                ->Field("Product Sub Id", &JobProduct::m_productSubID)
+                ->Field("Legacy Sub Ids", &JobProduct::m_legacySubIDs)
+                ->Field("Dependencies", &JobProduct::m_dependencies)
+                ->Field("Relative Path Dependencies", &JobProduct::m_pathDependencies)
+                ->Field("Dependencies Handled", &JobProduct::m_dependenciesHandled)
+                ->Field("Output Flags", &JobProduct::m_outputFlags)
+                ->Field("Output Path Override", &JobProduct::m_outputPathOverride)
+            ;
 
             serializeContext->RegisterGenericType<AZStd::vector<JobProduct>>();
         }
@@ -1084,7 +1087,11 @@ namespace AssetBuilderSDK
                 ->Property("productSubID", BehaviorValueProperty(&JobProduct::m_productSubID))
                 ->Property("productDependencies", BehaviorValueProperty(&JobProduct::m_dependencies))
                 ->Property("pathDependencies", BehaviorValueProperty(&JobProduct::m_pathDependencies))
-                ->Property("dependenciesHandled", BehaviorValueProperty(&JobProduct::m_dependenciesHandled));
+                ->Property("dependenciesHandled", BehaviorValueProperty(&JobProduct::m_dependenciesHandled))
+                ->Property("outputFlags", BehaviorValueProperty(&JobProduct::m_outputFlags))
+                ->Property("outputPathOverride", BehaviorValueProperty(&JobProduct::m_outputPathOverride))
+                ->Enum<aznumeric_cast<int>(ProductOutputFlags::ProductAsset)>("ProductAsset")
+                ->Enum<aznumeric_cast<int>(ProductOutputFlags::IntermediateAsset)>("IntermediateAsset")
             ;
         }
     }

+ 26 - 0
Code/Tools/AssetProcessor/AssetBuilderSDK/AssetBuilderSDK/AssetBuilderSDK.h

@@ -109,6 +109,8 @@ namespace AssetBuilderSDK
     extern const char* const s_processJobRequestFileName; //!< File name for having job requests send from the Asset Processor.
     extern const char* const s_processJobResponseFileName; //!< File name for having job responses returned to the Asset Processor.
 
+    constexpr const char* CommonPlatformName = "common"; // Use for platform-agnostic jobs
+
     // SubIDs uniquely identify a particular output product of a specific source asset
     // currently we use a scheme where various bits of the subId (which is a 32 bit unsigned) are used to designate different things.
     // we may expand this into a 64-bit "namespace" by adding additional 32 bits at the front at some point, if it becomes necessary.
@@ -624,6 +626,17 @@ namespace AssetBuilderSDK
         static void Reflect(AZ::ReflectContext* context);
     };
 
+    // A set of bit flags for a JobProduct
+    enum class ProductOutputFlags : AZ::u32
+    {
+        // Indicates this JobProduct is a product asset which should be output to the cache.  This is the default.
+        // Currently it is not supported to use this with IntermediateAsset since the Common platform is required for IntermediateAsset and not yet supported for ProductAsset.
+        ProductAsset = 1,
+        IntermediateAsset = 2 // Indicates this JobProduct is an intermediate asset which should be output to the intermediate asset folder.  Must be used with the Common platform.
+    };
+
+    AZ_DEFINE_ENUM_BITWISE_OPERATORS(ProductOutputFlags);
+
     using ProductPathDependencySet = AZStd::unordered_set<AssetBuilderSDK::ProductPathDependency>;
 
     //! JobProduct is used by the builder to store job product information
@@ -673,6 +686,18 @@ namespace AssetBuilderSDK
         /// When false, AP will emit a warning that dependencies have not been handled.
         bool m_dependenciesHandled{ false };
 
+        /// Bit flags for the output product
+        ProductOutputFlags m_outputFlags = ProductOutputFlags::ProductAsset;
+
+        /// Scan-folder relative path to use for the output product instead of the default.  An empty string will use the default pathing rules.
+        /// Only allowed for products with the IntermediateAsset flag.
+        /// Example:
+        /// Input: game/examples/example.shader
+        /// Output: IntermediateAsset - example.pdb
+        /// By default the product would output to <IntermediateAssetsFolder>/game/examples/example.pdb
+        /// With a PathOverride of shaders/debug the product is instead written to <IntermediateAssetsFolder>/shaders/debug
+        AZStd::string m_outputPathOverride;
+
         JobProduct() = default;
         JobProduct(const AZStd::string& productName, AZ::Data::AssetType productAssetType = AZ::Data::AssetType::CreateNull(), AZ::u32 productSubID = 0);
         JobProduct(AZStd::string&& productName, AZ::Data::AssetType productAssetType = AZ::Data::AssetType::CreateNull(), AZ::u32 productSubID = 0);
@@ -827,6 +852,7 @@ namespace AZ
     AZ_TYPE_INFO_SPECIALIZE(AssetBuilderSDK::ProcessJobResultCode, "{15797D63-4980-436A-9DE1-E0CCA9B5DB19}");
     AZ_TYPE_INFO_SPECIALIZE(AssetBuilderSDK::ProductPathDependencyType, "{EF77742B-9627-4072-B431-396AA7183C80}");
     AZ_TYPE_INFO_SPECIALIZE(AssetBuilderSDK::SourceFileDependency::SourceFileDependencyType, "{BE9C8805-DB17-4500-944A-EB33FD0BE347}");
+    AZ_TYPE_INFO_SPECIALIZE(AssetBuilderSDK::ProductOutputFlags, "{247B6AEB-D92E-40BE-B741-70E18DE5F888}");
 }
 
 namespace AZStd

+ 2 - 0
Code/Tools/AssetProcessor/assetprocessor_static_files.cmake

@@ -13,6 +13,8 @@ set(FILES
     native/AssetManager/AssetCatalog.h
     native/AssetManager/assetProcessorManager.cpp
     native/AssetManager/assetProcessorManager.h
+    native/AssetManager/ProductAsset.h
+    native/AssetManager/ProductAsset.cpp
     native/AssetManager/AssetRequestHandler.cpp
     native/AssetManager/AssetRequestHandler.h
     native/AssetManager/assetScanFolderInfo.h

+ 4 - 0
Code/Tools/AssetProcessor/assetprocessor_test_files.cmake

@@ -39,6 +39,10 @@ set(FILES
     native/tests/assetmanager/MockFileProcessor.cpp
     native/tests/assetmanager/TestEventSignal.cpp
     native/tests/assetmanager/TestEventSignal.h
+    native/tests/assetmanager/AssetManagerTestingBase.cpp
+    native/tests/assetmanager/AssetManagerTestingBase.h
+    native/tests/assetmanager/IntermediateAssetTests.cpp
+    native/tests/assetmanager/IntermediateAssetTests.h
     native/tests/utilities/assetUtilsTest.cpp
     native/tests/platformconfiguration/platformconfigurationtests.cpp
     native/tests/platformconfiguration/platformconfigurationtests.h

+ 52 - 8
Code/Tools/AssetProcessor/native/AssetDatabase/AssetDatabase.cpp

@@ -94,6 +94,7 @@ namespace AssetProcessor
             "    AssetType      BLOB NOT NULL, "
             "    LegacyGuid     BLOB NOT NULL, "
             "    Hash           INTEGER NOT NULL, "
+            "    Flags          INTEGER NOT NULL DEFAULT 1, "
             "    FOREIGN KEY (JobPK) REFERENCES "
             "       Jobs(JobID) ON DELETE CASCADE);";
 
@@ -396,8 +397,8 @@ namespace AssetProcessor
 
         static const char* INSERT_PRODUCT = "AssetProcessor::InsertProduct";
         static const char* INSERT_PRODUCT_STATEMENT =
-            "INSERT INTO Products (JobPK, SubID, ProductName, AssetType, LegacyGuid, Hash) "
-            "VALUES (:jobid, :subid, :productname, :assettype, :legacyguid, :hash);";
+            "INSERT INTO Products (JobPK, SubID, ProductName, AssetType, LegacyGuid, Hash, Flags) "
+            "VALUES (:jobid, :subid, :productname, :assettype, :legacyguid, :hash, :flags);";
 
         static const auto s_InsertProductQuery = MakeSqlQuery(INSERT_PRODUCT, INSERT_PRODUCT_STATEMENT, LOG_NAME,
             SqlParam<AZ::s64>(":jobid"),
@@ -405,7 +406,8 @@ namespace AssetProcessor
             SqlParam<const char*>(":productname"),
             SqlParam<AZ::Uuid>(":assettype"),
             SqlParam<AZ::Uuid>(":legacyguid"),
-            SqlParam<AZ::u64>(":hash"));
+            SqlParam<AZ::u64>(":hash"),
+            SqlParam<AZ::u64>(":flags"));
 
         static const char* UPDATE_PRODUCT = "AssetProcessor::UpdateProduct";
         static const char* UPDATE_PRODUCT_STATEMENT =
@@ -415,7 +417,8 @@ namespace AssetProcessor
             "ProductName = :productname, "
             "AssetType = :assettype, "
             "LegacyGuid = :legacyguid, "
-            "Hash = :hash "
+            "Hash = :hash, "
+            "Flags = :flags "
             "WHERE ProductID = :productid;";
 
         static const auto s_UpdateProductQuery = MakeSqlQuery(UPDATE_PRODUCT, UPDATE_PRODUCT_STATEMENT, LOG_NAME,
@@ -424,6 +427,7 @@ namespace AssetProcessor
             SqlParam<const char*>(":productname"),
             SqlParam<AZ::Uuid>(":assettype"),
             SqlParam<AZ::Uuid>(":legacyguid"),
+            SqlParam<AZ::u64>(":flags"),
             SqlParam<AZ::s64>(":productid"),
             SqlParam<AZ::u64>(":hash"));
 
@@ -782,6 +786,12 @@ namespace AssetProcessor
         static const char* INSERT_COLUMN_SOURCEDEPENDENCY_SUBIDS_STATEMENT =
             "ALTER TABLE SourceDependency "
             "ADD SubIds TEXT NOT NULL collate nocase default('');";
+
+        static const char* INSERT_COLUMN_PRODUCTS_FLAGS = "AssetProcessor::InsertColumnProductsFlags";
+        static const char* INSERT_COLUMN_PRODUCTS_FLAGS_STATEMENT =
+            "ALTER TABLE Products "
+            "ADD Flags INTEGER NOT NULL DEFAULT 1;";
+
     }
 
     AssetDatabaseConnection::AssetDatabaseConnection()
@@ -1075,6 +1085,15 @@ namespace AssetProcessor
             }
         }
 
+        if(foundVersion == AssetDatabase::DatabaseVersion::AddedSourceDependencySubIdsAndProductHashes)
+        {
+            if(m_databaseConnection->ExecuteOneOffStatement(INSERT_COLUMN_PRODUCTS_FLAGS))
+            {
+                foundVersion = AssetDatabase::DatabaseVersion::AddedFlagsColumnToProductTable;
+                AZ_TracePrintf(AssetProcessor::ConsoleChannel, "Upgraded Asset Database to version %i (AddedFlagsColumnToProductTable)\n", foundVersion);
+            }
+        }
+
         if (foundVersion == CurrentDatabaseVersion())
         {
             dropAllTables = false;
@@ -1218,6 +1237,7 @@ namespace AssetProcessor
         // ---------------------------------------------------------------------------------------------
         m_databaseConnection->AddStatement(CREATE_PRODUCT_TABLE, CREATE_PRODUCT_TABLE_STATEMENT);
         m_databaseConnection->AddStatement(INSERT_COLUMN_PRODUCT_HASH, INSERT_COLUMN_PRODUCT_HASH_STATEMENT);
+        m_databaseConnection->AddStatement(INSERT_COLUMN_PRODUCTS_FLAGS, INSERT_COLUMN_PRODUCTS_FLAGS_STATEMENT);
         m_createStatements.push_back(CREATE_PRODUCT_TABLE);
 
         AddStatement(m_databaseConnection, s_InsertProductQuery);
@@ -1622,6 +1642,30 @@ namespace AssetProcessor
         return  found && succeeded;
     }
 
+    bool AssetDatabaseConnection::GetSourcesLikeSourceNameScanFolderId(
+        QString likeSourceName,
+        AZ::s64 scanFolderID,
+        LikeType likeType,
+        AzToolsFramework::AssetDatabase::SourceDatabaseEntryContainer& container)
+    {
+        if (likeSourceName.isEmpty())
+        {
+            return false;
+        }
+
+        bool found = false;
+        bool succeeded = QuerySourceLikeSourceNameScanFolderID(
+            likeSourceName.toUtf8().constData(), scanFolderID, likeType,
+            [&](SourceDatabaseEntry& source)
+            {
+                found = true;
+                container.push_back();
+                container.back() = AZStd::move(source);
+                return true; // return true to continue iterating over additional results, we are populating a container
+            });
+        return found && succeeded;
+    }
+
     bool AssetDatabaseConnection::GetSourceByJobID(AZ::s64 jobID, SourceDatabaseEntry& entry)
     {
         bool found = false;
@@ -2297,7 +2341,7 @@ namespace AssetProcessor
             if (wasAlreadyInDatabase)
             {
                 // it was already in the database, so use the "UPDATE" version
-                if (!s_UpdateProductQuery.Bind(*m_databaseConnection, autoFinalizer, entry.m_jobPK, entry.m_subID, entry.m_productName.c_str(), entry.m_assetType, entry.m_legacyGuid, entry.m_productID, entry.m_hash))
+                if (!s_UpdateProductQuery.Bind(*m_databaseConnection, autoFinalizer, entry.m_jobPK, entry.m_subID, entry.m_productName.c_str(), entry.m_assetType, entry.m_legacyGuid, entry.m_flags.to_ullong(), entry.m_productID, entry.m_hash))
                 {
                     return false;
                 }
@@ -2305,7 +2349,7 @@ namespace AssetProcessor
             else
             {
                 // it wasn't in the database, so use the "INSERT" version
-                if (!s_InsertProductQuery.Bind(*m_databaseConnection, autoFinalizer, entry.m_jobPK, entry.m_subID, entry.m_productName.c_str(), entry.m_assetType, entry.m_legacyGuid, entry.m_hash))
+                if (!s_InsertProductQuery.Bind(*m_databaseConnection, autoFinalizer, entry.m_jobPK, entry.m_subID, entry.m_productName.c_str(), entry.m_assetType, entry.m_legacyGuid, entry.m_hash, entry.m_flags.to_ullong()))
                 {
                     return false;
                 }
@@ -3103,10 +3147,10 @@ namespace AssetProcessor
         return found && succeeded;
     }
 
-    bool AssetDatabaseConnection::GetFilesLikeFileName(QString likeFileName, LikeType likeType, FileDatabaseEntryContainer& container)
+    bool AssetDatabaseConnection::GetFilesLikeFileNameScanFolderId(QString likeFileName, LikeType likeType, AZ::s64 scanFolderId, FileDatabaseEntryContainer& container)
     {
         bool found = false;
-        bool succeeded = QueryFilesLikeFileName(likeFileName.toUtf8().constData(), likeType,
+        bool succeeded = QueryFilesLikeFileNameAndScanFolderID(likeFileName.toUtf8().constData(), likeType, scanFolderId,
             [&](FileDatabaseEntry& file)
         {
             found = true;

+ 13 - 12
Code/Tools/AssetProcessor/native/AssetDatabase/AssetDatabase.h

@@ -39,9 +39,9 @@ namespace AssetProcessor
         // AzToolsFramework::AssetDatabase::Connection
     public:
         bool IsReadOnly() const override
-        { 
+        {
             return false;// return false, we actually curate/write to this database.
-        } 
+        }
         void VacuumAndAnalyze();
 
     protected:
@@ -87,6 +87,7 @@ namespace AssetProcessor
         bool GetSourcesBySourceName(QString exactSourceName, AzToolsFramework::AssetDatabase::SourceDatabaseEntryContainer& source);
         bool GetSourcesBySourceNameScanFolderId(QString exactSourceName, AZ::s64 scanFolderID, AzToolsFramework::AssetDatabase::SourceDatabaseEntryContainer& source);
         bool GetSourcesLikeSourceName(QString likeSourceName, LikeType likeType, AzToolsFramework::AssetDatabase::SourceDatabaseEntryContainer& container);
+        bool GetSourcesLikeSourceNameScanFolderId(QString likeSourceName, AZ::s64 scanFolderID, LikeType likeType, AzToolsFramework::AssetDatabase::SourceDatabaseEntryContainer& container);
 
         bool GetSourceByJobID(AZ::s64 jobID, AzToolsFramework::AssetDatabase::SourceDatabaseEntry& entry);
 
@@ -102,9 +103,9 @@ namespace AssetProcessor
         bool InvalidateSourceAnalysisFingerprints();
 
         //jobs
-        
+
         // used to initialize the predictor for job Run Keys
-        AZ::s64 GetHighestJobRunKey(); 
+        AZ::s64 GetHighestJobRunKey();
         bool GetJobs(AzToolsFramework::AssetDatabase::JobDatabaseEntryContainer& container, AZ::Uuid builderGuid = AZ::Uuid::CreateNull(), QString jobKey = QString(), QString platform = QString(), AzToolsFramework::AssetSystem::JobStatus status = AzToolsFramework::AssetSystem::JobStatus::Any);
         bool GetJobByJobID(AZ::s64 jobID, AzToolsFramework::AssetDatabase::JobDatabaseEntry& entry);
         bool GetJobByProductID(AZ::s64 productID, AzToolsFramework::AssetDatabase::JobDatabaseEntry& entry);
@@ -115,7 +116,7 @@ namespace AssetProcessor
 
         bool GetJobsByProductName(QString exactProductName, AzToolsFramework::AssetDatabase::JobDatabaseEntryContainer& container, AZ::Uuid builderGuid = AZ::Uuid::CreateNull(), QString jobKey = QString(), QString platform = QString(), AzToolsFramework::AssetSystem::JobStatus status = AzToolsFramework::AssetSystem::JobStatus::Any);
         bool GetJobsLikeProductName(QString likeProductName, LikeType likeType, AzToolsFramework::AssetDatabase::JobDatabaseEntryContainer& container, AZ::Uuid builderGuid = AZ::Uuid::CreateNull(), QString jobKey = QString(), QString platform = QString(), AzToolsFramework::AssetSystem::JobStatus status = AzToolsFramework::AssetSystem::JobStatus::Any);
-        
+
         bool SetJob(AzToolsFramework::AssetDatabase::JobDatabaseEntry& entry); //on success sets jobID, if it already exists updates it
         bool RemoveJob(AZ::s64 jobID);
         bool RemoveJobs(AzToolsFramework::AssetDatabase::JobDatabaseEntryContainer& container);
@@ -127,15 +128,15 @@ namespace AssetProcessor
         // note that the pair of (JobID, SubID) uniquely identifies a single job, and thus the result is always only one entry:
         bool GetProductByJobIDSubId(AZ::s64 jobID, AZ::u32 subID, AzToolsFramework::AssetDatabase::ProductDatabaseEntry& result);
         bool GetProductBySourceGuidSubId(AZ::Uuid sourceGuid, AZ::u32 subId, AzToolsFramework::AssetDatabase::ProductDatabaseEntry& result);
-        
+
         bool GetProductByProductID(AZ::s64 productID, AzToolsFramework::AssetDatabase::ProductDatabaseEntry& entry);
         bool GetProductsByProductName(QString exactProductName, AzToolsFramework::AssetDatabase::ProductDatabaseEntryContainer& container, AZ::Uuid builderGuid = AZ::Uuid::CreateNull(), QString jobKey = QString(), QString platform = QString(), AzToolsFramework::AssetSystem::JobStatus status = AzToolsFramework::AssetSystem::JobStatus::Any);
         bool GetProductsLikeProductName(QString likeProductName, LikeType likeType, AzToolsFramework::AssetDatabase::ProductDatabaseEntryContainer& container, AZ::Uuid builderGuid = AZ::Uuid::CreateNull(), QString jobKey = QString(), QString platform = QString(), AzToolsFramework::AssetSystem::JobStatus status = AzToolsFramework::AssetSystem::JobStatus::Any);
-        
+
         bool GetProductsBySourceID(AZ::s64 sourceID, AzToolsFramework::AssetDatabase::ProductDatabaseEntryContainer& container, AZ::Uuid builderGuid = AZ::Uuid::CreateNull(), QString jobKey = QString(), QString platform = QString(), AzToolsFramework::AssetSystem::JobStatus status = AzToolsFramework::AssetSystem::JobStatus::Any);
         bool GetProductsBySourceName(QString exactSourceName, AzToolsFramework::AssetDatabase::ProductDatabaseEntryContainer& container, AZ::Uuid builderGuid = AZ::Uuid::CreateNull(), QString jobKey = QString(), QString platform = QString(), AzToolsFramework::AssetSystem::JobStatus status = AzToolsFramework::AssetSystem::JobStatus::Any);
         bool GetProductsLikeSourceName(QString likeSourceName, LikeType likeType, AzToolsFramework::AssetDatabase::ProductDatabaseEntryContainer& container, AZ::Uuid builderGuid = AZ::Uuid::CreateNull(), QString jobKey = QString(), QString platform = QString(), AzToolsFramework::AssetSystem::JobStatus status = AzToolsFramework::AssetSystem::JobStatus::Any);
-        
+
         bool SetProduct(AzToolsFramework::AssetDatabase::ProductDatabaseEntry& entry); //on success sets productID, if it already exists updates it
         bool SetProducts(AzToolsFramework::AssetDatabase::ProductDatabaseEntryContainer& container); //on success sets productID, if it already exists updates it
         bool RemoveProducts(AzToolsFramework::AssetDatabase::ProductDatabaseEntryContainer& container);
@@ -175,7 +176,7 @@ namespace AssetProcessor
         bool GetSourceFileDependenciesByBuilderGUIDAndSource(const AZ::Uuid& builderGuid, const char* source, AzToolsFramework::AssetDatabase::SourceFileDependencyEntry::TypeOfDependency typeOfDependency, AzToolsFramework::AssetDatabase::SourceFileDependencyEntryContainer& container);
         /// Given a source file, what depends ON IT? ('reverse dependency')
         bool GetSourceFileDependenciesByDependsOnSource(const QString& dependsOnSource, AzToolsFramework::AssetDatabase::SourceFileDependencyEntry::TypeOfDependency typeOfDependency, AzToolsFramework::AssetDatabase::SourceFileDependencyEntryContainer& container);
-        
+
         // --------------------- Legacy SUBID table -------------------
         bool CreateOrUpdateLegacySubID(AzToolsFramework::AssetDatabase::LegacySubIDsEntry& entry);  // create or overwrite operation.
         bool RemoveLegacySubID(AZ::s64 legacySubIDsEntryID);
@@ -210,16 +211,16 @@ namespace AssetProcessor
         // bulk replace builder info table with new builder info table.  Replaces the existing table of data.
         // Note:  newEntries will have their m_builderInfoID member set to their inserted rowId if this call succeeds.
         bool SetBuilderInfoTable(AzToolsFramework::AssetDatabase::BuilderInfoEntryContainer& newEntries);
- 
+
         //Files
         bool GetFileByFileID(AZ::s64 fileID, AzToolsFramework::AssetDatabase::FileDatabaseEntry& entry);
         bool GetFileByFileNameAndScanFolderId(QString fileName, AZ::s64 scanFolderId, AzToolsFramework::AssetDatabase::FileDatabaseEntry& entry);
-        bool GetFilesLikeFileName(QString likeFileName, LikeType likeType, AzToolsFramework::AssetDatabase::FileDatabaseEntryContainer& container);
+        bool GetFilesLikeFileNameScanFolderId(QString likeFileName, LikeType likeType, AZ::s64 scanFolderId, AzToolsFramework::AssetDatabase::FileDatabaseEntryContainer& container);
 
         bool InsertFiles(AzToolsFramework::AssetDatabase::FileDatabaseEntryContainer& entry);
         bool InsertFile(AzToolsFramework::AssetDatabase::FileDatabaseEntry& entry, bool& entryAlreadyExists);
         bool UpdateFile(AzToolsFramework::AssetDatabase::FileDatabaseEntry& entry, bool& entryAlreadyExists);
-        
+
         // updates the modtime and hash for a file if it exists.  Only returns true if the row existed and was successfully updated
         bool UpdateFileModTimeAndHashByFileNameAndScanFolderId(QString fileName, AZ::s64 scanFolderId, AZ::u64 modTime, AZ::u64 hash);
         bool RemoveFile(AZ::s64 sourceID);

+ 22 - 16
Code/Tools/AssetProcessor/native/AssetManager/AssetCatalog.cpp

@@ -30,6 +30,12 @@ namespace AssetProcessor
 
         for (const AssetBuilderSDK::PlatformInfo& info : m_platformConfig->GetEnabledPlatforms())
         {
+            if (info.m_identifier == AssetBuilderSDK::CommonPlatformName)
+            {
+                // Currently the Common platform is not supported as a product asset platform
+                continue;
+            }
+
             m_platforms.push_back(QString::fromUtf8(info.m_identifier.c_str()));
         }
 
@@ -42,8 +48,6 @@ namespace AssetProcessor
 
         AssetUtilities::ComputeProjectPath();
 
-        AssetUtilities::ComputeProjectCacheRoot(m_cacheRootDir);
-
         if (!ConnectToDatabase())
         {
             AZ_Error("AssetCatalog", false, "Failed to connect to sqlite database");
@@ -1211,8 +1215,7 @@ namespace AssetProcessor
             AssetUtilities::ComputeProjectCacheRoot(cacheRoot);
             QString normalizedCacheRoot = AssetUtilities::NormalizeFilePath(cacheRoot.path());
 
-            bool inCacheFolder = normalizedSourceOrProductPath.startsWith(normalizedCacheRoot, Qt::CaseInsensitive);
-            if (inCacheFolder)
+            if (AssetUtilities::IsInCacheFolder(normalizedSourceOrProductPath.toUtf8().constData(), cacheRoot.absolutePath().toUtf8().constData()))
             {
                 // The path send by the game/editor contains the cache root so we try to find the asset id
                 // from the asset database
@@ -1275,28 +1278,25 @@ namespace AssetProcessor
     void AssetCatalog::ProcessGetFullSourcePathFromRelativeProductPathRequest(const AZStd::string& relPath, AZStd::string& fullSourcePath)
     {
         QString assetPath = relPath.c_str();
-        QString normalisedAssetPath = AssetUtilities::NormalizeFilePath(assetPath);
+        QString normalizedAssetPath = AssetUtilities::NormalizeFilePath(assetPath);
         int resultCode = 0;
         QString fullAssetPath;
 
-        if (normalisedAssetPath.isEmpty())
+        if (normalizedAssetPath.isEmpty())
         {
             fullSourcePath = "";
             return;
         }
 
-        QDir inputPath(normalisedAssetPath);
+        QDir inputPath(normalizedAssetPath);
 
         if (inputPath.isAbsolute())
         {
-            bool inCacheFolder = false;
             QDir cacheRoot;
             AssetUtilities::ComputeProjectCacheRoot(cacheRoot);
             QString normalizedCacheRoot = AssetUtilities::NormalizeFilePath(cacheRoot.path());
-            //Check to see whether the path contains the cache root
-            inCacheFolder = normalisedAssetPath.startsWith(normalizedCacheRoot, Qt::CaseInsensitive);
 
-            if (!inCacheFolder)
+            if (!AssetUtilities::IsInCacheFolder(normalizedAssetPath.toUtf8().constData(), cacheRoot.absolutePath().toUtf8().constData()))
             {
                 // Attempt to convert to relative path
                 QString dummy, convertedRelPath;
@@ -1317,16 +1317,16 @@ namespace AssetProcessor
             else
             {
                 // The path send by the game/editor contains the cache root ,try to find the productName from it
-                normalisedAssetPath.remove(0, normalizedCacheRoot.length() + 1); // adding 1 for the native separator
+                normalizedAssetPath.remove(0, normalizedCacheRoot.length() + 1); // adding 1 for the native separator
             }
         }
 
         if (!resultCode)
         {
             //remove aliases if present
-            normalisedAssetPath = AssetUtilities::NormalizeAndRemoveAlias(normalisedAssetPath);
+            normalizedAssetPath = AssetUtilities::NormalizeAndRemoveAlias(normalizedAssetPath);
 
-            if (!normalisedAssetPath.isEmpty()) // this happens if it comes in as just for example "@products@/"
+            if (!normalizedAssetPath.isEmpty()) // this happens if it comes in as just for example "@products@/"
             {
                 AZStd::lock_guard<AZStd::mutex> lock(m_databaseMutex);
 
@@ -1336,8 +1336,14 @@ namespace AssetProcessor
                 QString productName;
                 for (const AssetBuilderSDK::PlatformInfo& platformInfo : platforms)
                 {
+                    if (platformInfo.m_identifier == AssetBuilderSDK::CommonPlatformName)
+                    {
+                        // Common platform is not supported for product assets currently
+                        continue;
+                    }
+
                     QString platformName = QString::fromUtf8(platformInfo.m_identifier.c_str());
-                    productName = AssetUtilities::GuessProductNameInDatabase(normalisedAssetPath, platformName, m_db.get());
+                    productName = AssetUtilities::GuessProductNameInDatabase(normalizedAssetPath, platformName, m_db.get());
                     if (!productName.isEmpty())
                     {
                         break;
@@ -1361,7 +1367,7 @@ namespace AssetProcessor
                 else
                 {
                     // if we are not able to guess the product name than maybe the asset path is an input name
-                    fullAssetPath = m_platformConfig->FindFirstMatchingFile(normalisedAssetPath);
+                    fullAssetPath = m_platformConfig->FindFirstMatchingFile(normalizedAssetPath);
                     if (!fullAssetPath.isEmpty())
                     {
                         resultCode = 1;

+ 6 - 8
Code/Tools/AssetProcessor/native/AssetManager/AssetCatalog.h

@@ -43,7 +43,7 @@ namespace AssetProcessor
 {
     class AssetDatabaseConnection;
 
-    class AssetCatalog 
+    class AssetCatalog
         : public QObject
         , private AssetRegistryRequestBus::Handler
         , private AzToolsFramework::AssetSystemRequestBus::Handler
@@ -53,7 +53,7 @@ namespace AssetProcessor
         using NetworkRequestID = AssetProcessor::NetworkRequestID;
         using BaseAssetProcessorMessage = AzFramework::AssetSystem::BaseAssetProcessorMessage;
         Q_OBJECT;
-    
+
     public:
         AssetCatalog(QObject* parent, AssetProcessor::PlatformConfiguration* platformConfiguration);
         virtual ~AssetCatalog();
@@ -75,7 +75,7 @@ namespace AssetProcessor
         void OnSourceQueued(AZ::Uuid sourceUuid, AZ::Uuid legacyUuid, QString rootPath, QString relativeFilePath);
         void OnSourceFinished(AZ::Uuid sourceUuid, AZ::Uuid legacyUuid);
         void AsyncAssetCatalogStatusRequest();
-        
+
     protected:
 
         //////////////////////////////////////////////////////////////////////////
@@ -135,13 +135,13 @@ namespace AssetProcessor
 
         //! Gets the source file info for an Asset by checking the DB first and the APM queue second
         bool GetSourceFileInfoFromAssetId(const AZ::Data::AssetId &assetId, AZStd::string& watchFolder, AZStd::string& relativePath);
-        
+
         //! Gets the product AssetInfo based on a platform and assetId.  If you specify a null or empty platform the current or first available will be used.
         AZ::Data::AssetInfo GetProductAssetInfo(const char* platformName, const AZ::Data::AssetId& id);
-        
+
         //! GetAssetInfo that tries to figure out if the asset is a product or source so it can return info about the product or source respectively
         bool GetAssetInfoByIdOnly(const AZ::Data::AssetId& id, const AZStd::string& platformName, AZ::Data::AssetInfo& assetInfo, AZStd::string& rootFilePath);
-        
+
         //! Checks in the currently-in-queue assets list for info on an asset (by source Id)
         bool GetQueuedAssetInfoById(const AZ::Uuid& guid, AZStd::string& watchFolder, AZStd::string& relativePath);
 
@@ -212,7 +212,5 @@ namespace AssetProcessor
         AZStd::unordered_multimap<AZ::Data::AssetId, QString> m_cachedNoPreloadDependenyAssetList;
 
         AZStd::vector<char> m_saveBuffer; // so that we don't realloc all the time
-
-        QDir m_cacheRootDir;
     };
 }

+ 188 - 0
Code/Tools/AssetProcessor/native/AssetManager/ProductAsset.cpp

@@ -0,0 +1,188 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#include <native/AssetManager/ProductAsset.h>
+#include <QDir>
+#include <AzToolsFramework/AssetDatabase/AssetDatabaseConnection.h>
+
+namespace AssetProcessor
+{
+    ProductAsset::ProductAsset(AZ::IO::Path absolutePath)
+        : m_absolutePath(AZStd::move(absolutePath))
+    {
+    }
+
+    bool ProductAsset::IsValid() const
+    {
+        return ExistsOnDisk(false);
+    }
+
+    bool ProductAsset::ExistsOnDisk(bool printErrorMessage) const
+    {
+        bool exists = AZ::IO::SystemFile::Exists(m_absolutePath.c_str());
+
+        if (!exists && printErrorMessage)
+        {
+            AZ_TracePrintf(
+                AssetProcessor::ConsoleChannel, "Was expecting product asset to exist at `%s` but it was not found\n",
+                m_absolutePath.c_str());
+        }
+
+        return exists;
+    }
+
+    bool ProductAsset::DeleteFile(bool sendNotification) const
+    {
+        if (!ExistsOnDisk(false))
+        {
+            AZ_TracePrintf(
+                AssetProcessor::ConsoleChannel, "Was expecting to delete product file %s but it already appears to be gone.\n",
+                m_absolutePath.c_str());
+            return false;
+        }
+
+        if(sendNotification)
+        {
+            AssetProcessor::ProcessingJobInfoBus::Broadcast(
+                &AssetProcessor::ProcessingJobInfoBus::Events::BeginCacheFileUpdate, m_absolutePath.AsPosix().c_str());
+        }
+
+        bool wasRemoved = AZ::IO::SystemFile::Delete(m_absolutePath.c_str());
+
+        // Another process may be holding on to the file currently, so wait for a very brief period and then retry deleting
+        // once in case we were just too quick to delete the file before.
+        if (!wasRemoved)
+        {
+            constexpr int DeleteRetryDelay = 10;
+            AZStd::this_thread::sleep_for(AZStd::chrono::milliseconds(DeleteRetryDelay));
+            wasRemoved = AZ::IO::SystemFile::Delete(m_absolutePath.c_str());
+        }
+
+        if(sendNotification)
+        {
+            AssetProcessor::ProcessingJobInfoBus::Broadcast(
+                &AssetProcessor::ProcessingJobInfoBus::Events::EndCacheFileUpdate, m_absolutePath.AsPosix().c_str(), false);
+        }
+
+        if(!wasRemoved)
+        {
+            return false;
+        }
+
+        // Try to clean up empty folder
+        if (QDir(m_absolutePath.AsPosix().c_str()).entryList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot).empty())
+        {
+            AZ::IO::SystemFile::DeleteDir(m_absolutePath.ParentPath().FixedMaxPathStringAsPosix().c_str());
+        }
+
+        AZ_TracePrintf(AssetProcessor::ConsoleChannel, "Deleted product file %s\n", m_absolutePath.c_str());
+        return true;
+    }
+
+    AZ::u64 ProductAsset::ComputeHash() const
+    {
+        return AssetUtilities::GetFileHash(m_absolutePath.c_str());
+    }
+
+    ProductAssetWrapper::ProductAssetWrapper(
+        const AssetBuilderSDK::JobProduct& jobProduct, const AssetUtilities::ProductPath& productPath)
+    {
+        AZ_Error("ProductAsset", AZ::IO::PathView(jobProduct.m_productFileName).IsRelative(), "Job Product m_productFileName (%s) must be relative",
+            jobProduct.m_productFileName.c_str());
+
+        if ((jobProduct.m_outputFlags & AssetBuilderSDK::ProductOutputFlags::ProductAsset) ==
+            AssetBuilderSDK::ProductOutputFlags::ProductAsset)
+        {
+            m_cacheProduct = true;
+            m_products.emplace_back(AZStd::make_unique<ProductAsset>(productPath.GetCachePath()));
+        }
+
+        if ((jobProduct.m_outputFlags & AssetBuilderSDK::ProductOutputFlags::IntermediateAsset) ==
+            AssetBuilderSDK::ProductOutputFlags::IntermediateAsset)
+        {
+            m_intermediateProduct = true;
+            m_products.emplace_back(AZStd::make_unique<ProductAsset>(productPath.GetIntermediatePath()));
+        }
+    }
+
+    ProductAssetWrapper::ProductAssetWrapper(
+        const AzToolsFramework::AssetDatabase::ProductDatabaseEntry& product,
+        const AssetUtilities::ProductPath& productPath)
+    {
+        if((static_cast<AssetBuilderSDK::ProductOutputFlags>(product.m_flags.to_ullong()) & AssetBuilderSDK::ProductOutputFlags::ProductAsset) ==
+            AssetBuilderSDK::ProductOutputFlags::ProductAsset)
+        {
+            m_cacheProduct = true;
+            m_products.emplace_back(AZStd::make_unique<ProductAsset>(productPath.GetCachePath()));
+        }
+
+        if((static_cast<AssetBuilderSDK::ProductOutputFlags>(product.m_flags.to_ullong()) & AssetBuilderSDK::ProductOutputFlags::IntermediateAsset) ==
+            AssetBuilderSDK::ProductOutputFlags::IntermediateAsset)
+        {
+            m_intermediateProduct = true;
+            m_products.emplace_back(AZStd::make_unique<ProductAsset>(productPath.GetIntermediatePath()));
+        }
+    }
+
+    bool ProductAssetWrapper::IsValid() const
+    {
+        return AZStd::all_of(
+            m_products.begin(), m_products.end(),
+            [](const AZStd::unique_ptr<ProductAsset>& productAsset)
+            {
+                return productAsset && productAsset->IsValid();
+            });
+    }
+
+    bool ProductAssetWrapper::ExistOnDisk(bool printErrorMessage) const
+    {
+        return AZStd::all_of(
+            m_products.begin(), m_products.end(),
+            [printErrorMessage](const AZStd::unique_ptr<ProductAsset>& productAsset)
+            {
+                return productAsset->ExistsOnDisk(printErrorMessage);
+            });
+    }
+
+    bool ProductAssetWrapper::HasCacheProduct() const
+    {
+        return m_cacheProduct;
+    }
+
+    bool ProductAssetWrapper::HasIntermediateProduct() const
+    {
+        return m_intermediateProduct;
+    }
+
+    bool ProductAssetWrapper::DeleteFiles(bool sendNotification) const
+    {
+        bool success = true;
+
+        // Use a manual loop here since we want to be sure we attempt to delete every file even if one doesn't exist
+        for(const auto& product : m_products)
+        {
+            success = product->DeleteFile(sendNotification) && success;
+        }
+
+        return success;
+    }
+
+    AZ::u64 ProductAssetWrapper::ComputeHash() const
+    {
+        for (const auto& product : m_products)
+        {
+            if (product)
+            {
+                // We just need one of the hashes, they should all be the same
+                return product->ComputeHash();
+            }
+        }
+
+        return 0;
+    }
+}

+ 53 - 0
Code/Tools/AssetProcessor/native/AssetManager/ProductAsset.h

@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#pragma once
+
+#include <AssetBuilderSDK/AssetBuilderSDK.h>
+#include <AssetDatabase/AssetDatabaseConnection.h>
+#include <AzCore/IO/SystemFile.h>
+#include <AzCore/IO/Path/Path.h>
+#include <native/utilities/assetUtils.h>
+
+namespace AssetProcessor
+{
+    //! Represents a single product asset file, either in the cache or the intermediate directory
+    class ProductAsset
+    {
+    public:
+        ProductAsset(AZ::IO::Path absolutePath);
+
+        bool IsValid() const;
+        bool ExistsOnDisk(bool printErrorMessage) const;
+        bool DeleteFile(bool sendNotification) const;
+        AZ::u64 ComputeHash() const;
+
+    protected:
+        const AZ::IO::Path m_absolutePath;
+    };
+
+    //! Represents a single job output, which itself can be either a cache product, intermediate product, or both
+    class ProductAssetWrapper
+    {
+    public:
+        ProductAssetWrapper(const AssetBuilderSDK::JobProduct& jobProduct, const AssetUtilities::ProductPath& productPath);
+        ProductAssetWrapper(const AzToolsFramework::AssetDatabase::ProductDatabaseEntry& product, const AssetUtilities::ProductPath& productPath);
+
+        bool IsValid() const;
+        bool ExistOnDisk(bool printErrorMessage) const;
+        bool DeleteFiles(bool sendNotification) const;
+        AZ::u64 ComputeHash() const;
+        bool HasCacheProduct() const;
+        bool HasIntermediateProduct() const;
+
+    protected:
+        AZStd::fixed_vector<AZStd::unique_ptr<ProductAsset>, 2> m_products;
+        bool m_cacheProduct = false;
+        bool m_intermediateProduct = false;
+    };
+}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 468 - 201
Code/Tools/AssetProcessor/native/AssetManager/assetProcessorManager.cpp


+ 39 - 3
Code/Tools/AssetProcessor/native/AssetManager/assetProcessorManager.h

@@ -42,6 +42,7 @@
 #include "SourceFileRelocator.h"
 
 #include <AssetManager/ExcludedFolderCache.h>
+#include <AssetManager/ProductAsset.h>
 #endif
 
 class FileWatcher;
@@ -130,7 +131,6 @@ namespace AssetProcessor
                 , m_initialProcessTime(initialProcessTime)
             {
             }
-
         };
 
         struct AssetProcessedEntry
@@ -223,7 +223,7 @@ namespace AssetProcessor
 
         //! Request to invalidate and reprocess a source asset or folder containing source assets
         AZ::u64 RequestReprocess(const QString& sourcePath);
-        AZ::u64 RequestReprocess(const QStringList& reprocessList);
+        AZ::u64 RequestReprocess(const AZStd::list<AZStd::string>& reprocessList);
     Q_SIGNALS:
         void NumRemainingJobsChanged(int newNumJobs);
 
@@ -273,6 +273,7 @@ namespace AssetProcessor
         void AssetCancelled(JobEntry jobEntry);
 
         void AssessFilesFromScanner(QSet<AssetFileInfo> filePaths);
+        void RecordFoldersFromScanner(QSet<AssetFileInfo> folderPaths);
 
         virtual void AssessModifiedFile(QString filePath);
         virtual void AssessAddedFile(QString filePath);
@@ -325,12 +326,19 @@ namespace AssetProcessor
         void CheckDeletedCacheFolder(QString normalizedPath);
         void CheckDeletedSourceFolder(QString normalizedPath, QString relativePath, const ScanFolderInfo* scanFolderInfo);
         void CheckCreatedSourceFolder(QString normalizedPath);
+        void FailTopLevelSourceForIntermediate(AZ::IO::PathView relativePathToIntermediateProduct, AZStd::string_view errorMessage);
         void CheckMetaDataRealFiles(QString relativePath);
         bool DeleteProducts(const AzToolsFramework::AssetDatabase::ProductDatabaseEntryContainer& products);
         void DispatchFileChange();
         bool InitializeCacheRoot();
         void PopulateJobStateCache();
-        void AutoFailJob(const AZStd::string& consoleMsg, const AZStd::string& autoFailReason, const AZStd::vector<AssetProcessedEntry>::iterator& assetIter);
+        void AutoFailJob(
+            AZStd::string_view consoleMsg,
+            AZStd::string_view autoFailReason,
+            const AZ::IO::Path& absoluteFilePath,
+            JobEntry jobEntry,
+            AZStd::string_view jobLog = "");
+        void AutoFailJob(AZStd::string_view consoleMsg, AZStd::string_view autoFailReason, const AZStd::vector<AssetProcessedEntry>::iterator& assetIter);
 
         using ProductInfoList = AZStd::vector<AZStd::pair<AzToolsFramework::AssetDatabase::ProductDatabaseEntry, const AssetBuilderSDK::JobProduct*>>;
 
@@ -362,6 +370,23 @@ namespace AssetProcessor
             QString m_analysisFingerprint;
         };
 
+        struct ConflictResult
+        {
+            enum class ConflictType
+            {
+                None,
+                //! Indicates the conflict occurred because of a new intermediate overriding an existing source
+                Intermediate,
+                //! Indicates the conflict occurred because of a new source overriding an existing intermediate
+                Source
+            };
+
+            ConflictType m_type;
+
+            //! Full path to the file that has caused the conflict.  If ConflictType == Intermediate, this is the path to the source, if ConflictType == Source, this is the intermediate
+            AZ::IO::Path m_conflictingFile;
+        };
+
         //! Search the database and the the source dependency maps for the the sourceUuid. if found returns the cached info
         bool SearchSourceInfoBySourceUUID(const AZ::Uuid& sourceUuid, AssetProcessorManager::SourceInfo& result);
 
@@ -407,6 +432,17 @@ namespace AssetProcessor
         //! Analyzes and forward the job to the RCController if the job requires processing
         void ProcessJob(JobDetails& jobDetails);
 
+        // Returns true if the path is inside the Cache and *not* inside the Intermediate Assets folder
+        bool IsInCacheFolder(AZ::IO::PathView path) const;
+
+        // Returns true if the path is inside the Intermediate Assets folder
+        bool IsInIntermediateAssetsFolder(AZ::IO::PathView path) const;
+        bool IsInIntermediateAssetsFolder(QString path) const;
+
+        ConflictResult CheckIntermediateProductConflict(bool isIntermediateProduct, const char* searchSourcePath);
+
+        bool CheckForIntermediateAssetLoop(AZStd::string_view currentAsset, AZStd::string_view productAsset);
+
         void UpdateForCacheServer(JobDetails& jobDetails);
 
         AssetProcessor::PlatformConfiguration* m_platformConfig = nullptr;

+ 2 - 11
Code/Tools/AssetProcessor/native/AssetManager/assetScannerWorker.cpp

@@ -101,16 +101,6 @@ void AssetScannerWorker::ScanForSourceFiles(const ScanFolderInfo& scanFolderInfo
         AZ::u64 fileSize = isDirectory ? 0 : entry.size();
         AssetFileInfo assetFileInfo(absPath, modTime, fileSize, &rootScanFolder, isDirectory);
 
-        // Skip over the Cache folder if the file entry is the project cache root
-        QDir projectCacheRoot;
-        AssetUtilities::ComputeProjectCacheRoot(projectCacheRoot);
-        QString relativeToProjectCacheRoot = projectCacheRoot.relativeFilePath(absPath);
-        if (QDir::isRelativePath(relativeToProjectCacheRoot) && !relativeToProjectCacheRoot.startsWith(".."))
-        {
-            // The Cache folder should not be scanned
-            continue;
-        }
-
         // Filtering out excluded files
         if (m_platformConfiguration->IsFileExcluded(absPath))
         {
@@ -121,11 +111,12 @@ void AssetScannerWorker::ScanForSourceFiles(const ScanFolderInfo& scanFolderInfo
         if (isDirectory)
         {
             //Entry is a directory
+            // The AP needs to know about all directories so it knows when a delete occurs if the path refers to a folder or a file
             m_folderList.insert(AZStd::move(assetFileInfo));
             ScanFolderInfo tempScanFolderInfo(absPath, "", "", false, true);
             ScanForSourceFiles(tempScanFolderInfo, rootScanFolder);
         }
-        else
+        else if (!AssetUtilities::IsInCacheFolder(absPath.toUtf8().constData())) // Ignore files in the cache
         {
             //Entry is a file
             m_fileList.insert(AZStd::move(assetFileInfo));

+ 6 - 5
Code/Tools/AssetProcessor/native/FileProcessor/FileProcessor.cpp

@@ -122,7 +122,7 @@ namespace AssetProcessor
     void FileProcessor::AssessDeletedFile(QString filePath)
     {
         using namespace AzToolsFramework;
-        
+
         if (m_shutdownSignalled)
         {
             return;
@@ -230,7 +230,7 @@ namespace AssetProcessor
 
         AssetSystem::FileInfosNotificationMessage message;
         ConnectionBus::Broadcast(&ConnectionBusTraits::Send, 0, message);
-        
+
         // It's important to clear this out since rescanning will end up filling this up with duplicates otherwise
         QList<AssetFileInfo> emptyList;
         m_filesInAssetScanner.swap(emptyList);
@@ -241,11 +241,11 @@ namespace AssetProcessor
     bool FileProcessor::GetRelativePath(QString& filePath, QString& relativeFileName, QString& scanFolderPath) const
     {
         filePath = AssetUtilities::NormalizeFilePath(filePath);
-        if (filePath.startsWith(m_normalizedCacheRootPath, Qt::CaseInsensitive))
+        if (AssetUtilities::IsInCacheFolder(filePath.toUtf8().constData(), m_normalizedCacheRootPath.toUtf8().constData()))
         {
             // modifies/adds to the cache are irrelevant.  Deletions are all we care about
             return false;
-        } 
+        }
 
         if (m_platformConfig->IsFileExcluded(filePath))
         {
@@ -273,9 +273,10 @@ namespace AssetProcessor
         {
             AssetDatabase::FileDatabaseEntryContainer container;
             AZStd::string searchStr = file.m_fileName + AZ_CORRECT_DATABASE_SEPARATOR;
-            m_connection->GetFilesLikeFileName(
+            m_connection->GetFilesLikeFileNameScanFolderId(
                 searchStr.c_str(),
                 AssetDatabaseConnection::LikeType::StartsWith,
+                file.m_scanFolderPK,
                 container);
             for (const auto& subFile : container)
             {

+ 7 - 3
Code/Tools/AssetProcessor/native/assetprocessor.h

@@ -22,6 +22,7 @@
 #include <AzCore/std/containers/map.h>
 #include <AzCore/std/containers/set.h>
 #include <AzCore/Asset/AssetCommon.h>
+#include <AzCore/IO/Path/Path.h>
 #include <AzFramework/Asset/AssetRegistry.h>
 #include <AzCore/Math/Crc.h>
 #include <native/AssetManager/assetScanFolderInfo.h>
@@ -39,6 +40,7 @@ namespace AssetProcessor
     const char* const PlaceHolderFileName = "$missing_dependency$"; // Used as a placeholder in the dependency system, such as when a source file is deleted and a previously met dependency is broken.
     const unsigned int g_RetriesForFenceFile = 5; // number of retries for fencing
     constexpr int RetriesForJobLostConnection = ASSETPROCESSOR_TRAIT_ASSET_BUILDER_LOST_CONNECTION_RETRIES; // number of times to retry a job when a network error due to network issues or a crashed AssetBuilder process is determined to have caused a job failure
+    constexpr const char* IntermediateAssetsFolderName = "Intermediate Assets"; // name of the intermediate assets folder
     // Even though AP can handle files with path length greater than window's legacy path length limit, we have some 3rdparty sdk's
     // which do not handle this case ,therefore we will make AP fail any jobs whose either source file or output file name exceeds the windows legacy path length limit
 #define AP_MAX_PATH_LEN 260
@@ -203,9 +205,11 @@ namespace AssetProcessor
         JobEntry m_jobEntry;
         AZStd::string m_extraInformationForFingerprinting;
         const ScanFolderInfo* m_scanFolder; // the scan folder info the file was found in
-        QString m_destinationPath; // the final folder that will be where your products are placed if you give relative path names
-        // destinationPath will be a cache folder.  If you tell it to emit something like "blah.dds"
-        // it will put it in (destinationPath)/blah.dds for example
+
+        AZ::IO::Path m_intermediatePath; // The base/root path of the intermediate output folder
+        AZ::IO::Path m_cachePath; // The base/root path of the cache folder, including the platform
+        AZ::IO::Path m_relativePath; // Relative path portion of the output file.  This can be overridden by the builder
+
         AZStd::vector<JobDependencyInternal> m_jobDependencyList;
 
         // which files to include in the fingerprinting. (Not including job dependencies)

+ 0 - 20
Code/Tools/AssetProcessor/native/resourcecompiler/JobsModel.cpp

@@ -463,26 +463,6 @@ namespace AssetProcessor
         }
     }
 
-    void JobsModel::OnFolderRemoved(QString folderPath)
-    {
-        QList<AssetProcessor::QueueElementID> elementsToRemove;
-        for (int index = 0; index < m_cachedJobs.size(); ++index)
-        {
-            if (m_cachedJobs[index]->m_elementId.GetInputAssetName().startsWith(folderPath, Qt::CaseSensitive))
-            {
-                elementsToRemove.push_back(m_cachedJobs[index]->m_elementId);
-            }
-        }
-
-        // now that we've collected all the elements to remove, we can remove them.  
-        // Doing it this way avoids problems with mutating these cache structures while iterating them.
-        for (const AssetProcessor::QueueElementID& removal : elementsToRemove)
-        {
-            RemoveJob(removal);
-        }
-
-    }
-
     void JobsModel::OnJobRemoved(AzToolsFramework::AssetSystem::JobInfo jobInfo)
     {
         RemoveJob(QueueElementID(jobInfo.m_sourceFile.c_str(), jobInfo.m_platform.c_str(), jobInfo.m_jobKey.c_str()));

+ 0 - 1
Code/Tools/AssetProcessor/native/resourcecompiler/JobsModel.h

@@ -92,7 +92,6 @@ public Q_SLOTS:
         void OnJobStatusChanged(JobEntry entry, AzToolsFramework::AssetSystem::JobStatus status);
         void OnJobRemoved(AzToolsFramework::AssetSystem::JobInfo jobInfo);
         void OnSourceRemoved(QString sourceDatabasePath);
-        void OnFolderRemoved(QString folderPath);
 
     protected:
         QIcon m_pendingIcon;

+ 187 - 35
Code/Tools/AssetProcessor/native/resourcecompiler/rcjob.cpp

@@ -19,6 +19,8 @@
 
 #include "native/utilities/JobDiagnosticTracker.h"
 
+#include <qstorageinfo.h>
+
 
 namespace
 {
@@ -60,7 +62,7 @@ using namespace AssetProcessor;
 
 bool Params::IsValidParams() const
 {
-    return (!m_finalOutputDir.isEmpty());
+    return !m_cacheOutputDir.empty() && !m_intermediateOutputDir.empty() && !m_relativePath.empty();
 }
 
 bool RCParams::IsValidParams() const
@@ -201,9 +203,19 @@ namespace AssetProcessor
         return m_jobDetails.m_jobEntry.m_sourceFileUUID;
     }
 
-    QString RCJob::GetFinalOutputPath() const
+    AZ::IO::Path RCJob::GetCacheOutputPath() const
+    {
+        return m_jobDetails.m_cachePath;
+    }
+
+    AZ::IO::Path RCJob::GetIntermediateOutputPath() const
+    {
+        return m_jobDetails.m_intermediatePath;
+    }
+
+    AZ::IO::Path RCJob::GetRelativePath() const
     {
-        return m_jobDetails.m_destinationPath;
+        return m_jobDetails.m_relativePath;
     }
 
     const AssetBuilderSDK::PlatformInfo& RCJob::GetPlatformInfo() const
@@ -278,7 +290,9 @@ namespace AssetProcessor
         PopulateProcessJobRequest(processJobRequest);
 
         builderParams.m_processJobRequest = processJobRequest;
-        builderParams.m_finalOutputDir = GetFinalOutputPath();
+        builderParams.m_cacheOutputDir = GetCacheOutputPath();
+        builderParams.m_intermediateOutputDir = GetIntermediateOutputPath();
+        builderParams.m_relativePath = GetRelativePath();
         builderParams.m_assetBuilderDesc = m_jobDetails.m_assetBuilderDesc;
 
         // when the job finishes, record the results and emit Finished()
@@ -328,6 +342,9 @@ namespace AssetProcessor
     {
         // Note: this occurs inside a worker thread.
 
+        // Signal start and end of the job
+        ScopedJobSignaler signaler;
+
         // listen for the user quitting (CTRL-C or otherwise)
         AssetUtilities::QuitListener listener;
         listener.BusConnect();
@@ -648,7 +665,6 @@ namespace AssetProcessor
         case AssetBuilderSDK::ProcessJobResult_Success:
             // make sure there's no subid collision inside a job.
             {
-
                 if (!CopyCompiledAssets(builderParams, result))
                 {
                     result.m_resultCode = AssetBuilderSDK::ProcessJobResult_Failed;
@@ -693,27 +709,21 @@ namespace AssetProcessor
             return true;
         }
 
-        QDir outputDirectory(params.m_finalOutputDir);
-        QString         tempFolder = params.m_processJobRequest.m_tempDirPath.c_str();
-        QDir            tempDir(tempFolder);
+        AZ::IO::Path cacheDirectory = params.m_cacheOutputDir;
+        AZ::IO::Path intermediateDirectory = params.m_intermediateOutputDir;
+        AZ::IO::Path relativeFilePath = params.m_relativePath;
+        QString tempFolder = params.m_processJobRequest.m_tempDirPath.c_str();
+        QDir tempDir(tempFolder);
 
-        if (params.m_finalOutputDir.isEmpty())
+        if (params.m_cacheOutputDir.empty() || params.m_intermediateOutputDir.empty())
         {
-            AZ_Assert(false, "CopyCompiledAssets:  params.m_finalOutputDir is empty for an asset processor job.  This should not happen and is because of a recent code change.  Check history of any new builders or rcjob.cpp\n");
+            AZ_Assert(false, "CopyCompiledAssets:  params.m_finalOutputDir or m_intermediateOutputDir is empty for an asset processor job.  This should not happen and is because of a recent code change.  Check history of any new builders or rcjob.cpp\n");
             return false;
         }
 
         if (!tempDir.exists())
         {
-            AZ_Assert(false, "PCopyCompiledAssets:  params.m_processJobRequest.m_tempDirPath is empty for an asset processor job.  This should not happen and is because of a recent code change!  Check history of RCJob.cpp and any new builder code changes.\n");
-            return false;
-        }
-
-        // if outputDirectory does not exist then create it
-        unsigned int waitTimeInSecs = 3;
-        if (!AssetUtilities::CreateDirectoryWithTimeout(outputDirectory, waitTimeInSecs))
-        {
-            AZ_TracePrintf(AssetBuilderSDK::ErrorWindow, "Failed to create output directory: %s\n", outputDirectory.absolutePath().toUtf8().data());
+            AZ_Assert(false, "CopyCompiledAssets:  params.m_processJobRequest.m_tempDirPath is empty for an asset processor job.  This should not happen and is because of a recent code change!  Check history of RCJob.cpp and any new builder code changes.\n");
             return false;
         }
 
@@ -725,7 +735,11 @@ namespace AssetProcessor
         // and  the second is the product destination we intend to copy it to.
         QList< QPair<QString, QString> > outputsToCopy;
         outputsToCopy.reserve(static_cast<int>(response.m_outputProducts.size()));
-        qint64 totalFileSizeRequired = 0;
+        qint64 totalCacheFileSizeRequired = 0;
+        qint64 totalIntermediateFileSizeRequired = 0;
+
+        bool needCacheDirectory = false;
+        bool needIntermediateDirectory = false;
 
         for (AssetBuilderSDK::JobProduct& product : response.m_outputProducts)
         {
@@ -745,44 +759,144 @@ namespace AssetProcessor
 
             QString absolutePathOfSource = fileInfo.absoluteFilePath();
             QString outputFilename = fileInfo.fileName();
-            QString productFile = AssetUtilities::NormalizeFilePath(outputDirectory.filePath(outputFilename.toLower()));
 
-            // Don't make productFile all lowercase for case-insensitive as this
-            // breaks macOS. The case is already setup properly when the job
-            // was created.
+            bool outputToCache = (product.m_outputFlags & AssetBuilderSDK::ProductOutputFlags::ProductAsset) == AssetBuilderSDK::ProductOutputFlags::ProductAsset;
+            bool outputToIntermediate = (product.m_outputFlags & AssetBuilderSDK::ProductOutputFlags::IntermediateAsset) ==
+                AssetBuilderSDK::ProductOutputFlags::IntermediateAsset;
+
+            if (outputToCache && outputToIntermediate)
+            {
+                // We currently do not support both since intermediate outputs require the Common platform, which is not supported for cache outputs yet
+                AZ_Error(AssetProcessor::ConsoleChannel, false, "Outputting an asset as both a product and intermediate is not supported.  To output both, please split the job into two separate ones.");
+                return false;
+            }
+
+            if (!outputToCache && !outputToIntermediate)
+            {
+                AZ_Error(AssetProcessor::ConsoleChannel, false, "An output asset must be flagged as either a product or an intermediate asset.  "
+                    "Please update the output job to include either AssetBuilderSDK::ProductOutputFlags::ProductAsset "
+                    "or AssetBuilderSDK::ProductOutputFlags::IntermediateAsset");
+                return false;
+            }
 
-            if (productFile.length() >= AP_MAX_PATH_LEN)
+            // Intermediates are required to output for the common platform only
+            if (outputToIntermediate && params.m_processJobRequest.m_platformInfo.m_identifier != AssetBuilderSDK::CommonPlatformName)
             {
-                AZ_Error(AssetBuilderSDK::ErrorWindow, false, "Cannot copy file: Product '%s' path length (%d) exceeds the max path length (%d) allowed on disk\n", productFile.toUtf8().data(), productFile.length(), AP_MAX_PATH_LEN);
+                AZ_Error(AssetProcessor::ConsoleChannel, false, "Intermediate outputs are only supported for the %s platform.  "
+                    "Either change the Job platform to %s or change the output flag to AssetBuilderSDK::ProductOutputFlags::ProductAsset",
+                    AssetBuilderSDK::CommonPlatformName,
+                    AssetBuilderSDK::CommonPlatformName);
                 return false;
             }
 
-            QFileInfo inFile(absolutePathOfSource);
-            if (!inFile.exists())
+            // Common platform is not currently supported for product assets
+            if (outputToCache && params.m_processJobRequest.m_platformInfo.m_identifier == AssetBuilderSDK::CommonPlatformName)
             {
-                AZ_Error(AssetBuilderSDK::ErrorWindow, false, "Cannot copy file - product file with absolute path '%s' attempting to save into cache could not be found", absolutePathOfSource.toUtf8().constData());
+                AZ_Error(
+                    AssetProcessor::ConsoleChannel, false,
+                    "Product asset outputs are not currently supported for the %s platform.  "
+                    "Either change the Job platform a normal platform or change the output flag to AssetBuilderSDK::ProductOutputFlags::IntermediateAsset",
+                    AssetBuilderSDK::CommonPlatformName);
                 return false;
             }
 
-            totalFileSizeRequired += inFile.size();
-            outputsToCopy.push_back(qMakePair(absolutePathOfSource, productFile));
+            if(outputToCache)
+            {
+                needCacheDirectory = true;
+
+                if(!product.m_outputPathOverride.empty())
+                {
+                    AZ_Error(AssetProcessor::ConsoleChannel, false, "%s specified m_outputPathOverride on a ProductAsset.  This is not supported."
+                    "  Please update the builder accordingly.", params.m_processJobRequest.m_sourceFile.c_str());
+                    return false;
+                }
+
+                if (!VerifyOutputProduct(
+                    QDir(cacheDirectory.c_str()), outputFilename, absolutePathOfSource, totalCacheFileSizeRequired,
+                    outputsToCopy))
+                {
+                    return false;
+                }
+            }
+
+            if(outputToIntermediate)
+            {
+                needIntermediateDirectory = true;
+
+                if(!product.m_outputPathOverride.empty())
+                {
+                    relativeFilePath = product.m_outputPathOverride;
+                }
 
-            // also update the product file name to be the final resting place of this product in the cache (normalized!)
-            product.m_productFileName = AssetUtilities::NormalizeFilePath(productFile).toUtf8().constData();
+                if (!VerifyOutputProduct(
+                    QDir(intermediateDirectory.c_str()), outputFilename, absolutePathOfSource,
+                    totalIntermediateFileSizeRequired, outputsToCopy))
+                {
+                    return false;
+                }
+            }
+
+            // update the productFileName to be the scanfolder relative path (without the platform)
+            product.m_productFileName = (relativeFilePath / outputFilename.toUtf8().constData()).c_str();
         }
 
         // now we can check if there's enough space for ALL the files before we copy any.
+
+        QStorageInfo cacheStorage(cacheDirectory.c_str());
+        QStorageInfo intermediateStorage(intermediateDirectory.c_str());
+
+        bool bothDirectoriesOnSameDrive = cacheStorage.device() == intermediateStorage.device();
         bool hasSpace = false;
-        AssetProcessor::DiskSpaceInfoBus::BroadcastResult(hasSpace, &AssetProcessor::DiskSpaceInfoBusTraits::CheckSufficientDiskSpace, outputDirectory.absolutePath(), totalFileSizeRequired, false);
+
+        if(bothDirectoriesOnSameDrive)
+        {
+            totalCacheFileSizeRequired += totalIntermediateFileSizeRequired;
+        }
+        else
+        {
+            AssetProcessor::DiskSpaceInfoBus::BroadcastResult(
+                hasSpace, &AssetProcessor::DiskSpaceInfoBusTraits::CheckSufficientDiskSpace, intermediateDirectory.c_str(),
+                totalIntermediateFileSizeRequired, false);
+
+            if (!hasSpace)
+            {
+                AZ_Error(
+                    AssetProcessor::ConsoleChannel, false,
+                    "Cannot save file(s) to intermediate directory, not enough disk space to save all the products of %s.  Total needed: %lli bytes",
+                    params.m_processJobRequest.m_sourceFile.c_str(), totalIntermediateFileSizeRequired);
+                return false;
+            }
+        }
+
+        AssetProcessor::DiskSpaceInfoBus::BroadcastResult(
+            hasSpace, &AssetProcessor::DiskSpaceInfoBusTraits::CheckSufficientDiskSpace, cacheDirectory.c_str(),
+            totalCacheFileSizeRequired, false);
 
         if (!hasSpace)
         {
-            AZ_Error(AssetProcessor::ConsoleChannel, false, "Cannot save file to cache, not enough disk space to save all the products of %s.  Total needed: %lli bytes", params.m_processJobRequest.m_sourceFile.c_str(), totalFileSizeRequired);
+            AZ_Error(
+                AssetProcessor::ConsoleChannel, false,
+                "Cannot save file(s) to cache, not enough disk space to save all the products of %s.  Total needed: %lli bytes",
+                params.m_processJobRequest.m_sourceFile.c_str(), totalCacheFileSizeRequired);
             return false;
         }
 
         // if we get here, we are good to go in terms of disk space and sources existing, so we make the best attempt we can.
 
+        // if outputDirectory does not exist then create it
+        unsigned int waitTimeInSecs = 3;
+        if (needCacheDirectory && !AssetUtilities::CreateDirectoryWithTimeout(QDir(cacheDirectory.AsPosix().c_str()), waitTimeInSecs))
+        {
+            AZ_TracePrintf(AssetBuilderSDK::ErrorWindow, "Failed to create output directory: %s\n", cacheDirectory.c_str());
+            return false;
+        }
+
+        if (needIntermediateDirectory && !AssetUtilities::CreateDirectoryWithTimeout(QDir(intermediateDirectory.AsPosix().c_str()), waitTimeInSecs))
+        {
+            AZ_TracePrintf(AssetBuilderSDK::ErrorWindow, "Failed to create intermediate directory: %s\n", intermediateDirectory.c_str());
+            return false;
+        }
+
         bool anyFileFailed = false;
 
         for (const QPair<QString, QString>& filePair : outputsToCopy)
@@ -809,6 +923,44 @@ namespace AssetProcessor
         return !anyFileFailed;
     }
 
+    bool RCJob::VerifyOutputProduct(
+        QDir outputDirectory,
+        QString outputFilename,
+        QString absolutePathOfSource,
+        qint64& totalFileSizeRequired,
+        QList<QPair<QString, QString>>& outputsToCopy)
+    {
+        QString productFile = AssetUtilities::NormalizeFilePath(outputDirectory.filePath(outputFilename.toLower()));
+
+        // Don't make productFile all lowercase for case-insensitive as this
+        // breaks macOS. The case is already setup properly when the job
+        // was created.
+
+        if (productFile.length() >= AP_MAX_PATH_LEN)
+        {
+            AZ_Error(
+                AssetBuilderSDK::ErrorWindow, false,
+                "Cannot copy file: Product '%s' path length (%d) exceeds the max path length (%d) allowed on disk\n",
+                productFile.toUtf8().data(), productFile.length(), AP_MAX_PATH_LEN);
+            return false;
+        }
+
+        QFileInfo inFile(absolutePathOfSource);
+        if (!inFile.exists())
+        {
+            AZ_Error(
+                AssetBuilderSDK::ErrorWindow, false,
+                "Cannot copy file - product file with absolute path '%s' attempting to save into cache could not be found",
+                absolutePathOfSource.toUtf8().constData());
+            return false;
+        }
+
+        totalFileSizeRequired += inFile.size();
+        outputsToCopy.push_back(qMakePair(absolutePathOfSource, productFile));
+
+        return true;
+    }
+
     AZ::Outcome<AZStd::vector<AZStd::string>> RCJob::BeforeStoringJobResult(const BuilderParams& builderParams, AssetBuilderSDK::ProcessJobResponse jobResponse)
     {
         AZStd::string normalizedTempFolderPath = builderParams.m_processJobRequest.m_tempDirPath;

+ 51 - 5
Code/Tools/AssetProcessor/native/resourcecompiler/rcjob.h

@@ -28,6 +28,42 @@ namespace AssetProcessor
     struct AssetRecognizer;
     class RCJob;
 
+    //! Interface for signalling when jobs start and stop
+    //! Primarily intended for unit tests
+    struct IRCJobSignal
+    {
+        AZ_RTTI(JobSignalReceiver, "{F81AEDE6-C670-4F3D-8393-4E2FF8ADDD02}");
+
+        virtual ~IRCJobSignal() = default;
+
+        virtual void Started(){}
+        virtual void Finished(){}
+    };
+
+    //! Scoped class to automatically signal job start and stop
+    struct ScopedJobSignaler
+    {
+        ScopedJobSignaler()
+        {
+            auto signalReciever = AZ::Interface<IRCJobSignal>::Get();
+
+            if (signalReciever)
+            {
+                signalReciever->Started();
+            }
+        }
+
+        ~ScopedJobSignaler()
+        {
+            auto signalReciever = AZ::Interface<IRCJobSignal>::Get();
+
+            if (signalReciever)
+            {
+                signalReciever->Finished();
+            }
+        }
+    };
+
     //! Params Base class
     struct Params
     {
@@ -37,7 +73,9 @@ namespace AssetProcessor
         virtual ~Params() = default;
 
         AssetProcessor::RCJob* m_rcJob;
-        QString m_finalOutputDir;
+        AZ::IO::Path m_cacheOutputDir;
+        AZ::IO::Path m_intermediateOutputDir;
+        AZ::IO::Path m_relativePath;
 
         Params(const Params&) = default;
 
@@ -151,7 +189,9 @@ namespace AssetProcessor
 
         //! the final output path is where the actual outputs are copied when processing succeeds
         //! this will be in the asset cache, in the gamename / platform / gamename folder.
-        QString GetFinalOutputPath() const;
+        AZ::IO::Path GetCacheOutputPath() const;
+        AZ::IO::Path GetIntermediateOutputPath() const;
+        AZ::IO::Path GetRelativePath() const;
 
         const AssetProcessor::AssetRecognizer* GetRecognizer() const;
         void SetRecognizer(const AssetProcessor::AssetRecognizer* value);
@@ -174,7 +214,7 @@ namespace AssetProcessor
         void SetCheckExclusiveLock(bool value);
 
     Q_SIGNALS:
-        //! This signal will be emitted when we make sure that no other application has a lock on the source file 
+        //! This signal will be emitted when we make sure that no other application has a lock on the source file
         //! and also that the fingerprint of the source file is stable and not changing.
         //! This will basically indicate that we are starting to perform work on the current job
         void BeginWork();
@@ -186,6 +226,12 @@ namespace AssetProcessor
         static void ExecuteBuilderCommand(BuilderParams builderParams);
         static void AutoFailJob(BuilderParams& builderParams);
         static bool CopyCompiledAssets(BuilderParams& params, AssetBuilderSDK::ProcessJobResponse& response);
+        static bool VerifyOutputProduct(
+            QDir outputDirectory,
+            QString outputFilename,
+            QString absolutePathOfSource,
+            qint64& totalFileSizeRequired,
+            QList<QPair<QString, QString>>& outputsToCopy);
         //! This method will save the processJobResponse and the job log to the temp directory as xml files.
         //! We will be modifying absolute paths in processJobResponse before saving it to the disk.
         static AZ::Outcome<AZStd::vector<AZStd::string>> BeforeStoringJobResult(const BuilderParams& builderParams, AssetBuilderSDK::ProcessJobResponse jobResponse);
@@ -201,7 +247,7 @@ namespace AssetProcessor
         const AZStd::vector<JobDependencyInternal>& GetJobDependencies();
 
     protected:
-        //! DoWork ensure that the job is ready for being processing and than makes the actual builder call   
+        //! DoWork ensure that the job is ready for being processing and than makes the actual builder call
         virtual void DoWork(AssetBuilderSDK::ProcessJobResponse& result, BuilderParams& builderParams, AssetUtilities::QuitListener& listener);
         void PopulateProcessJobRequest(AssetBuilderSDK::ProcessJobRequest& processJobRequest);
 
@@ -211,7 +257,7 @@ namespace AssetProcessor
 
         QueueElementID m_queueElementID; // cached to prevent lots of construction of this all over the place
 
-        int m_JobEscalation = AssetProcessor::JobEscalation::Default; // Escalation indicates how important the job is and how soon it needs processing, the greater the number the greater the escalation  
+        int m_JobEscalation = AssetProcessor::JobEscalation::Default; // Escalation indicates how important the job is and how soon it needs processing, the greater the number the greater the escalation
 
         QDateTime m_timeCreated;
         QDateTime m_timeLaunched;

+ 87 - 53
Code/Tools/AssetProcessor/native/tests/FileProcessor/FileProcessorTests.cpp

@@ -17,36 +17,42 @@ namespace UnitTests
         AssetProcessorTest::SetUp();
         ConnectionBus::Handler::BusConnect(ConnectionBusId);
 
-        m_data.reset(new StaticData());
-        m_data->m_databaseLocationListener.BusConnect();
+        m_databaseLocationListener.BusConnect();
 
-        m_data->m_temporarySourceDir = QDir(m_data->m_temporaryDir.path());
+        m_temporarySourceDir = QDir(m_temporaryDir.path());
 
         // in other unit tests we may open the database called ":memory:" to use an in-memory database instead of one on disk.
         // in this test, however, we use a real database, because the file processor shares it and opens its own connection to it.
         // ":memory:" databases are one-instance-only, and even if another connection is opened to ":memory:" it would
         // not share with others created using ":memory:" and get a unique database instead.
-        m_data->m_databaseLocation = m_data->m_temporarySourceDir.absoluteFilePath("test_database.sqlite").toUtf8().constData();
+        m_databaseLocation = m_temporarySourceDir.absoluteFilePath("test_database.sqlite").toUtf8().constData();
 
 
-        ON_CALL(m_data->m_databaseLocationListener, GetAssetDatabaseLocation(::testing::_))
+        ON_CALL(m_databaseLocationListener, GetAssetDatabaseLocation(::testing::_))
             .WillByDefault(
                 DoAll( // set the 0th argument ref (string) to the database location and return true.
-                    ::testing::SetArgReferee<0>(m_data->m_databaseLocation),
+                    ::testing::SetArgReferee<0>(m_databaseLocation),
                     ::testing::Return(true)));
 
         // Initialize the database:
-        m_data->m_connection.ClearData(); // this is expected to reset/clear/reopen
+        m_connection.ClearData(); // this is expected to reset/clear/reopen
 
-        m_data->m_config = AZStd::make_unique<AssetProcessor::PlatformConfiguration>();
-        m_data->m_config->EnablePlatform({ "pc", { "host", "renderer", "desktop" } }, true);
-        
-        m_data->m_fileProcessor = AZStd::make_unique<FileProcessor>(m_data->m_config.get());
+        m_config = AZStd::make_unique<AssetProcessor::PlatformConfiguration>();
+        m_config->EnablePlatform({ "pc", { "host", "renderer", "desktop" } }, true);
 
-        m_data->m_scanFolder = { m_data->m_temporarySourceDir.absolutePath().toUtf8().constData(), "dev", "rootportkey" };
-        ASSERT_TRUE(m_data->m_connection.SetScanFolder(m_data->m_scanFolder));
+        m_fileProcessor = AZStd::make_unique<FileProcessor>(m_config.get());
 
-        m_data->m_config->AddScanFolder(ScanFolderInfo(m_data->m_temporarySourceDir.absolutePath(), "dev", "rootportkey", false, true, m_data->m_config->GetEnabledPlatforms(), 0, m_data->m_scanFolder.m_scanFolderID));
+        m_scanFolder = { m_temporarySourceDir.absoluteFilePath("dev").toUtf8().constData(), "dev", "rootportkey" };
+        m_scanFolder2 = { m_temporarySourceDir.absoluteFilePath("dev2").toUtf8().constData(), "dev2", "dev2" };
+        ASSERT_TRUE(m_connection.SetScanFolder(m_scanFolder));
+        ASSERT_TRUE(m_connection.SetScanFolder(m_scanFolder2));
+
+        m_config->AddScanFolder(ScanFolderInfo(
+            m_scanFolder.m_scanFolder.c_str(), m_scanFolder.m_displayName.c_str(), m_scanFolder.m_portableKey.c_str(),
+            m_scanFolder.m_isRoot, true, m_config->GetEnabledPlatforms(), 0, m_scanFolder.m_scanFolderID));
+        m_config->AddScanFolder(ScanFolderInfo(
+            m_scanFolder2.m_scanFolder.c_str(), m_scanFolder2.m_displayName.c_str(), m_scanFolder2.m_portableKey.c_str(),
+            m_scanFolder2.m_isRoot, true, m_config->GetEnabledPlatforms(), 0, m_scanFolder2.m_scanFolderID));
 
         for (int index = 0; index < 10; ++index)
         {
@@ -54,54 +60,51 @@ namespace UnitTests
             entry.m_fileName = AZStd::string::format("somefile_%d.tif", index);
             entry.m_isFolder = false;
             entry.m_modTime = 0;
-            entry.m_scanFolderPK = m_data->m_scanFolder.m_scanFolderID;
-            m_data->m_fileEntries.push_back(entry);
+            entry.m_scanFolderPK = m_scanFolder.m_scanFolderID;
+            m_fileEntries.push_back(entry);
         }
     }
 
     void FileProcessorTests::TearDown()
     {
-        m_data->m_databaseLocationListener.BusDisconnect();
-        m_data.reset();
+        m_databaseLocationListener.BusDisconnect();
         ConnectionBus::Handler::BusDisconnect(ConnectionBusId);
         AssetProcessorTest::TearDown();
     }
 
     size_t FileProcessorTests::Send([[maybe_unused]] unsigned int serial, [[maybe_unused]] const AzFramework::AssetSystem::BaseAssetProcessorMessage& message)
     {
-        m_data->m_messagesSent++;
+        ++m_messagesSent;
 
         return 0;
     }
 
     TEST_F(FileProcessorTests, FilesAdded_WhenSentMultipleAdds_ShouldEmitOnlyOneAdd)
     {
-        QSet<AssetFileInfo> scannerFiles;
+        m_fileProcessor->AssessAddedFile((AZ::IO::Path(m_scanFolder.m_scanFolder) / m_fileEntries[0].m_fileName).c_str());
+        m_fileProcessor->AssessAddedFile((AZ::IO::Path(m_scanFolder.m_scanFolder) / m_fileEntries[0].m_fileName).c_str());
 
-        m_data->m_fileProcessor->AssessAddedFile(m_data->m_temporarySourceDir.absoluteFilePath(m_data->m_fileEntries[0].m_fileName.c_str()));
-        m_data->m_fileProcessor->AssessAddedFile(m_data->m_temporarySourceDir.absoluteFilePath(m_data->m_fileEntries[0].m_fileName.c_str()));
-
-        ASSERT_EQ(m_data->m_messagesSent, 1);
+        ASSERT_EQ(m_messagesSent, 1);
     }
 
     TEST_F(FileProcessorTests, FilesFromScanner_ShouldSaveToDatabaseWithoutCreatingDuplicates)
     {
         QSet<AssetFileInfo> scannerFiles;
-        auto* scanFolder = m_data->m_config->GetScanFolderByPath(m_data->m_scanFolder.m_scanFolder.c_str());
+        auto* scanFolder = m_config->GetScanFolderByPath(m_scanFolder.m_scanFolder.c_str());
 
         ASSERT_NE(scanFolder, nullptr);
 
-        for (const auto& file : m_data->m_fileEntries)
+        for (const auto& file : m_fileEntries)
         {
-            scannerFiles.insert(AssetFileInfo(m_data->m_temporarySourceDir.absoluteFilePath(file.m_fileName.c_str()), QDateTime::fromMSecsSinceEpoch(file.m_modTime), 1234, scanFolder, file.m_isFolder));
+            scannerFiles.insert(AssetFileInfo((AZ::IO::Path(m_scanFolder.m_scanFolder) / file.m_fileName.c_str()).c_str(), QDateTime::fromMSecsSinceEpoch(file.m_modTime), 1234, scanFolder, file.m_isFolder));
         }
 
-        m_data->m_fileProcessor->AssessFilesFromScanner(scannerFiles);
-        m_data->m_fileProcessor->Sync();
+        m_fileProcessor->AssessFilesFromScanner(scannerFiles);
+        m_fileProcessor->Sync();
 
         // Run again to make sure we don't get duplicate entries
-        m_data->m_fileProcessor->AssessFilesFromScanner(scannerFiles);
-        m_data->m_fileProcessor->Sync();
+        m_fileProcessor->AssessFilesFromScanner(scannerFiles);
+        m_fileProcessor->Sync();
 
         FileDatabaseEntryContainer actualEntries;
 
@@ -110,25 +113,56 @@ namespace UnitTests
             actualEntries.push_back(entry);
             return true;
         };
-        
-        ASSERT_TRUE(m_data->m_connection.QueryFilesTable(filesFunction));
-        ASSERT_THAT(m_data->m_fileEntries, testing::UnorderedElementsAreArray(actualEntries));
+
+        ASSERT_TRUE(m_connection.QueryFilesTable(filesFunction));
+        ASSERT_THAT(m_fileEntries, testing::UnorderedElementsAreArray(actualEntries));
+    }
+
+    TEST_F(FileProcessorTests, IdenticalFilesInDifferentScanFolders_DeleteFolder_CorrectFilesRemoved)
+    {
+        auto* scanFolder = m_config->GetScanFolderByPath(m_scanFolder.m_scanFolder.c_str());
+        auto* scanFolder2 = m_config->GetScanFolderByPath(m_scanFolder2.m_scanFolder.c_str());
+
+        m_fileProcessor->AssessFoldersFromScanner({
+            AssetFileInfo{ (AZ::IO::Path(m_scanFolder.m_scanFolder) / "folder").c_str(), QDateTime::currentDateTime(), 0, scanFolder, true },
+            AssetFileInfo{ (AZ::IO::Path(m_scanFolder2.m_scanFolder) / "folder").c_str(), QDateTime::currentDateTime(), 0, scanFolder2, true }
+        });
+
+        m_fileProcessor->AssessFilesFromScanner({
+            AssetFileInfo{ (AZ::IO::Path(m_scanFolder.m_scanFolder) / "folder" / "file.txt").c_str(), QDateTime::currentDateTime(), 0, scanFolder, true },
+            AssetFileInfo{ (AZ::IO::Path(m_scanFolder2.m_scanFolder) / "folder" / "file.txt").c_str(), QDateTime::currentDateTime(), 0, scanFolder2, true }
+        });
+
+        m_fileProcessor->Sync();
+
+        m_fileProcessor->AssessDeletedFile((AZ::IO::Path(m_scanFolder.m_scanFolder) / "folder").c_str());
+
+        int fileCount = 0;
+
+        m_connection.QueryFilesTable(
+            [&fileCount](FileDatabaseEntry&)
+            {
+                ++fileCount;
+                return true;
+            });
+
+        ASSERT_EQ(fileCount, 2); // 1 file, 1 folder
     }
 
     TEST_F(FileProcessorTests, FilesFromScanner_ShouldHandleChangesBetweenSyncs)
     {
         QSet<AssetFileInfo> scannerFiles;
-        auto* scanFolder = m_data->m_config->GetScanFolderByPath(m_data->m_scanFolder.m_scanFolder.c_str());
+        auto* scanFolder = m_config->GetScanFolderByPath(m_scanFolder.m_scanFolder.c_str());
 
         ASSERT_NE(scanFolder, nullptr);
 
-        for (const auto& file : m_data->m_fileEntries)
+        for (const auto& file : m_fileEntries)
         {
-            scannerFiles.insert(AssetFileInfo(m_data->m_temporarySourceDir.absoluteFilePath(file.m_fileName.c_str()), QDateTime::fromMSecsSinceEpoch(file.m_modTime), 1234, scanFolder, file.m_isFolder));
+            scannerFiles.insert(AssetFileInfo((AZ::IO::Path(m_scanFolder.m_scanFolder) / file.m_fileName).c_str(), QDateTime::fromMSecsSinceEpoch(file.m_modTime), 1234, scanFolder, file.m_isFolder));
         }
 
-        m_data->m_fileProcessor->AssessFilesFromScanner(scannerFiles);
-        m_data->m_fileProcessor->Sync();
+        m_fileProcessor->AssessFilesFromScanner(scannerFiles);
+        m_fileProcessor->Sync();
 
         FileDatabaseEntryContainer actualEntries;
 
@@ -138,41 +172,41 @@ namespace UnitTests
             return true;
         };
 
-        ASSERT_TRUE(m_data->m_connection.QueryFilesTable(filesFunction));
-        ASSERT_THAT(m_data->m_fileEntries, testing::UnorderedElementsAreArray(actualEntries));
+        ASSERT_TRUE(m_connection.QueryFilesTable(filesFunction));
+        ASSERT_THAT(m_fileEntries, testing::UnorderedElementsAreArray(actualEntries));
 
         // Clear the db (we don't have the file IDs in m_fileEntries to remove 1 by 1 so its easier to just remove them all)
         for (const auto& file : actualEntries)
         {
-            m_data->m_connection.RemoveFile(file.m_fileID);
+            m_connection.RemoveFile(file.m_fileID);
         }
 
         // Remove two files
-        m_data->m_fileEntries.erase(m_data->m_fileEntries.begin());
-        m_data->m_fileEntries.erase(m_data->m_fileEntries.begin());
+        m_fileEntries.erase(m_fileEntries.begin());
+        m_fileEntries.erase(m_fileEntries.begin());
 
         // Add a file
         FileDatabaseEntry entry;
         entry.m_fileName = AZStd::string::format("somefile_%d.tif", 11);
         entry.m_isFolder = false;
         entry.m_modTime = 0;
-        entry.m_scanFolderPK = m_data->m_scanFolder.m_scanFolderID;
-        m_data->m_fileEntries.push_back(entry);
+        entry.m_scanFolderPK = m_scanFolder.m_scanFolderID;
+        m_fileEntries.push_back(entry);
 
         scannerFiles.clear();
 
-        for (const auto& file : m_data->m_fileEntries)
+        for (const auto& file : m_fileEntries)
         {
-            scannerFiles.insert(AssetFileInfo(m_data->m_temporarySourceDir.absoluteFilePath(file.m_fileName.c_str()), QDateTime::fromMSecsSinceEpoch(file.m_modTime), 1234, scanFolder, file.m_isFolder));
+            scannerFiles.insert(AssetFileInfo((AZ::IO::Path(m_scanFolder.m_scanFolder) / file.m_fileName).c_str(), QDateTime::fromMSecsSinceEpoch(file.m_modTime), 1234, scanFolder, file.m_isFolder));
         }
-        
+
         // Sync again
-        m_data->m_fileProcessor->AssessFilesFromScanner(scannerFiles);
-        m_data->m_fileProcessor->Sync();
+        m_fileProcessor->AssessFilesFromScanner(scannerFiles);
+        m_fileProcessor->Sync();
 
         actualEntries.clear();
 
-        ASSERT_TRUE(m_data->m_connection.QueryFilesTable(filesFunction));
-        ASSERT_THAT(m_data->m_fileEntries, testing::UnorderedElementsAreArray(actualEntries));
+        ASSERT_TRUE(m_connection.QueryFilesTable(filesFunction));
+        ASSERT_THAT(m_fileEntries, testing::UnorderedElementsAreArray(actualEntries));
     }
 }

+ 19 - 28
Code/Tools/AssetProcessor/native/tests/FileProcessor/FileProcessorTests.h

@@ -47,6 +47,12 @@ namespace UnitTests
         public ConnectionBus::Handler
     {
     public:
+        FileProcessorTests() : m_coreApp(m_argc, nullptr), AssetProcessorTest()
+        {
+
+        }
+
+
         void SetUp() override;
         void TearDown() override;
 
@@ -72,38 +78,23 @@ namespace UnitTests
         void RemoveResponseHandler([[maybe_unused]] unsigned int serial) override {};
 
     protected:
-        struct StaticData
-        {
-            QTemporaryDir m_temporaryDir;
-            QDir m_temporarySourceDir;
-
-            // these variables are created during SetUp() and destroyed during TearDown() and thus are always available during tests using this fixture:
-            AZStd::string m_databaseLocation;
-            NiceMock<FileProcessorTestsMockDatabaseLocationListener> m_databaseLocationListener;
-            AssetProcessor::AssetDatabaseConnection m_connection;
-
-            AZStd::unique_ptr<AssetProcessor::PlatformConfiguration> m_config;
-
-            // The following database entry variables are initialized only when you call coverage test data CreateCoverageTestData().
-            // Tests which don't need or want a pre-made database should not call CreateCoverageTestData() but note that in that case
-            // these entries will be empty and their identifiers will be -1.
-            ScanFolderDatabaseEntry m_scanFolder;
+        QTemporaryDir m_temporaryDir;
+        QDir m_temporarySourceDir;
 
-            AZStd::unique_ptr<FileProcessor> m_fileProcessor;
+        AZStd::string m_databaseLocation;
+        NiceMock<FileProcessorTestsMockDatabaseLocationListener> m_databaseLocationListener;
+        AssetProcessor::AssetDatabaseConnection m_connection;
 
-            FileDatabaseEntryContainer m_fileEntries;
-            QCoreApplication m_coreApp;
-            int m_argc = 0;
-            int m_messagesSent = 0;
+        AZStd::unique_ptr<AssetProcessor::PlatformConfiguration> m_config;
 
-            StaticData() : m_coreApp(m_argc, nullptr)
-            {
+        ScanFolderDatabaseEntry m_scanFolder;
+        ScanFolderDatabaseEntry m_scanFolder2;
 
-            }
-        };
+        AZStd::unique_ptr<FileProcessor> m_fileProcessor;
 
-        // we store the above data in a unique_ptr so that its memory can be cleared during TearDown() in one call, before we destroy the memory
-        // allocator, reducing the chance of missing or forgetting to destroy one in the future.
-        AZStd::unique_ptr<StaticData> m_data;
+        FileDatabaseEntryContainer m_fileEntries;
+        QCoreApplication m_coreApp;
+        int m_argc = 0;
+        int m_messagesSent = 0;
     };
 }

+ 85 - 26
Code/Tools/AssetProcessor/native/tests/UnitTestUtilities.cpp

@@ -10,25 +10,41 @@
 
 namespace UnitTests
 {
-    MockBuilderInfoHandler::~MockBuilderInfoHandler()
+    MockMultiBuilderInfoHandler::~MockMultiBuilderInfoHandler()
     {
         BusDisconnect();
-        m_builderDesc = {};
     }
 
-    void MockBuilderInfoHandler::GetMatchingBuildersInfo(
-        [[maybe_unused]] const AZStd::string& assetPath, AssetProcessor::BuilderInfoList& builderInfoList)
+    void MockMultiBuilderInfoHandler::GetMatchingBuildersInfo(
+        const AZStd::string& assetPath, AssetProcessor::BuilderInfoList& builderInfoList)
     {
-        builderInfoList.push_back(m_builderDesc);
+        AZStd::set<AZ::Uuid> uniqueBuilderDescIDs;
+
+        for (AssetUtilities::BuilderFilePatternMatcher& matcherPair : m_matcherBuilderPatterns)
+        {
+            if (uniqueBuilderDescIDs.find(matcherPair.GetBuilderDescID()) != uniqueBuilderDescIDs.end())
+            {
+                continue;
+            }
+            if (matcherPair.MatchesPath(assetPath))
+            {
+                const AssetBuilderSDK::AssetBuilderDesc& builderDesc = m_builderDescMap[matcherPair.GetBuilderDescID()];
+                uniqueBuilderDescIDs.insert(matcherPair.GetBuilderDescID());
+                builderInfoList.push_back(builderDesc);
+            }
+        }
     }
 
-    void MockBuilderInfoHandler::GetAllBuildersInfo(AssetProcessor::BuilderInfoList& builderInfoList)
+    void MockMultiBuilderInfoHandler::GetAllBuildersInfo([[maybe_unused]] AssetProcessor::BuilderInfoList& builderInfoList)
     {
-        builderInfoList.push_back(m_builderDesc);
+        for (const auto& builder : m_builderDescMap)
+        {
+            builderInfoList.push_back(builder.second);
+        }
     }
 
-    void MockBuilderInfoHandler::CreateJobs(
-        const AssetBuilderSDK::CreateJobsRequest& request, AssetBuilderSDK::CreateJobsResponse& response)
+    void MockMultiBuilderInfoHandler::CreateJobs(
+        AssetBuilderExtraInfo extraInfo, const AssetBuilderSDK::CreateJobsRequest& request, AssetBuilderSDK::CreateJobsResponse& response)
     {
         response.m_result = AssetBuilderSDK::CreateJobsResultCode::Success;
 
@@ -39,40 +55,73 @@ namespace UnitTests
             jobDescriptor.m_critical = true;
             jobDescriptor.m_jobKey = "Mock Job";
             jobDescriptor.SetPlatformIdentifier(platform.m_identifier.c_str());
-            jobDescriptor.m_additionalFingerprintInfo = m_jobFingerprint.toUtf8().data();
+            jobDescriptor.m_additionalFingerprintInfo = extraInfo.m_jobFingerprint.toUtf8().constData();
 
-            if (!m_jobDependencyFilePath.isEmpty())
+            if (!extraInfo.m_jobDependencyFilePath.isEmpty())
             {
-                AssetBuilderSDK::JobDependency jobDependency(
+                auto jobDependency = AssetBuilderSDK::JobDependency(
                     "Mock Job", "pc", AssetBuilderSDK::JobDependencyType::Order,
-                    AssetBuilderSDK::SourceFileDependency(m_jobDependencyFilePath.toUtf8().constData(), AZ::Uuid::CreateNull()));
+                    AssetBuilderSDK::SourceFileDependency(extraInfo.m_jobDependencyFilePath.toUtf8().constData(), AZ::Uuid::CreateNull()));
 
-                if (!m_subIdDependencies.empty())
+                if (!extraInfo.m_subIdDependencies.empty())
                 {
-                    jobDependency.m_productSubIds = m_subIdDependencies;
+                    jobDependency.m_productSubIds = extraInfo.m_subIdDependencies;
                 }
 
                 jobDescriptor.m_jobDependencyList.push_back(jobDependency);
             }
 
-            if (!m_dependencyFilePath.isEmpty())
+            if (!extraInfo.m_dependencyFilePath.isEmpty())
             {
                 response.m_sourceFileDependencyList.push_back(
-                    AssetBuilderSDK::SourceFileDependency(m_dependencyFilePath.toUtf8().data(), AZ::Uuid::CreateNull()));
+                    AssetBuilderSDK::SourceFileDependency(extraInfo.m_dependencyFilePath.toUtf8().constData(), AZ::Uuid::CreateNull()));
             }
+
             response.m_createJobOutputs.push_back(jobDescriptor);
             m_createJobsCount++;
         }
     }
 
-    void MockBuilderInfoHandler::ProcessJob(
-        [[maybe_unused]] const AssetBuilderSDK::ProcessJobRequest& request, AssetBuilderSDK::ProcessJobResponse& response)
+    void MockMultiBuilderInfoHandler::ProcessJob(
+        AssetBuilderExtraInfo extraInfo,
+        [[maybe_unused]] const AssetBuilderSDK::ProcessJobRequest& request,
+        AssetBuilderSDK::ProcessJobResponse& response)
     {
         response.m_resultCode = AssetBuilderSDK::ProcessJobResultCode::ProcessJobResult_Success;
     }
 
-    AssetBuilderSDK::AssetBuilderDesc MockBuilderInfoHandler::CreateBuilderDesc(
-        const QString& builderName, const QString& builderId, const AZStd::vector<AssetBuilderSDK::AssetBuilderPattern>& builderPatterns)
+    void MockMultiBuilderInfoHandler::CreateBuilderDesc(
+        const QString& builderName,
+        const QString& builderId,
+        const AZStd::vector<AssetBuilderSDK::AssetBuilderPattern>& builderPatterns,
+        const AssetBuilderExtraInfo& extraInfo)
+    {
+        CreateBuilderDesc(
+            builderName, builderId, builderPatterns,
+            AZStd::bind(&MockMultiBuilderInfoHandler::CreateJobs, this, extraInfo, AZStd::placeholders::_1, AZStd::placeholders::_2),
+            AZStd::bind(&MockMultiBuilderInfoHandler::ProcessJob, this, extraInfo, AZStd::placeholders::_1, AZStd::placeholders::_2), extraInfo.m_analysisFingerprint);
+    }
+
+    void MockMultiBuilderInfoHandler::CreateBuilderDescInfoRef(
+        const QString& builderName,
+        const QString& builderId,
+        const AZStd::vector<AssetBuilderSDK::AssetBuilderPattern>& builderPatterns,
+        const AssetBuilderExtraInfo& extraInfo)
+    {
+        CreateBuilderDesc(
+            builderName, builderId, builderPatterns,
+            AZStd::bind(&MockMultiBuilderInfoHandler::CreateJobs, this, AZStd::ref(extraInfo), AZStd::placeholders::_1, AZStd::placeholders::_2),
+            AZStd::bind(&MockMultiBuilderInfoHandler::ProcessJob, this, AZStd::ref(extraInfo), AZStd::placeholders::_1, AZStd::placeholders::_2),
+            extraInfo.m_analysisFingerprint);
+    }
+
+    void MockMultiBuilderInfoHandler::CreateBuilderDesc(
+        const QString& builderName,
+        const QString& builderId,
+        const AZStd::vector<AssetBuilderSDK::AssetBuilderPattern>& builderPatterns,
+        const AssetBuilderSDK::CreateJobFunction& createJobsFunction,
+        const AssetBuilderSDK::ProcessJobFunction& processJobFunction,
+        AZStd::optional<QString> analysisFingerprint)
     {
         AssetBuilderSDK::AssetBuilderDesc builderDesc;
 
@@ -80,10 +129,20 @@ namespace UnitTests
         builderDesc.m_patterns = builderPatterns;
         builderDesc.m_busId = AZ::Uuid::CreateString(builderId.toUtf8().data());
         builderDesc.m_builderType = AssetBuilderSDK::AssetBuilderDesc::AssetBuilderType::Internal;
-        builderDesc.m_createJobFunction =
-            AZStd::bind(&MockBuilderInfoHandler::CreateJobs, this, AZStd::placeholders::_1, AZStd::placeholders::_2);
-        builderDesc.m_processJobFunction =
-            AZStd::bind(&MockBuilderInfoHandler::ProcessJob, this, AZStd::placeholders::_1, AZStd::placeholders::_2);
-        return builderDesc;
+        builderDesc.m_createJobFunction = createJobsFunction;
+        builderDesc.m_processJobFunction = processJobFunction;
+
+        if (analysisFingerprint && !analysisFingerprint->isEmpty())
+        {
+            builderDesc.m_analysisFingerprint = analysisFingerprint.value().toUtf8().constData();
+        }
+
+        m_builderDescMap[builderDesc.m_busId] = builderDesc;
+
+        for (const AssetBuilderSDK::AssetBuilderPattern& pattern : builderDesc.m_patterns)
+        {
+            AssetUtilities::BuilderFilePatternMatcher patternMatcher(pattern, builderDesc.m_busId);
+            m_matcherBuilderPatterns.push_back(patternMatcher);
+        }
     }
 }

+ 41 - 11
Code/Tools/AssetProcessor/native/tests/UnitTestUtilities.h

@@ -9,33 +9,63 @@
 #pragma once
 
 #include <utilities/AssetUtilEBusHelper.h>
+#include <utilities/assetUtils.h>
 #include <AzCore/Component/ComponentApplicationBus.h>
 #include <AzCore/Interface/Interface.h>
 #include <gmock/gmock.h>
 
 namespace UnitTests
 {
-    struct MockBuilderInfoHandler : public AssetProcessor::AssetBuilderInfoBus::Handler
+    struct MockMultiBuilderInfoHandler : public AssetProcessor::AssetBuilderInfoBus::Handler
     {
-        ~MockBuilderInfoHandler();
+        ~MockMultiBuilderInfoHandler() override;
+
+        struct AssetBuilderExtraInfo
+        {
+            QString m_jobFingerprint;
+            QString m_dependencyFilePath;
+            QString m_jobDependencyFilePath;
+            QString m_analysisFingerprint;
+            AZStd::vector<AZ::u32> m_subIdDependencies;
+        };
 
         //! AssetProcessor::AssetBuilderInfoBus Interface
         void GetMatchingBuildersInfo(const AZStd::string& assetPath, AssetProcessor::BuilderInfoList& builderInfoList) override;
         void GetAllBuildersInfo(AssetProcessor::BuilderInfoList& builderInfoList) override;
 
-        void CreateJobs(const AssetBuilderSDK::CreateJobsRequest& request, AssetBuilderSDK::CreateJobsResponse& response);
-        void ProcessJob(const AssetBuilderSDK::ProcessJobRequest& request, AssetBuilderSDK::ProcessJobResponse& response);
+        void CreateJobs(
+            AssetBuilderExtraInfo extraInfo,
+            const AssetBuilderSDK::CreateJobsRequest& request,
+            AssetBuilderSDK::CreateJobsResponse& response);
+        void ProcessJob(
+            AssetBuilderExtraInfo extraInfo,
+            const AssetBuilderSDK::ProcessJobRequest& request,
+            AssetBuilderSDK::ProcessJobResponse& response);
+
+        void CreateBuilderDesc(
+            const QString& builderName,
+            const QString& builderId,
+            const AZStd::vector<AssetBuilderSDK::AssetBuilderPattern>& builderPatterns,
+            const AssetBuilderExtraInfo& extraInfo);
 
-        AssetBuilderSDK::AssetBuilderDesc CreateBuilderDesc(
+        // Use this version if you intend to update the extraInfo struct dynamically (be sure extraInfo does not go out of scope)
+        void CreateBuilderDescInfoRef(
             const QString& builderName,
             const QString& builderId,
-            const AZStd::vector<AssetBuilderSDK::AssetBuilderPattern>& builderPatterns);
+            const AZStd::vector<AssetBuilderSDK::AssetBuilderPattern>& builderPatterns,
+            const AssetBuilderExtraInfo& extraInfo);
+
+        void CreateBuilderDesc(
+            const QString& builderName,
+            const QString& builderId,
+            const AZStd::vector<AssetBuilderSDK::AssetBuilderPattern>& builderPatterns,
+            const AssetBuilderSDK::CreateJobFunction& createJobsFunction,
+            const AssetBuilderSDK::ProcessJobFunction& processJobFunction,
+            AZStd::optional<QString> analysisFingerprint = AZStd::nullopt);
+
+        AZStd::vector<AssetUtilities::BuilderFilePatternMatcher> m_matcherBuilderPatterns;
+        AZStd::unordered_map<AZ::Uuid, AssetBuilderSDK::AssetBuilderDesc> m_builderDescMap;
 
-        AssetBuilderSDK::AssetBuilderDesc m_builderDesc;
-        QString m_jobFingerprint;
-        QString m_dependencyFilePath;
-        QString m_jobDependencyFilePath;
-        AZStd::vector<AZ::u32> m_subIdDependencies;
         int m_createJobsCount = 0;
     };
 

+ 9 - 2
Code/Tools/AssetProcessor/native/tests/assetdatabase/AssetDatabaseTest.cpp

@@ -248,8 +248,11 @@ namespace UnitTests
         ASSERT_NE(jobEntry.m_jobID, AzToolsFramework::AssetDatabase::InvalidEntryId);
 
         // --- set up complete --- perform the test!
+        AZStd::bitset<64> flags;
+        flags.set(static_cast<int>(AssetBuilderSDK::ProductOutputFlags::IntermediateAsset | AssetBuilderSDK::ProductOutputFlags::ProductAsset));
 
-        ProductDatabaseEntry product{ AzToolsFramework::AssetDatabase::InvalidEntryId, jobEntry.m_jobID, 1, "SomeProduct1.dds", validAssetType1 };
+        ProductDatabaseEntry product{ AzToolsFramework::AssetDatabase::InvalidEntryId, jobEntry.m_jobID, 1, "SomeProduct1.dds", validAssetType1,
+            AZ::Uuid::CreateNull(), 0, flags};
 
         m_errorAbsorber->Clear();
         EXPECT_TRUE(m_data->m_connection.SetProduct(product));
@@ -286,7 +289,10 @@ namespace UnitTests
         ASSERT_TRUE(m_data->m_connection.SetJob(jobEntry));
         ASSERT_TRUE(m_data->m_connection.SetJob(jobEntry2));
 
-        ProductDatabaseEntry product{ AzToolsFramework::AssetDatabase::InvalidEntryId, jobEntry.m_jobID, 1, "SomeProduct1.dds", validAssetType1 };
+        AZStd::bitset<64> flags;
+        flags.set(static_cast<int>(AssetBuilderSDK::ProductOutputFlags::ProductAsset));
+        ProductDatabaseEntry product{ AzToolsFramework::AssetDatabase::InvalidEntryId, jobEntry.m_jobID, 1, "SomeProduct1.dds", validAssetType1,
+            AZ::Uuid::CreateNull(), 0, flags};
         ASSERT_TRUE(m_data->m_connection.SetProduct(product));
 
         // --- set up complete --- perform the test!
@@ -297,6 +303,7 @@ namespace UnitTests
         newProductData.m_productName = "different name.dds";
         newProductData.m_subID = 2;
         newProductData.m_jobPK = jobEntry2.m_jobID; // move it to the other job, too!
+        newProductData.m_flags.set(static_cast<int>(AssetBuilderSDK::ProductOutputFlags::IntermediateAsset));
 
         // update the product
         EXPECT_TRUE(m_data->m_connection.SetProduct(newProductData));

+ 193 - 0
Code/Tools/AssetProcessor/native/tests/assetmanager/AssetManagerTestingBase.cpp

@@ -0,0 +1,193 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#include <AzCore/Component/Entity.h>
+#include <AzCore/Jobs/JobContext.h>
+#include <AzCore/Jobs/JobManager.h>
+#include <AzCore/Jobs/JobManagerComponent.h>
+#include <AzCore/Settings/SettingsRegistryMergeUtils.h>
+#include <AzCore/std/parallel/binary_semaphore.h>
+#include <QCoreApplication>
+#include <native/tests/assetmanager/AssetManagerTestingBase.h>
+#include <native/utilities/AssetUtilEBusHelper.h>
+#include <unittests/UnitTestRunner.h>
+
+namespace UnitTests
+{
+    bool TestingDatabaseLocationListener::GetAssetDatabaseLocation(AZStd::string& location)
+    {
+        location = m_databaseLocation;
+        return true;
+    }
+
+    void TestingAssetProcessorManager::CheckActiveFiles(int count)
+    {
+        ASSERT_EQ(m_activeFiles.size(), count);
+    }
+
+    void TestingAssetProcessorManager::CheckFilesToExamine(int count)
+    {
+        ASSERT_EQ(m_filesToExamine.size(), count);
+    }
+
+    void TestingAssetProcessorManager::CheckJobEntries(int count)
+    {
+        ASSERT_EQ(m_jobEntries.size(), count);
+    }
+
+    void AssetManagerTestingBase::SetUp()
+    {
+        ScopedAllocatorSetupFixture::SetUp();
+
+        // File IO is needed to hash the files
+        if (AZ::IO::FileIOBase::GetInstance() == nullptr)
+        {
+            m_localFileIo = aznew AZ::IO::LocalFileIO();
+            AZ::IO::FileIOBase::SetInstance(m_localFileIo);
+        }
+
+        // Specify the database lives in the temp directory
+        AZ::IO::Path tempDir(m_tempDir.GetDirectory());
+        m_databaseLocationListener.m_databaseLocation = (tempDir / "test_database.sqlite").Native();
+
+        // We need a settings registry in order for APM to figure out the cache path
+        m_settingsRegistry = AZStd::make_unique<AZ::SettingsRegistryImpl>();
+        AZ::SettingsRegistry::Register(m_settingsRegistry.get());
+
+        auto projectPathKey =
+            AZ::SettingsRegistryInterface::FixedValueString(AZ::SettingsRegistryMergeUtils::BootstrapSettingsRootKey) + "/project_path";
+        m_settingsRegistry->Set(projectPathKey, m_tempDir.GetDirectory());
+        AZ::SettingsRegistryMergeUtils::MergeSettingsToRegistry_AddRuntimeFilePaths(*m_settingsRegistry);
+
+        // We need a QCoreApplication set up in order for QCoreApplication::processEvents to function
+        m_qApp = AZStd::make_unique<QCoreApplication>(m_argc, m_argv);
+        qRegisterMetaType<AssetProcessor::JobEntry>("JobEntry");
+        qRegisterMetaType<AssetBuilderSDK::ProcessJobResponse>("ProcessJobResponse");
+        qRegisterMetaType<AZStd::string>("AZStd::string");
+        qRegisterMetaType<AssetProcessor::AssetScanningStatus>("AssetProcessor::AssetScanningStatus");
+        qRegisterMetaType<QSet<AssetProcessor::AssetFileInfo>>("QSet<AssetFileInfo>");
+
+        // Platform config with an enabled platform and scanfolder required by APM to function and find the files
+        m_platformConfig = AZStd::make_unique<AssetProcessor::PlatformConfiguration>();
+        m_platformConfig->EnablePlatform(AssetBuilderSDK::PlatformInfo{ "pc", { "test" } });
+
+        m_platformConfig->EnableCommonPlatform();
+
+        AZStd::vector<AssetBuilderSDK::PlatformInfo> platforms;
+        m_platformConfig->PopulatePlatformsForScanFolder(platforms);
+
+        m_platformConfig->AddScanFolder(
+            AssetProcessor::ScanFolderInfo{ (tempDir / "folder").c_str(), "folder", "folder", false, true, platforms });
+
+        m_platformConfig->AddIntermediateScanFolder();
+
+        // Create the APM
+        m_assetProcessorManager = AZStd::make_unique<TestingAssetProcessorManager>(m_platformConfig.get());
+
+        // Cache the db pointer because the TEST_F generates a subclass which can't access this private member
+        m_stateData = m_assetProcessorManager->m_stateData;
+
+        // Cache the scanfolder db entry, for convenience
+        ASSERT_TRUE(m_stateData->GetScanFolderByPortableKey("folder", m_scanfolder));
+
+        // Configure our mock builder so APM can find the builder and run CreateJobs
+        m_builderInfoHandler.CreateBuilderDesc(
+            "test", AZ::Uuid::CreateRandom().ToString<QString>(),
+            { AssetBuilderSDK::AssetBuilderPattern("*.txt", AssetBuilderSDK::AssetBuilderPattern::Wildcard) }, {});
+        m_builderInfoHandler.BusConnect();
+
+        // Set up the Job Context, required for the PathDependencyManager to do its work
+        AZ::AllocatorInstance<AZ::PoolAllocator>::Create();
+        AZ::AllocatorInstance<AZ::ThreadPoolAllocator>::Create();
+
+        m_serializeContext = AZStd::make_unique<AZ::SerializeContext>();
+        m_descriptor = AZ::JobManagerComponent::CreateDescriptor();
+        m_descriptor->Reflect(m_serializeContext.get());
+
+        m_jobManagerEntity = aznew AZ::Entity{};
+        m_jobManagerEntity->CreateComponent<AZ::JobManagerComponent>();
+        m_jobManagerEntity->Init();
+        m_jobManagerEntity->Activate();
+
+        // Set up a mock disk space responder, required for RCController to process a job
+        m_diskSpaceResponder = AZStd::make_unique<::testing::NiceMock<MockDiskSpaceResponder>>();
+
+        ON_CALL(*m_diskSpaceResponder, CheckSufficientDiskSpace(::testing::_, ::testing::_, ::testing::_))
+            .WillByDefault(::testing::Return(true));
+
+        QObject::connect(
+            m_assetProcessorManager.get(), &AssetProcessor::AssetProcessorManager::AssetToProcess,
+            [this](AssetProcessor::JobDetails jobDetails)
+            {
+                m_jobDetailsList.push_back(jobDetails);
+            });
+    }
+
+    void AssetManagerTestingBase::TearDown()
+    {
+        m_diskSpaceResponder = nullptr;
+        m_builderInfoHandler.BusDisconnect();
+
+        AZ::SettingsRegistry::Unregister(m_settingsRegistry.get());
+
+        if (m_localFileIo)
+        {
+            delete m_localFileIo;
+            m_localFileIo = nullptr;
+            AZ::IO::FileIOBase::SetInstance(nullptr);
+        }
+
+        m_jobManagerEntity->Deactivate();
+        delete m_jobManagerEntity;
+
+        delete m_descriptor;
+
+        AZ::AllocatorInstance<AZ::ThreadPoolAllocator>::Destroy();
+        AZ::AllocatorInstance<AZ::PoolAllocator>::Destroy();
+
+        ScopedAllocatorSetupFixture::TearDown();
+    }
+
+    void AssetManagerTestingBase::RunFile(int expectedJobCount, int expectedFileCount, int dependencyFileCount)
+    {
+        m_jobDetailsList.clear();
+
+        m_assetProcessorManager->CheckActiveFiles(expectedFileCount);
+
+        // AssessModifiedFile is going to set up a OneShotTimer with a 1ms delay on it.  We have to wait a short time for that timer to
+        // elapse before we can process that event. If we use the alternative processEvents that loops for X milliseconds we could
+        // accidentally process too many events.
+        AZStd::this_thread::sleep_for(AZStd::chrono::milliseconds(10));
+        QCoreApplication::processEvents();
+
+        m_assetProcessorManager->CheckActiveFiles(0);
+        m_assetProcessorManager->CheckFilesToExamine(expectedFileCount + dependencyFileCount);
+
+        QCoreApplication::processEvents(); // execute ProcessFilesToExamineQueue
+
+        if (expectedJobCount > 0)
+        {
+            m_assetProcessorManager->CheckJobEntries(expectedFileCount + dependencyFileCount);
+
+            QCoreApplication::processEvents(); // execute CheckForIdle
+
+            ASSERT_EQ(m_jobDetailsList.size(), expectedJobCount + dependencyFileCount);
+        }
+    }
+
+    void AssetManagerTestingBase::ProcessJob(AssetProcessor::RCController& rcController, const AssetProcessor::JobDetails& jobDetails)
+    {
+        rcController.JobSubmitted(jobDetails);
+
+        JobSignalReceiver receiver;
+        QCoreApplication::processEvents(); // Once to get the job started
+        receiver.WaitForFinish(); // Wait for the RCJob to signal it has completed working
+        QCoreApplication::processEvents(); // Once more to trigger the JobFinished event
+        QCoreApplication::processEvents(); // Again to trigger the Finished event
+    }
+} // namespace UnitTests

+ 128 - 0
Code/Tools/AssetProcessor/native/tests/assetmanager/AssetManagerTestingBase.h

@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#pragma once
+
+#include <API/AssetDatabaseBus.h>
+#include <AzCore/Settings/SettingsRegistryImpl.h>
+#include <AzCore/UnitTest/TestTypes.h>
+#include <AzCore/std/parallel/binary_semaphore.h>
+#include <Tests/Utils/Utils.h>
+#include <native/AssetManager/assetProcessorManager.h>
+#include <resourcecompiler/rccontroller.h>
+#include <tests/UnitTestUtilities.h>
+#include <QCoreApplication>
+
+namespace UnitTests
+{
+    class MockDiskSpaceResponder : public AssetProcessor::DiskSpaceInfoBus::Handler
+    {
+    public:
+        MOCK_METHOD3(CheckSufficientDiskSpace, bool(const QString&, qint64, bool));
+
+        MockDiskSpaceResponder()
+        {
+            BusConnect();
+        }
+
+        ~MockDiskSpaceResponder()
+        {
+            BusDisconnect();
+        }
+    };
+
+    class TestingDatabaseLocationListener : public AzToolsFramework::AssetDatabase::AssetDatabaseRequests::Bus::Handler
+    {
+    public:
+        TestingDatabaseLocationListener()
+        {
+            BusConnect();
+        }
+        ~TestingDatabaseLocationListener() override
+        {
+            BusDisconnect();
+        }
+
+        bool GetAssetDatabaseLocation(AZStd::string& location) override;
+
+        AZStd::string m_databaseLocation;
+    };
+
+    class JobSignalReceiver : AZ::Interface<AssetProcessor::IRCJobSignal>::Registrar
+    {
+    public:
+        AZ_RTTI(JobSignalReceiver, "{8C1BEBF9-655C-4352-84DB-3BBB421CB3D3}", AssetProcessor::IRCJobSignal);
+
+        void Finished() override
+        {
+            m_signal.release();
+        }
+
+        void WaitForFinish()
+        {
+            m_signal.acquire();
+        }
+
+    protected:
+        AZStd::binary_semaphore m_signal;
+    };
+
+    class AssetManagerTestingBase;
+
+    class TestingAssetProcessorManager : public AssetProcessor::AssetProcessorManager
+    {
+    public:
+        friend class AssetManagerTestingBase;
+
+        TestingAssetProcessorManager(AssetProcessor::PlatformConfiguration* config, QObject* parent = nullptr)
+            : AssetProcessorManager(config, parent)
+        {
+        }
+
+        void CheckActiveFiles(int count);
+        void CheckFilesToExamine(int count);
+        void CheckJobEntries(int count);
+    };
+
+    class AssetManagerTestingBase : public UnitTest::ScopedAllocatorSetupFixture
+    {
+    public:
+        void SetUp() override;
+        void TearDown() override;
+
+    protected:
+        void CreateTestData(AZ::u64 hashA, AZ::u64 hashB, bool useSubId);
+        void RunTest(bool firstProductChanged, bool secondProductChanged);
+
+        void RunFile(int expectedJobCount, int expectedFileCount = 1, int dependencyFileCount = 0);
+        void ProcessJob(AssetProcessor::RCController& rcController, const AssetProcessor::JobDetails& jobDetails);
+
+        int m_argc = 0;
+        char** m_argv{};
+
+        AssetProcessor::FileStatePassthrough m_fileStateCache;
+
+        AZStd::unique_ptr<QCoreApplication> m_qApp;
+        AZStd::unique_ptr<TestingAssetProcessorManager> m_assetProcessorManager;
+        AZStd::unique_ptr<AssetProcessor::PlatformConfiguration> m_platformConfig;
+        AZStd::unique_ptr<AZ::SettingsRegistryImpl> m_settingsRegistry;
+        AZStd::shared_ptr<AssetProcessor::AssetDatabaseConnection> m_stateData;
+        AZStd::unique_ptr<::testing::NiceMock<MockDiskSpaceResponder>> m_diskSpaceResponder;
+        AZ::Test::ScopedAutoTempDirectory m_tempDir;
+        TestingDatabaseLocationListener m_databaseLocationListener;
+        AzToolsFramework::AssetDatabase::ScanFolderDatabaseEntry m_scanfolder;
+        MockMultiBuilderInfoHandler m_builderInfoHandler;
+        AZ::IO::LocalFileIO* m_localFileIo;
+
+        AZStd::unique_ptr<AZ::SerializeContext> m_serializeContext;
+        AZ::Entity* m_jobManagerEntity{};
+        AZ::ComponentDescriptor* m_descriptor{};
+
+        AZStd::vector<AssetProcessor::JobDetails> m_jobDetailsList;
+    };
+} // namespace UnitTests

+ 75 - 141
Code/Tools/AssetProcessor/native/tests/assetmanager/AssetProcessorManagerTest.cpp

@@ -149,6 +149,7 @@ void AssetProcessorManagerTest::SetUp()
     m_config->AddScanFolder(ScanFolderInfo(tempPath.filePath("subfolder3"), "subfolder3", "subfolder3", false, true, m_config->GetEnabledPlatforms(), 1));
     m_config->AddScanFolder(ScanFolderInfo(tempPath.filePath("subfolder4"), "subfolder4", "subfolder4", false, true, m_config->GetEnabledPlatforms(), 1));
     m_config->AddMetaDataType("assetinfo", "");
+    m_config->AddIntermediateScanFolder();
 
     AssetRecognizer rec;
     rec.m_name = "txt files";
@@ -217,12 +218,11 @@ TEST_F(AssetProcessorManagerTest, UnitTestForGettingJobInfoBySourceUUIDSuccess)
     entry.m_jobKey = "txt";
     entry.m_platformInfo = { "pc", {"host", "renderer", "desktop"} };
     entry.m_jobRunKey = 1;
-
-    UnitTestUtils::CreateDummyFile(m_normalizedCacheRootDir.absoluteFilePath("outputfile.txt"));
+    UnitTestUtils::CreateDummyFile(m_normalizedCacheRootDir.absoluteFilePath("pc/outputfile.txt"));
 
     AssetBuilderSDK::ProcessJobResponse jobResponse;
     jobResponse.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-    jobResponse.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(m_normalizedCacheRootDir.absoluteFilePath("outputfile.txt").toUtf8().data()));
+    jobResponse.m_outputProducts.push_back(AssetBuilderSDK::JobProduct("outputfile.txt"));
 
     QMetaObject::invokeMethod(m_assetProcessorManager.get(), "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, entry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, jobResponse));
 
@@ -283,11 +283,11 @@ TEST_F(AssetProcessorManagerTest, WarningsAndErrorsReported_SuccessfullySavedToD
     entry.m_platformInfo = { "pc", {"host", "renderer", "desktop"} };
     entry.m_jobRunKey = 1;
 
-    UnitTestUtils::CreateDummyFile(m_normalizedCacheRootDir.absoluteFilePath("outputfile.txt"));
+    UnitTestUtils::CreateDummyFile(m_normalizedCacheRootDir.absoluteFilePath("pc/outputfile.txt"));
 
     AssetBuilderSDK::ProcessJobResponse jobResponse;
     jobResponse.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-    jobResponse.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(m_normalizedCacheRootDir.absoluteFilePath("outputfile.txt").toUtf8().data()));
+    jobResponse.m_outputProducts.push_back(AssetBuilderSDK::JobProduct("outputfile.txt"));
 
     JobDiagnosticRequestBus::Broadcast(&JobDiagnosticRequestBus::Events::RecordDiagnosticInfo, entry.m_jobRunKey, JobDiagnosticInfo(11, 22));
 
@@ -1330,16 +1330,18 @@ bool PathDependencyTest::ProcessAsset(TestAsset& asset, const OutputAssetSet& ou
 
         for (const char* outputExtension : outputSet)
         {
-            if(jobSet >= capturedDetails.size() || capturedDetails[jobSet].m_destinationPath.isEmpty())
+            if(jobSet >= capturedDetails.size() || capturedDetails[jobSet].m_cachePath.empty())
             {
                 return false;
             }
 
-            QString outputAssetPath = QDir(capturedDetails[jobSet].m_destinationPath).absoluteFilePath(QString(asset.m_name.c_str()) + outputExtension);
+            auto filename = capturedDetails[jobSet].m_relativePath / (asset.m_name + outputExtension);
 
-            UnitTestUtils::CreateDummyFile(outputAssetPath, "this is a test output asset");
+            AssetUtilities::ProductPath productPath{ filename.Native(), capturedDetails[jobSet].m_jobEntry.m_platformInfo.m_identifier };
 
-            JobProduct jobProduct(outputAssetPath.toUtf8().constData(), AZ::Uuid::CreateRandom(), subIdCounter);
+            UnitTestUtils::CreateDummyFile(productPath.GetCachePath().c_str(), "this is a test output asset");
+
+            JobProduct jobProduct(productPath.GetRelativePath(), AZ::Uuid::CreateRandom(), subIdCounter);
             jobProduct.m_pathDependencies.insert(dependencies.begin(), dependencies.end());
 
             processJobResponse.m_outputProducts.push_back(jobProduct);
@@ -1443,11 +1445,12 @@ TEST_F(DuplicateProcessTest, SameAssetProcessedTwice_DependenciesResolveWithoutE
         processJobResponse.m_resultCode = ProcessJobResult_Success;
 
         {
-            QString outputAssetPath = QDir(job.m_destinationPath).absoluteFilePath("test.asset");
+            auto filename = "test.asset";
+            QString outputAssetPath = (job.m_cachePath / filename).AsPosix().c_str();
 
             UnitTestUtils::CreateDummyFile(outputAssetPath, "this is a test output asset");
 
-            JobProduct jobProduct(outputAssetPath.toUtf8().constData());
+            JobProduct jobProduct(filename);
             jobProduct.m_pathDependencies.insert(dependencies.begin(), dependencies.end());
 
             processJobResponse.m_outputProducts.push_back(jobProduct);
@@ -1545,14 +1548,15 @@ TEST_F(PathDependencyTest, AssetProcessed_Impl_SelfReferrentialProductDependency
     ProcessJobResponse processJobResponse;
     processJobResponse.m_resultCode = ProcessJobResult_Success;
 
-    ASSERT_FALSE(jobDetails.m_destinationPath.isEmpty());
+    ASSERT_FALSE(jobDetails.m_cachePath.empty());
 
     // create a product asset
-    QString outputAssetPath = QDir(jobDetails.m_destinationPath).absoluteFilePath(QString(mainFile.m_name.c_str()) + ".asset");
+    auto filename = mainFile.m_name + ".asset";
+    QString outputAssetPath = (jobDetails.m_cachePath / filename).AsPosix().c_str();
     UnitTestUtils::CreateDummyFile(outputAssetPath, "this is a test output asset");
 
     // add the new product asset to its own product dependencies list by assetId
-    JobProduct jobProduct(outputAssetPath.toUtf8().constData(), outputAssetTypeId, subId);
+    JobProduct jobProduct(filename, outputAssetTypeId, subId);
     AZ::Data::AssetId productAssetId(jobDetails.m_jobEntry.m_sourceFileUUID, subId);
     jobProduct.m_dependencies.push_back(ProductDependency(productAssetId, 5));
 
@@ -2226,9 +2230,11 @@ TEST_F(PathDependencyTest, AbsoluteDependencies_Deferred_ResolveCorrectly)
     QDir tempPath(m_tempDir.path());
     AZStd::string relativePathDep1("dep1.txt");
     QString absPathDep1(tempPath.absoluteFilePath(QString("subfolder4%1%2").arg(QDir::separator()).arg(relativePathDep1.c_str())));
+
+    auto scanfolder4 = m_config->GetScanFolderForFile(absPathDep1);
     // When an absolute path matches a scan folder, the portion of the path matching that scan folder
     // is replaced with the scan folder's ID.
-    AZStd::string absPathDep1WithScanfolder(AZStd::string::format("$4$%s", relativePathDep1.c_str()));
+    AZStd::string absPathDep1WithScanfolder(AZStd::string::format("$%" PRId64 "$%s", aznumeric_cast<int64_t>(scanfolder4->ScanFolderID()), relativePathDep1.c_str()));
     QString absPathDep2(tempPath.absoluteFilePath("subfolder2/redirected/dep2.txt"));
     QString absPathDep3(tempPath.absoluteFilePath("subfolder1/dep3.txt"));
 
@@ -2504,9 +2510,10 @@ void MultiplatformPathDependencyTest::SetUp()
     m_config->EnablePlatform({ "provo",{ "console" } }, true);
     QDir tempPath(m_tempDir.path());
 
-    m_config->AddScanFolder(ScanFolderInfo(tempPath.filePath("subfolder1"), "subfolder1", "subfolder1", false, true, m_config->GetEnabledPlatforms() ));
+    m_config->AddScanFolder(ScanFolderInfo(tempPath.filePath("subfolder1"), "subfolder1", "subfolder1", false, true, m_config->GetEnabledPlatforms()));
     m_config->AddScanFolder(ScanFolderInfo(tempPath.filePath("subfolder2"), "subfolder2", "subfolder2", false, true, m_config->GetEnabledPlatforms()));
     m_config->AddScanFolder(ScanFolderInfo(tempPath.filePath("subfolder3"), "subfolder3", "subfolder3", false, true, m_config->GetEnabledPlatforms()));
+    m_config->AddIntermediateScanFolder();
 
     m_assetProcessorManager = nullptr; // we need to destroy the previous instance before creating a new one
     m_assetProcessorManager = AZStd::make_unique<AssetProcessorManager_Test>(m_config.get());
@@ -2716,14 +2723,14 @@ TEST_F(AssetProcessorManagerTest, AssetProcessedImpl_DifferentProductDependencie
     ProcessJobResponse response;
     response.m_resultCode = ProcessJobResult_Success;
 
-    QString destTestPath1 = QDir(capturedDetails.m_destinationPath).absoluteFilePath("test1.txt");
-    QString destTestPath2 = QDir(capturedDetails.m_destinationPath).absoluteFilePath("test2.txt");
+    QString destTestPath1 = (capturedDetails.m_cachePath / "test1.txt").AsPosix().c_str();
+    QString destTestPath2 = (capturedDetails.m_cachePath / "test2.txt").AsPosix().c_str();
 
     UnitTestUtils::CreateDummyFile(destTestPath1, "this is the first output");
     UnitTestUtils::CreateDummyFile(destTestPath2, "this is the second output");
 
-    JobProduct productA(destTestPath1.toUtf8().constData(), AZ::Uuid::CreateRandom(), 1);
-    JobProduct productB(destTestPath2.toUtf8().constData(), AZ::Uuid::CreateRandom(), 2);
+    JobProduct productA("test1.txt", AZ::Uuid::CreateRandom(), 1);
+    JobProduct productB("test2.txt", AZ::Uuid::CreateRandom(), 2);
     AZ::Data::AssetId expectedIdOfProductA(capturedDetails.m_jobEntry.m_sourceFileUUID, productA.m_productSubID);
     AZ::Data::AssetId expectedIdOfProductB(capturedDetails.m_jobEntry.m_sourceFileUUID, productB.m_productSubID);
 
@@ -2822,11 +2829,11 @@ TEST_F(AssetProcessorManagerTest, AssessDeletedFile_OnJobInFlight_IsIgnored)
     response.m_resultCode = ProcessJobResult_Success;
     for (int outputIdx = 0; outputIdx < numOutputsToSimulate; ++outputIdx)
     {
-        QString fileNameToGenerate = QString("test%1.txt").arg(outputIdx);
-        QString filePathToGenerate = QDir(capturedDetails.m_destinationPath).absoluteFilePath(fileNameToGenerate);
+        auto fileNameToGenerate = AZStd::string::format("test%d.txt", outputIdx);
+        QString filePathToGenerate = (capturedDetails.m_cachePath / fileNameToGenerate).AsPosix().c_str();
 
         UnitTestUtils::CreateDummyFile(filePathToGenerate, "an output");
-        JobProduct product(filePathToGenerate.toUtf8().constData(), AZ::Uuid::CreateRandom(), static_cast<AZ::u32>(outputIdx));
+        JobProduct product(fileNameToGenerate, AZ::Uuid::CreateRandom(), static_cast<AZ::u32>(outputIdx));
         response.m_outputProducts.push_back(product);
     }
 
@@ -2863,7 +2870,7 @@ TEST_F(AssetProcessorManagerTest, AssessDeletedFile_OnJobInFlight_IsIgnored)
     // we should have gotten at least one request to actually process that job:
     ASSERT_STREQ(capturedDetails.m_jobEntry.GetAbsoluteSourcePath().toUtf8().constData(), absPath.toUtf8().constData());
     ASSERT_FALSE(capturedDetails.m_autoFail);
-    ASSERT_FALSE(capturedDetails.m_destinationPath.isEmpty());
+    ASSERT_FALSE(capturedDetails.m_cachePath.empty());
     // ----------------------------- TEST BEGINS HERE -----------------------------
     // simulte a very slow computer processing the file one output at a time and feeding file change notifies:
 
@@ -2899,10 +2906,10 @@ TEST_F(AssetProcessorManagerTest, AssessDeletedFile_OnJobInFlight_IsIgnored)
         // every second one, we dont wait at all and let it rapidly process, to preturb the timing.
         bool shouldBlockAndWaitThisTime = outputIdx % 2 == 0;
 
-        QString fileNameToGenerate = QString("test%1.txt").arg(outputIdx);
-        QString filePathToGenerate = QDir(capturedDetails.m_destinationPath).absoluteFilePath(fileNameToGenerate);
+        auto fileNameToGenerate = AZStd::string::format("test%d.txt", outputIdx);
+        QString filePathToGenerate = (capturedDetails.m_cachePath / fileNameToGenerate).AsPosix().c_str();
 
-        JobProduct product(filePathToGenerate.toUtf8().constData(), AZ::Uuid::CreateRandom(), static_cast<AZ::u32>(outputIdx));
+        JobProduct product(fileNameToGenerate, AZ::Uuid::CreateRandom(), static_cast<AZ::u32>(outputIdx));
         response.m_outputProducts.push_back(product);
 
         AssetProcessor::ProcessingJobInfoBus::Broadcast(&AssetProcessor::ProcessingJobInfoBus::Events::BeginCacheFileUpdate, filePathToGenerate.toUtf8().data());
@@ -3059,7 +3066,7 @@ TEST_F(SourceFileDependenciesTest, UpdateSourceFileDependenciesDatabase_BasicTes
 {
     AssetProcessor::AssetProcessorManager::JobToProcessEntry job;
     SetupData({ MakeSourceDependency("a.txt"), MakeSourceDependency(m_uuidOfB) }, { MakeJobDependency("c.txt"), MakeJobDependency(m_uuidOfD) }, true, true, true, job);
-    
+
     // the rest of this test now performs a series of queries to verify the database was correctly set.
     // this indirectly verifies the QueryAbsolutePathDependenciesRecursive function also but it has its own dedicated tests, above.
     AssetProcessor::SourceFilesForFingerprintingContainer deps;
@@ -3093,10 +3100,10 @@ TEST_F(SourceFileDependenciesTest, UpdateSourceFileDependenciesDatabase_UpdateTe
 {
     // make sure that if we remove dependencies that are published, they disappear.
     // so the first part of this test is to put some data in there, the same as before:
-    
+
     AssetProcessor::AssetProcessorManager::JobToProcessEntry job;
     SetupData({ MakeSourceDependency("a.txt"), MakeSourceDependency(m_uuidOfB) }, { MakeJobDependency("c.txt"), MakeJobDependency(m_uuidOfD) }, true, true, true, job);
-    
+
     // in this test, though, we delete some after pushing them in there, and update it again:
     job.m_sourceFileDependencies.pop_back(); // erase the 'b' dependency.
     job.m_jobsToAnalyze[0].m_jobDependencyList.pop_back(); // erase the 'd' dependency, which is by guid.
@@ -3129,10 +3136,10 @@ TEST_F(SourceFileDependenciesTest, UpdateSourceFileDependenciesDatabase_UpdateTe
 TEST_F(SourceFileDependenciesTest, UpdateSourceFileDependenciesDatabase_MissingFiles_ByUuid)
 {
     // make sure that if we publish some dependencies, they do not appear if they are missing
-    
+
     AssetProcessor::AssetProcessorManager::JobToProcessEntry job;
     SetupData({ MakeSourceDependency("a.txt"), MakeSourceDependency(m_uuidOfB) }, { MakeJobDependency("c.txt"), MakeJobDependency(m_uuidOfD) }, false, true, true, job);
-    
+
     // the rest of this test now performs a series of queries to verify the database was correctly set.
     // this indirectly verifies the QueryAbsolutePathDependenciesRecursive function also but it has its own dedicated tests, above.
     AssetProcessor::SourceFilesForFingerprintingContainer deps;
@@ -3199,7 +3206,7 @@ TEST_F(SourceFileDependenciesTest, UpdateSourceFileDependenciesDatabase_MissingF
 
     AssetProcessor::AssetProcessorManager::JobToProcessEntry job;
     SetupData({ MakeSourceDependency("a.txt"), MakeSourceDependency(m_uuidOfB) }, { MakeJobDependency("c.txt"), MakeJobDependency(m_uuidOfD) }, true, false, false, job);
-    
+
     // so at this point, the database should be in the same state as after the UpdateSourceFileDependenciesDatabase_MissingFiles_ByUuid test
     // which was already verified, by that test.
 
@@ -3266,7 +3273,7 @@ TEST_F(SourceFileDependenciesTest, UpdateSourceFileDependenciesDatabase_MissingF
 
     AssetProcessor::AssetProcessorManager::JobToProcessEntry job;
     SetupData({ MakeSourceDependency("a.txt"), MakeSourceDependency(m_uuidOfB) }, { MakeJobDependency("c.txt"), MakeJobDependency(m_uuidOfD) }, false, true, true, job);
-    
+
     // so at this point, the database should be in the same state as after the UpdateSourceFileDependenciesDatabase_MissingFiles_ByUuid test
     // which was already verified, by that test.
 
@@ -3457,19 +3464,19 @@ TEST_F(AssetProcessorManagerTest, JobDependencyOrderOnce_MultipleJobs_EmitOK)
     EXPECT_EQ(jobDetails[1].m_jobDependencyList[0].m_jobDependency.m_sourceFile.m_sourceFileDependencyPath, secondRelSourceFile); // there should only be one job dependency
 
     // Process jobs in APM
-    QDir destination(jobDetails[0].m_destinationPath);
-    QString productAFileName = destination.absoluteFilePath("aoutput.txt");
-    QString productBFileName = destination.absoluteFilePath("boutput.txt");
+    auto destination = jobDetails[0].m_cachePath;
+    QString productAFileName = (destination / "aoutput.txt").AsPosix().c_str();
+    QString productBFileName = (destination / "boutput.txt").AsPosix().c_str();
     ASSERT_TRUE(UnitTestUtils::CreateDummyFile(productBFileName, QString("tempdata\n")));
     ASSERT_TRUE(UnitTestUtils::CreateDummyFile(productAFileName, QString("tempdata\n")));
 
     AssetBuilderSDK::ProcessJobResponse responseB;
     responseB.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-    responseB.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(productBFileName.toUtf8().constData(), AZ::Uuid::CreateNull(), 1));
+    responseB.m_outputProducts.push_back(AssetBuilderSDK::JobProduct("boutput.txt", AZ::Uuid::CreateNull(), 1));
 
     AssetBuilderSDK::ProcessJobResponse responseA;
     responseA.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-    responseA.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(productAFileName.toUtf8().constData(), AZ::Uuid::CreateNull(), 1));
+    responseA.m_outputProducts.push_back(AssetBuilderSDK::JobProduct("aoutput.txt", AZ::Uuid::CreateNull(), 1));
 
     m_isIdling = false;
     QMetaObject::invokeMethod(m_assetProcessorManager.get(), "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, jobDetails[0].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, responseB));
@@ -3574,7 +3581,7 @@ TEST_F(AssetProcessorManagerTest, SourceFile_With_NonASCII_Characters_Fail_Job_O
     folderPathDir.removeRecursively();
     m_assetProcessorManager.get()->AssessDeletedFile(folderPath);
     ASSERT_TRUE(BlockUntilIdle(5000));
-    EXPECT_EQ(deletedFolderPath, "Test\xD0");
+    EXPECT_EQ(deletedFolderPath, folderPath);
 }
 
 TEST_F(AssetProcessorManagerTest, SourceFileProcessFailure_ClearsFingerprint)
@@ -3606,14 +3613,14 @@ TEST_F(AssetProcessorManagerTest, SourceFileProcessFailure_ClearsFingerprint)
 
     for(const auto& processResult : processResults)
     {
-        auto file = QDir(processResult.m_destinationPath).absoluteFilePath(processResult.m_jobEntry.m_databaseSourceName + ".arc1");
+        AZStd::string file = (processResult.m_jobEntry.m_databaseSourceName + ".arc1").toUtf8().constData();
 
         // Create the file on disk
-        ASSERT_TRUE(UnitTestUtils::CreateDummyFile(file, "products."));
+        ASSERT_TRUE(UnitTestUtils::CreateDummyFile((processResult.m_cachePath / file).AsPosix().c_str(), "products."));
 
         AssetBuilderSDK::ProcessJobResponse response;
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(file.toUtf8().constData(), AZ::Uuid::CreateNull(), 1));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(file, AZ::Uuid::CreateNull(), 1));
 
         m_assetProcessorManager->AssetProcessed(processResult.m_jobEntry, response);
     }
@@ -3674,11 +3681,8 @@ void FingerprintTest::SetUp()
     // We don't want the mock application manager to provide builder descriptors, mockBuilderInfoHandler will provide our own
     m_mockApplicationManager->BusDisconnect();
 
-    m_mockBuilderInfoHandler.m_builderDesc = m_mockBuilderInfoHandler.CreateBuilderDesc("test builder", "{DF09DDC0-FD22-43B6-9E22-22C8574A6E1E}", { AssetBuilderSDK::AssetBuilderPattern("*.txt", AssetBuilderSDK::AssetBuilderPattern::Wildcard) });
-    m_mockBuilderInfoHandler.BusConnect();
-
     // Create the test file
-    const auto& scanFolder = m_config->GetScanFolderAt(0);
+    const auto& scanFolder = m_config->GetScanFolderAt(1);
     QString relativePathFromWatchFolder("fingerprintTest.txt");
     m_absolutePath = QDir(scanFolder.ScanPath()).absoluteFilePath(relativePathFromWatchFolder);
 
@@ -3700,8 +3704,12 @@ void FingerprintTest::TearDown()
 
 void FingerprintTest::RunFingerprintTest(QString builderFingerprint, QString jobFingerprint, bool expectedResult)
 {
-    m_mockBuilderInfoHandler.m_builderDesc.m_analysisFingerprint = builderFingerprint.toUtf8().data();
-    m_mockBuilderInfoHandler.m_jobFingerprint = jobFingerprint;
+    m_mockBuilderInfoHandler.CreateBuilderDesc(
+        "test builder", "{DF09DDC0-FD22-43B6-9E22-22C8574A6E1E}",
+        { AssetBuilderSDK::AssetBuilderPattern("*.txt", AssetBuilderSDK::AssetBuilderPattern::Wildcard) },
+        UnitTests::MockMultiBuilderInfoHandler::AssetBuilderExtraInfo{ jobFingerprint, "", "", builderFingerprint, {} });
+    m_mockBuilderInfoHandler.BusConnect();
+
     QMetaObject::invokeMethod(m_assetProcessorManager.get(), "AssessModifiedFile", Qt::QueuedConnection, Q_ARG(QString, m_absolutePath));
 
     ASSERT_TRUE(BlockUntilIdle(5000));
@@ -3862,13 +3870,14 @@ TEST_F(AssetProcessorManagerTest, RemoveSource_RemoveCacheFolderIfEmpty_Ok)
         QMetaObject::invokeMethod(m_assetProcessorManager.get(), "AssessModifiedFile", Qt::QueuedConnection, Q_ARG(QString, sourceFiles[idx]));
         ASSERT_TRUE(BlockUntilIdle(5000));
 
-        productFiles.append(QDir(jobDetails.m_destinationPath).absoluteFilePath("product_test%1.txt").arg(idx));
+        auto filename = AZStd::string::format("product_test%d.txt", idx);
+        productFiles.append((jobDetails.m_cachePath / filename).AsPosix().c_str());
         UnitTestUtils::CreateDummyFile(productFiles.back(), "product");
 
         // Populate ProcessJobResponse
         ProcessJobResponse response;
         response.m_resultCode = ProcessJobResult_Success;
-        JobProduct product(productFiles.back().toUtf8().constData(), AZ::Uuid::CreateRandom(), static_cast<AZ::u32>(idx));
+        JobProduct product((jobDetails.m_relativePath / filename).StringAsPosix(), AZ::Uuid::CreateRandom(), static_cast<AZ::u32>(idx));
         response.m_outputProducts.push_back(product);
 
         // Process the job
@@ -3895,7 +3904,7 @@ TEST_F(AssetProcessorManagerTest, RemoveSource_RemoveCacheFolderIfEmpty_Ok)
     ASSERT_FALSE(QFile::exists(productFiles[firstSourceIdx]));
 
     // Ensure that cache directory exists
-    QDir cacheDirectory(jobDetails.m_destinationPath);
+    QDir cacheDirectory(jobDetails.m_cachePath.AsPosix().c_str());
 
     ASSERT_TRUE(cacheDirectory.exists());
 
@@ -3963,12 +3972,13 @@ void DuplicateProductsTest::SetupDuplicateProductsTest(QString& sourceFile, QDir
     QMetaObject::invokeMethod(m_assetProcessorManager.get(), "AssessModifiedFile", Qt::QueuedConnection, Q_ARG(QString, sourceFile));
     ASSERT_TRUE(BlockUntilIdle(5000));
 
-    productFile.append(QDir(jobDetails[0].m_destinationPath).absoluteFilePath("product_test." + extension));
+    auto filename = "product_test." + extension;
+    productFile.append((jobDetails[0].m_cachePath / filename.toUtf8().constData()).AsPosix().c_str());
     UnitTestUtils::CreateDummyFile(productFile, "product");
 
     // Populate ProcessJobResponse
     response.m_resultCode = ProcessJobResult_Success;
-    JobProduct jobProduct(productFile.toUtf8().constData(), AZ::Uuid::CreateRandom(), static_cast<AZ::u32>(0));
+    JobProduct jobProduct(filename.toUtf8().constData(), AZ::Uuid::CreateRandom(), static_cast<AZ::u32>(0));
     response.m_outputProducts.push_back(jobProduct);
 
     // Process the first job
@@ -4044,10 +4054,11 @@ TEST_F(DuplicateProductsTest, SameSource_MultipleBuilder_NoDuplicateProductJob_N
     // ----------------------------- TEST BEGINS HERE -----------------------------
     // We will process another job with the same source file outputting a different product file
 
-    productFile = QDir(jobDetails[0].m_destinationPath).absoluteFilePath("product_test1.txt");
+    auto filename = "product_test1.txt";
+    productFile = (jobDetails[0].m_cachePath / filename).AsPosix().c_str();
     UnitTestUtils::CreateDummyFile(productFile, "product");
 
-    JobProduct newJobProduct(productFile.toUtf8().constData(), AZ::Uuid::CreateRandom(), static_cast<AZ::u32>(0));
+    JobProduct newJobProduct((jobDetails[0].m_relativePath / filename).c_str(), AZ::Uuid::CreateRandom(), static_cast<AZ::u32>(0));
     response.m_outputProducts.clear();
     response.m_outputProducts.push_back(newJobProduct);
 
@@ -4072,7 +4083,7 @@ void JobDependencyTest::SetUp()
     // We don't want the mock application manager to provide builder descriptors, mockBuilderInfoHandler will provide our own
     m_mockApplicationManager->BusDisconnect();
 
-    m_data->m_mockBuilderInfoHandler.m_builderDesc = m_data->m_mockBuilderInfoHandler.CreateBuilderDesc("test builder", m_data->m_builderUuid.ToString<QString>(), { AssetBuilderSDK::AssetBuilderPattern("*.txt", AssetBuilderSDK::AssetBuilderPattern::Wildcard) });
+    m_data->m_mockBuilderInfoHandler.CreateBuilderDescInfoRef("test builder", m_data->m_builderUuid.ToString<QString>(), { AssetBuilderSDK::AssetBuilderPattern("*.txt", AssetBuilderSDK::AssetBuilderPattern::Wildcard) }, m_data->m_assetBuilderConfig);
     m_data->m_mockBuilderInfoHandler.BusConnect();
 
     QDir tempPath(m_tempDir.path());
@@ -4115,7 +4126,7 @@ TEST_F(JobDependencyTest, JobDependency_ThatWasPreviouslyRun_IsFound)
     AZStd::vector<JobDetails> capturedDetails;
 
     capturedDetails.clear();
-    m_data->m_mockBuilderInfoHandler.m_jobDependencyFilePath = "a.txt";
+    m_data->m_assetBuilderConfig.m_jobDependencyFilePath = "a.txt";
     CaptureJobs(capturedDetails, "subfolder1/b.txt");
 
     ASSERT_EQ(capturedDetails.size(), 1);
@@ -4129,7 +4140,7 @@ TEST_F(JobDependencyTest, JobDependency_ThatWasJustRun_IsFound)
     CaptureJobs(capturedDetails, "subfolder1/c.txt");
 
     capturedDetails.clear();
-    m_data->m_mockBuilderInfoHandler.m_jobDependencyFilePath = "c.txt";
+    m_data->m_assetBuilderConfig.m_jobDependencyFilePath = "c.txt";
     CaptureJobs(capturedDetails, "subfolder1/b.txt");
 
     ASSERT_EQ(capturedDetails.size(), 1);
@@ -4142,7 +4153,7 @@ TEST_F(JobDependencyTest, JobDependency_ThatHasNotRun_IsNotFound)
     AZStd::vector<JobDetails> capturedDetails;
 
     capturedDetails.clear();
-    m_data->m_mockBuilderInfoHandler.m_jobDependencyFilePath = "c.txt";
+    m_data->m_assetBuilderConfig.m_jobDependencyFilePath = "c.txt";
     CaptureJobs(capturedDetails, "subfolder1/b.txt");
 
     ASSERT_EQ(capturedDetails.size(), 1);
@@ -4174,7 +4185,7 @@ void ChainJobDependencyTest::SetUp()
         }
 
         m_data->m_mockBuilderInfoHandler.CreateBuilderDesc(QString("test builder %1").arg(i), AZ::Uuid::CreateRandom().ToString<QString>(), { AssetBuilderSDK::AssetBuilderPattern(AZStd::string::format("*%d.txt", i), AssetBuilderSDK::AssetBuilderPattern::Wildcard) },
-            MockMultiBuilderInfoHandler::AssetBuilderExtraInfo{ jobDependencyPath });
+            UnitTests::MockMultiBuilderInfoHandler::AssetBuilderExtraInfo{ "", "", jobDependencyPath, "", {} });
     }
 
     m_data->m_mockBuilderInfoHandler.BusConnect();
@@ -4187,84 +4198,6 @@ void ChainJobDependencyTest::TearDown()
     AssetProcessorManagerTest::TearDown();
 }
 
-MockMultiBuilderInfoHandler::~MockMultiBuilderInfoHandler()
-{
-    BusDisconnect();
-}
-
-void MockMultiBuilderInfoHandler::GetMatchingBuildersInfo(const AZStd::string& assetPath, AssetProcessor::BuilderInfoList& builderInfoList)
-{
-    AZStd::set<AZ::Uuid>  uniqueBuilderDescIDs;
-
-    for (AssetUtilities::BuilderFilePatternMatcher& matcherPair : m_matcherBuilderPatterns)
-    {
-        if (uniqueBuilderDescIDs.find(matcherPair.GetBuilderDescID()) != uniqueBuilderDescIDs.end())
-        {
-            continue;
-        }
-        if (matcherPair.MatchesPath(assetPath))
-        {
-            const AssetBuilderSDK::AssetBuilderDesc& builderDesc = m_builderDescMap[matcherPair.GetBuilderDescID()];
-            uniqueBuilderDescIDs.insert(matcherPair.GetBuilderDescID());
-            builderInfoList.push_back(builderDesc);
-        }
-    }
-}
-
-void MockMultiBuilderInfoHandler::GetAllBuildersInfo([[maybe_unused]] AssetProcessor::BuilderInfoList& builderInfoList)
-{
-    // Only here to fulfill the interface requirement, this won't be called as part of the test
-    ASSERT_TRUE(false) << "Not implemented";
-}
-
-void MockMultiBuilderInfoHandler::CreateJobs(AssetBuilderExtraInfo extraInfo, const AssetBuilderSDK::CreateJobsRequest& request, AssetBuilderSDK::CreateJobsResponse& response)
-{
-    response.m_result = AssetBuilderSDK::CreateJobsResultCode::Success;
-
-    for (const auto& platform : request.m_enabledPlatforms)
-    {
-        AssetBuilderSDK::JobDescriptor jobDescriptor;
-        jobDescriptor.m_priority = 0;
-        jobDescriptor.m_critical = true;
-        jobDescriptor.m_jobKey = "Mock Job";
-        jobDescriptor.SetPlatformIdentifier(platform.m_identifier.c_str());
-
-        if (!extraInfo.m_jobDependencyFilePath.isEmpty())
-        {
-            jobDescriptor.m_jobDependencyList.push_back(AssetBuilderSDK::JobDependency("Mock Job", "pc", AssetBuilderSDK::JobDependencyType::Order,
-                AssetBuilderSDK::SourceFileDependency(extraInfo.m_jobDependencyFilePath.toUtf8().constData(), AZ::Uuid::CreateNull())));
-        }
-
-        response.m_createJobOutputs.push_back(jobDescriptor);
-        m_createJobsCount++;
-    }
-}
-
-void MockMultiBuilderInfoHandler::ProcessJob(AssetBuilderExtraInfo extraInfo, [[maybe_unused]] const AssetBuilderSDK::ProcessJobRequest& request, AssetBuilderSDK::ProcessJobResponse& response)
-{
-    response.m_resultCode = AssetBuilderSDK::ProcessJobResultCode::ProcessJobResult_Success;
-}
-
-void MockMultiBuilderInfoHandler::CreateBuilderDesc(const QString& builderName, const QString& builderId, const AZStd::vector<AssetBuilderSDK::AssetBuilderPattern>& builderPatterns, AssetBuilderExtraInfo extraInfo)
-{
-    AssetBuilderSDK::AssetBuilderDesc builderDesc;
-
-    builderDesc.m_name = builderName.toUtf8().data();
-    builderDesc.m_patterns = builderPatterns;
-    builderDesc.m_busId = AZ::Uuid::CreateString(builderId.toUtf8().data());
-    builderDesc.m_builderType = AssetBuilderSDK::AssetBuilderDesc::AssetBuilderType::Internal;
-    builderDesc.m_createJobFunction = AZStd::bind(&MockMultiBuilderInfoHandler::CreateJobs, this, extraInfo, AZStd::placeholders::_1, AZStd::placeholders::_2);
-    builderDesc.m_processJobFunction = AZStd::bind(&MockMultiBuilderInfoHandler::ProcessJob, this, extraInfo, AZStd::placeholders::_1, AZStd::placeholders::_2);
-
-    m_builderDescMap[builderDesc.m_busId] = builderDesc;
-
-    for (const AssetBuilderSDK::AssetBuilderPattern& pattern : builderDesc.m_patterns)
-    {
-        AssetUtilities::BuilderFilePatternMatcher patternMatcher(pattern, builderDesc.m_busId);
-        m_matcherBuilderPatterns.push_back(patternMatcher);
-    }
-}
-
 TEST_F(ChainJobDependencyTest, ChainDependency_EndCaseHasNoDependency)
 {
     AZStd::vector<JobDetails> capturedDetails;
@@ -4380,12 +4313,13 @@ TEST_F(MetadataFileTest, MetadataFile_SourceFileExtensionDifferentCase)
     entry.m_platformInfo = { "pc", {"host", "renderer", "desktop"} };
     entry.m_jobRunKey = 1;
 
-    QString productPath(m_normalizedCacheRootDir.absoluteFilePath("outputfile.TXT"));
+    const char* filename = "outputfile.TXT";
+    QString productPath(m_normalizedCacheRootDir.absoluteFilePath(filename));
     UnitTestUtils::CreateDummyFile(productPath);
 
     AssetBuilderSDK::ProcessJobResponse jobResponse;
     jobResponse.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-    jobResponse.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(productPath.toUtf8().data()));
+    jobResponse.m_outputProducts.push_back(AssetBuilderSDK::JobProduct("outputfile.TXT"));
 
     QMetaObject::invokeMethod(m_assetProcessorManager.get(), "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, entry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, jobResponse));
 

+ 4 - 29
Code/Tools/AssetProcessor/native/tests/assetmanager/AssetProcessorManagerTest.h

@@ -307,7 +307,7 @@ struct FingerprintTest
     void RunFingerprintTest(QString builderFingerprint, QString jobFingerprint, bool expectedResult);
 
     QString m_absolutePath;
-    UnitTests::MockBuilderInfoHandler m_mockBuilderInfoHandler;
+    UnitTests::MockMultiBuilderInfoHandler m_mockBuilderInfoHandler;
     AZStd::vector<AssetProcessor::JobDetails> m_jobResults;
 };
 
@@ -319,39 +319,14 @@ struct JobDependencyTest
 
     struct StaticData
     {
-        UnitTests::MockBuilderInfoHandler m_mockBuilderInfoHandler;
+        UnitTests::MockMultiBuilderInfoHandler m_mockBuilderInfoHandler;
+        UnitTests::MockMultiBuilderInfoHandler::AssetBuilderExtraInfo m_assetBuilderConfig;
         AZ::Uuid m_builderUuid;
     };
 
     AZStd::unique_ptr<StaticData> m_data;
 };
 
-struct MockMultiBuilderInfoHandler
-    : public AssetProcessor::AssetBuilderInfoBus::Handler
-{
-    ~MockMultiBuilderInfoHandler();
-
-    struct AssetBuilderExtraInfo
-    {
-        QString m_jobDependencyFilePath;
-    };
-
-    //! AssetProcessor::AssetBuilderInfoBus Interface
-    void GetMatchingBuildersInfo(const AZStd::string& assetPath, AssetProcessor::BuilderInfoList& builderInfoList) override;
-    void GetAllBuildersInfo(AssetProcessor::BuilderInfoList& builderInfoList) override;
-
-    void CreateJobs(AssetBuilderExtraInfo extraInfo, const AssetBuilderSDK::CreateJobsRequest& request, AssetBuilderSDK::CreateJobsResponse& response);
-    void ProcessJob(AssetBuilderExtraInfo extraInfo, const AssetBuilderSDK::ProcessJobRequest& request, AssetBuilderSDK::ProcessJobResponse& response);
-
-    void CreateBuilderDesc(const QString& builderName, const QString& builderId, const AZStd::vector<AssetBuilderSDK::AssetBuilderPattern>& builderPatterns, AssetBuilderExtraInfo extraInfo);
-
-    AZStd::vector<AssetBuilderSDK::AssetBuilderDesc> m_builderDesc;
-    AZStd::vector<AssetUtilities::BuilderFilePatternMatcher> m_matcherBuilderPatterns;
-    AZStd::unordered_map<AZ::Uuid, AssetBuilderSDK::AssetBuilderDesc> m_builderDescMap;
-
-    int m_createJobsCount = 0;
-};
-
 struct ChainJobDependencyTest
     : public PathDependencyTest
 {
@@ -360,7 +335,7 @@ struct ChainJobDependencyTest
 
     struct StaticData
     {
-        MockMultiBuilderInfoHandler m_mockBuilderInfoHandler;
+        UnitTests::MockMultiBuilderInfoHandler m_mockBuilderInfoHandler;
         AZStd::unique_ptr<AssetProcessor::RCController> m_rcController;
     };
 

+ 722 - 0
Code/Tools/AssetProcessor/native/tests/assetmanager/IntermediateAssetTests.cpp

@@ -0,0 +1,722 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#include <native/tests/assetmanager/IntermediateAssetTests.h>
+#include <QCoreApplication>
+#include <native/unittests/UnitTestRunner.h>
+
+namespace UnitTests
+{
+    AssetBuilderSDK::CreateJobFunction CreateJobStage(
+        const AZStd::string& name,
+        bool commonPlatform,
+        const AZStd::string& sourceDependencyPath = "")
+    {
+        using namespace AssetBuilderSDK;
+
+        // Note: capture by copy because we need these to stay around for a long time
+        return [name, commonPlatform, sourceDependencyPath]([[maybe_unused]] const CreateJobsRequest& request, CreateJobsResponse& response)
+        {
+            if (commonPlatform)
+            {
+                response.m_createJobOutputs.push_back(JobDescriptor{ "fingerprint", name, CommonPlatformName });
+            }
+            else
+            {
+                for (const auto& platform : request.m_enabledPlatforms)
+                {
+                    response.m_createJobOutputs.push_back(JobDescriptor{ "fingerprint", name, platform.m_identifier.c_str() });
+                }
+            }
+
+            if (!sourceDependencyPath.empty())
+            {
+                response.m_sourceFileDependencyList.push_back(SourceFileDependency{ sourceDependencyPath, AZ::Uuid::CreateNull() });
+            }
+
+            response.m_result = CreateJobsResultCode::Success;
+        };
+    }
+
+    AssetBuilderSDK::ProcessJobFunction ProcessJobStage(const AZStd::string& outputExtension, AssetBuilderSDK::ProductOutputFlags flags, bool outputExtraFile)
+    {
+        using namespace AssetBuilderSDK;
+
+        // Capture by copy because we need these to stay around a long time
+        return [outputExtension, flags, outputExtraFile](const ProcessJobRequest& request, ProcessJobResponse& response)
+        {
+            AZ::IO::Path outputFile = request.m_sourceFile;
+            outputFile.ReplaceExtension(outputExtension.c_str());
+
+            AZ::IO::LocalFileIO::GetInstance()->Copy(
+                request.m_fullPath.c_str(), (AZ::IO::Path(request.m_tempDirPath) / outputFile).c_str());
+
+            auto product = JobProduct{ outputFile.c_str(), AZ::Data::AssetType::CreateName(outputExtension.c_str()), 1 };
+
+            product.m_outputFlags = flags;
+            product.m_dependenciesHandled = true;
+            response.m_outputProducts.push_back(product);
+
+            if (outputExtraFile)
+            {
+                auto extraFilePath = AZ::IO::Path(request.m_tempDirPath) / "z_extra.txt"; // Z prefix to place at end of list when sorting for processing
+
+                UnitTestUtils::CreateDummyFile(extraFilePath.c_str(), "unit test file");
+
+                auto extraProduct = JobProduct{ extraFilePath.c_str(), AZ::Data::AssetType::CreateName("extra"), 2 };
+
+                extraProduct.m_outputFlags = flags;
+                extraProduct.m_dependenciesHandled = true;
+                response.m_outputProducts.push_back(extraProduct);
+            }
+
+            response.m_resultCode = ProcessJobResult_Success;
+        };
+    }
+
+    void IntermediateAssetTests::CreateBuilder(const char* name, const char* inputFilter, const char* outputExtension, bool createJobCommonPlatform, AssetBuilderSDK::ProductOutputFlags outputFlags, bool outputExtraFile)
+    {
+        using namespace AssetBuilderSDK;
+
+        m_builderInfoHandler.CreateBuilderDesc(
+            name, AZ::Uuid::CreateRandom().ToFixedString().c_str(),
+            { AssetBuilderPattern{ inputFilter, AssetBuilderPattern::Wildcard } }, CreateJobStage(name, createJobCommonPlatform),
+            ProcessJobStage(outputExtension, outputFlags, outputExtraFile), "fingerprint");
+    }
+
+    void IntermediateAssetTests::SetUp()
+    {
+        AssetManagerTestingBase::SetUp();
+
+        AZ::Debug::TraceMessageBus::Handler::BusConnect();
+
+        AZ::IO::Path scanFolderDir(m_scanfolder.m_scanFolder);
+        AZStd::string testFilename = "test.stage1";
+        m_testFilePath = (scanFolderDir / testFilename).AsPosix().c_str();
+
+        UnitTestUtils::CreateDummyFile(m_testFilePath.c_str(), "unit test file");
+
+        m_rc = AZStd::make_unique<AssetProcessor::RCController>(1, 1);
+
+        m_rc->SetDispatchPaused(false);
+
+        QObject::connect(
+            m_rc.get(), &AssetProcessor::RCController::FileFailed,
+            [this](auto entryIn)
+            {
+                m_fileFailed = true;
+            });
+
+        QObject::connect(
+            m_rc.get(), &AssetProcessor::RCController::FileCompiled,
+            [this](auto jobEntry, auto response)
+            {
+                m_fileCompiled = true;
+                m_processedJobEntry = jobEntry;
+                m_processJobResponse = response;
+            });
+
+        m_localFileIo->SetAlias("@log@", (AZ::IO::Path(m_tempDir.GetDirectory()) / "logs").c_str());
+    }
+
+    void IntermediateAssetTests::TearDown()
+    {
+        AZ::Debug::TraceMessageBus::Handler::BusDisconnect();
+
+        AssetManagerTestingBase::TearDown();
+    }
+
+    // Since AP will redirect any failures to a job log file, we won't see them output by default
+    // This will cause any error/assert to be printed out and mark the test as failed
+    bool IntermediateAssetTests::OnPreAssert(const char* fileName, int line, const char* /*func*/, const char* message)
+    {
+        if (m_expectedErrors > 0)
+        {
+            --m_expectedErrors;
+            return false;
+        }
+
+        UnitTest::ColoredPrintf(UnitTest::COLOR_RED, "Assert: %s\n", message);
+
+        ADD_FAILURE_AT(fileName, line);
+
+        return false;
+    }
+
+    bool IntermediateAssetTests::OnPreError(const char* /*window*/, const char* fileName, int line, const char* /*func*/, const char* message)
+    {
+        if (m_expectedErrors > 0)
+        {
+            --m_expectedErrors;
+            return false;
+        }
+
+        UnitTest::ColoredPrintf(UnitTest::COLOR_RED, "Error: %s\n", message);
+
+        ADD_FAILURE_AT(fileName, line);
+
+        return false;
+    }
+
+    void IntermediateAssetTests::IncorrectBuilderConfigurationTest(bool commonPlatform, AssetBuilderSDK::ProductOutputFlags flags)
+    {
+        using namespace AssetBuilderSDK;
+
+        CreateBuilder("stage1", "*.stage1", "stage2", commonPlatform, flags);
+
+        m_expectedErrors = 1;
+
+        QMetaObject::invokeMethod(
+            m_assetProcessorManager.get(), "AssessAddedFile", Qt::QueuedConnection, Q_ARG(QString, m_testFilePath.c_str()));
+        QCoreApplication::processEvents();
+
+        RunFile(1);
+        ProcessJob(*m_rc, m_jobDetailsList[0]);
+
+        ASSERT_TRUE(m_fileFailed);
+    }
+
+    AZStd::string IntermediateAssetTests::MakePath(const char* filename, bool intermediate)
+    {
+        auto cacheDir = AZ::IO::Path(m_tempDir.GetDirectory()) / "Cache";
+
+        if (intermediate)
+        {
+            cacheDir = AssetUtilities::GetIntermediateAssetsFolder(cacheDir);
+
+            return (cacheDir / filename).StringAsPosix();
+        }
+
+        return (cacheDir / "pc" / filename).StringAsPosix();
+    }
+
+    void IntermediateAssetTests::CheckProduct(const char* relativePath, bool exists)
+    {
+        auto expectedProductPath = MakePath(relativePath, false);
+        EXPECT_EQ(AZ::IO::SystemFile::Exists(expectedProductPath.c_str()), exists) << expectedProductPath.c_str();
+    }
+
+    void IntermediateAssetTests::CheckIntermediate(const char* relativePath, bool exists)
+    {
+        auto expectedIntermediatePath = MakePath(relativePath, true);
+        EXPECT_EQ(AZ::IO::SystemFile::Exists(expectedIntermediatePath.c_str()), exists) << expectedIntermediatePath.c_str();
+    }
+
+    void IntermediateAssetTests::ProcessSingleStep(int expectedJobCount, int expectedFileCount, int jobToRun, bool expectSuccess)
+    {
+        // Reset state
+        m_jobDetailsList.clear();
+        m_fileCompiled = false;
+        m_fileFailed = false;
+
+        RunFile(expectedJobCount, expectedFileCount);
+
+        std::stable_sort(
+            m_jobDetailsList.begin(), m_jobDetailsList.end(),
+            [](const AssetProcessor::JobDetails& a, const AssetProcessor::JobDetails& b) -> bool
+            {
+                return a.m_jobEntry.m_databaseSourceName.compare(b.m_jobEntry.m_databaseSourceName) < 0;
+            });
+
+        ProcessJob(*m_rc, m_jobDetailsList[jobToRun]);
+
+        if (expectSuccess)
+        {
+            ASSERT_TRUE(m_fileCompiled);
+            m_assetProcessorManager->AssetProcessed(m_processedJobEntry, m_processJobResponse);
+        }
+        else
+        {
+            ASSERT_TRUE(m_fileFailed);
+        }
+    }
+
+    void IntermediateAssetTests::ProcessFileMultiStage(
+        int endStage, bool doProductOutputCheck, const char* file, int startStage, bool expectAutofail, bool hasExtraFile)
+    {
+        auto cacheDir = AZ::IO::Path(m_tempDir.GetDirectory()) / "Cache";
+        auto intermediatesDir = AssetUtilities::GetIntermediateAssetsFolder(cacheDir);
+
+        if (file == nullptr)
+        {
+            file = m_testFilePath.c_str();
+        }
+
+        QMetaObject::invokeMethod(m_assetProcessorManager.get(), "AssessAddedFile", Qt::QueuedConnection, Q_ARG(QString, file));
+        QCoreApplication::processEvents();
+
+        for (int i = startStage; i <= endStage; ++i)
+        {
+            int expectedJobCount = 1;
+            int expectedFileCount = 1;
+            int jobToRun = 0;
+
+            // If there's an extra file output, it'll only show up after the 1st iteration
+            if (i > startStage && hasExtraFile)
+            {
+                expectedJobCount = 2;
+                expectedFileCount = 2;
+            }
+            else if (expectAutofail)
+            {
+                expectedJobCount = 2;
+                jobToRun = 1;
+            }
+
+            ProcessSingleStep(expectedJobCount, expectedFileCount, jobToRun, true);
+
+            if (i < endStage)
+            {
+                auto expectedIntermediatePath = intermediatesDir / AZStd::string::format("test.stage%d", i + 1);
+                EXPECT_TRUE(AZ::IO::SystemFile::Exists(expectedIntermediatePath.c_str())) << expectedIntermediatePath.c_str();
+            }
+
+            // Only first job should have an autofail due to a conflict
+            expectAutofail = false;
+        }
+
+        m_assetProcessorManager->CheckFilesToExamine(0);
+        m_assetProcessorManager->CheckActiveFiles(0);
+        m_assetProcessorManager->CheckJobEntries(0);
+
+        if (doProductOutputCheck)
+        {
+            CheckProduct(AZStd::string::format("test.stage%d", endStage + 1).c_str());
+        }
+    }
+
+    TEST_F(IntermediateAssetTests, FileProcessedAsIntermediateIntoProduct)
+    {
+        using namespace AssetBuilderSDK;
+
+        CreateBuilder("stage1", "*.stage1", "stage2", true, ProductOutputFlags::IntermediateAsset);
+        CreateBuilder("stage2", "*.stage2","stage3", false, ProductOutputFlags::ProductAsset);
+
+        ProcessFileMultiStage(2, true);
+    }
+
+    TEST_F(IntermediateAssetTests, IntermediateOutputWithWrongPlatform_CausesFailure)
+    {
+        IncorrectBuilderConfigurationTest(false, AssetBuilderSDK::ProductOutputFlags::IntermediateAsset);
+    }
+
+    TEST_F(IntermediateAssetTests, ProductOutputWithWrongPlatform_CausesFailure)
+    {
+        IncorrectBuilderConfigurationTest(true, AssetBuilderSDK::ProductOutputFlags::ProductAsset);
+    }
+
+    TEST_F(IntermediateAssetTests, IntermediateAndProductOutputFlags_NormalPlatform_CausesFailure)
+    {
+        IncorrectBuilderConfigurationTest(false, AssetBuilderSDK::ProductOutputFlags::IntermediateAsset | AssetBuilderSDK::ProductOutputFlags::ProductAsset);
+    }
+
+    TEST_F(IntermediateAssetTests, IntermediateAndProductOutputFlags_CommonPlatform_CausesFailure)
+    {
+        IncorrectBuilderConfigurationTest(true, AssetBuilderSDK::ProductOutputFlags::IntermediateAsset | AssetBuilderSDK::ProductOutputFlags::ProductAsset);
+    }
+
+    TEST_F(IntermediateAssetTests, NoFlags_CausesFailure)
+    {
+        IncorrectBuilderConfigurationTest(false, (AssetBuilderSDK::ProductOutputFlags)(0));
+    }
+
+    TEST_F(IntermediateAssetTests, ABALoop_CausesFailure)
+    {
+        using namespace AssetBuilderSDK;
+
+        CreateBuilder("stage1", "*.stage1", "stage2", true, ProductOutputFlags::IntermediateAsset);
+        CreateBuilder("stage2", "*.stage2", "stage3", true, ProductOutputFlags::IntermediateAsset);
+        CreateBuilder("stage3", "*.stage3", "stage2",  true, ProductOutputFlags::IntermediateAsset); // Loop back to an intermediate
+
+        ProcessFileMultiStage(3, false);
+
+        EXPECT_EQ(m_jobDetailsList.size(), 3);
+        EXPECT_TRUE(m_jobDetailsList[1].m_autoFail);
+        EXPECT_TRUE(m_jobDetailsList[2].m_autoFail);
+
+        EXPECT_EQ(m_jobDetailsList[1].m_jobEntry.m_databaseSourceName, "test.stage3");
+        EXPECT_EQ(m_jobDetailsList[2].m_jobEntry.m_databaseSourceName, "test.stage1");
+    }
+
+    TEST_F(IntermediateAssetTests, AALoop_CausesFailure)
+    {
+        using namespace AssetBuilderSDK;
+
+        CreateBuilder("stage1", "*.stage1", "stage2", true, ProductOutputFlags::IntermediateAsset);
+        CreateBuilder("stage2", "*.stage2", "stage1", true, ProductOutputFlags::IntermediateAsset); // Loop back to the source
+
+        ProcessFileMultiStage(2, false);
+
+        EXPECT_EQ(m_jobDetailsList.size(), 3);
+        EXPECT_TRUE(m_jobDetailsList[1].m_autoFail);
+        EXPECT_TRUE(m_jobDetailsList[2].m_autoFail);
+
+        EXPECT_EQ(m_jobDetailsList[1].m_jobEntry.m_databaseSourceName, "test.stage2");
+        EXPECT_EQ(m_jobDetailsList[2].m_jobEntry.m_databaseSourceName, "test.stage1");
+    }
+
+    TEST_F(IntermediateAssetTests, SelfLoop_CausesFailure)
+    {
+        using namespace AssetBuilderSDK;
+
+        CreateBuilder("stage1", "*.stage1", "stage1", true, ProductOutputFlags::IntermediateAsset); // Loop back to the source with a single job
+
+        ProcessFileMultiStage(1, false);
+
+        EXPECT_EQ(m_jobDetailsList.size(), 2);
+        EXPECT_TRUE(m_jobDetailsList[1].m_autoFail);
+
+        EXPECT_EQ(m_jobDetailsList[1].m_jobEntry.m_databaseSourceName, "test.stage1");
+    }
+
+    TEST_F(IntermediateAssetTests, CopyJob_Works)
+    {
+        using namespace AssetBuilderSDK;
+
+        CreateBuilder("stage1", "*.stage1", "stage1", false, ProductOutputFlags::ProductAsset); // Copy jobs are ok
+
+        ProcessFileMultiStage(1, false);
+
+        auto expectedProduct = AZ::IO::Path(m_tempDir.GetDirectory()) / "Cache" / "pc" / "test.stage1";
+
+        EXPECT_EQ(m_jobDetailsList.size(), 1);
+        EXPECT_TRUE(AZ::IO::SystemFile::Exists(expectedProduct.c_str())) << expectedProduct.c_str();
+    }
+
+    TEST_F(IntermediateAssetTests, DeleteSourceIntermediate_DeletesAllProducts)
+    {
+        using namespace AssetBuilderSDK;
+
+        CreateBuilder("stage1", "*.stage1", "stage2", true, ProductOutputFlags::IntermediateAsset);
+        CreateBuilder("stage2", "*.stage2", "stage3", true, ProductOutputFlags::IntermediateAsset);
+        CreateBuilder("stage3", "*.stage3", "stage4", false, ProductOutputFlags::ProductAsset);
+
+        ProcessFileMultiStage(3, true);
+
+        AZ::IO::SystemFile::Delete(m_testFilePath.c_str());
+        m_assetProcessorManager->AssessDeletedFile(m_testFilePath.c_str());
+        RunFile(0);
+
+        CheckIntermediate("test.stage2", false);
+        CheckIntermediate("test.stage3", false);
+        CheckProduct("test.stage4", false);
+    }
+
+    void IntermediateAssetTests::DeleteIntermediateTest(const char* deleteFilePath)
+    {
+        using namespace AssetBuilderSDK;
+
+        CreateBuilder("stage1", "*.stage1", "stage2", true, ProductOutputFlags::IntermediateAsset);
+        CreateBuilder("stage2", "*.stage2", "stage3", true, ProductOutputFlags::IntermediateAsset);
+        CreateBuilder("stage3", "*.stage3", "stage4", false, ProductOutputFlags::ProductAsset);
+
+        ProcessFileMultiStage(3, true);
+
+        AZ::IO::SystemFile::Delete(deleteFilePath);
+        m_assetProcessorManager->AssessDeletedFile(deleteFilePath);
+        RunFile(0); // Process the delete
+
+        // Reprocess the file
+        m_jobDetailsList.clear();
+
+        // AssessModifiedFile is going to set up a OneShotTimer with a 1ms delay on it.  We have to wait a short time for that timer to
+        // elapse before we can process that event. If we use the alternative processEvents that loops for X milliseconds we could
+        // accidentally process too many events.
+        AZStd::this_thread::sleep_for(AZStd::chrono::milliseconds(2));
+
+        // Unfortunately we need to just process the events a few times without doing any checks here
+        // due to the previous step queuing work which is sometimes executed immediately.
+        // Without a way to consistently be sure whether the work has been done or not, we need to just run enough until the job is emitted
+
+        QCoreApplication::processEvents();
+        QCoreApplication::processEvents();
+        QCoreApplication::processEvents();
+
+        ASSERT_EQ(m_jobDetailsList.size(), 1);
+
+        ProcessJob(*m_rc, m_jobDetailsList[0]);
+
+        ASSERT_TRUE(m_fileCompiled);
+
+        m_assetProcessorManager->AssetProcessed(m_processedJobEntry, m_processJobResponse);
+
+        CheckIntermediate("test.stage2");
+        CheckIntermediate("test.stage3");
+        CheckProduct("test.stage4");
+    }
+
+    TEST_F(IntermediateAssetTests, DeleteIntermediateProduct_Reprocesses)
+    {
+        DeleteIntermediateTest(MakePath("test.stage2", true).c_str());
+    }
+
+    TEST_F(IntermediateAssetTests, DeleteFinalProduct_Reprocesses)
+    {
+        DeleteIntermediateTest(MakePath("test.stage4", false).c_str());
+    }
+
+    TEST_F(IntermediateAssetTests, Override_NormalFileProcessedFirst_CausesFailure)
+    {
+        using namespace AssetBuilderSDK;
+
+        CreateBuilder("stage1", "*.stage1", "stage2", true, ProductOutputFlags::IntermediateAsset);
+        CreateBuilder("stage2", "*.stage2", "stage3", true, ProductOutputFlags::IntermediateAsset);
+        CreateBuilder("stage3", "*.stage3", "stage4", false, ProductOutputFlags::ProductAsset);
+
+        // Make and process a source file which matches an intermediate output name we will create later
+        AZ::IO::Path scanFolderDir(m_scanfolder.m_scanFolder);
+        AZStd::string testFilename = "test.stage2";
+        AZStd::string testFilePath = (scanFolderDir / testFilename).AsPosix();
+
+        UnitTestUtils::CreateDummyFile(testFilePath.c_str(), "unit test file");
+
+        ProcessFileMultiStage(3, true, testFilePath.c_str(), 2);
+
+        // Now process another file which produces intermediates that conflict with the existing source file above
+        // Only go to stage 1 since we're expecting a failure at that point
+        ProcessFileMultiStage(1, false);
+
+        EXPECT_EQ(m_jobDetailsList.size(), 2);
+        EXPECT_TRUE(m_jobDetailsList[1].m_autoFail);
+
+        EXPECT_EQ(m_jobDetailsList[1].m_jobEntry.m_databaseSourceName, "test.stage1");
+    }
+
+    TEST_F(IntermediateAssetTests, DeleteFileInIntermediateFolder_CorrectlyDeletesOneFile)
+    {
+        using namespace AzToolsFramework::AssetDatabase;
+
+        // Set up the test files and database entries
+        SourceDatabaseEntry source1{ m_scanfolder.m_scanFolderID, "folder/parent.txt", AZ::Uuid::CreateRandom(), "fingerprint" };
+        SourceDatabaseEntry source2{ m_platformConfig->GetIntermediateAssetsScanFolderId().value(), "folder/child.txt", AZ::Uuid::CreateRandom(), "fingerprint" };
+
+        auto sourceFile = AZ::IO::Path(m_scanfolder.m_scanFolder) / "folder/parent.txt";
+        auto intermediateFile = MakePath("folder/child.txt", true);
+        auto cacheFile = MakePath("pc/folder/product.txt", false);
+        auto cacheFile2 = MakePath("pc/folder/product777.txt", false);
+        UnitTestUtils::CreateDummyFile(sourceFile.Native().c_str(), QString("tempdata"));
+        UnitTestUtils::CreateDummyFile(intermediateFile.c_str(), QString("tempdata"));
+        UnitTestUtils::CreateDummyFile(cacheFile.c_str(), QString("tempdata"));
+        UnitTestUtils::CreateDummyFile(cacheFile2.c_str(), QString("tempdata"));
+
+        ASSERT_TRUE(m_stateData->SetSource(source1));
+        ASSERT_TRUE(m_stateData->SetSource(source2));
+
+        JobDatabaseEntry job1{ source1.m_sourceID,
+                               "Mock Job",
+                               1234,
+                               "pc",
+                               m_builderInfoHandler.m_builderDescMap.begin()->second.m_busId,
+                               AzToolsFramework::AssetSystem::JobStatus::Completed,
+                               999 };
+
+        JobDatabaseEntry job2{ source2.m_sourceID,
+                               "Mock Job",
+                               1234,
+                               "pc",
+                               m_builderInfoHandler.m_builderDescMap.begin()->second.m_busId,
+                               AzToolsFramework::AssetSystem::JobStatus::Completed,
+                               888 };
+
+        ASSERT_TRUE(m_stateData->SetJob(job1));
+        ASSERT_TRUE(m_stateData->SetJob(job2));
+
+        ProductDatabaseEntry product1{ job1.m_jobID,
+                                       0,
+                                       "pc/folder/product.txt",
+                                       AZ::Uuid::CreateName("one"),
+                                       AZ::Uuid::CreateName("product.txt"),
+                                       0,
+                                       static_cast<int>(AssetBuilderSDK::ProductOutputFlags::ProductAsset) };
+        ProductDatabaseEntry product2{ job2.m_jobID,
+                                       777,
+                                       "pc/folder/product777.txt",
+                                       AZ::Uuid::CreateName("two"),
+                                       AZ::Uuid::CreateName("product777.txt"),
+                                       0,
+                                       static_cast<int>(AssetBuilderSDK::ProductOutputFlags::ProductAsset) };
+
+        ASSERT_TRUE(m_stateData->SetProduct(product1));
+        ASSERT_TRUE(m_stateData->SetProduct(product2));
+
+        // Record the folder so its marked as a known folder
+        auto folderPath = MakePath("folder", true);
+        m_assetProcessorManager->RecordFoldersFromScanner(
+            QSet{ AssetProcessor::AssetFileInfo{ folderPath.c_str(), QDateTime::currentDateTime(), 0,
+                                                 m_platformConfig->GetScanFolderForFile(folderPath.c_str()), true } });
+
+        // Delete the file and folder in the intermediate folder
+        AZ::IO::LocalFileIO::GetInstance()->DestroyPath(folderPath.c_str());
+
+        QMetaObject::invokeMethod(
+            m_assetProcessorManager.get(), "AssessDeletedFile", Qt::QueuedConnection,
+            Q_ARG(QString, MakePath("folder", true).c_str()));
+        QCoreApplication::processEvents();
+
+        RunFile(0);
+
+        // Only 1 file (the one in the intermediate folder) should be marked for delete
+        m_assetProcessorManager->CheckActiveFiles(1);
+        m_assetProcessorManager->CheckFilesToExamine(0);
+        m_assetProcessorManager->CheckJobEntries(0);
+    }
+
+    TEST_F(IntermediateAssetTests, Override_IntermediateFileProcessedFirst_CausesFailure)
+    {
+        using namespace AssetBuilderSDK;
+
+        CreateBuilder("stage1", "*.stage1", "stage2", true, ProductOutputFlags::IntermediateAsset);
+        CreateBuilder("stage2", "*.stage2", "stage3", true, ProductOutputFlags::IntermediateAsset);
+        CreateBuilder("stage3", "*.stage3", "stage4", false, ProductOutputFlags::ProductAsset);
+
+        // Process a file from stage1 -> stage4, this will create several intermediates
+        ProcessFileMultiStage(3, true);
+
+        // Now make a source file which is the same name as an existing intermediate and process it
+        AZ::IO::Path scanFolderDir(m_scanfolder.m_scanFolder);
+        AZStd::string testFilename = "test.stage2";
+        AZStd::string testFilePath = (scanFolderDir / testFilename).AsPosix();
+
+        UnitTestUtils::CreateDummyFile(testFilePath.c_str(), "unit test file");
+
+        ProcessFileMultiStage(3, true, testFilePath.c_str(), 2, true);
+
+        EXPECT_EQ(m_jobDetailsList.size(), 1);
+        EXPECT_FALSE(m_jobDetailsList[0].m_autoFail);
+    }
+
+    TEST_F(IntermediateAssetTests, DuplicateOutputs_CausesFailure)
+    {
+        using namespace AssetBuilderSDK;
+
+        CreateBuilder("stage1", "*.stage1", "stage2", true, ProductOutputFlags::IntermediateAsset, true);
+        CreateBuilder("stage2", "*.stage2", "stage3", false, ProductOutputFlags::ProductAsset);
+
+        ProcessFileMultiStage(2, true, nullptr, 1, false, true);
+
+        AZ::IO::Path scanFolderDir(m_scanfolder.m_scanFolder);
+        AZStd::string testFilename = "test2.stage1";
+
+        UnitTestUtils::CreateDummyFile((scanFolderDir / testFilename).c_str(), "unit test file");
+
+        QMetaObject::invokeMethod(
+            m_assetProcessorManager.get(), "AssessAddedFile", Qt::QueuedConnection, Q_ARG(QString, (scanFolderDir / testFilename).c_str()));
+        QCoreApplication::processEvents();
+
+        RunFile(1);
+        ProcessJob(*m_rc, m_jobDetailsList[0]);
+
+        ASSERT_TRUE(m_fileCompiled);
+
+        m_jobDetailsList.clear();
+
+        m_assetProcessorManager->AssetProcessed(m_processedJobEntry, m_processJobResponse);
+
+        EXPECT_EQ(m_jobDetailsList.size(), 1);
+        EXPECT_TRUE(m_jobDetailsList[0].m_autoFail);
+    }
+
+    TEST_F(IntermediateAssetTests, SourceAsset_SourceDependencyOnIntermediate_Reprocesses)
+    {
+        using namespace AssetBuilderSDK;
+
+        CreateBuilder("stage1", "*.stage1", "stage2", true, ProductOutputFlags::IntermediateAsset);
+        CreateBuilder("stage2", "*.stage2", "stage3", false, ProductOutputFlags::ProductAsset);
+
+        // Builder for the normal file, with a source dependency on the .stage2 intermediate
+        m_builderInfoHandler.CreateBuilderDesc(
+            "normal file builder", AZ::Uuid::CreateName("normal file builder").ToFixedString().c_str(),
+            { AssetBuilderPattern{ "*.test", AssetBuilderPattern::Wildcard } },
+            UnitTests::MockMultiBuilderInfoHandler::AssetBuilderExtraInfo{ "", "test.stage2", "", "", {} });
+
+        AZ::IO::Path scanFolderDir(m_scanfolder.m_scanFolder);
+        AZStd::string testFilename = "one.test";
+
+        UnitTestUtils::CreateDummyFile((scanFolderDir / testFilename).c_str(), "unit test file");
+
+        // Process the intermediate-style file first
+        ProcessFileMultiStage(2, true);
+        // Process the regular source second
+        ProcessFileMultiStage(1, false, (scanFolderDir / testFilename).c_str());
+
+        // Modify the intermediate-style file so it will be processed again
+        QFile writer(m_testFilePath.c_str());
+        ASSERT_TRUE(writer.open(QFile::WriteOnly));
+
+        {
+            QTextStream ts(&writer);
+            ts.setCodec("UTF-8");
+            ts << "modified test file";
+        }
+
+        // Start processing the test.stage1 file again
+        QMetaObject::invokeMethod(m_assetProcessorManager.get(), "AssessAddedFile", Qt::QueuedConnection, Q_ARG(QString, m_testFilePath.c_str()));
+        QCoreApplication::processEvents();
+
+        // Process test.stage1, which should queue up test.stage2
+        ProcessSingleStep();
+        // Start processing test.stage2, this should cause one.test to also be placed in the processing queue
+        RunFile(1, 1, 1);
+    }
+
+    TEST_F(IntermediateAssetTests, IntermediateAsset_SourceDependencyOnSourceAsset_Reprocesses)
+    {
+        using namespace AssetBuilderSDK;
+
+        CreateBuilder("stage1", "*.stage1", "stage2", true, ProductOutputFlags::IntermediateAsset);
+
+        m_builderInfoHandler.CreateBuilderDesc(
+            "stage2", AZ::Uuid::CreateRandom().ToFixedString().c_str(), { AssetBuilderPattern{ "*.stage2", AssetBuilderPattern::Wildcard } },
+            CreateJobStage("stage2", false, "one.test"),
+            ProcessJobStage("stage3", ProductOutputFlags::ProductAsset, false), "fingerprint");
+
+        CreateBuilder("normal file builder", "*.test", "test", false, ProductOutputFlags::ProductAsset);
+
+        AZ::IO::Path scanFolderDir(m_scanfolder.m_scanFolder);
+        AZStd::string testFilename = "one.test";
+
+        UnitTestUtils::CreateDummyFile((scanFolderDir / testFilename).c_str(), "unit test file");
+
+        // Process the normal source first
+        ProcessFileMultiStage(1, false, (scanFolderDir / testFilename).c_str());
+        // Process the intermediate-style source second
+        ProcessFileMultiStage(2, true);
+
+        // Modify the normal source so it will be processed again
+        QFile writer((scanFolderDir / testFilename).c_str());
+        ASSERT_TRUE(writer.open(QFile::WriteOnly));
+
+        {
+            QTextStream ts(&writer);
+            ts.setCodec("UTF-8");
+            ts << "modified test file";
+        }
+
+        // Start processing the one.test file again
+        QMetaObject::invokeMethod(
+            m_assetProcessorManager.get(), "AssessAddedFile", Qt::QueuedConnection, Q_ARG(QString, (scanFolderDir / testFilename).c_str()));
+        QCoreApplication::processEvents();
+
+        // Start processing one.test, this should cause test.stage2 to also be placed in the processing queue
+        RunFile(1, 1, 1);
+    }
+
+    TEST_F(IntermediateAssetTests, RequestReprocess_ReprocessesAllIntermediates)
+    {
+        using namespace AssetBuilderSDK;
+
+        CreateBuilder("stage1", "*.stage1", "stage2", true, ProductOutputFlags::IntermediateAsset);
+        CreateBuilder("stage2", "*.stage2", "stage3", true, ProductOutputFlags::IntermediateAsset);
+        CreateBuilder("stage3", "*.stage3", "stage4", false, ProductOutputFlags::ProductAsset);
+
+        ProcessFileMultiStage(3, true);
+
+        EXPECT_EQ(m_assetProcessorManager->RequestReprocess(m_testFilePath.c_str()), 3);
+        EXPECT_EQ(m_assetProcessorManager->RequestReprocess(MakePath("test.stage2", true).c_str()), 3);
+    }
+} // namespace UnitTests

+ 62 - 0
Code/Tools/AssetProcessor/native/tests/assetmanager/IntermediateAssetTests.h

@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#pragma once
+
+#include <native/tests/assetmanager/AssetManagerTestingBase.h>
+
+namespace UnitTests
+{
+    class IntermediateAssetTests
+        : public AssetManagerTestingBase
+        , protected AZ::Debug::TraceMessageBus::Handler
+    {
+    public:
+        void SetUp() override;
+        void TearDown() override;
+
+    protected:
+        bool OnPreAssert(const char*, int, const char*, const char* message) override;
+        bool OnPreError(const char*, const char*, int, const char*, const char* message) override;
+
+        AZStd::string MakePath(const char* filename, bool intermediate);
+
+        void CheckProduct(const char* relativePath, bool exists = true);
+        void CheckIntermediate(const char* relativePath, bool exists = true);
+        void ProcessSingleStep(int expectedJobCount = 1, int expectedFileCount = 1, int jobToRun = 0, bool expectSuccess = true);
+
+        void ProcessFileMultiStage(
+            int endStage,
+            bool doProductOutputCheck,
+            const char* file = nullptr,
+            int startStage = 1,
+            bool expectAutofail = false,
+            bool hasExtraFile = false);
+
+        void DeleteIntermediateTest(const char* fileToDelete);
+
+        void IncorrectBuilderConfigurationTest(bool commonPlatform, AssetBuilderSDK::ProductOutputFlags flags);
+
+        void CreateBuilder(
+            const char* name,
+            const char* inputFilter,
+            const char* outputExtension,
+            bool createJobCommonPlatform,
+            AssetBuilderSDK::ProductOutputFlags outputFlags,
+            bool outputExtraFile = false);
+
+        AZStd::unique_ptr<AssetProcessor::RCController> m_rc;
+        bool m_fileCompiled = false;
+        bool m_fileFailed = false;
+        AssetProcessor::JobEntry m_processedJobEntry;
+        AssetBuilderSDK::ProcessJobResponse m_processJobResponse;
+        AZStd::string m_testFilePath;
+
+        int m_expectedErrors = 0;
+    };
+} // namespace UnitTests

+ 14 - 112
Code/Tools/AssetProcessor/native/tests/assetmanager/JobDependencySubIdTests.cpp

@@ -6,111 +6,17 @@
  *
  */
 
-#include <QCoreApplication>
-#include <AzCore/Settings/SettingsRegistryMergeUtils.h>
 #include <native/tests/assetmanager/JobDependencySubIdTests.h>
 #include <unittests/UnitTestRunner.h>
+#include <QApplication>
 
 namespace UnitTests
 {
-    bool JobDependencyDatabaseLocationListener::GetAssetDatabaseLocation(AZStd::string& location)
-    {
-        location = m_databaseLocation;
-        return true;
-    }
-
-    void JobDependencyAssetProcessorManager::CheckActiveFiles(int count)
-    {
-        ASSERT_EQ(m_activeFiles.size(), count);
-    }
-
-    void JobDependencyAssetProcessorManager::CheckFilesToExamine(int count)
-    {
-        ASSERT_EQ(m_filesToExamine.size(), count);
-    }
-
-    void JobDependencyAssetProcessorManager::CheckJobEntries(int count)
-    {
-        ASSERT_EQ(m_jobEntries.size(), count);
-    }
-
-    void JobDependencySubIdTest::SetUp()
-    {
-        ScopedAllocatorSetupFixture::SetUp();
-
-        // File IO is needed to hash the files
-        if (AZ::IO::FileIOBase::GetInstance() == nullptr)
-        {
-            m_localFileIo = aznew AZ::IO::LocalFileIO();
-            AZ::IO::FileIOBase::SetInstance(m_localFileIo);
-        }
-
-        // Specify the database lives in the temp directory
-        AZ::IO::Path tempDir(m_tempDir.GetDirectory());
-        m_databaseLocationListener.m_databaseLocation = (tempDir / "test_database.sqlite").Native();
-
-        // We need a settings registry in order for APM to figure out the cache path
-        m_settingsRegistry = AZStd::make_unique<AZ::SettingsRegistryImpl>();
-        AZ::SettingsRegistry::Register(m_settingsRegistry.get());
-
-        auto projectPathKey =
-            AZ::SettingsRegistryInterface::FixedValueString(AZ::SettingsRegistryMergeUtils::BootstrapSettingsRootKey) + "/project_path";
-        m_settingsRegistry->Set(projectPathKey, m_tempDir.GetDirectory());
-        AZ::SettingsRegistryMergeUtils::MergeSettingsToRegistry_AddRuntimeFilePaths(*m_settingsRegistry);
-
-        // We need a QCoreApplication set up in order for QCoreApplication::processEvents to function
-        m_qApp = AZStd::make_unique<QCoreApplication>(m_argc, m_argv);
-        qRegisterMetaType<AssetProcessor::JobEntry>("JobEntry");
-        qRegisterMetaType<AssetBuilderSDK::ProcessJobResponse>("ProcessJobResponse");
-        qRegisterMetaType<AZStd::string>("AZStd::string");
-        qRegisterMetaType<AssetProcessor::AssetScanningStatus>("AssetProcessor::AssetScanningStatus");
-        qRegisterMetaType<QSet<AssetProcessor::AssetFileInfo>>("QSet<AssetFileInfo>");
-
-        // Platform config with an enabled platform and scanfolder required by APM to function and find the files
-        m_platformConfig = AZStd::make_unique<AssetProcessor::PlatformConfiguration>();
-        m_platformConfig->EnablePlatform(AssetBuilderSDK::PlatformInfo{ "pc", { "test" } });
-
-        AZStd::vector<AssetBuilderSDK::PlatformInfo> platforms;
-        m_platformConfig->PopulatePlatformsForScanFolder(platforms);
-
-        m_platformConfig->AddScanFolder(AssetProcessor::ScanFolderInfo{
-            (tempDir/"folder").c_str(), "folder", "folder", false, true, platforms});
-
-        // Create the APM
-        m_assetProcessorManager = AZStd::make_unique<JobDependencyAssetProcessorManager>(m_platformConfig.get());
-
-        // Cache the db pointer because the TEST_F generates a subclass which can't access this private member
-        m_stateData = m_assetProcessorManager->m_stateData;
-
-        // Configure our mock builder so APM can find the builder and run CreateJobs
-        m_builderInfoHandler.m_builderDesc = m_builderInfoHandler.CreateBuilderDesc("test", AZ::Uuid::CreateRandom().ToString<QString>(), { AssetBuilderSDK::AssetBuilderPattern("*.txt", AssetBuilderSDK::AssetBuilderPattern::Wildcard) });
-        m_builderInfoHandler.BusConnect();
-    }
-
-    void JobDependencySubIdTest::TearDown()
-    {
-        m_builderInfoHandler.BusDisconnect();
-
-        AZ::SettingsRegistry::Unregister(m_settingsRegistry.get());
-
-        if (m_localFileIo)
-        {
-            delete m_localFileIo;
-            m_localFileIo = nullptr;
-            AZ::IO::FileIOBase::SetInstance(nullptr);
-        }
-
-        ScopedAllocatorSetupFixture::TearDown();
-    }
-
     void JobDependencySubIdTest::CreateTestData(AZ::u64 hashA, AZ::u64 hashB, bool useSubId)
     {
         using namespace AzToolsFramework::AssetDatabase;
 
         AZ::IO::Path tempDir(m_tempDir.GetDirectory());
-        m_scanfolder = { (tempDir / "folder").c_str(), "folder", "folder", 0 };
-
-        ASSERT_TRUE(m_stateData->SetScanFolder(m_scanfolder));
 
         SourceDatabaseEntry source1{ m_scanfolder.m_scanFolderID, "parent.txt", AZ::Uuid::CreateRandom(), "fingerprint" };
         SourceDatabaseEntry source2{ m_scanfolder.m_scanFolderID, "child.txt", AZ::Uuid::CreateRandom(), "fingerprint" };
@@ -124,21 +30,15 @@ namespace UnitTests
         ASSERT_TRUE(m_stateData->SetSource(source2));
 
         JobDatabaseEntry job1{
-            source1.m_sourceID, "Mock Job", 1234, "pc", m_builderInfoHandler.m_builderDesc.m_busId, AzToolsFramework::AssetSystem::JobStatus::Completed, 999
+            source1.m_sourceID, "Mock Job", 1234, "pc", m_builderInfoHandler.m_builderDescMap.begin()->second.m_busId, AzToolsFramework::AssetSystem::JobStatus::Completed, 999
         };
 
         ASSERT_TRUE(m_stateData->SetJob(job1));
 
-        ProductDatabaseEntry product1{
-            job1.m_jobID, 0, "product.txt", m_assetType, AZ::Uuid::CreateName("product.txt"),
-            hashA
-        };
-        ProductDatabaseEntry product2{ job1.m_jobID,
-                                       777,
-                                       "product777.txt",
-                                       m_assetType,
-                                       AZ::Uuid::CreateName("product777.txt"),
-                                       hashB };
+        ProductDatabaseEntry product1{ job1.m_jobID, 0, "pc/product.txt", m_assetType,
+            AZ::Uuid::CreateName("product.txt"), hashA, static_cast<int>(AssetBuilderSDK::ProductOutputFlags::ProductAsset) };
+        ProductDatabaseEntry product2{ job1.m_jobID, 777, "pc/product777.txt", m_assetType,
+            AZ::Uuid::CreateName("product777.txt"), hashB, static_cast<int>(AssetBuilderSDK::ProductOutputFlags::ProductAsset) };
 
         ASSERT_TRUE(m_stateData->SetProduct(product1));
         ASSERT_TRUE(m_stateData->SetProduct(product2));
@@ -157,9 +57,13 @@ namespace UnitTests
     {
         AZ::IO::Path cacheDir(m_tempDir.GetDirectory());
         cacheDir /= "Cache";
+        cacheDir /= "pc";
 
-        AZStd::string productPath = (cacheDir / "product.txt").AsPosix().c_str();
-        AZStd::string product2Path = (cacheDir / "product777.txt").AsPosix().c_str();
+        AZStd::string productFilename = "product.txt";
+        AZStd::string product2Filename = "product777.txt";
+
+        AZStd::string productPath = (cacheDir / productFilename).AsPosix().c_str();
+        AZStd::string product2Path = (cacheDir / product2Filename).AsPosix().c_str();
 
         UnitTestUtils::CreateDummyFile(productPath.c_str(), "unit test file");
         UnitTestUtils::CreateDummyFile(product2Path.c_str(), "unit test file");
@@ -202,13 +106,11 @@ namespace UnitTests
 
         AssetBuilderSDK::ProcessJobResponse response;
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(productPath, m_assetType, 0));
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(product2Path, m_assetType, 777));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(productFilename, m_assetType, 0));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(product2Filename, m_assetType, 777));
 
         m_assetProcessorManager->AssetProcessed(jobDetailsList[0].m_jobEntry, response);
 
-        ASSERT_TRUE(true);
-
         // We're only really interested in ActiveFiles but check the others to be sure
         m_assetProcessorManager->CheckFilesToExamine(0);
         m_assetProcessorManager->CheckActiveFiles(secondProductChanged ? 1 : 0); // The 2nd product is the one we have a dependency on, only if that changed should we see the other file process

+ 3 - 51
Code/Tools/AssetProcessor/native/tests/assetmanager/JobDependencySubIdTests.h

@@ -8,64 +8,16 @@
 
 #pragma once
 
-#include <AzCore/UnitTest/TestTypes.h>
-#include <API/AssetDatabaseBus.h>
-#include <AzCore/Settings/SettingsRegistryImpl.h>
-#include <native/AssetManager/assetProcessorManager.h>
-#include <tests/UnitTestUtilities.h>
-#include <Tests/Utils/Utils.h>
+#include <native/tests/assetmanager/AssetManagerTestingBase.h>
 
 namespace UnitTests
 {
-    class JobDependencyDatabaseLocationListener : public AzToolsFramework::AssetDatabase::AssetDatabaseRequests::Bus::Handler
+    struct JobDependencySubIdTest : AssetManagerTestingBase
     {
-    public:
-        JobDependencyDatabaseLocationListener() { BusConnect(); }
-        ~JobDependencyDatabaseLocationListener() override { BusDisconnect(); }
-
-        bool GetAssetDatabaseLocation(AZStd::string& location) override;
-
-        AZStd::string m_databaseLocation;
-    };
-
-    struct JobDependencySubIdTest;
-
-    struct JobDependencyAssetProcessorManager : AssetProcessor::AssetProcessorManager
-    {
-        friend struct JobDependencySubIdTest;
-
-        JobDependencyAssetProcessorManager(AssetProcessor::PlatformConfiguration* config, QObject* parent = nullptr)
-            : AssetProcessorManager(config, parent) {}
-
-        void CheckActiveFiles(int count);
-        void CheckFilesToExamine(int count);
-        void CheckJobEntries(int count);
-    };
-
-    struct JobDependencySubIdTest : ::UnitTest::ScopedAllocatorSetupFixture
-    {
-        void SetUp() override;
-        void TearDown() override;
         void CreateTestData(AZ::u64 hashA, AZ::u64 hashB, bool useSubId);
         void RunTest(bool firstProductChanged, bool secondProductChanged);
 
-        int m_argc = 0;
-        char** m_argv{};
-
-        AssetProcessor::FileStatePassthrough m_fileStateCache;
-
-        AZStd::unique_ptr<QCoreApplication> m_qApp;
-        AZStd::unique_ptr<JobDependencyAssetProcessorManager> m_assetProcessorManager;
-        AZStd::unique_ptr<AssetProcessor::PlatformConfiguration> m_platformConfig;
-        AZStd::unique_ptr<AZ::SettingsRegistryImpl> m_settingsRegistry;
-        AZStd::shared_ptr<AssetProcessor::AssetDatabaseConnection> m_stateData;
-        AZ::Test::ScopedAutoTempDirectory m_tempDir;
-        JobDependencyDatabaseLocationListener m_databaseLocationListener;
-        AzToolsFramework::AssetDatabase::ScanFolderDatabaseEntry m_scanfolder;
-        AZ::IO::Path m_parentFile, m_childFile;
-        MockBuilderInfoHandler m_builderInfoHandler;
-        AZ::IO::LocalFileIO* m_localFileIo;
-
         AZ::Uuid m_assetType = AZ::Uuid::CreateName("test");
+        AZ::IO::Path m_parentFile, m_childFile;
     };
 }

+ 28 - 31
Code/Tools/AssetProcessor/native/tests/assetmanager/ModtimeScanningTests.cpp

@@ -52,20 +52,8 @@ namespace UnitTests
 
         m_data = AZStd::make_unique<StaticData>();
 
-        // We don't want the mock application manager to provide builder descriptors, mockBuilderInfoHandler will provide our own
-        m_mockApplicationManager->BusDisconnect();
-
-        m_data->m_mockBuilderInfoHandler.m_builderDesc = m_data->m_mockBuilderInfoHandler.CreateBuilderDesc(
-            "test builder", "{DF09DDC0-FD22-43B6-9E22-22C8574A6E1E}",
-            { AssetBuilderSDK::AssetBuilderPattern("*.txt", AssetBuilderSDK::AssetBuilderPattern::Wildcard) });
-        m_data->m_mockBuilderInfoHandler.BusConnect();
-
-        ASSERT_TRUE(m_mockApplicationManager->GetBuilderByID("txt files", m_data->m_builderTxtBuilder));
-
-        SetUpAssetProcessorManager();
-
         // Create the test file
-        const auto& scanFolder = m_config->GetScanFolderAt(0);
+        const auto& scanFolder = m_config->GetScanFolderAt(1);
         m_data->m_relativePathFromWatchFolder[0] = "modtimeTestFile.txt";
         m_data->m_absolutePath.push_back(QDir(scanFolder.ScanPath()).absoluteFilePath(m_data->m_relativePathFromWatchFolder[0]));
 
@@ -80,7 +68,18 @@ namespace UnitTests
             ASSERT_TRUE(UnitTestUtils::CreateDummyFile(path, ""));
         }
 
-        m_data->m_mockBuilderInfoHandler.m_dependencyFilePath = m_data->m_absolutePath[1].toUtf8().data();
+        // We don't want the mock application manager to provide builder descriptors, mockBuilderInfoHandler will provide our own
+        m_mockApplicationManager->BusDisconnect();
+
+        m_data->m_mockBuilderInfoHandler.CreateBuilderDesc(
+            "test builder", "{DF09DDC0-FD22-43B6-9E22-22C8574A6E1E}",
+            { AssetBuilderSDK::AssetBuilderPattern("*.txt", AssetBuilderSDK::AssetBuilderPattern::Wildcard) },
+            MockMultiBuilderInfoHandler::AssetBuilderExtraInfo{ "", m_data->m_absolutePath[1].toUtf8().data(), "", "", {} });
+        m_data->m_mockBuilderInfoHandler.BusConnect();
+
+        ASSERT_TRUE(m_mockApplicationManager->GetBuilderByID("txt files", m_data->m_builderTxtBuilder));
+
+        SetUpAssetProcessorManager();
 
         // Add file to database with no modtime
         {
@@ -136,21 +135,20 @@ namespace UnitTests
 
         for (const auto& processResult : m_data->m_processResults)
         {
-            auto file =
-                QDir(processResult.m_destinationPath).absoluteFilePath(processResult.m_jobEntry.m_databaseSourceName.toLower() + ".arc1");
+            AZStd::string file = (processResult.m_jobEntry.m_databaseSourceName.toLower() + ".arc1").toUtf8().constData();
             m_data->m_productPaths.emplace(
                 QDir(processResult.m_jobEntry.m_watchFolderPath)
                     .absoluteFilePath(processResult.m_jobEntry.m_databaseSourceName)
                     .toUtf8()
                     .constData(),
-                file);
+                (processResult.m_cachePath / file).c_str());
 
             // Create the file on disk
-            ASSERT_TRUE(UnitTestUtils::CreateDummyFile(file, "products."));
+            ASSERT_TRUE(UnitTestUtils::CreateDummyFile((processResult.m_cachePath / file).AsPosix().c_str(), "products."));
 
             AssetBuilderSDK::ProcessJobResponse response;
             response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(file.toUtf8().constData(), AZ::Uuid::CreateNull(), 1));
+            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct((processResult.m_relativePath / file).StringAsPosix(), AZ::Uuid::CreateNull(), 1));
 
             using JobEntry = AssetProcessor::JobEntry;
 
@@ -269,7 +267,7 @@ namespace UnitTests
 
         // There's no way to remove scanfolders and adding a new one after enabling the platform will cause the pc assets to build as well,
         // which we don't want Instead we'll just const cast the vector and modify the enabled platforms for the scanfolder
-        auto& platforms = const_cast<AZStd::vector<AssetBuilderSDK::PlatformInfo>&>(m_config->GetScanFolderAt(0).GetPlatforms());
+        auto& platforms = const_cast<AZStd::vector<AssetBuilderSDK::PlatformInfo>&>(m_config->GetScanFolderAt(1).GetPlatforms());
         platforms.push_back(androidPlatform);
 
         // We need the builder fingerprints to be updated to reflect the newly enabled platform
@@ -281,8 +279,8 @@ namespace UnitTests
         ExpectWork(
             4, 2); // CreateJobs = 4, 2 files * 2 platforms.  ProcessJobs = 2, just the android platform jobs (pc is already processed)
 
-        ASSERT_TRUE(m_data->m_processResults[0].m_destinationPath.contains("android"));
-        ASSERT_TRUE(m_data->m_processResults[1].m_destinationPath.contains("android"));
+        ASSERT_TRUE(m_data->m_processResults[0].m_cachePath.Filename() == "android");
+        ASSERT_TRUE(m_data->m_processResults[1].m_cachePath.Filename() == "android");
     }
 
     TEST_F(ModtimeScanningTest, ModtimeSkipping_ModifyTimestamp)
@@ -505,7 +503,7 @@ namespace UnitTests
     {
         using namespace AzToolsFramework::AssetSystem;
 
-        const auto& scanFolder = m_config->GetScanFolderAt(0);
+        const auto& scanFolder = m_config->GetScanFolderAt(1);
 
         QString scanPath = scanFolder.ScanPath();
         m_assetProcessorManager->RequestReprocess(scanPath);
@@ -546,21 +544,20 @@ namespace UnitTests
                 });
 
             const auto& processResult = m_data->m_processResults[0];
-            auto file =
-                QDir(processResult.m_destinationPath).absoluteFilePath(processResult.m_jobEntry.m_databaseSourceName.toLower() + ".arc1");
+            AZStd::string file = (processResult.m_jobEntry.m_databaseSourceName.toLower() + ".arc1").toUtf8().constData();
             m_data->m_productPaths.emplace(
                 QDir(processResult.m_jobEntry.m_watchFolderPath)
                     .absoluteFilePath(processResult.m_jobEntry.m_databaseSourceName)
                     .toUtf8()
                     .constData(),
-                file);
+                (processResult.m_cachePath / file).c_str());
 
             // Create the file on disk
-            ASSERT_TRUE(UnitTestUtils::CreateDummyFile(file, "products."));
+            ASSERT_TRUE(UnitTestUtils::CreateDummyFile((processResult.m_cachePath / file).AsPosix().c_str(), "products."));
 
             AssetBuilderSDK::ProcessJobResponse response;
             response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(file.toUtf8().constData(), AZ::Uuid::CreateNull(), 1));
+            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(file, AZ::Uuid::CreateNull(), 1));
 
             using JobEntry = AssetProcessor::JobEntry;
 
@@ -598,9 +595,9 @@ namespace UnitTests
         // We don't want the mock application manager to provide builder descriptors, mockBuilderInfoHandler will provide our own
         m_mockApplicationManager->BusDisconnect();
 
-        m_data->m_mockBuilderInfoHandler.m_builderDesc = m_data->m_mockBuilderInfoHandler.CreateBuilderDesc(
+        m_data->m_mockBuilderInfoHandler.CreateBuilderDesc(
             "test builder", "{DF09DDC0-FD22-43B6-9E22-22C8574A6E1E}",
-            { AssetBuilderSDK::AssetBuilderPattern("*.txt", AssetBuilderSDK::AssetBuilderPattern::Wildcard) });
+            { AssetBuilderSDK::AssetBuilderPattern("*.txt", AssetBuilderSDK::AssetBuilderPattern::Wildcard) }, {});
         m_data->m_mockBuilderInfoHandler.BusConnect();
 
         ASSERT_TRUE(m_mockApplicationManager->GetBuilderByID("txt files", m_data->m_builderTxtBuilder));
@@ -688,7 +685,7 @@ namespace UnitTests
         ASSERT_TRUE(BlockUntilIdle(5000));
 
         ASSERT_THAT(m_data->m_deletedSources, testing::UnorderedElementsAre("textures/a.txt"));
-        ASSERT_THAT(deletedFolders, testing::UnorderedElementsAre("textures"));
+        ASSERT_THAT(deletedFolders, testing::UnorderedElementsAre(absPath.toUtf8().constData()));
     }
 
     TEST_F(LockedFileTest, DeleteFile_LockedProduct_DeleteFails)

+ 1 - 1
Code/Tools/AssetProcessor/native/tests/assetmanager/ModtimeScanningTests.h

@@ -33,7 +33,7 @@ namespace UnitTests
             AZStd::unordered_multimap<AZStd::string, QString> m_productPaths;
             AZStd::vector<QString> m_deletedSources;
             AZStd::shared_ptr<AssetProcessor::InternalMockBuilder> m_builderTxtBuilder;
-            MockBuilderInfoHandler m_mockBuilderInfoHandler;
+            MockMultiBuilderInfoHandler m_mockBuilderInfoHandler;
         };
 
         AZStd::unique_ptr<StaticData> m_data;

+ 50 - 46
Code/Tools/AssetProcessor/native/tests/platformconfiguration/platformconfigurationtests.cpp

@@ -55,9 +55,9 @@ TEST_F(PlatformConfigurationUnitTests, TestFailReadConfigFile_BadPlatform)
     auto configRoot = AZ::IO::FileIOBase::GetInstance()->ResolvePath("@exefolder@/testdata/config_broken_badplatform");
     ASSERT_TRUE(configRoot);
     UnitTestPlatformConfiguration config;
-    m_absorber.Clear();
+    m_errorAbsorber->Clear();
     ASSERT_FALSE(config.InitializeFromConfigFiles(configRoot->c_str(), testExeFolder->c_str(), projectPath.c_str(), false, false));
-    ASSERT_GT(m_absorber.m_numErrorsAbsorbed, 0);
+    ASSERT_GT(m_errorAbsorber->m_numErrorsAbsorbed, 0);
 }
 
 
@@ -71,9 +71,9 @@ TEST_F(PlatformConfigurationUnitTests, TestFailReadConfigFile_NoPlatform)
     auto configRoot = AZ::IO::FileIOBase::GetInstance()->ResolvePath("@exefolder@/testdata/config_broken_noplatform");
     ASSERT_TRUE(configRoot);
     UnitTestPlatformConfiguration config;
-    m_absorber.Clear();
+    m_errorAbsorber->Clear();
     ASSERT_FALSE(config.InitializeFromConfigFiles(configRoot->c_str(), testExeFolder->c_str(), projectPath.c_str(), false, false));
-    ASSERT_GT(m_absorber.m_numErrorsAbsorbed, 0);
+    ASSERT_GT(m_errorAbsorber->m_numErrorsAbsorbed, 0);
 }
 
 TEST_F(PlatformConfigurationUnitTests, TestFailReadConfigFile_NoScanFolders)
@@ -86,9 +86,9 @@ TEST_F(PlatformConfigurationUnitTests, TestFailReadConfigFile_NoScanFolders)
     auto configRoot = AZ::IO::FileIOBase::GetInstance()->ResolvePath("@exefolder@/testdata/config_broken_noscans");
     ASSERT_TRUE(configRoot);
     UnitTestPlatformConfiguration config;
-    m_absorber.Clear();
+    m_errorAbsorber->Clear();
     ASSERT_FALSE(config.InitializeFromConfigFiles(configRoot->c_str(), testExeFolder->c_str(), projectPath.c_str(), false, false));
-    ASSERT_GT(m_absorber.m_numErrorsAbsorbed, 0);
+    ASSERT_GT(m_errorAbsorber->m_numErrorsAbsorbed, 0);
 }
 
 TEST_F(PlatformConfigurationUnitTests, TestFailReadConfigFile_BrokenRecognizers)
@@ -101,9 +101,9 @@ TEST_F(PlatformConfigurationUnitTests, TestFailReadConfigFile_BrokenRecognizers)
     auto configRoot = AZ::IO::FileIOBase::GetInstance()->ResolvePath("@exefolder@/testdata/config_broken_recognizers");
     ASSERT_TRUE(configRoot);
     UnitTestPlatformConfiguration config;
-    m_absorber.Clear();
+    m_errorAbsorber->Clear();
     ASSERT_FALSE(config.InitializeFromConfigFiles(configRoot->c_str(), testExeFolder->c_str(), projectPath.c_str(), false, false));
-    ASSERT_GT(m_absorber.m_numErrorsAbsorbed, 0);
+    ASSERT_GT(m_errorAbsorber->m_numErrorsAbsorbed, 0);
 }
 
 TEST_F(PlatformConfigurationUnitTests, TestFailReadConfigFile_Regular_Platforms)
@@ -116,9 +116,9 @@ TEST_F(PlatformConfigurationUnitTests, TestFailReadConfigFile_Regular_Platforms)
     auto configRoot = AZ::IO::FileIOBase::GetInstance()->ResolvePath("@exefolder@/testdata/config_regular");
     ASSERT_TRUE(configRoot);
     UnitTestPlatformConfiguration config;
-    m_absorber.Clear();
+    m_errorAbsorber->Clear();
     ASSERT_TRUE(config.InitializeFromConfigFiles(configRoot->c_str(), testExeFolder->c_str(), projectPath.c_str(), false, false));
-    ASSERT_EQ(m_absorber.m_numErrorsAbsorbed, 0);
+    ASSERT_EQ(m_errorAbsorber->m_numErrorsAbsorbed, 0);
 
     // verify the data.
     ASSERT_NE(config.GetPlatformByIdentifier(AzToolsFramework::AssetSystem::GetHostAssetPlatform()), nullptr);
@@ -330,29 +330,32 @@ TEST_F(PlatformConfigurationUnitTests, TestFailReadConfigFile_RegularScanfolder)
     auto configRoot = AZ::IO::FileIOBase::GetInstance()->ResolvePath("@exefolder@/testdata/config_regular");
     ASSERT_TRUE(configRoot);
     UnitTestPlatformConfiguration config;
-    m_absorber.Clear();
+    m_errorAbsorber->Clear();
     AssetUtilities::ComputeProjectName(EmptyDummyProjectName, true);
     ASSERT_TRUE(config.InitializeFromConfigFiles(configRoot->c_str(), testExeFolder->c_str(), projectPath.c_str(), false, false));
-    ASSERT_EQ(m_absorber.m_numErrorsAbsorbed, 0);
+    ASSERT_EQ(m_errorAbsorber->m_numErrorsAbsorbed, 0);
 
-    ASSERT_EQ(config.GetScanFolderCount(), 3); // the two, and then the one that has the same data as prior but different identifier.
+    ASSERT_EQ(config.GetScanFolderCount(), 4); // the two, and then the one that has the same data as prior but different identifier, plus hardcoded intermediates scanfolder
     QString scanName = AssetUtilities::ComputeProjectPath(true) + " Scan Folder";
-    ASSERT_EQ(config.GetScanFolderAt(0).GetDisplayName(), scanName);
-    ASSERT_EQ(config.GetScanFolderAt(0).RecurseSubFolders(), true);
-    ASSERT_EQ(config.GetScanFolderAt(0).GetOrder(), 0);
-    ASSERT_EQ(config.GetScanFolderAt(0).GetPortableKey(), QString("Game"));
-
-    ASSERT_EQ(config.GetScanFolderAt(1).GetDisplayName(), QString("FeatureTests"));
-    ASSERT_EQ(config.GetScanFolderAt(1).RecurseSubFolders(), false);
-    ASSERT_EQ(config.GetScanFolderAt(1).GetOrder(), 5000);
-    // this proves that the featuretests name is used instead of the output prefix
-    ASSERT_EQ(config.GetScanFolderAt(1).GetPortableKey(), QString("FeatureTests"));
 
-    ASSERT_EQ(config.GetScanFolderAt(2).GetDisplayName(), QString("FeatureTests2"));
+    // Scanfolder 0 is the intermediate assets scanfolder, we don't need to check that folder, so start checking at 1
+
+    ASSERT_EQ(config.GetScanFolderAt(1).GetDisplayName(), scanName);
+    ASSERT_EQ(config.GetScanFolderAt(1).RecurseSubFolders(), true);
+    ASSERT_EQ(config.GetScanFolderAt(1).GetOrder(), 0);
+    ASSERT_EQ(config.GetScanFolderAt(1).GetPortableKey(), QString("Game"));
+
+    ASSERT_EQ(config.GetScanFolderAt(2).GetDisplayName(), QString("FeatureTests"));
     ASSERT_EQ(config.GetScanFolderAt(2).RecurseSubFolders(), false);
-    ASSERT_EQ(config.GetScanFolderAt(2).GetOrder(), 6000);
+    ASSERT_EQ(config.GetScanFolderAt(2).GetOrder(), 5000);
     // this proves that the featuretests name is used instead of the output prefix
-    ASSERT_EQ(config.GetScanFolderAt(2).GetPortableKey(), QString("FeatureTests2"));
+    ASSERT_EQ(config.GetScanFolderAt(2).GetPortableKey(), QString("FeatureTests"));
+
+    ASSERT_EQ(config.GetScanFolderAt(3).GetDisplayName(), QString("FeatureTests2"));
+    ASSERT_EQ(config.GetScanFolderAt(3).RecurseSubFolders(), false);
+    ASSERT_EQ(config.GetScanFolderAt(3).GetOrder(), 6000);
+    // this proves that the featuretests name is used instead of the output prefix
+    ASSERT_EQ(config.GetScanFolderAt(3).GetPortableKey(), QString("FeatureTests2"));
 }
 
 TEST_F(PlatformConfigurationUnitTests, TestFailReadConfigFile_RegularScanfolderPlatformSpecific)
@@ -365,39 +368,40 @@ TEST_F(PlatformConfigurationUnitTests, TestFailReadConfigFile_RegularScanfolderP
     auto configRoot = AZ::IO::FileIOBase::GetInstance()->ResolvePath("@exefolder@/testdata/config_regular_platform_scanfolder");
     ASSERT_TRUE(configRoot);
     UnitTestPlatformConfiguration config;
-    m_absorber.Clear();
+    m_errorAbsorber->Clear();
     ASSERT_TRUE(config.InitializeFromConfigFiles(configRoot->c_str(), testExeFolder->c_str(), projectPath.c_str(), false, false));
-    ASSERT_EQ(m_absorber.m_numErrorsAbsorbed, 0);
+    ASSERT_EQ(m_errorAbsorber->m_numErrorsAbsorbed, 0);
 
-    ASSERT_EQ(config.GetScanFolderCount(), 5);
-    ASSERT_EQ(config.GetScanFolderAt(0).GetDisplayName(), QString("gameoutput"));
-    AZStd::vector<AssetBuilderSDK::PlatformInfo> platforms = config.GetScanFolderAt(0).GetPlatforms();
+    ASSERT_EQ(config.GetScanFolderCount(), 6); // +1 for hardcoded intermediates scanfolder
+    // Scanfolder 0 is the intermediate assets folder, so start at 1
+    ASSERT_EQ(config.GetScanFolderAt(1).GetDisplayName(), QString("gameoutput"));
+    AZStd::vector<AssetBuilderSDK::PlatformInfo> platforms = config.GetScanFolderAt(1).GetPlatforms();
     ASSERT_EQ(platforms.size(), 4);
     ASSERT_TRUE(AZStd::find(platforms.begin(), platforms.end(), AssetBuilderSDK::PlatformInfo(AzToolsFramework::AssetSystem::GetHostAssetPlatform(), AZStd::unordered_set<AZStd::string>{})) != platforms.end());
     ASSERT_TRUE(AZStd::find(platforms.begin(), platforms.end(), AssetBuilderSDK::PlatformInfo("android", AZStd::unordered_set<AZStd::string>{})) != platforms.end());
     ASSERT_TRUE(AZStd::find(platforms.begin(), platforms.end(), AssetBuilderSDK::PlatformInfo("ios", AZStd::unordered_set<AZStd::string>{})) != platforms.end());
     ASSERT_TRUE(AZStd::find(platforms.begin(), platforms.end(), AssetBuilderSDK::PlatformInfo("server", AZStd::unordered_set<AZStd::string>{})) != platforms.end());
 
-    ASSERT_EQ(config.GetScanFolderAt(1).GetDisplayName(), QString("editoroutput"));
-    platforms = config.GetScanFolderAt(1).GetPlatforms();
+    ASSERT_EQ(config.GetScanFolderAt(2).GetDisplayName(), QString("editoroutput"));
+    platforms = config.GetScanFolderAt(2).GetPlatforms();
     ASSERT_EQ(platforms.size(), 2);
     ASSERT_TRUE(AZStd::find(platforms.begin(), platforms.end(), AssetBuilderSDK::PlatformInfo(AzToolsFramework::AssetSystem::GetHostAssetPlatform(), AZStd::unordered_set<AZStd::string>{})) != platforms.end());
     ASSERT_TRUE(AZStd::find(platforms.begin(), platforms.end(), AssetBuilderSDK::PlatformInfo("android", AZStd::unordered_set<AZStd::string>{})) != platforms.end());
 
-    ASSERT_EQ(config.GetScanFolderAt(2).GetDisplayName(), QString("folder1output"));
-    platforms = config.GetScanFolderAt(2).GetPlatforms();
+    ASSERT_EQ(config.GetScanFolderAt(3).GetDisplayName(), QString("folder1output"));
+    platforms = config.GetScanFolderAt(3).GetPlatforms();
     ASSERT_EQ(platforms.size(), 1);
     ASSERT_TRUE(AZStd::find(platforms.begin(), platforms.end(), AssetBuilderSDK::PlatformInfo("android", AZStd::unordered_set<AZStd::string>{})) != platforms.end());
 
-    ASSERT_EQ(config.GetScanFolderAt(3).GetDisplayName(), QString("folder2output"));
-    platforms = config.GetScanFolderAt(3).GetPlatforms();
+    ASSERT_EQ(config.GetScanFolderAt(4).GetDisplayName(), QString("folder2output"));
+    platforms = config.GetScanFolderAt(4).GetPlatforms();
     ASSERT_EQ(platforms.size(), 3);
     ASSERT_TRUE(AZStd::find(platforms.begin(), platforms.end(), AssetBuilderSDK::PlatformInfo(AzToolsFramework::AssetSystem::GetHostAssetPlatform(), AZStd::unordered_set<AZStd::string>{})) != platforms.end());
     ASSERT_TRUE(AZStd::find(platforms.begin(), platforms.end(), AssetBuilderSDK::PlatformInfo("ios", AZStd::unordered_set<AZStd::string>{})) != platforms.end());
     ASSERT_TRUE(AZStd::find(platforms.begin(), platforms.end(), AssetBuilderSDK::PlatformInfo("server", AZStd::unordered_set<AZStd::string>{})) != platforms.end());
 
-    ASSERT_EQ(config.GetScanFolderAt(4).GetDisplayName(), QString("folder3output"));
-    platforms = config.GetScanFolderAt(4).GetPlatforms();
+    ASSERT_EQ(config.GetScanFolderAt(5).GetDisplayName(), QString("folder3output"));
+    platforms = config.GetScanFolderAt(5).GetPlatforms();
     ASSERT_EQ(platforms.size(), 0);
 }
 
@@ -412,11 +416,11 @@ TEST_F(PlatformConfigurationUnitTests, TestFailReadConfigFile_RegularExcludes)
     auto configRoot = AZ::IO::FileIOBase::GetInstance()->ResolvePath("@exefolder@/testdata/config_regular");
     ASSERT_TRUE(configRoot);
     UnitTestPlatformConfiguration config;
-    
+
     config.AddScanFolder(ScanFolderInfo("blahblah", "Blah ScanFolder", "sf2", true, true), true);
-    m_absorber.Clear();
+    m_errorAbsorber->Clear();
     ASSERT_TRUE(config.InitializeFromConfigFiles(configRoot->c_str(), testExeFolder->c_str(), projectPath.c_str(), false, false));
-    ASSERT_EQ(m_absorber.m_numErrorsAbsorbed, 0);
+    ASSERT_EQ(m_errorAbsorber->m_numErrorsAbsorbed, 0);
 
     ASSERT_TRUE(config.IsFileExcluded("blahblah/$tmp_01.test"));
     ASSERT_FALSE(config.IsFileExcluded("blahblah/tmp_01.test"));
@@ -495,9 +499,9 @@ TEST_F(PlatformConfigurationUnitTests, ReadCheckServer_FromConfig_Valid)
     auto configRoot = AZ::IO::FileIOBase::GetInstance()->ResolvePath("@exefolder@/testdata/config_regular");
     ASSERT_TRUE(configRoot);
     UnitTestPlatformConfiguration config;
-    m_absorber.Clear();
+    m_errorAbsorber->Clear();
     ASSERT_TRUE(config.InitializeFromConfigFiles(configRoot->c_str(), testExeFolder->c_str(), projectPath.c_str(), false, false));
-    ASSERT_EQ(m_absorber.m_numErrorsAbsorbed, 0);
+    ASSERT_EQ(m_errorAbsorber->m_numErrorsAbsorbed, 0);
 
     const AssetProcessor::RecognizerContainer& recogs = config.GetAssetRecognizerContainer();
 
@@ -545,9 +549,9 @@ TEST_F(PlatformConfigurationUnitTests, Test_MetaFileTypes_AssetImporterExtension
     auto configRoot = AZ::IO::FileIOBase::GetInstance()->ResolvePath("@exefolder@/testdata/config_metadata");
     ASSERT_TRUE(configRoot);
     UnitTestPlatformConfiguration config;
-    m_absorber.Clear();
+    m_errorAbsorber->Clear();
     ASSERT_FALSE(config.InitializeFromConfigFiles(configRoot->c_str(), testExeFolder->c_str(), projectPath.c_str(), false, false));
-    ASSERT_GT(m_absorber.m_numErrorsAbsorbed, 0);
+    ASSERT_GT(m_errorAbsorber->m_numErrorsAbsorbed, 0);
     ASSERT_TRUE(config.MetaDataFileTypesCount() == 2);
 
     QStringList entriesToTest{ "aaa", "bbb" };

+ 0 - 2
Code/Tools/AssetProcessor/native/tests/platformconfiguration/platformconfigurationtests.h

@@ -26,8 +26,6 @@ public:
 protected:
     void SetUp() override;
     void TearDown() override;
-    UnitTestUtils::AssertAbsorber m_absorber;
-    AssetProcessor::FileStatePassthrough m_fileStateCache;
 
 private:
     int         m_argc;

+ 21 - 13
Code/Tools/AssetProcessor/native/tests/resourcecompiler/RCJobTest.cpp

@@ -28,14 +28,14 @@ namespace UnitTests
     class IgnoreNotifyTracker : public ProcessingJobInfoBus::Handler
     {
     public:
-        // Will notify other systems which old product is just about to get removed from the cache 
-        // before we copy the new product instead along. 
+        // Will notify other systems which old product is just about to get removed from the cache
+        // before we copy the new product instead along.
         void BeginCacheFileUpdate(const char* productPath) override
         {
             m_capturedStartPaths.push_back(productPath);
         }
-        
-        // Will notify other systems which product we are trying to copy in the cache 
+
+        // Will notify other systems which product we are trying to copy in the cache
         // along with status of whether that copy succeeded or failed.
         void EndCacheFileUpdate(const char* productPath, bool /*queueAgainForProcessing*/) override
         {
@@ -66,7 +66,7 @@ namespace UnitTests
             ON_CALL(m_data->m_diskSpaceResponder, CheckSufficientDiskSpace(_, _, _))
                 .WillByDefault(Return(true));
 
-            
+
         }
 
         void TearDown() override
@@ -108,7 +108,7 @@ namespace UnitTests
         ProcessJobResponse response;
         response.m_resultCode = ProcessJobResult_Success;
         response.m_outputProducts.push_back({ "file1.txt" }); // make sure that there is at least one product so that it doesn't early out.
-        
+
         // set only the input path, not the output path:
         builderParams.m_processJobRequest.m_tempDirPath = m_data->m_absolutePathToTempInputFolder.c_str(); // input working scratch space folder
 
@@ -125,8 +125,9 @@ namespace UnitTests
 
         // set the input dir to be a broken invalid dir:
         builderParams.m_processJobRequest.m_tempDirPath = AZ::Uuid::CreateRandom().ToString<AZStd::string>();
-        builderParams.m_finalOutputDir = QString::fromUtf8(m_data->m_absolutePathToTempOutputFolder.c_str());  // output folder in the 'cache'
-        
+        builderParams.m_cacheOutputDir = m_data->m_absolutePathToTempOutputFolder;  // output folder in the 'cache'
+        builderParams.m_intermediateOutputDir = AssetUtilities::GetIntermediateAssetsFolder(m_data->m_absolutePathToTempOutputFolder.c_str());
+
         EXPECT_FALSE(RCJob::CopyCompiledAssets(builderParams, response));
         EXPECT_EQ(m_errorAbsorber->m_numAssertsAbsorbed, 1);
     }
@@ -138,7 +139,8 @@ namespace UnitTests
         response.m_resultCode = ProcessJobResult_Success;
         // set only the output path, but not the input path:
         builderParams.m_processJobRequest.m_tempDirPath = m_data->m_absolutePathToTempInputFolder.c_str(); // input working scratch space folder
-        builderParams.m_finalOutputDir = QString::fromUtf8(m_data->m_absolutePathToTempOutputFolder.c_str());  // output folder in the 'cache'
+        builderParams.m_cacheOutputDir = m_data->m_absolutePathToTempOutputFolder;  // output folder in the 'cache'
+        builderParams.m_intermediateOutputDir = AssetUtilities::GetIntermediateAssetsFolder(m_data->m_absolutePathToTempOutputFolder.c_str());
 
         // give it an overly long file name:
         AZStd::string reallyLongFileName;
@@ -157,7 +159,9 @@ namespace UnitTests
         response.m_resultCode = ProcessJobResult_Success;
         // set only the output path, but not the input path:
         builderParams.m_processJobRequest.m_tempDirPath = m_data->m_absolutePathToTempInputFolder.c_str(); // input working scratch space folder
-        builderParams.m_finalOutputDir = QString::fromUtf8(m_data->m_absolutePathToTempOutputFolder.c_str());  // output folder in the 'cache'
+        builderParams.m_cacheOutputDir = m_data->m_absolutePathToTempOutputFolder;  // output folder in the 'cache'
+        builderParams.m_intermediateOutputDir =
+            AssetUtilities::GetIntermediateAssetsFolder(m_data->m_absolutePathToTempOutputFolder.c_str());
         response.m_resultCode = ProcessJobResult_Success;
         response.m_outputProducts.push_back({ "file1.txt" }); // make sure that there is at least one product so that it doesn't early out.
         UnitTestUtils::CreateDummyFile(QDir(m_data->m_absolutePathToTempInputFolder.c_str()).absoluteFilePath("file1.txt"), "output of file 1");
@@ -194,7 +198,8 @@ namespace UnitTests
         response.m_resultCode = ProcessJobResult_Success;
         // set only the output path, but not the input path:
         builderParams.m_processJobRequest.m_tempDirPath = m_data->m_absolutePathToTempInputFolder.c_str(); // input working scratch space folder
-        builderParams.m_finalOutputDir = QString::fromUtf8(m_data->m_absolutePathToTempOutputFolder.c_str());  // output folder in the 'cache'
+        builderParams.m_cacheOutputDir = m_data->m_absolutePathToTempOutputFolder;  // output folder in the 'cache'
+        builderParams.m_intermediateOutputDir = AssetUtilities::GetIntermediateAssetsFolder(m_data->m_absolutePathToTempOutputFolder.c_str());
         response.m_resultCode = ProcessJobResult_Success;
         response.m_outputProducts.push_back({ "FiLe1.TxT" }); // make sure that there is at least one product so that it doesn't early out.
         UnitTestUtils::CreateDummyFile(QDir(m_data->m_absolutePathToTempInputFolder.c_str()).absoluteFilePath("FiLe1.TxT"), "output of file 1");
@@ -219,7 +224,9 @@ namespace UnitTests
         response.m_resultCode = ProcessJobResult_Success;
         // set only the output path, but not the input path:
         builderParams.m_processJobRequest.m_tempDirPath = m_data->m_absolutePathToTempInputFolder.c_str(); // input working scratch space folder
-        builderParams.m_finalOutputDir = QString::fromUtf8(m_data->m_absolutePathToTempOutputFolder.c_str());  // output folder in the 'cache'
+        builderParams.m_cacheOutputDir = m_data->m_absolutePathToTempOutputFolder;  // output folder in the 'cache'
+        builderParams.m_intermediateOutputDir = AssetUtilities::GetIntermediateAssetsFolder(m_data->m_absolutePathToTempOutputFolder.c_str());
+        builderParams.m_relativePath = "";
         response.m_resultCode = ProcessJobResult_Success;
 
         // make up a completely different random path to put an absolute file in:
@@ -253,7 +260,8 @@ namespace UnitTests
         response.m_resultCode = ProcessJobResult_Success;
         // set only the output path, but not the input path:
         builderParams.m_processJobRequest.m_tempDirPath = m_data->m_absolutePathToTempInputFolder.c_str(); // input working scratch space folder
-        builderParams.m_finalOutputDir = QString::fromUtf8(m_data->m_absolutePathToTempOutputFolder.c_str());  // output folder in the 'cache'
+        builderParams.m_cacheOutputDir = m_data->m_absolutePathToTempOutputFolder;  // output folder in the 'cache'
+        builderParams.m_intermediateOutputDir = AssetUtilities::GetIntermediateAssetsFolder(m_data->m_absolutePathToTempOutputFolder.c_str());
         response.m_resultCode = ProcessJobResult_Success;
         response.m_outputProducts.push_back({ "FiLe1.TxT" }); // make sure that there is at least one product so that it doesn't early out.
         UnitTestUtils::CreateDummyFile(QDir(m_data->m_absolutePathToTempInputFolder.c_str()).absoluteFilePath("FiLe1.TxT"), "output of file 1");

+ 0 - 33
Code/Tools/AssetProcessor/native/tests/utilities/JobModelTest.cpp

@@ -130,39 +130,6 @@ TEST_F(JobModelUnitTests, Test_RemoveAllJobsBySource)
     }
 }
 
-TEST_F(JobModelUnitTests, Test_RemoveAllJobsBySourceFolder)
-{
-    VerifyModel(); // verify up front for sanity.
-
-    AssetProcessor::CachedJobInfo* testJobInfo = new AssetProcessor::CachedJobInfo();
-    testJobInfo->m_elementId.SetInputAssetName("sourceFolder1/source.txt");
-    testJobInfo->m_elementId.SetPlatform("platform");
-    testJobInfo->m_elementId.SetJobDescriptor("jobKey");
-
-    testJobInfo->m_jobState = AzToolsFramework::AssetSystem::JobStatus::Completed;
-    m_unitTestJobModel->m_cachedJobs.push_back(testJobInfo);
-    m_unitTestJobModel->m_cachedJobsLookup.insert(testJobInfo->m_elementId, aznumeric_caster(m_unitTestJobModel->m_cachedJobs.size() - 1));
-
-    AssetProcessor::QueueElementID elementId("sourceFolder1/source.txt", "platform", "jobKey");
-    auto iter = m_unitTestJobModel->m_cachedJobsLookup.find(elementId);
-    ASSERT_NE(iter, m_unitTestJobModel->m_cachedJobsLookup.end());
-    unsigned int jobIndex = iter.value();
-    ASSERT_EQ(jobIndex, 6); //last job
-
-    ASSERT_EQ(m_unitTestJobModel->m_cachedJobs.size(), 7);
-    m_unitTestJobModel->OnFolderRemoved("sourceFolder1");
-
-    ASSERT_EQ(m_unitTestJobModel->m_cachedJobs.size(), 6);
-    VerifyModel();
-
-    // make sure sourceFolder1/source.txt is completely gone.
-    for (int idx = 0; idx < m_unitTestJobModel->m_cachedJobs.size(); idx++)
-    {
-        AssetProcessor::CachedJobInfo* jobInfo = m_unitTestJobModel->m_cachedJobs[idx];
-        ASSERT_NE(jobInfo->m_elementId.GetInputAssetName(), QString::fromUtf8("sourceFolder1/source.txt"));
-    }
-}
-
 void JobModelUnitTests::SetUp()
 {
     AssetProcessorTest::SetUp();

+ 0 - 1
Code/Tools/AssetProcessor/native/ui/MainWindow.cpp

@@ -458,7 +458,6 @@ void MainWindow::Activate()
     connect(m_guiApplicationManager->GetRCController(), &AssetProcessor::RCController::JobStatusChanged, m_jobsModel, &AssetProcessor::JobsModel::OnJobStatusChanged);
     connect(m_guiApplicationManager->GetAssetProcessorManager(), &AssetProcessor::AssetProcessorManager::JobRemoved, m_jobsModel, &AssetProcessor::JobsModel::OnJobRemoved);
     connect(m_guiApplicationManager->GetAssetProcessorManager(), &AssetProcessor::AssetProcessorManager::SourceDeleted, m_jobsModel, &AssetProcessor::JobsModel::OnSourceRemoved);
-    connect(m_guiApplicationManager->GetAssetProcessorManager(), &AssetProcessor::AssetProcessorManager::SourceFolderDeleted, m_jobsModel, &AssetProcessor::JobsModel::OnFolderRemoved);
 
     connect(ui->jobTreeView, &AzQtComponents::TableView::customContextMenuRequested, this, &MainWindow::ShowJobViewContextMenu);
     connect(ui->jobContextLogTableView, &AzQtComponents::TableView::customContextMenuRequested, this, &MainWindow::ShowLogLineContextMenu);

+ 145 - 80
Code/Tools/AssetProcessor/native/unittests/AssetProcessorManagerUnitTests.cpp

@@ -169,7 +169,7 @@ namespace AssetProcessor
         UNIT_TEST_EXPECT_FALSE(gameName.isEmpty());
         // should create cache folder in the root, and read everything from there.
 
-        // There is a sub-case of handling mixed cases, but is only supported on case-insensitive filesystems. 
+        // There is a sub-case of handling mixed cases, but is only supported on case-insensitive filesystems.
 #if defined(AZ_PLATFORM_LINUX)
         // Linux is case-sensitive, so 'basefile.txt' will stay the same case as the other subfolder versions
         constexpr const char* subfolder3BaseFilePath = "subfolder3/basefile.txt";
@@ -190,7 +190,7 @@ namespace AssetProcessor
         expectedFiles << tempPath.absoluteFilePath("subfolder2/aaa/bbb/ccc/basefile.txt");
         expectedFiles << tempPath.absoluteFilePath("subfolder2/aaa/bbb/ccc/ddd/basefile.txt");
 
-        expectedFiles << tempPath.absoluteFilePath(subfolder3BaseFilePath); 
+        expectedFiles << tempPath.absoluteFilePath(subfolder3BaseFilePath);
 
         expectedFiles << tempPath.absoluteFilePath("subfolder8/a/b/c/test.txt");
 
@@ -259,6 +259,7 @@ namespace AssetProcessor
         config.AddScanFolder(ScanFolderInfo(tempPath.filePath("subfolder1"), "subfolder1", "subfolder1", false, true,  platforms, -1)); // subfolder1 overrides root
         config.AddScanFolder(ScanFolderInfo(tempPath.absolutePath(),         "temp",       "tempfolder", true, false,  platforms, 0)); // add the root
 
+        config.AddIntermediateScanFolder();
 
         config.AddMetaDataType("exportsettings", QString());
 
@@ -465,9 +466,14 @@ namespace AssetProcessor
             UNIT_TEST_EXPECT_TRUE(processResults[checkIdx].m_jobEntry.m_watchFolderPath == AssetUtilities::NormalizeFilePath(watchFolderPath));
             UNIT_TEST_EXPECT_TRUE(processResults[checkIdx].m_jobEntry.m_pathRelativeToWatchFolder == "uniquefile.txt");
             UNIT_TEST_EXPECT_TRUE(processResults[checkIdx].m_jobEntry.m_databaseSourceName == "uniquefile.txt");
+
             QString platformFolder = cacheRoot.filePath(QString::fromUtf8(processResults[checkIdx].m_jobEntry.m_platformInfo.m_identifier.c_str()));
             platformFolder = AssetUtilities::NormalizeDirectoryPath(platformFolder);
-            UNIT_TEST_EXPECT_TRUE(processResults[checkIdx].m_destinationPath.startsWith(platformFolder));
+            AZ::IO::Path expectedCachePath = normalizedCacheRootDir.absoluteFilePath(platformFolder).toUtf8().constData();
+            AZ::IO::FixedMaxPath intermediateAssetsFolder = AssetUtilities::GetIntermediateAssetsFolder(normalizedCacheRoot.toUtf8().constData());
+
+            UNIT_TEST_EXPECT_TRUE(processResults[checkIdx].m_cachePath == expectedCachePath);
+            UNIT_TEST_EXPECT_TRUE(processResults[checkIdx].m_intermediatePath == intermediateAssetsFolder);
             UNIT_TEST_EXPECT_TRUE(processResults[checkIdx].m_jobEntry.m_computedFingerprint != 0);
 
             QMetaObject::invokeMethod(&apm, "OnJobStatusChanged", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[checkIdx].m_jobEntry), Q_ARG(JobStatus, JobStatus::Queued));
@@ -651,6 +657,15 @@ namespace AssetProcessor
             }
         }
 
+        // Takes an absolute cache path and returns the portion after cache/platform/
+        auto absProductPathToRelative = [&cacheRoot](QString absolutePath) -> AZStd::string
+        {
+            AZ::IO::Path platformRelativePath = absolutePath.toUtf8().constData();
+            platformRelativePath = platformRelativePath.LexicallyRelative(cacheRoot.absolutePath().toUtf8().constData());
+
+            return (*++platformRelativePath.begin()).StringAsPosix();
+        };
+
         // ---------- test successes ----------
 
 
@@ -665,8 +680,8 @@ namespace AssetProcessor
         //Invoke Asset Processed for android platform , txt files job description
         AssetBuilderSDK::ProcessJobResponse response;
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(androidouts[0].toUtf8().constData(), AZ::Uuid::CreateNull(), 1));
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(androidouts[1].toUtf8().constData(), AZ::Uuid::CreateNull(), 2));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(androidouts[0]), AZ::Uuid::CreateNull(), 1));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(androidouts[1]), AZ::Uuid::CreateNull(), 2));
 
         // make sure legacy SubIds get stored in the DB and in asset response messages.
         // also make sure they don't get filed for the wrong asset.
@@ -792,7 +807,7 @@ namespace AssetProcessor
         //Invoke Asset Processed for android platform , txt files2 job description
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(androidouts[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(androidouts[0])));
 
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[1].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
@@ -816,7 +831,7 @@ namespace AssetProcessor
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts[0])));
 
         //Invoke Asset Processed for pc platform , txt files job description
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[2].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
@@ -844,7 +859,7 @@ namespace AssetProcessor
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts[0])));
 
         //Invoke Asset Processed for pc platform , txt files 2 job description
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[3].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
@@ -952,7 +967,7 @@ namespace AssetProcessor
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts[0])));
 
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[0].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
@@ -1010,8 +1025,11 @@ namespace AssetProcessor
             UNIT_TEST_EXPECT_TRUE(AssetUtilities::NormalizeFilePath(processFile1) == AssetUtilities::NormalizeFilePath(absolutePath));
             QString platformFolder = cacheRoot.filePath(QString::fromUtf8(processResults[checkIdx].m_jobEntry.m_platformInfo.m_identifier.c_str()));
             platformFolder = AssetUtilities::NormalizeDirectoryPath(platformFolder);
-            processFile1 = processResults[checkIdx].m_destinationPath;
-            UNIT_TEST_EXPECT_TRUE(processFile1.startsWith(platformFolder));
+            AZ::IO::Path expectedCachePath = normalizedCacheRootDir.absoluteFilePath(platformFolder).toUtf8().constData();
+            AZ::IO::FixedMaxPath intermediateAssetsFolder = AssetUtilities::GetIntermediateAssetsFolder(normalizedCacheRoot.toUtf8().constData());
+
+            UNIT_TEST_EXPECT_TRUE(processResults[checkIdx].m_cachePath == expectedCachePath);
+            UNIT_TEST_EXPECT_TRUE(processResults[checkIdx].m_intermediatePath == intermediateAssetsFolder);
             UNIT_TEST_EXPECT_TRUE(processResults[checkIdx].m_jobEntry.m_computedFingerprint != 0);
         }
 
@@ -1047,22 +1065,22 @@ namespace AssetProcessor
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(androidouts[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(androidouts[0])));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[0].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(androidouts2[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(androidouts2[0])));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[1].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts[0])));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[2].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts2[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts2[0])));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[3].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         // let events bubble through:
@@ -1146,37 +1164,45 @@ namespace AssetProcessor
         UNIT_TEST_EXPECT_TRUE((processResults[3].m_jobEntry.m_platformInfo.m_identifier == "pc"));
         UNIT_TEST_EXPECT_TRUE(processResults[0].m_jobEntry.m_computedFingerprint != 0);
 
+        auto verifyProductPaths = [&cacheRoot, this](const JobDetails& jobDetails)
+        {
+            QString platformFolder = cacheRoot.filePath(QString::fromUtf8(jobDetails.m_jobEntry.m_platformInfo.m_identifier.c_str()));
+            platformFolder = AssetUtilities::NormalizeDirectoryPath(platformFolder);
+            AZ::IO::Path expectedCachePath = cacheRoot.absoluteFilePath(platformFolder).toUtf8().constData();
+            AZ::IO::FixedMaxPath intermediateAssetsFolder = AssetUtilities::GetIntermediateAssetsFolder(cacheRoot.absolutePath().toUtf8().constData());
+
+            UNIT_TEST_EXPECT_TRUE(jobDetails.m_cachePath == expectedCachePath);
+            UNIT_TEST_EXPECT_TRUE(jobDetails.m_intermediatePath == intermediateAssetsFolder);
+        };
+
         // send all the done messages simultaneously:
         for (int checkIdx = 0; checkIdx < 4; ++checkIdx)
         {
             QString processFile1 = processResults[checkIdx].m_jobEntry.GetAbsoluteSourcePath();
             UNIT_TEST_EXPECT_TRUE(AssetUtilities::NormalizeFilePath(processFile1) == AssetUtilities::NormalizeFilePath(absolutePath));
-            QString platformFolder = cacheRoot.filePath(QString::fromUtf8(processResults[checkIdx].m_jobEntry.m_platformInfo.m_identifier.c_str()));
-            platformFolder = AssetUtilities::NormalizeDirectoryPath(platformFolder);
-            processFile1 = processResults[checkIdx].m_destinationPath;
-            UNIT_TEST_EXPECT_TRUE(processFile1.startsWith(platformFolder));
+            verifyProductPaths(processResults[checkIdx]);
             UNIT_TEST_EXPECT_TRUE(processResults[checkIdx].m_jobEntry.m_computedFingerprint != 0);
         }
 
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(androidouts[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(androidouts[0])));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[0].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(androidouts2[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(androidouts2[0])));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[1].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts[0])));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[2].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts2[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts2[0])));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[3].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         // let events bubble through:
@@ -1212,22 +1238,22 @@ namespace AssetProcessor
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(androidouts[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(androidouts[0])));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[0].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(androidouts2[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(androidouts2[0])));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[1].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts[0])));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[2].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts2[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts2[0])));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[3].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         // let events bubble through:
@@ -1329,23 +1355,23 @@ namespace AssetProcessor
         // send both done messages simultaneously!
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(androidouts[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(androidouts[0])));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[0].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(androidouts2[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(androidouts2[0])));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[1].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         // send one failure only for PC :
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts[0])));
         QMetaObject::invokeMethod(&apm, "AssetFailed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[2].m_jobEntry));
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts2[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts2[0])));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[3].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         // let events bubble through:
@@ -1451,7 +1477,7 @@ namespace AssetProcessor
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts[0])));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[0].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         // let events bubble through:
@@ -1493,9 +1519,9 @@ namespace AssetProcessor
         //Invoke Asset Processed for pc platform , txt files job description
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts[0].toUtf8().constData(), AZ::Uuid::CreateNull(), 1));
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts[1].toUtf8().constData(), AZ::Uuid::CreateNull(), 2));
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts[2].toUtf8().constData(), AZ::Uuid::CreateNull(), 3));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts[0]), AZ::Uuid::CreateNull(), 1));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts[1]), AZ::Uuid::CreateNull(), 2));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts[2]), AZ::Uuid::CreateNull(), 3));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[0].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         // let events bubble through:
@@ -1548,22 +1574,22 @@ namespace AssetProcessor
         // send all the done messages simultaneously:
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(androidouts[0].toUtf8().constData(), AZ::Uuid::CreateNull(), 1));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(androidouts[0]), AZ::Uuid::CreateNull(), 1));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[0].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(androidouts2[0].toUtf8().constData(), AZ::Uuid::CreateNull(), 2));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(androidouts2[0]), AZ::Uuid::CreateNull(), 2));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[1].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts[0].toUtf8().constData(), AZ::Uuid::CreateNull(), 3));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts[0]), AZ::Uuid::CreateNull(), 3));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[2].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         response.m_outputProducts.clear();
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts2[0].toUtf8().constData(), AZ::Uuid::CreateNull(), 4));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(absProductPathToRelative(pcouts2[0]), AZ::Uuid::CreateNull(), 4));
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[3].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
         // let events bubble through:
@@ -1630,10 +1656,7 @@ namespace AssetProcessor
         {
             QString processFile1 = processResults[checkIdx].m_jobEntry.GetAbsoluteSourcePath();
             UNIT_TEST_EXPECT_TRUE(processFile1 == expectedReplacementInputFile);
-            QString platformFolder = cacheRoot.filePath(QString::fromUtf8(processResults[checkIdx].m_jobEntry.m_platformInfo.m_identifier.c_str()));
-            platformFolder = AssetUtilities::NormalizeDirectoryPath(platformFolder);
-            processFile1 = processResults[checkIdx].m_destinationPath;
-            UNIT_TEST_EXPECT_TRUE(processFile1.startsWith(platformFolder));
+            verifyProductPaths(processResults[checkIdx]);
             UNIT_TEST_EXPECT_TRUE(processResults[checkIdx].m_jobEntry.m_computedFingerprint != 0);
         }
 #endif // defined(AZ_PLATFORM_LINUX)
@@ -1690,11 +1713,13 @@ namespace AssetProcessor
         for (const auto& processResult : processResults)
         {
             ++resultIdx;
-            QString outputFile = normalizedCacheRootDir.absoluteFilePath(processResult.m_destinationPath + "/doesn'tmatter.dds" + processResult.m_jobEntry.m_jobKey);
+            AZStd::string filename = ("doesn'tmatter.dds" + processResult.m_jobEntry.m_jobKey).toUtf8().constData();
+            QString outputFile = (processResult.m_cachePath / filename).AsPosix().c_str();
             CreateDummyFile(outputFile);
             response = {};
             response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(outputFile.toUtf8().constData(), AZ::Uuid::CreateNull(), resultIdx));
+            response.m_outputProducts.push_back(
+                AssetBuilderSDK::JobProduct((processResult.m_relativePath / filename).StringAsPosix(), AZ::Uuid::CreateNull(), resultIdx));
             apm.AssetProcessed(processResult.m_jobEntry, response);
         }
 
@@ -1717,7 +1742,7 @@ namespace AssetProcessor
         UNIT_TEST_EXPECT_TRUE((processResults[1].m_jobEntry.m_platformInfo.m_identifier == "pc") || (processResults[1].m_jobEntry.m_platformInfo.m_identifier == "android"));
 
         processResults.clear();
-        
+
         // ------------- Test querying asset status -------------------
         {
             absolutePath = tempPath.absoluteFilePath("subfolder2/folder/ship.tiff");
@@ -1729,13 +1754,15 @@ namespace AssetProcessor
             for (const JobDetails& processResult : processResults)
             {
                 ++resultIdx;
-                QString outputFile = normalizedCacheRootDir.absoluteFilePath(processResult.m_destinationPath + "/ship_nrm.dds");
+                AZStd::string filename = "ship_nrm.dds";
+                QString outputFile = (processResult.m_cachePath / filename).AsPosix().c_str();
 
                 CreateDummyFile(outputFile);
 
                 AssetBuilderSDK::ProcessJobResponse jobResponse;
                 jobResponse.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-                jobResponse.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(outputFile.toUtf8().constData(), AZ::Uuid::CreateNull(), resultIdx));
+                jobResponse.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(
+                    (processResult.m_relativePath / filename).StringAsPosix(), AZ::Uuid::CreateNull(), resultIdx));
 
                 apm.AssetProcessed(processResult.m_jobEntry, jobResponse);
             }
@@ -1824,12 +1851,13 @@ namespace AssetProcessor
         for (int index = 0; index < processResults.size(); ++index)
         {
             QFileInfo fi(processResults[index].m_jobEntry.GetAbsoluteSourcePath());
-            QString pcout = QDir(processResults[index].m_destinationPath).absoluteFilePath(fi.fileName());
+            AZStd::string filename = fi.fileName().toUtf8().constData();
+            QString pcout = (processResults[index].m_cachePath / filename).c_str();
             UNIT_TEST_EXPECT_TRUE(CreateDummyFile(pcout, "products."));
 
             response.m_outputProducts.clear();
             response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcout.toUtf8().constData(), AZ::Uuid::CreateNull(), index));
+            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct((processResults[index].m_relativePath / filename).StringAsPosix(), AZ::Uuid::CreateNull(), index));
             QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[index].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
         }
 
@@ -1880,12 +1908,14 @@ namespace AssetProcessor
         for (int index = 0; index < processResults.size(); ++index)
         {
             QFileInfo fi(processResults[index].m_jobEntry.GetAbsoluteSourcePath());
-            QString pcout = QDir(processResults[index].m_destinationPath).absoluteFilePath(fi.fileName());
+            AZStd::string filename = fi.fileName().toUtf8().constData();
+            QString pcout = (processResults[index].m_cachePath / filename).c_str();
             UNIT_TEST_EXPECT_TRUE(CreateDummyFile(pcout, "products."));
 
             response.m_outputProducts.clear();
             response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcout.toUtf8().constData(), AZ::Uuid::CreateNull(), index));
+            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(
+                (processResults[index].m_relativePath / filename).StringAsPosix(), AZ::Uuid::CreateNull(), index));
             QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[index].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
         }
 
@@ -1941,12 +1971,14 @@ namespace AssetProcessor
         for (int index = 0; index < processResults.size(); ++index)
         {
             QFileInfo fi(processResults[index].m_jobEntry.GetAbsoluteSourcePath());
-            QString pcout = QDir(processResults[index].m_destinationPath).absoluteFilePath(fi.fileName());
+            AZStd::string filename = fi.fileName().toUtf8().constData();
+            QString pcout = (processResults[index].m_cachePath / filename).c_str();
             UNIT_TEST_EXPECT_TRUE(CreateDummyFile(pcout, "products."));
 
             response.m_outputProducts.clear();
             response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcout.toUtf8().constData(), AZ::Uuid::CreateNull(), index));
+            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(
+                (processResults[index].m_relativePath / filename).StringAsPosix(), AZ::Uuid::CreateNull(), index));
             QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[index].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
         }
 
@@ -1996,12 +2028,14 @@ namespace AssetProcessor
         for (int index = 0; index < processResults.size(); ++index)
         {
             QFileInfo fi(processResults[index].m_jobEntry.GetAbsoluteSourcePath());
-            QString pcout = QDir(processResults[index].m_destinationPath).absoluteFilePath(fi.fileName());
+            AZStd::string filename = fi.fileName().toUtf8().constData();
+            QString pcout = (processResults[index].m_cachePath / filename).c_str();
             UNIT_TEST_EXPECT_TRUE(CreateDummyFile(pcout, "products."));
 
             response.m_outputProducts.clear();
             response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcout.toUtf8().constData(), AZ::Uuid::CreateNull(), index));
+            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(
+                (processResults[index].m_relativePath / filename).StringAsPosix(), AZ::Uuid::CreateNull(), index));
             QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[index].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
         }
 
@@ -2055,13 +2089,18 @@ namespace AssetProcessor
             // this time, ouput 2 files for each job instead of just one:
             QFileInfo fi(processResults[index].m_jobEntry.GetAbsoluteSourcePath());
 
-            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(QDir(processResults[index].m_destinationPath).absoluteFilePath(fi.fileName() + ".0.txt").toUtf8().constData(), AZ::Uuid::CreateNull(), index));
-            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(QDir(processResults[index].m_destinationPath).absoluteFilePath(fi.fileName() + ".1.txt").toUtf8().constData(), AZ::Uuid::CreateNull(), index + 100));
+            AZStd::string filename0 = (fi.fileName() + ".0.txt").toUtf8().constData();
+            AZStd::string filename1 = (fi.fileName() + ".1.txt").toUtf8().constData();
+
+            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(
+                (processResults[index].m_relativePath / filename0).StringAsPosix(), AZ::Uuid::CreateNull(), index));
+            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(
+                (processResults[index].m_relativePath / filename1).StringAsPosix(), AZ::Uuid::CreateNull(), index + 100));
 
-            createdDummyFiles.push_back(response.m_outputProducts[0].m_productFileName.c_str()); // we're only gong to delete this one out of the two, which is why we don't push the other one.
+            createdDummyFiles.push_back((processResults[index].m_cachePath / filename0).c_str()); // we're only gong to delete this one out of the two, which is why we don't push the other one.
 
-            UNIT_TEST_EXPECT_TRUE(CreateDummyFile(response.m_outputProducts[0].m_productFileName.c_str(), "product 0"));
-            UNIT_TEST_EXPECT_TRUE(CreateDummyFile(response.m_outputProducts[1].m_productFileName.c_str(), "product 1"));
+            UNIT_TEST_EXPECT_TRUE(CreateDummyFile((processResults[index].m_cachePath / filename0).c_str(), "product 0"));
+            UNIT_TEST_EXPECT_TRUE(CreateDummyFile((processResults[index].m_cachePath / filename1).c_str(), "product 1"));
 
             QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[index].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
         }
@@ -2094,8 +2133,11 @@ namespace AssetProcessor
             // this time, ouput only one file for each job instead of just one:
             QFileInfo fi(processResults[index].m_jobEntry.GetAbsoluteSourcePath());
 
-            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(QDir(processResults[index].m_destinationPath).absoluteFilePath(fi.fileName() + ".1.txt").toUtf8().constData(), AZ::Uuid::CreateNull(), index));
-            UNIT_TEST_EXPECT_TRUE(CreateDummyFile(response.m_outputProducts[0].m_productFileName.c_str(), "product 1 changed"));
+            AZStd::string filename = (fi.fileName() + ".1.txt").toUtf8().constData();
+
+            response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(
+                (processResults[index].m_relativePath / filename).StringAsPosix(), AZ::Uuid::CreateNull(), index));
+            UNIT_TEST_EXPECT_TRUE(CreateDummyFile((processResults[index].m_cachePath / filename).c_str(), "product 1 changed"));
 
             QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[index].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
         }
@@ -2255,6 +2297,8 @@ namespace AssetProcessor
         config.AddScanFolder(ScanFolderInfo(tempPath.filePath("subfolder1"), "subfolder1", "subfolder1", false, true, platforms,-1)); // subfolder1 overrides root
         config.AddScanFolder(ScanFolderInfo(tempPath.absolutePath(), "temp", "tempfolder", true, false, platforms, 0)); // add the root
 
+        config.AddIntermediateScanFolder();
+
         AssetProcessorManager_Test apm(&config);
 
         QDir cacheRoot;
@@ -2325,8 +2369,8 @@ namespace AssetProcessor
         // Invoke Asset Processed for pc platform for the first job
         AssetBuilderSDK::ProcessJobResponse response;
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts[0].toUtf8().constData(), AZ::Uuid::CreateNull(), 1));
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts[1].toUtf8().constData(), AZ::Uuid::CreateNull(), 2));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct("basefile.arc1", AZ::Uuid::CreateNull(), 1));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct("basefile.arc2", AZ::Uuid::CreateNull(), 2));
 
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[0].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
@@ -2354,7 +2398,7 @@ namespace AssetProcessor
 
         // Invoke Asset Processed for pc platform for the second job
         response.m_outputProducts.clear();
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(pcouts[0].toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct("basefile.arc3"));
         assetMessages.clear();
         changedInputResults.clear();
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[1].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
@@ -2710,6 +2754,8 @@ namespace AssetProcessor
         config.AddScanFolder(ScanFolderInfo(tempPath.filePath("subfolder1"), "subfolder1", "subfolder1", false, true, platforms, -1)); // subfolder1 overrides root
         config.AddScanFolder(ScanFolderInfo(tempPath.absolutePath(), "temp", "tempfolder", true, false, platforms, 0)); // add the root
 
+        config.AddIntermediateScanFolder();
+
         AssetProcessorManager_Test apm(&config);
 
         QList<JobDetails> processResults;
@@ -2737,11 +2783,17 @@ namespace AssetProcessor
         QDir cacheRoot;
         UNIT_TEST_EXPECT_TRUE(AssetUtilities::ComputeProjectCacheRoot(cacheRoot));
 
-        QString productFileAPath = cacheRoot.filePath(QString("pc/fileaproduct.txt"));
-        QString productFileBPath = cacheRoot.filePath(QString("pc/filebproduct1.txt"));
-        QString product2FileBPath = cacheRoot.filePath(QString("pc/filebproduct2.txt"));
-        QString productFileCPath = cacheRoot.filePath(QString("pc/filecproduct.txt"));
-        QString product2FileCPath = cacheRoot.filePath(QString("pc/filecproduct2.txt"));
+        constexpr const char* productFileAFilename = "fileaproduct.txt";
+        constexpr const char* productFileBFilename = "filebproduct1.txt";
+        constexpr const char* product2FileBFilename = "filebproduct2.txt";
+        constexpr const char* productFileCFilename = "filecproduct.txt";
+        constexpr const char* product2FileCFilename = "filecproduct2.txt";
+
+        QString productFileAPath = cacheRoot.filePath(QString("pc/") + productFileAFilename);
+        QString productFileBPath = cacheRoot.filePath(QString("pc/") + productFileBFilename);
+        QString product2FileBPath = cacheRoot.filePath(QString("pc/") + product2FileBFilename);
+        QString productFileCPath = cacheRoot.filePath(QString("pc/") + productFileCFilename);
+        QString product2FileCPath = cacheRoot.filePath(QString("pc/") + product2FileCFilename);
 
         UNIT_TEST_EXPECT_TRUE(CreateDummyFile(sourceFileAPath, ""));
         UNIT_TEST_EXPECT_TRUE(CreateDummyFile(sourceFileBPath, ""));
@@ -2752,6 +2804,8 @@ namespace AssetProcessor
         UNIT_TEST_EXPECT_TRUE(CreateDummyFile(productFileCPath, "product"));
         UNIT_TEST_EXPECT_TRUE(CreateDummyFile(product2FileCPath, "product"));
 
+        AZStd::string cacheWithPlatform = cacheRoot.absoluteFilePath("pc").toUtf8().constData();
+
         // Analyze FileA
         QMetaObject::invokeMethod(&apm, "AssessAddedFile", Qt::QueuedConnection, Q_ARG(QString, sourceFileAPath));
 
@@ -2763,7 +2817,8 @@ namespace AssetProcessor
         // Invoke Asset Processed for pc platform for the FileA job
         AssetBuilderSDK::ProcessJobResponse response;
         response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(productFileAPath.toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(productFileAFilename));
+        response.m_outputProducts.back().m_outputPathOverride = cacheWithPlatform;
 
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[0].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
 
@@ -2795,7 +2850,8 @@ namespace AssetProcessor
         UNIT_TEST_EXPECT_TRUE(onlyOneJobHaveJobDependency);
 
         // Invoke Asset Processed for pc platform for the first FileB job
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(productFileBPath.toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(productFileBFilename));
+        response.m_outputProducts.back().m_outputPathOverride = cacheWithPlatform;
 
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[0].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
         UNIT_TEST_EXPECT_TRUE(BlockUntil(idling, 5000));
@@ -2803,7 +2859,8 @@ namespace AssetProcessor
         response.m_outputProducts.clear();
 
         // Invoke Asset Processed for pc platform for the second FileB job
-        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(product2FileBPath.toUtf8().constData()));
+        response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(product2FileBFilename));
+        response.m_outputProducts.back().m_outputPathOverride = cacheWithPlatform;
 
         QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, processResults[1].m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
         UNIT_TEST_EXPECT_TRUE(BlockUntil(idling, 5000000));
@@ -2822,20 +2879,24 @@ namespace AssetProcessor
 
         for (JobDetails& jobDetail : processResults)
         {
+            UNIT_TEST_EXPECT_TRUE(processResults.size() == 2); // Repeat to ensure count doesn't change while looping
+
             if (QString(jobDetail.m_jobEntry.m_pathRelativeToWatchFolder).endsWith("FileB.txt"))
             {
                 // Ensure that we are processing the right FileB job
                 UNIT_TEST_EXPECT_TRUE(QString(jobDetail.m_jobEntry.m_jobKey).compare("yyy") == 0);
 
                 response.m_outputProducts.clear();
-                response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(product2FileBPath.toUtf8().constData()));
+                response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(product2FileBFilename));
+                response.m_outputProducts.back().m_outputPathOverride = cacheWithPlatform;
                 QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, jobDetail.m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
                 UNIT_TEST_EXPECT_TRUE(BlockUntil(idling, 5000));
             }
             else
             {
                 response.m_outputProducts.clear();
-                response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(productFileAPath.toUtf8().constData()));
+                response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(productFileAFilename));
+                response.m_outputProducts.back().m_outputPathOverride = cacheWithPlatform;
                 QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, jobDetail.m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
                 UNIT_TEST_EXPECT_TRUE(BlockUntil(idling, 5000));
             }
@@ -2863,14 +2924,16 @@ namespace AssetProcessor
                 UNIT_TEST_EXPECT_TRUE(QString(jobDetail.m_jobEntry.m_jobKey).compare("yyy") == 0);
 
                 response.m_outputProducts.clear();
-                response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(product2FileBPath.toUtf8().constData()));
+                response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(product2FileBFilename));
+                response.m_outputProducts.back().m_outputPathOverride = cacheWithPlatform;
                 QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, jobDetail.m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
                 UNIT_TEST_EXPECT_TRUE(BlockUntil(idling, 5000));
             }
             else
             {
                 response.m_outputProducts.clear();
-                response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(productFileAPath.toUtf8().constData()));
+                response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(productFileAFilename));
+                response.m_outputProducts.back().m_outputPathOverride = cacheWithPlatform;
                 QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, jobDetail.m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
                 UNIT_TEST_EXPECT_TRUE(BlockUntil(idling, 5000));
             }
@@ -2900,14 +2963,16 @@ namespace AssetProcessor
                 UNIT_TEST_EXPECT_TRUE(QString(jobDetail.m_jobDependencyList[0].m_jobDependency.m_jobKey.c_str()).compare("yyy") == 0);
 
                 response.m_outputProducts.clear();
-                response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(product2FileCPath.toUtf8().constData()));
+                response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(product2FileCFilename));
+                response.m_outputProducts.back().m_outputPathOverride = cacheWithPlatform;
                 QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, jobDetail.m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
                 UNIT_TEST_EXPECT_TRUE(BlockUntil(idling, 5000));
             }
             else
             {
                 response.m_outputProducts.clear();
-                response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(productFileCPath.toUtf8().constData()));
+                response.m_outputProducts.push_back(AssetBuilderSDK::JobProduct(productFileCFilename));
+                response.m_outputProducts.back().m_outputPathOverride = cacheWithPlatform;
                 QMetaObject::invokeMethod(&apm, "AssetProcessed", Qt::QueuedConnection, Q_ARG(JobEntry, jobDetail.m_jobEntry), Q_ARG(AssetBuilderSDK::ProcessJobResponse, response));
                 UNIT_TEST_EXPECT_TRUE(BlockUntil(idling, 5000));
             }

+ 3 - 0
Code/Tools/AssetProcessor/native/unittests/AssetScannerUnitTests.cpp

@@ -28,6 +28,9 @@ namespace UnitTest
         int argC = 0;
         m_qApp.reset(new QApplication(argC, nullptr));
 
+        qRegisterMetaType<AssetProcessor::AssetScanningStatus>("AssetScanningStatus");
+        qRegisterMetaType<QSet<AssetProcessor::AssetFileInfo>>("QSet<AssetFileInfo>");
+
         AZ::Test::ScopedAutoTempDirectory tempEngineRoot;
 
         AZStd::set<AZ::IO::Path> expectedFiles;

+ 3 - 3
Code/Tools/AssetProcessor/native/unittests/FileWatcherUnitTests.cpp

@@ -209,7 +209,7 @@ void FileWatcherUnitTestRunner::StartTest()
         UNIT_TEST_EXPECT_TRUE(QFile::rename(originalName, newName1));
 
         tries = 0;
-        while (!(fileAddCalled && fileRemoveCalled && fileModifiedCalled) && tries++ < 100)
+        while (!(fileAddCalled || fileRemoveCalled || fileModifiedCalled) && tries++ < 100)
         {
             QThread::msleep(10);
             QCoreApplication::processEvents(QEventLoop::AllEvents, 100);
@@ -235,7 +235,7 @@ void FileWatcherUnitTestRunner::StartTest()
         UNIT_TEST_EXPECT_TRUE(QFile::rename(newName1, newName2));
 
         tries = 0;
-        while (!(fileAddCalled && fileRemoveCalled && fileModifiedCalled) && tries++ < 100)
+        while (!(fileAddCalled || fileRemoveCalled || fileModifiedCalled) && tries++ < 100)
         {
             QThread::msleep(10);
             QCoreApplication::processEvents(QEventLoop::AllEvents, 100);
@@ -260,7 +260,7 @@ void FileWatcherUnitTestRunner::StartTest()
         fileModifiedCalled = false;
 
         tries = 0;
-        while (!(fileAddCalled && fileRemoveCalled && fileModifiedCalled) && tries++ < 100)
+        while (!(fileAddCalled || fileRemoveCalled || fileModifiedCalled) && tries++ < 100)
         {
             QThread::msleep(10);
             QCoreApplication::processEvents(QEventLoop::AllEvents, 100);

+ 29 - 5
Code/Tools/AssetProcessor/native/utilities/ApplicationManagerBase.cpp

@@ -381,6 +381,7 @@ void ApplicationManagerBase::InitAssetScanner()
     // asset processor manager
     QObject::connect(m_assetScanner, &AssetScanner::AssetScanningStatusChanged, m_assetProcessorManager, &AssetProcessorManager::OnAssetScannerStatusChange);
     QObject::connect(m_assetScanner, &AssetScanner::FilesFound,                 m_assetProcessorManager, &AssetProcessorManager::AssessFilesFromScanner);
+    QObject::connect(m_assetScanner, &AssetScanner::FoldersFound,                 m_assetProcessorManager, &AssetProcessorManager::RecordFoldersFromScanner);
 
     QObject::connect(m_assetScanner, &AssetScanner::FilesFound, [this](QSet<AssetFileInfo> files) { m_fileStateCache->AddInfoSet(files); });
     QObject::connect(m_assetScanner, &AssetScanner::FoldersFound, [this](QSet<AssetFileInfo> files) { m_fileStateCache->AddInfoSet(files); });
@@ -463,7 +464,7 @@ void ApplicationManagerBase::InitFileMonitor(AZStd::unique_ptr<FileWatcher> file
 
         const auto OnFileAdded = [this, cachePath](QString path)
         {
-            const bool isCacheRoot = path.startsWith(cachePath);
+            const bool isCacheRoot = AssetUtilities::IsInCacheFolder(path.toUtf8().constData(), cachePath.toUtf8().constData());
             if (!isCacheRoot)
             {
                 [[maybe_unused]] bool result = QMetaObject::invokeMethod(m_assetProcessorManager, [this, path]()
@@ -495,7 +496,7 @@ void ApplicationManagerBase::InitFileMonitor(AZStd::unique_ptr<FileWatcher> file
 
         const auto OnFileModified = [this, cachePath](QString path)
         {
-            const bool isCacheRoot = path.startsWith(cachePath);
+            const bool isCacheRoot = AssetUtilities::IsInCacheFolder(path.toUtf8().constData(), cachePath.toUtf8().constData());
             if (!isCacheRoot)
             {
                 m_fileStateCache->UpdateFile(path);
@@ -506,7 +507,8 @@ void ApplicationManagerBase::InitFileMonitor(AZStd::unique_ptr<FileWatcher> file
                 [this, path]
                 {
                     m_assetProcessorManager->AssessModifiedFile(path);
-                }, Qt::QueuedConnection);
+                },
+                Qt::QueuedConnection);
 
             AZ_Assert(result, "Failed to invoke m_assetProcessorManager::AssessModifiedFile");
         };
@@ -514,7 +516,7 @@ void ApplicationManagerBase::InitFileMonitor(AZStd::unique_ptr<FileWatcher> file
         const auto OnFileRemoved = [this, cachePath](QString path)
         {
             [[maybe_unused]] bool result = false;
-            const bool isCacheRoot = path.startsWith(cachePath);
+            const bool isCacheRoot = AssetUtilities::IsInCacheFolder(path.toUtf8().constData(), cachePath.toUtf8().constData());
             if (!isCacheRoot)
             {
                 result = QMetaObject::invokeMethod(m_fileProcessor.get(), [this, path]()
@@ -1866,15 +1868,37 @@ bool ApplicationManagerBase::OnError(const char* /*window*/, const char* /*messa
 
 bool ApplicationManagerBase::CheckSufficientDiskSpace(const QString& savePath, qint64 requiredSpace, bool shutdownIfInsufficient)
 {
+    bool createdDirectory = false;
+
     if (!QDir(savePath).exists())
     {
+        // GetFreeDiskSpace will fail if the path does not exist
         QDir dir;
-        dir.mkpath(savePath);
+        createdDirectory = dir.mkpath(savePath);
     }
 
     qint64 bytesFree = 0;
     [[maybe_unused]] bool result = AzToolsFramework::ToolsFileUtils::GetFreeDiskSpace(savePath, bytesFree);
 
+    if (createdDirectory)
+    {
+        // Clean up the folder so we're not leaving empty folders all over the place
+        // We need to walk up the path and try to delete each folder along the way since savePath might have created a series of folders
+        // Just deleting savePath would only delete the last folder created
+        AZ::IO::Path path(savePath.toUtf8().constData());
+        while(AZ::IO::SystemFile::DeleteDir(path.c_str())) // DeleteDir should fail if the directory is not empty
+        {
+            if (path.HasParentPath())
+            {
+                path = path.ParentPath();
+            }
+            else
+            {
+                break;
+            }
+        }
+    }
+
     AZ_Assert(result, "Unable to determine the amount of free space on drive containing path (%s).", savePath.toUtf8().constData());
 
     if (bytesFree < requiredSpace + s_ReservedDiskSpaceInBytes)

+ 103 - 1
Code/Tools/AssetProcessor/native/utilities/PlatformConfiguration.cpp

@@ -632,6 +632,12 @@ namespace AssetProcessor
         */
         for (const AssetBuilderSDK::PlatformInfo& platform : m_enabledPlatforms)
         {
+            // Exclude the common platform from the internal copy builder, we don't support it as an output for assets currently
+            if(platform.m_identifier == AssetBuilderSDK::CommonPlatformName)
+            {
+                continue;
+            }
+
             AZStd::string_view currentParams = assetRecognizer.m_defaultParams;
             // The "/Amazon/AssetProcessor/Settings/SJ */<platform>" entry will be queried
             AZ::IO::Path overrideParamsKey = AZ::IO::Path(AZ::IO::PosixPathSeparator);
@@ -944,6 +950,13 @@ namespace AssetProcessor
 
         FinalizeEnabledPlatforms();
 
+        if(!m_enabledPlatforms.empty())
+        {
+            // Add the common platform if we have some other platforms enabled.  For now, this is only intended for intermediate assets
+            // So we don't want to enable it unless at least one actual platform is available, to avoid hiding an error state of no real platforms being active
+            EnableCommonPlatform();
+        }
+
         if (scanFolderOverride)
         {
             AZStd::vector<AssetBuilderSDK::PlatformInfo> platforms;
@@ -964,6 +977,7 @@ namespace AssetProcessor
                     true));
             }
         }
+
         // Then read recognizers (which depend on platforms)
         if (!ReadRecognizersFromSettingsRegistry(absoluteAssetRoot, noConfigScanFolders, scanFolderPatterns))
         {
@@ -973,6 +987,14 @@ namespace AssetProcessor
             }
             return IsValid();
         }
+
+        if(!m_scanFolders.empty())
+        {
+            // Enable the intermediate scanfolder if we have some other scanfolders.  Since this is hardcoded we don't want to hide an error state
+            // where no other scanfolders are enabled besides this one.  It wouldn't make sense for the intermediate scanfolder to be the only enabled scanfolder
+            AddIntermediateScanFolder();
+        }
+
         if (!noGemScanFolders && addGemsConfigs)
         {
             if (settingsRegistry == nullptr || !AzFramework::GetGemsInfo(m_gemInfoList, *settingsRegistry))
@@ -1130,6 +1152,12 @@ namespace AssetProcessor
             // Add all enabled platforms
             for (const AssetBuilderSDK::PlatformInfo& platform : m_enabledPlatforms)
             {
+                if(platform.m_identifier == AssetBuilderSDK::CommonPlatformName)
+                {
+                    // The common platform is not included in any scanfolder to avoid builders by-default producing jobs for it
+                    continue;
+                }
+
                 if (AZStd::find(platformsList.begin(), platformsList.end(), platform) == platformsList.end())
                 {
                     platformsList.push_back(platform);
@@ -1142,6 +1170,12 @@ namespace AssetProcessor
             {
                 for (const AssetBuilderSDK::PlatformInfo& platform : m_enabledPlatforms)
                 {
+                    if(platform.m_identifier == AssetBuilderSDK::CommonPlatformName)
+                    {
+                        // The common platform is not included in any scanfolder to avoid builders by-default producing jobs for it
+                        continue;
+                    }
+
                     bool addPlatform = (QString::compare(identifier, platform.m_identifier.c_str(), Qt::CaseInsensitive) == 0) ||
                         platform.m_tags.find(identifier.toLower().toUtf8().data()) != platform.m_tags.end();
 
@@ -1171,6 +1205,32 @@ namespace AssetProcessor
         }
     }
 
+    void PlatformConfiguration::CacheIntermediateAssetsScanFolderId()
+    {
+        for (const auto& scanfolder : m_scanFolders)
+        {
+            if (scanfolder.GetPortableKey() == IntermediateAssetsFolderName)
+            {
+                m_intermediateAssetScanFolderId = scanfolder.ScanFolderID();
+                return;
+            }
+        }
+
+        AZ_Error(
+            "PlatformConfiguration", false,
+            "CacheIntermediateAssetsScanFolderId: Failed to find Intermediate Assets folder in scanfolder list");
+    }
+
+    AZStd::optional<AZ::s64> PlatformConfiguration::GetIntermediateAssetsScanFolderId() const
+    {
+        if (m_intermediateAssetScanFolderId >= 0)
+        {
+            return m_intermediateAssetScanFolderId;
+        }
+
+        return AZStd::nullopt;
+    }
+
     bool PlatformConfiguration::ReadRecognizersFromSettingsRegistry(const QString& assetRoot, bool skipScanFolders, QStringList scanFolderPatterns)
     {
         auto settingsRegistry = AZ::SettingsRegistry::Get();
@@ -1620,7 +1680,7 @@ namespace AssetProcessor
         return QString();
     }
 
-    QString PlatformConfiguration::FindFirstMatchingFile(QString relativeName) const
+    QString PlatformConfiguration::FindFirstMatchingFile(QString relativeName, bool skipIntermediateScanFolder) const
     {
         if (relativeName.isEmpty())
         {
@@ -1629,10 +1689,20 @@ namespace AssetProcessor
 
         auto* fileStateInterface = AZ::Interface<AssetProcessor::IFileStateRequests>::Get();
 
+        QDir cacheRoot;
+        AssetUtilities::ComputeProjectCacheRoot(cacheRoot);
+
         for (int pathIdx = 0; pathIdx < m_scanFolders.size(); ++pathIdx)
         {
             AssetProcessor::ScanFolderInfo scanFolderInfo = m_scanFolders[pathIdx];
 
+            if (skipIntermediateScanFolder && AssetUtilities::GetIntermediateAssetsFolder(cacheRoot.absolutePath().toUtf8().constData()) == AZ::IO::PathView(scanFolderInfo.ScanPath().toUtf8().constData()))
+            {
+                // There's only 1 intermediate assets folder, if we've skipped it, theres no point continuing to check every folder afterwards
+                skipIntermediateScanFolder = false;
+                continue;
+            }
+
             QString tempRelativeName(relativeName);
 
             if ((!scanFolderInfo.RecurseSubFolders()) && (tempRelativeName.contains('/')))
@@ -1821,6 +1891,38 @@ namespace AssetProcessor
         return m_maxJobs;
     }
 
+    void PlatformConfiguration::EnableCommonPlatform()
+    {
+        EnablePlatform(AssetBuilderSDK::PlatformInfo{ AssetBuilderSDK::CommonPlatformName, AZStd::unordered_set<AZStd::string>{ "common" } });
+    }
+
+    void PlatformConfiguration::AddIntermediateScanFolder()
+    {
+        auto settingsRegistry = AZ::SettingsRegistry::Get();
+        AZ::SettingsRegistryInterface::FixedValueString cacheRootFolder;
+        settingsRegistry->Get(cacheRootFolder, AZ::SettingsRegistryMergeUtils::FilePathKey_CacheProjectRootFolder);
+
+        AZ::IO::Path scanfolderPath = cacheRootFolder.c_str();
+        scanfolderPath /= IntermediateAssetsFolderName;
+
+        AZStd::vector<AssetBuilderSDK::PlatformInfo> platforms;
+        PopulatePlatformsForScanFolder(platforms);
+
+        // By default the project scanfolder is recursive with an order of 0
+        // The intermediate assets folder needs to be higher priority since its a subfolder (otherwise GetScanFolderForFile won't pick the right scanfolder)
+        constexpr int order = -1;
+
+        AddScanFolder(ScanFolderInfo{
+            scanfolderPath.c_str(),
+            IntermediateAssetsFolderName,
+            IntermediateAssetsFolderName,
+            false,
+            true,
+            platforms,
+            order
+        });
+    }
+
     void PlatformConfiguration::AddGemScanFolders(const AZStd::vector<AzFramework::GemInfo>& gemInfoList)
     {
         int gemOrder = g_gemStartingOrder;

+ 10 - 3
Code/Tools/AssetProcessor/native/utilities/PlatformConfiguration.h

@@ -70,8 +70,8 @@ namespace AssetProcessor
     {
         AssetRecognizer() = default;
 
-        AssetRecognizer(const QString& name, bool testLockSource, int priority, 
-            bool critical, bool supportsCreateJobs, AssetBuilderSDK::FilePatternMatcher patternMatcher, 
+        AssetRecognizer(const QString& name, bool testLockSource, int priority,
+            bool critical, bool supportsCreateJobs, AssetBuilderSDK::FilePatternMatcher patternMatcher,
             const QString& version, const AZ::Data::AssetType& productAssetType, bool outputProductDependencies, bool checkServer = false)
             : m_name(name)
             , m_testLockSource(testLockSource)
@@ -267,6 +267,9 @@ namespace AssetProcessor
         int GetMinJobs() const;
         int GetMaxJobs() const;
 
+        void EnableCommonPlatform();
+        void AddIntermediateScanFolder();
+
         //! Return how many scan folders there are
         int GetScanFolderCount() const;
 
@@ -318,7 +321,7 @@ namespace AssetProcessor
         QString GetOverridingFile(QString relativeName, QString scanFolderName) const;
 
         //! given a relative name, loop over folders and resolve it to a full path with the first existing match.
-        QString FindFirstMatchingFile(QString relativeName) const;
+        QString FindFirstMatchingFile(QString relativeName, bool skipIntermediateScanFolder = false) const;
 
         //! given a relative name with wildcard characters (* allowed) find a set of matching files or optionally folders
         QStringList FindWildcardMatches(const QString& sourceFolder, QString relativeName, bool includeFolders = false,
@@ -370,6 +373,9 @@ namespace AssetProcessor
 
         void PopulatePlatformsForScanFolder(AZStd::vector<AssetBuilderSDK::PlatformInfo>& platformsList, QStringList includeTagsList = QStringList(), QStringList excludeTagsList = QStringList());
 
+        void CacheIntermediateAssetsScanFolderId();
+        AZStd::optional<AZ::s64> GetIntermediateAssetsScanFolderId() const;
+
     protected:
 
         // call this first, to populate the list of platform informations
@@ -395,6 +401,7 @@ namespace AssetProcessor
         QList<QPair<QString, QString> > m_metaDataFileTypes;
         QSet<QString> m_metaDataRealFiles;
         AZStd::vector<AzFramework::GemInfo> m_gemInfoList;
+        AZ::s64 m_intermediateAssetScanFolderId = -1; // Cached ID for intermediate scanfolder, for quick lookups
 
         int m_minJobs = 1;
         int m_maxJobs = 3;

+ 190 - 2
Code/Tools/AssetProcessor/native/utilities/assetUtils.cpp

@@ -905,7 +905,7 @@ namespace AssetUtilities
         return true;
     }
 
-    AZStd::string_view StripAssetPlatformNoCopy(AZStd::string_view relativeProductPath)
+    AZStd::string_view StripAssetPlatformNoCopy(AZStd::string_view relativeProductPath, AZStd::string_view* outputPlatform)
     {
         // Skip over the assetPlatform path segment if it is matches one of the platform defaults
         // Otherwise return the path unchanged
@@ -913,8 +913,14 @@ namespace AssetUtilities
         AZStd::string_view originalPath = relativeProductPath;
         AZStd::optional firstPathSegment = AZ::StringFunc::TokenizeNext(relativeProductPath, AZ_CORRECT_AND_WRONG_FILESYSTEM_SEPARATOR);
 
-        if (firstPathSegment && AzFramework::PlatformHelper::GetPlatformIdFromName(*firstPathSegment) != AzFramework::PlatformId::Invalid)
+        if (firstPathSegment && (AzFramework::PlatformHelper::GetPlatformIdFromName(*firstPathSegment) != AzFramework::PlatformId::Invalid
+            || firstPathSegment == AssetBuilderSDK::CommonPlatformName))
         {
+            if(outputPlatform)
+            {
+                *outputPlatform = *firstPathSegment;
+            }
+
             return relativeProductPath;
         }
 
@@ -1499,6 +1505,123 @@ namespace AssetUtilities
         return false;
     }
 
+    bool IsInCacheFolder(AZ::IO::PathView path, AZ::IO::Path cachePath)
+    {
+        if(cachePath.empty())
+        {
+            QDir cacheDir;
+            [[maybe_unused]] bool result = ComputeProjectCacheRoot(cacheDir);
+
+            AZ_Error("AssetUtils", result, "Failed to get cache root for IsInCacheFolder");
+
+            cachePath = cacheDir.absolutePath().toUtf8().constData();
+        }
+
+        return path.IsRelativeTo(cachePath) && !IsInIntermediateAssetsFolder(path, cachePath);
+    }
+
+    bool IsInIntermediateAssetsFolder(AZ::IO::PathView path, AZ::IO::PathView cachePath)
+    {
+        AZ::IO::FixedMaxPath fixedCachedPath = cachePath;
+
+        if (fixedCachedPath.empty())
+        {
+            QDir cacheDir;
+            [[maybe_unused]] bool result = ComputeProjectCacheRoot(cacheDir);
+
+            AZ_Error("AssetUtils", result, "Failed to get cache root for IsInCacheFolder");
+
+            fixedCachedPath = cacheDir.absolutePath().toUtf8().constData();
+        }
+
+        AZ::IO::FixedMaxPath intermediateAssetsPath = GetIntermediateAssetsFolder(cachePath);
+
+        return path.IsRelativeTo(intermediateAssetsPath);
+    }
+
+    AZ::IO::FixedMaxPath GetIntermediateAssetsFolder(AZ::IO::PathView cachePath)
+    {
+        AZ::IO::FixedMaxPath path(cachePath);
+
+        return path / AssetProcessor::IntermediateAssetsFolderName;
+    }
+
+    AZStd::string GetIntermediateAssetDatabaseName(AZ::IO::PathView relativePath)
+    {
+        // For intermediate assets, the platform must always be common, we don't support anything else for intermediate assets
+        AZ::IO::Path platformPrefix = AssetBuilderSDK::CommonPlatformName;
+
+        return (platformPrefix / relativePath).LexicallyNormal().StringAsPosix();
+    }
+
+    AZStd::optional<AzToolsFramework::AssetDatabase::SourceDatabaseEntry> GetTopLevelSourceForProduct(
+        AZ::IO::PathView relativePath, AZStd::shared_ptr<AssetProcessor::AssetDatabaseConnection> db)
+    {
+        AzToolsFramework::AssetDatabase::SourceDatabaseEntryContainer sources;
+        db->GetSourcesByProductName(GetIntermediateAssetDatabaseName(relativePath).c_str(), sources);
+
+        if (sources.empty())
+        {
+            return {};
+        }
+
+        if (sources.size() > 1)
+        {
+            AZ_Error(AssetProcessor::ConsoleChannel, false, "GetTopLevelSourceForProduct found multiple sources for product %s", relativePath.FixedMaxPathStringAsPosix().c_str());
+            return {};
+        }
+
+        AzToolsFramework::AssetDatabase::SourceDatabaseEntry source;
+
+        do
+        {
+            source = sources[0];
+            sources = {}; // Clear the array, otherwise it keeps accumulating the results
+        } while (db->GetSourcesByProductName(GetIntermediateAssetDatabaseName(source.m_sourceName.c_str()).c_str(), sources));
+
+        return source;
+    }
+
+    AZStd::vector<AZStd::string> GetAllIntermediateSources(
+        AZ::IO::PathView relativeSourcePath, AZStd::shared_ptr<AssetProcessor::AssetDatabaseConnection> db)
+    {
+        AZStd::vector<AZStd::string> sources;
+
+        auto topLevelSource = GetTopLevelSourceForProduct(relativeSourcePath, db);
+
+        if (!topLevelSource)
+        {
+            AzToolsFramework::AssetDatabase::SourceDatabaseEntry source;
+            db->GetSourceBySourceName(relativeSourcePath.FixedMaxPathStringAsPosix().c_str(), source);
+
+            topLevelSource = source;
+        }
+
+        sources.emplace_back(topLevelSource->m_sourceName);
+
+        AzToolsFramework::AssetDatabase::ProductDatabaseEntryContainer products;
+        db->GetProductsBySourceID(topLevelSource->m_sourceID, products);
+
+        auto size = products.size();
+        for (int i = 0; i < size; ++i)
+        {
+            const auto& product = products[i];
+
+            if ((static_cast<AssetBuilderSDK::ProductOutputFlags>(product.m_flags.to_ullong()) & AssetBuilderSDK::ProductOutputFlags::IntermediateAsset) == AssetBuilderSDK::ProductOutputFlags::IntermediateAsset)
+            {
+                auto productSourceName = StripAssetPlatformNoCopy(product.m_productName);
+                sources.emplace_back(productSourceName);
+
+                // Note: This call is intentionally re-using the products array.  The new results will be appended to the end (via push_back).
+                // The array will not be cleared.  We're essentially using products as a queue
+                db->GetProductsBySourceName(QString(QByteArray(productSourceName.data(), static_cast<int>(productSourceName.size()))), products);
+                size = products.size(); // Update the loop size since the array grew
+            }
+        }
+
+        return sources;
+    }
+
     BuilderFilePatternMatcher::BuilderFilePatternMatcher(const AssetBuilderSDK::AssetBuilderPattern& pattern, const AZ::Uuid& builderDescID)
         : AssetBuilderSDK::FilePatternMatcher(pattern)
         , m_builderDescID(builderDescID)
@@ -1719,4 +1842,69 @@ namespace AssetUtilities
     {
         ++m_warningCount;
     }
+
+    ProductPath::ProductPath(AZStd::string scanfolderRelativeProductPath, AZStd::string platformIdentifier)
+    {
+        AZ_Assert(AZ::IO::PathView(scanfolderRelativeProductPath).IsRelative(), "scanfolderRelativeProductPath is not relative: %s", scanfolderRelativeProductPath.c_str());
+
+        QDir cacheDir;
+        [[maybe_unused]] bool result = ComputeProjectCacheRoot(cacheDir);
+
+        AZ_Error("AssetUtils", result, "Failed to get cache root");
+
+        AZ::IO::FixedMaxPath cachePath = cacheDir.absolutePath().toUtf8().constData();
+
+        // Lowercase the inputs.  The cache path is always lowercased, which means the database path is lowercased,
+        // and for consistency, the intermediate path is also lowercased.
+        // All the other parts of the path must remain properly cased.
+        AZStd::to_lower(scanfolderRelativeProductPath.begin(), scanfolderRelativeProductPath.end());
+        AZStd::to_lower(platformIdentifier.begin(), platformIdentifier.end());
+
+        m_relativePath = NormalizeFilePath(scanfolderRelativeProductPath.c_str()).toUtf8().constData();
+        m_cachePath = cachePath / platformIdentifier / scanfolderRelativeProductPath;
+        m_intermediatePath = AssetUtilities::GetIntermediateAssetsFolder(cachePath) / scanfolderRelativeProductPath;
+        m_databasePath = AZ::IO::FixedMaxPath(platformIdentifier) / scanfolderRelativeProductPath;
+    }
+
+    ProductPath ProductPath::FromDatabasePath(AZStd::string_view databasePath, AZStd::string_view* platformOut)
+    {
+        AZStd::string_view platform;
+        AZStd::string_view relativeProductPath = AssetUtilities::StripAssetPlatformNoCopy(databasePath, &platform);
+
+        if(platformOut)
+        {
+            *platformOut = platform;
+        }
+
+        return ProductPath{ relativeProductPath, platform };
+    }
+
+    ProductPath ProductPath::FromAbsoluteProductPath(AZ::IO::PathView absolutePath, AZStd::string& outPlatform)
+    {
+        QDir cacheDir;
+        [[maybe_unused]] bool result = ComputeProjectCacheRoot(cacheDir);
+
+        AZ_Error("AssetUtils", result, "Failed to get cache root for IsInCacheFolder");
+
+        AZ::IO::FixedMaxPath parentFolder = cacheDir.absolutePath().toUtf8().constData();
+
+        bool intermediateAsset = IsInIntermediateAssetsFolder(absolutePath, parentFolder);
+        if (intermediateAsset)
+        {
+            parentFolder = AssetUtilities::GetIntermediateAssetsFolder(parentFolder);
+            outPlatform = AssetBuilderSDK::CommonPlatformName;
+        }
+
+        auto relativePath = absolutePath.LexicallyRelative(parentFolder);
+
+        if (!intermediateAsset)
+        {
+            AZStd::string_view platform;
+            auto fixedString = relativePath.FixedMaxPathStringAsPosix();
+            relativePath = StripAssetPlatformNoCopy(fixedString, &platform);
+            outPlatform = platform;
+        }
+
+        return ProductPath{ relativePath.StringAsPosix(), outPlatform };
+    }
 } // namespace AssetUtilities

+ 48 - 2
Code/Tools/AssetProcessor/native/utilities/assetUtils.h

@@ -20,6 +20,8 @@
 #include "native/utilities/AssetUtilEBusHelper.h"
 #include "native/utilities/ApplicationManagerAPI.h"
 #include <AzToolsFramework/Asset/AssetProcessorMessages.h>
+#include <AzCore/IO/Path/Path.h>
+#include <AzToolsFramework/AssetDatabase/AssetDatabaseConnection.h>
 
 namespace AzToolsFramework
 {
@@ -161,7 +163,7 @@ namespace AssetUtilities
 
     //! Same as StripAssetPlatform, but does not perform any string copies
     //! The return result is only valid for as long as the original input is valid
-    AZStd::string_view StripAssetPlatformNoCopy(AZStd::string_view relativeProductPath);
+    AZStd::string_view StripAssetPlatformNoCopy(AZStd::string_view relativeProductPath, AZStd::string_view* outputPlatform = nullptr);
 
     //! Converts all slashes to forward slashes, removes double slashes,
     //! replaces all indirections such as '.' or '..' as appropriate.
@@ -263,9 +265,53 @@ namespace AssetUtilities
     //! which are discovered recursively. All the returned Uuids are unique, meaning they appear once in the returned list.
     AZStd::vector<AZ::Uuid> CollectAssetAndDependenciesRecursively(AssetProcessor::AssetDatabaseConnection& databaseConnection, const AZStd::vector<AZ::Uuid>& assetList);
 
-    // A utility function which checks the given path starting at the root and updates the relative path to be the actual case correct path.
+    //! A utility function which checks the given path starting at the root and updates the relative path to be the actual case correct path.
     bool UpdateToCorrectCase(const QString& rootPath, QString& relativePathFromRoot);
 
+    //! Returns true if the path is in the cachePath and *not* in the intermediate assets folder.
+    //! If cachePath is empty, it will be computed using ComputeProjectCacheRoot.
+    bool IsInCacheFolder(AZ::IO::PathView path, AZ::IO::Path cachePath = "");
+
+    //! Returns true if the path is in the intermediate assets folder.
+    //! If cachePath is empty, it will be computed using ComputeProjectCacheRoot.
+    bool IsInIntermediateAssetsFolder(AZ::IO::PathView path, AZ::IO::PathView cachePath = "");
+
+    //! Returns the absolute path of the intermediate assets folder
+    AZ::IO::FixedMaxPath GetIntermediateAssetsFolder(AZ::IO::PathView cachePath);
+
+    //! Appends the platform prefix for an intermediate asset to get the database name used for products
+    AZStd::string GetIntermediateAssetDatabaseName(AZ::IO::PathView relativePath);
+
+    //! Finds the top level source that produced an intermediate product.  If the source is not yet recorded in the database or has no top level source, this will return nothing
+    AZStd::optional<AzToolsFramework::AssetDatabase::SourceDatabaseEntry> GetTopLevelSourceForProduct(AZ::IO::PathView relativePath, AZStd::shared_ptr<AssetProcessor::AssetDatabaseConnection> db);
+
+    //! Finds all the souces (up and down) in an intermediate output chain
+    AZStd::vector<AZStd::string> GetAllIntermediateSources(
+        AZ::IO::PathView relativeSourcePath, AZStd::shared_ptr<AssetProcessor::AssetDatabaseConnection> db);
+
+    //! Helper class that provides various paths related to a single output asset.
+    //! Files are not guaranteed to exist at the given path.
+    struct ProductPath
+    {
+        ProductPath(AZStd::string scanfolderRelativeProductPath, AZStd::string platformIdentifier);
+
+        static ProductPath FromDatabasePath(AZStd::string_view databasePath, AZStd::string_view* platformOut = nullptr);
+        static ProductPath FromAbsoluteProductPath(AZ::IO::PathView absolutePath, AZStd::string& outPlatform);
+
+        //! Absolute path for the product in the intermediate asset folder.  Not guaranteed to exist, this is just the path the file would be at
+        AZStd::string GetIntermediatePath() const { return m_intermediatePath.StringAsPosix(); }
+        //! Absolute path for the product in the cache folder.  Not guaranteed to exist, this is just the path the file would be at
+        AZStd::string GetCachePath() const { return m_cachePath.StringAsPosix(); }
+        //! Relative path of the product for the database, this includes the platform prefix and is lowercased
+        AZStd::string GetDatabasePath() const { return m_databasePath.StringAsPosix(); }
+        //! Scanfolder relative path of the product.  This is lowercased and does not include the platform prefix
+        AZStd::string GetRelativePath() const { return m_relativePath; }
+
+    protected:
+        AZStd::string m_relativePath;
+        AZ::IO::Path m_intermediatePath, m_cachePath, m_databasePath;
+    };
+
     class BuilderFilePatternMatcher
         : public AssetBuilderSDK::FilePatternMatcher
     {

+ 273 - 0
Gems/TestAssetBuilder/Code/Source/Builder/TestIntermediateAssetBuilderComponent.cpp

@@ -0,0 +1,273 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#include <AssetBuilderSDK/AssetBuilderSDK.h>
+#include <AzCore/IO/SystemFile.h>
+#include <AzCore/IO/Path/Path.h>
+#include <AzCore/Serialization/EditContextConstants.inl>
+#include <AzCore/Serialization/SerializeContext.h>
+#include <AzFramework/IO/LocalFileIO.h>
+#include <Builder/TestIntermediateAssetBuilderComponent.h>
+
+namespace TestAssetBuilder
+{
+    void TestIntermediateAssetBuilderComponent::Init()
+    {
+    }
+
+    void TestIntermediateAssetBuilderComponent::Activate()
+    {
+        {
+            AssetBuilderSDK::AssetBuilderDesc builderDescriptor;
+            builderDescriptor.m_name = "Test Intermediate Asset Builder Stage 1";
+            builderDescriptor.m_version = 1;
+            builderDescriptor.m_patterns.emplace_back("*.intersource", AssetBuilderSDK::AssetBuilderPattern::PatternType::Wildcard);
+            builderDescriptor.m_busId = AZ::Uuid("{6A27C79F-28F0-44EA-B1CC-4A52DADB887D}");
+            builderDescriptor.m_createJobFunction = AZStd::bind(
+                &TestIntermediateAssetBuilderComponent::CreateJobsStage1, this, AZStd::placeholders::_1, AZStd::placeholders::_2);
+            builderDescriptor.m_processJobFunction = AZStd::bind(
+                &TestIntermediateAssetBuilderComponent::ProcessJobStage1, this, AZStd::placeholders::_1, AZStd::placeholders::_2);
+
+            BusConnect(builderDescriptor.m_busId);
+
+            AssetBuilderSDK::AssetBuilderBus::Broadcast(&AssetBuilderSDK::AssetBuilderBusTraits::RegisterBuilderInformation, builderDescriptor);
+        }
+
+        {
+            AssetBuilderSDK::AssetBuilderDesc builderDescriptor;
+            builderDescriptor.m_name = "Test Intermediate Asset Builder Stage 2";
+            builderDescriptor.m_version = 1;
+            builderDescriptor.m_patterns.emplace_back("*.stage1output", AssetBuilderSDK::AssetBuilderPattern::PatternType::Wildcard);
+            builderDescriptor.m_busId = AZ::Uuid("{1A1FB5D4-2F4A-434A-9D2C-9D51235C2C27}");
+            builderDescriptor.m_createJobFunction =
+                AZStd::bind(&TestIntermediateAssetBuilderComponent::CreateJobsStage2, this, AZStd::placeholders::_1, AZStd::placeholders::_2);
+            builderDescriptor.m_processJobFunction =
+                AZStd::bind(&TestIntermediateAssetBuilderComponent::ProcessJobStage2, this, AZStd::placeholders::_1, AZStd::placeholders::_2);
+
+            BusConnect(builderDescriptor.m_busId);
+
+            AssetBuilderSDK::AssetBuilderBus::Broadcast(&AssetBuilderSDK::AssetBuilderBusTraits::RegisterBuilderInformation, builderDescriptor);
+        }
+
+        {
+            AssetBuilderSDK::AssetBuilderDesc builderDescriptor;
+            builderDescriptor.m_name = "Test Intermediate Asset Builder Stage 3";
+            builderDescriptor.m_version = 1;
+            builderDescriptor.m_patterns.emplace_back("*.stage2output", AssetBuilderSDK::AssetBuilderPattern::PatternType::Wildcard);
+            builderDescriptor.m_busId = AZ::Uuid("{BB935CEF-63EE-44D1-A8C5-DEF3DD799D49}");
+            builderDescriptor.m_createJobFunction = AZStd::bind(
+                &TestIntermediateAssetBuilderComponent::CreateJobsStage3, this, AZStd::placeholders::_1, AZStd::placeholders::_2);
+            builderDescriptor.m_processJobFunction = AZStd::bind(
+                &TestIntermediateAssetBuilderComponent::ProcessJobStage3, this, AZStd::placeholders::_1, AZStd::placeholders::_2);
+
+            BusConnect(builderDescriptor.m_busId);
+
+            AssetBuilderSDK::AssetBuilderBus::Broadcast(
+                &AssetBuilderSDK::AssetBuilderBusTraits::RegisterBuilderInformation, builderDescriptor);
+        }
+    }
+
+    void TestIntermediateAssetBuilderComponent::Deactivate()
+    {
+        BusDisconnect();
+    }
+
+    void TestIntermediateAssetBuilderComponent::Reflect(AZ::ReflectContext* context)
+    {
+        if (AZ::SerializeContext* serialize = azrtti_cast<AZ::SerializeContext*>(context))
+        {
+            serialize->Class<TestIntermediateAssetBuilderComponent, AZ::Component>()
+                ->Version(0)
+                ->Attribute(AZ::Edit::Attributes::SystemComponentTags, AZStd::vector<AZ::Crc32>({ AssetBuilderSDK::ComponentTags::AssetBuilder }))
+            ;
+        }
+    }
+
+    void TestIntermediateAssetBuilderComponent::GetProvidedServices(AZ::ComponentDescriptor::DependencyArrayType& provided)
+    {
+        provided.push_back(AZ_CRC("TestIntermediateAssetBuilderPluginService"));
+    }
+
+    void TestIntermediateAssetBuilderComponent::GetIncompatibleServices(AZ::ComponentDescriptor::DependencyArrayType& incompatible)
+    {
+        incompatible.push_back(AZ_CRC("TestIntermediateAssetBuilderPluginService"));
+    }
+
+    void TestIntermediateAssetBuilderComponent::GetRequiredServices(AZ::ComponentDescriptor::DependencyArrayType& required)
+    {
+        AZ_UNUSED(required);
+    }
+
+    void TestIntermediateAssetBuilderComponent::GetDependentServices(AZ::ComponentDescriptor::DependencyArrayType& dependent)
+    {
+        AZ_UNUSED(dependent);
+    }
+
+    void TestIntermediateAssetBuilderComponent::CreateJobsStage1(const AssetBuilderSDK::CreateJobsRequest& request, AssetBuilderSDK::CreateJobsResponse& response)
+    {
+        if (m_isShuttingDown)
+        {
+            response.m_result = AssetBuilderSDK::CreateJobsResultCode::ShuttingDown;
+            return;
+        }
+
+        auto commonPlatform = AZStd::find_if(request.m_enabledPlatforms.begin(), request.m_enabledPlatforms.end(), [](auto input) -> bool
+        {
+            return input.m_identifier == AssetBuilderSDK::CommonPlatformName;
+        });
+
+        if(commonPlatform != request.m_enabledPlatforms.end())
+        {
+            AZ_Error("TestIntermediateAssetBuilder", false, "Common platform was found in the list of enabled platforms."
+                "  This is not expected as it will cause all builders to output files for the common platform.");
+            return;
+        }
+
+        AssetBuilderSDK::JobDescriptor desc{"", "Test Product Stage 1", AssetBuilderSDK::CommonPlatformName};
+        response.m_createJobOutputs.push_back(desc);
+        response.m_result = AssetBuilderSDK::CreateJobsResultCode::Success;
+    }
+
+    bool CopyWithExtension(const AssetBuilderSDK::ProcessJobRequest& request, AZStd::string_view extension, AZ::IO::Path& outDestinationPath)
+    {
+        outDestinationPath = request.m_tempDirPath;
+        outDestinationPath /= AZ::IO::PathView(request.m_fullPath).Filename();
+        outDestinationPath.ReplaceExtension(extension);
+
+        if (auto result = AZ::IO::FileIOBase::GetInstance()->Copy(request.m_fullPath.c_str(), outDestinationPath.c_str()); !result)
+        {
+            AZ_Error(
+                "TestIntermediateAssetBuilder", false, "Failed to copy input file `%s` to temp output `%s", request.m_fullPath.c_str(),
+                outDestinationPath.c_str());
+            return false;
+        }
+
+        return true;
+    }
+
+    void TestIntermediateAssetBuilderComponent::ProcessJobStage1(const AssetBuilderSDK::ProcessJobRequest& request, AssetBuilderSDK::ProcessJobResponse& response)
+    {
+        AssetBuilderSDK::JobCancelListener jobCancelListener(request.m_jobId);
+
+        AZ_TracePrintf(AssetBuilderSDK::InfoWindow, "Starting Job.\n");
+
+        // Check if we are cancelled or shutting down before doing intensive processing on this source file
+        if (jobCancelListener.IsCancelled())
+        {
+            AZ_TracePrintf(AssetBuilderSDK::WarningWindow, "Cancel was requested for job %s.\n", request.m_fullPath.c_str());
+            response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Cancelled;
+            return;
+        }
+        if (m_isShuttingDown)
+        {
+            AZ_TracePrintf(AssetBuilderSDK::WarningWindow, "Cancelled job %s because shutdown was requested.\n", request.m_fullPath.c_str());
+            response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Cancelled;
+            return;
+        }
+
+        AZ::IO::Path destinationPath;
+        if(!CopyWithExtension(request, ".stage1output", destinationPath))
+        {
+            return;
+        }
+
+        const AZ::Uuid assetType("{978D26F9-D9F4-40E5-888B-3A53E2363BEA}");
+
+        AssetBuilderSDK::JobProduct jobProduct(destinationPath.c_str(), assetType, 1);
+        jobProduct.m_outputFlags = AssetBuilderSDK::ProductOutputFlags::IntermediateAsset;
+        jobProduct.m_dependenciesHandled = true; // This builder has no product dependencies
+
+        response.m_outputProducts.push_back(jobProduct);
+        response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
+    }
+
+    void TestIntermediateAssetBuilderComponent::CreateJobsStage2(
+        const AssetBuilderSDK::CreateJobsRequest&, AssetBuilderSDK::CreateJobsResponse& response)
+    {
+        if (m_isShuttingDown)
+        {
+            response.m_result = AssetBuilderSDK::CreateJobsResultCode::ShuttingDown;
+            return;
+        }
+
+        AssetBuilderSDK::JobDescriptor desc{ "", "Test Product Stage 2", AssetBuilderSDK::CommonPlatformName };
+        response.m_createJobOutputs.push_back(desc);
+
+        response.m_result = AssetBuilderSDK::CreateJobsResultCode::Success;
+    }
+
+    void TestIntermediateAssetBuilderComponent::ProcessJobStage2(
+        const AssetBuilderSDK::ProcessJobRequest& request, AssetBuilderSDK::ProcessJobResponse& response)
+    {
+        AssetBuilderSDK::JobCancelListener jobCancelListener(request.m_jobId);
+
+        AZ_TracePrintf(AssetBuilderSDK::InfoWindow, "Starting Job.\n");
+
+        const AZ::Uuid assetType("{CE426CC8-86AE-48EB-8D03-5E09DBBEAC94}");
+
+        AZ::IO::Path destinationPath;
+        if (!CopyWithExtension(request, ".stage2output", destinationPath))
+        {
+            return;
+        }
+
+        AssetBuilderSDK::JobProduct jobProduct(destinationPath.AsPosix().c_str(), assetType, 1);
+        jobProduct.m_outputFlags = AssetBuilderSDK::ProductOutputFlags::IntermediateAsset;
+        jobProduct.m_dependenciesHandled = true; // This builder has no product dependencies
+
+        response.m_outputProducts.push_back(jobProduct);
+        response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
+    }
+
+    void TestIntermediateAssetBuilderComponent::CreateJobsStage3(
+        const AssetBuilderSDK::CreateJobsRequest& request, AssetBuilderSDK::CreateJobsResponse& response)
+    {
+        if (m_isShuttingDown)
+        {
+            response.m_result = AssetBuilderSDK::CreateJobsResultCode::ShuttingDown;
+            return;
+        }
+
+        for (const auto& platform : request.m_enabledPlatforms)
+        {
+            AssetBuilderSDK::JobDescriptor desc{ "", "Test Product Stage 3", platform.m_identifier.c_str() };
+            response.m_createJobOutputs.push_back(desc);
+        }
+
+        response.m_result = AssetBuilderSDK::CreateJobsResultCode::Success;
+    }
+
+    void TestIntermediateAssetBuilderComponent::ProcessJobStage3(
+        const AssetBuilderSDK::ProcessJobRequest& request, AssetBuilderSDK::ProcessJobResponse& response)
+    {
+        AssetBuilderSDK::JobCancelListener jobCancelListener(request.m_jobId);
+
+        AZ_TracePrintf(AssetBuilderSDK::InfoWindow, "Starting Job.\n");
+
+        const AZ::Uuid assetType("{1CC1DD34-5675-4071-8732-3E3406664ADB}");
+
+        AZ::IO::Path destinationPath;
+        if (!CopyWithExtension(request, ".stage3output", destinationPath))
+        {
+            return;
+        }
+
+        AssetBuilderSDK::JobProduct jobProduct(destinationPath.AsPosix().c_str(), assetType, 1);
+        jobProduct.m_dependenciesHandled = true; // This builder has no product dependencies
+
+        response.m_outputProducts.push_back(jobProduct);
+        response.m_resultCode = AssetBuilderSDK::ProcessJobResult_Success;
+    }
+
+    void TestIntermediateAssetBuilderComponent::ShutDown()
+    {
+        m_isShuttingDown = true;
+    }
+
+} // namespace TestAssetBuilder
+

+ 53 - 0
Gems/TestAssetBuilder/Code/Source/Builder/TestIntermediateAssetBuilderComponent.h

@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#pragma once
+
+#include <AzCore/Component/Component.h>
+#include <AssetBuilderSDK/AssetBuilderBusses.h>
+
+namespace TestAssetBuilder
+{
+
+    //! TestIntermediateAssetBuilderComponent handles the lifecycle of the builder.
+    class TestIntermediateAssetBuilderComponent
+        : public AZ::Component,
+          public AssetBuilderSDK::AssetBuilderCommandBus::MultiHandler
+    {
+    public:
+        AZ_COMPONENT(TestIntermediateAssetBuilderComponent, "{2D40D55D-7D31-4972-AFA3-1C396D0BEAC1}");
+
+        void Init() override;
+        void Activate() override;
+        void Deactivate() override;
+
+        static void Reflect(AZ::ReflectContext* context);
+        static void GetProvidedServices(AZ::ComponentDescriptor::DependencyArrayType& provided);
+        static void GetIncompatibleServices(AZ::ComponentDescriptor::DependencyArrayType& incompatible);
+        static void GetRequiredServices(AZ::ComponentDescriptor::DependencyArrayType& required);
+        static void GetDependentServices(AZ::ComponentDescriptor::DependencyArrayType& dependent);
+
+        //! Asset Builder Callback Functions
+        void CreateJobsStage1(const AssetBuilderSDK::CreateJobsRequest& request, AssetBuilderSDK::CreateJobsResponse& response);
+        void ProcessJobStage1(const AssetBuilderSDK::ProcessJobRequest& request, AssetBuilderSDK::ProcessJobResponse& response);
+
+        void CreateJobsStage2(const AssetBuilderSDK::CreateJobsRequest& request, AssetBuilderSDK::CreateJobsResponse& response);
+        void ProcessJobStage2(const AssetBuilderSDK::ProcessJobRequest& request, AssetBuilderSDK::ProcessJobResponse& response);
+        void CreateJobsStage3(const AssetBuilderSDK::CreateJobsRequest& request, AssetBuilderSDK::CreateJobsResponse& response);
+        void ProcessJobStage3(const AssetBuilderSDK::ProcessJobRequest& request, AssetBuilderSDK::ProcessJobResponse& response);
+
+        //////////////////////////////////////////////////////////////////////////
+        //!AssetBuilderSDK::AssetBuilderCommandBus interface
+        void ShutDown() override; // if you get this you must fail all existing jobs and return.
+        //////////////////////////////////////////////////////////////////////////
+
+    private:
+
+        bool m_isShuttingDown = false;
+    };
+} // namespace TestAssetBuilder

+ 2 - 0
Gems/TestAssetBuilder/Code/Source/TestAssetBuilderModule.cpp

@@ -10,6 +10,7 @@
 #include <AzCore/Module/Module.h>
 
 #include <Builder/TestAssetBuilderComponent.h>
+#include <Builder/TestIntermediateAssetBuilderComponent.h>
 
 namespace TestAssetBuilder
 {
@@ -25,6 +26,7 @@ namespace TestAssetBuilder
         {
             m_descriptors.insert(m_descriptors.end(), {
                 TestAssetBuilderComponent::CreateDescriptor(),
+                TestIntermediateAssetBuilderComponent::CreateDescriptor(),
             });
         }
     };

+ 2 - 0
Gems/TestAssetBuilder/Code/testassetbuilder_files.cmake

@@ -9,4 +9,6 @@
 set(FILES
     Source/Builder/TestAssetBuilderComponent.h
     Source/Builder/TestAssetBuilderComponent.cpp
+    Source/Builder/TestIntermediateAssetBuilderComponent.h
+    Source/Builder/TestIntermediateAssetBuilderComponent.cpp
 )

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels