// Copyright (c) 2026 Reignance Studios. MIT License, see LICENSE. #include "RSNetAuditRunner.h" #include "AbilitySystemComponent.h" #include "AbilitySystemInterface.h" #include "Abilities/GameplayAbility.h" #include "AssetRegistry/AssetRegistryModule.h" #include "AssetRegistry/IAssetRegistry.h" #include "AttributeSet.h" #include "EdGraph/EdGraph.h" #include "EdGraph/EdGraphPin.h" #include "EdGraphSchema_K2.h" #include "Engine/Blueprint.h" #include "GameFramework/PlayerState.h" #include "GameplayEffect.h" #include "HAL/FileManager.h" #include "Interfaces/IPluginManager.h" #include "Internationalization/Regex.h" #include "K2Node_BaseAsyncTask.h" #include "K2Node_CallFunction.h" #include "K2Node_Event.h" #include "Misc/App.h" #include "UObject/UObjectIterator.h" #include "Misc/DateTime.h" #include "Misc/EngineVersionComparison.h" #include "Misc/FileHelper.h" #include "Misc/PackageName.h" #include "Misc/Paths.h" #include "Modules/ModuleManager.h" DEFINE_LOG_CATEGORY_STATIC(LogRSNetAudit, Log, All); namespace RSNetAudit { static const TCHAR* SiteUrl = TEXT("https://reignancestudios.com/"); static const TCHAR* TutAscOnPlayerState = TEXT("tutorial-gas-asc-on-playerstate"); static const TCHAR* TutFiresTwice = TEXT("tutorial-gas-ability-fires-twice"); static const TCHAR* TutCostCooldown = TEXT("tutorial-gas-predicted-cost-cooldown"); static const TCHAR* TutLatency = TEXT("tutorial-ue5-test-multiplayer-real-latency"); // ------------------------------------------------------------------ class discovery static bool IsProjectPackage(const FString& PackageName) { if (PackageName.StartsWith(TEXT("/Game/"))) { return true; } if (PackageName.StartsWith(TEXT("/Script/"))) { // Native class: project modules are built into the project's or its plugins' Binaries. const FName ModuleName(*PackageName.RightChop(8)); const FString ModuleFile = FModuleManager::Get().GetModuleFilename(ModuleName); if (ModuleFile.IsEmpty()) { return false; } const FString FullModule = FPaths::ConvertRelativePathToFull(ModuleFile); const FString FullProject = FPaths::ConvertRelativePathToFull(FPaths::ProjectDir()); return FPaths::IsUnderDirectory(FullModule, FullProject); } // Blueprint in a plugin: /PluginName/... counts when the plugin ships with the project. FString MountName; if (PackageName.Split(TEXT("/"), nullptr, &MountName, ESearchCase::CaseSensitive, ESearchDir::FromStart)) { MountName.Split(TEXT("/"), &MountName, nullptr); const TSharedPtr Plugin = IPluginManager::Get().FindPlugin(MountName); return Plugin.IsValid() && Plugin->GetLoadedFrom() == EPluginLoadedFrom::Project; } return false; } static bool ShouldScan(const UClass* Class, const FRSAuditOptions& Options) { if (!Class || Class->HasAnyClassFlags(CLASS_Abstract | CLASS_Deprecated | CLASS_NewerVersionExists)) { return false; } const FString Name = Class->GetName(); if (Name.StartsWith(TEXT("SKEL_")) || Name.StartsWith(TEXT("REINST_")) || Name.StartsWith(TEXT("TRASHCLASS_"))) { return false; } return Options.bIncludeEngineClasses || IsProjectPackage(Class->GetOutermost()->GetName()); } /** Native and Blueprint classes derived from Base, loading Blueprints as needed. */ static TArray GatherClasses(UClass* Base, const FRSAuditOptions& Options) { IAssetRegistry& Registry = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")).Get(); // Object paths of derived Blueprint classes, e.g. /Game/GA_Dash.GA_Dash_C TArray ClassPaths; #if UE_VERSION_NEWER_THAN(5, 0, 99) TSet Derived; Registry.GetDerivedClassNames({ Base->GetClassPathName() }, TSet(), Derived); for (const FTopLevelAssetPath& Path : Derived) { ClassPaths.Add(Path.ToString()); } #else // 5.0 only returns short class names, so match them against each Blueprint's GeneratedClass tag. TSet DerivedNames; Registry.GetDerivedClassNames({ Base->GetFName() }, TSet(), DerivedNames); FARFilter Filter; Filter.ClassNames.Add(UBlueprint::StaticClass()->GetFName()); Filter.bRecursiveClasses = true; TArray Blueprints; Registry.GetAssets(Filter, Blueprints); for (const FAssetData& Asset : Blueprints) { FString GeneratedClass; if (Asset.GetTagValue(FBlueprintTags::GeneratedClassPath, GeneratedClass)) { const FString ObjectPath = FPackageName::ExportTextPathToObjectPath(GeneratedClass); if (DerivedNames.Contains(FName(*FPackageName::ObjectPathToObjectName(ObjectPath)))) { ClassPaths.Add(ObjectPath); } } } #endif TArray Classes; for (const FString& Path : ClassPaths) { if (!Options.bIncludeEngineClasses && !IsProjectPackage(FPackageName::ObjectPathToPackageName(Path))) { continue; } UClass* Class = FindObject(nullptr, *Path); if (!Class) { Class = LoadObject(nullptr, *Path, nullptr, LOAD_NoWarn | LOAD_Quiet); } if (Class && Class->IsChildOf(Base) && ShouldScan(Class, Options)) { Classes.AddUnique(Class); } } // Native classes are all loaded already; the registry does not always list them. for (TObjectIterator It; It; ++It) { UClass* Class = *It; if (Class->HasAnyClassFlags(CLASS_Native) && Class->IsChildOf(Base) && ShouldScan(Class, Options)) { Classes.AddUnique(Class); } } Classes.Sort([](const UClass& A, const UClass& B) { return A.GetPathName() < B.GetPathName(); }); return Classes; } // ------------------------------------------------------------------ reflection helpers static bool ReadBool(const UObject* Object, UClass* Owner, const TCHAR* Name, bool& bOut) { if (const FBoolProperty* Prop = FindFProperty(Owner, Name)) { bOut = Prop->GetPropertyValue_InContainer(Object); return true; } return false; } static int32 ReadArrayNum(const UObject* Object, UClass* Owner, const TCHAR* Name) { if (const FArrayProperty* Prop = FindFProperty(Owner, Name)) { FScriptArrayHelper Helper(Prop, Prop->ContainerPtrToValuePtr(Object)); return Helper.Num(); } return INDEX_NONE; } /** * ReplicationMode is protected and not reflected, so FindFProperty cannot see it. * A pointer to member formed inside a derived class reads it legally on any ASC. Never instantiated. */ struct FRSASCAccess : public UAbilitySystemComponent { static EGameplayEffectReplicationMode GetMode(const UAbilitySystemComponent* ASC) { return ASC->*(&FRSASCAccess::ReplicationMode); } }; static EGameplayEffectReplicationMode ReadReplicationMode(const UAbilitySystemComponent* ASC) { return FRSASCAccess::GetMode(ASC); } static float ReadNetUpdateFrequency(const AActor* Actor) { #if UE_VERSION_NEWER_THAN(5, 4, 99) return Actor->GetNetUpdateFrequency(); #else return Actor->NetUpdateFrequency; #endif } static UAbilitySystemComponent* FindDefaultASC(AActor* CDO) { if (const IAbilitySystemInterface* Interface = Cast(CDO)) { if (UAbilitySystemComponent* ASC = Interface->GetAbilitySystemComponent()) { return ASC; } } TInlineComponentArray Components; CDO->GetComponents(Components); return Components.Num() > 0 ? Components[0] : nullptr; } // ------------------------------------------------------------------ findings struct FCollector { FRSAuditResult& Result; void Add(const TCHAR* Id, ERSAuditSeverity Severity, const FString& Subject, const FString& Message, const FString& Fix, const TCHAR* Tutorial) { FRSAuditFinding& F = Result.Findings.AddDefaulted_GetRef(); F.CheckId = Id; F.Severity = Severity; F.Subject = Subject; F.Message = Message; F.Fix = Fix; F.Tutorial = Tutorial; } }; // ------------------------------------------------------------------ ability checks static bool IsCommitNode(const UK2Node_CallFunction* Node) { const FName Name = Node->FunctionReference.GetMemberName(); static const FName Names[] = { TEXT("K2_CommitAbility"), TEXT("K2_CommitAbilityCost"), TEXT("K2_CommitAbilityCooldown"), TEXT("CommitAbility"), TEXT("CommitAbilityCost"), TEXT("CommitAbilityCooldown") }; for (const FName& Candidate : Names) { if (Name == Candidate) { return true; } } return false; } static bool IsLatentNode(const UEdGraphNode* Node, FString& OutName) { if (const UK2Node_BaseAsyncTask* Async = Cast(Node)) { // Ability tasks (PlayMontageAndWait, WaitTargetData...) and async actions. OutName = Async->GetNodeTitle(ENodeTitleType::ListView).ToString(); return true; } if (const UK2Node_CallFunction* Call = Cast(Node)) { if (Call->IsLatentFunction()) { OutName = Call->GetNodeTitle(ENodeTitleType::ListView).ToString(); return true; } } return false; } /** Walks exec wires upstream from Start. Returns the first latent node found, or empty. */ static FString FindLatentUpstream(UEdGraphNode* Start) { TArray Stack; TSet Visited; Stack.Add(Start); while (Stack.Num() > 0) { UEdGraphNode* Node = Stack.Pop(); if (Visited.Contains(Node)) { continue; } Visited.Add(Node); FString LatentName; if (Node != Start && IsLatentNode(Node, LatentName)) { return LatentName; } if (Node != Start && Node->IsA()) { continue; // reached the event that starts this chain } for (UEdGraphPin* Pin : Node->Pins) { if (Pin && Pin->Direction == EGPD_Input && Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Exec) { for (UEdGraphPin* Linked : Pin->LinkedTo) { if (Linked && Linked->GetOwningNode()) { Stack.Add(Linked->GetOwningNode()); } } } } } return FString(); } static void CheckAbilityBlueprint(UClass* Class, FCollector& Out, FRSAuditResult& Result) { UBlueprint* Blueprint = UBlueprint::GetBlueprintFromClass(Class); if (!Blueprint) { return; } ++Result.AbilityBlueprintsScanned; TArray Graphs; Blueprint->GetAllGraphs(Graphs); for (UEdGraph* Graph : Graphs) { for (UEdGraphNode* Node : Graph->Nodes) { const UK2Node_CallFunction* Call = Cast(Node); if (!Call || !IsCommitNode(Call)) { continue; } const FString Latent = FindLatentUpstream(Node); if (!Latent.IsEmpty()) { Out.Add(TEXT("GA-05"), ERSAuditSeverity::Warning, Blueprint->GetPathName(), FString::Printf(TEXT("%s in graph '%s' runs after the latent node '%s'. That is outside the activation's prediction window, so the owning client cannot predict the cost or cooldown and the UI jumps a round trip later."), *Call->GetNodeTitle(ENodeTitleType::ListView).ToString(), *Graph->GetName(), *Latent), TEXT("Commit right after ActivateAbility, before any latent node. If the cost must come later (charge on release), commit from a task callback that carries a prediction key and test it at 150 ms."), TutCostCooldown); } } } } static void CheckAbility(UClass* Class, FCollector& Out, FRSAuditResult& Result) { const UGameplayAbility* CDO = Class->GetDefaultObject(); if (!CDO) { return; } ++Result.AbilitiesScanned; const FString Subject = Class->GetPathName(); // GA-01: net execution policy const int32 TriggerCount = ReadArrayNum(CDO, UGameplayAbility::StaticClass(), TEXT("AbilityTriggers")); const bool bHasTriggers = TriggerCount > 0; switch (CDO->GetNetExecutionPolicy()) { case EGameplayAbilityNetExecutionPolicy::ServerInitiated: Out.Add(TEXT("GA-01"), ERSAuditSeverity::Warning, Subject, TEXT("Net Execution Policy is ServerInitiated. The client waits a full round trip before the ability starts, then runs it again locally. This is rarely what player input needs."), TEXT("Use LocalPredicted for abilities the player activates, ServerOnly for abilities only the server starts."), TutFiresTwice); break; case EGameplayAbilityNetExecutionPolicy::ServerOnly: if (!bHasTriggers) { Out.Add(TEXT("GA-01"), ERSAuditSeverity::Info, Subject, TEXT("Net Execution Policy is ServerOnly and the ability has no triggers. If a player activates it from input, every activation waits a round trip."), TEXT("Keep ServerOnly for passives and AI. For player input use LocalPredicted."), TutFiresTwice); } break; case EGameplayAbilityNetExecutionPolicy::LocalOnly: Out.Add(TEXT("GA-01"), ERSAuditSeverity::Info, Subject, TEXT("Net Execution Policy is LocalOnly: it never runs on the server. Fine for cosmetics, wrong for anything that changes gameplay state."), TEXT("If it applies effects, costs or damage, use LocalPredicted."), TutFiresTwice); break; default: break; } // GA-02: input replication bool bReplicateInput = false; if (ReadBool(CDO, UGameplayAbility::StaticClass(), TEXT("bReplicateInputDirectly"), bReplicateInput) && bReplicateInput) { Out.Add(TEXT("GA-02"), ERSAuditSeverity::Warning, Subject, TEXT("Replicate Input Directly is on. Every press and release is sent as its own RPC, outside the prediction system."), TEXT("Turn it off and use the WaitInputPress / WaitInputRelease tasks, which replicate with the prediction key."), TutFiresTwice); } // GA-03: cooldown effect if (const UGameplayEffect* Cooldown = CDO->GetCooldownGameplayEffect()) { if (Cooldown->DurationPolicy == EGameplayEffectDurationType::Instant) { Out.Add(TEXT("GA-03"), ERSAuditSeverity::Error, Subject, FString::Printf(TEXT("Cooldown effect %s is Instant. It ends the moment it is applied, so the ability has no cooldown."), *GetNameSafe(Cooldown->GetClass())), TEXT("Set the cooldown effect's Duration Policy to Has Duration."), TutCostCooldown); } const FGameplayTagContainer* CooldownTags = CDO->GetCooldownTags(); if (!CooldownTags || CooldownTags->IsEmpty()) { Out.Add(TEXT("GA-03"), ERSAuditSeverity::Error, Subject, FString::Printf(TEXT("Cooldown effect %s grants no tags and GetCooldownTags returns none. CheckCooldown looks for those tags, so the cooldown never blocks activation."), *GetNameSafe(Cooldown->GetClass())), TEXT("Grant a tag such as Cooldown. from the cooldown effect, or override GetCooldownTags if you add tags at runtime."), TutCostCooldown); } } // GA-04: cost effect if (const UGameplayEffect* Cost = CDO->GetCostGameplayEffect()) { if (Cost->DurationPolicy != EGameplayEffectDurationType::Instant) { Out.Add(TEXT("GA-04"), ERSAuditSeverity::Warning, Subject, FString::Printf(TEXT("Cost effect %s is not Instant. A duration cost changes the current value only while it is active, then gives it back."), *GetNameSafe(Cost->GetClass())), TEXT("Make cost effects Instant. Use a separate effect for drains over time."), TutCostCooldown); } if (Cost->Modifiers.Num() == 0 && Cost->Executions.Num() == 0) { Out.Add(TEXT("GA-04"), ERSAuditSeverity::Warning, Subject, FString::Printf(TEXT("Cost effect %s has no modifiers or executions, so the ability is free."), *GetNameSafe(Cost->GetClass())), TEXT("Add a modifier on the resource attribute, or remove the cost effect."), TutCostCooldown); } } CheckAbilityBlueprint(Class, Out, Result); } // ------------------------------------------------------------------ PlayerState checks static void CheckPlayerState(UClass* Class, const FRSAuditOptions& Options, FCollector& Out, FRSAuditResult& Result) { APlayerState* CDO = Class->GetDefaultObject(); UAbilitySystemComponent* ASC = CDO ? FindDefaultASC(CDO) : nullptr; if (!ASC) { return; // not a GAS PlayerState, or the ASC is added in Blueprint (not visible on the CDO) } ++Result.PlayerStatesScanned; const FString Subject = Class->GetPathName(); // PS-01: update rate const float Rate = ReadNetUpdateFrequency(CDO); if (Rate < Options.MinPlayerStateNetUpdateFrequency) { Out.Add(TEXT("PS-01"), ERSAuditSeverity::Warning, Subject, FString::Printf(TEXT("PlayerState holds an Ability System Component but replicates only %.0f times a second. Ability confirmations, effects and attributes ride on this actor, so a low rate shows up as input lag."), Rate), TEXT("Raise Net Update Frequency on the PlayerState (Lyra uses 100), then profile."), TutAscOnPlayerState); } // PS-02: ASC replicated at all if (!ASC->GetIsReplicated()) { Out.Add(TEXT("PS-02"), ERSAuditSeverity::Error, Subject, TEXT("The Ability System Component on this PlayerState is not set to replicate. Clients never receive abilities, effects or tags."), TEXT("Call SetIsReplicated(true) on the ASC in the PlayerState constructor."), TutAscOnPlayerState); } // PS-03: replication mode const EGameplayEffectReplicationMode Mode = ReadReplicationMode(ASC); if (Mode == EGameplayEffectReplicationMode::Minimal) { Out.Add(TEXT("PS-03"), ERSAuditSeverity::Error, Subject, TEXT("Replication mode is Minimal on a player ASC. The owning client never receives its own gameplay effects, so predicted costs and cooldowns cannot be confirmed and cooldown UI has nothing to read."), TEXT("Use SetReplicationMode(EGameplayEffectReplicationMode::Mixed) for player ASCs. Keep Minimal for AI."), TutAscOnPlayerState); } else if (Mode == EGameplayEffectReplicationMode::Full) { Out.Add(TEXT("PS-03"), ERSAuditSeverity::Warning, Subject, TEXT("Replication mode is Full: every client receives every player's gameplay effects. Correct, but it costs bandwidth that grows with player count."), TEXT("Use Mixed for multiplayer player ASCs. Full is meant for single-player."), TutAscOnPlayerState); } } // ------------------------------------------------------------------ AttributeSet checks struct FSourceIndex { TArray> Files; // path, contents void Build() { TArray Roots = { FPaths::ProjectDir() / TEXT("Source"), FPaths::ProjectPluginsDir() }; for (const FString& Root : Roots) { TArray Found; IFileManager::Get().FindFilesRecursive(Found, *Root, TEXT("*.cpp"), true, false); for (const FString& Path : Found) { if (Path.Contains(TEXT("/Intermediate/")) || Path.Contains(TEXT("\\Intermediate\\"))) { continue; } FString Text; if (FFileHelper::LoadFileToString(Text, *Path)) { Files.Emplace(Path, MoveTemp(Text)); } } } } /** Body of ClassName::Function(...) { ... }, or empty if not found. */ bool FindFunctionBody(const FString& ClassName, const FString& Function, FString& OutBody, FString& OutFile) const { const FRegexPattern Pattern(FString::Printf(TEXT("\\b%s\\s*::\\s*%s\\s*\\([^)]*\\)\\s*(const\\s*)?\\{"), *ClassName, *Function)); for (const TPair& File : Files) { FRegexMatcher Matcher(Pattern, File.Value); if (!Matcher.FindNext()) { continue; } int32 Depth = 1; const int32 Start = Matcher.GetMatchEnding(); int32 Index = Start; for (; Index < File.Value.Len() && Depth > 0; ++Index) { const TCHAR C = File.Value[Index]; Depth += (C == TEXT('{')) ? 1 : (C == TEXT('}')) ? -1 : 0; } OutBody = File.Value.Mid(Start, Index - Start); OutFile = File.Key; return true; } return false; } }; static void CheckAttributeSet(UClass* Class, const FSourceIndex& Sources, FCollector& Out, FRSAuditResult& Result) { ++Result.AttributeSetsScanned; const FString CppName = FString(Class->GetPrefixCPP()) + Class->GetName(); FString RepPropsBody, RepPropsFile; const bool bHaveRepProps = Sources.FindFunctionBody(CppName, TEXT("GetLifetimeReplicatedProps"), RepPropsBody, RepPropsFile); for (TFieldIterator It(Class, EFieldIteratorFlags::ExcludeSuper); It; ++It) { const FStructProperty* Prop = *It; if (Prop->Struct != FGameplayAttributeData::StaticStruct()) { continue; } const FString Attr = Prop->GetName(); const FString Subject = FString::Printf(TEXT("%s.%s"), *CppName, *Attr); // AS-01: not replicated if (!Prop->HasAnyPropertyFlags(CPF_Net)) { Out.Add(TEXT("AS-01"), ERSAuditSeverity::Info, Subject, TEXT("Attribute is not replicated. Clients always see its default value."), TEXT("Fine for server-only meta attributes such as incoming damage. Otherwise add ReplicatedUsing=OnRep_."), TutCostCooldown); continue; } // AS-02: replicated without an OnRep if (Prop->RepNotifyFunc.IsNone()) { Out.Add(TEXT("AS-02"), ERSAuditSeverity::Warning, Subject, TEXT("Attribute replicates without an OnRep. The ASC is never told the server value arrived, so predicted changes on the owning client are not re-applied and the value jumps."), TEXT("Use ReplicatedUsing=OnRep_ and call GAMEPLAYATTRIBUTE_REPNOTIFY in it."), TutCostCooldown); } else { FString Body, File; if (Sources.FindFunctionBody(CppName, Prop->RepNotifyFunc.ToString(), Body, File)) { if (!Body.Contains(TEXT("GAMEPLAYATTRIBUTE_REPNOTIFY"))) { Out.Add(TEXT("AS-02"), ERSAuditSeverity::Warning, Subject, FString::Printf(TEXT("%s does not call GAMEPLAYATTRIBUTE_REPNOTIFY (%s). Predicted changes are lost when the server value lands."), *Prop->RepNotifyFunc.ToString(), *FPaths::GetCleanFilename(File)), TEXT("Call GAMEPLAYATTRIBUTE_REPNOTIFY(ClassName, Attribute, OldValue) as the first line of the OnRep."), TutCostCooldown); } } } // AS-03: REPNOTIFY_Always if (bHaveRepProps) { const FRegexPattern Pattern(FString::Printf(TEXT("DOREPLIFETIME\\w*\\s*\\(\\s*%s\\s*,\\s*%s\\s*(,[^;]*)?\\)"), *CppName, *Attr)); FRegexMatcher Matcher(Pattern, RepPropsBody); if (Matcher.FindNext()) { const FString Line = RepPropsBody.Mid(Matcher.GetMatchBeginning(), Matcher.GetMatchEnding() - Matcher.GetMatchBeginning()); if (!Line.Contains(TEXT("REPNOTIFY_Always"))) { Out.Add(TEXT("AS-03"), ERSAuditSeverity::Warning, Subject, TEXT("Registered without REPNOTIFY_Always. When the server value equals the client's predicted value, the OnRep does not fire and the prediction is never confirmed."), TEXT("Use DOREPLIFETIME_CONDITION_NOTIFY(Class, Attribute, COND_None, REPNOTIFY_Always)."), TutCostCooldown); } } } } if (!bHaveRepProps) { Out.Add(TEXT("AS-03"), ERSAuditSeverity::Info, CppName, TEXT("Could not find GetLifetimeReplicatedProps in the project source, so OnRep conditions were not checked."), TEXT("Check by hand that every replicated attribute uses REPNOTIFY_Always."), TutCostCooldown); } } // ------------------------------------------------------------------ report static const TCHAR* SeverityName(ERSAuditSeverity Severity) { switch (Severity) { case ERSAuditSeverity::Error: return TEXT("Error"); case ERSAuditSeverity::Warning: return TEXT("Warning"); default: return TEXT("Info"); } } static FString HtmlEscape(const FString& In) { return In.Replace(TEXT("&"), TEXT("&")).Replace(TEXT("<"), TEXT("<")).Replace(TEXT(">"), TEXT(">")).Replace(TEXT("\""), TEXT(""")); } static FString MdEscape(const FString& In) { return In.Replace(TEXT("|"), TEXT("\\|")).Replace(TEXT("\n"), TEXT(" ")); } static FString TutorialUrl(const FString& Slug) { return FString(SiteUrl) + Slug + TEXT("?src=netaudit"); } static const TCHAR* CannotSee = TEXT("Static checks cannot see: abilities activated from both input and a trigger, C++ abilities that commit after a latent task, ") TEXT("side effects spawned on both client and server, InitAbilityActorInfo missing on the client, abilities granted twice on respawn, ") TEXT("or anything that only breaks at real latency."); static FString BuildMarkdown(const FRSAuditResult& R, const FString& When) { FString Md; Md += TEXT("# GAS Net Audit report\n\n"); Md += FString::Printf(TEXT("Project: %s \nRun: %s \nTool: RS GAS Net Audit v0.1.0 (https://reignancestudios.com/code/gas-net-audit/)\n\n"), FApp::GetProjectName(), *When); Md += FString::Printf(TEXT("Scanned %d abilities (%d Blueprint graphs), %d PlayerStates with an ASC, %d AttributeSets.\n\n"), R.AbilitiesScanned, R.AbilityBlueprintsScanned, R.PlayerStatesScanned, R.AttributeSetsScanned); Md += FString::Printf(TEXT("**%d errors, %d warnings, %d info.**\n\n"), R.Count(ERSAuditSeverity::Error), R.Count(ERSAuditSeverity::Warning), R.Count(ERSAuditSeverity::Info)); for (ERSAuditSeverity Severity : { ERSAuditSeverity::Error, ERSAuditSeverity::Warning, ERSAuditSeverity::Info }) { if (R.Count(Severity) == 0) { continue; } Md += FString::Printf(TEXT("## %ss\n\n| Check | Subject | Problem | Fix | Guide |\n|---|---|---|---|---|\n"), SeverityName(Severity)); for (const FRSAuditFinding& F : R.Findings) { if (F.Severity == Severity) { Md += FString::Printf(TEXT("| %s | `%s` | %s | %s | [read](%s) |\n"), *F.CheckId, *MdEscape(F.Subject), *MdEscape(F.Message), *MdEscape(F.Fix), *TutorialUrl(F.Tutorial)); } } Md += TEXT("\n"); } Md += TEXT("## What this audit cannot see\n\n"); Md += FString(CannotSee) + TEXT("\n\n"); Md += FString::Printf(TEXT("Test at real latency: %s\n\n"), *TutorialUrl(TutLatency)); Md += TEXT("If abilities still desync in packaged builds after these fixes, a Diagnostic finds the cause across the project in two days: https://reignancestudios.com/gameplay-ability-system-cost?src=netaudit#diagnostic\n"); return Md; } static FString BuildHtml(const FRSAuditResult& R, const FString& When) { FString Rows; for (ERSAuditSeverity Severity : { ERSAuditSeverity::Error, ERSAuditSeverity::Warning, ERSAuditSeverity::Info }) { for (const FRSAuditFinding& F : R.Findings) { if (F.Severity != Severity) { continue; } Rows += FString::Printf( TEXT("%s
%s%s%s%sGuide\n"), SeverityName(F.Severity), SeverityName(F.Severity), *HtmlEscape(F.CheckId), *HtmlEscape(F.Subject), *HtmlEscape(F.Message), *HtmlEscape(F.Fix), *HtmlEscape(TutorialUrl(F.Tutorial))); } } if (Rows.IsEmpty()) { Rows = TEXT("No findings. Now test at real latency: static checks cannot see everything."); } FString Html; Html += TEXT(""); Html += FString::Printf(TEXT("GAS Net Audit: %s"), *HtmlEscape(FApp::GetProjectName())); Html += TEXT(""); Html += FString::Printf(TEXT("

