Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 247 additions & 1 deletion EditorReaderHarness/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Editor_Reader;
using osucatch_editor_realtimeviewer;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2249,6 +2251,250 @@ private static void ProbeReadThreshold()
}
}

// ------------------------------------------------------------------ 长时间错误监控

/// <summary>
/// 长时间错误监控:每 50ms 走一次完整的"编辑器校验 + 全量读取"链路,
/// 复刻生产里的重试策略(快照级失败原地重试 1 次,重试仍失败才计入),
/// 每 10 秒打印一次汇总,所有失败明细写入日志文件。
/// 用于"用户实际操作(进退编辑器/切难度/改谱/新建/保存)时有没有错误"。
/// </summary>
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<string, long>();

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

/// <summary>
/// 对每个编辑器候选分别用"旧判据"和"新判据"跑一遍,看谁能通过。
/// 旧判据 = 签名 + 状态字段 + HOM/物件列表指针非空(原 IsPlausibleEditor / EditorMissingObjects)。
/// 新判据 = 再跟着指针实际采样读一次物件(ObjectsReadableFromChain)。
/// 死副本应当表现为"旧判据通过、新判据拒绝"。
/// </summary>
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<long>();
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()
Expand Down
80 changes: 74 additions & 6 deletions osucatch-editor-realtimeviewer/EditorReader/EditorReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@
/// <summary>诊断用:最近一次 <see cref="ReadObjects"/> 抛异常时的物件下标与地址。</summary>
public string? DiagReadObjectFailure;

/// <summary>诊断用:最近一次 <see cref="ReadObjects"/> 跳过的空指针条数(编辑器重建列表的瞬间)。</summary>
public int DiagNullObjectPointers;

private IntPtr pClipboardL;

private IntPtr pClipboardA;
Expand Down Expand Up @@ -323,7 +326,7 @@
{
if (pString == IntPtr.Zero)
{
return null;

Check warning on line 329 in osucatch-editor-realtimeviewer/EditorReader/EditorReader.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference return.

Check warning on line 329 in osucatch-editor-realtimeviewer/EditorReader/EditorReader.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference return.
}

SafeReadProcessMemory(TargetHandle, pString + 4, buffer4, 4, ref bytesRead);
Expand Down Expand Up @@ -599,14 +602,55 @@
int count = BitConverter.ToInt32(probe16, 12);
if (pObjectsArray == IntPtr.Zero || count < 0 || count > 1000000) return false;

return true;
// 上面几步只证明了"指针链结构上说得通"。死副本的堆块在被复用前内容不会被清零,
// 所以一整条指针链都可能仍然自洽 —— 必须再实际跟着指针读一次物件才算数。
return ObjectsReadableFromChain(pHom, probe16, probe4);
}

/// <summary>
/// 从 HOM 出发跟着指针链检查"物件列表里的物件真的能读"。
/// <para />这是区分"活着的编辑器"与"堆里残留的死副本"的关键一步:
/// 死副本的指针链能通过结构校验,但它指向的物件已经被释放,
/// 于是候选校验/重载判断都会误判,之后每次全量读取都抛异常 ——
/// 表现就是"退出再进入编辑器后长时间一直读取失败、重绑也救不回来"。
/// </summary>
/// <returns>true 表示物件列表可读且至少有一个物件能读出来。</returns>
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;
}

/// <summary>
/// 绑定 osu! 进程。切换到新进程时清空扫描状态:地址空间已完全不同,
/// 旧的退避表/命中区域/扫描游标都失去意义。
/// </summary>
public void SetProcess(Process forceProcess = null)

Check warning on line 653 in osucatch-editor-realtimeviewer/EditorReader/EditorReader.cs

View workflow job for this annotation

GitHub Actions / build

Cannot convert null literal to non-nullable reference type.

Check warning on line 653 in osucatch-editor-realtimeviewer/EditorReader/EditorReader.cs

View workflow job for this annotation

GitHub Actions / build

Cannot convert null literal to non-nullable reference type.
{
if (forceProcess != null)
{
Expand Down Expand Up @@ -725,14 +769,21 @@
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
{
Expand Down Expand Up @@ -967,9 +1018,19 @@
{
var hitObjects = new List<HitObject>();
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));
Expand All @@ -983,6 +1044,13 @@
}
}

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++)
{
Expand Down
Loading
Loading