diff --git a/ChobbyLauncher/ChobbyLauncher.csproj b/ChobbyLauncher/ChobbyLauncher.csproj
index 2dfab0b91..19aff4d56 100644
--- a/ChobbyLauncher/ChobbyLauncher.csproj
+++ b/ChobbyLauncher/ChobbyLauncher.csproj
@@ -84,6 +84,7 @@
+
@@ -162,7 +163,7 @@
-
+
diff --git a/ChobbyLauncher/CrashReportHelper.cs b/ChobbyLauncher/CrashReportHelper.cs
index 1fb8f67b4..13c1d7c23 100644
--- a/ChobbyLauncher/CrashReportHelper.cs
+++ b/ChobbyLauncher/CrashReportHelper.cs
@@ -1,17 +1,15 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
-using System.IO;
using System.Linq;
-using System.Runtime.InteropServices;
using System.Text;
+using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows.Forms;
using GameAnalyticsSDK.Net;
using Octokit;
using PlasmaShared;
using ZkData;
-using FileMode = System.IO.FileMode;
namespace ChobbyLauncher
{
@@ -24,110 +22,306 @@ public enum CrashType {
public static class CrashReportHelper
{
- private const string TruncatedString = "------- TRUNCATED -------";
+ private const string CrashReportsRepoOwner = "ZeroK-RTS";
+ private const string CrashReportsRepoName = "CrashReports";
+
private const int MaxInfologSize = 62000;
- public static Issue ReportCrash(string infolog, CrashType type, string engine, string bugReportTitle, string bugReportDescription)
+ private const string InfoLogLineStartPattern = @"(^\[t=\d+:\d+:\d+\.\d+\]\[f=-?\d+\] )";
+ private const string InfoLogLineEndPattern = @"(\r?\n|\Z)";
+ private sealed class GameFromLog
{
- try
+ public int StartIdxInLog { get; set; }
+ public string GameID { get; set; }
+ public bool HasDesync { get => FirstDesyncIdxInLog.HasValue; }
+ public int? FirstDesyncIdxInLog { get; set; }
+ public List GameStateFileNames { get; set; }
+
+ //Perhaps in future versions, these could be added?
+ //PlayerName
+ //DemoFileName
+ //MapName
+ //ModName
+ }
+
+ private sealed class GameFromLogCollection
+ {
+ public IReadOnlyList Games { get; private set; }
+ public GameFromLogCollection(IEnumerable startIndexes)
{
- var client = new GitHubClient(new ProductHeaderValue("chobbyla"));
- client.Credentials = new Credentials(GlobalConst.CrashReportGithubToken);
+ Games = startIndexes.Select(idx => new GameFromLog { StartIdxInLog = idx }).ToArray();
+ }
-
- infolog = Truncate(infolog, MaxInfologSize);
+ private readonly struct GameStartList : IReadOnlyList
+ {
+ private readonly GameFromLogCollection _this;
+ public GameStartList(GameFromLogCollection v) => _this = v;
- var createdIssue =
- client.Issue.Create("ZeroK-RTS", "CrashReports", new NewIssue($"Spring {type} [{engine}] {bugReportTitle}") { Body = $"{bugReportDescription}\n\n```{infolog}```", })
- .Result;
+ int IReadOnlyList.this[int index] => _this.Games[index].StartIdxInLog;
- return createdIssue;
+ int IReadOnlyCollection.Count { get => _this.Games.Count; }
+
+ IEnumerator IEnumerable.GetEnumerator() => _this.Games.Select(g => g.StartIdxInLog).GetEnumerator();
+
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => ((IEnumerable)this).GetEnumerator();
}
- catch (Exception ex)
+ public IReadOnlyList AsGameStartReadOnlyList() => new GameStartList(this);
+
+ private GameFromLog GetGameForIndex(int idx)
{
- Trace.TraceWarning("Problem reporting a bug: {0}", ex);
+ //Equivalent to:
+ //return Games.LastOrDefault(g => g.StartIdxInLog < idx);
+ //but takes advantage of the fact that Games is sorted to have log rather than linear runtime.
+ var lb = AsGameStartReadOnlyList().LowerBoundIndex(idx);
+ return lb == 0 ? null : Games[lb - 1];
}
- return null;
- }
+ public void AddGameStateFileNames(IEnumerable<(int, string)> gameStateFileNames)
+ {
+ foreach (var file in gameStateFileNames)
+ {
+ var game = GetGameForIndex(file.Item1);
+ if (game == null)
+ {
+ Trace.TraceWarning("[CrashReportHelper] Unable to match GameState file to Game");
+ continue;
+ }
+ if (game.GameStateFileNames == null)
+ {
+ game.GameStateFileNames = new List();
+ }
+
+ game.GameStateFileNames.Add(file.Item2);
+ }
+ }
+ public void AddDesyncs(IEnumerable desyncs)
+ {
+ foreach (var desync in desyncs)
+ {
+ var game = GetGameForIndex(desync);
+ if (game == null)
+ {
+ Trace.TraceWarning("[CrashReportHelper] Unable to match Desync to Game");
+ continue;
+ }
+ if (!game.HasDesync)
+ {
+ game.FirstDesyncIdxInLog = desync;
+ }
+ }
+ }
+ public void AddGameIDs(IEnumerable<(int, string)> gameIDs)
+ {
+ foreach (var gameID in gameIDs)
+ {
+ var game = GetGameForIndex(gameID.Item1);
+ if (game == null)
+ {
+ Trace.TraceWarning("[CrashReportHelper] Unable to match GameID to Game");
+ continue;
+ }
+ if (game.GameID != null)
+ {
+ Trace.TraceWarning("[CrashReportHelper] Found multiple GameIDs for Game");
+ continue;
+ }
- public static bool IsDesyncMessage(string msg)
+ game.GameID = gameID.Item2;
+ }
+ }
+ }
+ private static IReadOnlyList MakeRegionsOfInterest(int stringLength, IEnumerable pointsOfInterest, IReadOnlyList regionBoundaries)
{
- return !string.IsNullOrEmpty(msg) && msg.Contains(" Sync error for ") && msg.Contains(" in frame ") && msg.Contains(" correct is ");
+ //Pre:
+ // pointsOfInterest is sorted
+ // regionBoundaries is sorted
+
+ //Automatically adds a region at the start and end of the string, that can expand to cover the whole string.
+ //For every element of pointsOfInterest, adds a region with StartLimit/EndLimit corresponding to the relevant regionBoundaries (or the start/end of the string)
+
+ var result = new List();
+
+ result.Add(new TextTruncator.RegionOfInterest { PointOfInterest = 0, StartLimit = 0, EndLimit = stringLength });
+ result.AddRange(pointsOfInterest.Select(poi => {
+ var regionEndIndex = Utils.LowerBoundIndex(regionBoundaries, poi);
+ return new TextTruncator.RegionOfInterest
+ {
+ PointOfInterest = poi,
+ StartLimit = regionEndIndex == 0 ? 0 : regionBoundaries[regionEndIndex - 1],
+ EndLimit = regionEndIndex == regionBoundaries.Count ? stringLength : regionBoundaries[regionEndIndex],
+ };
+ }));
+ result.Add(new TextTruncator.RegionOfInterest { PointOfInterest = stringLength, StartLimit = 0, EndLimit = stringLength });
+
+ return result;
}
+ private static string EscapeMarkdownTableCell(string str) => str.Replace("\r", "").Replace("\n", " ").Replace("|", @"\|");
+ private static string MakeDesyncGameTable(GameFromLogCollection gamesFromLog)
+ {
+ var tableEmpty = true;
+ var sb = new StringBuilder();
+ sb.AppendLine("\n\n|GameID|GameState File|");
+ sb.AppendLine("|-|-|");
+
+ foreach (var game in gamesFromLog.Games.Where(g => g.HasDesync && g.GameStateFileNames != null))
+ {
+ var gameIDString = EscapeMarkdownTableCell(game.GameID ?? "Unknown");
+ foreach (var gameStateFileName in game.GameStateFileNames)
+ {
+ tableEmpty = false;
+ sb.AppendLine($"|{gameIDString}|{EscapeMarkdownTableCell(gameStateFileName)}|");
+ }
+ }
+ return tableEmpty ? string.Empty : sb.ToString();
+ }
- private static string Truncate(string infolog, int maxSize)
+ private static async Task ReportCrash(string infolog, CrashType type, string engine, string bugReportTitle, string bugReportDescription, GameFromLogCollection gamesFromLog)
{
- if (infolog.Length > maxSize) // truncate infolog in middle
+ try
{
- var lines = infolog.Lines();
- var firstPart = new List();
- var lastPart = new List();
- int desyncFirst = -1;
+ var client = new GitHubClient(new ProductHeaderValue("chobbyla"))
+ {
+ Credentials = new Credentials(GlobalConst.CrashReportGithubToken)
+ };
- for (int a = 0; a < lines.Length;a++)
- if (IsDesyncMessage(lines[a]))
- {
- desyncFirst = a;
- break;
- }
+ var infologTruncated = TextTruncator.Truncate(infolog, MaxInfologSize, MakeRegionsOfInterest(infolog.Length, gamesFromLog.Games.Where(g => g.HasDesync).Select(g => g.FirstDesyncIdxInLog.Value), gamesFromLog.AsGameStartReadOnlyList()));
- if (desyncFirst != -1)
+ var desyncDebugInfo = MakeDesyncGameTable(gamesFromLog);
+
+ var newIssueRequest = new NewIssue($"Spring {type} [{engine}] {bugReportTitle}")
{
- var sumSize = 0;
- var firstIndex = desyncFirst;
- var lastIndex = desyncFirst + 1;
- do
- {
- if (firstIndex >= 0)
- {
- firstPart.Add(lines[firstIndex]);
- sumSize += lines[firstIndex].Length;
- }
- if (lastIndex < lines.Length)
- {
- lastPart.Add(lines[lastIndex]);
- sumSize += lines[lastIndex].Length;
- }
+ Body = $"{bugReportDescription}{desyncDebugInfo}"
+ };
+ var createdIssue = await client.Issue.Create(CrashReportsRepoOwner, CrashReportsRepoName, newIssueRequest);
- firstIndex--;
- lastIndex++;
+ await client.Issue.Comment.Create(CrashReportsRepoOwner, CrashReportsRepoName, createdIssue.Number, $"infolog_full.txt (truncated):\n\n```{infologTruncated}```");
- } while (sumSize < MaxInfologSize && (firstIndex > 0 || lastIndex < lines.Length));
- if (lastIndex < lines.Length) lastPart.Add(TruncatedString);
- if (firstIndex > 0) firstPart.Add(TruncatedString);
- firstPart.Reverse();
- }
- else
- {
+ return createdIssue;
+ }
+ catch (Exception ex)
+ {
+ Trace.TraceWarning("[CrashReportHelper] Problem reporting a bug: {0}", ex);
+ }
+ return null;
+ }
+
+ //All infolog parsing is best-effort only.
+ //The infolog file format does not have enough structure to guarantee that it is never misinterpreted.
+
+ private static int[] ReadGameReloads(string logStr)
+ {
+ //Game reload detected by [f=-000001] that follows either the start of the file, or [f={non-negative value}]
+
+ var list = new List();
- var sumSize = 0;
+ var negativeFrameRegex = new Regex(@"f=-(?<=(?^)\[t=\d+:\d+:\d+\.\d+\]\[f=-)\d+\]", RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.Multiline, TimeSpan.FromSeconds(30));
+ 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));
- for (int i = 0; i < lines.Length; i++)
+ var idx = 0;
+
+ try
+ {
+ while (true)
+ {
{
- int index = i%2 == 0 ? i/2 : lines.Length - i/2 - 1;
- if (sumSize + lines[index].Length < maxSize)
- {
- if (i%2 == 0) firstPart.Add(lines[index]);
- else lastPart.Add(lines[index]);
- }
- else
- {
- firstPart.Add(TruncatedString);
- break;
- }
- sumSize += lines[index].Length;
+ var m = negativeFrameRegex.Match(logStr, idx);
+ if (!m.Success) break;
+ idx = m.Index;
+ list.Add(m.Groups["s"].Index);
+ }
+ {
+ var m = nonNegativeFrameRegex.Match(logStr, idx);
+ if (!m.Success) break;
+ idx = m.Index;
}
- lastPart.Reverse();
}
+ return list.ToArray();
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ Trace.TraceError("\"[CrashReportHelper] RegexMatchTimeoutException in ReadGameReloads");
+ return Array.Empty();
+ }
+ }
+ private static (int, string)[] ReadGameStateFileNames(string logStr)
+ {
+ //See https://github.com/beyond-all-reason/spring/blob/f3ba23635e1462ae2084f10bf9ba777467d16090/rts/System/Sync/DumpState.cpp#L155
- infolog = string.Join("\r\n", firstPart) + "\r\n" + string.Join("\r\n", lastPart);
+ //[t=00:22:43.353840][f=0003461] [DumpState] using dump-file "ClientGameState--749245531-[3461-3461].txt"
+ try
+ {
+ return
+ Regex
+ .Matches(
+ logStr,
+ $@"(?<={InfoLogLineStartPattern})\[DumpState\] using dump-file ""(?[^{Regex.Escape(System.IO.Path.DirectorySeparatorChar.ToString())}{Regex.Escape(System.IO.Path.AltDirectorySeparatorChar.ToString())}""]+)""{InfoLogLineEndPattern}",
+ RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.Multiline,
+ TimeSpan.FromSeconds(30))
+ .Cast().Select(m => (m.Index, m.Groups["d"].Value)).Distinct()
+ .ToArray();
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ Trace.TraceError("\"[CrashReportHelper] RegexMatchTimeoutException in ReadClientStateFileNames");
+ return Array.Empty<(int, string)>();
+ }
+ }
+
+ private static int[] ReadDesyncs(string logStr)
+ {
+ //[t=00:22:43.533864][f=0003461] Sync error for mankarse in frame 3451 (got 927a6f33, correct is 6b550dd1)
+
+ //See ZkData.Account.IsValidLobbyName
+ var accountNamePattern = @"[_[\]a-zA-Z0-9]{1,25}";
+ try
+ {
+ return
+ Regex
+ .Matches(
+ logStr,
+ $@"Sync error for(?<={InfoLogLineStartPattern}Sync error for) {accountNamePattern} in frame \d+ \(got [a-z0-9]+, correct is [a-z0-9]+\){InfoLogLineEndPattern}",
+ RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.Multiline,
+ TimeSpan.FromSeconds(30))
+ .Cast().Select(m => m.Index).ToArray();
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ Trace.TraceError("\"[CrashReportHelper] RegexMatchTimeoutException in ReadDesyncs");
+ return Array.Empty();
}
- return infolog;
}
-
+
+
+ private static (int, string)[] ReadGameIDs(string logStr)
+ {
+ //[t=00:19:00.246149][f=-000001] GameID: 6065f665e92c7942def2c0c17c703e72
+
+ try
+ {
+ return
+ Regex
+ .Matches(
+ logStr,
+ $@"GameID:(?<={InfoLogLineStartPattern}GameID:) (?[0-9a-zA-Z]+){InfoLogLineEndPattern}",
+ RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.Multiline,
+ TimeSpan.FromSeconds(30))
+ .Cast().Select(m => { var g = m.Groups["g"]; return (g.Index, g.Value); }).ToArray();
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ Trace.TraceError("\"[CrashReportHelper] RegexMatchTimeoutException in ReadGameIDs");
+ return Array.Empty<(int, string)>();
+ }
+ }
+
public static void CheckAndReportErrors(string logStr, bool springRunOk, string bugReportTitle, string bugReportDescription, string engineVersion)
{
- var syncError = CrashReportHelper.IsDesyncMessage(logStr);
+ var gamesFromLog = new GameFromLogCollection(ReadGameReloads(logStr));
+
+ gamesFromLog.AddGameStateFileNames(ReadGameStateFileNames(logStr));
+ gamesFromLog.AddDesyncs(ReadDesyncs(logStr));
+ gamesFromLog.AddGameIDs(ReadGameIDs(logStr));
+
+ var syncError = gamesFromLog.Games.Any(g => g.HasDesync);
if (syncError) Trace.TraceWarning("Sync error detected");
var openGlFail = logStr.Contains("No OpenGL drivers installed.") ||
@@ -177,11 +371,16 @@ public static void CheckAndReportErrors(string logStr, bool springRunOk, string
: luaErr ? CrashType.LuaError
: CrashType.Crash;
- var ret = ReportCrash(logStr,
- crashType,
- engineVersion,
- bugReportTitle,
- bugReportDescription);
+ var ret =
+ ReportCrash(
+ logStr,
+ crashType,
+ engineVersion,
+ bugReportTitle,
+ bugReportDescription,
+ gamesFromLog)
+ .GetAwaiter().GetResult();
+
if (ret != null)
try
{
diff --git a/ChobbyLauncher/TextTruncator.cs b/ChobbyLauncher/TextTruncator.cs
new file mode 100644
index 000000000..fa725dd10
--- /dev/null
+++ b/ChobbyLauncher/TextTruncator.cs
@@ -0,0 +1,379 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Linq;
+
+namespace ChobbyLauncher
+{
+ public static class TextTruncator
+ {
+ private static readonly string TruncationMarker = "------- TRUNCATED -------" + Environment.NewLine;
+
+ public struct RegionOfInterest
+ {
+ public int PointOfInterest;
+ public int StartLimit;
+ public int EndLimit;
+ }
+
+ private struct Region
+ {
+ public int Start;
+ public int End;
+ public int Length { get => End - Start; }
+
+ public int StartLimit;
+ public int EndLimit;
+
+ public static Region Merge(Region a, Region b) =>
+ new Region
+ {
+ Start = Math.Min(a.Start, b.Start),
+ End = Math.Max(a.End, b.End),
+ StartLimit = Math.Min(a.StartLimit, b.StartLimit),
+ EndLimit = Math.Max(a.EndLimit, b.EndLimit),
+ };
+ }
+ private struct TruncatedTextBuilder
+ {
+ private int LineAlignForwards(int pos)
+ {
+ if (pos == 0)
+ {
+ return pos;
+ }
+ var nextNewline = _initialString.IndexOf('\n', pos - 1);
+ return nextNewline == -1 ? _initialString.Length : nextNewline + 1;
+ }
+ private int LineAlignBackwards(int pos)
+ {
+ if (pos == 0)
+ {
+ return pos;
+ }
+ var prevNewline = _initialString.LastIndexOf('\n', pos - 1);
+ return prevNewline + 1;
+ }
+
+ private readonly string _initialString;
+ private readonly int _maxLength;
+ private List Regions { get; set; }
+
+ //Should always equal:
+ // Regions.Sum(r => r.Length)
+ private int RegionLengthSum { get; set; }
+
+ //Should always equal:
+ // Regions.Any(r => r.Start == 0)
+ private bool HasRegionAtStart { get; set; }
+ //Should always equal:
+ // Regions.Any(r => r.End == _initialString.Length)
+ private bool HasRegionAtEnd { get; set; }
+
+ private int LengthEstimate { get => RegionLengthSum + TruncationMarker.Length * ((Regions.Count - 1) + (HasRegionAtStart ? 0 : 1) + (HasRegionAtEnd ? 0 : 1)); }
+
+ private bool LengthChangeImpossible(int lengthChange) => LengthEstimate > _maxLength - lengthChange;
+
+ private int PredictLengthChangeFromTerminalTruncationMarkers(int newRegionStart, int newRegionEnd) =>
+ //If a region will be at the start of _initialString, and
+ //there was not previously a region at the start of _initialString;
+ //the TruncationMarker at the start of the result is no longer needed;
+ //so there will be a negative length change.
+
+ //(This is also true for regions at the end of _initialString).
+ TruncationMarker.Length *
+ (
+ (!HasRegionAtStart && newRegionStart == 0 ? -1 : 0)
+ + (!HasRegionAtEnd && newRegionEnd == _initialString.Length ? -1 : 0)
+ );
+
+ private void UpdateLengthEstimate(int newRegionStart, int newRegionEnd, int regionLengthChange)
+ {
+ HasRegionAtStart = HasRegionAtStart || newRegionStart == 0;
+ HasRegionAtEnd = HasRegionAtEnd || newRegionEnd == _initialString.Length;
+ RegionLengthSum += regionLengthChange;
+ }
+
+ public TruncatedTextBuilder(string initialString, int maxLength, int expectedRegionCount)
+ {
+ _initialString = initialString;
+ _maxLength = maxLength;
+ Regions = new List(expectedRegionCount);
+ RegionLengthSum = 0;
+ HasRegionAtStart = false;
+ HasRegionAtEnd = false;
+ }
+
+ public bool AddRegion(RegionOfInterest newRegion)
+ {
+ //Cleans region before inserting it into Regions:
+ // Does not allow Regions to contain any Region with Length=0.
+ // Does not allow Regions to contain any Region where Start/End/StartLimit/EndLimit are not aligned to line boundaries.
+ // Does not allow Regions to contain any Region where Start 0 && region.Start <= Regions[Regions.Count - 1].End;
+ Region finalRegion;
+ int regionLengthChange;
+ if (mergeRegion)
+ {
+ finalRegion = Region.Merge(Regions[Regions.Count - 1], region);
+ regionLengthChange = finalRegion.Length - Regions[Regions.Count - 1].Length;
+ }
+ else
+ {
+ finalRegion = region;
+ regionLengthChange = region.Length;
+ }
+
+ var lengthChange =
+ regionLengthChange +
+ (mergeRegion ? 0 : TruncationMarker.Length) +
+ PredictLengthChangeFromTerminalTruncationMarkers(finalRegion.Start, finalRegion.End);
+
+ if (LengthChangeImpossible(lengthChange))
+ {
+ return false;
+ }
+
+ UpdateLengthEstimate(finalRegion.Start, finalRegion.End, regionLengthChange);
+ if (mergeRegion)
+ {
+ Regions[Regions.Count - 1] = finalRegion;
+ }
+ else
+ {
+ Regions.Add(finalRegion);
+ }
+
+ return true;
+ }
+
+ public bool ExpandRegions()
+ {
+ var expanded = false;
+ var expansionLocationIndex = new int[Regions.Count << 1];
+ var distancesToLimit = new int[Regions.Count << 1];
+ for (var i = 0; i != Regions.Count; ++i)
+ {
+ expansionLocationIndex[i << 1] = i << 1;
+ distancesToLimit[i << 1] = Regions[i].Start - Regions[i].StartLimit;
+
+ expansionLocationIndex[(i << 1) + 1] = (i << 1) + 1;
+ distancesToLimit[(i << 1) + 1] = Regions[i].EndLimit - Regions[i].End;
+ }
+
+ Array.Sort(distancesToLimit, expansionLocationIndex);
+
+ var remainingAmountToExpand = _maxLength - LengthEstimate;
+
+ for (var i = 0; i != expansionLocationIndex.Length; ++i)
+ {
+ var ri = expansionLocationIndex[i] >> 1;
+ var remainingRegions = expansionLocationIndex.Length - i;
+ var amountToExpand = remainingAmountToExpand / remainingRegions;
+
+ int newStart;
+ int newEnd;
+ int regionLengthChange;
+
+ if (distancesToLimit[i] <= amountToExpand)
+ {
+ //Expand by distancesToLimit[i]
+ if ((expansionLocationIndex[i] & 1) == 0)
+ {
+ newStart = Regions[ri].StartLimit;
+ newEnd = Regions[ri].End;
+ }
+ else
+ {
+ newStart = Regions[ri].Start;
+ newEnd = Regions[ri].EndLimit;
+ }
+
+ remainingAmountToExpand -= distancesToLimit[i] + PredictLengthChangeFromTerminalTruncationMarkers(newStart, newEnd);
+ regionLengthChange = distancesToLimit[i];
+ }
+ else
+ {
+ //Expand by amountToExpand (snap to line boundary)
+ var oldStart = Regions[ri].Start;
+ var oldEnd = Regions[ri].End;
+ if ((expansionLocationIndex[i] & 1) == 0)
+ {
+ newStart = LineAlignForwards(oldStart - amountToExpand);
+ newEnd = oldEnd;
+ regionLengthChange = oldStart - newStart;
+ }
+ else
+ {
+ newStart = oldStart;
+ newEnd = LineAlignBackwards(oldEnd + amountToExpand);
+ regionLengthChange = newEnd - oldEnd;
+ }
+
+ //Reduce by amountToExpand, rather than the actual distance that was expanded, for fairness between expansion points.
+ remainingAmountToExpand -= amountToExpand;
+ }
+ UpdateLengthEstimate(newStart, newEnd, regionLengthChange);
+ Regions[ri] = new Region
+ {
+ Start = newStart,
+ End = newEnd,
+ StartLimit = Regions[ri].StartLimit,
+ EndLimit = Regions[ri].EndLimit,
+ };
+ if (regionLengthChange > 0)
+ {
+ expanded = true;
+ }
+ }
+ return expanded;
+ }
+
+ public void MergeRegions()
+ {
+ var i = 0;
+ while (i + 1 < Regions.Count)
+ {
+ if (Regions[i].End >= Regions[i + 1].Start)
+ {
+ var mergedRegion = Region.Merge(Regions[i], Regions[i + 1]);
+ RegionLengthSum += mergedRegion.Length - (Regions[i].Length + Regions[i + 1].Length);
+ Regions[i] = mergedRegion;
+ Regions.RemoveAt(i + 1);
+ }
+ else
+ {
+ ++i;
+ }
+ }
+ }
+
+ public override string ToString()
+ {
+ //Always returns a string with a Length matching LengthEstimate.
+ //If Regions is not merged, the locations of the inserted
+ //TruncationMarkers might not be what you expect.
+
+ if (Regions.Count == 0)
+ {
+ return TruncationMarker;
+ }
+ var sb = new StringBuilder(LengthEstimate);
+ if (!HasRegionAtStart)
+ {
+ sb.Append(TruncationMarker);
+ }
+ var i = 0;
+ {
+ var r = Regions[i++];
+ sb.Append(_initialString, r.Start, r.Length);
+ }
+ while (i != Regions.Count)
+ {
+ var r = Regions[i++];
+ sb.Append(TruncationMarker);
+ sb.Append(_initialString, r.Start, r.Length);
+ }
+ if (!HasRegionAtEnd)
+ {
+ sb.Append(TruncationMarker);
+ }
+ return sb.ToString();
+ }
+ }
+
+ public static string Truncate(string str, int maxLength, IReadOnlyList regionsOfInterest)
+ {
+ //Requires:
+ // maxLength >= 0
+ // regionsOfInterest is sorted by PointOfInterest
+ // regionsOfInterest.All(r => r.StartLimit <= r.PointOfInterest && r.PointOfInterest <= r.EndLimit)
+ // regionsOfInterest.All(r => 0 <= r.PointOfInterest && r.PointOfInterest <= str.Length)
+ // regionsOfInterest.All(r => 0 <= r.EndLimit && r.EndLimit <= str.Length)
+ // regionsOfInterest.All(r => 0 <= r.StartLimit && r.StartLimit <= str.Length)
+
+ if (maxLength < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maxLength));
+ }
+
+ if (!regionsOfInterest
+ .Zip(regionsOfInterest.Skip(1), (l, r) => new { l, r })
+ .All(p => p.l.PointOfInterest <= p.r.PointOfInterest))
+ {
+ throw new ArgumentException("Not sorted by PointOfInterest", nameof(regionsOfInterest));
+ }
+
+ if (!regionsOfInterest.All(r => r.StartLimit <= r.PointOfInterest && r.PointOfInterest <= r.EndLimit))
+ {
+ throw new ArgumentException("Contains RegionOfInterest for which PointOfInterest not between StartLimit and EndLimit", nameof(regionsOfInterest));
+ }
+
+ if (!(
+ regionsOfInterest.All(r => 0 <= r.PointOfInterest && r.PointOfInterest <= str.Length)
+ && regionsOfInterest.All(r => 0 <= r.EndLimit && r.EndLimit <= str.Length)
+ && regionsOfInterest.All(r => 0 <= r.StartLimit && r.StartLimit <= str.Length)))
+ {
+ throw new ArgumentException("Contains RegionOfInterest that is not within str", nameof(regionsOfInterest));
+ }
+
+
+ //Returns: A version of str for which Length <= maxLength
+ // To reduce the length, lines are removed and replaced with TruncationMarker
+ // When removing lines, lines that are within regionsOfInterest are preferentially kept
+
+ if (str.Length <= maxLength)
+ {
+ return str;
+ }
+
+ //Special handling if maxLength < TruncationMarker.Length
+ if (maxLength < TruncationMarker.Length)
+ {
+ return string.Empty;
+ }
+
+ var result = new TruncatedTextBuilder(str, maxLength, regionsOfInterest.Count);
+
+ //Convert "regionsOfInterest" to regions
+ // Add them to TruncatedTextBuilder incrementally. Stop if maxLength is exceeded
+ // Snap PointOfInterest/StartLimit/EndLimit to line boundaries
+ foreach (var roi in regionsOfInterest)
+ {
+ if (!result.AddRegion(roi))
+ {
+ return result.ToString();
+ }
+ }
+ while (result.ExpandRegions())
+ {
+ result.MergeRegions();
+ }
+
+ return result.ToString();
+ }
+ }
+}
diff --git a/Shared/PlasmaShared/Utils.cs b/Shared/PlasmaShared/Utils.cs
index 3309cad71..249f1dc81 100644
--- a/Shared/PlasmaShared/Utils.cs
+++ b/Shared/PlasmaShared/Utils.cs
@@ -200,6 +200,35 @@ public static double StdDevSquared(this IEnumerable values)
}
+ public static int LowerBoundIndex(this IReadOnlyList list, T value) where T : IComparable
+ {
+ //Requires:
+ // list is sorted
+
+ //Returns:
+ // Smallest value of i for which: !(list[i] < value)
+ // or list.Count, if there is no such value.
+
+ int first = 0;
+ int len = list.Count;
+
+ while (len > 0)
+ {
+ var half = len / 2;
+ var mid = first + half;
+ if (list[mid].CompareTo(value) < 0)
+ {
+ first = mid + 1;
+ len = len - half - 1;
+ }
+ else
+ {
+ len = half;
+ }
+ }
+
+ return first;
+ }
public static bool CanRead(string filename)