// Copyright Epic Games, Inc. All Rights Reserved. #include "EditorValidator.h" #include "AssetData.h" #include "AssetRegistryModule.h" #include "Logging/MessageLog.h" #include "MessageLogModule.h" #include "Misc/ConfigCacheIni.h" #include "Misc/ScopedSlowTask.h" #include "Misc/MessageDialog.h" #include "EditorValidatorSubsystem.h" #include "Editor.h" #include "ISourceControlModule.h" #include "ISourceControlProvider.h" #include "SourceControlHelpers.h" #include "SourceControlOperations.h" #include "StudioAnalytics.h" #include "ShaderCompiler.h" #include "Misc/ConfigCacheIni.h" #include "Settings/ProjectPackagingSettings.h" #include "SourceCodeNavigation.h" #include "Stats/StatsMisc.h" #include "DataValidationModule.h" #include "LyraEditor.h" #define LOCTEXT_NAMESPACE "EditorValidator" int32 GMaxAssetsChangedByAHeader = 200; static FAutoConsoleVariableRef CVarMaxAssetsChangedByAHeader(TEXT("EditorValidator.MaxAssetsChangedByAHeader"), GMaxAssetsChangedByAHeader, TEXT("The maximum number of assets to check for content validation based on a single header change."), ECVF_Default); bool UEditorValidator::bAllowFullValidationInEditor = false; TArray FLyraValidationMessageGatherer::IgnorePatterns; UEditorValidator::UEditorValidator() : Super() { } void UEditorValidator::ValidateCheckedOutContent(bool bInteractive, const EDataValidationUsecase InValidationUsecase) { FStudioAnalytics::RecordEvent(TEXT("ValidateContent")); FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); if (AssetRegistryModule.Get().IsLoadingAssets()) { if (bInteractive) { FMessageDialog::Open(EAppMsgType::Ok, LOCTEXT("DiscoveringAssets", "Still discovering assets. Try again once it is complete.")); } else { UE_LOG(LogLyraEditor, Display, TEXT("Could not run ValidateCheckedOutContent because asset discovery was still being done.")); } return; } TArray ChangedPackageNames; TArray DeletedPackageNames; ISourceControlProvider& SourceControlProvider = ISourceControlModule::Get().GetProvider(); if (ISourceControlModule::Get().IsEnabled()) { // Request the opened files at filter construction time to make sure checked out files have the correct state for the filter TSharedRef UpdateStatusOperation = ISourceControlOperation::Create(); UpdateStatusOperation->SetGetOpenedOnly(true); SourceControlProvider.Execute(UpdateStatusOperation, EConcurrency::Synchronous); TArray CheckedOutFiles = SourceControlProvider.GetCachedStateByPredicate( [](const FSourceControlStateRef& State) { return State->IsCheckedOut() || State->IsAdded() || State->IsDeleted(); } ); for (const FSourceControlStateRef& FileState : CheckedOutFiles) { FString Filename = FileState->GetFilename(); if (FPackageName::IsPackageFilename(Filename)) { // Assets FString PackageName; if (FPackageName::TryConvertFilenameToLongPackageName(Filename, PackageName)) { if (FileState->IsDeleted()) { DeletedPackageNames.Add(PackageName); } else { ChangedPackageNames.Add(PackageName); } } } else if (Filename.EndsWith(TEXT(".h"))) { // Source code header changes for classes may cause issues in assets based on those classes UEditorValidator::GetChangedAssetsForCode(AssetRegistryModule.Get(), Filename, ChangedPackageNames); } } } bool bAnyIssuesFound = false; TArray AllWarningsAndErrors; { if (bInteractive) { bAllowFullValidationInEditor = true; // We will be flushing shader compile as we load materials, so dont let other shader warnings be attributed incorrectly to the package that is loading. if (GShaderCompilingManager) { FScopedSlowTask SlowTask(0.f, LOCTEXT("CompilingShadersBeforeCheckingContentTask", "Finishing shader compiles before checking content...")); SlowTask.MakeDialog(); GShaderCompilingManager->FinishAllCompilation(); } } { FScopedSlowTask SlowTask(0.f, LOCTEXT("CheckingContentTask", "Checking content...")); SlowTask.MakeDialog(); if (!ValidatePackages(ChangedPackageNames, DeletedPackageNames, 2000, AllWarningsAndErrors, InValidationUsecase)) { bAnyIssuesFound = true; } } if (bInteractive) { bAllowFullValidationInEditor = false; } } { FLyraValidationMessageGatherer ScopedMessageGatherer; if (!ValidateProjectSettings()) { bAnyIssuesFound = true; } AllWarningsAndErrors.Append(ScopedMessageGatherer.GetAllWarningsAndErrors()); } if (bInteractive) { const bool bAtLeastOneMessage = (AllWarningsAndErrors.Num() != 0); if (bAtLeastOneMessage) { FMessageDialog::Open(EAppMsgType::Ok, LOCTEXT("ContentValidationFailed", "!!!!!!! Your checked out content has issues. Don't submit until they are fixed !!!!!!!\r\n\r\nSee the MessageLog and OutputLog for details")); } else if (bAnyIssuesFound) { FMessageDialog::Open(EAppMsgType::Ok, LOCTEXT("ContentValidationFailedWithNoMessages", "No errors or warnings were found, but there was an error return code. Look in the OutputLog and log file for details. You may need engineering help.")); } else { FMessageDialog::Open(EAppMsgType::Ok, LOCTEXT("ContentValidationPassed", "All checked out content passed. Nice job.")); } } } bool UEditorValidator::ValidatePackages(const TArray& ExistingPackageNames, const TArray& DeletedPackageNames, int32 MaxPackagesToLoad, TArray& OutAllWarningsAndErrors, const EDataValidationUsecase InValidationUsecase) { bool bAnyIssuesFound = false; FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")); IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); TArray AllPackagesToValidate = ExistingPackageNames; for (const FString& DeletedPackageName : DeletedPackageNames) { UE_LOG(LogLyraEditor, Display, TEXT("Adding referencers for deleted package %s to be verified"), *DeletedPackageName); TArray PackageReferencers; AssetRegistry.GetReferencers(FName(*DeletedPackageName), PackageReferencers, UE::AssetRegistry::EDependencyCategory::Package); for (const FName& Referencer : PackageReferencers) { const FString ReferencerString = Referencer.ToString(); if (!DeletedPackageNames.Contains(ReferencerString) && !IsInUncookedFolder(ReferencerString)) { UE_LOG(LogLyraEditor, Display, TEXT(" Deleted package referencer %s was added to the queue to be verified"), *ReferencerString); AllPackagesToValidate.Add(ReferencerString); } } } FMessageLog DataValidationLog("AssetCheck"); DataValidationLog.NewPage(LOCTEXT("ValidatePackages", "Validate Packages")); if (AllPackagesToValidate.Num() > MaxPackagesToLoad) { // Too much changed to verify, just pass it. FString WarningMessage = FString::Printf(TEXT("Assets to validate (%d) exceeded -MaxPackagesToLoad=(%d). Skipping existing package validation."), AllPackagesToValidate.Num(), MaxPackagesToLoad); UE_LOG(LogLyraEditor, Warning, TEXT("%s"), *WarningMessage); OutAllWarningsAndErrors.Add(WarningMessage); DataValidationLog.Warning(FText::FromString(WarningMessage)); } else { // Load all packages that match the file filter string TArray AssetsToCheck; for (const FString& PackageName : AllPackagesToValidate) { if (FPackageName::IsValidLongPackageName(PackageName) && !IsInUncookedFolder(PackageName)) { int32 OldNumAssets = AssetsToCheck.Num(); AssetRegistry.GetAssetsByPackageName(FName(*PackageName), AssetsToCheck, true); if (AssetsToCheck.Num() == OldNumAssets) { FString WarningMessage; // See if the file exists at all. Otherwise, the package contains no assets. if (FPackageName::DoesPackageExist(PackageName)) { WarningMessage = FString::Printf(TEXT("Found no assets in package '%s'"), *PackageName); } else { if (ISourceControlModule::Get().IsEnabled()) { ISourceControlProvider& SourceControlProvider = ISourceControlModule::Get().GetProvider(); FString PackageFilename = FPackageName::LongPackageNameToFilename(PackageName, FPackageName::GetAssetPackageExtension()); TSharedPtr FileState = SourceControlProvider.GetState(PackageFilename, EStateCacheUsage::ForceUpdate); if (FileState->IsAdded()) { WarningMessage = FString::Printf(TEXT("Package '%s' is missing from disk. It is marked for add in perforce but missing from your hard drive."), *PackageName); } if (FileState->IsCheckedOut()) { WarningMessage = FString::Printf(TEXT("Package '%s' is missing from disk. It is checked out in perforce but missing from your hard drive."), *PackageName); } } if (WarningMessage.IsEmpty()) { WarningMessage = FString::Printf(TEXT("Package '%s' is missing from disk."), *PackageName); } } ensure(!WarningMessage.IsEmpty()); UE_LOG(LogLyraEditor, Warning, TEXT("%s"), *WarningMessage); OutAllWarningsAndErrors.Add(WarningMessage); DataValidationLog.Warning(FText::FromString(WarningMessage)); bAnyIssuesFound = true; } } } if (AssetsToCheck.Num() > 0) { // Preload all assets to check, so load warnings can be handled separately from validation warnings { for (const FAssetData& AssetToCheck : AssetsToCheck) { if (!AssetToCheck.IsAssetLoaded()) { UE_LOG(LogLyraEditor, Display, TEXT("Preloading %s..."), *AssetToCheck.ObjectPath.ToString()); // Start listening for load warnings FLyraValidationMessageGatherer ScopedPreloadMessageGatherer; // Load the asset AssetToCheck.GetAsset(); if (ScopedPreloadMessageGatherer.GetAllWarningsAndErrors().Num() > 0) { // Repeat all errant load warnings as errors, so other CIS systems can treat them more severely (i.e. Build health will create an issue and assign it to a developer) for (const FString& LoadWarning : ScopedPreloadMessageGatherer.GetAllWarnings()) { UE_LOG(LogLyraEditor, Error, TEXT("%s"), *LoadWarning); } OutAllWarningsAndErrors.Append(ScopedPreloadMessageGatherer.GetAllWarningsAndErrors()); bAnyIssuesFound = true; } } } } // Run all validators now. FLyraValidationMessageGatherer ScopedMessageGatherer; FValidateAssetsSettings Settings; FValidateAssetsResults Results; Settings.bSkipExcludedDirectories = true; Settings.bShowIfNoFailures = true; Settings.ValidationUsecase = InValidationUsecase; const bool bHasInvalidFiles = GEditor->GetEditorSubsystem()->ValidateAssetsWithSettings(AssetsToCheck, Settings, Results) > 0; if (bHasInvalidFiles || ScopedMessageGatherer.GetAllWarningsAndErrors().Num() > 0) { OutAllWarningsAndErrors.Append(ScopedMessageGatherer.GetAllWarningsAndErrors()); bAnyIssuesFound = true; } } } return !bAnyIssuesFound; } bool UEditorValidator::ValidateProjectSettings() { bool bSuccess = true; FMessageLog ValidationLog("AssetCheck"); { bool bDeveloperMode = false; GConfig->GetBool(TEXT("/Script/PythonScriptPlugin.PythonScriptPluginSettings"), TEXT("bDeveloperMode"), /*out*/ bDeveloperMode, GEngineIni); if (bDeveloperMode) { const FString ErrorMessage(TEXT("The project setting version of Python's bDeveloperMode should not be checked in. Use the editor preference version instead!")); UE_LOG(LogLyraEditor, Error, TEXT("%s"), *ErrorMessage); ValidationLog.Error(FText::AsCultureInvariant(ErrorMessage)); bSuccess = false; } } return bSuccess; } bool UEditorValidator::IsInUncookedFolder(const FString& PackageName, FString* OutUncookedFolderName) { const UProjectPackagingSettings* const PackagingSettings = GetDefault(); check(PackagingSettings); for (const FDirectoryPath& DirectoryToNeverCook : PackagingSettings->DirectoriesToNeverCook) { const FString& UncookedFolder = DirectoryToNeverCook.Path; if (PackageName.StartsWith(UncookedFolder)) { if (OutUncookedFolderName) { FString FolderToReport = UncookedFolder.StartsWith(TEXT("/Game/")) ? UncookedFolder.RightChop(6) : UncookedFolder; if (FolderToReport.EndsWith(TEXT("/"))) { *OutUncookedFolderName = FolderToReport.LeftChop(1); } else { *OutUncookedFolderName = FolderToReport; } } return true; } } return false; } bool UEditorValidator::ShouldAllowFullValidation() { return IsRunningCommandlet() || bAllowFullValidationInEditor; } bool UEditorValidator::CanValidateAsset_Implementation(UObject* InAsset) const { if (InAsset) { FString PackageName = InAsset->GetOutermost()->GetName(); if (!IsInUncookedFolder(PackageName)) { return true; } } return false; } void UEditorValidator::GetChangedAssetsForCode(IAssetRegistry& AssetRegistry, const FString& ChangedHeaderLocalFilename, TArray& OutChangedPackageNames) { static struct FCachedNativeClasses { public: FCachedNativeClasses() { static const FName ModuleNameFName = "ModuleName"; static const FName ModuleRelativePathFName = "ModuleRelativePath"; for (TObjectIterator ClassIt; ClassIt; ++ClassIt) { UClass* TestClass = *ClassIt; if (TestClass->HasAnyClassFlags(CLASS_Native)) { FAssetData ClassAssetData(TestClass); FString ModuleName, ModuleRelativePath; ClassAssetData.GetTagValue(ModuleNameFName, ModuleName); ClassAssetData.GetTagValue(ModuleRelativePathFName, ModuleRelativePath); Classes.Add(ModuleName + TEXT("+") + ModuleRelativePath, TestClass); } } } TArray> GetClassesInHeader(const FString& ModuleName, const FString& ModuleRelativePath) { TArray> ClassesInHeader; Classes.MultiFind(ModuleName + TEXT("+") + ModuleRelativePath, ClassesInHeader); return ClassesInHeader; } private: TMultiMap> Classes; } NativeClassCache; const TArray& ModuleNames = FSourceCodeNavigation::GetSourceFileDatabase().GetModuleNames(); const FString* Module = ModuleNames.FindByPredicate([ChangedHeaderLocalFilename](const FString& ModuleBuildPath) { const FString ModuleFullPath = FPaths::ConvertRelativePathToFull(FPaths::GetPath(ModuleBuildPath)); if (ChangedHeaderLocalFilename.StartsWith(ModuleFullPath)) { return true; } return false; }); if (Module) { SCOPE_LOG_TIME_IN_SECONDS(TEXT("Looking for blueprints affected by code changes"), nullptr); const FString FoundModulePath = FPaths::ConvertRelativePathToFull(FPaths::GetPath(*Module)); const FString FoundModulePathWithSlash = FoundModulePath / TEXT(""); FString ChangedHeaderReleativeToModule = ChangedHeaderLocalFilename; FPaths::MakePathRelativeTo(ChangedHeaderReleativeToModule, *FoundModulePathWithSlash); FString ChangedHeaderModule = FPaths::GetBaseFilename(FoundModulePath); // STEP 1 - Find all the native classes inside the header that changed. TArray> ClassList = NativeClassCache.GetClassesInHeader(ChangedHeaderModule, ChangedHeaderReleativeToModule); // STEP 2 - We now need to convert the set of native classes into actual derived blueprints. bool bTooManyFiles = false; TArray BlueprintsDerivedFromNativeModifiedClasses; for (TWeakObjectPtr ModifiedClassPtr : ClassList) { // If we capped out on maximum number of modified files for a single header change, don't try to keep looking for more stuff. if (bTooManyFiles) { break; } if (UClass* ModifiedClass = ModifiedClassPtr.Get()) { // This finds all native derived blueprints, both direct subclasses, or subclasses of subclasses. TSet DerivedClassNames; TArray ClassNames; ClassNames.Add(ModifiedClass->GetFName()); AssetRegistry.GetDerivedClassNames(ClassNames, TSet(), DerivedClassNames); UE_LOG(LogLyraEditor, Display, TEXT("Validating Subclasses of %s in %s + %s"), *ModifiedClass->GetName(), *ChangedHeaderModule, *ChangedHeaderReleativeToModule); FARFilter Filter; Filter.bRecursiveClasses = true; Filter.ClassNames.Add(UBlueprintCore::StaticClass()->GetFName()); // We enumerate all assets to find any blueprints who inherit from native classes directly - or // from other blueprints. AssetRegistry.EnumerateAssets(Filter, [&BlueprintsDerivedFromNativeModifiedClasses, &bTooManyFiles, &DerivedClassNames, ChangedHeaderModule, ChangedHeaderReleativeToModule](const FAssetData& AssetData) { FString PackageName = AssetData.PackageName.ToString(); // Don't check data-only blueprints, we'll be here all day. if (!AssetData.GetTagValueRef(FBlueprintTags::IsDataOnly) && !UEditorValidator::IsInUncookedFolder(PackageName)) { // Need to get the generated class here to see if it's one in the derived set we care about. const FString ClassFromData = AssetData.GetTagValueRef(FBlueprintTags::GeneratedClassPath); if (!ClassFromData.IsEmpty()) { const FString ClassObjectPath = FPackageName::ExportTextPathToObjectPath(ClassFromData); const FString ClassName = FPackageName::ObjectPathToObjectName(ClassObjectPath); if (DerivedClassNames.Contains(FName(*ClassName))) { UE_LOG(LogLyraEditor, Display, TEXT("\tAdding %s To Validate"), *PackageName); BlueprintsDerivedFromNativeModifiedClasses.Emplace(AssetData); if (BlueprintsDerivedFromNativeModifiedClasses.Num() >= GMaxAssetsChangedByAHeader) { bTooManyFiles = true; UE_LOG(LogLyraEditor, Display, TEXT("Too many assets invalidated (Max %d) by change to, %s + %s"), GMaxAssetsChangedByAHeader, *ChangedHeaderModule, *ChangedHeaderReleativeToModule); return false; // Stop enumerating. } } } } return true; }); } } // STEP 3 - Report the possibly changed blueprints as affected modified packages that need // to be proved out. for (const FAssetData& BlueprintsDerivedFromNativeModifiedClass : BlueprintsDerivedFromNativeModifiedClasses) { OutChangedPackageNames.Add(BlueprintsDerivedFromNativeModifiedClass.PackageName.ToString()); } } } #undef LOCTEXT_NAMESPACE