File size: 25,882 Bytes
b6a38d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
local logs_folder				= "AppData/crashes"

function GatherMinidumps(ignore_pattern)
	local err, files = AsyncListFiles(logs_folder, "*.dmp", "recursive,modified")
	if err then
		print(string.format("Crash folder enum error: %s", err))
		return
	end
	
	if ignore_pattern then
		for i = #files, 1, -1 do
			local filepath = files[i]
			local _, filename = SplitPath(filepath)
			if string.match(filename, ignore_pattern) then
				table.remove(files, i)
				table.remove(files.modified, i)
			end
		end
	end
	
	return files
end

local function check(str, what)
	return string.starts_with(str, what, true)
end
	
function CrashFileParse(crash_file)
	local info = {}
	local crash_section_found, crash_section_complete
	local err, lines = AsyncFileToString(crash_file, nil, nil, "lines")
	if err then
		return err
	end
	PauseInfiniteLoopDetection("CrashFileParse")
	local crash_keys = { "Thread", "Module", "Address", "Function", "Process", "Error", "Details" }
	local header_keys = {"Lua revision", "Timestamp", "CPU", "GPU" }
	local patterns = {
		["Lua revision"] = '^Lua revision:%s*(%d+)',
		["Timestamp"] = "^Timestamp:%s*(%x+)",
		["CPU"] = "^CPU%s*(.+)",
		["GPU"] = "^GPU%s*(.+)",
	}
	local values = {}
	local _
	local bR = string.byte("R")
	local b_ = string.byte("-")
	local bkeys, hkeys = {}, {}
	for i, key in ipairs(crash_keys) do
		bkeys[i] = string.byte(key)
	end
	for i, key in ipairs(header_keys) do
		hkeys[i] = string.byte(key)
	end
	for i, line in ipairs(lines) do
		local b = string.byte(line)
		
		for i, key in ipairs(header_keys) do
			if b == hkeys[i] and check(line, key) then
				local pattern = patterns[key] or ('^' .. key .. ':%s+(.+)$')
				local value = string.match(line, pattern)
				value = value and string.trim_spaces(value)
				if value then
					value = string.gsub(value, "[\n\r]", "")
					if key == "GPU" then
						local idx = string.find_lower(value, 'Feature Level') or string.find_lower(value, '{')
						if idx then
							value = string.sub(value, 1, idx - 1)
							value = string.trim_spaces(value)
						end
					elseif key == "CPU" then
						if string.starts_with(value, 'name', true) then
							value = string.sub(value, 5)
							value = string.trim_spaces(value)
						end
					end
					info[#info + 1] = key .. ": " .. value
					values[key] = value
					table.remove(header_keys, i)
					table.remove(hkeys, i)
				end
				break
			end
		end
		
		if crash_section_complete then
			--
		elseif not crash_section_found then
			if b_ == b and check(line, "-- Exception Information") then
				crash_section_found = true
			end
		else
			if b == bR and check(line, "Registers:") or #crash_keys == 0 then
				crash_section_complete = true
			else
				for i, key in ipairs(crash_keys) do
					if b == bkeys[i] and check(line, key) then
						local value = string.match(line, '^' .. key .. ':%s+(.+)$')
						value = value and string.trim_spaces(value)
						if value then
							info[#info + 1] = key .. ": " .. value
							if key == "Thread" then
								_, value = string.match(value, '^(%d+)%s*\"(.+)\"$')
							elseif key == "Address" then
								value = string.sub(value, -4)
							end
							values[key] = value
							table.remove(crash_keys, i)
							table.remove(bkeys, i)
						end
						break
					end
				end
			end
		end
		if (#crash_keys == 0 or crash_section_complete) and (#header_keys == 0 or i > 1024) then
			break
		end
	end
	ResumeInfiniteLoopDetection("CrashFileParse")
	if not crash_section_found then
		return "Crash info not found"
	end
	local hash = xxhash(values.Address, values.Thread, values.Error, values.Details)
	local label = string.format("[Crash] @%s%s%s (%s) %s%s%s", 
		values.Address or "", values.Function and " " or "", values.Function or "",
		values.Thread or "",
		values.Error or "", values.Details and ": " or "", values.Details or "")
	local revision = values["Lua revision"]
	local revision_num = revision and tonumber(revision) or 0
	local info_str = table.concat(info, "\n")
	return nil, info_str, label, values, revision_num, hash
end

function CrashUploadToMantis(minidumps)
	local exception_info = {}
	local min_revision = config.BugReportCrashesMinRevision or 0
	local unmount
	local function report(dump_file)
		local dump_dir, dump_name, dump_ext = SplitPath(dump_file)
		local crash_file = dump_dir .. dump_name .. ".crash"
		local err, info_str, label, values, revision_num, hash = CrashFileParse(crash_file)
		if err or not info_str or revision_num < min_revision or exception_info[hash] then
			return
		end
		exception_info[hash] = true
		if MountsByPath("memorytmp") == 0 then
			local err = MountPack("memorytmp", "", "create", 16*1024*1024)
			if err then
				print("MountPack error:", err)
				return
			end
			unmount = true
		end
		local pack_file = "memorytmp/" .. dump_name .. ".hpk"
		local pack_index = {
			{ src = dump_file, dst = dump_name .. ".dmp", },
		}
		local err, log = AsyncPack(pack_file, "", pack_index)
		if err then
			print("Pack error:", err)
			return
		end
		
		--print(os.date("%T"), ConvertToOSPath(crash_file))
		local files = { crash_file, pack_file }
		local descr = "All crash and dump files are already attached." 
		WaitXBugReportDlg(label, descr, files, {
			summary_readonly = true,
			no_screenshot = true,
			no_extra_info = true,
			append_description = "\n----\n" .. info_str,
			tags = { "Crash" },
			severity = "crash",
		})
		AsyncFileDelete(pack_file)
	end
	for _, minidump in ipairs(minidumps) do
		report(minidump)
	end
	if unmount then
		local err = UnmountByPath("memorytmp")
		if err then
			print("UnmountByPath error:", err)
			return
		end
	end
end

function MinidumpUploadAsync(url, os_path)
	local err, json = LuaToJSON({upload_file_minidump=os_path})
	if err then
		print("Failed to convert minidump data to JSON", err)
		return
	end
	local err, info = AsyncWebRequest{
		url = url,
		method = "POST",
		headers = {["Content-Type"] = "application/json"},
		body = json,
	}
	err = err or info and info.error
	if err then
		print(string.format("Minidump upload fail: %s", err))
	end
end

function GetCrashFiles(file_spec)
	local _, crash_files = AsyncListFiles(logs_folder, file_spec or "*.crash", "recursive,modified")
	crash_files = crash_files or {}
	local crash_date, index = table.max(crash_files.modified)
	
	return crash_files, crash_files[index], crash_date, index
end

function EmptyCrashFolder()
	return AsyncEmptyPath(logs_folder)
end

function CrashReportingEnabled()
	if not Platform.pc then return end
	return config.UploadMinidump or config.BugReportCrashesOnStartup
end

function RenameCrashPair(minidump, new_minidump)
	AsyncFileRename(minidump, new_minidump)
	local crash_file = string.gsub(minidump, ".dmp$", ".crash")
	local new_crash_file = string.gsub(new_minidump, ".dmp$", ".crash")
	AsyncFileRename(crash_file, new_crash_file)
end

if FirstLoad then
	g_bCrashReported = false
end

function WaitBugReportCrashesOnStartup()
	local _, minidump = GetCrashFiles("*.dmp")
	if not minidump then return end
	CrashUploadToMantis({minidump})
	EmptyCrashFolder()
end

function OnMsg.EngineStarted()
	if not config.BugReportCrashesOnStartup or g_bCrashReported then return end
	g_bCrashReported = true
	CreateRealTimeThread(WaitBugReportCrashesOnStartup)
end

----

if FirstLoad then
	SymbolsFolders = false
	GedFolderCrashesInstance = false
	CrashCache = false
	CrashFilter = false
	CrashResolved = false
end

CrashCacheVersion = 4
local base_cache_folder = "AppData/CrashCache/"
local cache_file = base_cache_folder .. "CrashCache.bin"
local resolved_file = ConvertToBenderProjectPath("Logs/Crashes/__Resolved.lua")

CrashFolderSymbols = ConvertToBenderProjectPath("Logs/Pdbs/")
CrashFolderBender = ConvertToBenderProjectPath("Logs/Crashes")
CrashFolderSwarm = ConvertToBenderProjectPath("SwarmBackup/*/Storage/log-crash")
CrashFolderLocal = "AppData/crashes"

local defaults_groups = {
	SwarmBackup = ">Swarm",
}

local CrashInfoButtons = {
	{name = "LocateSymbols", func = "SymbolsFolderOpen"},
}

DefineClass.CrashInfo = {
	__parents = {"PropertyObject"},
	properties = {
		{ category = "Actions", id = "Actions", editor = "buttons", default = "", buttons = CrashInfoButtons },
		
		{ category = "Crash", id = "ExeTimestamp", name = "Exe Timestamp", editor = "text", default = "" },
		{ category = "Crash", id = "SymbolsFolder", name = "Symbols Folder", editor = "text", default = "", buttons = {{name = "Open", func = "SymbolsFolderOpen"}} },
	}
}

function CrashInfo:SymbolsFolderOpen()
	local bdb_folder = self.SymbolsFolder
	if bdb_folder ~= 0 then
		local os_command = string.format("cmd /c start \"\" \"%s\"", bdb_folder)
		os.execute(os_command)
	end
end

function CrashInfo:GetCacheFolder()
	local timestamp = self.ExeTimestamp
	if timestamp == "" then
		return "Missing timestamp!"
	end
	local cache_folder = base_cache_folder .. timestamp .. "/"
	if not io.exists(cache_folder) then
		local err = AsyncCreatePath(cache_folder)
		if err then
			return err
		end
	end
	return nil, cache_folder
end

function GedFolderCrashesRun(get)
	local crash = get.selected_object
	if crash then crash:OpenLogFile() end
end

DefineClass.FolderCrashGroup = {
	__parents = {"SortedBy", "GedFilter" },
	properties = {
		{ id = "name", editor = "text", default = "", read_only = true, buttons = {{name = "Export", func = "ExportToCSV"}} },
		{ id = "count", editor = "number", default = 0, read_only = true },
		{ id = "thread", name = "Show Thread", editor = "combo", default = false, items = function(self) return table.keys(self.threads, true) end },
		{ id = "timestamp", name = "Show Timestamp", editor = "combo", default = false, items = function(self) return table.keys(self.timestamps, true) end },
		{ id = "filter", name = "Show Name", editor = "combo", default = false, items = function(self) return table.keys(self.names, true) end },
		{ id = "cpu", name = "Show CPU", editor = "combo", default = false, items = function(self) return table.keys(self.cpus, true) end },
		{ id = "gpu", name = "Show GPU", editor = "combo", default = false, items = function(self) return table.keys(self.gpus, true) end },
		{ id = "unique", name = "Show Unique Only", editor = "bool", default = false },
		{ id = "resolved", name = "Show Resolved", editor = "bool", default = false },
		{ id = "shown_count", name = "Shown Count", editor = "number", default = 0, read_only = true },
	},
	shown = false,
	names = false,
	timestamps = false,
	threads = false,
	cpus = false,
	gpus = false,
}

function FolderCrashGroup:PrepareForFiltering()
	self.shown = {}
	self.shown_count = 0
end

function FolderCrashGroup:FilterObject(obj)
	local name = obj.name
	if self.unique then
		if self.shown[name] then
			return
		end
		self.shown[name] = true
	end
	if not self.resolved and CrashResolved and CrashResolved[obj.hash] then
		return
	end
	local timestamp = self.timestamp
	if timestamp and timestamp ~= obj.ExeTimestamp then
		return
	end
	local thread = self.thread
	if thread and thread ~= obj.thread then
		return
	end
	local cpu = self.cpu
	if cpu and cpu ~= obj.CPU then
		return
	end
	local gpu = self.gpu
	if gpu and gpu ~= obj.GPU then
		return
	end
	local filter = self.filter
	if filter and filter ~= name and not string.find(name, filter) then
		return
	end
	self.shown_count = self.shown_count + 1
	return true
end

function FolderCrashGroup:ExportToCSV()
	local name = string.starts_with(self.name, ">") and string.sub(self.name, 2) or self.name
	local path = base_cache_folder .. name .. ".csv"
	local err = SaveCSV(path, self, {"name", "thread", "date", "CPU", "GPU", "ExeTimestamp"}, {"name", "thread", "date", "CPU", "GPU", "Exe"})
	if err then
		print(err, "while exporting", path)
	else
		print("Exported to", path)
		OpenTextFileWithEditorOfChoice(path)
	end
end

function FolderCrashGroup:GetSortItems()
	return {"name", "timestamp", "thread", "date", "CPU", "GPU", "occurrences"}
end

function FolderCrashGroup:Cmp(c1, c2, sort_by)
	local n1, n2 = c1.name, c2.name
	local ts1, ts2 = c1.ExeTimestamp, c2.ExeTimestamp
	local d1, d2 = c1.DmpTimestamp, c2.DmpTimestamp
	local CPU1, CPU2 = c1.CPU, c2.CPU
	local GPU1, GPU2 = c1.GPU, c2.GPU
	local o1, o2 = c1.occurrences, c2.occurrences
	
	if sort_by == "occurrences" then
		if o1 ~= o2 then
			return o1 > o2
		end
	elseif sort_by == "date" then
		if d1 ~= d2 then
			return d1 < d2
		end
	elseif sort_by == "thread" then
		local t1, t2 = c1.thread, c2.thread
		if t1 ~= t2 then
			return t1 < t2
		end
	elseif sort_by == "timestamp" then
		if ts1 ~= ts2 then
			return ts1 < ts2
		end
	elseif sort_by == "CPU" then
		if CPU1 ~= CPU2 then
			return CPU1 < CPU2
		end
	elseif sort_by == "GPU" then
		if GPU1 ~= GPU2 then
			return GPU1 < GPU2
		end
	end
	if n1 ~= n2 then
		return n1 < n2
	end
	if ts1 ~= ts2 then
		return ts1 < ts2
	end
	if d1 ~= d2 then
		return d1 < d2
	end
	if o1 ~= o2 then
		return o1 > o2
	end
	if GPU1 ~= GPU2 then
		return GPU1 < GPU2
	end
	if CPU1 ~= CPU2 then
		return CPU1 < CPU2
	end
end

function FolderCrashGroup:GetEditorView()
	return string.format("%s  <color 128 128 128>%d</color>", self.name, self.count)
end

local FolderCrashButtons = {
	{name = "DebugInVS", func = "DebugDump"},
	{name = "LocateSymbols", func = "SymbolsFolderOpen"},
	{name = "OpenLog", func = "OpenLogFile"},
	{name = "Resolve", func = "ResolveCrash"},
}

DefineClass.FolderCrash = {
	__parents = {"CrashInfo"},
	properties = {
		{ category = "Actions", id = "Actions", editor = "buttons", default = "", buttons = FolderCrashButtons },
		{ category = "Actions", id = "Resolved", editor = "bool", default = false, read_only = true },
		
		{ category = "Crash", id = "ModuleName", editor = "text", default = "" },
		{ category = "Crash", id = "LocalModuleName", editor = "text", default = "", help = "Use it to change the symbols name locally, if the expected PDB name do not match" },
		
		{ category = "Crash", id = "name", name = "Summary", editor = "text", default = "" },
		{ category = "Crash", id = "occurrences", name = "Occurrences", editor = "number", default = 0, },
		{ category = "Crash", id = "date", name = "Dmp Date", editor = "text", default = "" },
		{ category = "Crash", id = "DmpTimestamp", name = "Dmp Timestamp", editor = "number", default = 0 },
		{ category = "Crash", id = "thread", editor = "text", default = "" },
		{ category = "Crash", id = "CPU", editor = "text", default = "" },
		{ category = "Crash", id = "GPU", editor = "text", default = "" },
		{ category = "Crash", id = "full_path", name = "Log Path", editor = "text", default = "", buttons = {{name = "Open", func = "OpenLogFile"}}, },
		{ category = "Crash", id = "crash_info", name = "Full Info", editor = "text", default = "", max_lines = 30, lines = 10, },
		
		{ category = "Crash", id = "dump_file", name = "text", editor = "text", default = "", no_edit = true, },
		{ category = "Crash", id = "group", name = "text", editor = "text", default = "", no_edit = true, },
		{ category = "Crash", id = "values", editor = "prop_table", default = false, no_edit = true, },
		{ category = "Crash", id = "hash", editor = "number", default = false, no_edit = true, },
	},
	StoreAsTable = true,
	CustomModuleName = false,
}

function WaitSaveCrashResolved()
	local code = pstr("return ", 1024)
	TableToLuaCode(CrashResolved, nil, code)
	local err = AsyncStringToFile(resolved_file, code)
	if err then
		print("once", "Failed to save the resolved crashes to", resolved_file, ":", err)
	end
end

function FolderCrash:GetModuleNameRaw()
	local module_file = self.values and self.values.Module
	if (module_file or "") == "" then
		return ""
	end
	local module_dir, module_name, module_ext = SplitPath(module_file)
	return module_name
end

function FolderCrash:GetModuleName()
	if (self.CustomModuleName or "") ~= "" then
		return self.CustomModuleName
	end
	return self:GetModuleNameRaw()
end

function FolderCrash:SetModuleName(module_name)
	self.CustomModuleName = (module_name or "") ~= "" and module_name ~= self:GetModuleNameRaw() and module_name or nil
end

function FolderCrash:GetResolved()
	return CrashResolved and CrashResolved[self.hash]
end

function FolderCrash:ResolveCrash(root, prop_id, ged)
	if self:GetResolved() then
		print(self.name, "is already resolved")
		return
	end
	if ged:WaitQuestion("Resolve", string.format("Mark crash \"%s\" as resolved?", self.name), "Yes", "No") ~= "ok" then
		return
	end
	CrashResolved = CrashResolved or {}
	CrashResolved[self.hash] = self.name .. " " .. self.ExeTimestamp
	DelayedCall(0, WaitSaveCrashResolved)
end

function FolderCrash:GetEditorView()
	local resolved = self:GetResolved()
	local color_start = resolved and "RESOLVED <color 128 128 128>" or ""
	local color_end = resolved and "</color>" or ""
	return string.format("<style GedMultiLine>%s%s%s <color 64 128 196>%s</color> <color 64 196 128>%s</color> <color 196 128 64>%s</color></style>",
		color_start, self.name, color_end, self.ExeTimestamp, self.CPU, self.GPU)
end

function FolderCrash:OpenLogFile()
	local full_path = self.full_path or ""
	if full_path ~= "" then
		OpenTextFileWithEditorOfChoice(full_path)
	end
end

function CopySymbols(cache_folder, src_folder, module_name, local_name)
	if (module_name or "") == "" then
		return "Invalid param!"
	end
	if (local_name or "") == "" then
		local_name = module_name
	end
	local pdbfile = cache_folder .. local_name .. ".pdb"
	if io.exists(pdbfile) then
		print("Using locally cached", pdbfile)
		return
	end
	if src_folder == "" then
		return "Symbols folder not found!"
	end
	local err, files = AsyncListFiles(src_folder, module_name .. ".*")
	if err then
		return print_format("Failed to list", src_folder, ":", err)
	end
	for _, file in ipairs(files) do
		local file_dir, file_name, file_ext =  SplitPath(file)
		local dest = cache_folder .. local_name .. file_ext
		print("Copying", file, "to", dest)
		local err = AsyncCopyFile(file, dest, "raw")
		if err then
			return print_format("Failed to copy", file, ":", err)
		end
	end
	if not io.exists(pdbfile) then
		return print_format("No symbols found at", src_folder)
	end
end

function FolderCrash:DebugDump()
	if not Platform.pc then
		print("Supported on PC only!")
		return
	end
	local err
	local module_name = self:GetModuleName() or ""
	if module_name == "" or string.lower(module_name) == "unknown" then
		print("Invalid module name!")
		return
	end
	local err, cache_folder = self:GetCacheFolder()
	if err then
		print("Failed to create working directory:", err)
		return
	end
	local orig_dump_file = self.dump_file
	local orig_dump_dir, dump_name, dump_ext = SplitPath(orig_dump_file)
	local dump_file = cache_folder .. dump_name .. dump_ext
	if not io.exists(dump_file) then
		if not io.exists(orig_dump_file) then
			print("No dump pack found!")
			return
		end
		local err = AsyncCopyFile(orig_dump_file, dump_file, "raw")
		if err then
			print("Failed to copy", orig_dump_file, ":", err)
			return
		end
	end
	local err = CopySymbols(cache_folder, self.SymbolsFolder, module_name, self.LocalModuleName)
	if err then
		print("Copy symbols error:", err)
		return
	end
	local os_path = ConvertToOSPath(dump_file)
	local os_command = string.format("cmd /c start \"\" \"%s\"", os_path)
	os.execute(os_command)
end

function FetchSymbolsFolders()
	local err
	local st = GetPreciseTicks()
	err, SymbolsFolders = AsyncListFiles(CrashFolderSymbols, "*", "folders")
	if err then
		print("Failed to fetch symbols folders from Bender:", err)
		SymbolsFolders = {}
	end
	print(#SymbolsFolders, "symbol folders found in", GetPreciseTicks() - st, "ms at", CrashFolderSymbols)
end

function ResolveSymbolsFolder(timestamp)
	if (timestamp or "") == "" then
		return
	end
	assert(SymbolsFolders)
	for _, folder in ipairs(SymbolsFolders) do
		if string.ends_with(folder, timestamp, true) then
			return folder
		end
	end
end

function OpenCrashFolderBrowser(location, timestamp)
	CreateRealTimeThread(WaitOpenCrashFolderBrowser, location, timestamp)
end

function WaitOpenCrashFolderBrowser(location, timestamp)
	print("Opening crash folder browser at", location)
	FetchSymbolsFolders()
	if not CrashCache then
		local err, str = AsyncFileToString(cache_file)
		if not err then
			CrashCache = dostring(str)
		end
		if not CrashCache or CrashCache.version ~= CrashCacheVersion then
			CrashCache = { version = CrashCacheVersion }
		end
	end
	if not CrashResolved then
		local err, str = AsyncFileToString(resolved_file)
		if not err then
			CrashResolved = dostring(str)
		end
		if not CrashResolved then
			CrashResolved = {}
		end
	end
	local to_read, to_delete = {}, {}
	local to_delete_count = 0
	local groups = {}
	local total_count = 0
	local function AddCrashTo(crash, crash_name, group_name)
		local group = groups[group_name]
		if not group then
			group = FolderCrashGroup:new{ name = group_name }
			groups[group_name] = group
			groups[#groups + 1] = group
		end
		group[#group + 1] = crash
	end
	local skipped = 0
	local function AddCrash(crash, group_name)
		if timestamp and timestamp ~= crash.ExeTimestamp then
			skipped = skipped + 1
			return
		end
		AddCrashTo(crash, crash.name, crash.group)
		AddCrashTo(crash, crash.name, ">All")
		total_count = total_count + 1
	end
	local created = 0
	local read = 0
	local function ReadCrash(info)
		read = read + 1
		local crashfile, folder = info[1], info[2]
		local file_dir, file_name, file_ext = SplitPath(crashfile)
		local dump_file = file_dir .. file_name .. ".dmp"
		local err, info, label, values, revision_num, hash, DmpTimestamp
		err, DmpTimestamp = AsyncGetFileAttribute(dump_file, "timestamp")
		if err then
			print(err, "while getting timestamp of", dump_file)
		else
			err, info, label, values, revision_num, hash = CrashFileParse(crashfile)
			if err then
				print(err, "error while reading", crashfile)
			end
		end
		if err then
			to_delete_count = to_delete_count + 1
			to_delete[#to_delete + 1] = crashfile
			to_delete[#to_delete + 1] = dump_file
			return
		end
		local group_name = string.sub(file_dir, #folder + 2)
		if group_name == "" then
			group_name = ">Ungrouped"
			for pattern, name in pairs(defaults_groups) do
				if file_dir:find(pattern) then
					group_name = name
					break
				end
			end
		else
			group_name = group_name:sub(1, -2)
			group_name = group_name:gsub("\\", "/")
		end
		local crash = FolderCrash:new{
			dump_file = dump_file,
			group = group_name,
			folder = file_dir,
			name = label,
			full_path = crashfile,
			crash_info = info,
			date = os.date("%y/%m/%d %H:%M:%S", DmpTimestamp),
			DmpTimestamp = DmpTimestamp,
			ExeTimestamp = values.Timestamp,
			SymbolsFolder = ResolveSymbolsFolder(values.Timestamp),
			CPU = values.CPU,
			GPU = values.GPU,
			thread = values.Thread,
			values = values,
			hash = hash,
		}
		CrashCache[crashfile] = crash
		AddCrash(crash)
		created = created + 1
		if read % 100 == 0 then
			print(#to_read - read, "remaining...")
		end
	end
	
	local folders
	if type(location) == "string" then
		folders = { location }
	elseif type(location) == "table" then
		folders = location
	else
		folders = { CrashFolderBender }
	end
	print("Fetching folder structure...")
	while true do
		local found
		for i=#folders,1,-1 do
			local folder = folders[i]
			local star_i = folder:find_lower("*")
			if star_i then
				found = true
				table.remove(folders, i)
				local base = folder:sub(1, star_i - 1)
				local sub = folder:sub(star_i + 1)
				local err, subfolders = AsyncListFiles(base, "*", "folders")
				if err then
					print("Failed to fetch issues from", base, ":", err)
				else
					for _, subfolder in ipairs(subfolders) do
						local f1 = subfolder .. sub
						if io.exists(f1) then
							folders[#folders + 1] = f1
						end
					end
				end
			end
		end
		if not found then
			break
		end
	end
	for _, folder in ipairs(folders) do
		if folder:ends_with("/") or folder:ends_with("\\") then
			folder = folder:sub(1, -2)
		end
		local st = GetPreciseTicks()
		local err, files = AsyncListFiles(folder, "*.crash", "recursive")
		if err then
			printf("Failed to fetch issues (%s) from '%s'", err, folder)
		else
			printf("%d crashes found in '%s'", #files, folder)
			for i, crashfile in ipairs(files) do
				local group_name
				local cache = CrashCache[crashfile]
				if cache then
					AddCrash(cache)
				else
					to_read[#to_read + 1] = { crashfile, folder }
				end
			end
		end
	end
	local st = GetPreciseTicks()
	parallel_foreach(to_read, ReadCrash)
	table.sortby_field(groups, "name")
	for _, group in ipairs(groups) do
		local names, timestamps, threads, gpus, cpus = {}, {}, {}, {}, {}
		group.names = names
		group.timestamps = timestamps
		group.threads = threads
		group.gpus = gpus
		group.cpus = cpus
		for _, crash in ipairs(group) do
			local name = crash.name
			names[name] = (names[name] or 0) + 1
			timestamps[crash.ExeTimestamp] = true
			threads[crash.thread] = true
			gpus[crash.GPU] = true
			cpus[crash.CPU] = true
		end
		for _, crash in ipairs(group) do
			crash.occurrences = names[crash.name]
		end
		group:Sort()
		group.count = #group
	end
	print("Crashes processed:", total_count, ", skipped:", skipped, ", time:", GetPreciseTicks() - st, "ms")
	if created > 0 then
		local code = pstr("return ", 1024)
		TableToLuaCode(CrashCache, nil, code)
		AsyncCreatePath(base_cache_folder)
		local err = AsyncStringToFile(cache_file, code, -2, 0, "zstd")
		if err then
			print("once", "Failed to save the crash cache to", cache_file, ":", err)
		end
	end
	local ged = OpenGedAppSingleton("GedFolderCrashes", groups)
	ged:SetSelection("root", { 1 }, nil, not "notify")
	if to_delete_count > 0 then
		if "ok" == WaitQuestion(terminal.desktop, "Warning", string.format("Confirm removal of %s invalid crash files?", to_delete_count)) then
			local err = AsyncFileDelete(to_delete)
			if err then
				print(err, "while deleting invalid crash files!")
			else
				print(to_delete_count, "invalid crash files removed.")
			end
		end
	end
end