// 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 "Dom/JsonObject.h" #include "EdGraph/EdGraph.h" #include "EdGraph/EdGraphPin.h" #include "EdGraphSchema_K2.h" #include "Engine/Blueprint.h" #include "Engine/Engine.h" #include "Engine/LocalPlayer.h" #include "Engine/NetDriver.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" #include "Serialization/JsonSerializer.h" #include "Serialization/JsonWriter.h" DEFINE_LOG_CATEGORY_STATIC(LogRSNetAudit, Log, All); namespace RSNetAudit { static const TCHAR* ToolVersion = TEXT("0.2.0"); static const TCHAR* SiteUrl = TEXT("https://reignancestudios.com/"); /** Every check id, so the JUnit report can list the ones that passed. */ static const TCHAR* AllChecks[] = { TEXT("GA-01"), TEXT("GA-02"), TEXT("GA-03"), TEXT("GA-04"), TEXT("GA-05"), TEXT("GA-06"), TEXT("GA-07"), TEXT("PS-01"), TEXT("PS-02"), TEXT("PS-03"), TEXT("AS-01"), TEXT("AS-02"), TEXT("AS-03"), TEXT("AS-04"), TEXT("NET-01") }; 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 ReadInt(const UObject* Object, const TCHAR* Name) { if (Object) { if (const FIntProperty* Prop = FindFProperty(Object->GetClass(), Name)) { return Prop->GetPropertyValue_InContainer(Object); } } return INDEX_NONE; } struct FTriggerInfo { int32 Num = 0; bool bTagTrigger = false; FString Description; // "Event Ability.Jump, TagAdded State.Stunned" }; /** AbilityTriggers is protected, so read it through reflection. */ static FTriggerInfo ReadTriggers(const UGameplayAbility* CDO) { FTriggerInfo Info; const FArrayProperty* Prop = FindFProperty(UGameplayAbility::StaticClass(), TEXT("AbilityTriggers")); const FStructProperty* Inner = Prop ? CastField(Prop->Inner) : nullptr; if (!Inner || Inner->Struct != FAbilityTriggerData::StaticStruct()) { return Info; } FScriptArrayHelper Helper(Prop, Prop->ContainerPtrToValuePtr(CDO)); TArray Parts; for (int32 Index = 0; Index < Helper.Num(); ++Index) { const FAbilityTriggerData* Trigger = reinterpret_cast(Helper.GetRawPtr(Index)); const TCHAR* Source = TEXT("Event"); switch (static_cast(Trigger->TriggerSource)) { case EGameplayAbilityTriggerSource::OwnedTagAdded: Source = TEXT("TagAdded"); Info.bTagTrigger = true; break; case EGameplayAbilityTriggerSource::OwnedTagPresent: Source = TEXT("TagPresent"); Info.bTagTrigger = true; break; default: break; } Parts.Add(FString::Printf(TEXT("%s %s"), Source, *Trigger->TriggerTag.ToString())); } Info.Num = Parts.Num(); Info.Description = FString::Join(Parts, TEXT(", ")); return Info; } /** * 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; } static const TCHAR* NetPolicyName(const UGameplayAbility* CDO) { switch (CDO->GetNetExecutionPolicy()) { case EGameplayAbilityNetExecutionPolicy::LocalPredicted: return TEXT("LocalPredicted"); case EGameplayAbilityNetExecutionPolicy::LocalOnly: return TEXT("LocalOnly"); case EGameplayAbilityNetExecutionPolicy::ServerInitiated: return TEXT("ServerInitiated"); case EGameplayAbilityNetExecutionPolicy::ServerOnly: return TEXT("ServerOnly"); default: return TEXT("?"); } } static const TCHAR* InstancingName(const UGameplayAbility* CDO) { switch (CDO->GetInstancingPolicy()) { case EGameplayAbilityInstancingPolicy::InstancedPerActor: return TEXT("PerActor"); case EGameplayAbilityInstancingPolicy::InstancedPerExecution: return TEXT("PerExecution"); default: return TEXT("NonInstanced"); } } static const TCHAR* ReplicationModeName(EGameplayEffectReplicationMode Mode) { switch (Mode) { case EGameplayEffectReplicationMode::Minimal: return TEXT("Minimal"); case EGameplayEffectReplicationMode::Mixed: return TEXT("Mixed"); case EGameplayEffectReplicationMode::Full: return TEXT("Full"); default: return TEXT("?"); } } static FString IntCell(int32 Value) { return Value == INDEX_NONE ? FString(TEXT("?")) : FString::FromInt(Value); } // ------------------------------------------------------------------ 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. * An async task's "then" pin fires right after the task starts, inside the same * prediction window, so reaching a task through it does not count as latent. */ static FString FindLatentUpstream(UEdGraphNode* Start) { // Node plus the output pin the walk arrived through (null for Start). TArray> Stack; TSet> Visited; Stack.Emplace(Start, nullptr); while (Stack.Num() > 0) { const TPair Item = Stack.Pop(); UEdGraphNode* Node = Item.Key; const bool bViaImmediateThen = Item.Value && Node->IsA() && Item.Value->PinName == UEdGraphSchema_K2::PN_Then; if (Visited.Contains({ Node, bViaImmediateThen })) { continue; } Visited.Add({ Node, bViaImmediateThen }); FString LatentName; if (Node != Start && !bViaImmediateThen && 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.Emplace(Linked->GetOwningNode(), Linked); } } } } } return FString(); } static void CheckAbilityGraphs(const UBlueprint* Blueprint, FCollector& Out) { 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 CheckAbilityBlueprint(UClass* Class, FCollector& Out, FRSAuditResult& Result) { if (const UBlueprint* Blueprint = UBlueprint::GetBlueprintFromClass(Class)) { ++Result.AbilityBlueprintsScanned; CheckAbilityGraphs(Blueprint, Out); } } static void CheckAbility(UClass* Class, FCollector& Out, FRSAuditResult& Result, FRSAuditTable& Table) { const UGameplayAbility* CDO = Class->GetDefaultObject(); if (!CDO) { return; } ++Result.AbilitiesScanned; const FString Subject = Class->GetPathName(); // GA-01: net execution policy const FTriggerInfo Triggers = ReadTriggers(CDO); const bool bHasTriggers = Triggers.Num > 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); } } // GA-06: replicated ability instance if (CDO->GetReplicationPolicy() == EGameplayAbilityReplicationPolicy::ReplicateYes) { Out.Add(TEXT("GA-06"), ERSAuditSeverity::Warning, Subject, TEXT("Replication Policy is ReplicateYes. The ability instance replicates its properties to the owning client, which costs bandwidth on every activation and is not needed for prediction, effects or tags."), TEXT("Set Replication Policy to Do Not Replicate. Keep shared state in tags, attributes or a replicated component, and send data through ability tasks or gameplay events."), TutFiresTwice); } // GA-07: triggers on a predicted ability if (bHasTriggers && CDO->GetNetExecutionPolicy() == EGameplayAbilityNetExecutionPolicy::LocalPredicted) { if (Triggers.bTagTrigger) { Out.Add(TEXT("GA-07"), ERSAuditSeverity::Warning, Subject, FString::Printf(TEXT("LocalPredicted ability triggered by an owned tag (%s). Tag triggers fire wherever the tag changes. A tag added by a replicated effect changes on the server and on the owning client, so both try to activate: the ability runs twice or the client's prediction is rejected."), *Triggers.Description), TEXT("Make tag-triggered abilities ServerOnly (or ServerInitiated if the client needs to run it too), or trigger them with a gameplay event sent from one side only."), TutFiresTwice); } else { Out.Add(TEXT("GA-07"), ERSAuditSeverity::Info, Subject, FString::Printf(TEXT("LocalPredicted ability triggered by a gameplay event (%s). If the event is sent on both the server and the owning client, for example from an anim notify that runs on both, the ability activates twice. If it is also bound to input, it can start from both paths."), *Triggers.Description), TEXT("Send the event from the owning client only (the predicted activation reaches the server), and do not also bind the ability to input."), TutFiresTwice); } } const UGameplayEffect* CostEffect = CDO->GetCostGameplayEffect(); const UGameplayEffect* CooldownEffect = CDO->GetCooldownGameplayEffect(); Table.Rows.Add({ Class->GetName(), Class->HasAnyClassFlags(CLASS_Native) ? TEXT("C++") : TEXT("Blueprint"), NetPolicyName(CDO), InstancingName(CDO), CDO->GetReplicationPolicy() == EGameplayAbilityReplicationPolicy::ReplicateYes ? TEXT("Yes") : TEXT("No"), CostEffect ? CostEffect->GetClass()->GetName() : FString(TEXT("-")), CooldownEffect ? CooldownEffect->GetClass()->GetName() : FString(TEXT("-")), bHasTriggers ? Triggers.Description : FString(TEXT("-")) }); CheckAbilityBlueprint(Class, Out, Result); } // ------------------------------------------------------------------ PlayerState checks static void CheckPlayerState(UClass* Class, const FRSAuditOptions& Options, FCollector& Out, FRSAuditResult& Result, FRSAuditTable& Table) { 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); } Table.Rows.Add({ Class->GetName(), Class->HasAnyClassFlags(CLASS_Native) ? TEXT("C++") : TEXT("Blueprint"), ASC->GetClass()->GetName(), ASC->GetIsReplicated() ? TEXT("Yes") : TEXT("No"), ReplicationModeName(Mode), FString::Printf(TEXT("%.0f"), Rate) }); } // ------------------------------------------------------------------ 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 bool HasClamp(const FString& Body) { return Body.Contains(TEXT("Clamp")) || Body.Contains(TEXT("FMath::Max")) || Body.Contains(TEXT("FMath::Min")); } static void CheckAttributeSet(UClass* Class, const FSourceIndex& Sources, FCollector& Out, FRSAuditResult& Result, FRSAuditTable& Table) { ++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); const bool bReplicated = Prop->HasAnyPropertyFlags(CPF_Net); FString AlwaysCell = TEXT("-"); // AS-01: not replicated if (!bReplicated) { 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); } else { // 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 AlwaysCell = TEXT("?"); 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()); const bool bAlways = Line.Contains(TEXT("REPNOTIFY_Always")); AlwaysCell = bAlways ? TEXT("Yes") : TEXT("No"); if (!bAlways) { 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); } } } } Table.Rows.Add({ CppName, Attr, bReplicated ? TEXT("Yes") : TEXT("No"), Prop->RepNotifyFunc.IsNone() ? FString(TEXT("-")) : Prop->RepNotifyFunc.ToString(), AlwaysCell }); } bool bAnyReplicated = false; for (TFieldIterator It(Class, EFieldIteratorFlags::ExcludeSuper); It; ++It) { bAnyReplicated |= It->Struct == FGameplayAttributeData::StaticStruct() && It->HasAnyPropertyFlags(CPF_Net); } if (!bHaveRepProps && bAnyReplicated) { 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); } // AS-04: clamp only in PreAttributeChange FString PreBody, PreFile; if (Sources.FindFunctionBody(CppName, TEXT("PreAttributeChange"), PreBody, PreFile) && HasClamp(PreBody)) { FString PostBody, PostFile, BaseBody, BaseFile; const bool bPostClamps = Sources.FindFunctionBody(CppName, TEXT("PostGameplayEffectExecute"), PostBody, PostFile) && HasClamp(PostBody); const bool bBaseClamps = Sources.FindFunctionBody(CppName, TEXT("PreAttributeBaseChange"), BaseBody, BaseFile) && HasClamp(BaseBody); if (!bPostClamps && !bBaseClamps) { Out.Add(TEXT("AS-04"), ERSAuditSeverity::Warning, CppName, FString::Printf(TEXT("PreAttributeChange clamps (%s), but neither PostGameplayEffectExecute nor PreAttributeBaseChange does. PreAttributeChange only clamps the value a query returns. Instant effects change the base value, which stays unclamped, so health can sit below 0 or above max and the error shows when a modifier is removed."), *FPaths::GetCleanFilename(PreFile)), TEXT("Clamp the base value too: in PostGameplayEffectExecute (SetHealth(FMath::Clamp(GetHealth(), 0.f, GetMaxHealth()))) or in PreAttributeBaseChange."), TutCostCooldown); } } } // ------------------------------------------------------------------ network settings static void CheckNetSettings(const FRSAuditOptions& Options, FCollector& Out, FRSAuditTable& Table) { FString DriverClassName; if (GEngine) { for (const FNetDriverDefinition& Definition : GEngine->NetDriverDefinitions) { if (Definition.DefName == NAME_GameNetDriver) { DriverClassName = Definition.DriverClassName.ToString(); break; } } } const UClass* DriverClass = DriverClassName.IsEmpty() ? nullptr : LoadClass(nullptr, *DriverClassName, nullptr, LOAD_NoWarn | LOAD_Quiet); const UObject* Driver = DriverClass ? DriverClass->GetDefaultObject() : nullptr; const UObject* Player = GetDefault(); const int32 MaxInternetClientRate = ReadInt(Driver, TEXT("MaxInternetClientRate")); const int32 MaxClientRate = ReadInt(Driver, TEXT("MaxClientRate")); const int32 TickRate = ReadInt(Driver, TEXT("NetServerMaxTickRate")); const int32 InternetSpeed = ReadInt(Player, TEXT("ConfiguredInternetSpeed")); const int32 LanSpeed = ReadInt(Player, TEXT("ConfiguredLanSpeed")); // The config section of a class is its path, e.g. /Script/OnlineSubsystemUtils.IpNetDriver const FString DriverSection = DriverClass ? FString::Printf(TEXT("[%s]"), *DriverClass->GetPathName()) : FString(TEXT("?")); Table.Rows.Add({ TEXT("Game net driver"), DriverClassName.IsEmpty() ? FString(TEXT("?")) : DriverClassName, TEXT("[/Script/Engine.Engine] NetDriverDefinitions") }); Table.Rows.Add({ TEXT("MaxInternetClientRate (bytes/s)"), IntCell(MaxInternetClientRate), DriverSection }); Table.Rows.Add({ TEXT("MaxClientRate, LAN (bytes/s)"), IntCell(MaxClientRate), DriverSection }); Table.Rows.Add({ TEXT("NetServerMaxTickRate"), IntCell(TickRate), DriverSection }); Table.Rows.Add({ TEXT("ConfiguredInternetSpeed (bytes/s)"), IntCell(InternetSpeed), TEXT("[/Script/Engine.Player]") }); Table.Rows.Add({ TEXT("ConfiguredLanSpeed (bytes/s)"), IntCell(LanSpeed), TEXT("[/Script/Engine.Player]") }); if (!Driver) { Out.Add(TEXT("NET-01"), ERSAuditSeverity::Info, TEXT("DefaultEngine.ini"), FString::Printf(TEXT("Could not load the game net driver class '%s' in the editor, so bandwidth limits were not read."), *DriverClassName), TEXT("Check MaxInternetClientRate on your net driver and ConfiguredInternetSpeed by hand."), TutLatency); return; } // A connection runs at the client's requested speed, capped by the server's limit. int32 Effective = INDEX_NONE; if (MaxInternetClientRate > 0 && InternetSpeed > 0) { Effective = FMath::Min(MaxInternetClientRate, InternetSpeed); } else if (MaxInternetClientRate > 0) { Effective = MaxInternetClientRate; } if (Effective != INDEX_NONE && Effective < Options.MinClientRate) { Out.Add(TEXT("NET-01"), ERSAuditSeverity::Warning, TEXT("DefaultEngine.ini"), FString::Printf(TEXT("Each internet client is capped at %d bytes/s (%d kbit/s): MaxInternetClientRate %s, ConfiguredInternetSpeed %s. Ability RPCs, effect and attribute replication and cues for every player share this budget. When it is full the server holds updates back, which looks like ability lag that never shows in PIE."), Effective, Effective * 8 / 1000, *IntCell(MaxInternetClientRate), *IntCell(InternetSpeed)), FString::Printf(TEXT("Raise both in DefaultEngine.ini, e.g. %s MaxInternetClientRate=100000 and [/Script/Engine.Player] ConfiguredInternetSpeed=100000, then measure real usage with Network Insights."), *DriverSection), TutLatency); } } // ------------------------------------------------------------------ suppressions struct FSuppression { FString CheckId; FString Pattern; FString Reason; }; /** One rule per line: CHECK-ID Subject-with-*-wildcards # reason. Lines starting with ; or # are comments. */ static TArray LoadSuppressions(const FString& Path) { TArray Rules; TArray Lines; if (!FFileHelper::LoadFileToStringArray(Lines, *Path)) { return Rules; } for (FString Line : Lines) { FString Reason; int32 Hash = INDEX_NONE; if (Line.FindChar(TEXT('#'), Hash)) { Reason = Line.Mid(Hash + 1).TrimStartAndEnd(); Line = Line.Left(Hash); } Line = Line.Replace(TEXT("\t"), TEXT(" ")).TrimStartAndEnd(); if (Line.IsEmpty() || Line.StartsWith(TEXT(";"))) { continue; } FSuppression& Rule = Rules.AddDefaulted_GetRef(); if (!Line.Split(TEXT(" "), &Rule.CheckId, &Rule.Pattern)) { Rule.CheckId = Line; } Rule.Pattern = Rule.Pattern.TrimStartAndEnd(); if (Rule.Pattern.IsEmpty()) { Rule.Pattern = TEXT("*"); } Rule.Reason = Reason.IsEmpty() ? FString(TEXT("No reason given")) : Reason; } return Rules; } static void ApplySuppressions(const TArray& Rules, FRSAuditResult& Result) { for (FRSAuditFinding& Finding : Result.Findings) { for (const FSuppression& Rule : Rules) { const bool bIdMatches = Rule.CheckId == TEXT("*") || Rule.CheckId.Equals(Finding.CheckId, ESearchCase::IgnoreCase); if (bIdMatches && Finding.Subject.MatchesWildcard(Rule.Pattern)) { Finding.bSuppressed = true; Finding.SuppressReason = Rule.Reason; break; } } } } // ------------------------------------------------------------------ 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 FString IgnoreLine(const FRSAuditFinding& F) { return FString::Printf(TEXT("%s %s"), *F.CheckId, *F.Subject); } static const TCHAR* CannotSee = TEXT("Static checks cannot see: whether a triggered ability is also bound to input, 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 v%s (https://reignancestudios.com/code/gas-net-audit/)\n\n"), FApp::GetProjectName(), *When, ToolVersion); Md += FString::Printf(TEXT("Scanned %d abilities (%d Blueprint graphs), %d PlayerStates with an ASC, %d AttributeSets, and the network settings.\n\n"), R.AbilitiesScanned, R.AbilityBlueprintsScanned, R.PlayerStatesScanned, R.AttributeSetsScanned); Md += FString::Printf(TEXT("**%d errors, %d warnings, %d info.** %d suppressed.\n\n"), R.Count(ERSAuditSeverity::Error), R.Count(ERSAuditSeverity::Warning), R.Count(ERSAuditSeverity::Info), R.SuppressedCount()); for (ERSAuditSeverity Severity : { ERSAuditSeverity::Error, ERSAuditSeverity::Warning, ERSAuditSeverity::Info }) { if (R.Count(Severity) == 0) { continue; } Md += FString::Printf(TEXT("## %s\n\n| Check | Subject | Problem | Fix | Guide |\n|---|---|---|---|---|\n"), Severity == ERSAuditSeverity::Error ? TEXT("Errors") : Severity == ERSAuditSeverity::Warning ? TEXT("Warnings") : TEXT("Info")); for (const FRSAuditFinding& F : R.Findings) { if (F.Severity == Severity && !F.bSuppressed) { 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 += FString::Printf(TEXT("To hide a finding you have checked, add `CHECK-ID Subject # reason` to `%s`. `*` works as a wildcard in the subject.\n\n"), *R.IgnoreFile); if (R.SuppressedCount() > 0) { Md += TEXT("## Suppressed\n\n| Check | Subject | Reason |\n|---|---|---|\n"); for (const FRSAuditFinding& F : R.Findings) { if (F.bSuppressed) { Md += FString::Printf(TEXT("| %s | `%s` | %s |\n"), *F.CheckId, *MdEscape(F.Subject), *MdEscape(F.SuppressReason)); } } Md += TEXT("\n"); } Md += TEXT("## Inventory\n\nWhat the audit read, so you can review the setup at a glance.\n\n"); for (const FRSAuditTable& Table : R.Inventory) { Md += FString::Printf(TEXT("### %s (%d)\n\n| %s |\n|"), *Table.Title, Table.Rows.Num(), *FString::Join(Table.Columns, TEXT(" | "))); for (int32 Index = 0; Index < Table.Columns.Num(); ++Index) { Md += TEXT("---|"); } Md += TEXT("\n"); for (const TArray& Row : Table.Rows) { TArray Cells; for (const FString& Cell : Row) { Cells.Add(MdEscape(Cell)); } Md += FString::Printf(TEXT("| %s |\n"), *FString::Join(Cells, TEXT(" | "))); } 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 || F.bSuppressed) { continue; } Rows += FString::Printf( TEXT("%s
%s%s%s%s
Ignore: %s
Guide\n"), SeverityName(F.Severity), SeverityName(F.Severity), *HtmlEscape(F.CheckId), *HtmlEscape(F.Subject), *HtmlEscape(F.Message), *HtmlEscape(F.Fix), *HtmlEscape(IgnoreLine(F)), *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 v%s

