// Copyright Epic Games, Inc. All Rights Reserved. using EpicGames.Core; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; namespace AutomationTool { [Help("CookDiffReportHelper analyses the json files created by -run=cook -diffonly -cookdiffjson=path/to/my.json ... ")] [Help("Source", "Path to the cookdiffjson file or folder")] [Help("MaxResults", "Maximum number of results to display (default is all results)")] [Help("MaxFiles", "Maximum number of files to log for a particular diff (default 10. use 0 to show all files)")] public class CookDiffReportHelper : BuildCommand { public override void ExecuteBuild() { string Source = ParseRequiredStringParam("Source"); int MaxResults = ParseParamInt("MaxResults", int.MaxValue); int MaxFiles = ParseParamInt("MaxFiles", 10); if (MaxFiles == 0) { MaxFiles = int.MaxValue; } DetailRoot Detail = ReadJson(Source); GenerateReport(Detail, MaxResults, MaxFiles); } private void GenerateReport(DetailRoot Detail, int MaxResults, int MaxFiles ) { Logger.LogInformation("************************** COOK DIFF REPORT **************************"); Logger.LogInformation("Project Name: {ProjectName}", Detail.ProjectName); Logger.LogInformation("Build Version: {BuildVersion}", Detail.BuildVersion); Logger.LogInformation(""); int NumResults = MaxResults; HashSet UsedDiffs = []; PropertyNamePriorityReport.GenerateReport(Detail, ref NumResults, UsedDiffs, MaxFiles); CallstackPriorityReport.GenerateReport(Detail, ref NumResults, UsedDiffs, MaxFiles); PackagePriorityReport.GenerateReport(Detail, ref NumResults, UsedDiffs); Logger.LogInformation("**********************************************************************"); } private sealed class DetailRoot { public string ProjectName { get; set; } public string BuildVersion { get; set; } public List Packages { get; set; } = []; }; private sealed class Package { public string Filename { get; set; } public string ClassName { get; set; } public Section Header { get; set; } public Section Exports { get; set; } public string Diagnostics { get; set; } public IEnumerable
Sections => new[] {Header, Exports}.Where( S => S != null ); }; private sealed class Section { public string Filename { get; set; } public UInt64 SourceSize { get; set; } = 0; public UInt64 DestSize { get; set; } = 0; public bool UndiagnosedDiff { get; set; } = false; public int UnreportedDiffs { get; set; } = 0; public List Diffs { get; set; } = []; public List TableDiffs { get; set; } = []; public bool SizeMismatch => (SourceSize != DestSize); }; private sealed class Diff { public Int64 LocalOffset { get; set; } = 0; public int Size { get; set; } = 0; public int Count { get; set; } = 1; public string ObjectName { get; set; } public string PropertyName { get; set; } public string Callstack { get; set; } }; private sealed class TableDiff { public string ItemName { get; set; } public string Detail { get; set; } }; private IEnumerable FindSourceJsons(string Source) { // source is a folder DirectoryReference SourceDirectory = DirectoryReference.FromString(Source); if (DirectoryReference.Exists(SourceDirectory)) { return DirectoryReference.EnumerateFiles( SourceDirectory, "*.json" ); } if (Source.EndsWith('/') || Source.EndsWith('\\')) { return []; } // source is a file FileReference JsonFile = FileReference.FromString(Source); // for multiprocess cook, additional json files may have been created alongside this one, myfile-1.json, myfile-2.json etc string BaseFileName = JsonFile.GetFileNameWithoutExtension(); string SubFileNameFilter = BaseFileName + "-*" + JsonFile.GetExtension(); List JsonFiles = DirectoryReference.EnumerateFiles(JsonFile.Directory, SubFileNameFilter).ToList(); if (FileReference.Exists(JsonFile)) { JsonFiles.Add(JsonFile); } return JsonFiles; } private async Task ParseAndMergeJsonAsync(IEnumerable SourceJsons) { // read all json files (these could be big so go wide) IEnumerable Reports = (await Task.WhenAll(SourceJsons.Select( async SourceJson => { try { using FileStream JsonFile = File.OpenRead(SourceJson.FullName); return await JsonSerializer.DeserializeAsync(JsonFile); } catch(Exception e) { Logger.LogError("Failed to read json file {file} : {msg}", SourceJson.FullName, e.Message); return null; } }))).Where( R => R != null ); if (!Reports.Any()) { throw new AutomationException("Could not parse any json files"); } // sanity check if (!Reports.All( R => R.ProjectName == Reports.First().ProjectName)) { throw new AutomationException("Mismatched ProjectName. Expected {0} in all json files", Reports.First().ProjectName); } if (!Reports.All( R => R.BuildVersion == Reports.First().BuildVersion)) { throw new AutomationException("Mismatched BuildVersion. Expected {0} in all json files", Reports.First().BuildVersion); } // merge the result DetailRoot Result = new() { ProjectName = Reports.First().ProjectName, BuildVersion = Reports.First().BuildVersion, Packages = [.. Reports.SelectMany(R => R.Packages)] }; return Result; } private DetailRoot ReadJson(string Source) { // find the json files in the source folder IEnumerable SourceJsons = FindSourceJsons(Source); if (!SourceJsons.Any()) { throw new AutomationException("No json files found in -source={0}", Source); } Logger.LogInformation("Found {num} json files:", SourceJsons.Count()); foreach (FileReference SourceJson in SourceJsons) { Logger.LogInformation("\t{json}", SourceJson); } // load and merge all json files DetailRoot Detail = ParseAndMergeJsonAsync(SourceJsons).GetAwaiter().GetResult(); return Detail; } private static string GetCleanCallstack(string Callstack) { if (string.IsNullOrEmpty(Callstack)) { return string.Empty; } List Lines = Callstack.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); // remove lines that may contain instance-specific data string[] ExcludePrefixes = ["Serialized Object:"/*, "Serialized Property:"*/ ]; Lines.RemoveAll( L => ExcludePrefixes.Any( P => L.StartsWith(P, StringComparison.OrdinalIgnoreCase) ) ); return string.Join('\n', Lines); } private static string GetCleanPackageFilename(string Filename) { // trim off most of the path string TrimString = "Saved/Cooked/"; int CropIndex = Filename.IndexOf(TrimString); if (CropIndex != -1) { CropIndex = Filename.IndexOf('/', CropIndex + TrimString.Length + 1); if (CropIndex != -1) { Filename = Filename.Substring(CropIndex+1); } } return Filename; } private class PropertyNamePriorityReport { class CallstackInfo { public string Callstack; public HashSet Filenames = []; }; class PropertyNameInfo { public Dictionary CallstackMap = []; // key = callstack hash public HashSet Filenames = []; public Dictionary ClassCounts = []; public List Diffs = []; public int DiffBytes = 0; public int SizeMismatches = 0; }; public static void GenerateReport(DetailRoot Detail, ref int MaxItems, HashSet UsedDiffs, int MaxFiles) { Dictionary InfoMap = []; foreach (Package Package in Detail.Packages) { foreach (Section Section in Package.Sections) { foreach (Diff Diff in Section.Diffs) { if (string.IsNullOrEmpty(Diff.PropertyName)) { continue; } if (UsedDiffs.Contains(Diff)) { continue; } string Callstack = GetCleanCallstack(Diff.Callstack); if (string.IsNullOrEmpty(Callstack)) { continue; } if (!InfoMap.TryGetValue(Diff.PropertyName, out PropertyNameInfo Info)) { Info = new(); InfoMap.Add(Diff.PropertyName, Info ); } int CallstackHash = Callstack.GetHashCode(); if (!Info.CallstackMap.TryGetValue(CallstackHash, out CallstackInfo CallstackInfo)) { CallstackInfo = new(); CallstackInfo.Callstack = Callstack; Info.CallstackMap.Add(CallstackHash, CallstackInfo); } CallstackInfo.Filenames.Add(Section.Filename ?? Package.Filename); Info.DiffBytes += (Diff.Count * Diff.Size); Info.Filenames.Add(Section.Filename ?? Package.Filename); Info.Diffs.Add(Diff); Info.ClassCounts.TryAdd(Package.ClassName, 0); Info.ClassCounts[Package.ClassName]++; if (Section.SizeMismatch) { Info.SizeMismatches++; } } } } int NumItems = Math.Min(MaxItems, InfoMap.Count); MaxItems -= NumItems; IOrderedEnumerable> OrderedInfoMap = InfoMap.OrderByDescending( Pair => Pair.Value.Filenames.Count ); // @todo: may need heuristic here foreach (KeyValuePair InfoPair in OrderedInfoMap.Take(NumItems)) { PropertyNameInfo Info = InfoPair.Value; IOrderedEnumerable> OrderedCallstackMap = Info.CallstackMap.OrderByDescending( Pair => Pair.Value.Filenames.Count ); // @todo: may need heuristic here CallstackInfo CallstackInfo = OrderedCallstackMap.First().Value; int NumFiles = Info.Filenames.Count; Logger.LogWarning("Serialization property affecting {files} files, totalling at least {bytes} bytes of nondeterminism{sizemsg}", NumFiles, Info.DiffBytes, (Info.SizeMismatches > 0) ? $" - {Info.SizeMismatches} files have nondeterministic sizes" : "" ); Logger.LogInformation("\tSerialized Property: {property}", InfoPair.Key); Logger.LogInformation("\tHighest-Impact Callstack Affecting {count} files ({other} other callstacks):", CallstackInfo.Filenames.Count, Info.CallstackMap.Count-1); foreach (string CallstackLine in CallstackInfo.Callstack.EnumerateLines()) { if (!CallstackLine.StartsWith("Serialized Property:")) // don't show this as it's already been shown { Logger.LogInformation("\t\t{line}", CallstackLine); } } Logger.LogInformation("\tAffected classes:"); foreach (KeyValuePair ClassCountPair in Info.ClassCounts) { Logger.LogInformation("\t\t{class} x {count}", ClassCountPair.Key, ClassCountPair.Value); } if (NumFiles > MaxFiles) { Logger.LogInformation("\tAffected files (showing first {max}):", MaxFiles); NumFiles = MaxFiles; } else { Logger.LogInformation("\tAffected files:"); } foreach (string Filename in Info.Filenames.Take(NumFiles)) { Logger.LogInformation("\t\t{filename}", GetCleanPackageFilename(Filename)); } Logger.LogInformation(""); foreach (Diff Diff in Info.Diffs) { UsedDiffs.Add(Diff); } } } } private class CallstackPriorityReport { class CallstackInfo { public string Callstack; public HashSet Filenames = []; public Dictionary ClassCounts = []; public List Diffs = []; public int DiffBytes = 0; public int SizeMismatches = 0; }; public static void GenerateReport(DetailRoot Detail, ref int MaxItems, HashSet UsedDiffs, int MaxFiles) { Dictionary InfoMap = []; // key = callstack hash foreach (Package Package in Detail.Packages) { foreach (Section Section in Package.Sections) { foreach (Diff Diff in Section.Diffs) { if (UsedDiffs.Contains(Diff)) { continue; } string Callstack = GetCleanCallstack(Diff.Callstack); if (string.IsNullOrEmpty(Callstack)) { continue; } int CallstackHash = Callstack.GetHashCode(); if (!InfoMap.TryGetValue(CallstackHash, out CallstackInfo Info)) { Info = new(); Info.Callstack = Callstack; InfoMap.Add(CallstackHash, Info); } Info.DiffBytes += (Diff.Count * Diff.Size); Info.Filenames.Add(Section.Filename ?? Package.Filename); Info.Diffs.Add(Diff); Info.ClassCounts.TryAdd(Package.ClassName, 0); Info.ClassCounts[Package.ClassName]++; if (Section.SizeMismatch) { Info.SizeMismatches++; } } } } int NumItems = Math.Min(MaxItems, InfoMap.Count); MaxItems -= NumItems; IOrderedEnumerable> OrderedInfoMap = InfoMap.OrderByDescending( Pair => Pair.Value.Filenames.Count ); // @todo: may need heuristic here foreach (KeyValuePair InfoPair in OrderedInfoMap.Take(NumItems)) { CallstackInfo Info = InfoPair.Value; int NumFiles = Info.Filenames.Count; Logger.LogWarning("Serialization callstack affecting {files} files, totalling at least {bytes} bytes of nondeterminism{sizemsg}", NumFiles, Info.DiffBytes, (Info.SizeMismatches > 0) ? $" - {Info.SizeMismatches} files have nondeterministic sizes" : "" ); Logger.LogInformation("\t{callstack}", Info.Callstack.Replace("\n","\n\t")); Logger.LogInformation("\tAffected classes:"); foreach (KeyValuePair ClassCountPair in Info.ClassCounts) { Logger.LogInformation("\t\t{class} x {count}", ClassCountPair.Key, ClassCountPair.Value); } if (NumFiles > MaxFiles) { Logger.LogInformation("\tAffected files (showing first {max}):", MaxFiles); NumFiles = MaxFiles; } else { Logger.LogInformation("\tAffected files:"); } foreach (string Filename in Info.Filenames.Take(NumFiles)) { Logger.LogInformation("\t\t{filename}", GetCleanPackageFilename(Filename)); } Logger.LogInformation(""); foreach (Diff Diff in Info.Diffs) { UsedDiffs.Add(Diff); } } } } private class PackagePriorityReport { public static void GenerateReport(DetailRoot Detail, ref int MaxItems, HashSet UsedDiffs) { IOrderedEnumerable OrderedPackages = Detail.Packages.OrderByDescending( P => CalculateImportance(P, UsedDiffs) ); foreach (Package Package in OrderedPackages) { MaxItems--; if (MaxItems < 0) { break; } IEnumerable HeaderDiffs = Package.Header?.Diffs.Where( D => !UsedDiffs.Contains(D)); // } IEnumerable ExportDiffs = Package.Exports?.Diffs.Where( D => !UsedDiffs.Contains(D)); // } only reporting diffs we've not reported elsewhere bool bHeaderHasDiffs = SectionHasDiffs(Package.Header, HeaderDiffs); bool bExportHasDiffs = SectionHasDiffs(Package.Exports, ExportDiffs); if (bHeaderHasDiffs || bExportHasDiffs) { int NumHeaderDiffBytes = HeaderDiffs?.Sum( D => D.Count * D.Size) ?? 0; int NumExportDiffBytes = ExportDiffs?.Sum( D => D.Count * D.Size) ?? 0; if (NumHeaderDiffBytes > 0 && NumExportDiffBytes > 0) { Logger.LogWarning("Package header with {header} byte(s) of nondeterminism, and exports with {exports} byte(s) of nondeterminism", NumHeaderDiffBytes, NumExportDiffBytes); } else if (NumHeaderDiffBytes > 0) { Logger.LogWarning("Package header with {header} byte(s) of nondeterminism", NumHeaderDiffBytes); } else if (NumExportDiffBytes > 0) { Logger.LogWarning("Package exports with {exports} byte(s) of nondeterminism", NumExportDiffBytes); } else { Logger.LogWarning("Nondeterministic package"); } Logger.LogInformation("Package Name: {PackageName}", GetCleanPackageFilename(Package.Filename)); Logger.LogInformation("Class Name: {ClassName}", Package.ClassName); if (bHeaderHasDiffs) { Logger.LogInformation("Header:"); GenerateReport(Package.Header, HeaderDiffs, UsedDiffs); } if (bExportHasDiffs) { Logger.LogInformation("Exports:"); GenerateReport(Package.Exports, ExportDiffs, UsedDiffs); } Logger.LogInformation(""); } } } private static bool SectionHasDiffs( Section Section, IEnumerable SectionDiffs ) { return Section != null && (Section.SizeMismatch || Section.UndiagnosedDiff || Section.UnreportedDiffs > 0 || Section.TableDiffs.Count > 0 || (SectionDiffs?.Any() ?? false)); } private static void GenerateReport(Section Section, IEnumerable SectionDiffs, HashSet UsedDiffs) { int NumPreviousReported = Section.Diffs.Intersect(UsedDiffs).Count(); if (!string.IsNullOrEmpty(Section.Filename)) { Logger.LogInformation("\tFile name: {filename}", GetCleanPackageFilename(Section.Filename)); } if (Section.SizeMismatch) { Logger.LogInformation("\tSize mismatch: {sourcesize} vs {destsize}", Section.SourceSize, Section.DestSize); } if (Section.UndiagnosedDiff) { Logger.LogInformation("\tUndiagnosed diff (DumpPackageHeaderDiffs does not yet implement describing the difference)"); } if (SectionDiffs.Any()) { Logger.LogInformation("\tDiffs:"); foreach (Diff Diff in SectionDiffs) { if (Diff.Count > 1) { Logger.LogInformation("\t\t{count} x {size} byte(s), starting at local offset {offset}", Diff.Count, Diff.Size, Diff.LocalOffset); } else { Logger.LogInformation("\t\t{size} byte(s), starting at local offset {offset}", Diff.Size, Diff.LocalOffset); } if (!string.IsNullOrEmpty(Diff.ObjectName)) { Logger.LogInformation("\t\t\tObject name: {name}", Diff.ObjectName); } if (!string.IsNullOrEmpty(Diff.PropertyName)) { Logger.LogInformation("\t\t\tProperty name: {name}", Diff.PropertyName); } if (!string.IsNullOrEmpty(Diff.Callstack)) { Logger.LogInformation("\t\t\tCallstack:"); PrintLinesIndented("\t\t\t\t", Diff.Callstack); } UsedDiffs.Add(Diff); } } if (Section.UnreportedDiffs > 0) { Logger.LogInformation("\t\t+{count} more diffs", Section.UnreportedDiffs); } if (NumPreviousReported > 0) { Logger.LogInformation("\t\t+{count} previously reported diffs", NumPreviousReported); } if (Section.TableDiffs.Count > 0) { foreach (TableDiff TableDiff in Section.TableDiffs) { Logger.LogInformation("\tTable Diffs for {item}", TableDiff.ItemName); PrintLinesIndented("\t\t", TableDiff.Detail); } } } private static void PrintLinesIndented( string Indent, string Lines) { foreach (string Line in Lines.EnumerateLines()) { Logger.LogInformation("{indent}{line}", Indent, Line ); } } private static int CalculateImportance(Package Package, HashSet UsedDiffs) { // @todo: proper heuristics!! int HeaderImportance = CalculateImportance(Package.Header, UsedDiffs); int ExportsImportance = CalculateImportance(Package.Exports, UsedDiffs); return HeaderImportance += (ExportsImportance * 100); } private static int CalculateImportance(Section Section, HashSet UsedDiffs) { // @todo: proper heuristics!! int Importance = 0; if (Section != null) { if (Section.SizeMismatch) { Importance += 10; } if (Section.UndiagnosedDiff) { Importance += 10; } Importance += Section.UnreportedDiffs * 10; Importance += CalculateImportance(Section.Diffs, UsedDiffs); Importance += CalculateImportance(Section.TableDiffs); } return Importance; } private static int CalculateImportance(IEnumerable Diffs, HashSet UsedDiffs) { // @todo: proper heuristics!! int Importance = 0; foreach (Diff Diff in Diffs) { Importance += (Diff.Count * Diff.Size) * (UsedDiffs.Contains(Diff) ? 1 : 4); // lower priority if we've prevously reported it } return Importance; } private static int CalculateImportance(IEnumerable TableDiffs) { return TableDiffs.Count() * 10; } } } }