GAS Net Audit

%s · %s · RS GAS Net Audit v0.1.0

"), *HtmlEscape(FApp::GetProjectName()), *HtmlEscape(When)); Html += FString::Printf(TEXT("

%d errors, %d warnings, %d info

"), R.Count(ERSAuditSeverity::Error), R.Count(ERSAuditSeverity::Warning), R.Count(ERSAuditSeverity::Info)); Html += FString::Printf(TEXT("

Scanned %d abilities (%d Blueprint graphs), %d PlayerStates with an ASC, %d AttributeSets.

"), R.AbilitiesScanned, R.AbilityBlueprintsScanned, R.PlayerStatesScanned, R.AttributeSetsScanned); Html += TEXT(""); Html += Rows; Html += TEXT("
SeveritySubjectProblemFix
"); Html += FString::Printf(TEXT("

What this audit cannot see

%s Test at real latency.

"), CannotSee, *HtmlEscape(TutorialUrl(TutLatency))); Html += TEXT("

Still desyncing in packaged builds? A Diagnostic finds the cause across your project in two days.

") TEXT("

Request a Diagnostic · ") TEXT("Book a free teardown

"); Html += TEXT("\n"); return Html; } } int32 FRSAuditResult::Count(ERSAuditSeverity Severity) const { int32 N = 0; for (const FRSAuditFinding& F : Findings) { N += F.Severity == Severity ? 1 : 0; } return N; } FRSAuditResult FRSNetAuditRunner::Run(const FRSAuditOptions& Options) { using namespace RSNetAudit; FRSAuditResult Result; FCollector Out{ Result }; IAssetRegistry& Registry = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")).Get(); Registry.SearchAllAssets(true); for (UClass* Class : GatherClasses(UGameplayAbility::StaticClass(), Options)) { CheckAbility(Class, Out, Result); } for (UClass* Class : GatherClasses(APlayerState::StaticClass(), Options)) { CheckPlayerState(Class, Options, Out, Result); } FSourceIndex Sources; Sources.Build(); for (UClass* Class : GatherClasses(UAttributeSet::StaticClass(), Options)) { if (Class->HasAnyClassFlags(CLASS_Native)) { CheckAttributeSet(Class, Sources, Out, Result); } } const FDateTime Now = FDateTime::Now(); const FString When = Now.ToString(TEXT("%Y-%m-%d %H:%M")); const FString Dir = Options.OutputDir.IsEmpty() ? FPaths::ProjectSavedDir() / TEXT("RSNetAudit") : Options.OutputDir; const FString Stamp = Now.ToString(TEXT("%Y%m%d-%H%M%S")); Result.MarkdownPath = FPaths::ConvertRelativePathToFull(Dir / FString::Printf(TEXT("GASNetAudit-%s.md"), *Stamp)); Result.HtmlPath = FPaths::ConvertRelativePathToFull(Dir / FString::Printf(TEXT("GASNetAudit-%s.html"), *Stamp)); const FString Markdown = BuildMarkdown(Result, When); const FString Html = BuildHtml(Result, When); FFileHelper::SaveStringToFile(Markdown, *Result.MarkdownPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); FFileHelper::SaveStringToFile(Html, *Result.HtmlPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); FFileHelper::SaveStringToFile(Markdown, *(Dir / TEXT("GASNetAudit-latest.md")), FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); FFileHelper::SaveStringToFile(Html, *(Dir / TEXT("GASNetAudit-latest.html")), FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); for (const FRSAuditFinding& F : Result.Findings) { const FString Line = FString::Printf(TEXT("[%s] %s %s: %s"), *F.CheckId, SeverityName(F.Severity), *F.Subject, *F.Message); if (F.Severity == ERSAuditSeverity::Error) { UE_LOG(LogRSNetAudit, Error, TEXT("%s"), *Line); } else if (F.Severity == ERSAuditSeverity::Warning) { UE_LOG(LogRSNetAudit, Warning, TEXT("%s"), *Line); } else { UE_LOG(LogRSNetAudit, Display, TEXT("%s"), *Line); } } UE_LOG(LogRSNetAudit, Display, TEXT("GAS Net Audit: %d errors, %d warnings, %d info. Report: %s"), Result.Count(ERSAuditSeverity::Error), Result.Count(ERSAuditSeverity::Warning), Result.Count(ERSAuditSeverity::Info), *Result.HtmlPath); return Result; }