"), *HtmlEscape(FApp::GetProjectName()), *HtmlEscape(When), ToolVersion); 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, and the network settings. %d findings suppressed by %s.

"), R.AbilitiesScanned, R.AbilityBlueprintsScanned, R.PlayerStatesScanned, R.AttributeSetsScanned, R.SuppressedCount(), *HtmlEscape(R.IgnoreFile)); Html += TEXT(""); Html += Rows; Html += TEXT("
SeveritySubjectProblemFix
"); Html += TEXT("

Checked a finding and it is fine? Copy its Ignore line into the ignore file, with # reason after it. * works as a wildcard.

"); if (R.SuppressedCount() > 0) { Html += FString::Printf(TEXT("
Suppressed (%d)"), R.SuppressedCount()); for (const FRSAuditFinding& F : R.Findings) { if (F.bSuppressed) { Html += FString::Printf(TEXT(""), *HtmlEscape(F.CheckId), *HtmlEscape(F.Subject), *HtmlEscape(F.SuppressReason)); } } Html += TEXT("
CheckSubjectReason
%s%s%s
"); } Html += TEXT("

Inventory

What the audit read, so you can review the setup at a glance.

"); for (const FRSAuditTable& Table : R.Inventory) { Html += FString::Printf(TEXT("
%s (%d)"), *HtmlEscape(Table.Title), Table.Rows.Num()); for (const FString& Column : Table.Columns) { Html += FString::Printf(TEXT(""), *HtmlEscape(Column)); } Html += TEXT(""); for (const TArray& Row : Table.Rows) { Html += TEXT(""); for (const FString& Cell : Row) { Html += FString::Printf(TEXT(""), *HtmlEscape(Cell)); } Html += TEXT(""); } Html += TEXT("
%s
%s
"); } 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; } static TArray> JsonStrings(const TArray& Values) { TArray> Out; for (const FString& Value : Values) { Out.Add(MakeShared(Value)); } return Out; } static FString BuildJson(const FRSAuditResult& R, const FString& When) { const TSharedRef Root = MakeShared(); Root->SetStringField(TEXT("tool"), TEXT("RS GAS Net Audit")); Root->SetStringField(TEXT("version"), ToolVersion); Root->SetStringField(TEXT("project"), FApp::GetProjectName()); Root->SetStringField(TEXT("run"), When); const TSharedRef Summary = MakeShared(); Summary->SetNumberField(TEXT("errors"), R.Count(ERSAuditSeverity::Error)); Summary->SetNumberField(TEXT("warnings"), R.Count(ERSAuditSeverity::Warning)); Summary->SetNumberField(TEXT("info"), R.Count(ERSAuditSeverity::Info)); Summary->SetNumberField(TEXT("suppressed"), R.SuppressedCount()); Summary->SetNumberField(TEXT("abilities"), R.AbilitiesScanned); Summary->SetNumberField(TEXT("abilityBlueprints"), R.AbilityBlueprintsScanned); Summary->SetNumberField(TEXT("playerStates"), R.PlayerStatesScanned); Summary->SetNumberField(TEXT("attributeSets"), R.AttributeSetsScanned); Root->SetObjectField(TEXT("summary"), Summary); TArray> Findings; for (const FRSAuditFinding& F : R.Findings) { const TSharedRef Item = MakeShared(); Item->SetStringField(TEXT("check"), F.CheckId); Item->SetStringField(TEXT("severity"), SeverityName(F.Severity)); Item->SetStringField(TEXT("subject"), F.Subject); Item->SetStringField(TEXT("message"), F.Message); Item->SetStringField(TEXT("fix"), F.Fix); Item->SetStringField(TEXT("guide"), TutorialUrl(F.Tutorial)); Item->SetBoolField(TEXT("suppressed"), F.bSuppressed); if (F.bSuppressed) { Item->SetStringField(TEXT("suppressReason"), F.SuppressReason); } Findings.Add(MakeShared(Item)); } Root->SetArrayField(TEXT("findings"), Findings); TArray> Inventory; for (const FRSAuditTable& Table : R.Inventory) { const TSharedRef Item = MakeShared(); Item->SetStringField(TEXT("title"), Table.Title); Item->SetArrayField(TEXT("columns"), JsonStrings(Table.Columns)); TArray> Rows; for (const TArray& Row : Table.Rows) { Rows.Add(MakeShared(JsonStrings(Row))); } Item->SetArrayField(TEXT("rows"), Rows); Inventory.Add(MakeShared(Item)); } Root->SetArrayField(TEXT("inventory"), Inventory); FString Out; const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Out); FJsonSerializer::Serialize(Root, Writer); return Out; } static FString XmlEscape(const FString& In) { return HtmlEscape(In).Replace(TEXT("'"), TEXT("'")); } /** JUnit XML: one test case per finding, plus one passing case per check with no findings. */ static FString BuildJUnit(const FRSAuditResult& R, ERSAuditFailOn FailOn) { FString Cases; int32 Tests = 0; TSet ChecksWithFindings; for (const FRSAuditFinding& F : R.Findings) { ++Tests; ChecksWithFindings.Add(F.CheckId); Cases += FString::Printf(TEXT(" "), *XmlEscape(F.CheckId), *XmlEscape(F.Subject)); if (F.bSuppressed) { Cases += FString::Printf(TEXT(""), *XmlEscape(F.SuppressReason)); } else if (R.IsFailing(F, FailOn)) { Cases += FString::Printf(TEXT("Fix: %s Guide: %s"), SeverityName(F.Severity), *XmlEscape(F.Message), *XmlEscape(F.Fix), *XmlEscape(TutorialUrl(F.Tutorial))); } else { Cases += FString::Printf(TEXT("%s: %s"), SeverityName(F.Severity), *XmlEscape(F.Message)); } Cases += TEXT("\n"); } for (const TCHAR* Check : AllChecks) { if (!ChecksWithFindings.Contains(Check)) { ++Tests; Cases += FString::Printf(TEXT(" \n"), Check); } } const int32 Failures = R.FailingCount(FailOn); const int32 Skipped = R.SuppressedCount(); FString Xml = TEXT("\n"); Xml += FString::Printf(TEXT("\n"), Tests, Failures, Skipped); Xml += FString::Printf(TEXT(" \n"), Tests, Failures, Skipped); Xml += Cases; Xml += TEXT(" \n\n"); return Xml; } } int32 FRSAuditResult::Count(ERSAuditSeverity Severity) const { int32 N = 0; for (const FRSAuditFinding& F : Findings) { N += (F.Severity == Severity && !F.bSuppressed) ? 1 : 0; } return N; } int32 FRSAuditResult::SuppressedCount() const { int32 N = 0; for (const FRSAuditFinding& F : Findings) { N += F.bSuppressed ? 1 : 0; } return N; } bool FRSAuditResult::IsFailing(const FRSAuditFinding& Finding, ERSAuditFailOn FailOn) const { if (Finding.bSuppressed || FailOn == ERSAuditFailOn::None) { return false; } const ERSAuditSeverity Threshold = FailOn == ERSAuditFailOn::Warning ? ERSAuditSeverity::Warning : ERSAuditSeverity::Error; return static_cast(Finding.Severity) >= static_cast(Threshold); } int32 FRSAuditResult::FailingCount(ERSAuditFailOn FailOn) const { int32 N = 0; for (const FRSAuditFinding& F : Findings) { N += IsFailing(F, FailOn) ? 1 : 0; } return N; } TArray FRSNetAuditRunner::CheckAbilityGraphs(const UBlueprint* Blueprint) { FRSAuditResult Result; if (Blueprint) { RSNetAudit::FCollector Out{ Result }; RSNetAudit::CheckAbilityGraphs(Blueprint, Out); } return Result.Findings; } FRSAuditResult FRSNetAuditRunner::Run(const FRSAuditOptions& Options) { using namespace RSNetAudit; FRSAuditResult Result; FCollector Out{ Result }; IAssetRegistry& Registry = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")).Get(); Registry.SearchAllAssets(true); FRSAuditTable NetTable{ TEXT("Network settings"), { TEXT("Setting"), TEXT("Value"), TEXT("Where") } }; CheckNetSettings(Options, Out, NetTable); FRSAuditTable AbilityTable{ TEXT("Abilities"), { TEXT("Ability"), TEXT("Type"), TEXT("Net policy"), TEXT("Instancing"), TEXT("Replicated"), TEXT("Cost"), TEXT("Cooldown"), TEXT("Triggers") } }; for (UClass* Class : GatherClasses(UGameplayAbility::StaticClass(), Options)) { CheckAbility(Class, Out, Result, AbilityTable); } FRSAuditTable PlayerStateTable{ TEXT("PlayerStates with an ASC"), { TEXT("PlayerState"), TEXT("Type"), TEXT("ASC class"), TEXT("ASC replicated"), TEXT("Replication mode"), TEXT("Net update frequency") } }; for (UClass* Class : GatherClasses(APlayerState::StaticClass(), Options)) { CheckPlayerState(Class, Options, Out, Result, PlayerStateTable); } FRSAuditTable AttributeTable{ TEXT("Attributes"), { TEXT("AttributeSet"), TEXT("Attribute"), TEXT("Replicated"), TEXT("OnRep"), TEXT("REPNOTIFY_Always") } }; FSourceIndex Sources; Sources.Build(); for (UClass* Class : GatherClasses(UAttributeSet::StaticClass(), Options)) { if (Class->HasAnyClassFlags(CLASS_Native)) { CheckAttributeSet(Class, Sources, Out, Result, AttributeTable); } } Result.Inventory = { NetTable, AbilityTable, PlayerStateTable, AttributeTable }; Result.IgnoreFile = FPaths::ConvertRelativePathToFull(Options.IgnoreFile.IsEmpty() ? FPaths::ProjectConfigDir() / TEXT("RSNetAudit.ignore") : Options.IgnoreFile); ApplySuppressions(LoadSuppressions(Result.IgnoreFile), Result); const FDateTime Now = FDateTime::Now(); const FString When = Now.ToString(TEXT("%Y-%m-%d %H:%M")); const FString Dir = FPaths::ConvertRelativePathToFull(Options.OutputDir.IsEmpty() ? FPaths::ProjectSavedDir() / TEXT("RSNetAudit") : Options.OutputDir); const FString Stamp = Now.ToString(TEXT("%Y%m%d-%H%M%S")); // Each report is written twice: a dated copy to compare runs, and -latest for links and CI. auto Save = [&Dir, &Stamp](const FString& Content, const TCHAR* Extension) -> FString { const FString Path = Dir / FString::Printf(TEXT("GASNetAudit-%s.%s"), *Stamp, Extension); FFileHelper::SaveStringToFile(Content, *Path, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); FFileHelper::SaveStringToFile(Content, *(Dir / FString::Printf(TEXT("GASNetAudit-latest.%s"), Extension)), FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); return Path; }; Result.MarkdownPath = Save(BuildMarkdown(Result, When), TEXT("md")); Result.HtmlPath = Save(BuildHtml(Result, When), TEXT("html")); Result.JsonPath = Save(BuildJson(Result, When), TEXT("json")); Result.JUnitPath = Save(BuildJUnit(Result, Options.FailOn), TEXT("junit.xml")); for (const FRSAuditFinding& F : Result.Findings) { if (F.bSuppressed) { continue; } const FString Line = FString::Printf(TEXT("[%s] %s %s: %s"), *F.CheckId, SeverityName(F.Severity), *F.Subject, *F.Message); // Error findings log as warnings on purpose: an Error log line makes Unreal // exit the commandlet with 1, which would override -FailOn. if (F.Severity != ERSAuditSeverity::Info) { 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, %d suppressed. Report: %s"), Result.Count(ERSAuditSeverity::Error), Result.Count(ERSAuditSeverity::Warning), Result.Count(ERSAuditSeverity::Info), Result.SuppressedCount(), *Result.HtmlPath); return Result; }