RSNetAuditRunner.cpp
Click a line number to link it. Shift-click to select a range.
| 1 | // Copyright (c) 2026 Reignance Studios. MIT License, see LICENSE. |
| 2 | |
| 3 | #include "RSNetAuditRunner.h" |
| 4 | |
| 5 | #include "AbilitySystemComponent.h" |
| 6 | #include "AbilitySystemInterface.h" |
| 7 | #include "Abilities/GameplayAbility.h" |
| 8 | #include "AssetRegistry/AssetRegistryModule.h" |
| 9 | #include "AssetRegistry/IAssetRegistry.h" |
| 10 | #include "AttributeSet.h" |
| 11 | #include "EdGraph/EdGraph.h" |
| 12 | #include "EdGraph/EdGraphPin.h" |
| 13 | #include "EdGraphSchema_K2.h" |
| 14 | #include "Engine/Blueprint.h" |
| 15 | #include "GameFramework/PlayerState.h" |
| 16 | #include "GameplayEffect.h" |
| 17 | #include "HAL/FileManager.h" |
| 18 | #include "Interfaces/IPluginManager.h" |
| 19 | #include "Internationalization/Regex.h" |
| 20 | #include "K2Node_BaseAsyncTask.h" |
| 21 | #include "K2Node_CallFunction.h" |
| 22 | #include "K2Node_Event.h" |
| 23 | #include "Misc/App.h" |
| 24 | #include "UObject/UObjectIterator.h" |
| 25 | #include "Misc/DateTime.h" |
| 26 | #include "Misc/EngineVersionComparison.h" |
| 27 | #include "Misc/FileHelper.h" |
| 28 | #include "Misc/PackageName.h" |
| 29 | #include "Misc/Paths.h" |
| 30 | #include "Modules/ModuleManager.h" |
| 31 | |
| 32 | DEFINE_LOG_CATEGORY_STATIC(LogRSNetAudit, Log, All); |
| 33 | |
| 34 | namespace RSNetAudit |
| 35 | { |
| 36 | static const TCHAR* SiteUrl = TEXT("https://reignancestudios.com/"); |
| 37 | |
| 38 | static const TCHAR* TutAscOnPlayerState = TEXT("tutorial-gas-asc-on-playerstate"); |
| 39 | static const TCHAR* TutFiresTwice = TEXT("tutorial-gas-ability-fires-twice"); |
| 40 | static const TCHAR* TutCostCooldown = TEXT("tutorial-gas-predicted-cost-cooldown"); |
| 41 | static const TCHAR* TutLatency = TEXT("tutorial-ue5-test-multiplayer-real-latency"); |
| 42 | |
| 43 | // ------------------------------------------------------------------ class discovery |
| 44 | |
| 45 | static bool IsProjectPackage(const FString& PackageName) |
| 46 | { |
| 47 | if (PackageName.StartsWith(TEXT("/Game/"))) |
| 48 | { |
| 49 | return true; |
| 50 | } |
| 51 | |
| 52 | if (PackageName.StartsWith(TEXT("/Script/"))) |
| 53 | { |
| 54 | // Native class: project modules are built into the project's or its plugins' Binaries. |
| 55 | const FName ModuleName(*PackageName.RightChop(8)); |
| 56 | const FString ModuleFile = FModuleManager::Get().GetModuleFilename(ModuleName); |
| 57 | if (ModuleFile.IsEmpty()) |
| 58 | { |
| 59 | return false; |
| 60 | } |
| 61 | const FString FullModule = FPaths::ConvertRelativePathToFull(ModuleFile); |
| 62 | const FString FullProject = FPaths::ConvertRelativePathToFull(FPaths::ProjectDir()); |
| 63 | return FPaths::IsUnderDirectory(FullModule, FullProject); |
| 64 | } |
| 65 | |
| 66 | // Blueprint in a plugin: /PluginName/... counts when the plugin ships with the project. |
| 67 | FString MountName; |
| 68 | if (PackageName.Split(TEXT("/"), nullptr, &MountName, ESearchCase::CaseSensitive, ESearchDir::FromStart)) |
| 69 | { |
| 70 | MountName.Split(TEXT("/"), &MountName, nullptr); |
| 71 | const TSharedPtr<IPlugin> Plugin = IPluginManager::Get().FindPlugin(MountName); |
| 72 | return Plugin.IsValid() && Plugin->GetLoadedFrom() == EPluginLoadedFrom::Project; |
| 73 | } |
| 74 | return false; |
| 75 | } |
| 76 | |
| 77 | static bool ShouldScan(const UClass* Class, const FRSAuditOptions& Options) |
| 78 | { |
| 79 | if (!Class || Class->HasAnyClassFlags(CLASS_Abstract | CLASS_Deprecated | CLASS_NewerVersionExists)) |
| 80 | { |
| 81 | return false; |
| 82 | } |
| 83 | const FString Name = Class->GetName(); |
| 84 | if (Name.StartsWith(TEXT("SKEL_")) || Name.StartsWith(TEXT("REINST_")) || Name.StartsWith(TEXT("TRASHCLASS_"))) |
| 85 | { |
| 86 | return false; |
| 87 | } |
| 88 | return Options.bIncludeEngineClasses || IsProjectPackage(Class->GetOutermost()->GetName()); |
| 89 | } |
| 90 | |
| 91 | /** Native and Blueprint classes derived from Base, loading Blueprints as needed. */ |
| 92 | static TArray<UClass*> GatherClasses(UClass* Base, const FRSAuditOptions& Options) |
| 93 | { |
| 94 | IAssetRegistry& Registry = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry")).Get(); |
| 95 | |
| 96 | // Object paths of derived Blueprint classes, e.g. /Game/GA_Dash.GA_Dash_C |
| 97 | TArray<FString> ClassPaths; |
| 98 | #if UE_VERSION_NEWER_THAN(5, 0, 99) |
| 99 | TSet<FTopLevelAssetPath> Derived; |
| 100 | Registry.GetDerivedClassNames({ Base->GetClassPathName() }, TSet<FTopLevelAssetPath>(), Derived); |
| 101 | for (const FTopLevelAssetPath& Path : Derived) |
| 102 | { |
| 103 | ClassPaths.Add(Path.ToString()); |
| 104 | } |
| 105 | #else |
| 106 | // 5.0 only returns short class names, so match them against each Blueprint's GeneratedClass tag. |
| 107 | TSet<FName> DerivedNames; |
| 108 | Registry.GetDerivedClassNames({ Base->GetFName() }, TSet<FName>(), DerivedNames); |
| 109 | |
| 110 | FARFilter Filter; |
| 111 | Filter.ClassNames.Add(UBlueprint::StaticClass()->GetFName()); |
| 112 | Filter.bRecursiveClasses = true; |
| 113 | TArray<FAssetData> Blueprints; |
| 114 | Registry.GetAssets(Filter, Blueprints); |
| 115 | for (const FAssetData& Asset : Blueprints) |
| 116 | { |
| 117 | FString GeneratedClass; |
| 118 | if (Asset.GetTagValue(FBlueprintTags::GeneratedClassPath, GeneratedClass)) |
| 119 | { |
| 120 | const FString ObjectPath = FPackageName::ExportTextPathToObjectPath(GeneratedClass); |
| 121 | if (DerivedNames.Contains(FName(*FPackageName::ObjectPathToObjectName(ObjectPath)))) |
| 122 | { |
| 123 | ClassPaths.Add(ObjectPath); |
| 124 | } |
| 125 | } |
| 126 | } |
| 127 | #endif |
| 128 | |
| 129 | TArray<UClass*> Classes; |
| 130 | for (const FString& Path : ClassPaths) |
| 131 | { |
| 132 | if (!Options.bIncludeEngineClasses && !IsProjectPackage(FPackageName::ObjectPathToPackageName(Path))) |
| 133 | { |
| 134 | continue; |
| 135 | } |
| 136 | |
| 137 | UClass* Class = FindObject<UClass>(nullptr, *Path); |
| 138 | if (!Class) |
| 139 | { |
| 140 | Class = LoadObject<UClass>(nullptr, *Path, nullptr, LOAD_NoWarn | LOAD_Quiet); |
| 141 | } |
| 142 | if (Class && Class->IsChildOf(Base) && ShouldScan(Class, Options)) |
| 143 | { |
| 144 | Classes.AddUnique(Class); |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | // Native classes are all loaded already; the registry does not always list them. |
| 149 | for (TObjectIterator<UClass> It; It; ++It) |
| 150 | { |
| 151 | UClass* Class = *It; |
| 152 | if (Class->HasAnyClassFlags(CLASS_Native) && Class->IsChildOf(Base) && ShouldScan(Class, Options)) |
| 153 | { |
| 154 | Classes.AddUnique(Class); |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | Classes.Sort([](const UClass& A, const UClass& B) { return A.GetPathName() < B.GetPathName(); }); |
| 159 | return Classes; |
| 160 | } |
| 161 | |
| 162 | // ------------------------------------------------------------------ reflection helpers |
| 163 | |
| 164 | static bool ReadBool(const UObject* Object, UClass* Owner, const TCHAR* Name, bool& bOut) |
| 165 | { |
| 166 | if (const FBoolProperty* Prop = FindFProperty<FBoolProperty>(Owner, Name)) |
| 167 | { |
| 168 | bOut = Prop->GetPropertyValue_InContainer(Object); |
| 169 | return true; |
| 170 | } |
| 171 | return false; |
| 172 | } |
| 173 | |
| 174 | static int32 ReadArrayNum(const UObject* Object, UClass* Owner, const TCHAR* Name) |
| 175 | { |
| 176 | if (const FArrayProperty* Prop = FindFProperty<FArrayProperty>(Owner, Name)) |
| 177 | { |
| 178 | FScriptArrayHelper Helper(Prop, Prop->ContainerPtrToValuePtr<void>(Object)); |
| 179 | return Helper.Num(); |
| 180 | } |
| 181 | return INDEX_NONE; |
| 182 | } |
| 183 | |
| 184 | /** |
| 185 | * ReplicationMode is protected and not reflected, so FindFProperty cannot see it. |
| 186 | * A pointer to member formed inside a derived class reads it legally on any ASC. Never instantiated. |
| 187 | */ |
| 188 | struct FRSASCAccess : public UAbilitySystemComponent |
| 189 | { |
| 190 | static EGameplayEffectReplicationMode GetMode(const UAbilitySystemComponent* ASC) |
| 191 | { |
| 192 | return ASC->*(&FRSASCAccess::ReplicationMode); |
| 193 | } |
| 194 | }; |
| 195 | |
| 196 | static EGameplayEffectReplicationMode ReadReplicationMode(const UAbilitySystemComponent* ASC) |
| 197 | { |
| 198 | return FRSASCAccess::GetMode(ASC); |
| 199 | } |
| 200 | |
| 201 | static float ReadNetUpdateFrequency(const AActor* Actor) |
| 202 | { |
| 203 | #if UE_VERSION_NEWER_THAN(5, 4, 99) |
| 204 | return Actor->GetNetUpdateFrequency(); |
| 205 | #else |
| 206 | return Actor->NetUpdateFrequency; |
| 207 | #endif |
| 208 | } |
| 209 | |
| 210 | static UAbilitySystemComponent* FindDefaultASC(AActor* CDO) |
| 211 | { |
| 212 | if (const IAbilitySystemInterface* Interface = Cast<IAbilitySystemInterface>(CDO)) |
| 213 | { |
| 214 | if (UAbilitySystemComponent* ASC = Interface->GetAbilitySystemComponent()) |
| 215 | { |
| 216 | return ASC; |
| 217 | } |
| 218 | } |
| 219 | TInlineComponentArray<UAbilitySystemComponent*> Components; |
| 220 | CDO->GetComponents(Components); |
| 221 | return Components.Num() > 0 ? Components[0] : nullptr; |
| 222 | } |
| 223 | |
| 224 | // ------------------------------------------------------------------ findings |
| 225 | |
| 226 | struct FCollector |
| 227 | { |
| 228 | FRSAuditResult& Result; |
| 229 | |
| 230 | void Add(const TCHAR* Id, ERSAuditSeverity Severity, const FString& Subject, const FString& Message, const FString& Fix, const TCHAR* Tutorial) |
| 231 | { |
| 232 | FRSAuditFinding& F = Result.Findings.AddDefaulted_GetRef(); |
| 233 | F.CheckId = Id; |
| 234 | F.Severity = Severity; |
| 235 | F.Subject = Subject; |
| 236 | F.Message = Message; |
| 237 | F.Fix = Fix; |
| 238 | F.Tutorial = Tutorial; |
| 239 | } |
| 240 | }; |
| 241 | |
| 242 | // ------------------------------------------------------------------ ability checks |
| 243 | |
| 244 | static bool IsCommitNode(const UK2Node_CallFunction* Node) |
| 245 | { |
| 246 | const FName Name = Node->FunctionReference.GetMemberName(); |
| 247 | static const FName Names[] = { |
| 248 | TEXT("K2_CommitAbility"), TEXT("K2_CommitAbilityCost"), TEXT("K2_CommitAbilityCooldown"), |
| 249 | TEXT("CommitAbility"), TEXT("CommitAbilityCost"), TEXT("CommitAbilityCooldown") |
| 250 | }; |
| 251 | for (const FName& Candidate : Names) |
| 252 | { |
| 253 | if (Name == Candidate) |
| 254 | { |
| 255 | return true; |
| 256 | } |
| 257 | } |
| 258 | return false; |
| 259 | } |
| 260 | |
| 261 | static bool IsLatentNode(const UEdGraphNode* Node, FString& OutName) |
| 262 | { |
| 263 | if (const UK2Node_BaseAsyncTask* Async = Cast<UK2Node_BaseAsyncTask>(Node)) |
| 264 | { |
| 265 | // Ability tasks (PlayMontageAndWait, WaitTargetData...) and async actions. |
| 266 | OutName = Async->GetNodeTitle(ENodeTitleType::ListView).ToString(); |
| 267 | return true; |
| 268 | } |
| 269 | if (const UK2Node_CallFunction* Call = Cast<UK2Node_CallFunction>(Node)) |
| 270 | { |
| 271 | if (Call->IsLatentFunction()) |
| 272 | { |
| 273 | OutName = Call->GetNodeTitle(ENodeTitleType::ListView).ToString(); |
| 274 | return true; |
| 275 | } |
| 276 | } |
| 277 | return false; |
| 278 | } |
| 279 | |
| 280 | /** Walks exec wires upstream from Start. Returns the first latent node found, or empty. */ |
| 281 | static FString FindLatentUpstream(UEdGraphNode* Start) |
| 282 | { |
| 283 | TArray<UEdGraphNode*> Stack; |
| 284 | TSet<UEdGraphNode*> Visited; |
| 285 | Stack.Add(Start); |
| 286 | |
| 287 | while (Stack.Num() > 0) |
| 288 | { |
| 289 | UEdGraphNode* Node = Stack.Pop(); |
| 290 | if (Visited.Contains(Node)) |
| 291 | { |
| 292 | continue; |
| 293 | } |
| 294 | Visited.Add(Node); |
| 295 | |
| 296 | FString LatentName; |
| 297 | if (Node != Start && IsLatentNode(Node, LatentName)) |
| 298 | { |
| 299 | return LatentName; |
| 300 | } |
| 301 | if (Node != Start && Node->IsA<UK2Node_Event>()) |
| 302 | { |
| 303 | continue; // reached the event that starts this chain |
| 304 | } |
| 305 | |
| 306 | for (UEdGraphPin* Pin : Node->Pins) |
| 307 | { |
| 308 | if (Pin && Pin->Direction == EGPD_Input && Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Exec) |
| 309 | { |
| 310 | for (UEdGraphPin* Linked : Pin->LinkedTo) |
| 311 | { |
| 312 | if (Linked && Linked->GetOwningNode()) |
| 313 | { |
| 314 | Stack.Add(Linked->GetOwningNode()); |
| 315 | } |
| 316 | } |
| 317 | } |
| 318 | } |
| 319 | } |
| 320 | return FString(); |
| 321 | } |
| 322 | |
| 323 | static void CheckAbilityBlueprint(UClass* Class, FCollector& Out, FRSAuditResult& Result) |
| 324 | { |
| 325 | UBlueprint* Blueprint = UBlueprint::GetBlueprintFromClass(Class); |
| 326 | if (!Blueprint) |
| 327 | { |
| 328 | return; |
| 329 | } |
| 330 | ++Result.AbilityBlueprintsScanned; |
| 331 | |
| 332 | TArray<UEdGraph*> Graphs; |
| 333 | Blueprint->GetAllGraphs(Graphs); |
| 334 | for (UEdGraph* Graph : Graphs) |
| 335 | { |
| 336 | for (UEdGraphNode* Node : Graph->Nodes) |
| 337 | { |
| 338 | const UK2Node_CallFunction* Call = Cast<UK2Node_CallFunction>(Node); |
| 339 | if (!Call || !IsCommitNode(Call)) |
| 340 | { |
| 341 | continue; |
| 342 | } |
| 343 | const FString Latent = FindLatentUpstream(Node); |
| 344 | if (!Latent.IsEmpty()) |
| 345 | { |
| 346 | Out.Add(TEXT("GA-05"), ERSAuditSeverity::Warning, Blueprint->GetPathName(), |
| 347 | 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."), |
| 348 | *Call->GetNodeTitle(ENodeTitleType::ListView).ToString(), *Graph->GetName(), *Latent), |
| 349 | 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."), |
| 350 | TutCostCooldown); |
| 351 | } |
| 352 | } |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | static void CheckAbility(UClass* Class, FCollector& Out, FRSAuditResult& Result) |
| 357 | { |
| 358 | const UGameplayAbility* CDO = Class->GetDefaultObject<UGameplayAbility>(); |
| 359 | if (!CDO) |
| 360 | { |
| 361 | return; |
| 362 | } |
| 363 | ++Result.AbilitiesScanned; |
| 364 | const FString Subject = Class->GetPathName(); |
| 365 | |
| 366 | // GA-01: net execution policy |
| 367 | const int32 TriggerCount = ReadArrayNum(CDO, UGameplayAbility::StaticClass(), TEXT("AbilityTriggers")); |
| 368 | const bool bHasTriggers = TriggerCount > 0; |
| 369 | switch (CDO->GetNetExecutionPolicy()) |
| 370 | { |
| 371 | case EGameplayAbilityNetExecutionPolicy::ServerInitiated: |
| 372 | Out.Add(TEXT("GA-01"), ERSAuditSeverity::Warning, Subject, |
| 373 | 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."), |
| 374 | TEXT("Use LocalPredicted for abilities the player activates, ServerOnly for abilities only the server starts."), |
| 375 | TutFiresTwice); |
| 376 | break; |
| 377 | case EGameplayAbilityNetExecutionPolicy::ServerOnly: |
| 378 | if (!bHasTriggers) |
| 379 | { |
| 380 | Out.Add(TEXT("GA-01"), ERSAuditSeverity::Info, Subject, |
| 381 | 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."), |
| 382 | TEXT("Keep ServerOnly for passives and AI. For player input use LocalPredicted."), |
| 383 | TutFiresTwice); |
| 384 | } |
| 385 | break; |
| 386 | case EGameplayAbilityNetExecutionPolicy::LocalOnly: |
| 387 | Out.Add(TEXT("GA-01"), ERSAuditSeverity::Info, Subject, |
| 388 | TEXT("Net Execution Policy is LocalOnly: it never runs on the server. Fine for cosmetics, wrong for anything that changes gameplay state."), |
| 389 | TEXT("If it applies effects, costs or damage, use LocalPredicted."), |
| 390 | TutFiresTwice); |
| 391 | break; |
| 392 | default: |
| 393 | break; |
| 394 | } |
| 395 | |
| 396 | // GA-02: input replication |
| 397 | bool bReplicateInput = false; |
| 398 | if (ReadBool(CDO, UGameplayAbility::StaticClass(), TEXT("bReplicateInputDirectly"), bReplicateInput) && bReplicateInput) |
| 399 | { |
| 400 | Out.Add(TEXT("GA-02"), ERSAuditSeverity::Warning, Subject, |
| 401 | TEXT("Replicate Input Directly is on. Every press and release is sent as its own RPC, outside the prediction system."), |
| 402 | TEXT("Turn it off and use the WaitInputPress / WaitInputRelease tasks, which replicate with the prediction key."), |
| 403 | TutFiresTwice); |
| 404 | } |
| 405 | |
| 406 | // GA-03: cooldown effect |
| 407 | if (const UGameplayEffect* Cooldown = CDO->GetCooldownGameplayEffect()) |
| 408 | { |
| 409 | if (Cooldown->DurationPolicy == EGameplayEffectDurationType::Instant) |
| 410 | { |
| 411 | Out.Add(TEXT("GA-03"), ERSAuditSeverity::Error, Subject, |
| 412 | FString::Printf(TEXT("Cooldown effect %s is Instant. It ends the moment it is applied, so the ability has no cooldown."), *GetNameSafe(Cooldown->GetClass())), |
| 413 | TEXT("Set the cooldown effect's Duration Policy to Has Duration."), |
| 414 | TutCostCooldown); |
| 415 | } |
| 416 | |
| 417 | const FGameplayTagContainer* CooldownTags = CDO->GetCooldownTags(); |
| 418 | if (!CooldownTags || CooldownTags->IsEmpty()) |
| 419 | { |
| 420 | Out.Add(TEXT("GA-03"), ERSAuditSeverity::Error, Subject, |
| 421 | 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())), |
| 422 | TEXT("Grant a tag such as Cooldown.<Ability> from the cooldown effect, or override GetCooldownTags if you add tags at runtime."), |
| 423 | TutCostCooldown); |
| 424 | } |
| 425 | } |
| 426 | |
| 427 | // GA-04: cost effect |
| 428 | if (const UGameplayEffect* Cost = CDO->GetCostGameplayEffect()) |
| 429 | { |
| 430 | if (Cost->DurationPolicy != EGameplayEffectDurationType::Instant) |
| 431 | { |
| 432 | Out.Add(TEXT("GA-04"), ERSAuditSeverity::Warning, Subject, |
| 433 | 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())), |
| 434 | TEXT("Make cost effects Instant. Use a separate effect for drains over time."), |
| 435 | TutCostCooldown); |
| 436 | } |
| 437 | if (Cost->Modifiers.Num() == 0 && Cost->Executions.Num() == 0) |
| 438 | { |
| 439 | Out.Add(TEXT("GA-04"), ERSAuditSeverity::Warning, Subject, |
| 440 | FString::Printf(TEXT("Cost effect %s has no modifiers or executions, so the ability is free."), *GetNameSafe(Cost->GetClass())), |
| 441 | TEXT("Add a modifier on the resource attribute, or remove the cost effect."), |
| 442 | TutCostCooldown); |
| 443 | } |
| 444 | } |
| 445 | |
| 446 | CheckAbilityBlueprint(Class, Out, Result); |
| 447 | } |
| 448 | |
| 449 | // ------------------------------------------------------------------ PlayerState checks |
| 450 | |
| 451 | static void CheckPlayerState(UClass* Class, const FRSAuditOptions& Options, FCollector& Out, FRSAuditResult& Result) |
| 452 | { |
| 453 | APlayerState* CDO = Class->GetDefaultObject<APlayerState>(); |
| 454 | UAbilitySystemComponent* ASC = CDO ? FindDefaultASC(CDO) : nullptr; |
| 455 | if (!ASC) |
| 456 | { |
| 457 | return; // not a GAS PlayerState, or the ASC is added in Blueprint (not visible on the CDO) |
| 458 | } |
| 459 | ++Result.PlayerStatesScanned; |
| 460 | const FString Subject = Class->GetPathName(); |
| 461 | |
| 462 | // PS-01: update rate |
| 463 | const float Rate = ReadNetUpdateFrequency(CDO); |
| 464 | if (Rate < Options.MinPlayerStateNetUpdateFrequency) |
| 465 | { |
| 466 | Out.Add(TEXT("PS-01"), ERSAuditSeverity::Warning, Subject, |
| 467 | 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), |
| 468 | TEXT("Raise Net Update Frequency on the PlayerState (Lyra uses 100), then profile."), |
| 469 | TutAscOnPlayerState); |
| 470 | } |
| 471 | |
| 472 | // PS-02: ASC replicated at all |
| 473 | if (!ASC->GetIsReplicated()) |
| 474 | { |
| 475 | Out.Add(TEXT("PS-02"), ERSAuditSeverity::Error, Subject, |
| 476 | TEXT("The Ability System Component on this PlayerState is not set to replicate. Clients never receive abilities, effects or tags."), |
| 477 | TEXT("Call SetIsReplicated(true) on the ASC in the PlayerState constructor."), |
| 478 | TutAscOnPlayerState); |
| 479 | } |
| 480 | |
| 481 | // PS-03: replication mode |
| 482 | const EGameplayEffectReplicationMode Mode = ReadReplicationMode(ASC); |
| 483 | if (Mode == EGameplayEffectReplicationMode::Minimal) |
| 484 | { |
| 485 | Out.Add(TEXT("PS-03"), ERSAuditSeverity::Error, Subject, |
| 486 | 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."), |
| 487 | TEXT("Use SetReplicationMode(EGameplayEffectReplicationMode::Mixed) for player ASCs. Keep Minimal for AI."), |
| 488 | TutAscOnPlayerState); |
| 489 | } |
| 490 | else if (Mode == EGameplayEffectReplicationMode::Full) |
| 491 | { |
| 492 | Out.Add(TEXT("PS-03"), ERSAuditSeverity::Warning, Subject, |
| 493 | TEXT("Replication mode is Full: every client receives every player's gameplay effects. Correct, but it costs bandwidth that grows with player count."), |
| 494 | TEXT("Use Mixed for multiplayer player ASCs. Full is meant for single-player."), |
| 495 | TutAscOnPlayerState); |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | // ------------------------------------------------------------------ AttributeSet checks |
| 500 | |
| 501 | struct FSourceIndex |
| 502 | { |
| 503 | TArray<TPair<FString, FString>> Files; // path, contents |
| 504 | |
| 505 | void Build() |
| 506 | { |
| 507 | TArray<FString> Roots = { FPaths::ProjectDir() / TEXT("Source"), FPaths::ProjectPluginsDir() }; |
| 508 | for (const FString& Root : Roots) |
| 509 | { |
| 510 | TArray<FString> Found; |
| 511 | IFileManager::Get().FindFilesRecursive(Found, *Root, TEXT("*.cpp"), true, false); |
| 512 | for (const FString& Path : Found) |
| 513 | { |
| 514 | if (Path.Contains(TEXT("/Intermediate/")) || Path.Contains(TEXT("\\Intermediate\\"))) |
| 515 | { |
| 516 | continue; |
| 517 | } |
| 518 | FString Text; |
| 519 | if (FFileHelper::LoadFileToString(Text, *Path)) |
| 520 | { |
| 521 | Files.Emplace(Path, MoveTemp(Text)); |
| 522 | } |
| 523 | } |
| 524 | } |
| 525 | } |
| 526 | |
| 527 | /** Body of ClassName::Function(...) { ... }, or empty if not found. */ |
| 528 | bool FindFunctionBody(const FString& ClassName, const FString& Function, FString& OutBody, FString& OutFile) const |
| 529 | { |
| 530 | const FRegexPattern Pattern(FString::Printf(TEXT("\\b%s\\s*::\\s*%s\\s*\\([^)]*\\)\\s*(const\\s*)?\\{"), *ClassName, *Function)); |
| 531 | for (const TPair<FString, FString>& File : Files) |
| 532 | { |
| 533 | FRegexMatcher Matcher(Pattern, File.Value); |
| 534 | if (!Matcher.FindNext()) |
| 535 | { |
| 536 | continue; |
| 537 | } |
| 538 | int32 Depth = 1; |
| 539 | const int32 Start = Matcher.GetMatchEnding(); |
| 540 | int32 Index = Start; |
| 541 | for (; Index < File.Value.Len() && Depth > 0; ++Index) |
| 542 | { |
| 543 | const TCHAR C = File.Value[Index]; |
| 544 | Depth += (C == TEXT('{')) ? 1 : (C == TEXT('}')) ? -1 : 0; |
| 545 | } |
| 546 | OutBody = File.Value.Mid(Start, Index - Start); |
| 547 | OutFile = File.Key; |
| 548 | return true; |
| 549 | } |
| 550 | return false; |
| 551 | } |
| 552 | }; |
| 553 | |
| 554 | static void CheckAttributeSet(UClass* Class, const FSourceIndex& Sources, FCollector& Out, FRSAuditResult& Result) |
| 555 | { |
| 556 | ++Result.AttributeSetsScanned; |
| 557 | const FString CppName = FString(Class->GetPrefixCPP()) + Class->GetName(); |
| 558 | |
| 559 | FString RepPropsBody, RepPropsFile; |
| 560 | const bool bHaveRepProps = Sources.FindFunctionBody(CppName, TEXT("GetLifetimeReplicatedProps"), RepPropsBody, RepPropsFile); |
| 561 | |
| 562 | for (TFieldIterator<FStructProperty> It(Class, EFieldIteratorFlags::ExcludeSuper); It; ++It) |
| 563 | { |
| 564 | const FStructProperty* Prop = *It; |
| 565 | if (Prop->Struct != FGameplayAttributeData::StaticStruct()) |
| 566 | { |
| 567 | continue; |
| 568 | } |
| 569 | const FString Attr = Prop->GetName(); |
| 570 | const FString Subject = FString::Printf(TEXT("%s.%s"), *CppName, *Attr); |
| 571 | |
| 572 | // AS-01: not replicated |
| 573 | if (!Prop->HasAnyPropertyFlags(CPF_Net)) |
| 574 | { |
| 575 | Out.Add(TEXT("AS-01"), ERSAuditSeverity::Info, Subject, |
| 576 | TEXT("Attribute is not replicated. Clients always see its default value."), |
| 577 | TEXT("Fine for server-only meta attributes such as incoming damage. Otherwise add ReplicatedUsing=OnRep_<Name>."), |
| 578 | TutCostCooldown); |
| 579 | continue; |
| 580 | } |
| 581 | |
| 582 | // AS-02: replicated without an OnRep |
| 583 | if (Prop->RepNotifyFunc.IsNone()) |
| 584 | { |
| 585 | Out.Add(TEXT("AS-02"), ERSAuditSeverity::Warning, Subject, |
| 586 | 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."), |
| 587 | TEXT("Use ReplicatedUsing=OnRep_<Name> and call GAMEPLAYATTRIBUTE_REPNOTIFY in it."), |
| 588 | TutCostCooldown); |
| 589 | } |
| 590 | else |
| 591 | { |
| 592 | FString Body, File; |
| 593 | if (Sources.FindFunctionBody(CppName, Prop->RepNotifyFunc.ToString(), Body, File)) |
| 594 | { |
| 595 | if (!Body.Contains(TEXT("GAMEPLAYATTRIBUTE_REPNOTIFY"))) |
| 596 | { |
| 597 | Out.Add(TEXT("AS-02"), ERSAuditSeverity::Warning, Subject, |
| 598 | 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)), |
| 599 | TEXT("Call GAMEPLAYATTRIBUTE_REPNOTIFY(ClassName, Attribute, OldValue) as the first line of the OnRep."), |
| 600 | TutCostCooldown); |
| 601 | } |
| 602 | } |
| 603 | } |
| 604 | |
| 605 | // AS-03: REPNOTIFY_Always |
| 606 | if (bHaveRepProps) |
| 607 | { |
| 608 | const FRegexPattern Pattern(FString::Printf(TEXT("DOREPLIFETIME\\w*\\s*\\(\\s*%s\\s*,\\s*%s\\s*(,[^;]*)?\\)"), *CppName, *Attr)); |
| 609 | FRegexMatcher Matcher(Pattern, RepPropsBody); |
| 610 | if (Matcher.FindNext()) |
| 611 | { |
| 612 | const FString Line = RepPropsBody.Mid(Matcher.GetMatchBeginning(), Matcher.GetMatchEnding() - Matcher.GetMatchBeginning()); |
| 613 | if (!Line.Contains(TEXT("REPNOTIFY_Always"))) |
| 614 | { |
| 615 | Out.Add(TEXT("AS-03"), ERSAuditSeverity::Warning, Subject, |
| 616 | 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."), |
| 617 | TEXT("Use DOREPLIFETIME_CONDITION_NOTIFY(Class, Attribute, COND_None, REPNOTIFY_Always)."), |
| 618 | TutCostCooldown); |
| 619 | } |
| 620 | } |
| 621 | } |
| 622 | } |
| 623 | |
| 624 | if (!bHaveRepProps) |
| 625 | { |
| 626 | Out.Add(TEXT("AS-03"), ERSAuditSeverity::Info, CppName, |
| 627 | TEXT("Could not find GetLifetimeReplicatedProps in the project source, so OnRep conditions were not checked."), |
| 628 | TEXT("Check by hand that every replicated attribute uses REPNOTIFY_Always."), |
| 629 | TutCostCooldown); |
| 630 | } |
| 631 | } |
| 632 | |
| 633 | // ------------------------------------------------------------------ report |
| 634 | |
| 635 | static const TCHAR* SeverityName(ERSAuditSeverity Severity) |
| 636 | { |
| 637 | switch (Severity) |
| 638 | { |
| 639 | case ERSAuditSeverity::Error: return TEXT("Error"); |
| 640 | case ERSAuditSeverity::Warning: return TEXT("Warning"); |
| 641 | default: return TEXT("Info"); |
| 642 | } |
| 643 | } |
| 644 | |
| 645 | static FString HtmlEscape(const FString& In) |
| 646 | { |
| 647 | return In.Replace(TEXT("&"), TEXT("&")).Replace(TEXT("<"), TEXT("<")).Replace(TEXT(">"), TEXT(">")).Replace(TEXT("\""), TEXT(""")); |
| 648 | } |
| 649 | |
| 650 | static FString MdEscape(const FString& In) |
| 651 | { |
| 652 | return In.Replace(TEXT("|"), TEXT("\\|")).Replace(TEXT("\n"), TEXT(" ")); |
| 653 | } |
| 654 | |
| 655 | static FString TutorialUrl(const FString& Slug) |
| 656 | { |
| 657 | return FString(SiteUrl) + Slug + TEXT("?src=netaudit"); |
| 658 | } |
| 659 | |
| 660 | static const TCHAR* CannotSee = |
| 661 | TEXT("Static checks cannot see: abilities activated from both input and a trigger, C++ abilities that commit after a latent task, ") |
| 662 | TEXT("side effects spawned on both client and server, InitAbilityActorInfo missing on the client, abilities granted twice on respawn, ") |
| 663 | TEXT("or anything that only breaks at real latency."); |
| 664 | |
| 665 | static FString BuildMarkdown(const FRSAuditResult& R, const FString& When) |
| 666 | { |
| 667 | FString Md; |
| 668 | Md += TEXT("# GAS Net Audit report\n\n"); |
| 669 | 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); |
| 670 | Md += FString::Printf(TEXT("Scanned %d abilities (%d Blueprint graphs), %d PlayerStates with an ASC, %d AttributeSets.\n\n"), |
| 671 | R.AbilitiesScanned, R.AbilityBlueprintsScanned, R.PlayerStatesScanned, R.AttributeSetsScanned); |
| 672 | Md += FString::Printf(TEXT("**%d errors, %d warnings, %d info.**\n\n"), R.Count(ERSAuditSeverity::Error), R.Count(ERSAuditSeverity::Warning), R.Count(ERSAuditSeverity::Info)); |
| 673 | |
| 674 | for (ERSAuditSeverity Severity : { ERSAuditSeverity::Error, ERSAuditSeverity::Warning, ERSAuditSeverity::Info }) |
| 675 | { |
| 676 | if (R.Count(Severity) == 0) |
| 677 | { |
| 678 | continue; |
| 679 | } |
| 680 | Md += FString::Printf(TEXT("## %ss\n\n| Check | Subject | Problem | Fix | Guide |\n|---|---|---|---|---|\n"), SeverityName(Severity)); |
| 681 | for (const FRSAuditFinding& F : R.Findings) |
| 682 | { |
| 683 | if (F.Severity == Severity) |
| 684 | { |
| 685 | Md += FString::Printf(TEXT("| %s | `%s` | %s | %s | [read](%s) |\n"), |
| 686 | *F.CheckId, *MdEscape(F.Subject), *MdEscape(F.Message), *MdEscape(F.Fix), *TutorialUrl(F.Tutorial)); |
| 687 | } |
| 688 | } |
| 689 | Md += TEXT("\n"); |
| 690 | } |
| 691 | |
| 692 | Md += TEXT("## What this audit cannot see\n\n"); |
| 693 | Md += FString(CannotSee) + TEXT("\n\n"); |
| 694 | Md += FString::Printf(TEXT("Test at real latency: %s\n\n"), *TutorialUrl(TutLatency)); |
| 695 | 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"); |
| 696 | return Md; |
| 697 | } |
| 698 | |
| 699 | static FString BuildHtml(const FRSAuditResult& R, const FString& When) |
| 700 | { |
| 701 | FString Rows; |
| 702 | for (ERSAuditSeverity Severity : { ERSAuditSeverity::Error, ERSAuditSeverity::Warning, ERSAuditSeverity::Info }) |
| 703 | { |
| 704 | for (const FRSAuditFinding& F : R.Findings) |
| 705 | { |
| 706 | if (F.Severity != Severity) |
| 707 | { |
| 708 | continue; |
| 709 | } |
| 710 | Rows += FString::Printf( |
| 711 | TEXT("<tr class=\"%s\"><td><b>%s</b><br>%s</td><td><code>%s</code></td><td>%s</td><td>%s</td><td><a href=\"%s\">Guide</a></td></tr>\n"), |
| 712 | SeverityName(F.Severity), SeverityName(F.Severity), *HtmlEscape(F.CheckId), *HtmlEscape(F.Subject), |
| 713 | *HtmlEscape(F.Message), *HtmlEscape(F.Fix), *HtmlEscape(TutorialUrl(F.Tutorial))); |
| 714 | } |
| 715 | } |
| 716 | if (Rows.IsEmpty()) |
| 717 | { |
| 718 | Rows = TEXT("<tr><td colspan=\"5\">No findings. Now test at real latency: static checks cannot see everything.</td></tr>"); |
| 719 | } |
| 720 | |
| 721 | FString Html; |
| 722 | Html += TEXT("<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"); |
| 723 | Html += FString::Printf(TEXT("<title>GAS Net Audit: %s</title>"), *HtmlEscape(FApp::GetProjectName())); |
| 724 | Html += TEXT("<style>body{font:15px/1.55 system-ui,sans-serif;margin:0;padding:24px;color:#0B0D11;background:#fff}") |
| 725 | TEXT("h1{font-size:28px;margin:0 0 4px}p{max-width:72ch}table{border-collapse:collapse;width:100%;margin:16px 0}") |
| 726 | TEXT("th,td{border-bottom:1px solid #DFE2E6;padding:10px 8px;text-align:left;vertical-align:top}th{font-size:13px;color:#565D68}") |
| 727 | TEXT("code{font:13px ui-monospace,Consolas,monospace;word-break:break-all}tr.Error td:first-child{border-left:4px solid #A1260F}") |
| 728 | TEXT("tr.Warning td:first-child{border-left:4px solid #757C86}tr.Info td:first-child{border-left:4px solid #DFE2E6}") |
| 729 | TEXT(".sum{font-size:18px;font-weight:600}.cta{margin-top:24px;padding:16px;background:#ECEEF1}a{color:#0B0D11}") |
| 730 | TEXT("@media(prefers-color-scheme:dark){body{background:#0B0D11;color:#ECEEF1}th,td{border-color:#23282F}.cta{background:#14171D}a{color:#ECEEF1}}</style></head><body>"); |
| 731 | Html += FString::Printf(TEXT("<h1>GAS Net Audit</h1><p>%s · %s · RS GAS Net Audit v0.1.0</p>"), *HtmlEscape(FApp::GetProjectName()), *HtmlEscape(When)); |
| 732 | Html += FString::Printf(TEXT("<p class=\"sum\">%d errors, %d warnings, %d info</p>"), R.Count(ERSAuditSeverity::Error), R.Count(ERSAuditSeverity::Warning), R.Count(ERSAuditSeverity::Info)); |
| 733 | Html += FString::Printf(TEXT("<p>Scanned %d abilities (%d Blueprint graphs), %d PlayerStates with an ASC, %d AttributeSets.</p>"), |
| 734 | R.AbilitiesScanned, R.AbilityBlueprintsScanned, R.PlayerStatesScanned, R.AttributeSetsScanned); |
| 735 | Html += TEXT("<table><thead><tr><th>Severity</th><th>Subject</th><th>Problem</th><th>Fix</th><th></th></tr></thead><tbody>"); |
| 736 | Html += Rows; |
| 737 | Html += TEXT("</tbody></table>"); |
| 738 | Html += FString::Printf(TEXT("<h2>What this audit cannot see</h2><p>%s <a href=\"%s\">Test at real latency</a>.</p>"), CannotSee, *HtmlEscape(TutorialUrl(TutLatency))); |
| 739 | Html += TEXT("<div class=\"cta\"><p><b>Still desyncing in packaged builds?</b> A Diagnostic finds the cause across your project in two days.</p>") |
| 740 | TEXT("<p><a href=\"https://reignancestudios.com/gameplay-ability-system-cost?src=netaudit#diagnostic\">Request a Diagnostic</a> · ") |
| 741 | TEXT("<a href=\"https://calendly.com/contact-reignancestudios/new-meeting\">Book a free teardown</a></p></div>"); |
| 742 | Html += TEXT("</body></html>\n"); |
| 743 | return Html; |
| 744 | } |
| 745 | } |
| 746 | |
| 747 | int32 FRSAuditResult::Count(ERSAuditSeverity Severity) const |
| 748 | { |
| 749 | int32 N = 0; |
| 750 | for (const FRSAuditFinding& F : Findings) |
| 751 | { |
| 752 | N += F.Severity == Severity ? 1 : 0; |
| 753 | } |
| 754 | return N; |
| 755 | } |
| 756 | |
| 757 | FRSAuditResult FRSNetAuditRunner::Run(const FRSAuditOptions& Options) |
| 758 | { |
| 759 | using namespace RSNetAudit; |
| 760 | |
| 761 | FRSAuditResult Result; |
| 762 | FCollector Out{ Result }; |
| 763 | |
| 764 | IAssetRegistry& Registry = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry")).Get(); |
| 765 | Registry.SearchAllAssets(true); |
| 766 | |
| 767 | for (UClass* Class : GatherClasses(UGameplayAbility::StaticClass(), Options)) |
| 768 | { |
| 769 | CheckAbility(Class, Out, Result); |
| 770 | } |
| 771 | |
| 772 | for (UClass* Class : GatherClasses(APlayerState::StaticClass(), Options)) |
| 773 | { |
| 774 | CheckPlayerState(Class, Options, Out, Result); |
| 775 | } |
| 776 | |
| 777 | FSourceIndex Sources; |
| 778 | Sources.Build(); |
| 779 | for (UClass* Class : GatherClasses(UAttributeSet::StaticClass(), Options)) |
| 780 | { |
| 781 | if (Class->HasAnyClassFlags(CLASS_Native)) |
| 782 | { |
| 783 | CheckAttributeSet(Class, Sources, Out, Result); |
| 784 | } |
| 785 | } |
| 786 | |
| 787 | const FDateTime Now = FDateTime::Now(); |
| 788 | const FString When = Now.ToString(TEXT("%Y-%m-%d %H:%M")); |
| 789 | const FString Dir = Options.OutputDir.IsEmpty() ? FPaths::ProjectSavedDir() / TEXT("RSNetAudit") : Options.OutputDir; |
| 790 | const FString Stamp = Now.ToString(TEXT("%Y%m%d-%H%M%S")); |
| 791 | |
| 792 | Result.MarkdownPath = FPaths::ConvertRelativePathToFull(Dir / FString::Printf(TEXT("GASNetAudit-%s.md"), *Stamp)); |
| 793 | Result.HtmlPath = FPaths::ConvertRelativePathToFull(Dir / FString::Printf(TEXT("GASNetAudit-%s.html"), *Stamp)); |
| 794 | |
| 795 | const FString Markdown = BuildMarkdown(Result, When); |
| 796 | const FString Html = BuildHtml(Result, When); |
| 797 | FFileHelper::SaveStringToFile(Markdown, *Result.MarkdownPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); |
| 798 | FFileHelper::SaveStringToFile(Html, *Result.HtmlPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); |
| 799 | FFileHelper::SaveStringToFile(Markdown, *(Dir / TEXT("GASNetAudit-latest.md")), FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); |
| 800 | FFileHelper::SaveStringToFile(Html, *(Dir / TEXT("GASNetAudit-latest.html")), FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); |
| 801 | |
| 802 | for (const FRSAuditFinding& F : Result.Findings) |
| 803 | { |
| 804 | const FString Line = FString::Printf(TEXT("[%s] %s %s: %s"), *F.CheckId, SeverityName(F.Severity), *F.Subject, *F.Message); |
| 805 | if (F.Severity == ERSAuditSeverity::Error) |
| 806 | { |
| 807 | UE_LOG(LogRSNetAudit, Error, TEXT("%s"), *Line); |
| 808 | } |
| 809 | else if (F.Severity == ERSAuditSeverity::Warning) |
| 810 | { |
| 811 | UE_LOG(LogRSNetAudit, Warning, TEXT("%s"), *Line); |
| 812 | } |
| 813 | else |
| 814 | { |
| 815 | UE_LOG(LogRSNetAudit, Display, TEXT("%s"), *Line); |
| 816 | } |
| 817 | } |
| 818 | UE_LOG(LogRSNetAudit, Display, TEXT("GAS Net Audit: %d errors, %d warnings, %d info. Report: %s"), |
| 819 | Result.Count(ERSAuditSeverity::Error), Result.Count(ERSAuditSeverity::Warning), Result.Count(ERSAuditSeverity::Info), *Result.HtmlPath); |
| 820 | |
| 821 | return Result; |
| 822 | } |