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 "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
32DEFINE_LOG_CATEGORY_STATIC(LogRSNetAudit, Log, All);
33
34namespace 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("&amp;")).Replace(TEXT("<"), TEXT("&lt;")).Replace(TEXT(">"), TEXT("&gt;")).Replace(TEXT("\""), TEXT("&quot;"));
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
747int32 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
757FRSAuditResult 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}

Still desyncing in packaged builds?

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