Let's talk

RSNetAuditRunner.cpp

Source/RSNetAudit/Private/RSNetAuditRunner.cpp Raw Download ZIP

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 "Dom/JsonObject.h"
12#include "EdGraph/EdGraph.h"
13#include "EdGraph/EdGraphPin.h"
14#include "EdGraphSchema_K2.h"
15#include "Engine/Blueprint.h"
16#include "Engine/Engine.h"
17#include "Engine/LocalPlayer.h"
18#include "Engine/NetDriver.h"
19#include "GameFramework/PlayerState.h"
20#include "GameplayEffect.h"
21#include "HAL/FileManager.h"
22#include "Interfaces/IPluginManager.h"
23#include "Internationalization/Regex.h"
24#include "K2Node_BaseAsyncTask.h"
25#include "K2Node_CallFunction.h"
26#include "K2Node_Event.h"
27#include "Misc/App.h"
28#include "UObject/UObjectIterator.h"
29#include "Misc/DateTime.h"
30#include "Misc/EngineVersionComparison.h"
31#include "Misc/FileHelper.h"
32#include "Misc/PackageName.h"
33#include "Misc/Paths.h"
34#include "Modules/ModuleManager.h"
35#include "Serialization/JsonSerializer.h"
36#include "Serialization/JsonWriter.h"
37
38DEFINE_LOG_CATEGORY_STATIC(LogRSNetAudit, Log, All);
39
40namespace RSNetAudit
41{
42 static const TCHAR* ToolVersion = TEXT("0.2.0");
43 static const TCHAR* SiteUrl = TEXT("https://reignancestudios.com/");
44
45 /** Every check id, so the JUnit report can list the ones that passed. */
46 static const TCHAR* AllChecks[] = {
47 TEXT("GA-01"), TEXT("GA-02"), TEXT("GA-03"), TEXT("GA-04"), TEXT("GA-05"), TEXT("GA-06"), TEXT("GA-07"),
48 TEXT("PS-01"), TEXT("PS-02"), TEXT("PS-03"),
49 TEXT("AS-01"), TEXT("AS-02"), TEXT("AS-03"), TEXT("AS-04"),
50 TEXT("NET-01")
51 };
52
53 static const TCHAR* TutAscOnPlayerState = TEXT("tutorial-gas-asc-on-playerstate");
54 static const TCHAR* TutFiresTwice = TEXT("tutorial-gas-ability-fires-twice");
55 static const TCHAR* TutCostCooldown = TEXT("tutorial-gas-predicted-cost-cooldown");
56 static const TCHAR* TutLatency = TEXT("tutorial-ue5-test-multiplayer-real-latency");
57
58 // ------------------------------------------------------------------ class discovery
59
60 static bool IsProjectPackage(const FString& PackageName)
61 {
62 if (PackageName.StartsWith(TEXT("/Game/")))
63 {
64 return true;
65 }
66
67 if (PackageName.StartsWith(TEXT("/Script/")))
68 {
69 // Native class: project modules are built into the project's or its plugins' Binaries.
70 const FName ModuleName(*PackageName.RightChop(8));
71 const FString ModuleFile = FModuleManager::Get().GetModuleFilename(ModuleName);
72 if (ModuleFile.IsEmpty())
73 {
74 return false;
75 }
76 const FString FullModule = FPaths::ConvertRelativePathToFull(ModuleFile);
77 const FString FullProject = FPaths::ConvertRelativePathToFull(FPaths::ProjectDir());
78 return FPaths::IsUnderDirectory(FullModule, FullProject);
79 }
80
81 // Blueprint in a plugin: /PluginName/... counts when the plugin ships with the project.
82 FString MountName;
83 if (PackageName.Split(TEXT("/"), nullptr, &MountName, ESearchCase::CaseSensitive, ESearchDir::FromStart))
84 {
85 MountName.Split(TEXT("/"), &MountName, nullptr);
86 const TSharedPtr<IPlugin> Plugin = IPluginManager::Get().FindPlugin(MountName);
87 return Plugin.IsValid() && Plugin->GetLoadedFrom() == EPluginLoadedFrom::Project;
88 }
89 return false;
90 }
91
92 static bool ShouldScan(const UClass* Class, const FRSAuditOptions& Options)
93 {
94 if (!Class || Class->HasAnyClassFlags(CLASS_Abstract | CLASS_Deprecated | CLASS_NewerVersionExists))
95 {
96 return false;
97 }
98 const FString Name = Class->GetName();
99 if (Name.StartsWith(TEXT("SKEL_")) || Name.StartsWith(TEXT("REINST_")) || Name.StartsWith(TEXT("TRASHCLASS_")))
100 {
101 return false;
102 }
103 return Options.bIncludeEngineClasses || IsProjectPackage(Class->GetOutermost()->GetName());
104 }
105
106 /** Native and Blueprint classes derived from Base, loading Blueprints as needed. */
107 static TArray<UClass*> GatherClasses(UClass* Base, const FRSAuditOptions& Options)
108 {
109 IAssetRegistry& Registry = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry")).Get();
110
111 // Object paths of derived Blueprint classes, e.g. /Game/GA_Dash.GA_Dash_C
112 TArray<FString> ClassPaths;
113#if UE_VERSION_NEWER_THAN(5, 0, 99)
114 TSet<FTopLevelAssetPath> Derived;
115 Registry.GetDerivedClassNames({ Base->GetClassPathName() }, TSet<FTopLevelAssetPath>(), Derived);
116 for (const FTopLevelAssetPath& Path : Derived)
117 {
118 ClassPaths.Add(Path.ToString());
119 }
120#else
121 // 5.0 only returns short class names, so match them against each Blueprint's GeneratedClass tag.
122 TSet<FName> DerivedNames;
123 Registry.GetDerivedClassNames({ Base->GetFName() }, TSet<FName>(), DerivedNames);
124
125 FARFilter Filter;
126 Filter.ClassNames.Add(UBlueprint::StaticClass()->GetFName());
127 Filter.bRecursiveClasses = true;
128 TArray<FAssetData> Blueprints;
129 Registry.GetAssets(Filter, Blueprints);
130 for (const FAssetData& Asset : Blueprints)
131 {
132 FString GeneratedClass;
133 if (Asset.GetTagValue(FBlueprintTags::GeneratedClassPath, GeneratedClass))
134 {
135 const FString ObjectPath = FPackageName::ExportTextPathToObjectPath(GeneratedClass);
136 if (DerivedNames.Contains(FName(*FPackageName::ObjectPathToObjectName(ObjectPath))))
137 {
138 ClassPaths.Add(ObjectPath);
139 }
140 }
141 }
142#endif
143
144 TArray<UClass*> Classes;
145 for (const FString& Path : ClassPaths)
146 {
147 if (!Options.bIncludeEngineClasses && !IsProjectPackage(FPackageName::ObjectPathToPackageName(Path)))
148 {
149 continue;
150 }
151
152 UClass* Class = FindObject<UClass>(nullptr, *Path);
153 if (!Class)
154 {
155 Class = LoadObject<UClass>(nullptr, *Path, nullptr, LOAD_NoWarn | LOAD_Quiet);
156 }
157 if (Class && Class->IsChildOf(Base) && ShouldScan(Class, Options))
158 {
159 Classes.AddUnique(Class);
160 }
161 }
162
163 // Native classes are all loaded already; the registry does not always list them.
164 for (TObjectIterator<UClass> It; It; ++It)
165 {
166 UClass* Class = *It;
167 if (Class->HasAnyClassFlags(CLASS_Native) && Class->IsChildOf(Base) && ShouldScan(Class, Options))
168 {
169 Classes.AddUnique(Class);
170 }
171 }
172
173 Classes.Sort([](const UClass& A, const UClass& B) { return A.GetPathName() < B.GetPathName(); });
174 return Classes;
175 }
176
177 // ------------------------------------------------------------------ reflection helpers
178
179 static bool ReadBool(const UObject* Object, UClass* Owner, const TCHAR* Name, bool& bOut)
180 {
181 if (const FBoolProperty* Prop = FindFProperty<FBoolProperty>(Owner, Name))
182 {
183 bOut = Prop->GetPropertyValue_InContainer(Object);
184 return true;
185 }
186 return false;
187 }
188
189 static int32 ReadInt(const UObject* Object, const TCHAR* Name)
190 {
191 if (Object)
192 {
193 if (const FIntProperty* Prop = FindFProperty<FIntProperty>(Object->GetClass(), Name))
194 {
195 return Prop->GetPropertyValue_InContainer(Object);
196 }
197 }
198 return INDEX_NONE;
199 }
200
201 struct FTriggerInfo
202 {
203 int32 Num = 0;
204 bool bTagTrigger = false;
205 FString Description; // "Event Ability.Jump, TagAdded State.Stunned"
206 };
207
208 /** AbilityTriggers is protected, so read it through reflection. */
209 static FTriggerInfo ReadTriggers(const UGameplayAbility* CDO)
210 {
211 FTriggerInfo Info;
212 const FArrayProperty* Prop = FindFProperty<FArrayProperty>(UGameplayAbility::StaticClass(), TEXT("AbilityTriggers"));
213 const FStructProperty* Inner = Prop ? CastField<FStructProperty>(Prop->Inner) : nullptr;
214 if (!Inner || Inner->Struct != FAbilityTriggerData::StaticStruct())
215 {
216 return Info;
217 }
218
219 FScriptArrayHelper Helper(Prop, Prop->ContainerPtrToValuePtr<void>(CDO));
220 TArray<FString> Parts;
221 for (int32 Index = 0; Index < Helper.Num(); ++Index)
222 {
223 const FAbilityTriggerData* Trigger = reinterpret_cast<const FAbilityTriggerData*>(Helper.GetRawPtr(Index));
224 const TCHAR* Source = TEXT("Event");
225 switch (static_cast<EGameplayAbilityTriggerSource::Type>(Trigger->TriggerSource))
226 {
227 case EGameplayAbilityTriggerSource::OwnedTagAdded:
228 Source = TEXT("TagAdded");
229 Info.bTagTrigger = true;
230 break;
231 case EGameplayAbilityTriggerSource::OwnedTagPresent:
232 Source = TEXT("TagPresent");
233 Info.bTagTrigger = true;
234 break;
235 default:
236 break;
237 }
238 Parts.Add(FString::Printf(TEXT("%s %s"), Source, *Trigger->TriggerTag.ToString()));
239 }
240 Info.Num = Parts.Num();
241 Info.Description = FString::Join(Parts, TEXT(", "));
242 return Info;
243 }
244
245 /**
246 * ReplicationMode is protected and not reflected, so FindFProperty cannot see it.
247 * A pointer to member formed inside a derived class reads it legally on any ASC. Never instantiated.
248 */
249 struct FRSASCAccess : public UAbilitySystemComponent
250 {
251 static EGameplayEffectReplicationMode GetMode(const UAbilitySystemComponent* ASC)
252 {
253 return ASC->*(&FRSASCAccess::ReplicationMode);
254 }
255 };
256
257 static EGameplayEffectReplicationMode ReadReplicationMode(const UAbilitySystemComponent* ASC)
258 {
259 return FRSASCAccess::GetMode(ASC);
260 }
261
262 static float ReadNetUpdateFrequency(const AActor* Actor)
263 {
264#if UE_VERSION_NEWER_THAN(5, 4, 99)
265 return Actor->GetNetUpdateFrequency();
266#else
267 return Actor->NetUpdateFrequency;
268#endif
269 }
270
271 static UAbilitySystemComponent* FindDefaultASC(AActor* CDO)
272 {
273 if (const IAbilitySystemInterface* Interface = Cast<IAbilitySystemInterface>(CDO))
274 {
275 if (UAbilitySystemComponent* ASC = Interface->GetAbilitySystemComponent())
276 {
277 return ASC;
278 }
279 }
280 TInlineComponentArray<UAbilitySystemComponent*> Components;
281 CDO->GetComponents(Components);
282 return Components.Num() > 0 ? Components[0] : nullptr;
283 }
284
285 static const TCHAR* NetPolicyName(const UGameplayAbility* CDO)
286 {
287 switch (CDO->GetNetExecutionPolicy())
288 {
289 case EGameplayAbilityNetExecutionPolicy::LocalPredicted: return TEXT("LocalPredicted");
290 case EGameplayAbilityNetExecutionPolicy::LocalOnly: return TEXT("LocalOnly");
291 case EGameplayAbilityNetExecutionPolicy::ServerInitiated: return TEXT("ServerInitiated");
292 case EGameplayAbilityNetExecutionPolicy::ServerOnly: return TEXT("ServerOnly");
293 default: return TEXT("?");
294 }
295 }
296
297 static const TCHAR* InstancingName(const UGameplayAbility* CDO)
298 {
299 switch (CDO->GetInstancingPolicy())
300 {
301 case EGameplayAbilityInstancingPolicy::InstancedPerActor: return TEXT("PerActor");
302 case EGameplayAbilityInstancingPolicy::InstancedPerExecution: return TEXT("PerExecution");
303 default: return TEXT("NonInstanced");
304 }
305 }
306
307 static const TCHAR* ReplicationModeName(EGameplayEffectReplicationMode Mode)
308 {
309 switch (Mode)
310 {
311 case EGameplayEffectReplicationMode::Minimal: return TEXT("Minimal");
312 case EGameplayEffectReplicationMode::Mixed: return TEXT("Mixed");
313 case EGameplayEffectReplicationMode::Full: return TEXT("Full");
314 default: return TEXT("?");
315 }
316 }
317
318 static FString IntCell(int32 Value)
319 {
320 return Value == INDEX_NONE ? FString(TEXT("?")) : FString::FromInt(Value);
321 }
322
323 // ------------------------------------------------------------------ findings
324
325 struct FCollector
326 {
327 FRSAuditResult& Result;
328
329 void Add(const TCHAR* Id, ERSAuditSeverity Severity, const FString& Subject, const FString& Message, const FString& Fix, const TCHAR* Tutorial)
330 {
331 FRSAuditFinding& F = Result.Findings.AddDefaulted_GetRef();
332 F.CheckId = Id;
333 F.Severity = Severity;
334 F.Subject = Subject;
335 F.Message = Message;
336 F.Fix = Fix;
337 F.Tutorial = Tutorial;
338 }
339 };
340
341 // ------------------------------------------------------------------ ability checks
342
343 static bool IsCommitNode(const UK2Node_CallFunction* Node)
344 {
345 const FName Name = Node->FunctionReference.GetMemberName();
346 static const FName Names[] = {
347 TEXT("K2_CommitAbility"), TEXT("K2_CommitAbilityCost"), TEXT("K2_CommitAbilityCooldown"),
348 TEXT("CommitAbility"), TEXT("CommitAbilityCost"), TEXT("CommitAbilityCooldown")
349 };
350 for (const FName& Candidate : Names)
351 {
352 if (Name == Candidate)
353 {
354 return true;
355 }
356 }
357 return false;
358 }
359
360 static bool IsLatentNode(const UEdGraphNode* Node, FString& OutName)
361 {
362 if (const UK2Node_BaseAsyncTask* Async = Cast<UK2Node_BaseAsyncTask>(Node))
363 {
364 // Ability tasks (PlayMontageAndWait, WaitTargetData...) and async actions.
365 OutName = Async->GetNodeTitle(ENodeTitleType::ListView).ToString();
366 return true;
367 }
368 if (const UK2Node_CallFunction* Call = Cast<UK2Node_CallFunction>(Node))
369 {
370 if (Call->IsLatentFunction())
371 {
372 OutName = Call->GetNodeTitle(ENodeTitleType::ListView).ToString();
373 return true;
374 }
375 }
376 return false;
377 }
378
379 /**
380 * Walks exec wires upstream from Start. Returns the first latent node found, or empty.
381 * An async task's "then" pin fires right after the task starts, inside the same
382 * prediction window, so reaching a task through it does not count as latent.
383 */
384 static FString FindLatentUpstream(UEdGraphNode* Start)
385 {
386 // Node plus the output pin the walk arrived through (null for Start).
387 TArray<TPair<UEdGraphNode*, const UEdGraphPin*>> Stack;
388 TSet<TPair<UEdGraphNode*, bool>> Visited;
389 Stack.Emplace(Start, nullptr);
390
391 while (Stack.Num() > 0)
392 {
393 const TPair<UEdGraphNode*, const UEdGraphPin*> Item = Stack.Pop();
394 UEdGraphNode* Node = Item.Key;
395 const bool bViaImmediateThen = Item.Value && Node->IsA<UK2Node_BaseAsyncTask>() && Item.Value->PinName == UEdGraphSchema_K2::PN_Then;
396 if (Visited.Contains({ Node, bViaImmediateThen }))
397 {
398 continue;
399 }
400 Visited.Add({ Node, bViaImmediateThen });
401
402 FString LatentName;
403 if (Node != Start && !bViaImmediateThen && IsLatentNode(Node, LatentName))
404 {
405 return LatentName;
406 }
407 if (Node != Start && Node->IsA<UK2Node_Event>())
408 {
409 continue; // reached the event that starts this chain
410 }
411
412 for (UEdGraphPin* Pin : Node->Pins)
413 {
414 if (Pin && Pin->Direction == EGPD_Input && Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Exec)
415 {
416 for (UEdGraphPin* Linked : Pin->LinkedTo)
417 {
418 if (Linked && Linked->GetOwningNode())
419 {
420 Stack.Emplace(Linked->GetOwningNode(), Linked);
421 }
422 }
423 }
424 }
425 }
426 return FString();
427 }
428
429 static void CheckAbilityGraphs(const UBlueprint* Blueprint, FCollector& Out)
430 {
431 TArray<UEdGraph*> Graphs;
432 Blueprint->GetAllGraphs(Graphs);
433 for (UEdGraph* Graph : Graphs)
434 {
435 for (UEdGraphNode* Node : Graph->Nodes)
436 {
437 const UK2Node_CallFunction* Call = Cast<UK2Node_CallFunction>(Node);
438 if (!Call || !IsCommitNode(Call))
439 {
440 continue;
441 }
442 const FString Latent = FindLatentUpstream(Node);
443 if (!Latent.IsEmpty())
444 {
445 Out.Add(TEXT("GA-05"), ERSAuditSeverity::Warning, Blueprint->GetPathName(),
446 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."),
447 *Call->GetNodeTitle(ENodeTitleType::ListView).ToString(), *Graph->GetName(), *Latent),
448 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."),
449 TutCostCooldown);
450 }
451 }
452 }
453 }
454
455 static void CheckAbilityBlueprint(UClass* Class, FCollector& Out, FRSAuditResult& Result)
456 {
457 if (const UBlueprint* Blueprint = UBlueprint::GetBlueprintFromClass(Class))
458 {
459 ++Result.AbilityBlueprintsScanned;
460 CheckAbilityGraphs(Blueprint, Out);
461 }
462 }
463
464 static void CheckAbility(UClass* Class, FCollector& Out, FRSAuditResult& Result, FRSAuditTable& Table)
465 {
466 const UGameplayAbility* CDO = Class->GetDefaultObject<UGameplayAbility>();
467 if (!CDO)
468 {
469 return;
470 }
471 ++Result.AbilitiesScanned;
472 const FString Subject = Class->GetPathName();
473
474 // GA-01: net execution policy
475 const FTriggerInfo Triggers = ReadTriggers(CDO);
476 const bool bHasTriggers = Triggers.Num > 0;
477 switch (CDO->GetNetExecutionPolicy())
478 {
479 case EGameplayAbilityNetExecutionPolicy::ServerInitiated:
480 Out.Add(TEXT("GA-01"), ERSAuditSeverity::Warning, Subject,
481 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."),
482 TEXT("Use LocalPredicted for abilities the player activates, ServerOnly for abilities only the server starts."),
483 TutFiresTwice);
484 break;
485 case EGameplayAbilityNetExecutionPolicy::ServerOnly:
486 if (!bHasTriggers)
487 {
488 Out.Add(TEXT("GA-01"), ERSAuditSeverity::Info, Subject,
489 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."),
490 TEXT("Keep ServerOnly for passives and AI. For player input use LocalPredicted."),
491 TutFiresTwice);
492 }
493 break;
494 case EGameplayAbilityNetExecutionPolicy::LocalOnly:
495 Out.Add(TEXT("GA-01"), ERSAuditSeverity::Info, Subject,
496 TEXT("Net Execution Policy is LocalOnly: it never runs on the server. Fine for cosmetics, wrong for anything that changes gameplay state."),
497 TEXT("If it applies effects, costs or damage, use LocalPredicted."),
498 TutFiresTwice);
499 break;
500 default:
501 break;
502 }
503
504 // GA-02: input replication
505 bool bReplicateInput = false;
506 if (ReadBool(CDO, UGameplayAbility::StaticClass(), TEXT("bReplicateInputDirectly"), bReplicateInput) && bReplicateInput)
507 {
508 Out.Add(TEXT("GA-02"), ERSAuditSeverity::Warning, Subject,
509 TEXT("Replicate Input Directly is on. Every press and release is sent as its own RPC, outside the prediction system."),
510 TEXT("Turn it off and use the WaitInputPress / WaitInputRelease tasks, which replicate with the prediction key."),
511 TutFiresTwice);
512 }
513
514 // GA-03: cooldown effect
515 if (const UGameplayEffect* Cooldown = CDO->GetCooldownGameplayEffect())
516 {
517 if (Cooldown->DurationPolicy == EGameplayEffectDurationType::Instant)
518 {
519 Out.Add(TEXT("GA-03"), ERSAuditSeverity::Error, Subject,
520 FString::Printf(TEXT("Cooldown effect %s is Instant. It ends the moment it is applied, so the ability has no cooldown."), *GetNameSafe(Cooldown->GetClass())),
521 TEXT("Set the cooldown effect's Duration Policy to Has Duration."),
522 TutCostCooldown);
523 }
524
525 const FGameplayTagContainer* CooldownTags = CDO->GetCooldownTags();
526 if (!CooldownTags || CooldownTags->IsEmpty())
527 {
528 Out.Add(TEXT("GA-03"), ERSAuditSeverity::Error, Subject,
529 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())),
530 TEXT("Grant a tag such as Cooldown.<Ability> from the cooldown effect, or override GetCooldownTags if you add tags at runtime."),
531 TutCostCooldown);
532 }
533 }
534
535 // GA-04: cost effect
536 if (const UGameplayEffect* Cost = CDO->GetCostGameplayEffect())
537 {
538 if (Cost->DurationPolicy != EGameplayEffectDurationType::Instant)
539 {
540 Out.Add(TEXT("GA-04"), ERSAuditSeverity::Warning, Subject,
541 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())),
542 TEXT("Make cost effects Instant. Use a separate effect for drains over time."),
543 TutCostCooldown);
544 }
545 if (Cost->Modifiers.Num() == 0 && Cost->Executions.Num() == 0)
546 {
547 Out.Add(TEXT("GA-04"), ERSAuditSeverity::Warning, Subject,
548 FString::Printf(TEXT("Cost effect %s has no modifiers or executions, so the ability is free."), *GetNameSafe(Cost->GetClass())),
549 TEXT("Add a modifier on the resource attribute, or remove the cost effect."),
550 TutCostCooldown);
551 }
552 }
553
554 // GA-06: replicated ability instance
555 if (CDO->GetReplicationPolicy() == EGameplayAbilityReplicationPolicy::ReplicateYes)
556 {
557 Out.Add(TEXT("GA-06"), ERSAuditSeverity::Warning, Subject,
558 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."),
559 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."),
560 TutFiresTwice);
561 }
562
563 // GA-07: triggers on a predicted ability
564 if (bHasTriggers && CDO->GetNetExecutionPolicy() == EGameplayAbilityNetExecutionPolicy::LocalPredicted)
565 {
566 if (Triggers.bTagTrigger)
567 {
568 Out.Add(TEXT("GA-07"), ERSAuditSeverity::Warning, Subject,
569 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),
570 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."),
571 TutFiresTwice);
572 }
573 else
574 {
575 Out.Add(TEXT("GA-07"), ERSAuditSeverity::Info, Subject,
576 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),
577 TEXT("Send the event from the owning client only (the predicted activation reaches the server), and do not also bind the ability to input."),
578 TutFiresTwice);
579 }
580 }
581
582 const UGameplayEffect* CostEffect = CDO->GetCostGameplayEffect();
583 const UGameplayEffect* CooldownEffect = CDO->GetCooldownGameplayEffect();
584 Table.Rows.Add({
585 Class->GetName(),
586 Class->HasAnyClassFlags(CLASS_Native) ? TEXT("C++") : TEXT("Blueprint"),
587 NetPolicyName(CDO),
588 InstancingName(CDO),
589 CDO->GetReplicationPolicy() == EGameplayAbilityReplicationPolicy::ReplicateYes ? TEXT("Yes") : TEXT("No"),
590 CostEffect ? CostEffect->GetClass()->GetName() : FString(TEXT("-")),
591 CooldownEffect ? CooldownEffect->GetClass()->GetName() : FString(TEXT("-")),
592 bHasTriggers ? Triggers.Description : FString(TEXT("-"))
593 });
594
595 CheckAbilityBlueprint(Class, Out, Result);
596 }
597
598 // ------------------------------------------------------------------ PlayerState checks
599
600 static void CheckPlayerState(UClass* Class, const FRSAuditOptions& Options, FCollector& Out, FRSAuditResult& Result, FRSAuditTable& Table)
601 {
602 APlayerState* CDO = Class->GetDefaultObject<APlayerState>();
603 UAbilitySystemComponent* ASC = CDO ? FindDefaultASC(CDO) : nullptr;
604 if (!ASC)
605 {
606 return; // not a GAS PlayerState, or the ASC is added in Blueprint (not visible on the CDO)
607 }
608 ++Result.PlayerStatesScanned;
609 const FString Subject = Class->GetPathName();
610
611 // PS-01: update rate
612 const float Rate = ReadNetUpdateFrequency(CDO);
613 if (Rate < Options.MinPlayerStateNetUpdateFrequency)
614 {
615 Out.Add(TEXT("PS-01"), ERSAuditSeverity::Warning, Subject,
616 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),
617 TEXT("Raise Net Update Frequency on the PlayerState (Lyra uses 100), then profile."),
618 TutAscOnPlayerState);
619 }
620
621 // PS-02: ASC replicated at all
622 if (!ASC->GetIsReplicated())
623 {
624 Out.Add(TEXT("PS-02"), ERSAuditSeverity::Error, Subject,
625 TEXT("The Ability System Component on this PlayerState is not set to replicate. Clients never receive abilities, effects or tags."),
626 TEXT("Call SetIsReplicated(true) on the ASC in the PlayerState constructor."),
627 TutAscOnPlayerState);
628 }
629
630 // PS-03: replication mode
631 const EGameplayEffectReplicationMode Mode = ReadReplicationMode(ASC);
632 if (Mode == EGameplayEffectReplicationMode::Minimal)
633 {
634 Out.Add(TEXT("PS-03"), ERSAuditSeverity::Error, Subject,
635 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."),
636 TEXT("Use SetReplicationMode(EGameplayEffectReplicationMode::Mixed) for player ASCs. Keep Minimal for AI."),
637 TutAscOnPlayerState);
638 }
639 else if (Mode == EGameplayEffectReplicationMode::Full)
640 {
641 Out.Add(TEXT("PS-03"), ERSAuditSeverity::Warning, Subject,
642 TEXT("Replication mode is Full: every client receives every player's gameplay effects. Correct, but it costs bandwidth that grows with player count."),
643 TEXT("Use Mixed for multiplayer player ASCs. Full is meant for single-player."),
644 TutAscOnPlayerState);
645 }
646
647 Table.Rows.Add({
648 Class->GetName(),
649 Class->HasAnyClassFlags(CLASS_Native) ? TEXT("C++") : TEXT("Blueprint"),
650 ASC->GetClass()->GetName(),
651 ASC->GetIsReplicated() ? TEXT("Yes") : TEXT("No"),
652 ReplicationModeName(Mode),
653 FString::Printf(TEXT("%.0f"), Rate)
654 });
655 }
656
657 // ------------------------------------------------------------------ AttributeSet checks
658
659 struct FSourceIndex
660 {
661 TArray<TPair<FString, FString>> Files; // path, contents
662
663 void Build()
664 {
665 TArray<FString> Roots = { FPaths::ProjectDir() / TEXT("Source"), FPaths::ProjectPluginsDir() };
666 for (const FString& Root : Roots)
667 {
668 TArray<FString> Found;
669 IFileManager::Get().FindFilesRecursive(Found, *Root, TEXT("*.cpp"), true, false);
670 for (const FString& Path : Found)
671 {
672 if (Path.Contains(TEXT("/Intermediate/")) || Path.Contains(TEXT("\\Intermediate\\")))
673 {
674 continue;
675 }
676 FString Text;
677 if (FFileHelper::LoadFileToString(Text, *Path))
678 {
679 Files.Emplace(Path, MoveTemp(Text));
680 }
681 }
682 }
683 }
684
685 /** Body of ClassName::Function(...) { ... }, or empty if not found. */
686 bool FindFunctionBody(const FString& ClassName, const FString& Function, FString& OutBody, FString& OutFile) const
687 {
688 const FRegexPattern Pattern(FString::Printf(TEXT("\\b%s\\s*::\\s*%s\\s*\\([^)]*\\)\\s*(const\\s*)?\\{"), *ClassName, *Function));
689 for (const TPair<FString, FString>& File : Files)
690 {
691 FRegexMatcher Matcher(Pattern, File.Value);
692 if (!Matcher.FindNext())
693 {
694 continue;
695 }
696 int32 Depth = 1;
697 const int32 Start = Matcher.GetMatchEnding();
698 int32 Index = Start;
699 for (; Index < File.Value.Len() && Depth > 0; ++Index)
700 {
701 const TCHAR C = File.Value[Index];
702 Depth += (C == TEXT('{')) ? 1 : (C == TEXT('}')) ? -1 : 0;
703 }
704 OutBody = File.Value.Mid(Start, Index - Start);
705 OutFile = File.Key;
706 return true;
707 }
708 return false;
709 }
710 };
711
712 static bool HasClamp(const FString& Body)
713 {
714 return Body.Contains(TEXT("Clamp")) || Body.Contains(TEXT("FMath::Max")) || Body.Contains(TEXT("FMath::Min"));
715 }
716
717 static void CheckAttributeSet(UClass* Class, const FSourceIndex& Sources, FCollector& Out, FRSAuditResult& Result, FRSAuditTable& Table)
718 {
719 ++Result.AttributeSetsScanned;
720 const FString CppName = FString(Class->GetPrefixCPP()) + Class->GetName();
721
722 FString RepPropsBody, RepPropsFile;
723 const bool bHaveRepProps = Sources.FindFunctionBody(CppName, TEXT("GetLifetimeReplicatedProps"), RepPropsBody, RepPropsFile);
724
725 for (TFieldIterator<FStructProperty> It(Class, EFieldIteratorFlags::ExcludeSuper); It; ++It)
726 {
727 const FStructProperty* Prop = *It;
728 if (Prop->Struct != FGameplayAttributeData::StaticStruct())
729 {
730 continue;
731 }
732 const FString Attr = Prop->GetName();
733 const FString Subject = FString::Printf(TEXT("%s.%s"), *CppName, *Attr);
734 const bool bReplicated = Prop->HasAnyPropertyFlags(CPF_Net);
735 FString AlwaysCell = TEXT("-");
736
737 // AS-01: not replicated
738 if (!bReplicated)
739 {
740 Out.Add(TEXT("AS-01"), ERSAuditSeverity::Info, Subject,
741 TEXT("Attribute is not replicated. Clients always see its default value."),
742 TEXT("Fine for server-only meta attributes such as incoming damage. Otherwise add ReplicatedUsing=OnRep_<Name>."),
743 TutCostCooldown);
744 }
745 else
746 {
747 // AS-02: replicated without an OnRep
748 if (Prop->RepNotifyFunc.IsNone())
749 {
750 Out.Add(TEXT("AS-02"), ERSAuditSeverity::Warning, Subject,
751 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."),
752 TEXT("Use ReplicatedUsing=OnRep_<Name> and call GAMEPLAYATTRIBUTE_REPNOTIFY in it."),
753 TutCostCooldown);
754 }
755 else
756 {
757 FString Body, File;
758 if (Sources.FindFunctionBody(CppName, Prop->RepNotifyFunc.ToString(), Body, File))
759 {
760 if (!Body.Contains(TEXT("GAMEPLAYATTRIBUTE_REPNOTIFY")))
761 {
762 Out.Add(TEXT("AS-02"), ERSAuditSeverity::Warning, Subject,
763 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)),
764 TEXT("Call GAMEPLAYATTRIBUTE_REPNOTIFY(ClassName, Attribute, OldValue) as the first line of the OnRep."),
765 TutCostCooldown);
766 }
767 }
768 }
769
770 // AS-03: REPNOTIFY_Always
771 AlwaysCell = TEXT("?");
772 if (bHaveRepProps)
773 {
774 const FRegexPattern Pattern(FString::Printf(TEXT("DOREPLIFETIME\\w*\\s*\\(\\s*%s\\s*,\\s*%s\\s*(,[^;]*)?\\)"), *CppName, *Attr));
775 FRegexMatcher Matcher(Pattern, RepPropsBody);
776 if (Matcher.FindNext())
777 {
778 const FString Line = RepPropsBody.Mid(Matcher.GetMatchBeginning(), Matcher.GetMatchEnding() - Matcher.GetMatchBeginning());
779 const bool bAlways = Line.Contains(TEXT("REPNOTIFY_Always"));
780 AlwaysCell = bAlways ? TEXT("Yes") : TEXT("No");
781 if (!bAlways)
782 {
783 Out.Add(TEXT("AS-03"), ERSAuditSeverity::Warning, Subject,
784 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."),
785 TEXT("Use DOREPLIFETIME_CONDITION_NOTIFY(Class, Attribute, COND_None, REPNOTIFY_Always)."),
786 TutCostCooldown);
787 }
788 }
789 }
790 }
791
792 Table.Rows.Add({
793 CppName,
794 Attr,
795 bReplicated ? TEXT("Yes") : TEXT("No"),
796 Prop->RepNotifyFunc.IsNone() ? FString(TEXT("-")) : Prop->RepNotifyFunc.ToString(),
797 AlwaysCell
798 });
799 }
800
801 bool bAnyReplicated = false;
802 for (TFieldIterator<FStructProperty> It(Class, EFieldIteratorFlags::ExcludeSuper); It; ++It)
803 {
804 bAnyReplicated |= It->Struct == FGameplayAttributeData::StaticStruct() && It->HasAnyPropertyFlags(CPF_Net);
805 }
806 if (!bHaveRepProps && bAnyReplicated)
807 {
808 Out.Add(TEXT("AS-03"), ERSAuditSeverity::Info, CppName,
809 TEXT("Could not find GetLifetimeReplicatedProps in the project source, so OnRep conditions were not checked."),
810 TEXT("Check by hand that every replicated attribute uses REPNOTIFY_Always."),
811 TutCostCooldown);
812 }
813
814 // AS-04: clamp only in PreAttributeChange
815 FString PreBody, PreFile;
816 if (Sources.FindFunctionBody(CppName, TEXT("PreAttributeChange"), PreBody, PreFile) && HasClamp(PreBody))
817 {
818 FString PostBody, PostFile, BaseBody, BaseFile;
819 const bool bPostClamps = Sources.FindFunctionBody(CppName, TEXT("PostGameplayEffectExecute"), PostBody, PostFile) && HasClamp(PostBody);
820 const bool bBaseClamps = Sources.FindFunctionBody(CppName, TEXT("PreAttributeBaseChange"), BaseBody, BaseFile) && HasClamp(BaseBody);
821 if (!bPostClamps && !bBaseClamps)
822 {
823 Out.Add(TEXT("AS-04"), ERSAuditSeverity::Warning, CppName,
824 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)),
825 TEXT("Clamp the base value too: in PostGameplayEffectExecute (SetHealth(FMath::Clamp(GetHealth(), 0.f, GetMaxHealth()))) or in PreAttributeBaseChange."),
826 TutCostCooldown);
827 }
828 }
829 }
830
831 // ------------------------------------------------------------------ network settings
832
833 static void CheckNetSettings(const FRSAuditOptions& Options, FCollector& Out, FRSAuditTable& Table)
834 {
835 FString DriverClassName;
836 if (GEngine)
837 {
838 for (const FNetDriverDefinition& Definition : GEngine->NetDriverDefinitions)
839 {
840 if (Definition.DefName == NAME_GameNetDriver)
841 {
842 DriverClassName = Definition.DriverClassName.ToString();
843 break;
844 }
845 }
846 }
847 const UClass* DriverClass = DriverClassName.IsEmpty() ? nullptr : LoadClass<UNetDriver>(nullptr, *DriverClassName, nullptr, LOAD_NoWarn | LOAD_Quiet);
848 const UObject* Driver = DriverClass ? DriverClass->GetDefaultObject() : nullptr;
849 const UObject* Player = GetDefault<ULocalPlayer>();
850
851 const int32 MaxInternetClientRate = ReadInt(Driver, TEXT("MaxInternetClientRate"));
852 const int32 MaxClientRate = ReadInt(Driver, TEXT("MaxClientRate"));
853 const int32 TickRate = ReadInt(Driver, TEXT("NetServerMaxTickRate"));
854 const int32 InternetSpeed = ReadInt(Player, TEXT("ConfiguredInternetSpeed"));
855 const int32 LanSpeed = ReadInt(Player, TEXT("ConfiguredLanSpeed"));
856
857 // The config section of a class is its path, e.g. /Script/OnlineSubsystemUtils.IpNetDriver
858 const FString DriverSection = DriverClass ? FString::Printf(TEXT("[%s]"), *DriverClass->GetPathName()) : FString(TEXT("?"));
859 Table.Rows.Add({ TEXT("Game net driver"), DriverClassName.IsEmpty() ? FString(TEXT("?")) : DriverClassName, TEXT("[/Script/Engine.Engine] NetDriverDefinitions") });
860 Table.Rows.Add({ TEXT("MaxInternetClientRate (bytes/s)"), IntCell(MaxInternetClientRate), DriverSection });
861 Table.Rows.Add({ TEXT("MaxClientRate, LAN (bytes/s)"), IntCell(MaxClientRate), DriverSection });
862 Table.Rows.Add({ TEXT("NetServerMaxTickRate"), IntCell(TickRate), DriverSection });
863 Table.Rows.Add({ TEXT("ConfiguredInternetSpeed (bytes/s)"), IntCell(InternetSpeed), TEXT("[/Script/Engine.Player]") });
864 Table.Rows.Add({ TEXT("ConfiguredLanSpeed (bytes/s)"), IntCell(LanSpeed), TEXT("[/Script/Engine.Player]") });
865
866 if (!Driver)
867 {
868 Out.Add(TEXT("NET-01"), ERSAuditSeverity::Info, TEXT("DefaultEngine.ini"),
869 FString::Printf(TEXT("Could not load the game net driver class '%s' in the editor, so bandwidth limits were not read."), *DriverClassName),
870 TEXT("Check MaxInternetClientRate on your net driver and ConfiguredInternetSpeed by hand."),
871 TutLatency);
872 return;
873 }
874
875 // A connection runs at the client's requested speed, capped by the server's limit.
876 int32 Effective = INDEX_NONE;
877 if (MaxInternetClientRate > 0 && InternetSpeed > 0)
878 {
879 Effective = FMath::Min(MaxInternetClientRate, InternetSpeed);
880 }
881 else if (MaxInternetClientRate > 0)
882 {
883 Effective = MaxInternetClientRate;
884 }
885
886 if (Effective != INDEX_NONE && Effective < Options.MinClientRate)
887 {
888 Out.Add(TEXT("NET-01"), ERSAuditSeverity::Warning, TEXT("DefaultEngine.ini"),
889 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."),
890 Effective, Effective * 8 / 1000, *IntCell(MaxInternetClientRate), *IntCell(InternetSpeed)),
891 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),
892 TutLatency);
893 }
894 }
895
896 // ------------------------------------------------------------------ suppressions
897
898 struct FSuppression
899 {
900 FString CheckId;
901 FString Pattern;
902 FString Reason;
903 };
904
905 /** One rule per line: CHECK-ID Subject-with-*-wildcards # reason. Lines starting with ; or # are comments. */
906 static TArray<FSuppression> LoadSuppressions(const FString& Path)
907 {
908 TArray<FSuppression> Rules;
909 TArray<FString> Lines;
910 if (!FFileHelper::LoadFileToStringArray(Lines, *Path))
911 {
912 return Rules;
913 }
914 for (FString Line : Lines)
915 {
916 FString Reason;
917 int32 Hash = INDEX_NONE;
918 if (Line.FindChar(TEXT('#'), Hash))
919 {
920 Reason = Line.Mid(Hash + 1).TrimStartAndEnd();
921 Line = Line.Left(Hash);
922 }
923 Line = Line.Replace(TEXT("\t"), TEXT(" ")).TrimStartAndEnd();
924 if (Line.IsEmpty() || Line.StartsWith(TEXT(";")))
925 {
926 continue;
927 }
928
929 FSuppression& Rule = Rules.AddDefaulted_GetRef();
930 if (!Line.Split(TEXT(" "), &Rule.CheckId, &Rule.Pattern))
931 {
932 Rule.CheckId = Line;
933 }
934 Rule.Pattern = Rule.Pattern.TrimStartAndEnd();
935 if (Rule.Pattern.IsEmpty())
936 {
937 Rule.Pattern = TEXT("*");
938 }
939 Rule.Reason = Reason.IsEmpty() ? FString(TEXT("No reason given")) : Reason;
940 }
941 return Rules;
942 }
943
944 static void ApplySuppressions(const TArray<FSuppression>& Rules, FRSAuditResult& Result)
945 {
946 for (FRSAuditFinding& Finding : Result.Findings)
947 {
948 for (const FSuppression& Rule : Rules)
949 {
950 const bool bIdMatches = Rule.CheckId == TEXT("*") || Rule.CheckId.Equals(Finding.CheckId, ESearchCase::IgnoreCase);
951 if (bIdMatches && Finding.Subject.MatchesWildcard(Rule.Pattern))
952 {
953 Finding.bSuppressed = true;
954 Finding.SuppressReason = Rule.Reason;
955 break;
956 }
957 }
958 }
959 }
960
961 // ------------------------------------------------------------------ report
962
963 static const TCHAR* SeverityName(ERSAuditSeverity Severity)
964 {
965 switch (Severity)
966 {
967 case ERSAuditSeverity::Error: return TEXT("Error");
968 case ERSAuditSeverity::Warning: return TEXT("Warning");
969 default: return TEXT("Info");
970 }
971 }
972
973 static FString HtmlEscape(const FString& In)
974 {
975 return In.Replace(TEXT("&"), TEXT("&amp;")).Replace(TEXT("<"), TEXT("&lt;")).Replace(TEXT(">"), TEXT("&gt;")).Replace(TEXT("\""), TEXT("&quot;"));
976 }
977
978 static FString MdEscape(const FString& In)
979 {
980 return In.Replace(TEXT("|"), TEXT("\\|")).Replace(TEXT("\n"), TEXT(" "));
981 }
982
983 static FString TutorialUrl(const FString& Slug)
984 {
985 return FString(SiteUrl) + Slug + TEXT("?src=netaudit");
986 }
987
988 static FString IgnoreLine(const FRSAuditFinding& F)
989 {
990 return FString::Printf(TEXT("%s %s"), *F.CheckId, *F.Subject);
991 }
992
993 static const TCHAR* CannotSee =
994 TEXT("Static checks cannot see: whether a triggered ability is also bound to input, C++ abilities that commit after a latent task, ")
995 TEXT("side effects spawned on both client and server, InitAbilityActorInfo missing on the client, abilities granted twice on respawn, ")
996 TEXT("or anything that only breaks at real latency.");
997
998 static FString BuildMarkdown(const FRSAuditResult& R, const FString& When)
999 {
1000 FString Md;
1001 Md += TEXT("# GAS Net Audit report\n\n");
1002 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);
1003 Md += FString::Printf(TEXT("Scanned %d abilities (%d Blueprint graphs), %d PlayerStates with an ASC, %d AttributeSets, and the network settings.\n\n"),
1004 R.AbilitiesScanned, R.AbilityBlueprintsScanned, R.PlayerStatesScanned, R.AttributeSetsScanned);
1005 Md += FString::Printf(TEXT("**%d errors, %d warnings, %d info.** %d suppressed.\n\n"),
1006 R.Count(ERSAuditSeverity::Error), R.Count(ERSAuditSeverity::Warning), R.Count(ERSAuditSeverity::Info), R.SuppressedCount());
1007
1008 for (ERSAuditSeverity Severity : { ERSAuditSeverity::Error, ERSAuditSeverity::Warning, ERSAuditSeverity::Info })
1009 {
1010 if (R.Count(Severity) == 0)
1011 {
1012 continue;
1013 }
1014 Md += FString::Printf(TEXT("## %s\n\n| Check | Subject | Problem | Fix | Guide |\n|---|---|---|---|---|\n"),
1015 Severity == ERSAuditSeverity::Error ? TEXT("Errors") : Severity == ERSAuditSeverity::Warning ? TEXT("Warnings") : TEXT("Info"));
1016 for (const FRSAuditFinding& F : R.Findings)
1017 {
1018 if (F.Severity == Severity && !F.bSuppressed)
1019 {
1020 Md += FString::Printf(TEXT("| %s | `%s` | %s | %s | [read](%s) |\n"),
1021 *F.CheckId, *MdEscape(F.Subject), *MdEscape(F.Message), *MdEscape(F.Fix), *TutorialUrl(F.Tutorial));
1022 }
1023 }
1024 Md += TEXT("\n");
1025 }
1026
1027 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);
1028
1029 if (R.SuppressedCount() > 0)
1030 {
1031 Md += TEXT("## Suppressed\n\n| Check | Subject | Reason |\n|---|---|---|\n");
1032 for (const FRSAuditFinding& F : R.Findings)
1033 {
1034 if (F.bSuppressed)
1035 {
1036 Md += FString::Printf(TEXT("| %s | `%s` | %s |\n"), *F.CheckId, *MdEscape(F.Subject), *MdEscape(F.SuppressReason));
1037 }
1038 }
1039 Md += TEXT("\n");
1040 }
1041
1042 Md += TEXT("## Inventory\n\nWhat the audit read, so you can review the setup at a glance.\n\n");
1043 for (const FRSAuditTable& Table : R.Inventory)
1044 {
1045 Md += FString::Printf(TEXT("### %s (%d)\n\n| %s |\n|"), *Table.Title, Table.Rows.Num(), *FString::Join(Table.Columns, TEXT(" | ")));
1046 for (int32 Index = 0; Index < Table.Columns.Num(); ++Index)
1047 {
1048 Md += TEXT("---|");
1049 }
1050 Md += TEXT("\n");
1051 for (const TArray<FString>& Row : Table.Rows)
1052 {
1053 TArray<FString> Cells;
1054 for (const FString& Cell : Row)
1055 {
1056 Cells.Add(MdEscape(Cell));
1057 }
1058 Md += FString::Printf(TEXT("| %s |\n"), *FString::Join(Cells, TEXT(" | ")));
1059 }
1060 Md += TEXT("\n");
1061 }
1062
1063 Md += TEXT("## What this audit cannot see\n\n");
1064 Md += FString(CannotSee) + TEXT("\n\n");
1065 Md += FString::Printf(TEXT("Test at real latency: %s\n\n"), *TutorialUrl(TutLatency));
1066 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");
1067 return Md;
1068 }
1069
1070 static FString BuildHtml(const FRSAuditResult& R, const FString& When)
1071 {
1072 FString Rows;
1073 for (ERSAuditSeverity Severity : { ERSAuditSeverity::Error, ERSAuditSeverity::Warning, ERSAuditSeverity::Info })
1074 {
1075 for (const FRSAuditFinding& F : R.Findings)
1076 {
1077 if (F.Severity != Severity || F.bSuppressed)
1078 {
1079 continue;
1080 }
1081 Rows += FString::Printf(
1082 TEXT("<tr class=\"%s\"><td><b>%s</b><br>%s</td><td><code>%s</code></td><td>%s</td><td>%s<div class=\"ign\">Ignore: <code>%s</code></div></td><td><a href=\"%s\">Guide</a></td></tr>\n"),
1083 SeverityName(F.Severity), SeverityName(F.Severity), *HtmlEscape(F.CheckId), *HtmlEscape(F.Subject),
1084 *HtmlEscape(F.Message), *HtmlEscape(F.Fix), *HtmlEscape(IgnoreLine(F)), *HtmlEscape(TutorialUrl(F.Tutorial)));
1085 }
1086 }
1087 if (Rows.IsEmpty())
1088 {
1089 Rows = TEXT("<tr><td colspan=\"5\">No findings. Now test at real latency: static checks cannot see everything.</td></tr>");
1090 }
1091
1092 FString Html;
1093 Html += TEXT("<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
1094 Html += FString::Printf(TEXT("<title>GAS Net Audit: %s</title>"), *HtmlEscape(FApp::GetProjectName()));
1095 Html += TEXT("<style>body{font:15px/1.55 system-ui,sans-serif;margin:0;padding:24px;color:#0B0D11;background:#fff}")
1096 TEXT("h1{font-size:28px;margin:0 0 4px}h2{margin-top:32px}p{max-width:72ch}table{border-collapse:collapse;width:100%;margin:16px 0}")
1097 TEXT("th,td{border-bottom:1px solid #DFE2E6;padding:10px 8px;text-align:left;vertical-align:top}th{font-size:13px;color:#565D68}")
1098 TEXT("code{font:13px ui-monospace,Consolas,monospace;word-break:break-all}tr.Error td:first-child{border-left:4px solid #A1260F}")
1099 TEXT("tr.Warning td:first-child{border-left:4px solid #757C86}tr.Info td:first-child{border-left:4px solid #DFE2E6}")
1100 TEXT(".ign{margin-top:6px;font-size:12px;color:#565D68}details{margin:12px 0}summary{cursor:pointer;font-weight:600}")
1101 TEXT(".sum{font-size:18px;font-weight:600}.cta{margin-top:24px;padding:16px;background:#ECEEF1}a{color:#0B0D11}")
1102 TEXT("@media(prefers-color-scheme:dark){body{background:#0B0D11;color:#ECEEF1}th,td{border-color:#23282F}.ign,th{color:#9AA1AB}.cta{background:#14171D}a{color:#ECEEF1}}</style></head><body>");
1103 Html += FString::Printf(TEXT("<h1>GAS Net Audit</h1><p>%s · %s · RS GAS Net Audit v%s</p>"), *HtmlEscape(FApp::GetProjectName()), *HtmlEscape(When), ToolVersion);
1104 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));
1105 Html += FString::Printf(TEXT("<p>Scanned %d abilities (%d Blueprint graphs), %d PlayerStates with an ASC, %d AttributeSets, and the network settings. %d findings suppressed by <code>%s</code>.</p>"),
1106 R.AbilitiesScanned, R.AbilityBlueprintsScanned, R.PlayerStatesScanned, R.AttributeSetsScanned, R.SuppressedCount(), *HtmlEscape(R.IgnoreFile));
1107 Html += TEXT("<table><thead><tr><th>Severity</th><th>Subject</th><th>Problem</th><th>Fix</th><th></th></tr></thead><tbody>");
1108 Html += Rows;
1109 Html += TEXT("</tbody></table>");
1110 Html += TEXT("<p>Checked a finding and it is fine? Copy its Ignore line into the ignore file, with <code># reason</code> after it. <code>*</code> works as a wildcard.</p>");
1111
1112 if (R.SuppressedCount() > 0)
1113 {
1114 Html += FString::Printf(TEXT("<details><summary>Suppressed (%d)</summary><table><thead><tr><th>Check</th><th>Subject</th><th>Reason</th></tr></thead><tbody>"), R.SuppressedCount());
1115 for (const FRSAuditFinding& F : R.Findings)
1116 {
1117 if (F.bSuppressed)
1118 {
1119 Html += FString::Printf(TEXT("<tr><td>%s</td><td><code>%s</code></td><td>%s</td></tr>"), *HtmlEscape(F.CheckId), *HtmlEscape(F.Subject), *HtmlEscape(F.SuppressReason));
1120 }
1121 }
1122 Html += TEXT("</tbody></table></details>");
1123 }
1124
1125 Html += TEXT("<h2>Inventory</h2><p>What the audit read, so you can review the setup at a glance.</p>");
1126 for (const FRSAuditTable& Table : R.Inventory)
1127 {
1128 Html += FString::Printf(TEXT("<details open><summary>%s (%d)</summary><table><thead><tr>"), *HtmlEscape(Table.Title), Table.Rows.Num());
1129 for (const FString& Column : Table.Columns)
1130 {
1131 Html += FString::Printf(TEXT("<th>%s</th>"), *HtmlEscape(Column));
1132 }
1133 Html += TEXT("</tr></thead><tbody>");
1134 for (const TArray<FString>& Row : Table.Rows)
1135 {
1136 Html += TEXT("<tr>");
1137 for (const FString& Cell : Row)
1138 {
1139 Html += FString::Printf(TEXT("<td>%s</td>"), *HtmlEscape(Cell));
1140 }
1141 Html += TEXT("</tr>");
1142 }
1143 Html += TEXT("</tbody></table></details>");
1144 }
1145
1146 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)));
1147 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>")
1148 TEXT("<p><a href=\"https://reignancestudios.com/gameplay-ability-system-cost?src=netaudit#diagnostic\">Request a Diagnostic</a> · ")
1149 TEXT("<a href=\"https://calendly.com/contact-reignancestudios/new-meeting\">Book a free teardown</a></p></div>");
1150 Html += TEXT("</body></html>\n");
1151 return Html;
1152 }
1153
1154 static TArray<TSharedPtr<FJsonValue>> JsonStrings(const TArray<FString>& Values)
1155 {
1156 TArray<TSharedPtr<FJsonValue>> Out;
1157 for (const FString& Value : Values)
1158 {
1159 Out.Add(MakeShared<FJsonValueString>(Value));
1160 }
1161 return Out;
1162 }
1163
1164 static FString BuildJson(const FRSAuditResult& R, const FString& When)
1165 {
1166 const TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
1167 Root->SetStringField(TEXT("tool"), TEXT("RS GAS Net Audit"));
1168 Root->SetStringField(TEXT("version"), ToolVersion);
1169 Root->SetStringField(TEXT("project"), FApp::GetProjectName());
1170 Root->SetStringField(TEXT("run"), When);
1171
1172 const TSharedRef<FJsonObject> Summary = MakeShared<FJsonObject>();
1173 Summary->SetNumberField(TEXT("errors"), R.Count(ERSAuditSeverity::Error));
1174 Summary->SetNumberField(TEXT("warnings"), R.Count(ERSAuditSeverity::Warning));
1175 Summary->SetNumberField(TEXT("info"), R.Count(ERSAuditSeverity::Info));
1176 Summary->SetNumberField(TEXT("suppressed"), R.SuppressedCount());
1177 Summary->SetNumberField(TEXT("abilities"), R.AbilitiesScanned);
1178 Summary->SetNumberField(TEXT("abilityBlueprints"), R.AbilityBlueprintsScanned);
1179 Summary->SetNumberField(TEXT("playerStates"), R.PlayerStatesScanned);
1180 Summary->SetNumberField(TEXT("attributeSets"), R.AttributeSetsScanned);
1181 Root->SetObjectField(TEXT("summary"), Summary);
1182
1183 TArray<TSharedPtr<FJsonValue>> Findings;
1184 for (const FRSAuditFinding& F : R.Findings)
1185 {
1186 const TSharedRef<FJsonObject> Item = MakeShared<FJsonObject>();
1187 Item->SetStringField(TEXT("check"), F.CheckId);
1188 Item->SetStringField(TEXT("severity"), SeverityName(F.Severity));
1189 Item->SetStringField(TEXT("subject"), F.Subject);
1190 Item->SetStringField(TEXT("message"), F.Message);
1191 Item->SetStringField(TEXT("fix"), F.Fix);
1192 Item->SetStringField(TEXT("guide"), TutorialUrl(F.Tutorial));
1193 Item->SetBoolField(TEXT("suppressed"), F.bSuppressed);
1194 if (F.bSuppressed)
1195 {
1196 Item->SetStringField(TEXT("suppressReason"), F.SuppressReason);
1197 }
1198 Findings.Add(MakeShared<FJsonValueObject>(Item));
1199 }
1200 Root->SetArrayField(TEXT("findings"), Findings);
1201
1202 TArray<TSharedPtr<FJsonValue>> Inventory;
1203 for (const FRSAuditTable& Table : R.Inventory)
1204 {
1205 const TSharedRef<FJsonObject> Item = MakeShared<FJsonObject>();
1206 Item->SetStringField(TEXT("title"), Table.Title);
1207 Item->SetArrayField(TEXT("columns"), JsonStrings(Table.Columns));
1208 TArray<TSharedPtr<FJsonValue>> Rows;
1209 for (const TArray<FString>& Row : Table.Rows)
1210 {
1211 Rows.Add(MakeShared<FJsonValueArray>(JsonStrings(Row)));
1212 }
1213 Item->SetArrayField(TEXT("rows"), Rows);
1214 Inventory.Add(MakeShared<FJsonValueObject>(Item));
1215 }
1216 Root->SetArrayField(TEXT("inventory"), Inventory);
1217
1218 FString Out;
1219 const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Out);
1220 FJsonSerializer::Serialize(Root, Writer);
1221 return Out;
1222 }
1223
1224 static FString XmlEscape(const FString& In)
1225 {
1226 return HtmlEscape(In).Replace(TEXT("'"), TEXT("&apos;"));
1227 }
1228
1229 /** JUnit XML: one test case per finding, plus one passing case per check with no findings. */
1230 static FString BuildJUnit(const FRSAuditResult& R, ERSAuditFailOn FailOn)
1231 {
1232 FString Cases;
1233 int32 Tests = 0;
1234 TSet<FString> ChecksWithFindings;
1235 for (const FRSAuditFinding& F : R.Findings)
1236 {
1237 ++Tests;
1238 ChecksWithFindings.Add(F.CheckId);
1239 Cases += FString::Printf(TEXT(" <testcase classname=\"RSNetAudit.%s\" name=\"%s\">"), *XmlEscape(F.CheckId), *XmlEscape(F.Subject));
1240 if (F.bSuppressed)
1241 {
1242 Cases += FString::Printf(TEXT("<skipped message=\"%s\"/>"), *XmlEscape(F.SuppressReason));
1243 }
1244 else if (R.IsFailing(F, FailOn))
1245 {
1246 Cases += FString::Printf(TEXT("<failure type=\"%s\" message=\"%s\">Fix: %s&#10;Guide: %s</failure>"),
1247 SeverityName(F.Severity), *XmlEscape(F.Message), *XmlEscape(F.Fix), *XmlEscape(TutorialUrl(F.Tutorial)));
1248 }
1249 else
1250 {
1251 Cases += FString::Printf(TEXT("<system-out>%s: %s</system-out>"), SeverityName(F.Severity), *XmlEscape(F.Message));
1252 }
1253 Cases += TEXT("</testcase>\n");
1254 }
1255 for (const TCHAR* Check : AllChecks)
1256 {
1257 if (!ChecksWithFindings.Contains(Check))
1258 {
1259 ++Tests;
1260 Cases += FString::Printf(TEXT(" <testcase classname=\"RSNetAudit.%s\" name=\"No findings\"/>\n"), Check);
1261 }
1262 }
1263
1264 const int32 Failures = R.FailingCount(FailOn);
1265 const int32 Skipped = R.SuppressedCount();
1266 FString Xml = TEXT("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1267 Xml += FString::Printf(TEXT("<testsuites name=\"GAS Net Audit\" tests=\"%d\" failures=\"%d\" skipped=\"%d\">\n"), Tests, Failures, Skipped);
1268 Xml += FString::Printf(TEXT(" <testsuite name=\"RSNetAudit\" tests=\"%d\" failures=\"%d\" skipped=\"%d\">\n"), Tests, Failures, Skipped);
1269 Xml += Cases;
1270 Xml += TEXT(" </testsuite>\n</testsuites>\n");
1271 return Xml;
1272 }
1273}
1274
1275int32 FRSAuditResult::Count(ERSAuditSeverity Severity) const
1276{
1277 int32 N = 0;
1278 for (const FRSAuditFinding& F : Findings)
1279 {
1280 N += (F.Severity == Severity && !F.bSuppressed) ? 1 : 0;
1281 }
1282 return N;
1283}
1284
1285int32 FRSAuditResult::SuppressedCount() const
1286{
1287 int32 N = 0;
1288 for (const FRSAuditFinding& F : Findings)
1289 {
1290 N += F.bSuppressed ? 1 : 0;
1291 }
1292 return N;
1293}
1294
1295bool FRSAuditResult::IsFailing(const FRSAuditFinding& Finding, ERSAuditFailOn FailOn) const
1296{
1297 if (Finding.bSuppressed || FailOn == ERSAuditFailOn::None)
1298 {
1299 return false;
1300 }
1301 const ERSAuditSeverity Threshold = FailOn == ERSAuditFailOn::Warning ? ERSAuditSeverity::Warning : ERSAuditSeverity::Error;
1302 return static_cast<uint8>(Finding.Severity) >= static_cast<uint8>(Threshold);
1303}
1304
1305int32 FRSAuditResult::FailingCount(ERSAuditFailOn FailOn) const
1306{
1307 int32 N = 0;
1308 for (const FRSAuditFinding& F : Findings)
1309 {
1310 N += IsFailing(F, FailOn) ? 1 : 0;
1311 }
1312 return N;
1313}
1314
1315TArray<FRSAuditFinding> FRSNetAuditRunner::CheckAbilityGraphs(const UBlueprint* Blueprint)
1316{
1317 FRSAuditResult Result;
1318 if (Blueprint)
1319 {
1320 RSNetAudit::FCollector Out{ Result };
1321 RSNetAudit::CheckAbilityGraphs(Blueprint, Out);
1322 }
1323 return Result.Findings;
1324}
1325
1326FRSAuditResult FRSNetAuditRunner::Run(const FRSAuditOptions& Options)
1327{
1328 using namespace RSNetAudit;
1329
1330 FRSAuditResult Result;
1331 FCollector Out{ Result };
1332
1333 IAssetRegistry& Registry = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry")).Get();
1334 Registry.SearchAllAssets(true);
1335
1336 FRSAuditTable NetTable{ TEXT("Network settings"), { TEXT("Setting"), TEXT("Value"), TEXT("Where") } };
1337 CheckNetSettings(Options, Out, NetTable);
1338
1339 FRSAuditTable AbilityTable{ TEXT("Abilities"), { TEXT("Ability"), TEXT("Type"), TEXT("Net policy"), TEXT("Instancing"), TEXT("Replicated"), TEXT("Cost"), TEXT("Cooldown"), TEXT("Triggers") } };
1340 for (UClass* Class : GatherClasses(UGameplayAbility::StaticClass(), Options))
1341 {
1342 CheckAbility(Class, Out, Result, AbilityTable);
1343 }
1344
1345 FRSAuditTable PlayerStateTable{ TEXT("PlayerStates with an ASC"), { TEXT("PlayerState"), TEXT("Type"), TEXT("ASC class"), TEXT("ASC replicated"), TEXT("Replication mode"), TEXT("Net update frequency") } };
1346 for (UClass* Class : GatherClasses(APlayerState::StaticClass(), Options))
1347 {
1348 CheckPlayerState(Class, Options, Out, Result, PlayerStateTable);
1349 }
1350
1351 FRSAuditTable AttributeTable{ TEXT("Attributes"), { TEXT("AttributeSet"), TEXT("Attribute"), TEXT("Replicated"), TEXT("OnRep"), TEXT("REPNOTIFY_Always") } };
1352 FSourceIndex Sources;
1353 Sources.Build();
1354 for (UClass* Class : GatherClasses(UAttributeSet::StaticClass(), Options))
1355 {
1356 if (Class->HasAnyClassFlags(CLASS_Native))
1357 {
1358 CheckAttributeSet(Class, Sources, Out, Result, AttributeTable);
1359 }
1360 }
1361
1362 Result.Inventory = { NetTable, AbilityTable, PlayerStateTable, AttributeTable };
1363
1364 Result.IgnoreFile = FPaths::ConvertRelativePathToFull(Options.IgnoreFile.IsEmpty() ? FPaths::ProjectConfigDir() / TEXT("RSNetAudit.ignore") : Options.IgnoreFile);
1365 ApplySuppressions(LoadSuppressions(Result.IgnoreFile), Result);
1366
1367 const FDateTime Now = FDateTime::Now();
1368 const FString When = Now.ToString(TEXT("%Y-%m-%d %H:%M"));
1369 const FString Dir = FPaths::ConvertRelativePathToFull(Options.OutputDir.IsEmpty() ? FPaths::ProjectSavedDir() / TEXT("RSNetAudit") : Options.OutputDir);
1370 const FString Stamp = Now.ToString(TEXT("%Y%m%d-%H%M%S"));
1371
1372 // Each report is written twice: a dated copy to compare runs, and -latest for links and CI.
1373 auto Save = [&Dir, &Stamp](const FString& Content, const TCHAR* Extension) -> FString
1374 {
1375 const FString Path = Dir / FString::Printf(TEXT("GASNetAudit-%s.%s"), *Stamp, Extension);
1376 FFileHelper::SaveStringToFile(Content, *Path, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM);
1377 FFileHelper::SaveStringToFile(Content, *(Dir / FString::Printf(TEXT("GASNetAudit-latest.%s"), Extension)), FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM);
1378 return Path;
1379 };
1380 Result.MarkdownPath = Save(BuildMarkdown(Result, When), TEXT("md"));
1381 Result.HtmlPath = Save(BuildHtml(Result, When), TEXT("html"));
1382 Result.JsonPath = Save(BuildJson(Result, When), TEXT("json"));
1383 Result.JUnitPath = Save(BuildJUnit(Result, Options.FailOn), TEXT("junit.xml"));
1384
1385 for (const FRSAuditFinding& F : Result.Findings)
1386 {
1387 if (F.bSuppressed)
1388 {
1389 continue;
1390 }
1391 const FString Line = FString::Printf(TEXT("[%s] %s %s: %s"), *F.CheckId, SeverityName(F.Severity), *F.Subject, *F.Message);
1392 // Error findings log as warnings on purpose: an Error log line makes Unreal
1393 // exit the commandlet with 1, which would override -FailOn.
1394 if (F.Severity != ERSAuditSeverity::Info)
1395 {
1396 UE_LOG(LogRSNetAudit, Warning, TEXT("%s"), *Line);
1397 }
1398 else
1399 {
1400 UE_LOG(LogRSNetAudit, Display, TEXT("%s"), *Line);
1401 }
1402 }
1403 UE_LOG(LogRSNetAudit, Display, TEXT("GAS Net Audit: %d errors, %d warnings, %d info, %d suppressed. Report: %s"),
1404 Result.Count(ERSAuditSeverity::Error), Result.Count(ERSAuditSeverity::Warning), Result.Count(ERSAuditSeverity::Info),
1405 Result.SuppressedCount(), *Result.HtmlPath);
1406
1407 return Result;
1408}

Still desyncing in packaged builds?

Free code fixes one symptom. A Diagnostic finds the cause across your project in two days.