From 2fd79d33922c5017153202c2a159ea392850203c Mon Sep 17 00:00:00 2001 From: Exsper Date: Fri, 18 Sep 2026 07:18:46 +0800 Subject: [PATCH] debug: editor reload --- EditorReaderHarness/Program.cs | 248 +++++++++++++++++- .../EditorReader/EditorReader.cs | 80 +++++- .../EditorReaderHelper.cs | 4 +- 3 files changed, 323 insertions(+), 9 deletions(-) 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."); }