diff --git a/EditorReaderHarness/Program.cs b/EditorReaderHarness/Program.cs
index 5b229d0..e05d4dd 100644
--- a/EditorReaderHarness/Program.cs
+++ b/EditorReaderHarness/Program.cs
@@ -1,4 +1,4 @@
-using System.Diagnostics;
+using System.Diagnostics;
using System.Runtime.InteropServices;
using Editor_Reader;
using osucatch_editor_realtimeviewer;
@@ -76,6 +76,8 @@ private static void RunCommand(string cmd)
case "q2": SnapshotConsistency(); break;
case "L": LoadWindowStress(); break;
case "T": ProbeReadThreshold(); break;
+ case "M": ErrorMonitor(); break;
+ case "C": CompareCandidateChecks(); break;
case "e": EagerVsLazyLines(); break;
case "k": ReadCostBreakdown(); break;
case "x": RetryHealTest(); break;
@@ -2249,6 +2251,250 @@ private static void ProbeReadThreshold()
}
}
+ // ------------------------------------------------------------------ 长时间错误监控
+
+ ///
+ /// 长时间错误监控:每 50ms 走一次完整的"编辑器校验 + 全量读取"链路,
+ /// 复刻生产里的重试策略(快照级失败原地重试 1 次,重试仍失败才计入),
+ /// 每 10 秒打印一次汇总,所有失败明细写入日志文件。
+ /// 用于"用户实际操作(进退编辑器/切难度/改谱/新建/保存)时有没有错误"。
+ ///
+ private static void ErrorMonitor()
+ {
+ WriteLine("长时间错误监控。输入持续秒数(回车=1800):");
+ string? s = Console.ReadLine();
+ int seconds = int.TryParse(s, out int v) && v > 0 ? v : 1800;
+
+ WriteLine($"开始监控 {seconds} 秒。请现在开始你的操作序列…\n");
+
+ long ticks = 0, fullReads = 0, transient = 0, hardFailures = 0;
+ long editorChecks = 0, editorNotReady = 0, rebinds = 0;
+ long lastObjects = -1;
+ string lastFile = "";
+ long mapSwitches = 0;
+ var reasonCounts = new Dictionary();
+
+ var sw = Stopwatch.StartNew();
+ long nextReportAt = 10_000;
+ string logPath = Path.Combine(Path.GetTempPath(), "editorreader-monitor.log");
+ try { File.WriteAllText(logPath, "EditorReader 监控日志 " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "\r\n"); } catch { }
+ void Log(string line)
+ {
+ try { File.AppendAllText(logPath, line + "\r\n"); } catch { }
+ }
+
+ while (sw.ElapsedMilliseconds < seconds * 1000L)
+ {
+ ticks++;
+
+ // ---- 编辑器校验(等价于 FetchEditor 的廉价路径)
+ try
+ {
+ editorChecks++;
+ if (_reader.EditorNeedsReload())
+ {
+ rebinds++;
+ Log($"[{sw.ElapsedMilliseconds / 1000.0:F1}s] 需要重绑(退出编辑器 / 换谱面 / 地址失效)");
+ try
+ {
+ _reader.ResetEditor();
+ _reader.FetchEditor();
+ }
+ catch (Exception ex)
+ {
+ editorNotReady++;
+ Log($"[{sw.ElapsedMilliseconds / 1000.0:F1}s] 重绑失败(可能不在编辑器): {ex.Message}");
+ Thread.Sleep(50);
+ continue;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ editorNotReady++;
+ Log($"[{sw.ElapsedMilliseconds / 1000.0:F1}s] 编辑器校验异常: {ex.Message}");
+ Thread.Sleep(50);
+ continue;
+ }
+
+ // ---- 全量读取(含生产策略:快照级失败重试 1 次)
+ try
+ {
+ fullReads++;
+ try
+ {
+ _reader.FetchAll(false);
+ }
+ catch (Exception first)
+ {
+ try
+ {
+ _reader.FetchAll(false);
+ transient++;
+ Log($"[{sw.ElapsedMilliseconds / 1000.0:F1}s] 瞬时失败(重试恢复): {_reader.DiagReadObjectFailure ?? first.Message}");
+ }
+ catch (Exception second)
+ {
+ hardFailures++;
+ string detail = _reader.DiagReadObjectFailure ?? second.Message;
+ reasonCounts[detail] = reasonCounts.GetValueOrDefault(detail) + 1;
+ Log($"[{sw.ElapsedMilliseconds / 1000.0:F1}s] !! 硬失败(重试仍失败): {detail}");
+ }
+ }
+
+ if (_reader.numObjects != lastObjects || _reader.Filename != lastFile)
+ {
+ mapSwitches++;
+ Log($"[{sw.ElapsedMilliseconds / 1000.0:F1}s] 谱面变化: {lastObjects} -> {_reader.numObjects} {_reader.Filename} 控制点={_reader.numControlPoints}");
+ lastObjects = _reader.numObjects;
+ lastFile = _reader.Filename;
+ }
+ }
+ catch (Exception ex)
+ {
+ hardFailures++;
+ reasonCounts["外层: " + ex.Message] = reasonCounts.GetValueOrDefault("外层: " + ex.Message) + 1;
+ Log($"[{sw.ElapsedMilliseconds / 1000.0:F1}s] !! 外层异常: {ex}");
+ }
+
+ // ---- 周期汇总
+ if (sw.ElapsedMilliseconds >= nextReportAt)
+ {
+ nextReportAt += 10_000;
+ string line = $"[{sw.ElapsedMilliseconds / 1000.0,6:F0}s] tick={ticks} 全量={fullReads} 瞬时={transient} 硬失败={hardFailures} " +
+ $"重绑={rebinds} 未就绪={editorNotReady} 谱面变化={mapSwitches} 当前物件={_reader.numObjects}";
+ WriteLine(line);
+ Log(line);
+ }
+
+ Thread.Sleep(50);
+ }
+
+ WriteLine("\n===== 监控结束 =====");
+ WriteLine($"tick={ticks} 全量读取={fullReads} 编辑器校验={editorChecks}");
+ WriteLine($"瞬时失败(重试即恢复)={transient}");
+ WriteLine($"硬失败(重试仍失败) ={hardFailures}");
+ WriteLine($"编辑器重绑={rebinds} 绑定未就绪={editorNotReady} 谱面变化={mapSwitches}");
+ if (reasonCounts.Count > 0)
+ {
+ WriteLine("硬失败原因:");
+ foreach (var kv in reasonCounts.OrderByDescending(k => k.Value).Take(20)) WriteLine($" ×{kv.Value} {kv.Key}");
+ }
+ else
+ {
+ WriteLine("没有硬失败。");
+ }
+ WriteLine($"完整日志: {logPath}");
+ }
+
+ // ------------------------------------------------------------------ 候选判据对照(旧 vs 新)
+
+ ///
+ /// 对每个编辑器候选分别用"旧判据"和"新判据"跑一遍,看谁能通过。
+ /// 旧判据 = 签名 + 状态字段 + HOM/物件列表指针非空(原 IsPlausibleEditor / EditorMissingObjects)。
+ /// 新判据 = 再跟着指针实际采样读一次物件(ObjectsReadableFromChain)。
+ /// 死副本应当表现为"旧判据通过、新判据拒绝"。
+ ///
+ private static void CompareCandidateChecks()
+ {
+ if (_hProcess == IntPtr.Zero) { WriteLine("先 a 绑定"); return; }
+
+ byte[] pattern = new byte[]
+ {
+ 0x23,0,0,0, 0x14,0,0,0, 0x19,0,0,0,
+ 0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,
+ 0xEE,0xEE,0xEE,0xEE,
+ 0x0C,0,0,0,
+ 0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,0xEE,
+ 0x00
+ };
+
+ WriteLine("扫描全部编辑器候选…");
+ var regions = Mem.Regions(_hProcess);
+ var candidates = new List();
+ foreach (var r in regions)
+ {
+ long size = r.RegionSize.ToInt64();
+ if (size <= 0 || size > 256L * 1024 * 1024) continue;
+ byte[] buffer = new byte[size];
+ if (!Mem.Rpm(_hProcess, r.BaseAddress, buffer, (int)size)) continue;
+ for (int j = 0; j + pattern.Length <= buffer.Length; j += 4)
+ {
+ if (buffer[j] != 0x23 || buffer[j + 4] != 0x14 || buffer[j + 8] != 0x19 || buffer[j + 32] != 0x0C) continue;
+ if (!PatternMatch(buffer, pattern, j)) continue;
+ candidates.Add(r.BaseAddress.ToInt64() + j - 160);
+ }
+ }
+
+ WriteLine($"命中 {candidates.Count} 个候选。判据对照:\n");
+ WriteLine($"{"候选地址",-14}{"状态字段",-14}{"旧判据",-8}{"新判据",-8}{"物件数",-8}{"采样可读",-10}说明");
+ WriteLine(new string('-', 104));
+
+ byte[] b16 = new byte[16];
+ byte[] b4 = new byte[4];
+
+ foreach (long c in candidates)
+ {
+ IntPtr pE = (IntPtr)c;
+
+ string fields = "读取失败";
+ if (Mem.Rpm(_hProcess, pE + 160, b16, 16))
+ {
+ fields = $"({BitConverter.ToInt32(b16, 0)},{BitConverter.ToInt32(b16, 4)},{BitConverter.ToInt32(b16, 8)})";
+ }
+
+ // 旧判据:HOM 非空 且 物件列表指针非空
+ bool oldOk = false;
+ IntPtr pHom = IntPtr.Zero;
+ if (Mem.Rpm(_hProcess, pE + 28, b4, 4))
+ {
+ pHom = (IntPtr)(long)BitConverter.ToUInt32(b4, 0);
+ if (pHom != IntPtr.Zero && Mem.Rpm(_hProcess, (IntPtr)(pHom.ToInt64() + 72), b4, 4))
+ {
+ oldOk = BitConverter.ToUInt32(b4, 0) != 0;
+ }
+ }
+
+ // 新判据:再跟着指针采样读物件
+ int count = -1, readable = 0;
+ bool newOk = false;
+ if (oldOk)
+ {
+ Mem.Rpm(_hProcess, (IntPtr)(pHom.ToInt64() + 72), b4, 4);
+ IntPtr pList = (IntPtr)(long)BitConverter.ToUInt32(b4, 0);
+ if (Mem.Rpm(_hProcess, pList, b16, 16))
+ {
+ IntPtr pArr = (IntPtr)(long)BitConverter.ToUInt32(b16, 4);
+ count = BitConverter.ToInt32(b16, 12);
+ if (count > 0 && pArr != IntPtr.Zero)
+ {
+ foreach (int idx in new[] { 0, count / 2, count - 1 })
+ {
+ if (!Mem.Rpm(_hProcess, (IntPtr)(pArr.ToInt64() + 8 + 4 * idx), b4, 4)) continue;
+ IntPtr pObj = (IntPtr)(long)BitConverter.ToUInt32(b4, 0);
+ if (pObj == IntPtr.Zero) continue;
+ if (Mem.Rpm(_hProcess, pObj, b4, 4)) readable++;
+ }
+ }
+ else if (count == 0)
+ {
+ readable = -2; // 空列表
+ }
+ }
+ newOk = count == 0 || readable > 0;
+ }
+
+ bool isCurrent = c == _reader.EditorAddress.ToInt64();
+ string note = isCurrent ? "[当前绑定] " : "";
+ if (oldOk && !newOk) note += "<== 死副本:旧判据放过、新判据拒绝";
+ else if (!oldOk) note += "(旧判据就拒绝了)";
+
+ WriteLine($"{("0x" + c.ToString("X8")),-16}{fields,-14}{(oldOk ? "通过" : "拒绝"),-8}{(newOk ? "通过" : "拒绝"),-8}{count,-8}{readable,-10}{note}");
+ }
+
+ WriteLine("\n旧判据只要求 HOM 与物件列表指针非空;新判据还要求采样到的物件真能读出来。");
+ }
+
// ------------------------------------------------------------------ 8. 重试修复测试
private static void RetryHealTest()
diff --git a/osucatch-editor-realtimeviewer/EditorReader/EditorReader.cs b/osucatch-editor-realtimeviewer/EditorReader/EditorReader.cs
index a46cc9c..c923521 100644
--- a/osucatch-editor-realtimeviewer/EditorReader/EditorReader.cs
+++ b/osucatch-editor-realtimeviewer/EditorReader/EditorReader.cs
@@ -142,6 +142,9 @@ public IntPtr ForceHandle
/// 诊断用:最近一次 抛异常时的物件下标与地址。
public string? DiagReadObjectFailure;
+ /// 诊断用:最近一次 跳过的空指针条数(编辑器重建列表的瞬间)。
+ public int DiagNullObjectPointers;
+
private IntPtr pClipboardL;
private IntPtr pClipboardA;
@@ -599,7 +602,48 @@ private bool IsPlausibleEditor(IntPtr pE, byte[] probe16, byte[] probe4)
int count = BitConverter.ToInt32(probe16, 12);
if (pObjectsArray == IntPtr.Zero || count < 0 || count > 1000000) return false;
- return true;
+ // 上面几步只证明了"指针链结构上说得通"。死副本的堆块在被复用前内容不会被清零,
+ // 所以一整条指针链都可能仍然自洽 —— 必须再实际跟着指针读一次物件才算数。
+ return ObjectsReadableFromChain(pHom, probe16, probe4);
+ }
+
+ ///
+ /// 从 HOM 出发跟着指针链检查"物件列表里的物件真的能读"。
+ /// 这是区分"活着的编辑器"与"堆里残留的死副本"的关键一步:
+ /// 死副本的指针链能通过结构校验,但它指向的物件已经被释放,
+ /// 于是候选校验/重载判断都会误判,之后每次全量读取都抛异常 ——
+ /// 表现就是"退出再进入编辑器后长时间一直读取失败、重绑也救不回来"。
+ ///
+ /// true 表示物件列表可读且至少有一个物件能读出来。
+ private bool ObjectsReadableFromChain(IntPtr pHom, byte[] probe16, byte[] probe4)
+ {
+ IntPtr read = IntPtr.Zero;
+
+ if (!ReadProcessMemory(TargetHandle, pHom + 72, probe4, 4, ref read)) return false;
+ IntPtr pObjectsList = ToIntPtr(probe4, 0);
+ if (pObjectsList == IntPtr.Zero) return false;
+
+ if (!ReadProcessMemory(TargetHandle, pObjectsList, probe16, 16, ref read)) return false;
+ IntPtr pObjectsArray = ToIntPtr(probe16, 4);
+ int count = BitConverter.ToInt32(probe16, 12);
+ if (pObjectsArray == IntPtr.Zero || count < 0 || count > 1000000) return false;
+
+ // 编辑器里总有物件可读;numObjects==0 只可能出现在"列表正在被重建"的瞬间,
+ // 不作为判定依据(那种瞬态由上层重试覆盖)。
+ if (count == 0) return true;
+
+ // 采样首、中、尾三个物件:跟着指针读 4 字节即可确认整条链没被复用
+ int[] samples = { 0, count / 2, count - 1 };
+ int readable = 0;
+ foreach (int index in samples)
+ {
+ if (!ReadProcessMemory(TargetHandle, pObjectsArray + 8 + TargetPointerSize * index, probe4, 4, ref read)) continue;
+ IntPtr pObject = ToIntPtr(probe4, 0);
+ if (pObject == IntPtr.Zero) continue;
+ if (ReadProcessMemory(TargetHandle, pObject, probe4, 4, ref read)) readable++;
+ }
+
+ return readable > 0;
}
///
@@ -725,14 +769,21 @@ private bool EditorMissingObjects(IntPtr pE)
try
{
SafeReadProcessMemory(TargetHandle, pE + 28, buffer4, 4, ref bytesRead);
- IntPtr intPtr = ToIntPtr(buffer4, 0);
- if (intPtr == IntPtr.Zero)
+ IntPtr pHom = ToIntPtr(buffer4, 0);
+ if (pHom == IntPtr.Zero)
+ {
+ return true;
+ }
+
+ SafeReadProcessMemory(TargetHandle, pHom + 72, buffer4, 4, ref bytesRead);
+ if (ToIntPtr(buffer4, 0) == IntPtr.Zero)
{
return true;
}
- SafeReadProcessMemory(TargetHandle, intPtr + 72, buffer4, 4, ref bytesRead);
- IntPtr intPtr2 = ToIntPtr(buffer4, 0);
- return intPtr2 == IntPtr.Zero;
+
+ // 光看指针非空不够:退出/重进编辑器后,旧编辑器对象常常整条指针链都还自洽,
+ // 但链上的物件已经被释放。实际跟指针读一次物件,才能识别出这种死副本。
+ return !ObjectsReadableFromChain(pHom, buffer16, buffer4);
}
catch
{
@@ -967,9 +1018,19 @@ public void ReadObjects(bool fetchHitSound = true)
{
var hitObjects = new List();
DiagReadObjectFailure = null;
+ int nullPointers = 0;
for (int i = 0; i < numObjects; i++)
{
IntPtr pObject = ToIntPtr(pObjects, 4 * i);
+ if (pObject == IntPtr.Zero)
+ {
+ // 编辑器重建物件列表的瞬间,指针数组里会出现空槽(实测 index=0 ptr=0x0)。
+ // 那不是"读到垃圾",只是这一条还没写进去,跳过即可;
+ // 若当成错误抛出,整个全量读取就会失败,客户端开始退避/重绑。
+ nullPointers++;
+ continue;
+ }
+
try
{
hitObjects.Add(ReadObject(pObject, fetchHitSound));
@@ -983,6 +1044,13 @@ public void ReadObjects(bool fetchHitSound = true)
}
}
+ DiagNullObjectPointers = nullPointers;
+ if (nullPointers > 0)
+ {
+ Log.ConsoleLog($"ReadObjects: skipped {nullPointers} null pointer(s) out of {numObjects}.",
+ Log.LogType.EditorReader, Log.LogLevel.Warning);
+ }
+
// 诊断:统计哪些字段越界(EditorReaderHarness 使用,正常运行时开销为 0)
for (int i = 0; i < hitObjects.Count; i++)
{
diff --git a/osucatch-editor-realtimeviewer/EditorReaderHelper.cs b/osucatch-editor-realtimeviewer/EditorReaderHelper.cs
index bf1ecaf..f495376 100644
--- a/osucatch-editor-realtimeviewer/EditorReaderHelper.cs
+++ b/osucatch-editor-realtimeviewer/EditorReaderHelper.cs
@@ -702,7 +702,7 @@ public BeatmapInfoCollection(EditorReader reader)
// Under MIT Licnece https://github.com/OliBomby/Mapping_Tools/blob/master/LICENCE
if (!(reader.numControlPoints > 0 &&
reader.controlPoints != null && reader.hitObjects != null &&
- reader.numControlPoints == reader.controlPoints.Count && reader.numObjects == reader.hitObjects.Count))
+ reader.controlPoints.Count <= reader.numControlPoints && reader.hitObjects.Count <= reader.numObjects))
{
throw new Exception("Fetched data is invalid.");
}
@@ -759,7 +759,7 @@ public BeatmapInfoCollection(EditorReader reader, double partialLoadingHalfTimeS
// Under MIT Licnece https://github.com/OliBomby/Mapping_Tools/blob/master/LICENCE
if (!(reader.numControlPoints > 0 &&
reader.controlPoints != null && reader.hitObjects != null &&
- reader.numControlPoints == reader.controlPoints.Count && reader.numObjects == reader.hitObjects.Count))
+ reader.controlPoints.Count <= reader.numControlPoints && reader.hitObjects.Count <= reader.numObjects))
{
throw new Exception("Fetched data is invalid.");
}