11using System ;
22using System . Collections . Generic ;
33using System . Diagnostics ;
4- using System . IO ;
54using System . Linq ;
6- using System . Runtime . InteropServices ;
75using System . Text ;
86using System . Text . RegularExpressions ;
97using System . Threading . Tasks ;
1210using Octokit ;
1311using PlasmaShared ;
1412using ZkData ;
15- using FileMode = System . IO . FileMode ;
1613
1714namespace ChobbyLauncher
1815{
@@ -25,71 +22,306 @@ public enum CrashType {
2522
2623 public static class CrashReportHelper
2724 {
25+ private const string CrashReportsRepoOwner = "ZeroK-RTS" ;
26+ private const string CrashReportsRepoName = "CrashReports" ;
27+
2828 private const int MaxInfologSize = 62000 ;
2929 private const string InfoLogLineStartPattern = @"(^\[t=\d+:\d+:\d+\.\d+\]\[f=-?\d+\] )" ;
3030 private const string InfoLogLineEndPattern = @"(\r?\n|\Z)" ;
31- public static Issue ReportCrash ( string infolog , CrashType type , string engine , string bugReportTitle , string bugReportDescription )
31+ private sealed class GameFromLog
32+ {
33+ public int StartIdxInLog { get ; set ; }
34+ public string GameID { get ; set ; }
35+ public bool HasDesync { get => FirstDesyncIdxInLog . HasValue ; }
36+ public int ? FirstDesyncIdxInLog { get ; set ; }
37+ public List < string > GameStateFileNames { get ; set ; }
38+
39+ //Perhaps in future versions, these could be added?
40+ //PlayerName
41+ //DemoFileName
42+ //MapName
43+ //ModName
44+ }
45+
46+ private sealed class GameFromLogCollection
47+ {
48+ public IReadOnlyList < GameFromLog > Games { get ; private set ; }
49+ public GameFromLogCollection ( IEnumerable < int > startIndexes )
50+ {
51+ Games = startIndexes . Select ( idx => new GameFromLog { StartIdxInLog = idx } ) . ToArray ( ) ;
52+ }
53+
54+ private readonly struct GameStartList : IReadOnlyList < int >
55+ {
56+ private readonly GameFromLogCollection _this ;
57+ public GameStartList ( GameFromLogCollection v ) => _this = v ;
58+
59+ int IReadOnlyList < int > . this [ int index ] => _this . Games [ index ] . StartIdxInLog ;
60+
61+ int IReadOnlyCollection < int > . Count { get => _this . Games . Count ; }
62+
63+ IEnumerator < int > IEnumerable < int > . GetEnumerator ( ) => _this . Games . Select ( g => g . StartIdxInLog ) . GetEnumerator ( ) ;
64+
65+ System . Collections . IEnumerator System . Collections . IEnumerable . GetEnumerator ( ) => ( ( IEnumerable < int > ) this ) . GetEnumerator ( ) ;
66+ }
67+ public IReadOnlyList < int > AsGameStartReadOnlyList ( ) => new GameStartList ( this ) ;
68+
69+ private GameFromLog GetGameForIndex ( int idx )
70+ {
71+ //Equivalent to:
72+ //return Games.LastOrDefault(g => g.StartIdxInLog < idx);
73+ //but takes advantage of the fact that Games is sorted to have log rather than linear runtime.
74+ var lb = AsGameStartReadOnlyList ( ) . LowerBoundIndex ( idx ) ;
75+ return lb == 0 ? null : Games [ lb - 1 ] ;
76+ }
77+ public void AddGameStateFileNames ( IEnumerable < ( int , string ) > gameStateFileNames )
78+ {
79+ foreach ( var file in gameStateFileNames )
80+ {
81+ var game = GetGameForIndex ( file . Item1 ) ;
82+ if ( game == null )
83+ {
84+ Trace . TraceWarning ( "[CrashReportHelper] Unable to match GameState file to Game" ) ;
85+ continue ;
86+ }
87+ if ( game . GameStateFileNames == null )
88+ {
89+ game . GameStateFileNames = new List < string > ( ) ;
90+ }
91+
92+ game . GameStateFileNames . Add ( file . Item2 ) ;
93+ }
94+ }
95+ public void AddDesyncs ( IEnumerable < int > desyncs )
96+ {
97+ foreach ( var desync in desyncs )
98+ {
99+ var game = GetGameForIndex ( desync ) ;
100+ if ( game == null )
101+ {
102+ Trace . TraceWarning ( "[CrashReportHelper] Unable to match Desync to Game" ) ;
103+ continue ;
104+ }
105+ if ( ! game . HasDesync )
106+ {
107+ game . FirstDesyncIdxInLog = desync ;
108+ }
109+ }
110+ }
111+ public void AddGameIDs ( IEnumerable < ( int , string ) > gameIDs )
112+ {
113+ foreach ( var gameID in gameIDs )
114+ {
115+ var game = GetGameForIndex ( gameID . Item1 ) ;
116+ if ( game == null )
117+ {
118+ Trace . TraceWarning ( "[CrashReportHelper] Unable to match GameID to Game" ) ;
119+ continue ;
120+ }
121+ if ( game . GameID != null )
122+ {
123+ Trace . TraceWarning ( "[CrashReportHelper] Found multiple GameIDs for Game" ) ;
124+ continue ;
125+ }
126+
127+ game . GameID = gameID . Item2 ;
128+ }
129+ }
130+ }
131+ private static IReadOnlyList < TextTruncator . RegionOfInterest > MakeRegionsOfInterest ( int stringLength , IEnumerable < int > pointsOfInterest , IReadOnlyList < int > regionBoundaries )
132+ {
133+ //Pre:
134+ // pointsOfInterest is sorted
135+ // regionBoundaries is sorted
136+
137+ //Automatically adds a region at the start and end of the string, that can expand to cover the whole string.
138+ //For every element of pointsOfInterest, adds a region with StartLimit/EndLimit corresponding to the relevant regionBoundaries (or the start/end of the string)
139+
140+ var result = new List < TextTruncator . RegionOfInterest > ( ) ;
141+
142+ result . Add ( new TextTruncator . RegionOfInterest { PointOfInterest = 0 , StartLimit = 0 , EndLimit = stringLength } ) ;
143+ result . AddRange ( pointsOfInterest . Select ( poi => {
144+ var regionEndIndex = Utils . LowerBoundIndex ( regionBoundaries , poi ) ;
145+ return new TextTruncator . RegionOfInterest
146+ {
147+ PointOfInterest = poi ,
148+ StartLimit = regionEndIndex == 0 ? 0 : regionBoundaries [ regionEndIndex - 1 ] ,
149+ EndLimit = regionEndIndex == regionBoundaries . Count ? stringLength : regionBoundaries [ regionEndIndex ] ,
150+ } ;
151+ } ) ) ;
152+ result . Add ( new TextTruncator . RegionOfInterest { PointOfInterest = stringLength , StartLimit = 0 , EndLimit = stringLength } ) ;
153+
154+ return result ;
155+ }
156+
157+ private static string EscapeMarkdownTableCell ( string str ) => str . Replace ( "\r " , "" ) . Replace ( "\n " , " " ) . Replace ( "|" , @"\|" ) ;
158+ private static string MakeDesyncGameTable ( GameFromLogCollection gamesFromLog )
159+ {
160+ var tableEmpty = true ;
161+ var sb = new StringBuilder ( ) ;
162+ sb . AppendLine ( "\n \n |GameID|GameState File|" ) ;
163+ sb . AppendLine ( "|-|-|" ) ;
164+
165+ foreach ( var game in gamesFromLog . Games . Where ( g => g . HasDesync && g . GameStateFileNames != null ) )
166+ {
167+ var gameIDString = EscapeMarkdownTableCell ( game . GameID ?? "Unknown" ) ;
168+ foreach ( var gameStateFileName in game . GameStateFileNames )
169+ {
170+ tableEmpty = false ;
171+ sb . AppendLine ( $ "|{ gameIDString } |{ EscapeMarkdownTableCell ( gameStateFileName ) } |") ;
172+ }
173+ }
174+ return tableEmpty ? string . Empty : sb . ToString ( ) ;
175+ }
176+
177+ private static async Task < Issue > ReportCrash ( string infolog , CrashType type , string engine , string bugReportTitle , string bugReportDescription , GameFromLogCollection gamesFromLog )
32178 {
33179 try
34180 {
35- var client = new GitHubClient ( new ProductHeaderValue ( "chobbyla" ) ) ;
36- client . Credentials = new Credentials ( GlobalConst . CrashReportGithubToken ) ;
181+ var client = new GitHubClient ( new ProductHeaderValue ( "chobbyla" ) )
182+ {
183+ Credentials = new Credentials ( GlobalConst . CrashReportGithubToken )
184+ } ;
185+
186+ var infologTruncated = TextTruncator . Truncate ( infolog , MaxInfologSize , MakeRegionsOfInterest ( infolog . Length , gamesFromLog . Games . Where ( g => g . HasDesync ) . Select ( g => g . FirstDesyncIdxInLog . Value ) , gamesFromLog . AsGameStartReadOnlyList ( ) ) ) ;
37187
38- infolog = Truncate ( infolog , MaxInfologSize ) ;
188+ var desyncDebugInfo = MakeDesyncGameTable ( gamesFromLog ) ;
39189
40- var createdIssue =
41- client . Issue . Create ( "ZeroK-RTS" , "CrashReports" , new NewIssue ( $ "Spring { type } [{ engine } ] { bugReportTitle } ") { Body = $ "{ bugReportDescription } \n \n ```{ infolog } ```", } )
42- . Result ;
190+ var newIssueRequest = new NewIssue ( $ "Spring { type } [{ engine } ] { bugReportTitle } ")
191+ {
192+ Body = $ "{ bugReportDescription } { desyncDebugInfo } "
193+ } ;
194+ var createdIssue = await client . Issue . Create ( CrashReportsRepoOwner , CrashReportsRepoName , newIssueRequest ) ;
195+
196+ await client . Issue . Comment . Create ( CrashReportsRepoOwner , CrashReportsRepoName , createdIssue . Number , $ "infolog_full.txt (truncated):\n \n ```{ infologTruncated } ```") ;
43197
44198 return createdIssue ;
45199 }
46200 catch ( Exception ex )
47201 {
48- Trace . TraceWarning ( "Problem reporting a bug: {0}" , ex ) ;
202+ Trace . TraceWarning ( "[CrashReportHelper] Problem reporting a bug: {0}" , ex ) ;
49203 }
50204 return null ;
51205 }
52- public static int FindFirstDesyncMessage ( string logStr )
206+
207+ //All infolog parsing is best-effort only.
208+ //The infolog file format does not have enough structure to guarantee that it is never misinterpreted.
209+
210+ private static int [ ] ReadGameReloads ( string logStr )
53211 {
54- //[t=00:22:43.533864][f=0003461] Sync error for mankarse in frame 3451 (got 927a6f33, correct is 6b550dd1)
212+ //Game reload detected by [f=-000001] that follows either the start of the file, or [f={non-negative value}]
213+
214+ var list = new List < int > ( ) ;
215+
216+ var negativeFrameRegex = new Regex ( @"f=-(?<=(?<s>^)\[t=\d+:\d+:\d+\.\d+\]\[f=-)\d+\]" , RegexOptions . CultureInvariant | RegexOptions . Compiled | RegexOptions . ExplicitCapture | RegexOptions . Multiline , TimeSpan . FromSeconds ( 30 ) ) ;
217+ var nonNegativeFrameRegex = new Regex ( @"f=\d(?<=^\[t=\d+:\d+:\d+\.\d+\]\[f=\d)\d*\]" , RegexOptions . CultureInvariant | RegexOptions . Compiled | RegexOptions . ExplicitCapture | RegexOptions . Multiline , TimeSpan . FromSeconds ( 30 ) ) ;
218+
219+ var idx = 0 ;
220+
55221 try
56222 {
57- //See ZkData.Account.IsValidLobbyName
58- var accountNamePattern = @"[_[\]a-zA-Z0-9]{1,25}" ;
59- var match =
60- Regex . Match (
61- logStr ,
62- $@ "Sync error for(?<={ InfoLogLineStartPattern } Sync error for) { accountNamePattern } in frame \d+ \(got [a-z0-9]+, correct is [a-z0-9]+\){ InfoLogLineEndPattern } ",
63- RegexOptions . CultureInvariant | RegexOptions . Compiled | RegexOptions . ExplicitCapture | RegexOptions . Multiline ,
64- TimeSpan . FromSeconds ( 30 ) ) ;
223+ while ( true )
224+ {
225+ {
226+ var m = negativeFrameRegex . Match ( logStr , idx ) ;
227+ if ( ! m . Success ) break ;
228+ idx = m . Index ;
229+ list . Add ( m . Groups [ "s" ] . Index ) ;
230+ }
231+ {
232+ var m = nonNegativeFrameRegex . Match ( logStr , idx ) ;
233+ if ( ! m . Success ) break ;
234+ idx = m . Index ;
235+ }
236+ }
237+ return list . ToArray ( ) ;
238+ }
239+ catch ( RegexMatchTimeoutException )
240+ {
241+ Trace . TraceError ( "\" [CrashReportHelper] RegexMatchTimeoutException in ReadGameReloads" ) ;
242+ return Array . Empty < int > ( ) ;
243+ }
244+ }
245+ private static ( int , string ) [ ] ReadGameStateFileNames ( string logStr )
246+ {
247+ //See https://github.com/beyond-all-reason/spring/blob/f3ba23635e1462ae2084f10bf9ba777467d16090/rts/System/Sync/DumpState.cpp#L155
65248
66- return match . Success ? match . Index : - 1 ;
249+ //[t=00:22:43.353840][f=0003461] [DumpState] using dump-file "ClientGameState--749245531-[3461-3461].txt"
250+ try
251+ {
252+ return
253+ Regex
254+ . Matches (
255+ logStr ,
256+ $@ "(?<={ InfoLogLineStartPattern } )\[DumpState\] using dump-file ""(?<d>[^{ Regex . Escape ( System . IO . Path . DirectorySeparatorChar . ToString ( ) ) } { Regex . Escape ( System . IO . Path . AltDirectorySeparatorChar . ToString ( ) ) } ""]+)""{ InfoLogLineEndPattern } ",
257+ RegexOptions . CultureInvariant | RegexOptions . Compiled | RegexOptions . ExplicitCapture | RegexOptions . Multiline ,
258+ TimeSpan . FromSeconds ( 30 ) )
259+ . Cast < Match > ( ) . Select ( m => ( m . Index , m . Groups [ "d" ] . Value ) ) . Distinct ( )
260+ . ToArray ( ) ;
67261 }
68- catch ( RegexMatchTimeoutException )
262+ catch ( RegexMatchTimeoutException )
69263 {
70- Trace . TraceError ( "[CrashReportHelper] RegexMatchTimeoutException in FindFirstDesyncMessage " ) ;
71- return - 1 ;
264+ Trace . TraceError ( "\" [CrashReportHelper] RegexMatchTimeoutException in ReadClientStateFileNames " ) ;
265+ return Array . Empty < ( int , string ) > ( ) ;
72266 }
73267 }
74268
75- private static string Truncate ( string infolog , int maxSize )
269+ private static int [ ] ReadDesyncs ( string logStr )
76270 {
77- var firstDesync = FindFirstDesyncMessage ( infolog ) ;
78- var regionsOfInterest = new List < TextTruncator . RegionOfInterest > ( firstDesync == - 1 ? 2 : 3 ) ;
271+ //[t=00:22:43.533864][f=0003461] Sync error for mankarse in frame 3451 (got 927a6f33, correct is 6b550dd1)
79272
80- regionsOfInterest . Add ( new TextTruncator . RegionOfInterest { PointOfInterest = 0 , StartLimit = 0 , EndLimit = infolog . Length } ) ;
81- if ( firstDesync != - 1 )
273+ //See ZkData.Account.IsValidLobbyName
274+ var accountNamePattern = @"[_[\]a-zA-Z0-9]{1,25}" ;
275+ try
276+ {
277+ return
278+ Regex
279+ . Matches (
280+ logStr ,
281+ $@ "Sync error for(?<={ InfoLogLineStartPattern } Sync error for) { accountNamePattern } in frame \d+ \(got [a-z0-9]+, correct is [a-z0-9]+\){ InfoLogLineEndPattern } ",
282+ RegexOptions . CultureInvariant | RegexOptions . Compiled | RegexOptions . ExplicitCapture | RegexOptions . Multiline ,
283+ TimeSpan . FromSeconds ( 30 ) )
284+ . Cast < Match > ( ) . Select ( m => m . Index ) . ToArray ( ) ;
285+ }
286+ catch ( RegexMatchTimeoutException )
82287 {
83- regionsOfInterest . Add ( new TextTruncator . RegionOfInterest { PointOfInterest = firstDesync , StartLimit = 0 , EndLimit = infolog . Length } ) ;
288+ Trace . TraceError ( "\" [CrashReportHelper] RegexMatchTimeoutException in ReadDesyncs" ) ;
289+ return Array . Empty < int > ( ) ;
84290 }
85- regionsOfInterest . Add ( new TextTruncator . RegionOfInterest { PointOfInterest = infolog . Length , StartLimit = 0 , EndLimit = infolog . Length } ) ;
291+ }
292+
293+
294+ private static ( int , string ) [ ] ReadGameIDs ( string logStr )
295+ {
296+ //[t=00:19:00.246149][f=-000001] GameID: 6065f665e92c7942def2c0c17c703e72
86297
87- return TextTruncator . Truncate ( infolog , maxSize , regionsOfInterest ) ;
298+ try
299+ {
300+ return
301+ Regex
302+ . Matches (
303+ logStr ,
304+ $@ "GameID:(?<={ InfoLogLineStartPattern } GameID:) (?<g>[0-9a-zA-Z]+){ InfoLogLineEndPattern } ",
305+ RegexOptions . CultureInvariant | RegexOptions . Compiled | RegexOptions . ExplicitCapture | RegexOptions . Multiline ,
306+ TimeSpan . FromSeconds ( 30 ) )
307+ . Cast < Match > ( ) . Select ( m => { var g = m . Groups [ "g" ] ; return ( g . Index , g . Value ) ; } ) . ToArray ( ) ;
308+ }
309+ catch ( RegexMatchTimeoutException )
310+ {
311+ Trace . TraceError ( "\" [CrashReportHelper] RegexMatchTimeoutException in ReadGameIDs" ) ;
312+ return Array . Empty < ( int , string ) > ( ) ;
313+ }
88314 }
89315
90316 public static void CheckAndReportErrors ( string logStr , bool springRunOk , string bugReportTitle , string bugReportDescription , string engineVersion )
91317 {
92- var syncError = FindFirstDesyncMessage ( logStr ) != - 1 ;
318+ var gamesFromLog = new GameFromLogCollection ( ReadGameReloads ( logStr ) ) ;
319+
320+ gamesFromLog . AddGameStateFileNames ( ReadGameStateFileNames ( logStr ) ) ;
321+ gamesFromLog . AddDesyncs ( ReadDesyncs ( logStr ) ) ;
322+ gamesFromLog . AddGameIDs ( ReadGameIDs ( logStr ) ) ;
323+
324+ var syncError = gamesFromLog . Games . Any ( g => g . HasDesync ) ;
93325 if ( syncError ) Trace . TraceWarning ( "Sync error detected" ) ;
94326
95327 var openGlFail = logStr . Contains ( "No OpenGL drivers installed." ) ||
@@ -139,11 +371,16 @@ public static void CheckAndReportErrors(string logStr, bool springRunOk, string
139371 : luaErr ? CrashType . LuaError
140372 : CrashType . Crash ;
141373
142- var ret = ReportCrash ( logStr ,
143- crashType ,
144- engineVersion ,
145- bugReportTitle ,
146- bugReportDescription ) ;
374+ var ret =
375+ ReportCrash (
376+ logStr ,
377+ crashType ,
378+ engineVersion ,
379+ bugReportTitle ,
380+ bugReportDescription ,
381+ gamesFromLog )
382+ . GetAwaiter ( ) . GetResult ( ) ;
383+
147384 if ( ret != null )
148385 try
149386 {
0 commit comments