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))
{