diff --git a/EditorReaderHarness/Program.cs b/EditorReaderHarness/Program.cs index d08f633..55ed348 100644 --- a/EditorReaderHarness/Program.cs +++ b/EditorReaderHarness/Program.cs @@ -93,6 +93,7 @@ private static void RunCommand(string cmd) case "M": ErrorMonitor(); break; case "C": CompareCandidateChecks(); break; case "Z": ForceScanCheck(); break; + case "H": ScanHotColdCompare(); break; case "W": case "y": // 手动触发:等待/附加 osu! + 输出诊断报告。 @@ -494,7 +495,7 @@ private static FailureTally RunStress(int milliseconds, int intervalMs, bool fet _reader.FetchAll(fetchFull); string? why = Snapshot.Validate(_reader, out int rejects, out var reasons); ok = why == null; - if (!ok) + if (why != null) { tally.Bucket(why); foreach (var kv in reasons) tally.RejectReasons[kv.Key] = tally.RejectReasons.GetValueOrDefault(kv.Key) + kv.Value; @@ -1734,8 +1735,6 @@ void R(string what, IntPtr address, byte[] buf, int size) } } - int ptr(uint v) => (int)v; - // ---- SetEditor 之后的部分:FetchHOM / FetchBeatmap / FetchControlPoints / FetchObjects R("pEditor+28 (HOM)", _reader.EditorAddress + 28, b4, 4); int pHom = BitConverter.ToInt32(b4, 0); @@ -3140,6 +3139,58 @@ private static void ForceScanCheck() } } + // ------------------------------------------------------------------ 扫描热/冷路径对比 + + /// + /// 对比冷扫描(lastFoundRegionBase 被清空,从第 0 个区域开始)与热扫描 + /// (沿用上次命中的区域优先)的耗时与扫描区域数。 + /// 用于回答环形游标 / 区域退避这类机制在 Windows 上到底有没有收益。 + /// + private static void ScanHotColdCompare() + { + if (_osu == null) Attach(); + if (_hProcess == IntPtr.Zero) { WriteLine("未附加到 osu!"); return; } + + WriteLine("冷/热扫描对比:ResetEditor 会清掉命中区域记忆,因此下一次是冷扫描。"); + WriteLine("对比耗时与实际扫描区域数(区域数越少,说明优先扫描省得越多)。\n"); + + var coldMs = new List(); + var coldRegions = new List(); + var hotMs = new List(); + var hotRegions = new List(); + + for (int i = 0; i < 5; i++) + { + _reader.ResetEditor(); + var sw = Stopwatch.StartNew(); + try { _reader.SetEditor(); } catch (Exception ex) { WriteLine(" 冷扫描失败: " + ex.Message); continue; } + sw.Stop(); + coldMs.Add(sw.ElapsedMilliseconds); + coldRegions.Add(_reader.DiagScanRegionsScanned); + + sw.Restart(); + try { _reader.SetEditor(); } catch (Exception ex) { WriteLine(" 热扫描失败: " + ex.Message); continue; } + sw.Stop(); + hotMs.Add(sw.ElapsedMilliseconds); + hotRegions.Add(_reader.DiagScanRegionsScanned); + } + + string FmtMs(List xs) => xs.Count == 0 ? "(无数据)" : $"平均 {xs.Average():F0} ms [{string.Join(", ", xs)}]"; + string FmtRegions(List xs) => xs.Count == 0 ? "(无数据)" : $"平均 {xs.Average():F0} [{string.Join(", ", xs)}]"; + + WriteLine($"冷扫描(从区域 0 开始): {FmtMs(coldMs)}"); + WriteLine($" 扫描区域数: {FmtRegions(coldRegions)}"); + WriteLine($"热扫描(命中区域优先): {FmtMs(hotMs)}"); + WriteLine($" 扫描区域数: {FmtRegions(hotRegions)}"); + WriteLine(); + if (coldMs.Count > 0 && hotMs.Count > 0) + { + WriteLine($"节省: {coldMs.Average() - hotMs.Average():F0} ms / 次,区域数差 {coldRegions.Average() - hotRegions.Average():F0} 个"); + } + WriteLine(); + WriteLine("说明:扫描只在绑定失效或编辑器被重建时发生,正常运行时不会反复扫。"); + } + // ------------------------------------------------------------------ 8. 重试修复测试 private static void RetryHealTest() diff --git a/osucatch-editor-realtimeviewer/BeatmapConverterOsuStable.cs b/osucatch-editor-realtimeviewer/BeatmapConverterOsuStable.cs index 65357ed..8c8fc94 100644 --- a/osucatch-editor-realtimeviewer/BeatmapConverterOsuStable.cs +++ b/osucatch-editor-realtimeviewer/BeatmapConverterOsuStable.cs @@ -1,4 +1,4 @@ -using osu.Game.Beatmaps; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Catch.Objects; using System.Globalization; @@ -15,10 +15,6 @@ public override List GetPalpableObjects(IBeatmap beatmap Log.ConsoleLog("Building hitobjects.", Log.LogType.BeatmapConverter, Log.LogLevel.Debug); HitObjectManagerCatch manager = new(beatmap, mods, false); - float? lastPosition = null; - double lastStartTime = 0; - bool isFirstSame = true; - int sourceIndex = 0; foreach (var currentObject in beatmap.HitObjects) { diff --git a/osucatch-editor-realtimeviewer/BookmarkPlus.cs b/osucatch-editor-realtimeviewer/BookmarkPlus.cs index c21042e..5cb0baa 100644 --- a/osucatch-editor-realtimeviewer/BookmarkPlus.cs +++ b/osucatch-editor-realtimeviewer/BookmarkPlus.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; @@ -21,12 +21,14 @@ public class LineStyle : IComparable [JsonConverter(typeof(ColorConverter))] public Color Color { get; set; } - public string Comment { get; set; } + + /// 线条说明文字(如 HyperDash);没写过时为空字符串而不是 null(设置窗口直接读它填文本框)。 + public string Comment { get; set; } = ""; public int CompareTo(object? lineStyle) { if (lineStyle == null) return 1; - LineStyle otherLineStyle = lineStyle as LineStyle; + LineStyle? otherLineStyle = lineStyle as LineStyle; if (otherLineStyle == null) throw new ArgumentException("Object is not a LineStyle."); return otherLineStyle.Id - Id; } @@ -40,7 +42,7 @@ public class Bookmark : IComparable public int CompareTo(object? bookmark) { if (bookmark == null) return 1; - Bookmark otherBookmark = bookmark as Bookmark; + Bookmark? otherBookmark = bookmark as Bookmark; if (otherBookmark == null) throw new ArgumentException("Object is not a Bookmark."); return (int)(otherBookmark.Time - Time); } @@ -296,8 +298,14 @@ public class BookmarkManager { public List Bookmarks { get; set; } = new(); - public string BeatmapFolder; - public string BeatmapFilename; + /// + /// 当前书签对应的谱面位置;还没绑定到任何谱面时为空字符串 + /// (调用方用 == "" 判断“编辑器没在运行 / 还没读到谱面”)。 + /// + public string BeatmapFolder = ""; + + /// + public string BeatmapFilename = ""; public BookmarkManager() { diff --git a/osucatch-editor-realtimeviewer/Canvas.cs b/osucatch-editor-realtimeviewer/Canvas.cs index 8042d17..1767d43 100644 --- a/osucatch-editor-realtimeviewer/Canvas.cs +++ b/osucatch-editor-realtimeviewer/Canvas.cs @@ -622,7 +622,7 @@ private static void DrawJudgementLine() { double baseY = (screensContain > 1) ? 240.0 * screensContain : 408; DrawingHelper drawing = Form1.drawingHelper; - double y = JudgementLineY(baseY, drawing.FixedPreviewTime, drawing.EditorTime, drawing.CurrentTime, drawing.TimePerPixels); + double y = JudgementLineY(baseY, drawing.FixedPreviewTime, drawing.EditorTime, drawing.CurrentTime, drawing.PixelsPerMs); Vector2 rp0 = new Vector2(64, (float)y); Vector2 rp1 = new Vector2(576, (float)y); @@ -631,15 +631,16 @@ private static void DrawJudgementLine() /// /// 判定线(当前时刻线)在画面上的 Y 坐标。 - /// 正常模式:画在判定线本身的位置(当前时刻所在高度)。 + /// 正常模式:画在判定线本身的位置(当前时刻所在高度,垂直缩放的基准,因此不受缩放影响)。 /// 固定预览时刻:画面停在预览时刻,判定线改为按 editor 时刻在画面上的位置绘制 - /// (换算方式与绘制物件完全一致:baseY - (time - CurrentTime) / TimePerPixels), + /// (换算方式与绘制物件完全一致:baseY - (time - CurrentTime) × PixelsPerMs, + /// 其中 已含垂直缩放), /// 这样固定预览时也能一眼看出当前编辑位置在画面的哪里。 /// - private static double JudgementLineY(double baseY, bool fixedPreviewTime, double editorTime, double currentTime, double timePerPixels) + private static double JudgementLineY(double baseY, bool fixedPreviewTime, double editorTime, double currentTime, double pixelsPerMs) { - if (!fixedPreviewTime || !(timePerPixels > 0)) return baseY; - return baseY - (editorTime - currentTime) / timePerPixels; + if (!fixedPreviewTime || !(pixelsPerMs > 0)) return baseY; + return baseY - (editorTime - currentTime) * pixelsPerMs; } } } diff --git a/osucatch-editor-realtimeviewer/DrawingHelper.cs b/osucatch-editor-realtimeviewer/DrawingHelper.cs index 4ef3ad2..92b9feb 100644 --- a/osucatch-editor-realtimeviewer/DrawingHelper.cs +++ b/osucatch-editor-realtimeviewer/DrawingHelper.cs @@ -58,6 +58,18 @@ public class DrawingHelper /// public bool FixedPreviewTime { get; set; } + /// + /// 垂直(时间轴)缩放比例:只拉伸 / 压缩画面的 Y 轴,X 轴(物件的横向位置)保持不动。 + /// 1.0 = 不缩放;大于 1 把时间轴拉长(同样高度里看到的时刻更少、物件在竖直方向更分散), + /// 小于 1 把时间轴压扁(同样高度里看到更多时刻)。缩放以判定线 + /// (,即当前时刻所在高度)为基准,判定线本身不动。 + /// + /// 由快捷开关栏的“Y缩放”滑块设置,只作用于当前会话, + /// 不写入设置文件,因此每次启动都从 1.0 开始。 + /// + /// + public float VerticalScale { get; set; } = 1f; + public ControlPointInfo? ControlPointInfo { get; set; } List BarLines { get; set; } public List CatchHitObjects { get; set; } @@ -173,6 +185,26 @@ public void ApplyBuildResult(DrawingHelper staged) /// public double JudgeLineBaseY => (ScreensContain <= 1) ? 408 : 240.0 * ScreensContain; + /// + /// 垂直缩放后“一个毫秒对应多少屏幕 Y 像素”,也就是 的倒数再乘上 + /// 。时间 → 画面的换算一律走这里,避免各处各写一遍缩放。 + /// 数据无效时返回 0(调用方据此跳过绘制)。 + /// + /// + public double PixelsPerMs => (TimePerPixels > 0 && VerticalScale > 0) ? VerticalScale / TimePerPixels : 0; + + /// + /// 把时间差(ms)换算成画面上的 Y 偏移(像素,正数表示时刻更晚、在画面上更高)。 + /// 未缩放时等于 deltaTime / TimePerPixels。 + /// + public double TimeDeltaToPixels(double deltaTime) => deltaTime * PixelsPerMs; + + /// + /// 把画面上的 Y 偏移(像素)换算回时间差(ms): 的逆运算, + /// 用于固定预览整页翻页等“先定画面位置再反推时刻”的场合。 + /// + public double PixelsToTimeDelta(double pixels) => (VerticalScale > 0) ? pixels * TimePerPixels / VerticalScale : 0; + /// /// 整页翻页后判定线与画面边缘保留的距离(占可视高度的比例)。 /// 留余量有两个作用:翻页后判定线不贴边(看得清), @@ -191,15 +223,15 @@ public void ApplyBuildResult(DrawingHelper staged) /// 是否发生了翻页。未处于固定预览模式或时刻数据无效时不做事。 public bool PageToKeepEditorVisible(double visibleTopY, double visibleBottomY) { - if (!FixedPreviewTime || !(TimePerPixels > 0)) return false; + if (!FixedPreviewTime || !(PixelsPerMs > 0)) return false; double baseY = JudgeLineBaseY; - double lineY = baseY - (EditorTime - CurrentTime) / TimePerPixels; + double lineY = baseY - TimeDeltaToPixels(EditorTime - CurrentTime); if (lineY >= visibleTopY && lineY <= visibleBottomY) return false; double margin = Math.Max((visibleBottomY - visibleTopY) * PageMarginRatio, 1); double targetY = (lineY < visibleTopY) ? visibleBottomY - margin : visibleTopY + margin; - CurrentTime = (float)(EditorTime - (baseY - targetY) * TimePerPixels); + CurrentTime = (float)(EditorTime - PixelsToTimeDelta(baseY - targetY)); return true; } @@ -334,7 +366,7 @@ private void DrawTemplate() if (obj is TinyDroplet) diameter *= obj.Scale / 2f; else if (obj is Droplet) diameter *= obj.Scale; - float posY = (float)(baseY - deltaTime / TimePerPixels); + float posY = (float)(baseY - TimeDeltaToPixels(deltaTime)); Canvas.DrawDashedCircleOutline(new Vector2(64 + obj.EffectiveX, posY), diameter / 2f, templateColor); } } @@ -379,14 +411,14 @@ public void DrawBarLines(List barLines, BarLineMode mode) { double timeSpan = ScreensContain * ApproachTime * 1.25; if (deltaTime > timeSpan || deltaTime < -timeSpan) return; - posY = (int)(240.0 * ScreensContain - deltaTime / TimePerPixels); + posY = (int)(240.0 * ScreensContain - TimeDeltaToPixels(deltaTime)); } else { double upTime = ApproachTime; double bottomTime = ApproachTime * 3 / 17; if (deltaTime > upTime || deltaTime < -bottomTime) return; - posY = (int)(384 - deltaTime / TimePerPixels); + posY = (int)(384 - TimeDeltaToPixels(deltaTime)); } Vector2 rp0 = new Vector2(64, posY); @@ -462,7 +494,7 @@ private void DrawSubdivisionLine(double time, Color color) if (deltaTime > span || deltaTime < -span) return; } - int posY = (int)(baseY - deltaTime / TimePerPixels); + int posY = (int)(baseY - TimeDeltaToPixels(deltaTime)); Canvas.DrawLine(new Vector2(64, posY), new Vector2(576, posY), color); } @@ -500,11 +532,15 @@ private double NextBarLineTime(double time) /// 这样固定预览时刻后辅助线仍然指示当前编辑位置的可达距离; /// 而锚点在画面上的位置要按预览时刻()换算——画面就是按预览时刻画的, /// 用 editor 时刻会让锚点脱离画面上那个物件,并随 editor 时刻前进一起向下漂移。 + /// 垂直缩放()下锚点高度、可见时长的像素高度与射线斜率 + /// 都按同一比例换算,射线才仍然贴着“可达距离”的边界(见 )。 + /// /// private void DrawDistanceHelper() { if (!app.Default.Show_Distance_Helper) return; if (CatchHitObjects == null || CatchHitObjects.Count <= 0) return; + if (!(PixelsPerMs > 0)) return; int currentIndex = FindFruitIndexAtTime(EditorTime); if (currentIndex < 0) return; @@ -513,9 +549,12 @@ private void DrawDistanceHelper() if (previous == null) return; double baseY = (ScreensContain <= 1) ? 408 : 240.0 * ScreensContain; - double topY = (ScreensContain <= 1) - ? baseY - (ApproachTime + CircleDiameter * TimePerPixels) - : baseY - ScreensContain * ApproachTime * 1.25; + // 射线向上延伸到“可视时长”的顶端:这段时长在垂直缩放后占的像素高度也跟着变, + // 所以这里必须一起缩放,否则放大时间轴时射线会提前被截断。 + double topTime = (ScreensContain <= 1) + ? ApproachTime + CircleDiameter * TimePerPixels + : ScreensContain * ApproachTime * 1.25; + double topY = baseY - TimeDeltaToPixels(topTime); if (topY >= baseY) return; double anchorX = 64 + previous.EffectiveX; @@ -536,7 +575,7 @@ private void DrawDistanceHelper() /// 换成 editor 时刻会让锚点落到画面上另一个位置(固定预览后还会随 editor 前进而向下漂移)。 /// private double DistanceHelperAnchorY(double previousStartTime, double baseY) - => baseY - (previousStartTime - CurrentTime) / TimePerPixels; + => baseY - TimeDeltaToPixels(previousStartTime - CurrentTime); /// /// 按 SameWithEditor 语义换算水平速度(px/ms): @@ -560,13 +599,19 @@ private double CalcSameWithEditorSpeed(double multiplier) /// /// 从锚点画两条对称的向上放射线(右上、左上),延伸到可视窗口顶部,超出 playfield 时在边缘截断。 - /// 屏幕坐标下时间轴向上为未来,速度 s 的斜率为 dy/dx = -1/(s * TimePerPixels)。 + /// 屏幕坐标下时间轴向上为未来;水平速度 s(px/ms)在未缩放时的斜率为 dy/dx = -1/(s * TimePerPixels)。 + /// 垂直缩放的影响:X 轴不受缩放影响,而画面上 1 像素高度对应的时长从 + /// TimePerPixels 变成 TimePerPixels / VerticalScale,即向上 dy 像素可用的时间变成 + /// dy / PixelsPerMs;这段时间能横移 s * dy / PixelsPerMs 像素, + /// 于是斜率按同一比例变成 VerticalScale / (s * TimePerPixels)—— + /// 时间轴拉长时射线更陡,压扁时更平,射线始终贴着当前速度下的可达距离边界。 + /// /// private void DrawConeRays(double anchorX, double anchorY, double topY, double speed, Color color) { - if (speed <= 0 || TimePerPixels <= 0) return; + if (speed <= 0 || !(PixelsPerMs > 0)) return; - double slope = 1.0 / (speed * TimePerPixels); + double slope = VerticalScale / (speed * TimePerPixels); Vector2 anchor = new Vector2((float)anchorX, (float)anchorY); Canvas.DrawLine(anchor, ConeRayEndpoint(anchorX, anchorY, topY, slope, +1), color); @@ -627,7 +672,7 @@ public void DrawBookmarkPlus(List bookmarks) double timeSpan = ScreensContain * ApproachTime * 1.25; if (deltaTime <= timeSpan && deltaTime >= -timeSpan) { - int posY = (int)(240.0 * ScreensContain - deltaTime / TimePerPixels); + int posY = (int)(240.0 * ScreensContain - TimeDeltaToPixels(deltaTime)); Vector2 rp0 = new Vector2(64, posY); Vector2 rp1 = new Vector2(576, posY); int width = BookmarkPlus.GetLineWidthByStyleId(bookmark.StyleId); @@ -644,7 +689,7 @@ public void DrawBookmarkPlus(List bookmarks) double bottomTime = ApproachTime * 3 / 17; if (deltaTime <= upTime && deltaTime >= -bottomTime) { - int posY = (int)(384 - deltaTime / TimePerPixels); + int posY = (int)(384 - TimeDeltaToPixels(deltaTime)); Vector2 rp0 = new Vector2(64, posY); Vector2 rp1 = new Vector2(576, posY); int width = BookmarkPlus.GetLineWidthByStyleId(bookmark.StyleId); @@ -669,7 +714,7 @@ public void DrawTimingPoints(List timingControlPoints) double timeSpan = ScreensContain * ApproachTime * 1.25; if (deltaTime <= timeSpan && deltaTime >= -timeSpan) { - int posY = (int)(240.0 * ScreensContain - deltaTime / TimePerPixels); + int posY = (int)(240.0 * ScreensContain - TimeDeltaToPixels(deltaTime)); Canvas.DrawBPMLabel(timingControlPoint.BPM, posY); } } @@ -679,7 +724,7 @@ public void DrawTimingPoints(List timingControlPoints) double bottomTime = ApproachTime * 3 / 17; if (deltaTime <= upTime && deltaTime >= -bottomTime) { - int posY = (int)(384 - deltaTime / TimePerPixels); + int posY = (int)(384 - TimeDeltaToPixels(deltaTime)); Canvas.DrawBPMLabel(timingControlPoint.BPM, posY); } } @@ -697,7 +742,7 @@ public void DrawDifficultyControPoints(List difficultyCo double timeSpan = ScreensContain * ApproachTime * 1.25; if (deltaTime <= timeSpan && deltaTime >= -timeSpan) { - int posY = (int)(240.0 * ScreensContain - deltaTime / TimePerPixels); + int posY = (int)(240.0 * ScreensContain - TimeDeltaToPixels(deltaTime)); Canvas.DrawSVLabel(difficultyControlPoint.SliderVelocity, posY); } } @@ -707,7 +752,7 @@ public void DrawDifficultyControPoints(List difficultyCo double bottomTime = ApproachTime * 3 / 17; if (deltaTime <= upTime && deltaTime >= -bottomTime) { - int posY = (int)(384 - deltaTime / TimePerPixels); + int posY = (int)(384 - TimeDeltaToPixels(deltaTime)); Canvas.DrawSVLabel(difficultyControlPoint.SliderVelocity, posY); } } @@ -717,7 +762,7 @@ public void DrawDifficultyControPoints(List difficultyCo private void DrawHitcircle(PalpableCatchHitObject hitObject, double deltaTime) { double baseY = (ScreensContain <= 1) ? 408 : 240.0 * this.ScreensContain; - Vector2 pos = new Vector2(64 + hitObject.EffectiveX, (float)(baseY - deltaTime / TimePerPixels)); + Vector2 pos = new Vector2(64 + hitObject.EffectiveX, (float)(baseY - TimeDeltaToPixels(deltaTime))); bool withColor = app.Default.Combo_Colour; int comboColorIndex = (hitObject.ComboIndex) % CustomComboColours.Count; Color4 color = CustomComboColours[comboColorIndex]; @@ -798,7 +843,7 @@ private void DrawSpline() else if (xVal > 512) xVal = 512; double baseY = (ScreensContain <= 1) ? 408 : 240.0 * this.ScreensContain; double deltaTime = tVal - CurrentTime; - Vector2 pos = new Vector2(64 + xVal, (float)(baseY - deltaTime / TimePerPixels)); + Vector2 pos = new Vector2(64 + xVal, (float)(baseY - TimeDeltaToPixels(deltaTime))); splinePoints.Add(pos); } for (int i = 1; i < splinePoints.Count; i++) diff --git a/osucatch-editor-realtimeviewer/EditorReader/EditorReader.cs b/osucatch-editor-realtimeviewer/EditorReader/EditorReader.cs index b62eb68..50dfac1 100644 --- a/osucatch-editor-realtimeviewer/EditorReader/EditorReader.cs +++ b/osucatch-editor-realtimeviewer/EditorReader/EditorReader.cs @@ -15,7 +15,11 @@ public class EditorReader public bool autoRound; - private byte[] buffer; + /// + /// 复用读取缓冲:每次用前都先过 (它按需扩容), + /// 所以这里给个空数组而不是 null,交给 EnsureBuffer 决定何时真正分配。 + /// + private byte[] buffer = Array.Empty(); private byte[] buffer4 = new byte[4]; @@ -27,7 +31,8 @@ public class EditorReader private IntPtr bytesRead; - private Process process; + /// 目标 osu! 进程;由 / 绑定后才可用。 + private Process process = null!; /// /// 目标进程句柄。默认走 ; @@ -66,11 +71,14 @@ public IntPtr ForceHandle public int numBookmarks; - public int[] bookmarks; + /// 书签时刻列表;读取成功前为 null(调用方按 null 判断“还没读到”)。 + public int[] bookmarks = null!; - public string ContainingFolder; + /// 当前谱面所在文件夹;还没读到谱面时为空字符串(不是 null,调用方按 == "" 判断)。 + public string ContainingFolder = ""; - public string Filename; + /// 当前谱面文件名;还没读到谱面时为空字符串。 + public string Filename = ""; public float HPDrainRate; @@ -98,9 +106,10 @@ public IntPtr ForceHandle public int numControlPoints; - private byte[] pControlPoints; + private byte[] pControlPoints = Array.Empty(); - public List controlPoints; + /// 控制点列表;读取成功前为 null。 + public List controlPoints = null!; private IntPtr pObjectsL; @@ -108,7 +117,7 @@ public IntPtr ForceHandle public int numObjects; - private byte[] pObjects; + private byte[] pObjects = Array.Empty(); /// /// 主物件列表指针 -> 主物件下标,用于轻量地把选中物件列表映射回 SourceIndex。 @@ -116,7 +125,8 @@ public IntPtr ForceHandle /// private Dictionary? masterIndexByPointer; - public List hitObjects; + /// 主物件列表;读取成功前为 null(调用方按 null 判断“还没读到”)。 + public List hitObjects = null!; /// /// 目标进程的指针宽度。绝不能再用 : @@ -124,8 +134,12 @@ public IntPtr ForceHandle /// IntPtr.Size == 8,会把每 8 字节才读一次指针,从第二个物件起全部错位 /// ——表现为"物件读出来全是垃圾值 / 数量为 0",而不是报错。 /// 因此 32 位目标必须按 4 字节指针读取。 + /// 刻意不写成 const:写成常量后编译器会把 + /// 里“目标指针宽度 > 4 就按 8 字节读”的防御分支判成死代码(CS0162)。 + /// 这里保留成一个只读值,语义不变,同时那条分支仍然可编译、可保留。 + /// /// - private const int TargetPointerSize = 4; + private static readonly int TargetPointerSize = 4; /// /// 诊断用:最近一次 的失败分类计数。 @@ -188,7 +202,7 @@ public IntPtr ForceHandle /// 诊断用:枚举到但从未进入扫描循环的区域(索引与地址),用于定位"漏扫"。 public readonly List DiagNeverScannedRegions = new(); - /// 诊断用:GetScanOrder 本应产出的区域数。 + /// 诊断用:本次扫描待扫描的区域总数(MemReg 数量)。 public int DiagScanOrderCount; /// 诊断用:实际进入扫描循环的区域数(按集合统计,与计数器互相印证)。 @@ -224,23 +238,19 @@ private static bool DetectWine() private IntPtr pClipboardL; - private IntPtr pClipboardA; - public int numClipboard; - private byte[] pClipboard; - - public List clipboardObjects; + /// 剪贴板物件列表;读取成功前为 null。 + public List clipboardObjects = null!; private IntPtr pSelectedL; - private IntPtr pSelectedA; - public int numSelected; - private byte[] pSelected; + private byte[] pSelected = Array.Empty(); - public List selectedObjects; + /// 选中物件列表;读取成功前为 null。 + public List selectedObjects = null!; /// /// 单个内存区域允许扫描的最大字节数:超过则跳过并记录警告。 @@ -250,38 +260,14 @@ private static bool DetectWine() private const long MaxScanRegionSize = 512L * 1024 * 1024; /// - /// 单次内存扫描的最长时间。Wine 下 ReadProcessMemory 可能对某些区域永久阻塞, - /// 无法从超时点中断原生调用,因此把扫描放到独立线程,超时后放弃该线程并稍后重试。 + /// 单次内存扫描的最长时间。某些环境下 ReadProcessMemory 可能对个别区域永久阻塞, + /// 无法从超时点中断原生调用,因此把扫描放到独立线程,超时后放弃该线程、下次重试。 /// private const int ScanTimeoutMs = 30000; - /// ReadProcessMemory 长时间不返回的区域:首次退避 60 秒,随后指数增长。 - private const int InitialRegionPenaltyMs = 60 * 1000; - - /// 区域退避上限:保证"被拉黑的区域"最终一定会被重试。 - private const int MaxRegionPenaltyMs = 30 * 60 * 1000; - - /// 某个区域的退避状态。 - private sealed class RegionPenalty - { - public int Count; - public long NextRetryTimestamp; - } - - /// - /// ReadProcessMemory 长时间不返回过的区域(Wine 上确实存在):按指数退避延后重试, - /// 而不是永久跳过。永久拉黑会造成"该区域恰好含有 editor 对象时,本进程内永远无法重新绑定", - /// 用户只能不断重启 viewer —— 这正是 issue 中"重启 3 次以上 / 永久卡住"的来源之一。 - /// - private readonly Dictionary penalizedRegions = new(); - private readonly object penalizedRegionsLock = new(); - /// 上次成功找到 editor 的区域起始地址:重绑时优先扫描它(快路径)。 private long lastFoundRegionBase; - /// 上次扫描超时后继续扫描的起点,避免每次重试都从头开始。 - private int scanCursorIndex; - /// /// 仍在运行的扫描线程数(含超时被放弃、但可能还阻塞在 ReadProcessMemory 里的线程)。 /// 超时的线程无法被中断,这里限制并发数,避免反复重试时线程无限堆积。 @@ -295,13 +281,11 @@ private sealed class RegionPenalty /// 当前扫描线程正在读取的区域序号(供超时看门狗定位卡点)。 private volatile int scanningRegionIndex; - private IntPtr pHoveredObject; + /// 鼠标悬停的物件;该功能尚未接入读取流程,因此恒为 null。 + public HitObject? hoveredObject; - public HitObject hoveredObject; - - private IntPtr pSliderPlacement; - - public HitObject sliderPlacement; + /// 滑条放置预览物件;该功能尚未接入读取流程,因此恒为 null。 + public HitObject? sliderPlacement; private IntPtr pPointsL; @@ -315,7 +299,7 @@ private sealed class RegionPenalty private int numTemp; - private byte[] bTemp; + private byte[] bTemp = Array.Empty(); [DllImport("kernel32.dll", SetLastError = true)] protected static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, int dwSize, ref IntPtr lpNumberOfBytesRead); @@ -399,7 +383,11 @@ private bool SafeReadSegmented(IntPtr address, byte[] buf, int size, int minRequ return offset >= minRequired; } - private string ReadString(IntPtr pString) + /// + /// 从目标进程读一个 .NET 字符串。指针为 0(该字段没有字符串)时返回 null, + /// 调用方按需要自行回退(路径类字段回退成空字符串)。 + /// + private string? ReadString(IntPtr pString) { if (pString == IntPtr.Zero) { @@ -456,7 +444,8 @@ public string ProbeRegionWithMainPath(long regionBase, long regionSize) const int ReadChunkSize = 8 * 1024 * 1024; byte[] array = ToByteArray("230000001400000019000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0C000000eeeeeeeeeeeeeeeeeeeeeeeeee00"); int overlap = array.Length - 1; - byte[] scanBuffer = null; + // 扫描缓冲:交给 EnsureBuffer 按块大小分配(空数组即“还没分配”) + byte[] scanBuffer = Array.Empty(); byte[] probe16 = new byte[16]; byte[] probe4 = new byte[4]; IntPtr read = IntPtr.Zero; @@ -578,10 +567,10 @@ private IntPtr FindEditorAddress() if (scanError != null) throw scanError; + // 记住命中的区域:下一次重绑时该区域会被优先扫描(实测冷扫描 694ms -> 热扫描 63ms) if (lastEditorCandidate != IntPtr.Zero) { lastFoundRegionBase = scanningRegionAddress; - ClearRegionPenalties(); } return lastEditorCandidate; @@ -611,7 +600,8 @@ private IntPtr ScanForEditorAddress(Internals internals) byte[] array = ToByteArray("230000001400000019000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0C000000eeeeeeeeeeeeeeeeeeeeeeeeee00"); int overlap = array.Length - 1; - byte[] scanBuffer = null; + // 扫描缓冲:交给 EnsureBuffer 按块大小分配(空数组即“还没分配”) + byte[] scanBuffer = Array.Empty(); byte[] probe16 = new byte[16]; byte[] probe4 = new byte[4]; IntPtr read = IntPtr.Zero; @@ -724,73 +714,6 @@ private IntPtr ScanForEditorAddress(Internals internals) throw new InvalidOperationException("No active editor found."); } - /// - /// 扫描顺序:先试上次命中 editor 的区域(通常直接命中,省掉整轮全堆扫描), - /// 再从上次超时点按环形顺序扫完整个列表,避免每次重试都从头扫、反复卡在同一个区域。 - /// - private IEnumerable GetScanOrder(Internals internals) - { - int regionCount = internals.MemReg.Count; - int fastPathIndex = -1; - - if (lastFoundRegionBase != 0) - { - for (int i = 0; i < regionCount; i++) - { - if (internals.MemReg[i].BaseAddress.ToInt64() == lastFoundRegionBase) - { - fastPathIndex = i; - yield return i; - break; - } - } - } - - int start = (scanCursorIndex >= 0 && scanCursorIndex < regionCount) ? scanCursorIndex : 0; - for (int k = 0; k < regionCount; k++) - { - int i = (start + k) % regionCount; - if (i == fastPathIndex) continue; - yield return i; - } - } - - private bool IsRegionPenalized(long regionBase) - { - lock (penalizedRegionsLock) - { - return penalizedRegions.TryGetValue(regionBase, out RegionPenalty? penalty) && - Stopwatch.GetTimestamp() < penalty.NextRetryTimestamp; - } - } - - /// - /// 把"读取超时"的区域延后重试(60 秒起,逐次翻倍,上限 30 分钟),而不是永久跳过。 - /// - private void PenalizeRegion(long regionBase) - { - lock (penalizedRegionsLock) - { - if (!penalizedRegions.TryGetValue(regionBase, out RegionPenalty? penalty)) - { - penalty = new RegionPenalty(); - penalizedRegions[regionBase] = penalty; - } - - penalty.Count++; - long delayMs = Math.Min((long)InitialRegionPenaltyMs << Math.Min(penalty.Count - 1, 10), MaxRegionPenaltyMs); - penalty.NextRetryTimestamp = Stopwatch.GetTimestamp() + (long)(Stopwatch.Frequency * (delayMs / 1000.0)); - } - } - - private void ClearRegionPenalties() - { - lock (penalizedRegionsLock) - { - penalizedRegions.Clear(); - } - } - /// /// 候选编辑器的可信度校验:签名命中只说明内存里有这段字节。 /// osu! 退出 test mode 后会重建编辑器对象,堆里可能残留同样能通过签名的死副本, @@ -885,7 +808,7 @@ private bool ObjectsReadableFromChain(IntPtr pHom, byte[] probe16, byte[] probe4 /// 绑定 osu! 进程。切换到新进程时清空扫描状态:地址空间已完全不同, /// 旧的退避表/命中区域/扫描游标都失去意义。 /// - public void SetProcess(Process forceProcess = null) + public void SetProcess(Process? forceProcess = null) { if (forceProcess != null) { @@ -897,7 +820,9 @@ public void SetProcess(Process forceProcess = null) Process[] processesByName = Process.GetProcessesByName("osu!"); foreach (Process process in processesByName) { - if (process.MainModule.ModuleName == "osu!.exe" && process.MainModule.FileVersionInfo.ProductName == "osu!") + // MainModule 在目标进程已退出/权限不足时为 null,这里必须判空 + ProcessModule? mainModule = process.MainModule; + if (mainModule != null && mainModule.ModuleName == "osu!.exe" && mainModule.FileVersionInfo.ProductName == "osu!") { this.process = process; ResetScanState(); @@ -910,9 +835,7 @@ public void SetProcess(Process forceProcess = null) private void ResetScanState() { - ClearRegionPenalties(); lastFoundRegionBase = 0; - scanCursorIndex = 0; Interlocked.Exchange(ref scanningRegionAddress, 0); } @@ -1213,8 +1136,8 @@ public void ReadBeatmap() CircleSize = BitConverter.ToSingle(buffer, 48); HPDrainRate = BitConverter.ToSingle(buffer, 52); OverallDifficulty = BitConverter.ToSingle(buffer, 56); - ContainingFolder = ReadString(ToIntPtr(buffer, 120)); - Filename = ReadString(ToIntPtr(buffer, 144)); + ContainingFolder = ReadString(ToIntPtr(buffer, 120)) ?? ""; + Filename = ReadString(ToIntPtr(buffer, 144)) ?? ""; BeatmapVersion = BitConverter.ToInt32(buffer, 216); PreviewTime = BitConverter.ToInt32(buffer, 288); StackLeniency = BitConverter.ToSingle(buffer, 296); diff --git a/osucatch-editor-realtimeviewer/EditorReader/HitObject.cs b/osucatch-editor-realtimeviewer/EditorReader/HitObject.cs index de0bb6e..9d132cd 100644 --- a/osucatch-editor-realtimeviewer/EditorReader/HitObject.cs +++ b/osucatch-editor-realtimeviewer/EditorReader/HitObject.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2019 Karoo13. Licensed under https://github.com/Karoo13/EditorReader/blob/master/LICENSE +// Copyright (c) 2019 Karoo13. Licensed under https://github.com/Karoo13/EditorReader/blob/master/LICENSE // See the LICENCE file in the EditorReader folder for full licence text. // https://github.com/Karoo13/EditorReader // Decompiled with ICSharpCode.Decompiler 8.1.1.7464 @@ -31,7 +31,8 @@ public class HitObject public float BaseY; - public string SampleFile; + /// 自定义音效文件名;字段没有字符串时为 null( 读取时才填)。 + public string? SampleFile; public int SampleVolume; @@ -41,11 +42,14 @@ public class HitObject public int CustomSampleSet; - public int[] SoundTypeList; + /// 每段音效类型;非滑条或整段音效统一时保持 null,读取路径外不要去解引用。 + public int[] SoundTypeList = null!; - public int[] SampleSetList; + /// 每段采样组;非滑条或整段音效统一时保持 null。 + public int[] SampleSetList = null!; - public int[] SampleSetAdditionsList; + /// 每段附加采样组;非滑条或整段音效统一时保持 null。 + public int[] SampleSetAdditionsList = null!; public bool IsSelected; @@ -55,7 +59,8 @@ public class HitObject public int CurveType; - public float[] sliderCurvePoints; + /// 滑条曲线采样点(x,y 交替);非滑条为 null, 只在滑条上调用。 + public float[] sliderCurvePoints = null!; public double curveLength; diff --git a/osucatch-editor-realtimeviewer/Form1.cs b/osucatch-editor-realtimeviewer/Form1.cs index faba769..4bcc33f 100644 --- a/osucatch-editor-realtimeviewer/Form1.cs +++ b/osucatch-editor-realtimeviewer/Form1.cs @@ -25,10 +25,11 @@ public partial class Form1 : Form private int _rebuildGeneration; private long _rebuildRetryTicks; - // 模板谱面(只读参考) - private ToolStripMenuItem? templateToolStripMenuItem; - private ToolStripMenuItem? selectTemplateStripMenuItem; - private ToolStripMenuItem? unloadTemplateStripMenuItem; + // 模板谱面(只读参考):三个菜单项都在构造函数里由 CreateTemplateMenu 创建, + // 构造完成后必然非空,所以声明成不可空(null! 只是让编译器知道“稍后在构造函数里赋值”) + private ToolStripMenuItem templateToolStripMenuItem = null!; + private ToolStripMenuItem selectTemplateStripMenuItem = null!; + private ToolStripMenuItem unloadTemplateStripMenuItem = null!; private TemplateBeatmapData? templateData; // 快捷开关条:可吸附在菜单栏下方(横排)或画布左 / 右侧(竖排),也可拖出为浮动小窗口 @@ -69,6 +70,19 @@ public partial class Form1 : Form private const string BarLineGroupKey = "BarLine"; private const string BarLineValueKey = "BarLineMode"; + // 垂直缩放功能区(滑块 + 当前比例文字) + private const string VerticalScaleGroupKey = "VerticalScale"; + private const string VerticalScaleValueKey = "VerticalScale"; + + /// + /// 垂直缩放滑块的取值范围与默认值(百分比整数):50 ~ 200 对应画面 Y 轴的 x0.5 ~ x2.0,默认 x1.0。 + /// 只能取整数,所以滑块内部用百分比表示,显示时才换算成倍率。 + /// + /// + private const int VerticalScaleMinPercent = 50; + private const int VerticalScaleMaxPercent = 200; + private const int VerticalScaleDefaultPercent = 100; + /// 快捷开关条上的下拉框 / 滑块所用的值标识。 private const string FreezeValueKey = FreezePreviewTimeKey; @@ -188,7 +202,8 @@ private static string ResolveImagePath(string relativePath) private static System.Timers.Timer backup_timer = new System.Timers.Timer(app.Default.Backup_Interval); private static System.Timers.Timer Memory_Monitor_Timer = new System.Timers.Timer(200); - private PeriodicTaskRunner runner; + /// 读取定时器;在 里创建,之前的代码不应该碰它。 + private PeriodicTaskRunner runner = null!; private HealthMonitor? healthMonitor; public Form1() @@ -324,6 +339,8 @@ private void Form1_Load(object sender, EventArgs e) SyncModToggleFromMenu(); SyncLabelToggleFromMenu(); ApplyBarLineMode(BarLineSettings.CurrentMode, persist: false); + // 垂直缩放不持久化:每次启动都把滑块与画面恢复成默认的 x1.0 + ApplyVerticalScale(VerticalScaleDefaultPercent); quickToggleDocking?.ApplyStartupState( app.Default.QuickToggle_Visible, app.Default.QuickToggle_Floating, @@ -1287,7 +1304,7 @@ private static void AppLang(ToolStripMenuItem item, System.ComponentModel.Compon { if (item is ToolStripMenuItem) { - resources.ApplyResources(item, item.Name); + resources.ApplyResources(item, item.Name ?? ""); ToolStripMenuItem tsmi = (ToolStripMenuItem)item; if (tsmi.DropDownItems.Count > 0) { @@ -1393,7 +1410,7 @@ private void restartProgramStripMenuItem_Click(object sender, EventArgs e) Application.Exit(); } - private async void selectTemplateStripMenuItem_Click(object sender, EventArgs e) + private async void selectTemplateStripMenuItem_Click(object? sender, EventArgs e) { using (OpenFileDialog openFileDialog = new OpenFileDialog()) { @@ -1439,7 +1456,7 @@ private async void selectTemplateStripMenuItem_Click(object sender, EventArgs e) } } - private void unloadTemplateStripMenuItem_Click(object sender, EventArgs e) + private void unloadTemplateStripMenuItem_Click(object? sender, EventArgs e) { templateData = null; drawingHelper.Template = null; @@ -1476,9 +1493,7 @@ private void CreateTemplateMenu() /// private void RestoreTemplateMenuText() { - if (unloadTemplateStripMenuItem == null) return; - - string baseText = unloadTemplateStripMenuItem.Text; + string baseText = unloadTemplateStripMenuItem.Text ?? ""; int suffixIndex = baseText.LastIndexOf(" ("); if (suffixIndex > 0) baseText = baseText.Substring(0, suffixIndex); @@ -1543,8 +1558,9 @@ private void CreateQuickToggles() } // ---- 果子标注:四个按钮单选,对应菜单栏里最常用的四种标注。 - // 每个模式都有对应图标,因此整组不加标题、按钮只显示图标(名称见按钮提示)。 ---- - QuickToggleBar.QuickToggleGroup labelGroup = quickToggleBar.AddGroup(LabelGroupKey, ""); + // 每个模式都有对应图标,因此整组不加标题、按钮只显示图标(名称见按钮提示); + // 条上没标题,但右键菜单里得有个看得懂的名字,见 LabelGroupMenuText。 ---- + QuickToggleBar.QuickToggleGroup labelGroup = quickToggleBar.AddGroup(LabelGroupKey, "", LabelGroupMenuText()); for (int i = 0; i < LabelKeys.Length; i++) { labelGroup.AddToggle(LabelKeys[i], LabelToggleText((QuickLabelMode)i), (QuickLabelMode)i == QuickLabelMode.Hidden); @@ -1560,6 +1576,19 @@ private void CreateQuickToggles() 1); barLineGroup.AddLabel(BarLineValueKey + "_Text", BarLineStatusText()); + // ---- 垂直缩放:滑块调节 Y 轴拉伸 / 压缩(X 轴不动),旁边显示当前比例。 + // 值只作用于本次运行,不写入设置文件,因此每次启动都是默认的 x1.0。 ---- + QuickToggleBar.QuickToggleGroup scaleGroup = quickToggleBar.AddGroup( + VerticalScaleGroupKey, + chinese ? "Y缩放" : "Y Scale"); + scaleGroup.AddSlider( + VerticalScaleValueKey, + VerticalScaleMinPercent, + VerticalScaleMaxPercent, + VerticalScaleDefaultPercent, + 25); + scaleGroup.AddLabel(VerticalScaleValueKey + "_Text", VerticalScaleStatusText()); + ApplyQuickToggleIcons(); } @@ -1682,6 +1711,19 @@ private static string ModToggleToolTip(ModMode mode) }; } + /// + /// 右键菜单里“果子标注”功能区的名称。 + /// 这个功能区在条上只显示四个图标、故意不带标题, + /// 若不显式给名字,菜单里的勾选项就会显示内部标识 HitObjectLabel——看不出是什么。 + /// 文案与菜单栏的“果子标注 / Fruit Labels”保持一致(不带快捷键标记,菜单项是代码创建的)。 + /// + /// + private static string LabelGroupMenuText() + { + bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh"; + return chinese ? "果子标注" : "Fruit Labels"; + } + /// 果子标注快捷按钮在当前语言下的文本。 private static string LabelToggleText(QuickLabelMode mode) { @@ -1713,6 +1755,27 @@ private static string LabelToggleToolTip(QuickLabelMode mode) /// private static string BarLineStatusText() => BarLineSettings.GetOptionName(BarLineSettings.CurrentMode); + /// + /// 设置画面 Y 轴(时间轴)的缩放比例:滑块值是按百分比存的整数,这里换算成倍率后立即生效。 + /// 只影响绘制(),不写入设置文件, + /// 所以重启后回到默认的 x1.0;画面 X 轴与各物件的显示大小都不受影响。 + /// + /// + private void ApplyVerticalScale(int percent) + { + int clamped = Math.Clamp(percent, VerticalScaleMinPercent, VerticalScaleMaxPercent); + drawingHelper.VerticalScale = clamped / 100f; + + quickToggleBar?.SetGroupSliderValue(VerticalScaleGroupKey, VerticalScaleValueKey, clamped); + quickToggleBar?.SetGroupLabelText(VerticalScaleGroupKey, VerticalScaleValueKey + "_Text", VerticalScaleStatusText()); + } + + /// + /// 垂直缩放功能区里显示的当前比例文字(形如 x1.00)。 + /// 滑块按 1% 步进,因此显示两位小数才能反映出每一档的变化。 + /// + private static string VerticalScaleStatusText() => "x" + drawingHelper.VerticalScale.ToString("0.00"); + @@ -1742,6 +1805,12 @@ private void quickToggleBar_ToggleChanged(object? sender, QuickToggleChangedEven private void quickToggleBar_GroupChanged(object? sender, QuickToggleGroupChangedEventArgs e) { + if (e.Key == VerticalScaleValueKey) + { + ApplyVerticalScale(e.Value); + return; + } + if (e.Key != BarLineValueKey) return; ApplyBarLineMode(BarLineSettings.Clamp(e.Value), persist: true); @@ -1996,8 +2065,9 @@ private void ApplyQuickToggleLanguage() // 功能区标题(AddGroup 对已存在的功能区只更新标题) quickToggleBar.AddGroup(ModGroupKey, ""); - quickToggleBar.AddGroup(LabelGroupKey, ""); + quickToggleBar.AddGroup(LabelGroupKey, "", LabelGroupMenuText()); quickToggleBar.AddGroup(BarLineGroupKey, chinese ? "拍线" : "Bar Lines"); + quickToggleBar.AddGroup(VerticalScaleGroupKey, chinese ? "Y缩放" : "Y Scale"); for (int i = 0; i < ModKeys.Length; i++) { @@ -2012,6 +2082,7 @@ private void ApplyQuickToggleLanguage() ApplyQuickToggleIcons(); quickToggleBar.SetGroupLabelText(BarLineGroupKey, BarLineValueKey + "_Text", BarLineStatusText()); + quickToggleBar.SetGroupLabelText(VerticalScaleGroupKey, VerticalScaleValueKey + "_Text", VerticalScaleStatusText()); } /// @@ -2127,7 +2198,7 @@ private void saveBookmarkToolStripMenuItem_Click(object sender, EventArgs e) private void SetDelBookmark(int styleId) { - if (bookmarkManager.BeatmapFolder == null || bookmarkManager.BeatmapFilename == null) + if (string.IsNullOrEmpty(bookmarkManager.BeatmapFolder) || string.IsNullOrEmpty(bookmarkManager.BeatmapFilename)) { MessageBox.Show("Editor is not running.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); return; diff --git a/osucatch-editor-realtimeviewer/OsuCatch-Editor-RealtimeViewer.csproj b/osucatch-editor-realtimeviewer/OsuCatch-Editor-RealtimeViewer.csproj index be2bc8f..7379616 100644 --- a/osucatch-editor-realtimeviewer/OsuCatch-Editor-RealtimeViewer.csproj +++ b/osucatch-editor-realtimeviewer/OsuCatch-Editor-RealtimeViewer.csproj @@ -21,6 +21,22 @@ 两份实现提供完全相同的公开 API,调用方(EditorReaderHelper / Form1)无需改动。 --> false + + + $(NoWarn);CS8981;NU1701 + false @@ -123,7 +139,9 @@ - + diff --git a/osucatch-editor-realtimeviewer/PeriodicTaskRunner.cs b/osucatch-editor-realtimeviewer/PeriodicTaskRunner.cs index a3cbafc..fb7c33c 100644 --- a/osucatch-editor-realtimeviewer/PeriodicTaskRunner.cs +++ b/osucatch-editor-realtimeviewer/PeriodicTaskRunner.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Runtime.InteropServices; namespace osucatch_editor_realtimeviewer @@ -6,8 +6,10 @@ namespace osucatch_editor_realtimeviewer public class PeriodicTaskRunner { private readonly Func _task; - private CancellationTokenSource _cts; - private Task _runTask; + + /// 运行期状态: 之前 / 之后为 null。 + private CancellationTokenSource? _cts; + private Task? _runTask; private long _lastStartTimestamp; private long _intervalTicks; private long _errorDelayTicks; @@ -36,17 +38,21 @@ public void Start() public async Task StopAsync() { - if (_cts == null) return; + // 用局部变量承接:字段可能在 await 期间被其它线程改写, + // 局部量既避免重复判空,也让 finally 里的 Dispose 明确非空 + CancellationTokenSource? cts = _cts; + if (cts == null) return; - _cts.Cancel(); + cts.Cancel(); try { - await _runTask; + Task? runTask = _runTask; + if (runTask != null) await runTask; } catch (OperationCanceledException) { } finally { - _cts.Dispose(); + cts.Dispose(); _cts = null; timeEndPeriod(1); } diff --git a/osucatch-editor-realtimeviewer/QuickToggleBar.cs b/osucatch-editor-realtimeviewer/QuickToggleBar.cs index d6baa36..c55f70d 100644 --- a/osucatch-editor-realtimeviewer/QuickToggleBar.cs +++ b/osucatch-editor-realtimeviewer/QuickToggleBar.cs @@ -166,19 +166,35 @@ private void RequestDockSide(QuickToggleDockSide side) /// /// 功能区标题(显示在按钮之前)。传空字符串表示不加标题—— /// 例如 MOD 区只有 NM/EZ/HR 三个按钮,用户一看便知,标题反而占地方; - /// 此时该功能区依然出现在右键菜单里(菜单项文字回退为 )。 + /// 此时该功能区依然出现在右键菜单里(菜单项文字回退为 , + /// 或用下面那个重载显式给一个看得懂的名字)。 /// internal QuickToggleGroup AddGroup(string key, string label) + => AddGroup(key, label, menuText: null); + + /// + /// 添加一个功能区,并单独指定它在右键菜单里的名称。 + /// 用于“条上只显示按钮 / 图标、不要标题,但右键菜单需要一个看得懂的名字”的功能区 + /// (如“果子标注”:条上是四个图标,菜单里若回退成内部标识 HitObjectLabel 就没法看)。 + /// + /// 与 一样,语言切换后必须重新传一次 + /// (传 null 表示沿用上次给的名字)。 + /// + /// + /// 功能区标识,用于持久化显示状态,需保持稳定。 + /// 功能区在条上的标题;传空字符串表示条上不加标题。 + /// 右键菜单里的名称;传 null 时按标题 / 标识回退。 + internal QuickToggleGroup AddGroup(string key, string label, string? menuText) { ArgumentException.ThrowIfNullOrEmpty(key); if (groups.TryGetValue(key, out QuickToggleGroup? existing)) { - existing.SetLabel(label); + existing.SetLabel(label, menuText); return existing; } - QuickToggleGroup group = new(this, key, label, showLabel: label.Length > 0); + QuickToggleGroup group = new(this, key, label, showLabel: label.Length > 0, menuText); groups[key] = group; groupOrder.Add(group); RebuildItems(); @@ -1012,12 +1028,15 @@ internal sealed class QuickToggleGroup private readonly string fallbackText; private ToolStripMenuItem? menuItem; - internal QuickToggleGroup(QuickToggleBar owner, string key, string labelText, bool showLabel) + /// 右键菜单里显式指定的名称;为 null 时按条上标题 / 标识回退。 + private string? menuText; + + internal QuickToggleGroup(QuickToggleBar owner, string key, string labelText, bool showLabel, string? menuText = null) { this.owner = owner; Key = key; fallbackText = key; - DisplayText = labelText.Length > 0 ? labelText : key; + ApplyTexts(labelText, menuText); if (!showLabel) return; @@ -1036,10 +1055,11 @@ internal QuickToggleGroup(QuickToggleBar owner, string key, string labelText, bo internal string Key { get; } /// - /// 功能区标题文本。没有标题的功能区( 传空字符串)用标识兜底, - /// 这样右键菜单里的勾选项仍然有文字可显示。 + /// 功能区在右键菜单里显示的名称:优先用显式指定的菜单名, + /// 其次用条上标题( 传空字符串表示条上不加标题), + /// 都没有时用标识兜底——这样菜单里的勾选项始终有文字可显示。 /// - internal string DisplayText { get; private set; } + internal string DisplayText { get; private set; } = string.Empty; /// 功能区当前是否显示(右键菜单可切换)。 internal bool Visible { get; set; } = true; @@ -1054,12 +1074,22 @@ internal ToolStripMenuItem? MenuItem /// 该功能区已加入的项(含标题与值控件)。 internal IReadOnlyList Items => items; - /// 设置功能区标题(语言切换时调用)。传空字符串只影响标题项,不影响右键菜单文字。 - internal void SetLabel(string text) + /// + /// 设置功能区的标题与右键菜单名称(语言切换时调用)。 + /// 传空标题只影响条上的标题项; 传 null 表示沿用上次给的名字。 + /// + internal void SetLabel(string text, string? menuText = null) { - DisplayText = text.Length > 0 ? text : fallbackText; + ApplyTexts(text, menuText); if (label != null) label.Text = text; - if (menuItem != null) menuItem.Text = DisplayText; + if (this.menuItem != null) this.menuItem.Text = DisplayText; + } + + /// 按条上标题与菜单名算出 。 + private void ApplyTexts(string text, string? menuText) + { + if (menuText != null) this.menuText = menuText; + DisplayText = this.menuText ?? (text.Length > 0 ? text : fallbackText); } /// diff --git a/osucatch-editor-realtimeviewer/osuTK/Math/Vector2.cs b/osucatch-editor-realtimeviewer/osuTK/Math/Vector2.cs index 7678f27..48c82ae 100644 --- a/osucatch-editor-realtimeviewer/osuTK/Math/Vector2.cs +++ b/osucatch-editor-realtimeviewer/osuTK/Math/Vector2.cs @@ -265,7 +265,7 @@ public override int GetHashCode() /// /// The object to compare to. /// True if the instances are equal; false otherwise. - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (!(obj is Vector2)) {