diff --git a/EditorReaderHarness/EditorReaderHarness.csproj b/EditorReaderHarness/EditorReaderHarness.csproj new file mode 100644 index 0000000..52ffc4b --- /dev/null +++ b/EditorReaderHarness/EditorReaderHarness.csproj @@ -0,0 +1,24 @@ + + + + + Exe + net8.0-windows7.0 + EditorReaderHarness + enable + enable + true + EditorReaderHarness + true + true + + + + + + + diff --git a/EditorReaderHarness/Log.cs b/EditorReaderHarness/Log.cs new file mode 100644 index 0000000..37fa66e --- /dev/null +++ b/EditorReaderHarness/Log.cs @@ -0,0 +1,38 @@ +namespace osucatch_editor_realtimeviewer +{ + /// + /// 主工程 Log 的替身:EditorReader 的内部错误日志走到这里。 + /// 默认只累计计数,避免在计时循环里往控制台写 IO 把数字带偏。 + /// + public static class Log + { + public enum LogType { Default, Program, EditorReader, BeatmapBuilder, BeatmapConverter, Drawing, Backup, Timer, Bookmark } + + public enum LogLevel { Debug, Info, Warning, Error } + + public static long Count; + + public static bool Echo; + + public static readonly List Recent = new(); + + public static void ConsoleLog(string msg, LogType logType = LogType.Default, LogLevel logLevel = LogLevel.Info) + { + Interlocked.Increment(ref Count); + lock (Recent) + { + if (Recent.Count < 200) + { + Recent.Add("[" + logLevel + "][" + logType + "] " + msg); + } + } + + if (Echo) + { + Console.WriteLine(" [osu-reader] " + logLevel + ": " + msg); + } + } + + public static void Breadcrumb(string msg) => ConsoleLog(msg, LogType.Program, LogLevel.Debug); + } +} diff --git a/EditorReaderHarness/Mem.cs b/EditorReaderHarness/Mem.cs new file mode 100644 index 0000000..e6bc6d2 --- /dev/null +++ b/EditorReaderHarness/Mem.cs @@ -0,0 +1,388 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace EditorReaderHarness; + +/// +/// 跨进程读取原语集合:所有"更好的内存读取方法"的候选实现都放在这里, +/// 方便用同一套计时框架横向对比。 +/// +public static class Mem +{ + // ---------------------------------------------------------------- native + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, int dwSize, out IntPtr lpNumberOfBytesRead); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, IntPtr lpBuffer, int dwSize, out IntPtr lpNumberOfBytesRead); + + [DllImport("ntdll.dll")] + private static extern int NtReadVirtualMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, IntPtr dwSize, out IntPtr lpNumberOfBytesRead); + + [DllImport("ntdll.dll")] + private static extern int NtReadVirtualMemory(IntPtr hProcess, IntPtr lpBaseAddress, IntPtr lpBuffer, IntPtr dwSize, out IntPtr lpNumberOfBytesRead); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool IsWow64Process(IntPtr hProcess, out bool wow64Process); + + [StructLayout(LayoutKind.Sequential)] + public struct MEMORY_BASIC_INFORMATION + { + public IntPtr BaseAddress; + public IntPtr AllocationBase; + public uint AllocationProtect; + public IntPtr RegionSize; + public uint State; + public uint Protect; + public uint Type; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern int VirtualQueryEx(IntPtr hProcess, IntPtr lpAddress, out MEMORY_BASIC_INFORMATION lpBuffer, int dwLength); + + public static long NumRpmCalls; + public static long NumBytesRead; + + public static void ResetCounters() + { + NumRpmCalls = 0; + NumBytesRead = 0; + } + + // ---------------------------------------------------------------- 现有的逐次读取方式 + + /// 与主工程 EditorReader.SafeReadProcessMemory 完全一致(kernel32 ReadProcessMemory)。 + public static bool Rpm(IntPtr hProcess, IntPtr address, byte[] buffer, int size) + { + NumRpmCalls++; + NumBytesRead += size; + return ReadProcessMemory(hProcess, address, buffer, size, out _); + } + + public static bool Rpm(IntPtr hProcess, IntPtr address, IntPtr buffer, int size) + { + NumRpmCalls++; + NumBytesRead += size; + return ReadProcessMemory(hProcess, address, buffer, size, out _); + } + + // ---------------------------------------------------------------- 候选 A:ntdll 直连 + + /// 绕过 kernel32 包装,直接调用 NtReadVirtualMemory。 + public static bool NtRpm(IntPtr hProcess, IntPtr address, byte[] buffer, int size) + { + NumRpmCalls++; + NumBytesRead += size; + int status = NtReadVirtualMemory(hProcess, address, buffer, (IntPtr)size, out IntPtr read); + return status >= 0 && read.ToInt64() == size; + } + + public static bool NtRpm(IntPtr hProcess, IntPtr address, IntPtr buffer, int size) + { + NumRpmCalls++; + NumBytesRead += size; + int status = NtReadVirtualMemory(hProcess, address, buffer, (IntPtr)size, out IntPtr read); + return status >= 0 && read.ToInt64() == size; + } + + // ---------------------------------------------------------------- 候选 B:散列聚合(scatter-gather) + + /// + /// 把一批地址合并成尽量少的"大块读取":相邻地址间隔小于 maxGap 时就并进同一块。 + /// maxGap=0 表示要求地址严格连续,maxGap 越大合并越激进(阅读量也越大)。 + /// 返回:(读取次数, 覆盖字节数, 命中物件的读取次数)。 + /// + public static (int Reads, long Bytes, int HitObjects) ScatterReadStructures( + IntPtr hProcess, IReadOnlyList addresses, int structSize, long maxGap, byte[] scratch, int stride) + { + int reads = 0; + long bytes = 0; + var order = new int[addresses.Count]; + for (int i = 0; i < order.Length; i++) order[i] = i; + Array.Sort(order, (a, b) => addresses[a].CompareTo(addresses[b])); + + int k = 0; + while (k < order.Length) + { + long blockStart = addresses[order[k]]; + long blockEnd = blockStart + structSize; + int j = k + 1; + while (j < order.Length) + { + long next = addresses[order[j]]; + if (next - blockEnd > maxGap) break; + if (next + structSize > blockEnd) blockEnd = next + structSize; + j++; + } + + long span = blockEnd - blockStart; + // 单次读取上限(避免一个稀疏区域把整个地址空间拉进来) + if (span > 1L * 1024 * 1024) + { + j = k + 1; + blockEnd = blockStart + structSize; + span = structSize; + } + + int readSize = (int)span; + if (scratch != null && readSize <= scratch.Length && span > 0) + { + Rpm(hProcess, (IntPtr)blockStart, scratch, readSize); + bytes += readSize; + reads++; + } + + k = j; + } + + return (reads, bytes, addresses.Count); + } + + // ---------------------------------------------------------------- 候选 C:整区映射 + + [DllImport("ntdll.dll")] + private static extern int NtMapViewOfSection(IntPtr SectionHandle, IntPtr ProcessHandle, ref IntPtr BaseAddress, + IntPtr ZeroBits, IntPtr CommitSize, IntPtr SectionOffset, ref IntPtr ViewSize, int InheritDisposition, uint AllocationType, uint Win32Protect); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr OpenProcess(uint dwDesiredAccess, bool bInheritHandle, int dwProcessId); + + [DllImport("ntdll.dll")] + private static extern int NtCreateSection(out IntPtr SectionHandle, uint DesiredAccess, IntPtr ObjectAttributes, + ref long MaximumSize, uint SectionPageProtection, uint AllocationAttributes, IntPtr FileHandle); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr hObject); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool UnmapViewOfFile(IntPtr lpBaseAddress); + + /// + /// 用 NtCreateSection(SEC_COMMIT) + NtMapViewOfSection 把目标进程的一段提交内存直接映射进本进程 + /// 地址空间,之后轮询就是本地读(零 syscall)。失败返回 IntPtr.Zero 并给出 NTSTATUS。 + /// + public static IntPtr TryMapRegion(IntPtr hProcess, long baseAddress, long size, out IntPtr sectionHandle, out long mappedSize, out int createStatus, out int mapStatus) + { + sectionHandle = IntPtr.Zero; + mappedSize = 0; + createStatus = 0; + mapStatus = 0; + + long maxSize = size; + createStatus = NtCreateSection(out IntPtr section, 0x0004 /*SECTION_MAP_READ*/, IntPtr.Zero, + ref maxSize, 0x02 /*PAGE_READONLY*/, 0x08000000 /*SEC_COMMIT*/, IntPtr.Zero); + if (createStatus < 0) return IntPtr.Zero; + + IntPtr viewBase = IntPtr.Zero; + IntPtr viewSize = IntPtr.Zero; + mapStatus = NtMapViewOfSection(section, (IntPtr)(-1) /*NtCurrentProcess*/, ref viewBase, IntPtr.Zero, IntPtr.Zero, + IntPtr.Zero, ref viewSize, 1 /*ViewUnmap*/, 0, 0x02 /*PAGE_READONLY*/); + if (mapStatus < 0) + { + CloseHandle(section); + return IntPtr.Zero; + } + + sectionHandle = section; + mappedSize = viewSize.ToInt64(); + return viewBase; + } + + public static void UnmapRegion(IntPtr viewBase, IntPtr sectionHandle) + { + if (viewBase != IntPtr.Zero) UnmapViewOfFile(viewBase); + if (sectionHandle != IntPtr.Zero) CloseHandle(sectionHandle); + } + + // ---------------------------------------------------------------- 区域枚举 + + public static List Regions(IntPtr hProcess) + { + var list = new List(); + IntPtr address = IntPtr.Zero; + int guard = 0; + while (guard++ < 200000) + { + if (VirtualQueryEx(hProcess, address, out MEMORY_BASIC_INFORMATION mbi, Marshal.SizeOf()) == 0) + { + break; + } + if (mbi.State == 0x1000 && mbi.Protect == 0x04 && mbi.Type == 0x20000) + { + list.Add(mbi); + } + + long next = mbi.BaseAddress.ToInt64() + mbi.RegionSize.ToInt64(); + if (next <= address.ToInt64()) break; + address = (IntPtr)next; + } + + return list; + } + + // ---------------------------------------------------------------- 进程连接 + + public static Process? FindOsu() + { + foreach (Process p in Process.GetProcessesByName("osu!")) + { + try + { + if (p.MainModule?.ModuleName == "osu!.exe") return p; + } + catch + { + // 32 位进程 / 权限问题:忽略 + } + } + + return null; + } + + public static bool IsTargetWow64(Process p) + { + try + { + return IsWow64Process(p.Handle, out bool wow64) && wow64; + } + catch + { + return false; + } + } + + /// 用已有句柄判断目标位数(句柄权限只需 QUERY_LIMITED_INFORMATION)。 + public static bool IsTargetWow64FromHandle(IntPtr hProcess) + => IsWow64Process(hProcess, out bool wow64) && wow64; + + public static int PtrSize(Process p) => IsTargetWow64(p) ? 4 : 8; + + public static long ReadPointer(byte[] buffer, int offset, int ptrSize) + => ptrSize == 4 ? BitConverter.ToUInt32(buffer, offset) : (long)BitConverter.ToUInt64(buffer, offset); + + /// 计时助手:跑 n 次取统计。 + public static (double TotalMs, double AvgUs, double P50Us, double P99Us, double MaxUs) Time(int iterations, Action body) + { + var samples = new double[iterations]; + var sw = new Stopwatch(); + for (int i = 0; i < iterations; i++) + { + sw.Restart(); + body(); + sw.Stop(); + samples[i] = sw.Elapsed.TotalMilliseconds; + } + + Array.Sort(samples); + double total = 0; + foreach (double s in samples) total += s; + return (total, total * 1000.0 / iterations, samples[iterations / 2] * 1000.0, + samples[Math.Min(iterations - 1, (int)(iterations * 0.99))] * 1000.0, samples[iterations - 1] * 1000.0); + } + + public static string Fmt(double us) => us < 1000 ? us.ToString("F1") + "us" : (us / 1000).ToString("F2") + "ms"; + + public static void Ensure(ref byte[] buffer, int size) + { + if (buffer == null || buffer.Length < size) buffer = new byte[size]; + } + + /// 查询某个地址所在的内存区域,用于判断"读不到"是因为地址没映射还是权限不足。 + public static string DescribeRegion(IntPtr hProcess, IntPtr address) + { + if (VirtualQueryEx(hProcess, address, out MEMORY_BASIC_INFORMATION mbi, Marshal.SizeOf()) == 0) + { + return "VirtualQueryEx 失败 err=" + Marshal.GetLastWin32Error(); + } + + string state = mbi.State switch { 0x1000 => "MEM_COMMIT", 0x2000 => "MEM_RESERVE", 0x10000 => "MEM_FREE", _ => "0x" + mbi.State.ToString("X") }; + string protect = mbi.Protect switch + { + 0x01 => "PAGE_NOACCESS", + 0x02 => "PAGE_READONLY", + 0x04 => "PAGE_READWRITE", + 0x10 => "PAGE_EXECUTE", + 0x20 => "PAGE_EXECUTE_READ", + 0x40 => "PAGE_EXECUTE_READWRITE", + 0x00 => "(none)", + _ => "0x" + mbi.Protect.ToString("X") + }; + string type = mbi.Type switch { 0x20000 => "MEM_PRIVATE", 0x40000 => "MEM_MAPPED", 0x1000000 => "MEM_IMAGE", _ => "0x" + mbi.Type.ToString("X") }; + + return $"{state} {protect} {type} 区域=[0x{mbi.BaseAddress.ToInt64():X8}, +{mbi.RegionSize.ToInt64()}) 查询地址偏移={address.ToInt64() - mbi.BaseAddress.ToInt64()}"; + } + + public static uint ReadUInt32(IntPtr hProcess, long address) + { + byte[] b = new byte[4]; + return Rpm(hProcess, (IntPtr)address, b, 4) ? BitConverter.ToUInt32(b, 0) : 0; + } + + /// 枚举 [from, to) 之间的所有内存区域并描述它们。 + public static List DescribeRegionsIn(IntPtr hProcess, long from, long to) + { + var result = new List(); + long address = from; + int guard = 0; + while (address < to && guard++ < 500) + { + if (VirtualQueryEx(hProcess, (IntPtr)address, out MEMORY_BASIC_INFORMATION mbi, Marshal.SizeOf()) == 0) + { + result.Add($"0x{address:X8}: VirtualQueryEx 失败 err={Marshal.GetLastWin32Error()}"); + break; + } + + string state = mbi.State switch { 0x1000 => "COMMIT", 0x2000 => "RESERVE", 0x10000 => "FREE", _ => "0x" + mbi.State.ToString("X") }; + string protect = mbi.Protect switch + { + 0x01 => "NOACCESS", + 0x02 => "READONLY", + 0x04 => "READWRITE", + 0x10 => "EXECUTE", + 0x20 => "EXECUTE_READ", + 0x40 => "EXECUTE_READWRITE", + 0x00 => "-", + _ => "0x" + mbi.Protect.ToString("X") + }; + string type = mbi.Type switch { 0x20000 => "PRIVATE", 0x40000 => "MAPPED", 0x1000000 => "IMAGE", _ => "-" }; + + long size = mbi.RegionSize.ToInt64(); + result.Add($"[0x{mbi.BaseAddress.ToInt64():X8}, +0x{size:X}) {state} {protect} {type}"); + + long next = mbi.BaseAddress.ToInt64() + size; + if (next <= address) break; + address = next; + } + return result; + } + + public static int ReadInt32(IntPtr hProcess, long address) + { + byte[] b = new byte[4]; + return Rpm(hProcess, (IntPtr)address, b, 4) ? BitConverter.ToInt32(b, 0) : int.MinValue; + } + + /// + /// 从地址开始尽量往后读,遇到不可读就停。用于判断"整块读失败"是因为数据不存在, + /// 还是因为尾部越过了已提交区域(例如对象正好压在页边界上)。 + /// + public static (int Bytes, byte[] Data) ReadAsMuchAsPossible(IntPtr hProcess, IntPtr address, int size) + { + byte[] data = new byte[size]; + int got = 0; + int step = 64; + while (got < size) + { + int want = Math.Min(step, size - got); + byte[] chunk = new byte[want]; + if (!Rpm(hProcess, address + got, chunk, want)) break; + Buffer.BlockCopy(chunk, 0, data, got, want); + got += want; + } + return (got, data); + } + + /// 指针解析宽度覆盖(诊断用:模拟旧的 IntPtr.Size 行为)。 + public static int? PointerSizeOverride; +} diff --git a/EditorReaderHarness/Program.cs b/EditorReaderHarness/Program.cs new file mode 100644 index 0000000..5b229d0 --- /dev/null +++ b/EditorReaderHarness/Program.cs @@ -0,0 +1,2351 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Editor_Reader; +using osucatch_editor_realtimeviewer; + +namespace EditorReaderHarness; + +// 与主工程 app.Default 对应的本地设置替身(harness 不引用主工程) +internal static class Defaults +{ + public static int FullRead_Interval = 20; + public static int LowFreqRead_Interval = 100; +} + +internal static class Program +{ + private static EditorReader _reader = new(); + private static Process? _osu; + + private static long Us(long a, long b) => (b - a) * 1_000_000 / Stopwatch.Frequency; + + private static void WriteLine(string s = "") => Console.WriteLine(s); + + private static void Main(string[] args) + { + Console.OutputEncoding = System.Text.Encoding.UTF8; + WriteLine("EditorReader 诊断 harness —— 只读,不会写入 osu! 进程"); + WriteLine("命令: a=附加osu并绑定editor i=阶段级耗时 f=全量路径构成 s=压力测试 m=监控(模拟生产tick)"); + WriteLine(" v=读取原语对比 g=散列聚合对比 r=失效指针缓存 d=dump x=重试修复测试 q=退出"); + WriteLine(); + + Attach(); + + // 支持 `EditorReaderHarness.exe a d f` 这种一次性执行,便于在后台任务里驱动 + if (args.Length > 0) + { + foreach (string arg in args) RunCommand(arg); + return; + } + + while (true) + { + Console.Write("\n> "); + string? line = Console.ReadLine(); + if (line == null) return; + string cmd = line.Trim().ToLowerInvariant(); + if (cmd == "q") return; + RunCommand(cmd); + } + } + + private static void RunCommand(string cmd) + { + switch (cmd) + { + case "a": Attach(); break; + case "i": ItemizedTiming(); break; + case "f": FullFetchBreakdown(); break; + case "s": StressTest(); break; + case "m": MonitorLoop(); break; + case "v": ReadPrimitiveBench(); break; + case "g": ScatterBench(); break; + case "r": PointerCacheBench(); break; + case "d": Dump(); break; + case "o": ProbeOffsets(); break; + case "p": PointerSizeRepro(); break; + case "b": BatchReadBench(); break; + case "z": FastPathBench(); break; + case "t": TickLoopSim(); break; + case "c": ListEditorCandidates(); break; + case "u": DiagnoseStuck(); break; + case "w": ReplayFetchAll(); break; + case "y": PointerSignCheck(); break; + case "j": DiagnoseSegmentRead(); break; + case "n": SwitchStress(); break; + case "q2": SnapshotConsistency(); break; + case "L": LoadWindowStress(); break; + case "T": ProbeReadThreshold(); break; + case "e": EagerVsLazyLines(); break; + case "k": ReadCostBreakdown(); break; + case "x": RetryHealTest(); break; + default: WriteLine("未知命令: " + cmd); break; + } + } + + // ------------------------------------------------------------------ 绑定 + + private static bool Attach() + { + _osu = Mem.FindOsu(); + if (_osu == null) + { + WriteLine("!! 没找到 osu!.exe"); + return false; + } + + // 用最小权限打开目标进程:osu! 常以管理员身份运行,.NET 的 Process.Handle + // 默认申请权限过高会 "拒绝访问",而 PROCESS_VM_READ 仍可读内存。 + if (_hProcess != IntPtr.Zero) + { + NativeClose(_hProcess); + _hProcess = IntPtr.Zero; + } + + _hProcess = Mem.OpenProcess(0x0010 /*VM_READ*/ | 0x1000 /*QUERY_LIMITED_INFORMATION*/, false, _osu.Id); + if (_hProcess == IntPtr.Zero) + { + WriteLine("!! OpenProcess(VM_READ) 失败,err=" + Marshal.GetLastWin32Error()); + return false; + } + + WriteLine($"osu! pid={_osu.Id} 目标位数={(Mem.IsTargetWow64FromHandle(_hProcess) ? "32 (WOW64)" : "64")} 标题={_osu.MainWindowTitle}"); + _reader.ForceHandle = _hProcess; + _reader.SetProcess(_osu); + try + { + _reader.SetEditor(); + _reader.SetHOM(); + _reader.ReadHOM(); + _reader.SetBeatmap(); + _reader.ReadBeatmap(); + _reader.SetControlPoints(); + // 顺便把物件列表指针也读出来(不读物件内容),后续 bench 才有数据 + try { _reader.SetObjects(); } catch (Exception ex) { WriteLine("SetObjects 失败(可能不在编辑器): " + ex.Message); } + WriteLine($"绑定成功: 控制点={_reader.numControlPoints} 物件={_reader.numObjects} 文件={_reader.Filename}"); + WriteLine($"路径={_reader.ContainingFolder}"); + WriteLine("(提示: 若此时不在编辑器里,请切到 osu! 编辑器后输入 a 重新绑定)"); + return true; + } + catch (Exception ex) + { + WriteLine("!! 绑定失败: " + ex.Message); + return false; + } + } + + private static IntPtr _hProcess = IntPtr.Zero; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr hObject); + + private static void NativeClose(IntPtr h) => CloseHandle(h); + + // ------------------------------------------------------------------ 1. 阶段级耗时 + + private static void ItemizedTiming() + { + int n = _reader.numObjects; + if (n <= 0) { WriteLine("没有物件数据,先绑定并确认在编辑器里"); return; } + + var (listHeader, dataArray, count, ptrSize) = _reader.GetObjectPointers(); + WriteLine($"物件数={count}, 目标指针宽度={ptrSize}, listHeader=0x{listHeader:X}, dataArray=0x{dataArray:X}"); + WriteLine("阶段取值:一次 FetchAll 中各阶段各执行 1 次;单项 = 单次调用耗时。\n"); + + const int iter = 60; + var hProcess = _hProcess; + + // 1) 列表头 + byte[] hdr = new byte[16]; + var r1 = Mem.Time(iter, () => Mem.Rpm(hProcess, listHeader, hdr, 16)); + WriteLine($"{"列表头读取(16B)",-34} 平均 {Mem.Fmt(r1.AvgUs),-9} p99 {Mem.Fmt(r1.P99Us),-9} /次 × 2 次/全量"); + + // 2) 指针数组 + byte[] ptrs = new byte[4 * count + 64]; + var r2 = Mem.Time(iter, () => Mem.Rpm(hProcess, dataArray + 8, ptrs, 4 * count)); + WriteLine($"{"指针数组读取",-34} 平均 {Mem.Fmt(r2.AvgUs),-9} p99 {Mem.Fmt(r2.P99Us),-9} ({4 * count} B) × 1 次/全量"); + + // 3) 逐物件结构体(每物件一次 336B 读取,生产实现即如此) + byte[] one = new byte[336]; + long objPtr0 = Mem.ReadPointer(ptrs, 0, ptrSize); + var r3 = Mem.Time(iter, () => Mem.Rpm(hProcess, (IntPtr)objPtr0, one, 336)); + WriteLine($"{"单个物件结构体读取(336B)",-34} 平均 {Mem.Fmt(r3.AvgUs),-9} p99 {Mem.Fmt(r3.P99Us),-9} × {count} 次/全量 → {Mem.Fmt(r3.AvgUs * count)}"); + + // 4) 全物件结构体(同一缓冲循环,模拟 ReadObjects 的读取部分) + byte[] big = new byte[336 * count]; + var r4 = Mem.Time(Math.Max(3, iter / 6), () => + { + for (int i = 0; i < count; i++) + { + Mem.Rpm(hProcess, (IntPtr)Mem.ReadPointer(ptrs, 4 * i, ptrSize), big, 336 * 0 + 336); + } + }); + WriteLine($"{"全部物件结构体(单缓冲)",-34} 平均 {Mem.Fmt(r4.AvgUs),-9} p99 {Mem.Fmt(r4.P99Us),-9} × 1 次/全量"); + + // 5) SampleFile 字符串(每次 2 次 RPM) + Mem.Ensure(ref one, 336); + // 取第一个物件,读 SampleFile 指针(偏移 84) + Mem.Rpm(hProcess, (IntPtr)objPtr0, one, 336); + long pStr = Mem.ReadPointer(one, 84, ptrSize); + if (pStr != 0) + { + byte[] lenBuf = new byte[4]; + var r5 = Mem.Time(iter, () => Mem.Rpm(hProcess, (IntPtr)(pStr + 4), lenBuf, 4)); + WriteLine($"{"字符串长度读取(4B)",-34} 平均 {Mem.Fmt(r5.AvgUs),-9} p99 {Mem.Fmt(r5.P99Us),-9} × 物件数/全量"); + } + + WriteLine(); + WriteLine($"推算: 逐物件路径 syscall ≈ {count} (结构体) + {count}×slider(控制点列表) + {count}(字符串长度) + …"); + WriteLine($" 仅结构体一项就约 {Mem.Fmt(r3.AvgUs * count)};真实全量请用 f 命令测量。"); + } + + // ------------------------------------------------------------------ 2. 全量路径构成 + + private static void FullFetchBreakdown() + { + WriteLine("正在测量(每项取多次),请保持编辑器状态不变…"); + int warmup = 3; + for (int i = 0; i < warmup; i++) _reader.FetchAll(false); + + const int iter = 25; + // 单次全量读取的 RPM 次数/字节量 + Mem.ResetCounters(); + _reader.FetchAll(false); + long rpmCalls = Mem.NumRpmCalls; + long rpmBytes = Mem.NumBytesRead; + + double fetchAllMs = 0, readObjectsMs = 0; + + for (int i = 0; i < iter; i++) + { + long a = Stopwatch.GetTimestamp(); + _reader.FetchAll(false); + long b = Stopwatch.GetTimestamp(); + fetchAllMs += Us(a, b) / 1000.0; + } + + for (int i = 0; i < iter; i++) + { + _reader.SetObjects(); + long a = Stopwatch.GetTimestamp(); + _reader.ReadObjects(false); + long b = Stopwatch.GetTimestamp(); + readObjectsMs += Us(a, b) / 1000.0; + } + + // 只读指针数组的部分 + double setObjectsMs = 0; + for (int i = 0; i < iter; i++) + { + long a = Stopwatch.GetTimestamp(); + _reader.SetObjects(); + long b = Stopwatch.GetTimestamp(); + setObjectsMs += Us(a, b) / 1000.0; + } + + _reader.FetchAll(false); + + // 差异比较:主工程用 CheckDifference 逐物件比较字符串;这里用等价的逐字段比较估算同量级成本。 + _reader.FetchAll(false); + var snapA = _reader.hitObjects.ToArray(); + var cpA = _reader.controlPoints.ToArray(); + _reader.FetchAll(false); + var snapB = _reader.hitObjects.ToArray(); + var cpB = _reader.controlPoints.ToArray(); + + double diffMs = 0; + int diffCounted = 0; + for (int i = 0; i < iter; i++) + { + long a = Stopwatch.GetTimestamp(); + diffCounted = 0; + for (int k = 0; k < snapA.Length; k++) if (!HitObjectEquals(snapA[k], snapB[k])) diffCounted++; + for (int k = 0; k < cpA.Length; k++) if (!ControlPointEquals(cpA[k], cpB[k])) diffCounted++; + long b = Stopwatch.GetTimestamp(); + diffMs += Us(a, b) / 1000.0; + } + + // 字符串行(主工程把这行缓存在 HitObjectLines,绘制时直接查表) + double linesMs = 0; + for (int i = 0; i < iter; i++) + { + long a = Stopwatch.GetTimestamp(); + for (int k = 0; k < snapA.Length; k++) snapA[k].ToString(); + long b = Stopwatch.GetTimestamp(); + linesMs += Us(a, b) / 1000.0; + } + + // 校验成本(主工程在 BeatmapInfoCollection 构造函数里跑) + double validateMs = 0; + for (int i = 0; i < iter; i++) + { + long a = Stopwatch.GetTimestamp(); + Snapshot.Validate(_reader, out _, out _); + long b = Stopwatch.GetTimestamp(); + validateMs += Us(a, b) / 1000.0; + } + + WriteLine(); + WriteLine($"物件={_reader.numObjects} 控制点={_reader.numControlPoints} 迭代={iter} 次平均"); + WriteLine($"{"reader.FetchAll(false) 总计",-34} {fetchAllMs / iter,10:F2} ms ← 真实全量读取(HOM/Beatmap/控制点/物件)"); + WriteLine($"{" reader.SetObjects",-34} {setObjectsMs / iter,10:F2} ms ← 列表头 + 指针数组(2 次 RPM)"); + WriteLine($"{" reader.ReadObjects",-34} {readObjectsMs / iter,10:F2} ms ← 逐物件读取(含 slider 子结构/字符串)"); + WriteLine($"{"逐物件 ToString() 建行",-34} {linesMs / iter,10:F2} ms ← BeatmapInfoCollection 里的 HitObjectLines"); + WriteLine($"{"逐物件字段比较(≈CheckDiff)",-34} {diffMs / iter,10:F2} ms"); + WriteLine($"{"逐物件合法性校验",-34} {validateMs / iter,10:F2} ms"); + WriteLine(); + WriteLine($"RPM syscall 统计(单次 FetchAll): {rpmCalls} 次 / {rpmBytes / 1024.0 / 1024.0:F2} MB"); + WriteLine($"物件平均读取字节: {(double)rpmBytes / Math.Max(1, _reader.numObjects):F0} B/物件"); + + // 分配量:全量读取的 GC 压力(主工程按 20ms 跑,这里是每帧的垃圾量) + int g0 = GC.CollectionCount(0); + long before = GC.GetTotalAllocatedBytes(true); + for (int i = 0; i < 20; i++) _reader.FetchAll(false); + long after = GC.GetTotalAllocatedBytes(true); + int g0After = GC.CollectionCount(0); + double kbPerFetch = (after - before) / 20.0 / 1024; + WriteLine($"每次 FetchAll 托管分配: {kbPerFetch:F1} KB 20 次触发 Gen0 GC {g0After - g0} 次"); + WriteLine($"(按 20ms 一次全量读取折算:{kbPerFetch * 50:F0} KB/s 垃圾)"); + + // 现状(旧路径)vs 修复后(惰性字符串)的单次 tick 成本 + double oldPath = 0, newPath = 0; + int n = _reader.hitObjects.Count; + for (int i = 0; i < 10; i++) + { + long a = Stopwatch.GetTimestamp(); + _reader.FetchAll(false); + var lines = _reader.hitObjects.Select((ho, idx) => (Line: ho.ToString(), ho.IsSelected, idx)).ToList(); + long b = Stopwatch.GetTimestamp(); + oldPath += Us(a, b) / 1000.0; + GC.KeepAlive(lines); + } + + for (int i = 0; i < 10; i++) + { + long a = Stopwatch.GetTimestamp(); + _reader.FetchAll(false); + var lines = BuildSelectionRows(_reader.hitObjects); + long b = Stopwatch.GetTimestamp(); + newPath += Us(a, b) / 1000.0; + GC.KeepAlive(lines); + } + + WriteLine(); + WriteLine($"单次 tick:旧路径(读取 + 逐条 ToString 建行) {oldPath / 10,8:F2} ms"); + WriteLine($"单次 tick:新路径(读取 + 只建 IsSelect 行) {newPath / 10,8:F2} ms"); + double speedup = newPath > 0 ? oldPath / newPath : 0; + WriteLine($"省下 {(oldPath - newPath) / 10:F2} ms/tick → 吞吐提升 {speedup:F2}x"); + + if (!Snapshot.TryValidateObjects(_reader, out string detail, out int rejects, out var reasons)) + { + WriteLine($"\n!! 当前快照含非法物件 {rejects} 个,主工程会抛异常走退避。明细: {detail}"); + foreach (var kv in reasons) WriteLine($" {Snapshot.ReasonName(kv.Key)}: {kv.Value}"); + } + } + + private static bool HitObjectEquals(HitObject a, HitObject b) + { + return a.StartTime == b.StartTime && a.EndTime == b.EndTime && a.Type == b.Type && a.SoundType == b.SoundType && + a.SegmentCount == b.SegmentCount && a.X == b.X && a.Y == b.Y && a.BaseX == b.BaseX && a.BaseY == b.BaseY && + a.SpatialLength == b.SpatialLength && a.CurveType == b.CurveType && a.curveLength == b.curveLength && + a.SampleVolume == b.SampleVolume && a.SampleSet == b.SampleSet && a.SampleSetAdditions == b.SampleSetAdditions && + a.CustomSampleSet == b.CustomSampleSet && a.SampleFile == b.SampleFile && a.unifiedSoundAddition == b.unifiedSoundAddition; + } + + private static bool ControlPointEquals(ControlPoint a, ControlPoint b) + { + return a.Offset == b.Offset && a.BeatLength == b.BeatLength && a.TimeSignature == b.TimeSignature && + a.SampleSet == b.SampleSet && a.CustomSamples == b.CustomSamples && a.Volume == b.Volume && + a.TimingChange == b.TimingChange && a.EffectFlags == b.EffectFlags; + } + + /// 复刻修复后的 SelectionLines 构建(只取 IsSelect,不拼字符串)。 + private static List<(bool IsSelect, int Index)> BuildSelectionRows(List hitObjects) + { + var rows = new List<(bool, int)>(hitObjects.Count); + for (int i = 0; i < hitObjects.Count; i++) rows.Add((hitObjects[i].IsSelected, i)); + return rows; + } + + // ------------------------------------------------------------------ 3. 压力测试(贴合"读取失败进退避"的痛点) + + private sealed class FailureTally + { + public long Attempts, Success; + public long FailRpm; + public long TotalRejectedObjects; + public readonly Dictionary RejectReasons = new(); + public readonly Dictionary ReasonBuckets = new(); + public string? FirstFailure; + public readonly List Ms = new(); + + public bool Bucket(string reason) + { + ReasonBuckets[reason] = ReasonBuckets.GetValueOrDefault(reason) + 1; + FirstFailure ??= reason; + return false; + } + } + + private static FailureTally RunStress(int milliseconds, int intervalMs, bool fetchFull) + { + var tally = new FailureTally(); + var sw = Stopwatch.StartNew(); + var sample = new Stopwatch(); + + while (sw.ElapsedMilliseconds < milliseconds) + { + tally.Attempts++; + sample.Restart(); + + bool ok; + try + { + _reader.FetchAll(fetchFull); + string? why = Snapshot.Validate(_reader, out int rejects, out var reasons); + ok = why == null; + if (!ok) + { + tally.Bucket(why); + foreach (var kv in reasons) tally.RejectReasons[kv.Key] = tally.RejectReasons.GetValueOrDefault(kv.Key) + kv.Value; + tally.TotalRejectedObjects += rejects; + } + } + catch (Exception ex) + { + ok = false; + tally.FailRpm++; + tally.Bucket("RPM异常: " + ex.Message.Split('\n')[0]); + } + + sample.Stop(); + tally.Ms.Add(sample.Elapsed.TotalMilliseconds); + if (ok) tally.Success++; + + Thread.Sleep(intervalMs); + } + + return tally; + } + + private static void StressTest() + { + WriteLine("压力测试:模拟生产每 tick 的全量读取(FetchAll),统计失败率与失败原因。"); + WriteLine("三个阶段共约 35 秒,请按提示手动切换编辑器状态;每阶段结束会自动打印统计。"); + WriteLine("按回车立即开始…"); + Console.ReadLine(); + + foreach ((string name, int ms) in new[] { ("安静(停在编辑器)", 10_000), ("编辑中(拖动/放置物件)", 15_000), ("播放中", 10_000) }) + { + WriteLine($"\n--- {name} ({ms / 1000}s) ---"); + var t = RunStress(ms, Defaults.FullRead_Interval, false); + PrintTally(t, ms); + } + } + + private static void PrintTally(FailureTally t, int ms) + { + double sum = 0, max = 0; + foreach (double m in t.Ms) { sum += m; max = Math.Max(max, m); } + double avg = t.Ms.Count > 0 ? sum / t.Ms.Count : 0; + t.Ms.Sort(); + double p50 = t.Ms.Count > 0 ? t.Ms[t.Ms.Count / 2] : 0; + double p99 = t.Ms.Count > 0 ? t.Ms[Math.Min(t.Ms.Count - 1, (int)(t.Ms.Count * 0.99))] : 0; + + long failures = t.Attempts - t.Success; + WriteLine($"尝试={t.Attempts} ({t.Attempts * 1000.0 / ms:F1}/s) 失败={failures} ({100.0 * failures / Math.Max(1, t.Attempts):F1}%) 自愈判定用的失败物件总数={t.TotalRejectedObjects}"); + WriteLine($"FetchAll 耗时 avg={avg:F2}ms p50={p50:F2}ms p99={p99:F2}ms max={max:F2}ms"); + if (t.FailRpm > 0) WriteLine($" RPM 抛异常: {t.FailRpm}"); + foreach (var kv in t.RejectReasons.OrderByDescending(k => k.Value)) + { + WriteLine($" 非法物件({Snapshot.ReasonName(kv.Key)}): {kv.Value} 个"); + } + foreach (var kv in t.ReasonBuckets.OrderByDescending(k => k.Value).Take(4)) + { + WriteLine($" 失败原因: {kv.Key} ×{kv.Value}"); + } + if (t.FirstFailure != null) WriteLine($" 首次失败样本: {t.FirstFailure}"); + } + + // ------------------------------------------------------------------ 4. 监控(模拟生产 tick) + + private static void MonitorLoop() + { + WriteLine($"模拟生产 tick:interval={Defaults.FullRead_Interval}ms,FetchEditor 缓存 + 全量间隔判定。"); + WriteLine("输入持续时间(秒,回车=30):"); + string? s = Console.ReadLine(); + int seconds = int.TryParse(s, out int v) && v > 0 ? v : 30; + + WriteLine("请现在手动切换编辑器状态(安静 / 拖动 / 播放 / 退出到选歌再回来),测试会自动统计…"); + + long lastFullFetch = 0, lastEditorCheck = 0; + bool editorOk = false; + int failCount = 0; + long ticks = 0, fullFetches = 0, nulls = 0, exceptions = 0, rejectObjects = 0; + var reasons = new Dictionary(); + var rejectReasons = new Dictionary(); + var sw = Stopwatch.StartNew(); + + while (sw.ElapsedMilliseconds < seconds * 1000L) + { + ticks++; + try + { + if (!editorOk || sw.ElapsedMilliseconds - lastEditorCheck > Defaults.FullRead_Interval) + { + lastEditorCheck = sw.ElapsedMilliseconds; + editorOk = _reader.EditorNeedsReload() == false; + if (!editorOk) + { + try { _reader.SetEditor(); editorOk = true; } catch { } + } + } + + if (!editorOk) { Thread.Sleep(Defaults.FullRead_Interval); continue; } + + bool due = sw.ElapsedMilliseconds - lastFullFetch >= Defaults.FullRead_Interval; + if (due) + { + fullFetches++; + lastFullFetch = sw.ElapsedMilliseconds; + try + { + _reader.FetchAll(false); + string? why = Snapshot.Validate(_reader, out int rejects, out var rs); + if (why == null) + { + failCount = 0; + } + else + { + failCount++; + rejectObjects += rejects; + reasons[why] = reasons.GetValueOrDefault(why) + 1; + foreach (var kv in rs) rejectReasons[kv.Key] = rejectReasons.GetValueOrDefault(kv.Key) + kv.Value; + } + } + catch (Exception ex) + { + exceptions++; + failCount++; + reasons["RPM异常: " + ex.Message.Split('\n')[0]] = reasons.GetValueOrDefault("RPM异常: " + ex.Message.Split('\n')[0]) + 1; + } + } + else + { + _reader.EditorTime(); + } + } + catch (Exception ex) + { + nulls++; + reasons["外层异常: " + ex.GetType().Name] = reasons.GetValueOrDefault("外层异常: " + ex.GetType().Name) + 1; + } + + Thread.Sleep(Defaults.FullRead_Interval); + } + + WriteLine($"\ntick={ticks} 全量读取={fullFetches} 异常={exceptions} 其它错误={nulls} 失败物件总数={rejectObjects}"); + if (reasons.Count > 0) + { + WriteLine("失败原因分布:"); + foreach (var kv in reasons.OrderByDescending(k => k.Value).Take(12)) WriteLine($" {kv.Value,6} {kv.Key}"); + } + else + { + WriteLine("没有出现读取失败。"); + } + if (rejectReasons.Count > 0) + { + WriteLine("非法物件原因分布:"); + foreach (var kv in rejectReasons.OrderByDescending(k => k.Value)) WriteLine($" {kv.Value,6} {Snapshot.ReasonName(kv.Key)}"); + } + } + + private static bool ValidateSnapshot(out string why) + { + why = Snapshot.Validate(_reader, out _, out _) ?? ""; + return why.Length == 0; + } + + // ------------------------------------------------------------------ 5. 读取原语对比 + + private static void ReadPrimitiveBench() + { + if (_osu == null) return; + var h = _hProcess; + var (_, dataArray, count, ptrSize) = _reader.GetObjectPointers(); + if (count <= 0) { WriteLine("先绑定 editor"); return; } + + byte[] ptrs = new byte[4 * count + 64]; + Mem.Rpm(h, dataArray + 8, ptrs, 4 * count); + long target = Mem.ReadPointer(ptrs, 0, ptrSize); + + WriteLine($"目标地址 = 0x{target:X}(第一个物件)\n"); + WriteLine($"{"方法",-22}{"大小",-10}{"平均",-11}{"p99",-11}{"失败",-8}"); + foreach (int size in new[] { 4, 16, 336, 4096, 65536, 1048576 }) + { + byte[] buf = new byte[size]; + int iter = size <= 4096 ? 20000 : 400; + long failRpm = 0, failNt = 0; + + var a = Mem.Time(iter, () => { if (!Mem.Rpm(h, (IntPtr)target, buf, size)) failRpm++; }); + var b = Mem.Time(iter, () => { if (!Mem.NtRpm(h, (IntPtr)target, buf, size)) failNt++; }); + + WriteLine($"{"ReadProcessMemory",-22}{size + "B",-10}{Mem.Fmt(a.AvgUs),-11}{Mem.Fmt(a.P99Us),-11}{failRpm,-8}"); + WriteLine($"{"NtReadVirtualMemory",-22}{size + "B",-10}{Mem.Fmt(b.AvgUs),-11}{Mem.Fmt(b.P99Us),-11}{failNt,-8}"); + WriteLine(); + } + } + + // ------------------------------------------------------------------ 6. 散列聚合对比 + + private static void ScatterBench() + { + if (_osu == null) return; + var h = _hProcess; + var (_, dataArray, count, ptrSize) = _reader.GetObjectPointers(); + if (count <= 0) { WriteLine("先绑定 editor"); return; } + + byte[] ptrs = new byte[4 * count + 64]; + Mem.Rpm(h, dataArray + 8, ptrs, 4 * count); + var addrs = new long[count]; + for (int i = 0; i < count; i++) addrs[i] = Mem.ReadPointer(ptrs, 4 * i, ptrSize); + + // 地址连续性统计 + var sorted = (long[])addrs.Clone(); + Array.Sort(sorted); + long[] gaps = new long[sorted.Length - 1]; + for (int i = 1; i < sorted.Length; i++) gaps[i - 1] = sorted[i] - sorted[i - 1]; + Array.Sort(gaps); + WriteLine($"物件={count} 地址间隔: p25={gaps[gaps.Length / 4]} p50={gaps[gaps.Length / 2]} p90={gaps[(int)(gaps.Length * 0.9)]} max={gaps[^1]}"); + WriteLine($"物件总跨度={(sorted[^1] + 336 - sorted[0]) / 1024.0 / 1024.0:F2} MB\n"); + + byte[] buf = new byte[8 * 1024 * 1024]; + var baseline = Mem.Time(20, () => + { + for (int i = 0; i < count; i++) Mem.Rpm(h, (IntPtr)addrs[i], buf, 336); + }); + WriteLine($"{"逐物件 1×RPM/个",-26}{"reads=" + count,-16}{"bytes=" + (count * 336 / 1024) + "KB",-18}avg={Mem.Fmt(baseline.AvgUs)}"); + + foreach (long gap in new long[] { 0, 64, 512, 4096, 65536 }) + { + long reads = 0, bytes = 0; + var r = Mem.Time(10, () => + { + var res = Mem.ScatterReadStructures(h, addrs, 336, gap, buf, 336); + reads = res.Reads; + bytes = res.Bytes; + }); + WriteLine($"{"聚合 gap<=" + gap,-26}{"reads=" + reads,-16}{"bytes=" + (bytes / 1024) + "KB",-18}avg={Mem.Fmt(r.AvgUs)} ({(double)count / Math.Max(1, reads):F1} 物件/read)"); + } + + WriteLine("\n注:聚合读取需要解析同一块缓冲里的多个物件,读到的字节数可能增加(gap 越大越浪费)。"); + } + + // ------------------------------------------------------------------ 7. 失效指针缓存 + + private static void PointerCacheBench() + { + if (_osu == null) return; + var h = _hProcess; + var (_, dataArray, count, ptrSize) = _reader.GetObjectPointers(); + if (count <= 0) { WriteLine("先绑定 editor"); return; } + + byte[] a = new byte[4 * count + 64]; + byte[] b = new byte[4 * count + 64]; + + WriteLine("快速路径成本对比(判断'物件列表是否变化'需要多少 syscall/时间):\n"); + var r1 = Mem.Time(200, () => Mem.Rpm(h, dataArray + 8, a, 4 * count)); + WriteLine($"{"读整个指针数组",-34} 平均 {Mem.Fmt(r1.AvgUs),-10} ({4 * count} B, 1 次 RPM)"); + + byte[] hdr = new byte[16]; + var r2 = Mem.Time(200, () => Mem.Rpm(h, dataArray, hdr, 16)); + WriteLine($"{"只读列表头(容量+长度)",-34} 平均 {Mem.Fmt(r2.AvgUs),-10} (16 B, 1 次 RPM)"); + + // 缓存指针表:只比长度 + 抽样指针 + long cachedLen = count; + var r3 = Mem.Time(200, () => + { + Mem.Rpm(h, dataArray, hdr, 16); + int len = BitConverter.ToInt32(hdr, 12); + if (len != cachedLen) cachedLen = len; + }); + WriteLine($"{"仅长度校验(判定是否变化)",-34} 平均 {Mem.Fmt(r3.AvgUs),-10} (16 B, 1 次 RPM)"); + + // 长度 + 首尾指针校验 + var r4 = Mem.Time(200, () => + { + Mem.Rpm(h, dataArray, hdr, 16); + int len = BitConverter.ToInt32(hdr, 12); + Mem.Rpm(h, dataArray + 8, a, 4); + Mem.Rpm(h, (IntPtr)(dataArray.ToInt64() + 8 + 4L * (len - 1)), b, 4); + }); + WriteLine($"{"长度+首尾指针校验",-34} 平均 {Mem.Fmt(r4.AvgUs),-10} (24 B, 3 次 RPM)"); + } + + // ------------------------------------------------------------------ 偏移探测 + + /// + /// 定位 HOM 内部的对象列表偏移:EditorReader 里写死的 72 在使用者这份 osu! 上可能已经变了。 + /// 判据:偏移处的指针指向一个 List 头(_items 非 0、_size 合理),且首元素指针指向的对象 + /// 结构里 StartTime/X/Y 落在合理范围。 + /// + private static void ProbeOffsets() + { + if (_hProcess == IntPtr.Zero || _reader.HomAddress == IntPtr.Zero) + { + WriteLine("先执行 a 绑定 editor"); + return; + } + + IntPtr hom = _reader.HomAddress; + IntPtr pEditor = _reader.EditorAddress; + WriteLine($"pEditor=0x{pEditor:X} pHOM=0x{hom:X} pBeatmap=0x{_reader.BeatmapAddress:X} 目标指针宽度=4\n"); + + byte[] buf = new byte[512]; + Mem.Rpm(_hProcess, hom, buf, 512); + + // 编辑器对象 +112 是 Compose;HOM+72 / Compose+72 都是"列表"候选,逐个 dump 头部 + byte[] ebuf = new byte[256]; + Mem.Rpm(_hProcess, pEditor, ebuf, 256); + uint compose = BitConverter.ToUInt32(ebuf, 112); + WriteLine($"pCompose (pEditor+112) = 0x{compose:X8}"); + + byte[] hdrDump = new byte[16]; + void DumpList(string label, long addr) + { + if (addr <= 0) { WriteLine($" {label}: 空指针"); return; } + if (!Mem.Rpm(_hProcess, (IntPtr)addr, hdrDump, 16)) { WriteLine($" {label}: 0x{addr:X8} 读取失败"); return; } + WriteLine($" {label}: 0x{addr:X8} [0]=0x{BitConverter.ToUInt32(hdrDump, 0):X8} [4]=0x{BitConverter.ToUInt32(hdrDump, 4):X8} " + + $"[8]=0x{BitConverter.ToUInt32(hdrDump, 8):X8} [12]={BitConverter.ToInt32(hdrDump, 12)}"); + } + + WriteLine("\n候选列表头 dump([4]=_items 指针, [12]=_size):"); + DumpList("HOM+56 (Bookmarks)", BitConverter.ToUInt32(buf, 56)); + DumpList("HOM+72 (主工程当作 Objects)", BitConverter.ToUInt32(buf, 72)); + DumpList("HOM+80 ", BitConverter.ToUInt32(buf, 80)); + DumpList("HOM+88 ", BitConverter.ToUInt32(buf, 88)); + DumpList("HOM+100 ", BitConverter.ToUInt32(buf, 100)); + DumpList("Compose+36", BitConverter.ToUInt32(ebuf, 112) + 36); + + byte[] cbuf = new byte[256]; + if (compose > 0x10000 && Mem.Rpm(_hProcess, (IntPtr)compose, cbuf, 256)) + { + WriteLine("\nCompose 前 128 字节里像指针的值:"); + for (int off = 0; off < 128; off += 4) + { + uint v = BitConverter.ToUInt32(cbuf, off); + if (v < 0x10000 || v > 0x7FFFFFFF) continue; + WriteLine($" Compose+{off,-4} = 0x{v:X8}"); + } + DumpList("\nCompose+48 (Clipboard)", BitConverter.ToUInt32(cbuf, 48)); + DumpList("Compose+72 (Selected)", BitConverter.ToUInt32(cbuf, 72)); + DumpList("Compose+36", BitConverter.ToUInt32(cbuf, 36)); + } + + WriteLine("\nHOM 前 160 字节里像指针的值:"); + for (int off = 0; off < 160; off += 4) + { + uint v = BitConverter.ToUInt32(buf, off); + if (v < 0x10000 || v > 0x7FFFFFFF) continue; + WriteLine($" +{off,-4} = 0x{v:X8}"); + } + + WriteLine("\n逐偏移尝试解析 List(寻找 偏移=72 之外的候选):"); + byte[] hdr = new byte[16]; + byte[] probe = new byte[336]; + int bestOffset = -1; + long bestScore = -1; + + for (int off = 0; off < 200; off += 4) + { + if (!Mem.Rpm(_hProcess, hom + off, hdr, 16)) continue; + uint items = BitConverter.ToUInt32(hdr, 4); + int size = BitConverter.ToInt32(hdr, 12); + if (items < 0x10000 || items > 0x7FFFFFFF) continue; + if (size <= 0 || size > 100000) continue; + + if (!Mem.Rpm(_hProcess, (IntPtr)items + 8, hdr, 16)) continue; + uint first = BitConverter.ToUInt32(hdr, 0); + if (first < 0x10000 || first > 0x7FFFFFFF) continue; + + if (!Mem.Rpm(_hProcess, (IntPtr)first, probe, 336)) continue; + int startTime = BitConverter.ToInt32(probe, 16); + int endTime = BitConverter.ToInt32(probe, 20); + int type = BitConverter.ToInt32(probe, 24); + float x = BitConverter.ToSingle(probe, 56); + float y = BitConverter.ToSingle(probe, 60); + float baseX = BitConverter.ToSingle(probe, 140); + float baseY = BitConverter.ToSingle(probe, 144); + + bool plausible = type != 0 && (type & ~0xFF) == 0 && startTime >= -100000 && startTime < 10_000_000 + && x >= -1000 && x <= 1000 && y >= -1000 && y <= 1000 && endTime >= startTime - 100000; + long score = plausible ? 1000 - off : 0; + + WriteLine($" 偏移 {off,-3} items=0x{items:X8} size={size,-6} 首元素=0x{first:X8} " + + $"Start={startTime} End={endTime} Type={type} X={x:F1} Y={y:F1} Base=({baseX:F1},{baseY:F1}) {(plausible ? " <== 可信" : "")}"); + + if (score > bestScore) + { + bestScore = score; + bestOffset = off; + } + } + + WriteLine($"\n结论: 主工程写死的偏移是 72;本机可信候选偏移 = {(bestScore > 0 ? bestOffset.ToString() : "无")}"); + if (bestScore <= 0) + { + WriteLine("(若物件的 Start/X/Y 全为 0,可能该谱面确实没有物件,或编辑器未加载完)"); + } + } + + // ------------------------------------------------------------------ 指针宽度对照 + + /// + /// 对照实验:同样一批物件指针,按 4 字节 vs 8 字节解析,各读一遍并校验。 + /// 用于验证"主工程用 IntPtr.Size(viewer 自身位数)解析 32 位 osu! 指针"导致的读取失败。 + /// + private static void PointerSizeRepro() + { + var (listHeader, dataArray, count, ptrSize) = _reader.GetObjectPointers(); + if (count <= 0) { WriteLine("没有物件,先 a 绑定"); return; } + + byte[] ptrs = new byte[4 * count + 64]; + Mem.Rpm(_hProcess, dataArray + 8, ptrs, 4 * count); + + const int TargetPtr = 4; // 32 位 osu!:正确宽度 + const int WrongPtr = 8; // viewer 汇编成 x64 时 IntPtr.Size 的值(错误) + + foreach ((string label, int size) in new[] { ("4 字节(正确,目标位数)", TargetPtr), ("8 字节(viewer 为 x64 时 IntPtr.Size)", WrongPtr) }) + { + byte[] obj = new byte[336]; + int ok = 0, fail = 0, rejected = 0; + string firstBad = ""; + for (int i = 0; i < count; i++) + { + long addr = size == 4 ? BitConverter.ToUInt32(ptrs, 4 * i) : (long)BitConverter.ToUInt64(ptrs, 4 * i); + if (!Mem.Rpm(_hProcess, (IntPtr)addr, obj, 336)) { fail++; if (firstBad.Length == 0) firstBad = $"#{i} addr=0x{addr:X} 读取失败"; continue; } + + var ho = new HitObject + { + X = BitConverter.ToSingle(obj, 56), + Y = BitConverter.ToSingle(obj, 60), + StartTime = BitConverter.ToInt32(obj, 16), + EndTime = BitConverter.ToInt32(obj, 20), + Type = BitConverter.ToInt32(obj, 24), + SegmentCount = BitConverter.ToInt32(obj, 32), + SampleVolume = BitConverter.ToInt32(obj, 108), + SampleSet = BitConverter.ToInt32(obj, 112), + SampleSetAdditions = BitConverter.ToInt32(obj, 116), + }; + + int r = Snapshot.RejectReason(ho); + if (r >= 0) { rejected++; if (firstBad.Length == 0) firstBad = $"#{i} {Snapshot.ReasonName(r)} X={ho.X} Y={ho.Y} Type={ho.Type}"; } + else ok++; + } + + WriteLine($"{label,-34} 合法={ok,-5} 非法={rejected,-5} 读取失败={fail,-5} {(firstBad.Length > 0 ? "首个问题: " + firstBad : "")}"); + } + } + + /// + /// 候选 B 完整对照:逐物件读取 vs 按"地址跨度聚合"批量读取。 + /// 两者解析出的字段必须完全一致(会做校验),再比耗时与 syscall 数。 + /// + private static void BatchReadBench() + { + var (_, dataArray, count, _) = _reader.GetObjectPointers(); + if (count <= 0) { WriteLine("没有物件,先 a 绑定"); return; } + if (count < 3) { WriteLine($"只有 {count} 个物件,不足以对比聚合读取,换张图"); return; } + + byte[] ptrs = new byte[4 * count + 64]; + Mem.Rpm(_hProcess, dataArray + 8, ptrs, 4 * count); + var addrs = new long[count]; + for (int i = 0; i < count; i++) addrs[i] = BitConverter.ToUInt32(ptrs, 4 * i); + + const int StructSize = 336; + var parsedBaseline = new HitObject[count]; + var parsedBatch = new HitObject[count]; + byte[] single = new byte[StructSize]; + + // 基准:逐物件 1 次 RPM + int baselineReads = 0; + var tBase = Mem.Time(20, () => + { + baselineReads = 0; + for (int i = 0; i < count; i++) + { + if (Mem.Rpm(_hProcess, (IntPtr)addrs[i], single, StructSize)) baselineReads++; + parsedBaseline[i] = Parse(single); + } + }); + + WriteLine($"{"逐物件 1×RPM",-40} {count,7} reads {count * StructSize / 1024.0,9:F0} KB {Mem.Fmt(tBase.AvgUs),10}"); + WriteLine($"{" (其中成功读取)",-40} {baselineReads,7}\n"); + + foreach (int maxGap in new[] { 64, 1024, 4096, 16384, 65536 }) + { + long reads = 0, bytes = 0; + var t = Mem.Time(20, () => + { + var res = BatchRead(addrs, StructSize, maxGap, parsedBatch); + reads = res.Reads; + bytes = res.Bytes; + }); + + int mismatch = 0; + string firstMismatch = ""; + for (int i = 0; i < count; i++) + { + if (!SameObject(parsedBaseline[i], parsedBatch[i])) + { + mismatch++; + if (firstMismatch.Length == 0) + { + firstMismatch = $"#{i} base(X={parsedBaseline[i].X},T={parsedBaseline[i].StartTime}) batch(X={parsedBatch[i].X},T={parsedBatch[i].StartTime})"; + } + } + } + + WriteLine($"{"聚合读取 gap<=" + maxGap,-40} {reads,7} reads {bytes / 1024.0,9:F0} KB {Mem.Fmt(t.AvgUs),10} " + + $"读放比 {(double)bytes / (count * StructSize):F1}x 字段不一致={mismatch} {firstMismatch}"); + } + + WriteLine("\n注:'字段不一致' 应始终为 0,否则聚合方案不可用。"); + } + + private static HitObject Parse(byte[] b) => new() + { + SpatialLength = BitConverter.ToDouble(b, 8), + StartTime = BitConverter.ToInt32(b, 16), + EndTime = BitConverter.ToInt32(b, 20), + Type = BitConverter.ToInt32(b, 24), + SoundType = BitConverter.ToInt32(b, 28), + SegmentCount = BitConverter.ToInt32(b, 32), + X = BitConverter.ToSingle(b, 56), + Y = BitConverter.ToSingle(b, 60), + SampleVolume = BitConverter.ToInt32(b, 108), + SampleSet = BitConverter.ToInt32(b, 112), + SampleSetAdditions = BitConverter.ToInt32(b, 116), + BaseX = BitConverter.ToSingle(b, 140), + BaseY = BitConverter.ToSingle(b, 144), + }; + + private static bool SameObject(HitObject a, HitObject b) + => a.StartTime == b.StartTime && a.EndTime == b.EndTime && a.Type == b.Type && a.SoundType == b.SoundType + && a.SegmentCount == b.SegmentCount && a.X == b.X && a.Y == b.Y + && a.BaseX == b.BaseX && a.BaseY == b.BaseY && a.SpatialLength == b.SpatialLength; + + /// + /// 按地址跨度聚合读取:把排序后的物件地址按 maxGap 合并成若干"大块", + /// 每块一次 RPM 读进本地缓冲,再按偏移切片解析。返回实际 reads/bytes。 + /// + private static (int Reads, long Bytes) BatchRead(long[] addrs, int structSize, int maxGap, HitObject[] output) + { + int n = addrs.Length; + var order = new int[n]; + for (int i = 0; i < n; i++) order[i] = i; + Array.Sort(order, (a, b) => addrs[a].CompareTo(addrs[b])); + + if (_batchBuffer.Length < structSize) _batchBuffer = new byte[structSize]; + + int reads = 0; + long bytes = 0; + int k = 0; + while (k < n) + { + long blockStart = addrs[order[k]]; + long blockEnd = blockStart + structSize; + int j = k + 1; + while (j < n) + { + long next = addrs[order[j]]; + if (next - blockEnd > maxGap) break; + long end = next + structSize; + if (end > blockEnd) blockEnd = end; + j++; + } + + long span = blockEnd - blockStart; + if (span > 4L * 1024 * 1024) + { + // 太大就退回单物件读取,避免一次拉进巨量数据 + j = k + 1; + span = structSize; + } + + if (_batchBuffer.Length < span) _batchBuffer = new byte[span]; + if (Mem.Rpm(_hProcess, (IntPtr)blockStart, _batchBuffer, (int)span)) + { + reads++; + bytes += span; + for (int t = k; t < j; t++) + { + int off = (int)(addrs[order[t]] - blockStart); + var slice = new byte[structSize]; + Buffer.BlockCopy(_batchBuffer, off, slice, 0, structSize); + output[order[t]] = Parse(slice); + } + } + else + { + for (int t = k; t < j; t++) output[order[t]] = new HitObject(); + } + + k = j; + } + + return (reads, bytes); + } + + private static byte[] _batchBuffer = new byte[4096]; + + // ------------------------------------------------------------------ 快速路径(指针表未变则跳过全量) + + /// + /// tick 循环模拟:复刻 EditorReaderHelper.FetchWithCache 的判定链, + /// 对比"修复前(标题判断放在间隔判断之前)"与"修复后"在同样时长里各做了多少次全量读取。 + /// 只测判定链与读取成本,不涉及绘制/转换。 + /// + private static void TickLoopSim() + { + if (_reader.numObjects <= 0) { WriteLine("没有物件,先 a 绑定"); return; } + + const int TickIntervalMs = 20; // Drawing_Interval + const int TicksToSimulate = 400; + + WriteLine($"物件={_reader.numObjects} 虚拟 tick 间隔=20ms 模拟 {TicksToSimulate} 个 tick"); + WriteLine("(同一段操作里各配置下的全量读取次数)\n"); foreach (int fullInterval in new[] { 20, 100, 500, 2000 }) + foreach (bool titleCheckFirst in new[] { true, false }) + { + // 先建立一份缓存 + _reader.FetchAll(false); + object? cachedCollection = new object(); + string cachedTitle = "x.osu@0"; + long virtualNow = 0; // 虚拟时钟:只用于判定,避免被真实读取耗时掩盖结论 + long lastEditorCheck = 0; + long lastFullFetch = 0; + long ticks = 0, fullFetches = 0; + double realFetchMs = 0; + + while (ticks < TicksToSimulate) + { + ticks++; + // 每次 tick 之间至少隔一个 Drawing_Interval:虚拟时钟必须先前进再判定, + // 否则 lastFullFetch 会一直等于当前虚拟时间,间隔判断永远成立。 + virtualNow += TickIntervalMs; + + // FetchEditor:每 20ms 做一次真实校验 + if (virtualNow - lastEditorCheck >= TickIntervalMs) + { + lastEditorCheck = virtualNow; + } + + // beatmap_title 由 FetchEditor() 在每次校验时刷新、并在**全量读取成功后才**被记进 cachedTitle。 + // 同一个谱面期间标题文本不变,但它的"最新来源"总是比 cachedTitle 新 —— 用校验时间来模拟: + // 只要"校验过但还没读"的 tick 存在,两者就不等。 + string beatmapTitle = "x.osu@" + lastEditorCheck; + + bool due; + if (titleCheckFirst) + { + due = cachedCollection == null || cachedTitle != beatmapTitle; + if (!due) due = virtualNow - lastFullFetch >= fullInterval; + } + else + { + due = cachedCollection == null || virtualNow - lastFullFetch >= fullInterval; + if (!due) due = cachedTitle != beatmapTitle; + } + + if (due) + { + fullFetches++; + lastFullFetch = virtualNow; + long a = Stopwatch.GetTimestamp(); + _reader.FetchAll(false); + long b = Stopwatch.GetTimestamp(); + realFetchMs += Us(a, b) / 1000.0; + cachedTitle = beatmapTitle; // 全量读取成功后才记下标题 + } + else + { + _reader.EditorTime(); + } + } + + string label = titleCheckFirst ? "修复前" : "修复后"; + WriteLine($"读取间隔={fullInterval,5}ms {label} tick={ticks,-6} 全量读取={fullFetches,-6} 读取总耗时={realFetchMs,9:F0} ms"); + } + + WriteLine("\n结论:同一个谱面期间 beatmap_title 每次校验都被刷新,而 cachedTitle 只有全量读取成功才更新,"); + WriteLine("于是'标题判断放在间隔判断之前'会让间隔设置完全失效;修复后间隔判断先返回,标题只在间隔到时才参与。"); + WriteLine("另外注意:默认 FullRead_Interval=20ms 与 Drawing_Interval 相同时,间隔本身每个 tick 都会到期,"); + WriteLine("所以默认配置下修好这一项并不会减少读取次数 —— 它的价值在于把 LowFreqRead_Interval / 放大后的"); + WriteLine("间隔设置重新变成有效参数(修复前无论设多大都等于每 tick 全量读)。"); + + WriteLine("\n(虚拟时钟:两次 tick 固定间隔 20ms,读取间隔 50ms。同一段操作里的全量读取次数对比。)"); + + WriteLine("\n(同一段操作里各配置下的全量读取次数)\n"); + } + + /// + /// 候选 C:把"物件指针表"缓存下来,每 tick 只做少量低成本读取判断表是否变化, + /// 未变化则完全跳过全量重读,只刷新选中/时间。 + /// + private static void FastPathBench() + { + var (_, dataArray, count, _) = _reader.GetObjectPointers(); + if (count <= 0) { WriteLine("没有物件,先 a 绑定"); return; } + + byte[] hdr = new byte[16]; + byte[] ptrs = new byte[4 * count + 64]; + byte[] one = new byte[4]; + + WriteLine($"物件={count}\n"); + WriteLine("判断'物件列表是否变化'的几种成本:\n"); + + var t1 = Mem.Time(500, () => Mem.Rpm(_hProcess, dataArray, hdr, 16)); + WriteLine($"{"A. 只读列表头(容量+长度)",-36} {Mem.Fmt(t1.AvgUs),10} 1 次 RPM / 16 B"); + + int cachedLen = count; + var t2 = Mem.Time(500, () => + { + Mem.Rpm(_hProcess, dataArray, hdr, 16); + if (BitConverter.ToInt32(hdr, 12) != cachedLen) cachedLen = BitConverter.ToInt32(hdr, 12); + }); + WriteLine($"{"B. 长度比较(增删物件才会变)",-36} {Mem.Fmt(t2.AvgUs),10} 1 次 RPM / 16 B"); + + uint cachedFirst = BitConverter.ToUInt32(ptrs, 0), cachedLast = BitConverter.ToUInt32(ptrs, 4 * (count - 1)); + Mem.Rpm(_hProcess, dataArray + 8, ptrs, 4 * count); + cachedFirst = BitConverter.ToUInt32(ptrs, 0); + cachedLast = BitConverter.ToUInt32(ptrs, 4 * (count - 1)); + + var t3 = Mem.Time(500, () => + { + Mem.Rpm(_hProcess, dataArray, hdr, 16); + int len = BitConverter.ToInt32(hdr, 12); + if (len == cachedLen) + { + Mem.Rpm(_hProcess, dataArray + 8, one, 4); + if (BitConverter.ToUInt32(one, 0) == cachedFirst) + { + Mem.Rpm(_hProcess, (IntPtr)(dataArray.ToInt64() + 8 + 4L * (len - 1)), one, 4); + if (BitConverter.ToUInt32(one, 0) == cachedLast) { /* 未变化 */ } + } + } + }); + WriteLine($"{"C. 长度+首+尾指针比较",-36} {Mem.Fmt(t3.AvgUs),10} 3 次 RPM / 24 B"); + + var t4 = Mem.Time(500, () => Mem.Rpm(_hProcess, dataArray + 8, ptrs, 4 * count)); + WriteLine($"{"D. 整张指针表逐一比较",-36} {Mem.Fmt(t4.AvgUs),10} 1 次 RPM / {4 * count} B"); + + var t5 = Mem.Time(500, () => _reader.ReadObjects(false)); + WriteLine($"{"E. 全量重读物件(现状)",-36} {Mem.Fmt(t5.AvgUs),10} ~{count} 次 RPM"); + + WriteLine("\n结论参考:若 B/C 足够便宜,就能在'物件没变'的 tick 上只花 B/C 的成本;"); + WriteLine("只有真的增删/移动了物件,才需要 fall back 到 E。"); + } + + // ------------------------------------------------------------------ 字符串行的真实用途 + + /// + /// HitObjectLines(每条 ho.ToString())只有"重建谱面"时才真正被消费; + /// 绘制时用的是同一条数据的 IsSelect 标志。这里量一下"现状 vs 惰性"的差距。 + /// + private static void EagerVsLazyLines() + { + if (_reader.numObjects <= 0) { WriteLine("没有物件,先 a 绑定"); return; } + const int iter = 15; + + _reader.FetchAll(false); + var objects = _reader.hitObjects; + + // 现状:每次全量读取都建字符串 + double eager = 0; + for (int i = 0; i < iter; i++) + { + long a = Stopwatch.GetTimestamp(); + var lines = objects.Select((ho, idx) => new { Line = ho.ToString(), ho.IsSelected, idx }).ToList(); + long b = Stopwatch.GetTimestamp(); + eager += Us(a, b) / 1000.0; + GC.KeepAlive(lines); + } + + // 惰性:只用 IsSelect 标志,不建字符串 + double lazy = 0; + for (int i = 0; i < iter; i++) + { + long a = Stopwatch.GetTimestamp(); + var lines = objects.Select((ho, idx) => new { Line = (string?)null, ho.IsSelected, idx }).ToList(); + long b = Stopwatch.GetTimestamp(); + lazy += Us(a, b) / 1000.0; + GC.KeepAlive(lines); + } + + WriteLine($"物件={objects.Count} 迭代={iter}"); + WriteLine($"{"现状:每条 ToString() 建行",-34} {eager / iter,8:F2} ms"); + WriteLine($"{"惰性:只取 IsSelect 标志",-34} {lazy / iter,8:F2} ms"); + WriteLine($"{"可省",-34} {(eager - lazy) / iter,8:F2} ms / 次全量读取"); WriteLine(); + WriteLine("(这一省法只有在'字符串确实只在重建谱面时被消费'时才成立——已确认:"); + WriteLine(" DrawingHelper 只用 SelectionLines[i].IsSelect,HitObjectLines 的字符串只在"); + WriteLine(" BuildNewBeatmapWithColorString 里被读取。)"); + } + + // ------------------------------------------------------------------ 剩余成本拆解 + + /// + /// ReadObjects 里还剩什么:syscall、共享缓冲、对象分配。 + /// 用同一批指针分别测"逐物件读进复用的单缓冲"和"读进新数组(现状)"。 + /// + private static void ReadCostBreakdown() + { + var (_, dataArray, count, _) = _reader.GetObjectPointers(); + if (count <= 0) { WriteLine("没有物件,先 a 绑定"); return; } + + byte[] ptrs = new byte[4 * count + 64]; + Mem.Rpm(_hProcess, dataArray + 8, ptrs, 4 * count); + var addrs = new int[count]; + for (int i = 0; i < count; i++) addrs[i] = (int)BitConverter.ToUInt32(ptrs, 4 * i); + + const int StructSize = 336; + byte[] shared = new byte[StructSize]; + + WriteLine($"物件={count}\n"); + + var t1 = Mem.Time(15, () => + { + for (int i = 0; i < count; i++) Mem.Rpm(_hProcess, (IntPtr)addrs[i], shared, StructSize); + }); + WriteLine($"{"逐物件读进复用缓冲",-32} {Mem.Fmt(t1.AvgUs),10} {count} 次 RPM,0 次 336B 分配"); + + var t2 = Mem.Time(15, () => + { + for (int i = 0; i < count; i++) + { + byte[] fresh = new byte[StructSize]; + Mem.Rpm(_hProcess, (IntPtr)addrs[i], fresh, StructSize); + GC.KeepAlive(fresh); + } + }); + WriteLine($"{"逐物件读进新数组(现状)",-32} {Mem.Fmt(t2.AvgUs),10} {count} 次 RPM,{count} 次 336B 分配"); + + var t3 = Mem.Time(15, () => + { + for (int i = 0; i < count; i++) + { + _ = new HitObject { X = 1, Y = 1, Type = 1 }; + } + }); + WriteLine($"{"只 new HitObject(不读内存)",-32} {Mem.Fmt(t3.AvgUs),10} {count} 次对象分配"); + + var t4 = Mem.Time(15, () => _reader.ReadObjects(false)); + WriteLine($"{"reader.ReadObjects(现状全量)",-32} {Mem.Fmt(t4.AvgUs),10} RPM + 分配 + DeStack"); + + var t5 = Mem.Time(15, () => _reader.EditorTime()); + WriteLine($"{"reader.EditorTime(高频路径)",-32} {Mem.Fmt(t5.AvgUs),10} 1 次 RPM"); + } + + // ------------------------------------------------------------------ 编辑器候选枚举 + + /// + /// 枚举进程里所有"看起来像 editor 对象"的候选(签名 + IsPlausibleEditor 那套字段校验), + /// 用来诊断"从同一谱面集切难度后永久卡住":堆里会残留死副本,选错就会一直读失败。 + /// + private static void ListEditorCandidates() + { + if (_hProcess == IntPtr.Zero) { WriteLine("先 a 绑定"); return; } + + // 与主工程 ScanForEditorAddress 里的 ToByteArray("230000001400000019000000…") 完全一致: + // 50 字节,0x0C 在索引 32,0xEE 是通配符 + 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($"模式长度自检: {pattern.Length} 字节 (应为 50)"); + + // 先验证模式本身:在"已知能读到数据的编辑器地址 +160"处应该能匹配 + { + byte[] known = new byte[pattern.Length]; + if (Mem.Rpm(_hProcess, _reader.EditorAddress + 160, known, known.Length)) + { + bool match = PatternMatch(known, pattern, 0); + WriteLine($"模式自检:当前绑定编辑器 0x{_reader.EditorAddress:X} +160 处 {(match ? "匹配" : "不匹配")}"); + WriteLine(" 实际字节: " + string.Join(" ", known.Select(b => b.ToString("X2")))); + WriteLine(" 模式字节: " + string.Join(" ", pattern.Select(b => b == 0xEE ? "??" : b.ToString("X2")))); + for (int i = 0; i < pattern.Length; i++) + { + if (pattern[i] != 0xEE && pattern[i] != known[i]) + { + WriteLine($" 第 {i} 字节不符: 模式=0x{pattern[i]:X2} 实际=0x{known[i]:X2}"); + } + } + + WriteLine(); + } + else + { + WriteLine("模式自检:读取当前编辑器失败\n"); + } + } + + WriteLine("正在枚举内存区域并扫描 editor 签名(每个区域一次读取,稍等)…"); + var regions = Mem.Regions(_hProcess); + WriteLine($"已提交且可读的区域数: {regions.Count}\n"); + + var found = new List<(long Candidate, long RegionBase, long RegionSize, string Verdict)>(); + long totalRead = 0; + var sw = Stopwatch.StartNew(); + + 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; + totalRead += size; + + for (int j = 0; j + pattern.Length <= buffer.Length; j += 4) + { + // 只用模式里"确定"的字节做快速筛除(0xEE 是通配符,不能拿来筛) + if (buffer[j] != 0x23 || buffer[j + 1] != 0 || buffer[j + 2] != 0 || buffer[j + 3] != 0) continue; + if (buffer[j + 4] != 0x14 || buffer[j + 8] != 0x19 || buffer[j + 32] != 0x0C || buffer[j + 49] != 0x00) continue; + if (!PatternMatch(buffer, pattern, j)) continue; + + long candidate = r.BaseAddress.ToInt64() + j - 160; + found.Add((candidate, r.BaseAddress.ToInt64(), size, "")); + } + + if (sw.ElapsedMilliseconds > 60000) { WriteLine("扫描超时,提前结束"); break; } + } + + WriteLine($"扫描完成: {sw.ElapsedMilliseconds} ms, 读取 {totalRead / 1024.0 / 1024.0:F0} MB, 命中 {found.Count} 个候选\n"); + + byte[] probe16 = new byte[16]; + byte[] probe4 = new byte[4]; + + foreach (var (candidate, regionBase, regionSize, _) in found) + { + string verdict = DescribeEditorCandidate(candidate, probe16, probe4); + bool isCurrent = candidate == _reader.EditorAddress.ToInt64(); + WriteLine($" 0x{candidate:X8} region=0x{regionBase:X8}({regionSize / 1024}KB) {(isCurrent ? "[当前绑定] " : "")}{verdict}"); + } + + WriteLine($"\n各候选的 pEditor+28(HOM) 指向的对象列表情况:"); + foreach (var (candidate, _, _, _) in found) + { + DumpCandidateChain(candidate, probe16, probe4); + } + } + + private static bool PatternMatch(byte[] buffer, byte[] pattern, int offset) + { + for (int i = 0; i < pattern.Length; i++) + { + if (pattern[i] != 0xEE && pattern[i] != buffer[offset + i]) return false; + } + return true; + } + + /// 复刻 IsPlausibleEditor 的每一步,并把失败在哪一步说清楚。 + private static string DescribeEditorCandidate(long candidate, byte[] probe16, byte[] probe4) + { + IntPtr pE = (IntPtr)candidate; + if (!Mem.Rpm(_hProcess, pE + 160, probe16, 16)) + { + return "字段读取失败 (pE+160)"; + } + + int f0 = BitConverter.ToInt32(probe16, 0), f4 = BitConverter.ToInt32(probe16, 4), f8 = BitConverter.ToInt32(probe16, 8); + if (f0 != 35 || f4 != 20 || f8 != 25) return $"状态字段不符 ({f0},{f4},{f8})"; + + if (!Mem.Rpm(_hProcess, pE + 28, probe4, 4)) return "pE+28 读取失败"; + IntPtr pHom = (IntPtr)BitConverter.ToUInt32(probe4, 0); + if (pHom == IntPtr.Zero) return "HOM 为空"; + + if (!Mem.Rpm(_hProcess, pHom + 72, probe4, 4)) return "HOM+72 读取失败"; + IntPtr pObjectsList = (IntPtr)BitConverter.ToUInt32(probe4, 0); + if (pObjectsList == IntPtr.Zero) return "物件列表为空"; + + if (!Mem.Rpm(_hProcess, pObjectsList, probe16, 16)) return "物件列表头读取失败"; + IntPtr pObjectsArray = (IntPtr)BitConverter.ToUInt32(probe16, 4); + int count = BitConverter.ToInt32(probe16, 12); + if (pObjectsArray == IntPtr.Zero || count < 0 || count > 1000000) return $"物件表非法 (items=0x{pObjectsArray:X}, size={count})"; + + return $"通过校验 items=0x{pObjectsArray:X} count={count}"; + } + + /// 逐个候选看它整条指针链读到的是什么谱面(HOM+48 是 Beatmap)。 + private static void DumpCandidateChain(long candidate, byte[] probe16, byte[] probe4) + { + IntPtr pE = (IntPtr)candidate; + byte[] buffer = new byte[256]; + + if (!Mem.Rpm(_hProcess, pE + 28, probe4, 4)) { WriteLine($" 0x{candidate:X8}: HOM 读取失败"); return; } + IntPtr pHom = (IntPtr)BitConverter.ToUInt32(probe4, 0); + if (pHom == IntPtr.Zero) { WriteLine($" 0x{candidate:X8}: HOM 为空"); return; } + + if (!Mem.Rpm(_hProcess, pHom + 48, probe4, 4)) { WriteLine($" 0x{candidate:X8}: HOM+48 读取失败"); return; } + IntPtr pBeatmap = (IntPtr)BitConverter.ToUInt32(probe4, 0); + if (pBeatmap == IntPtr.Zero) { WriteLine($" 0x{candidate:X8}: Beatmap 为空"); return; } + + // 物件列表 + Mem.Rpm(_hProcess, pHom + 72, probe4, 4); + IntPtr pObjL = (IntPtr)BitConverter.ToUInt32(probe4, 0); + int objCount = -1; + if (pObjL != IntPtr.Zero && Mem.Rpm(_hProcess, pObjL, probe16, 16)) objCount = BitConverter.ToInt32(probe16, 12); + + // 控制点列表 + int cpCount = -1; + if (Mem.Rpm(_hProcess, pBeatmap, buffer, 192)) + { + IntPtr pCpL = (IntPtr)BitConverter.ToUInt32(buffer, 176); + if (pCpL != IntPtr.Zero && Mem.Rpm(_hProcess, pCpL, probe16, 16)) cpCount = BitConverter.ToInt32(probe16, 12); + } + + // 文件名 + string filename = "?"; + if (Mem.Rpm(_hProcess, pBeatmap, buffer, 320)) + { + filename = ReadStringSafe(BitConverter.ToUInt32(buffer, 144)); + } + + // 编辑器状态字段 + int v0 = -1, v4 = -1, v8 = -1; + if (Mem.Rpm(_hProcess, pE + 160, probe16, 16)) + { + v0 = BitConverter.ToInt32(probe16, 0); v4 = BitConverter.ToInt32(probe16, 4); v8 = BitConverter.ToInt32(probe16, 8); + } + + bool isCurrent = candidate == _reader.EditorAddress.ToInt64(); + WriteLine($" 0x{candidate:X8} {(isCurrent ? "[当前绑定]" : " ")} 物件={objCount,-6} 控制点={cpCount,-4} 状态=({v0},{v4},{v8}) 文件={filename}"); + } + + private static string ReadStringSafe(uint pString) + { + if (pString == 0) return "(null)"; + byte[] len = new byte[4]; + if (!Mem.Rpm(_hProcess, (IntPtr)(pString + 4), len, 4)) return "(读取失败)"; + int n = BitConverter.ToInt32(len, 0); + if (n <= 0 || n > 512) return "(长度异常 " + n + ")"; + byte[] buf = new byte[2 * n]; + if (!Mem.Rpm(_hProcess, (IntPtr)(pString + 8), buf, 2 * n)) return "(内容读取失败)"; + return new string(System.Text.Encoding.Unicode.GetChars(buf)); + } + + // ------------------------------------------------------------------ 卡住状态诊断 + + /// + /// 复刻主工程"读取失败 → 退避 → 重绑"的那条判定链,逐步打印每一环的结果, + /// 用来定位"切难度后永久卡住"到底卡在哪一步。 + /// + private static void DiagnoseStuck() + { + if (_hProcess == IntPtr.Zero) { WriteLine("先 a 绑定"); return; } + if (_osu == null) return; + + WriteLine($"窗口标题(当前) : {_osu.MainWindowTitle}"); + WriteLine($"缓存编辑器地址 : 0x{_reader.EditorAddress:X}"); + WriteLine($"缓存 HOM 地址 : 0x{_reader.HomAddress:X}"); + WriteLine(); + + byte[] b16 = new byte[16]; + byte[] b4 = new byte[4]; + IntPtr pE = _reader.EditorAddress; + + bool ok160 = Mem.Rpm(_hProcess, pE + 160, b16, 16); + bool ok208 = Mem.Rpm(_hProcess, pE + 208, b4, 4); + + WriteLine($"RPM(pE+160,16) : {ok160} -> ({BitConverter.ToInt32(b16, 0)}, {BitConverter.ToInt32(b16, 4)}, {BitConverter.ToInt32(b16, 8)})"); + WriteLine($" 期望 (35, 20, 25)"); + WriteLine($"RPM(pE+208,4) : {ok208} -> 字节 = {string.Join(" ", b4.Select(b => b.ToString("X2")))}"); + if (ok208) WriteLine($" BitConverter.ToBoolean(b,1) = {BitConverter.ToBoolean(b4, 1)} (true 即判定为需要重载)"); + WriteLine(); + + if (ok160 && ok208) + { + bool flag = BitConverter.ToBoolean(b4, 1); + bool fields = BitConverter.ToInt32(b16, 0) == 35 && BitConverter.ToInt32(b16, 4) == 20 && BitConverter.ToInt32(b16, 8) == 25; + WriteLine($"EditorNeedsReload 的判定: 标志位={flag} 字段匹配={fields} => {(flag || !fields ? "需要重载" : "不需要重载")}"); + } + + WriteLine(); + WriteLine("FetchAll 逐步走一遍(定位到底哪一步读不到):"); + { + bool ok; + ok = Mem.Rpm(_hProcess, pE + 28, b4, 4); + IntPtr pHomNow = ok ? (IntPtr)BitConverter.ToUInt32(b4, 0) : IntPtr.Zero; + WriteLine($" 1) pE+28 -> HOM : ok={ok} HOM=0x{pHomNow:X} (缓存里是 0x{_reader.HomAddress:X})"); + + ok = Mem.Rpm(_hProcess, pE + 112, b4, 4); + IntPtr pCompose = ok ? (IntPtr)BitConverter.ToUInt32(b4, 0) : IntPtr.Zero; + WriteLine($" 2) pE+112 -> Compose : ok={ok} Compose=0x{pCompose:X}"); + + byte[] hom = new byte[256]; + ok = Mem.Rpm(_hProcess, pHomNow, hom, 256); + WriteLine($" 3) HOM 头部 256B : ok={ok}"); + if (ok) + { + WriteLine($" HOM+48 Beatmap = 0x{BitConverter.ToUInt32(hom, 48):X} (缓存 0x{_reader.BeatmapAddress:X})"); + WriteLine($" HOM+56 Bookmarks = 0x{BitConverter.ToUInt32(hom, 56):X}"); + WriteLine($" HOM+72 Objects = 0x{BitConverter.ToUInt32(hom, 72):X}"); + WriteLine($" objectRadius={BitConverter.ToSingle(hom, 24)} stackOffset={BitConverter.ToSingle(hom, 44)}"); + } + + IntPtr pObjL = ok ? (IntPtr)BitConverter.ToUInt32(hom, 72) : IntPtr.Zero; + ok = pObjL != IntPtr.Zero && Mem.Rpm(_hProcess, pObjL, b16, 16); + int count = ok ? BitConverter.ToInt32(b16, 12) : -1; + IntPtr pArr = ok ? (IntPtr)BitConverter.ToUInt32(b16, 4) : IntPtr.Zero; + WriteLine($" 4) HOM+72 -> 物件列表 : ok={ok} list=0x{pObjL:X} items=0x{pArr:X} count={count}"); + + ok = count > 0 && Mem.Rpm(_hProcess, pArr + 8, b4, 4); + IntPtr first = ok ? (IntPtr)BitConverter.ToUInt32(b4, 0) : IntPtr.Zero; + WriteLine($" 5) 首个物件指针 : ok={ok} p0=0x{first:X}"); + + byte[] obj = new byte[336]; + ok = first != IntPtr.Zero && Mem.Rpm(_hProcess, first, obj, 336); + WriteLine($" 6) 读取首个物件 336B : ok={ok}"); + if (ok) + { + WriteLine($" Start={BitConverter.ToInt32(obj, 16)} Type={BitConverter.ToInt32(obj, 24)} X={BitConverter.ToSingle(obj, 56)} Y={BitConverter.ToSingle(obj, 60)}"); + } + } + + WriteLine(); + WriteLine("尝试读取一次全量数据(复刻 FetchAll):"); + try + { + Mem.ResetCounters(); + long a = Stopwatch.GetTimestamp(); + _reader.FetchAll(false); + long b = Stopwatch.GetTimestamp(); + WriteLine($" FetchAll 成功: 物件={_reader.numObjects} 控制点={_reader.numControlPoints} 耗时={(b - a) * 1000.0 / Stopwatch.Frequency:F2} ms"); + string? why = Snapshot.Validate(_reader, out int rejects, out var reasons); + WriteLine($" 校验: {(why == null ? "通过" : why)}"); + if (reasons.Count > 0) + { + foreach (var kv in reasons) WriteLine($" {Snapshot.ReasonName(kv.Key)}: {kv.Value}"); + } + } + catch (Exception ex) + { + WriteLine($" FetchAll 失败: {ex.Message}"); + if (_reader.DiagReadObjectFailure != null) WriteLine($" 失败位置: {_reader.DiagReadObjectFailure}"); + } + + WriteLine(); + WriteLine("pE+208 附近的字段语义探测(按 4 字节逐格打印 pE+192 .. pE+240):"); + byte[] window = new byte[48]; + if (Mem.Rpm(_hProcess, pE + 192, window, 48)) + { + for (int i = 0; i < 48; i += 4) + { + int asInt = BitConverter.ToInt32(window, i); + float asFloat = BitConverter.ToSingle(window, i); + WriteLine($" pE+{192 + i,-4} = 0x{asInt:X8} int={asInt,-12} float={asFloat}"); + } + } + } + + // ------------------------------------------------------------------ 复刻 FetchAll 读取序列 + + /// + /// 按主工程 FetchAll 的顺序把每一次 RPM 都重放一遍,精确指出第一个失败的调用。 + /// 主工程的读取散落在多个方法里,一旦抛异常就只剩一句 "ReadProcessMemory Error", + /// 这个重放是为了把失败的那一次读(地址、大小、属于哪个字段)暴露出来。 + /// + private static void ReplayFetchAll() + { + if (_hProcess == IntPtr.Zero) { WriteLine("先 a 绑定"); return; } + + byte[] b4 = new byte[4]; + byte[] b16 = new byte[16]; + long fails = 0; + int step = 0; + + void R(string what, IntPtr address, byte[] buf, int size) + { + step++; + if (Mem.Rpm(_hProcess, address, buf, size)) return; + fails++; + if (fails <= 20) + { + WriteLine($" [{step}] 失败 {what}: addr=0x{address:X} size={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); + R("pEditor+112 (Compose)", _reader.EditorAddress + 112, b4, 4); + int pCompose = BitConverter.ToInt32(b4, 0); + + byte[] hom = new byte[256]; + R("HOM 头部 80", (IntPtr)pHom, hom, 80); + int pBookmarksL = BitConverter.ToInt32(hom, 56); + int pObjectsL = BitConverter.ToInt32(hom, 72); + R("Compose 256", (IntPtr)pCompose, hom, 256); + int pClipboardL = BitConverter.ToInt32(hom, 48); + int pSelectedL = BitConverter.ToInt32(hom, 72); + + R("HOM+48 -> Beatmap", (IntPtr)(pHom + 48), b4, 4); + int pBeatmap = BitConverter.ToInt32(b4, 0); + + byte[] bm = new byte[320]; + R("Beatmap 320", (IntPtr)pBeatmap, bm, 320); + int pFolderStr = BitConverter.ToInt32(bm, 120); + int pFileStr = BitConverter.ToInt32(bm, 144); + + void ReadStringDiag(string what, int pString) + { + if (pString == 0) { WriteLine($" {what}: 空指针"); return; } + R(what + " 长度", (IntPtr)(pString + 4), b4, 4); + int n = BitConverter.ToInt32(b4, 0); + if (n < 0 || n > 100000) + { + WriteLine($" {what}: 长度异常 = {n} (字符串指针 0x{pString:X})"); + return; + } + byte[] s = new byte[2 * Math.Max(n, 1)]; + R(what + " 内容", (IntPtr)(pString + 8), s, 2 * n); + } + + ReadStringDiag("ContainingFolder", pFolderStr); + ReadStringDiag("Filename", pFileStr); + + // 控制点 + R("Beatmap+176 -> 控制点列表", (IntPtr)(pBeatmap + 176), b4, 4); + int pControlPointsL = BitConverter.ToInt32(b4, 0); + R("控制点列表头", (IntPtr)pControlPointsL, b16, 16); + int pControlPointsA = BitConverter.ToInt32(b16, 4); + int numControlPoints = BitConverter.ToInt32(b16, 12); + WriteLine($" 控制点: list=0x{pControlPointsL:X} items=0x{pControlPointsA:X} count={numControlPoints}"); + if (numControlPoints > 0 && numControlPoints < 1000000) + { + byte[] cps = new byte[4 * numControlPoints]; + R("控制点指针数组", (IntPtr)(pControlPointsA + 8), cps, 4 * numControlPoints); + byte[] cp = new byte[48]; + for (int i = 0; i < numControlPoints; i++) + { + R($"控制点[{i}] 48B", (IntPtr)BitConverter.ToInt32(cps, 4 * i), cp, 48); + } + } + + // 物件 + R("物件列表头", (IntPtr)pObjectsL, b16, 16); + int pObjectsA = BitConverter.ToInt32(b16, 4); + int numObjects = BitConverter.ToInt32(b16, 12); + WriteLine($" 物件: list=0x{pObjectsL:X} items=0x{pObjectsA:X} count={numObjects}"); + + byte[] ptrs = new byte[4 * Math.Max(numObjects, 1)]; + R("物件指针数组", (IntPtr)(pObjectsA + 8), ptrs, 4 * numObjects); + + byte[] obj = new byte[336]; + int firstBadObject = -1; + int badFieldStep = 0; + long stringFails = 0; + + for (int i = 0; i < numObjects; i++) + { + int pObj = BitConverter.ToInt32(ptrs, 4 * i); + step++; + if (!Mem.Rpm(_hProcess, (IntPtr)pObj, obj, 336)) + { + if (firstBadObject < 0) { firstBadObject = i; badFieldStep = step; } + fails++; + if (fails <= 20) WriteLine($" [{step}] 失败 物件[{i}] 336B: addr=0x{pObj:X}"); + continue; + } + + int type = BitConverter.ToInt32(obj, 24); + string sampleFile = ""; + + // SampleFile 字符串 + int pStr = BitConverter.ToInt32(obj, 84); + if (pStr != 0) + { + step++; + if (!Mem.Rpm(_hProcess, (IntPtr)(pStr + 4), b4, 4)) + { + stringFails++; + if (stringFails <= 10) WriteLine($" [{step}] 失败 物件[{i}] SampleFile 长度: str=0x{pStr:X}"); + continue; + } + int n = BitConverter.ToInt32(b4, 0); + if (n < 0 || n > 100000) + { + if (stringFails <= 10) WriteLine($" [{step}] 物件[{i}] SampleFile 长度异常 = {n}"); + stringFails++; + continue; + } + step++; + byte[] s = new byte[2 * Math.Max(n, 1)]; + if (!Mem.Rpm(_hProcess, (IntPtr)(pStr + 8), s, 2 * n)) + { + stringFails++; + if (stringFails <= 10) WriteLine($" [{step}] 失败 物件[{i}] SampleFile 内容: str=0x{pStr:X} len={n}"); + continue; + } + sampleFile = new string(System.Text.Encoding.Unicode.GetChars(s, 0, 2 * n)); + } + + if ((type & 2) > 0) // slider + { + int pPointsL = BitConverter.ToInt32(obj, 196); + int pSTL = BitConverter.ToInt32(obj, 224); + int pSSL = BitConverter.ToInt32(obj, 228); + int pSSAL = BitConverter.ToInt32(obj, 232); + + R($"物件[{i}] slider 控制点列表头", (IntPtr)pPointsL, b16, 16); + int pTempA = BitConverter.ToInt32(b16, 4); + int numTemp = BitConverter.ToInt32(b16, 12); + if (numTemp > 0 && numTemp < 1000000) + { + byte[] curve = new byte[8 * numTemp]; + R($"物件[{i}] slider 曲线点", (IntPtr)(pTempA + 8), curve, 8 * numTemp); + } + + bool unified = BitConverter.ToBoolean(obj, 286); + if (!unified) + { + foreach ((string name, int list) in new[] { ("SoundTypeList", pSTL), ("SampleSetList", pSSL), ("SampleSetAdditionsList", pSSAL) }) + { + R($"物件[{i}] {name} 列表头", (IntPtr)list, b16, 16); + int a = BitConverter.ToInt32(b16, 4); + int c = BitConverter.ToInt32(b16, 12); + if (c > 0 && c < 1000000) + { + byte[] tmp = new byte[4 * c]; + R($"物件[{i}] {name} 内容", (IntPtr)(a + 8), tmp, 4 * c); + } + } + } + } + } + + WriteLine(); + WriteLine($"重放结果: 共 {step} 次读取, 失败 {fails} 次"); + WriteLine($" 第一个读不到的物件下标 = {(firstBadObject < 0 ? "无" : firstBadObject.ToString())}(该次读取是第 {badFieldStep} 步)"); + WriteLine($" SampleFile 相关失败 = {stringFails}"); + + // 边界与内存映射状态:失败是不是"地址根本没映射"? + WriteLine(); + WriteLine($"边界诊断入口: firstBadObject={firstBadObject} numObjects={numObjects} pObjectsA=0x{pObjectsA:X}"); + if (firstBadObject > 0 && firstBadObject < numObjects) + { + WriteLine("失败边界处的指针与映射状态:"); + for (int i = Math.Max(0, firstBadObject - 2); i < Math.Min(numObjects, firstBadObject + 2); i++) + { + long addr = (uint)BitConverter.ToInt32(ptrs, 4 * i); + string state = Mem.DescribeRegion(_hProcess, (IntPtr)addr); + bool readable = Mem.Rpm(_hProcess, (IntPtr)addr, obj, 336); + WriteLine($" 物件[{i,6}] ptr=0x{addr:X8} 可读={readable,-6} {state}"); + } + + byte[] ptrs2 = new byte[4 * numObjects]; + bool ok2 = Mem.Rpm(_hProcess, (IntPtr)(pObjectsA + 8), ptrs2, 4 * numObjects); + int changed = 0, firstChanged = -1; + if (ok2) + { + for (int i = 0; i < numObjects; i++) + { + if (ptrs[i] != ptrs2[i]) + { + changed++; + if (firstChanged < 0) firstChanged = i; + } + } + } + WriteLine($"\n两次读取指针数组的差异: 变化 {changed} 个(首个变化下标 {(firstChanged < 0 ? "无" : firstChanged.ToString())})"); + WriteLine($"列表头二次读取: items=0x{Mem.ReadUInt32(_hProcess, pObjectsL + 4):X8} size={Mem.ReadInt32(_hProcess, pObjectsL + 12)}"); + + // 把边界附近的内存区域全部列出来,看两个"可读"地址之间是不是夹了不可读的洞 + WriteLine(); + WriteLine("0x7F12F000..0x80014000 之间的内存区域:"); + foreach (string line in Mem.DescribeRegionsIn(_hProcess, 0x7F12F000, 0x80014000)) + { + WriteLine(" " + line); + } + + // 单页读取测试:失败是不是因为"跨页",还是因为某段确实读不到 + WriteLine(); + WriteLine("逐地址 336B 读取测试:"); + foreach (long addr in new long[] { 0x7F12F550, 0x7F12FE00, 0x7F12FEC8, 0x7F12FF00, 0x7F12FFD0, 0x7F130000, 0x7F131000, 0x80011DDC }) + { + bool r336 = Mem.Rpm(_hProcess, (IntPtr)addr, obj, 336); + bool r1 = Mem.Rpm(_hProcess, (IntPtr)addr, b4, 4); + WriteLine($" 0x{addr:X8}: 读336B={r336,-6} 读4B={r1}"); + } + + // 关键判断:失败物件是真的没数据,还是"数据存在但读取被最后几个字节卡住"? + WriteLine(); + WriteLine("失败物件的内容可用性测试(能读多少就读多少):"); + foreach (int i in new[] { firstBadObject, firstBadObject + 1, firstBadObject + 500, numObjects - 1 }) + { + long addr = (uint)BitConverter.ToInt32(ptrs, 4 * i); + var reads = Mem.ReadAsMuchAsPossible(_hProcess, (IntPtr)addr, 336); + string desc = reads.Bytes > 0 + ? $"读到 {reads.Bytes}B: Start={BitConverter.ToInt32(reads.Data, 16)} Type={BitConverter.ToInt32(reads.Data, 24)} X={BitConverter.ToSingle(reads.Data, 56):F1} Y={BitConverter.ToSingle(reads.Data, 60):F1}" + : "一个字节都读不到"; + WriteLine($" 物件[{i,6}] ptr=0x{addr:X8} {desc}"); + } + } + } + + // ------------------------------------------------------------------ 指针符号扩展验证 + + /// + /// 直接对比"有符号读指针"与"无符号读指针":对地址 >= 0x80000000 的物件, + /// 有符号读会在 64 位宿主上符号扩展成 0xFFFFFFFF8xxxxxxx,RPM 立刻失败。 + /// + private static void PointerSignCheck() + { + var (_, dataArray, count, _) = _reader.GetObjectPointers(); + if (count <= 0) { WriteLine("没有物件,先 a 绑定"); return; } + + byte[] ptrs = new byte[4 * count + 64]; + Mem.Rpm(_hProcess, dataArray + 8, ptrs, 4 * count); + + int below = 0, above = 0; + int signedFailBelow = 0, unsignedFailBelow = 0; + int signedFailAbove = 0; + string firstAboveSample = ""; + + byte[] obj = new byte[336]; + for (int i = 0; i < count; i++) + { + uint raw = BitConverter.ToUInt32(ptrs, 4 * i); + + // 有符号读(修复前的行为) + int signedVal = BitConverter.ToInt32(ptrs, 4 * i); + IntPtr signedPtr = (IntPtr)signedVal; // x64 上会符号扩展 + // 无符号读(修复后的行为) + IntPtr unsignedPtr = (IntPtr)(long)raw; + + bool above2G = raw >= 0x80000000; + if (above2G) + { + above++; + if (signedPtr == unsignedPtr) + { + signedFailAbove++; // 出乎意料:应当不同 + } + if (firstAboveSample.Length == 0) + { + firstAboveSample = $"#{i} raw=0x{raw:X8} 有符号=0x{signedPtr.ToInt64():X16} 无符号=0x{unsignedPtr.ToInt64():X16}"; + } + } + else + { + below++; + if (!Mem.Rpm(_hProcess, signedPtr, obj, 336)) signedFailBelow++; + if (!Mem.Rpm(_hProcess, unsignedPtr, obj, 336)) unsignedFailBelow++; + } + } + + WriteLine($"物件总数 = {count}"); + WriteLine($" 地址 < 0x80000000 的物件: {below}(有符号/无符号读结果相同,失败 有符号={signedFailBelow} 无符号={unsignedFailBelow})"); + WriteLine($" 地址 >= 0x80000000 的物件: {above}"); + if (firstAboveSample.Length > 0) + { + WriteLine($" 首个高位地址样本: {firstAboveSample}"); + WriteLine($" 其中有符号与无符号相同的数量 = {signedFailAbove}(应当为 0,否则说明符号扩展没发生)"); + } + + // 对高位地址做实际读取对比 + WriteLine(); + WriteLine("对高位地址物件的实际读取对比:"); + int shown = 0; + for (int i = 0; i < count && shown < 5; i++) + { + uint raw = BitConverter.ToUInt32(ptrs, 4 * i); + if (raw < 0x80000000) continue; + shown++; + IntPtr signedPtr = (IntPtr)BitConverter.ToInt32(ptrs, 4 * i); + IntPtr unsignedPtr = (IntPtr)(long)raw; + bool a = Mem.Rpm(_hProcess, signedPtr, obj, 336); + bool b = Mem.Rpm(_hProcess, unsignedPtr, obj, 336); + WriteLine($" 物件[{i,6}] raw=0x{raw:X8} 有符号读={a,-6} 无符号读={b}"); + } + } + + // ------------------------------------------------------------------ 分段读取诊断 + + /// + /// 对当前失败的那个物件地址,手工重放"整块读 → 按页切分逐段读"的过程, + /// 看每一段到底能不能读、差多少字节。 + /// + private static void DiagnoseSegmentRead() + { + var (_, dataArray, count, _) = _reader.GetObjectPointers(); + if (count <= 0) { WriteLine("没有物件,先 a 绑定"); return; } + if (dataArray == IntPtr.Zero) { WriteLine("物件数组为空"); return; } + + try { _reader.FetchAll(false); WriteLine("FetchAll 成功,当前没有失败物件"); return; } + catch { } + + string? diag = _reader.DiagReadObjectFailure; + WriteLine($"DiagReadObjectFailure = {diag ?? "(无)"}"); + + int index = -1; + if (diag != null) + { + var m = System.Text.RegularExpressions.Regex.Match(diag, @"index=(\d+)"); + if (m.Success) index = int.Parse(m.Groups[1].Value); + } + if (index < 0) { WriteLine("无法解析失败下标"); return; } + + byte[] ptrs = new byte[4 * count + 64]; + Mem.Rpm(_hProcess, dataArray + 8, ptrs, 4 * count); + + WriteLine(); + for (int i = Math.Max(0, index - 2); i <= Math.Min(count - 1, index + 2); i++) + { + uint p = BitConverter.ToUInt32(ptrs, 4 * i); + byte[] b = new byte[336]; + bool full = Mem.Rpm(_hProcess, (IntPtr)(long)p, b, 336); + var partial = Mem.ReadAsMuchAsPossible(_hProcess, (IntPtr)(long)p, 336); + WriteLine($" 物件[{i,6}] ptr=0x{p:X8} 整块336B={full,-6} 最多可读={partial.Bytes}B"); + WriteLine($" {Mem.DescribeRegion(_hProcess, (IntPtr)(long)p)}"); + } + + uint bad = BitConverter.ToUInt32(ptrs, 4 * index); + WriteLine(); + WriteLine($"失败物件 0x{bad:X8} 的逐块可读性(每块 16B,只打印每 64B 一次或不ok的):"); + for (int off = 0; off < 336; off += 16) + { + byte[] b = new byte[16]; + bool ok = Mem.Rpm(_hProcess, (IntPtr)(long)(bad + off), b, 16); + if (!ok || off % 64 == 0) + { + WriteLine($" +{off,4}..{off + 16,4}: {(ok ? "ok" : "读取失败")} 地址=0x{bad + off:X8}"); + } + } + + WriteLine(); + WriteLine("解析所需的最大偏移 = 286(unifiedSoundAddition),即需要 287 字节"); + var p2 = Mem.ReadAsMuchAsPossible(_hProcess, (IntPtr)(long)bad, 336); + WriteLine($"该物件实际可读 {p2.Bytes} 字节 => 是否够 287: {p2.Bytes >= 287}"); + if (p2.Bytes >= 160) + { + WriteLine($" 已读到的字段: Start={BitConverter.ToInt32(p2.Data, 16)} Type={BitConverter.ToInt32(p2.Data, 24)} X={BitConverter.ToSingle(p2.Data, 56):F1} Y={BitConverter.ToSingle(p2.Data, 60):F1}"); + } + } + + // ------------------------------------------------------------------ 切难度压力测试 + + /// + /// 专门针对"切换同一谱面集的不同难度":持续全量读取并记录每次失败的下标/地址, + /// 同时打印谱面是否发生了变化,用于定位剩下那 ~1/12 的失败。 + /// + private static void SwitchStress() + { + WriteLine("切难度压力测试:每 50ms 全量读取一次,记录谱面变化与失败。"); + WriteLine("输入持续秒数(回车=90):"); + string? s = Console.ReadLine(); + int seconds = int.TryParse(s, out int v) && v > 0 ? v : 90; + WriteLine($"开始,请现在反复切换同类谱面集的不同难度({seconds} 秒)…\n"); + + int lastObjects = -1; + string lastFile = ""; + long fetches = 0, failures = 0, switches = 0, healed = 0, persistent = 0; + var failureReasons = new Dictionary(); + var sw = Stopwatch.StartNew(); + + while (sw.ElapsedMilliseconds < seconds * 1000L) + { + fetches++; + try + { + _reader.FetchAll(false); + + if (_reader.numObjects != lastObjects || _reader.Filename != lastFile) + { + switches++; + WriteLine($" [{(sw.ElapsedMilliseconds / 1000.0):F1}s] 谱面变化: 物件 {lastObjects} -> {_reader.numObjects} 文件={_reader.Filename}"); + lastObjects = _reader.numObjects; + lastFile = _reader.Filename; + } + } + catch (Exception ex) + { + failures++; + string detail = _reader.DiagReadObjectFailure ?? ex.Message; + failureReasons[detail] = failureReasons.GetValueOrDefault(detail) + 1; + WriteLine($" [{(sw.ElapsedMilliseconds / 1000.0):F1}s] !! 读取失败: {detail}"); + + // 立刻重试:区分"瞬时撕裂(重读即可)"与"真的读不到" + bool ok = false; + for (int r = 0; r < 3 && !ok; r++) + { + try { _reader.FetchAll(false); ok = true; } + catch { } + } + if (ok) healed++; else persistent++; + WriteLine($" 立即重试 => {(ok ? "成功(瞬时)" : "仍然失败(持续)")}"); + + if (!ok) + { + // 持续失败:把失败物件的映射状态、可读字节数都打出来 + WriteLine(" --- 持续失败详细诊断 ---"); + var (_, dataArray, count, _) = _reader.GetObjectPointers(); + int idx = -1; + var m = System.Text.RegularExpressions.Regex.Match(detail, @"index=(\d+)"); + if (m.Success) idx = int.Parse(m.Groups[1].Value); + + if (dataArray != IntPtr.Zero && idx >= 0 && idx < count) + { + byte[] ptrs = new byte[4 * count + 64]; + if (Mem.Rpm(_hProcess, dataArray + 8, ptrs, 4 * count)) + { + for (int i = Math.Max(0, idx - 1); i <= Math.Min(count - 1, idx + 1); i++) + { + uint p = BitConverter.ToUInt32(ptrs, 4 * i); + var partial = Mem.ReadAsMuchAsPossible(_hProcess, (IntPtr)(long)p, 336); + // 用 Type 判断这个物件需要多少字节 + int need = 148; + if (partial.Bytes >= 28) + { + int type = BitConverter.ToInt32(partial.Data, 24); + if ((type & 2) > 0) need = 287; + } + WriteLine($" 物件[{i,6}] ptr=0x{p:X8} 可读={partial.Bytes}B 需要={need}B 够用={(partial.Bytes >= need)}"); + WriteLine($" {Mem.DescribeRegion(_hProcess, (IntPtr)(long)p)}"); + } + } + } + + // 三次重试分别失败在哪 + WriteLine(" 连续 3 次重试的失败位置:"); + for (int r = 0; r < 3; r++) + { + try { _reader.FetchAll(false); WriteLine($" 第{r + 1}次: 成功"); } + catch (Exception ex2) { WriteLine($" 第{r + 1}次: {(_reader.DiagReadObjectFailure ?? ex2.Message)}"); } + } + } + } + + Thread.Sleep(50); + } + + WriteLine($"\n总计: 读取 {fetches} 次, 谱面变化 {switches} 次, 失败 {failures} 次"); + WriteLine($" 失败后立即重试即可恢复(瞬时): {healed}"); + WriteLine($" 失败后重试仍失败(持续) : {persistent}"); + if (failureReasons.Count > 0) + { + WriteLine("失败明细:"); + foreach (var kv in failureReasons.OrderByDescending(k => k.Value)) WriteLine($" ×{kv.Value} {kv.Key}"); + } + else + { + WriteLine("没有失败。"); + } + } + + // ------------------------------------------------------------------ 快照一致性(seqlock)测试 + + /// + /// 验证"边加载边读"导致的撕裂:列表头读到的 _size / _items,与随后读到的指针数组 + /// 可能不属于同一时刻(编辑器正在往里添加物件)。做法是读完指针数组后再读一次列表头, + /// 看两次头是否一致;不一致就说明这一份快照是撕裂的。 + /// + private static void SnapshotConsistency() + { + var (listHeader, _, _, _) = _reader.GetObjectPointers(); + if (listHeader == IntPtr.Zero) { WriteLine("先 a 绑定"); return; } + + WriteLine("快照一致性测试:每次读取前后各读一次列表头,统计不一致(撕裂)的比例。"); + WriteLine("输入持续秒数(回车=60):"); + string? s = Console.ReadLine(); + int seconds = int.TryParse(s, out int v) && v > 0 ? v : 60; + WriteLine($"开始,请现在反复切换难度 / 让编辑器加载谱面({seconds} 秒)…\n"); + + byte[] h1 = new byte[16]; + byte[] h2 = new byte[16]; + byte[] ptrs = new byte[4 * 200000]; + + long reads = 0, torn = 0, tornAndFailed = 0, tornAndOk = 0; + var sw = Stopwatch.StartNew(); + + while (sw.ElapsedMilliseconds < seconds * 1000L) + { + reads++; + + if (!Mem.Rpm(_hProcess, listHeader, h1, 16)) { Thread.Sleep(20); continue; } + uint items1 = BitConverter.ToUInt32(h1, 4); + int size1 = BitConverter.ToInt32(h1, 12); + if (items1 == 0 || size1 <= 0 || size1 > 200000) { Thread.Sleep(20); continue; } + + bool arrOk = Mem.Rpm(_hProcess, (IntPtr)(long)(items1 + 8), ptrs, 4 * size1); + + bool hdr2Ok = Mem.Rpm(_hProcess, listHeader, h2, 16); + uint items2 = BitConverter.ToUInt32(h2, 4); + int size2 = BitConverter.ToInt32(h2, 12); + + bool isTorn = !hdr2Ok || items1 != items2 || size1 != size2; + + if (isTorn) + { + torn++; + bool anyFail = !arrOk; + if (!anyFail) + { + byte[] one = new byte[336]; + for (int i = Math.Max(0, size1 - 3); i < size1; i++) + { + if (!Mem.Rpm(_hProcess, (IntPtr)(long)BitConverter.ToUInt32(ptrs, 4 * i), one, 336)) { anyFail = true; break; } + } + } + if (anyFail) tornAndFailed++; else tornAndOk++; + } + + Thread.Sleep(20); + } + + WriteLine($"\n总计读取 {reads} 次,其中快照撕裂 {torn} 次 ({100.0 * torn / Math.Max(1, reads):F1}%)"); + WriteLine($" 撕裂且末尾物件读不到: {tornAndFailed}"); + WriteLine($" 撕裂但恰好还能读 : {tornAndOk}"); + WriteLine("\n若撕裂比例明显高于实际失败率,说明'撕裂 → 抛异常 → 退避'是对的,"); + WriteLine("但可以通过'读前后各校验一次列表头'把撕裂的快照直接丢弃,而不是让它去撞异常。"); + } + + // ------------------------------------------------------------------ 加载窗口自动压测 + + /// + /// 只在"检测到谱面正在加载/切换"时才高强度读取,从而稳定命中撕裂窗口。 + /// 检测方式:物件数变化,或前后两次读取的数不一致。命中后立刻连续读取 200 次, + /// 记录每次失败并判断"重读一次是否恢复"。 + /// + private static void LoadWindowStress() + { + WriteLine("加载窗口自动压测:检测到谱面变化后立刻连读 200 次,专门抓撕裂窗口。"); + WriteLine("输入持续秒数(回车=120):"); + string? s = Console.ReadLine(); + int seconds = int.TryParse(s, out int v) && v > 0 ? v : 120; + WriteLine("无需你配合——你只要照常切难度就行。也可以什么都不做。\n"); + + int lastObjects = -1; + string lastFile = ""; + long loads = 0, bursts = 0, burstReads = 0, fails = 0, healed = 0, persistent = 0; + var sw = Stopwatch.StartNew(); + + while (sw.ElapsedMilliseconds < seconds * 1000L) + { + try + { + _reader.FetchAll(false); + if (_reader.numObjects != lastObjects || _reader.Filename != lastFile) + { + WriteLine($" [{(sw.ElapsedMilliseconds / 1000.0):F1}s] 谱面变化: {lastObjects} -> {_reader.numObjects} {_reader.Filename}"); + lastObjects = _reader.numObjects; + lastFile = _reader.Filename; + loads++; + + // 命中变化:立刻连打,把加载窗口里的撕裂都抓出来 + bursts++; + for (int k = 0; k < 200; k++) + { + burstReads++; + try { _reader.FetchAll(false); } + catch (Exception ex) + { + fails++; + string detail = _reader.DiagReadObjectFailure ?? ex.Message; + // 判断是否瞬时:立刻重试 + bool ok = false; + for (int r = 0; r < 3 && !ok; r++) + { + try { _reader.FetchAll(false); ok = true; } catch { } + } + if (ok) healed++; else + { + // "立即重试也失败"不等于"永久读不到":可能几次都落在同一个重分配窗口里。 + // 隔 1 秒再确认,才能区分真·持续失败。 + Thread.Sleep(1000); + bool stillBad = false; + try { _reader.FetchAll(false); } catch { stillBad = true; } + + if (stillBad) + { + persistent++; + WriteLine($" !! 真·持续失败: {detail}"); + WriteLine($" {Mem.DescribeRegion(_hProcess, _reader.EditorAddress)}"); + } + else + { + healed++; + if (fails <= 8) WriteLine($" [burst{k}] 瞬时(1 秒后恢复): {detail}"); + } + } + + if (fails <= 8 && ok) WriteLine($" [burst{k}] 瞬时: {detail}"); + } + } + } + } + catch (Exception ex) + { + WriteLine($" [{(sw.ElapsedMilliseconds / 1000.0):F1}s] 常规读取失败: {_reader.DiagReadObjectFailure ?? ex.Message}"); + } + + Thread.Sleep(200); + } + + WriteLine($"\n总计: 谱面变化 {loads} 次, 其中触发连打 {bursts} 次, 连打共读 {burstReads} 次"); + WriteLine($" 失败 {fails} 次 ({(burstReads > 0 ? 100.0 * fails / burstReads : 0):F2}%)"); + WriteLine($" 瞬时(重读即恢复): {healed}"); + WriteLine($" 持续(重读仍失败): {persistent}"); + } + + // ------------------------------------------------------------------ 读取长度阈值探测 + + /// + /// 对一个"读不到"的地址,用递增的长度反复读,找出从多少字节开始失败。 + /// 这能区分"整页不可读" / "跨页不可读" / "只有尾部几条字节不可读"。 + /// + private static void ProbeReadThreshold() + { + WriteLine("输入要探测的地址(十六进制,可带 0x 前缀):"); + string? input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input)) { WriteLine("未输入"); return; } + input = input.Trim(); + if (input.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) input = input.Substring(2); + if (input.Length > 8) input = input.Substring(input.Length - 8); // 只取低 32 位 + + if (!long.TryParse(input, System.Globalization.NumberStyles.HexNumber, null, out long addr)) + { + WriteLine("地址解析失败"); + return; + } + + IntPtr p = (IntPtr)addr; + WriteLine($"\n目标地址 = 0x{addr:X8}"); + WriteLine(Mem.DescribeRegion(_hProcess, p)); + WriteLine($"页 = 0x{addr & ~0xFFFL:X8},页内偏移 = 0x{addr & 0xFFF:X},距页尾 {0x1000 - (addr & 0xFFF)} 字节\n"); + + WriteLine("长度阈值(从哪个长度开始失败):"); + byte[] buf = new byte[4096]; + foreach (int size in new[] { 4, 16, 28, 32, 64, 128, 148, 192, 256, 272, 287, 288, 320, 336, 352, 512, 1024, 2048, 4096 }) + { + bool ok = Mem.Rpm(_hProcess, p, buf, size); + WriteLine($" {size,5} B {(ok ? "ok" : "失败")}"); + } + + WriteLine("\n逐 16 字节分段(前 400 字节):"); + for (int off = 0; off < 400; off += 16) + { + byte[] b = new byte[16]; + bool ok = Mem.Rpm(_hProcess, (IntPtr)(addr + off), b, 16); + WriteLine($" +{off,4} (0x{addr + off:X8}): {(ok ? "ok" : "失败")}"); + } + } + + // ------------------------------------------------------------------ 8. 重试修复测试 + + private static void RetryHealTest() + { + WriteLine("重试修复测试:模拟'读到非法快照后立刻重读',看能否自愈以及重读代价。"); + WriteLine("请现在手动连续编辑物件(拖动/放置/删除)约 20 秒,测完按回车继续:"); + Console.ReadLine(); + + long failures = 0, healed = 0, attempts = 0; + var sw = Stopwatch.StartNew(); + var reasons = new Dictionary(); + + while (sw.ElapsedMilliseconds < 20_000) + { + attempts++; + string why; + try + { + _reader.FetchAll(false); + if (ValidateSnapshot(out why)) { Thread.Sleep(Defaults.FullRead_Interval); continue; } + } + catch (Exception ex) + { + why = "RPM异常: " + ex.Message.Split('\n')[0]; + } + + failures++; + reasons[why] = reasons.GetValueOrDefault(why) + 1; + + int retries = 1; + for (; retries <= 3; retries++) + { + try + { + _reader.FetchAll(false); + if (ValidateSnapshot(out _)) { healed++; break; } + } + catch { } + } + + if (retries <= 3) + { + WriteLine($" 失败→第 {retries} 次重读成功 ({why})"); + } + Thread.Sleep(Defaults.FullRead_Interval); + } + + WriteLine($"\n尝试={attempts} 失败={failures} 重读后自愈={healed} ({(failures > 0 ? 100.0 * healed / failures : 0):F1}%)"); + foreach (var kv in reasons.OrderByDescending(k => k.Value).Take(10)) WriteLine($" {kv.Value,5} {kv.Key}"); + } + + // ------------------------------------------------------------------ 9. dump + + private static void Dump() + { + { + // 诊断:核对 ReadHOM 实际读到的原始字节与主工程解析出的指针 + byte[] raw = new byte[96]; + Mem.Rpm(_hProcess, _reader.HomAddress, raw, 96); + WriteLine($"pHOM=0x{_reader.HomAddress:X} 原始 +56=0x{BitConverter.ToUInt32(raw, 56):X8} +72=0x{BitConverter.ToUInt32(raw, 72):X8} +112(pCompose?)=0x{BitConverter.ToUInt32(raw, 112 < 96 ? 88 : 0):X8}"); + string hex = string.Join(" ", Enumerable.Range(0, 96 / 4).Select(i => BitConverter.ToUInt32(raw, i * 4).ToString("X8"))); + WriteLine("原始 dword[0..23]: " + hex); + + byte[] h16 = new byte[16]; + IntPtr pol = new IntPtr(BitConverter.ToUInt32(raw, 72)); + bool okList = Mem.Rpm(_hProcess, pol, h16, 16); + WriteLine($"直接解析 HOM+72 = 0x{pol:X8}: ok={okList} _items=0x{BitConverter.ToUInt32(h16, 4):X8} _size={BitConverter.ToInt32(h16, 12)}"); + + Mem.Rpm(_hProcess, _reader.EditorAddress + 112, h16, 4); + uint pCompose = BitConverter.ToUInt32(h16, 0); + Mem.Rpm(_hProcess, (IntPtr)pCompose, h16, 16); + WriteLine($"pEditor+112 = 0x{pCompose:X8}, Compose+72 = 0x{BitConverter.ToUInt32(h16, 0):X8} (需再解引用)"); + Mem.Rpm(_hProcess, (IntPtr)(h16.Length >= 16 ? BitConverter.ToUInt32(h16, 0) : 0), h16, 16); + } + + var (listHeader, dataArray, count, ptrSize) = _reader.GetObjectPointers(); + { + byte[] h16 = new byte[16]; + bool ok = Mem.Rpm(_hProcess, listHeader, h16, 16); + WriteLine($"直接读 listHeader=0x{listHeader:X} ok={ok} [4]=0x{BitConverter.ToUInt32(h16, 4):X8} [12]={BitConverter.ToInt32(h16, 12)}"); + _reader.SetObjects(); + var after = _reader.GetObjectPointers(); + WriteLine($"调用 SetObjects 后: header=0x{after.ListHeader:X} data=0x{after.DataArray:X} count={after.Count}"); + } + WriteLine($"指针宽度={ptrSize}"); + WriteLine($"物件列表: header=0x{listHeader:X} data=0x{dataArray:X} count={count}"); + WriteLine($"控制点={_reader.numControlPoints} 书签={_reader.numBookmarks} 选中={_reader.numSelected}"); + WriteLine($"文件={_reader.Filename}"); + WriteLine($"BPM 相关: SliderMultiplier={_reader.SliderMultiplier} TickRate={_reader.SliderTickRate} CS={_reader.CircleSize} AR={_reader.ApproachRate} OD={_reader.OverallDifficulty} HP={_reader.HPDrainRate}"); + try + { + WriteLine($"EditorTime={_reader.EditorTime()} objectRadius={_reader.objectRadius} stackOffset={_reader.stackOffset}"); + } + catch (Exception ex) { WriteLine("EditorTime 读取失败: " + ex.Message); } + WriteLine($"Diag 拒绝原因计数=[{string.Join(", ", _reader.DiagObjectRejectReasons)}]"); + if (_reader.DiagLastRejectSample != null) WriteLine("Diag 样本: " + _reader.DiagLastRejectSample); + WriteLine("日志计数=" + Log.Count); + } + +} diff --git a/EditorReaderHarness/Snapshot.cs b/EditorReaderHarness/Snapshot.cs new file mode 100644 index 0000000..257c663 --- /dev/null +++ b/EditorReaderHarness/Snapshot.cs @@ -0,0 +1,104 @@ +using Editor_Reader; + +namespace EditorReaderHarness; + +/// +/// 复刻主工程 BeatmapInfoCollection 的合法性校验(不引用主工程,因为那份实现依赖 WinForms/设置)。 +/// 若主工程的校验规则有变化,这里必须同步修改,否则测量结果会失真。 +/// +internal static class Snapshot +{ + public const int ReasonX = 1; + public const int ReasonY = 2; + public const int ReasonSegmentCount = 3; + public const int ReasonType = 4; + public const int ReasonSampleSet = 5; + public const int ReasonSampleSetAdditions = 6; + public const int ReasonSampleVolume = 7; + + public static string ReasonName(int r) => r switch + { + 0 => "序号越界", + 1 => "X 越界", + 2 => "Y 越界", + 3 => "SegmentCount>9000", + 4 => "Type==0", + 5 => "SampleSet>1000", + 6 => "SampleSetAdditions>1000", + 7 => "SampleVolume>1000", + _ => "未知" + }; + + /// 与主工程一致的逐物件校验,返回 -1 表示合法。 + public static int RejectReason(HitObject ho) + { + if (ho.X > 1000 || ho.X < -1000) return ReasonX; + if (ho.Y > 1000 || ho.Y < -1000) return ReasonY; + if (ho.SegmentCount > 9000) return ReasonSegmentCount; + if (ho.Type == 0) return ReasonType; + if (ho.SampleSet > 1000) return ReasonSampleSet; + if (ho.SampleSetAdditions > 1000) return ReasonSampleSetAdditions; + if (ho.SampleVolume > 1000) return ReasonSampleVolume; + return -1; + } + + /// + /// 逐物件校验。返回 false 时 给出第一个非法物件的字段值与下标。 + /// + public static bool TryValidateObjects(EditorReader reader, out string detail, out int rejectCount, out Dictionary reasons) + { + reasons = new Dictionary(); + detail = ""; + rejectCount = 0; + + if (reader.hitObjects == null) { detail = "hitObjects == null"; return false; } + + for (int i = 0; i < reader.hitObjects.Count; i++) + { + HitObject ho = reader.hitObjects[i]; + int r = RejectReason(ho); + if (r < 0) continue; + + rejectCount++; + reasons[r] = reasons.GetValueOrDefault(r) + 1; + if (detail.Length == 0) + { + detail = $"#{i} {ReasonName(r)}: X={ho.X} Y={ho.Y} BaseX={ho.BaseX} BaseY={ho.BaseY} Type={ho.Type} " + + $"Start={ho.StartTime} End={ho.EndTime} Seg={ho.SegmentCount} Vol={ho.SampleVolume} " + + $"SS={ho.SampleSet} SSA={ho.SampleSetAdditions} unified={ho.unifiedSoundAddition} curve={ho.sliderCurvePoints?.Length}"; + } + } + + return rejectCount == 0; + } + + /// + /// 复刻 BeatmapInfoCollection 构造时的整体校验,返回失败原因(null 表示通过)。 + /// + public static string? Validate(EditorReader reader, out int rejectCount, out Dictionary reasons) + { + reasons = new Dictionary(); + rejectCount = 0; + + if (reader.hitObjects == null) return "hitObjects == null"; + if (!(reader.numControlPoints > 0 && reader.controlPoints != null && reader.hitObjects != null)) + { + return $"控制点校验失败: numControlPoints={reader.numControlPoints} controlPoints={(reader.controlPoints == null ? "null" : reader.controlPoints.Count.ToString())}"; + } + if (reader.numControlPoints != reader.controlPoints.Count) + { + return $"控制点数不一致: reader={reader.numControlPoints} list={reader.controlPoints.Count}"; + } + if (reader.numObjects != reader.hitObjects.Count) + { + return $"物件数不一致: reader={reader.numObjects} list={reader.hitObjects.Count}"; + } + + if (!TryValidateObjects(reader, out string detail, out rejectCount, out reasons)) + { + return "非法物件 " + detail; + } + + return null; + } +} diff --git a/README.md b/README.md index d9e08b3..28a7f16 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ These guides explain how to add the self-contained 32-bit build to the osu-winel - Reads hit objects, timeline position and selection directly from the editor - Configurable refresh intervals with foreground/mouse-move aware updates - Template and bookmark tools, including an optional global hotkey for bookmarks +- Quick toggle bar under the menu bar: docked by default, or dragged out into a floating window for instant switches while editing +- "Freeze preview" switch: pins the preview at the current editor time while the editor keeps running (the distance helper still follows the editor) - Unified user settings and crash logs under a fixed `%LocalAppData%` path ## Requirements diff --git a/docs/osu-winello-guide.en.md b/docs/osu-winello-guide.en.md index fc8c79c..3fa7f1f 100644 --- a/docs/osu-winello-guide.en.md +++ b/docs/osu-winello-guide.en.md @@ -1,4 +1,4 @@ -# Running OsuCatch Editor Realtime Viewer (32-bit) with osu! under osu-winello (Linux / Wine) +# Running OsuCatch Editor Realtime Viewer (32-bit) with osu! under osu-winello (Linux / Wine) This guide is for users who installed osu! stable on Linux with [osu-winello](https://github.com/NelloKudo/osu-winello). It explains how to add this viewer (32-bit build) to the same Wine prefix that osu! uses, launch it together with osu!, and monitor the osu! editor in real time. @@ -135,12 +135,12 @@ The viewer's settings and logs are written under Wine's `%LocalAppData%`, which - **The log keeps showing `No Osu!.exe found`**: make sure the viewer is launched from the same prefix (use the batch file from Step 2) and that osu! is already running. - **The viewer crashes on startup with `Current version of GDI+ does not support this feature` (or a `Gdip` type-initializer exception)**: the legacy GDI+ (`gdiplus_winxp`, GDI+ 1.0) installed by osu-winello in the prefix is incompatible with .NET 8's System.Drawing (which requires GDI+ 1.1). Use the self-contained build `release-x86-self-contained.zip` (bundles the Windows 7 `GdiPlus.dll`) and make sure `GdiPlus.dll` is present next to the executable; do not set `WINEDLLOVERRIDES=gdiplus=b`. - **`No active editor found.` is shown**: first confirm you are actually in the editor (window title ending in `.osu`). After an osu! update the in-memory layout may change, so update the viewer to the latest release. -- **The viewer gets stuck at `Try fetch editor` with no further log output**: this was a memory-scan compatibility issue under Wine in older builds; the latest release fixes it (chunked reads plus a watchdog that skips regions where `ReadProcessMemory` blocks). If the new log shows `scan aborted ... blocked at region ...`, the program automatically skips that region on the next retry and keeps scanning — that is expected. +- **The viewer gets stuck at `Try fetch editor` with no further log output**: this was a memory-scan compatibility issue under Wine in older builds; the latest release fixes it (chunked reads plus a watchdog for regions where `ReadProcessMemory` blocks). If the new log shows `scan aborted ... stalled at region ...`, that region is backed off (retried later with a growing delay) and the next attempt resumes from where it stopped — that is expected, and the picture keeps updating while the re-scan runs in the background. - **The viewer exits immediately with no logs when cachy (wine-osu-cachy) is enabled**: osu-winello's cachy wine (an experimental wow64 build) is incompatible with .NET 8, so the viewer (a .NET 8 self-contained build) cannot start. Keep the default wine-osu and do **not** set `WINE_USE_CACHY="true"` (that option is only recommended for tools like Mapping Tools, which are based on .NET 6). -- **The viewer window appears but the picture does not refresh**: while the editor is not in the foreground or the mouse is idle, refresh runs on a low-frequency interval — this is by design; enter the editor and move the mouse to see real-time updates. +- **The viewer window appears but the picture does not refresh**: a beatmap is static, so the only moving thing on screen is the object you are dragging/placing — and its current position can only be read from the editor's memory, so the `Full read` interval is effectively the frame rate of that object following your mouse. While the editor is not in the foreground or the mouse is idle, full reads drop to the low-frequency interval — this is by design; enter the editor and move the mouse (for example drag an object) to see real-time updates. The status bar tells you which state the viewer is in: `Editor is not running` (test mode / song select — normal, the last known map stays on screen), `Re-binding editor...` (the editor address is being re-scanned in the background), or `Editor read failed, retrying` (reads keep failing; the viewer retries with backoff and keeps drawing the last good data). - **Performance-related settings were changed automatically after an abnormal exit**: the program detects a previous unclean shutdown and automatically disables batch rendering; you can re-enable it in the settings. - **The viewer fails to start after `osu-wine --fixprefix`**: the prefix reinstall does not affect the viewer itself (it lives under `osuconfig`, so its bundled runtime and `GdiPlus.dll` are not removed); if it still fails to start, make sure you are using the self-contained build. -- **How to report a problem**: attach the files under `logs/` — `log_YYYYMMDD.log` (the run log for that day) and `crash_*.log` (crash reports). The folder is Wine's `%LocalAppData%\OsuCatch-Editor-RealtimeViewer\logs\`, which maps to `~/.local/share/wineprefixes/osu-wineprefix/drive_c/users//AppData/Local/OsuCatch-Editor-RealtimeViewer/logs/`. +- **How to report a problem**: attach the files under `logs/` — `log_YYYYMMDD.log` (the run log for that day) and `crash_*.log` (crash reports). The folder is Wine's `%LocalAppData%\OsuCatch-Editor-RealtimeViewer\logs\`, which maps to `~/.local/share/wineprefixes/osu-wineprefix/drive_c/users//AppData/Local/OsuCatch-Editor-RealtimeViewer/logs/`. If the problem is about reading the editor (for example repeated `Periodic task error: ... FetchEditor error.` lines), please also enable **Settings → Log → Editor Reader** (and set the log level to Debug) before reproducing: without that switch the log only contains the short summary and not the reason (which read failed, which region stalled, which snapshot was rejected). ## Related links diff --git a/docs/osu-winello-guide.zh-CN.md b/docs/osu-winello-guide.zh-CN.md index 86fd3dc..e0d0f5d 100644 --- a/docs/osu-winello-guide.zh-CN.md +++ b/docs/osu-winello-guide.zh-CN.md @@ -1,4 +1,4 @@ -# 在 osu-winello(Linux / Wine)中运行 OsuCatch Editor Realtime Viewer(32 位版)并与 osu! 同时启动 +# 在 osu-winello(Linux / Wine)中运行 OsuCatch Editor Realtime Viewer(32 位版)并与 osu! 同时启动 本文面向通过 [osu-winello](https://github.com/NelloKudo/osu-winello) 在 Linux 上安装 osu! stable 的用户,介绍如何把本查看器(32 位版)放进 osu! 所在的 Wine prefix,并让它在 osu! 启动时一起启动,从而实时监控 osu! 的编辑器(editor)。 @@ -135,12 +135,12 @@ chmod +x ~/.local/bin/osu-with-viewer - **日志里反复出现 `No Osu!.exe found`**:确认查看器是在同一 prefix 下启动的(用第二步的批处理启动即可),并确认 osu! 已经打开。 - **启动查看器报 `Current version of GDI+ does not support this feature`(或 `Gdip` 类型初始化异常)后闪退**:osu-winello prefix 里的 `gdiplus_winxp`(旧版 GDI+ 1.0)与 .NET 8 的 System.Drawing(需要 GDI+ 1.1)不兼容。请使用自包含版 `release-x86-self-contained.zip`(已内置 Win7 版 `GdiPlus.dll`),并确认查看器目录里有 `GdiPlus.dll`;不要设置 `WINEDLLOVERRIDES=gdiplus=b`。 - **提示 `No active editor found.`**:先确认已进入编辑器(窗口标题以 `.osu` 结尾)。osu! 更新后内存布局可能变化,请更新查看器到最新发布版。 -- **启动后一直停在 `Try fetch editor`,没有后续日志**:这是旧版本在 Wine 下内存扫描的兼容性问题,最新版已修复(分块读取 + 超时自动跳过阻塞区域)。请更新到最新 release;新版日志里如出现 `scan aborted ... blocked at region ...`,程序会在下一次重试时自动跳过该区域继续扫描,属于正常现象。 +- **启动后一直停在 `Try fetch editor`,没有后续日志**:这是旧版本在 Wine 下内存扫描的兼容性问题,最新版已修复(分块读取 + 对阻塞区域加看门狗)。新版日志里如出现 `scan aborted ... stalled at region ...`,该区域会被退避(稍后按递增间隔重试),下一次扫描从断点继续,属于正常现象;重扫在后台进行,期间画面仍会刷新。 - **开启 cachy(wine-osu-cachy)后查看器直接退出、没有任何日志**:osu-winello 的 cachy wine(wow64 实验构建)与 .NET 8 不兼容,基于 .NET 8 的查看器无法启动。请保持默认 wine-osu,**不要**设置 `WINE_USE_CACHY="true"`(该选项只建议给 Mapping Tools 等 .NET 6 程序使用)。 -- **查看器窗口出现但画面不刷新**:编辑器不在前台或鼠标静止时刷新会按低频间隔走,这是正常设计;进入编辑器并移动鼠标即可看到实时刷新。 +- **查看器窗口出现但画面不刷新**:谱面本身是静态的,画面里唯一会动的是你正在拖动/放置的物件,而它的当前位置只能从编辑器内存读到——所以 `Full read` 间隔实际上就是"物件跟手"的帧率。编辑器不在前台或鼠标静止时,全量读取会降到低频间隔,这是正常设计;进入编辑器并移动鼠标(例如拖动物件)即可看到实时刷新。状态栏会说明当前处于哪种状态:`Editor is not running`(test mode / 选歌中,属正常,画面保留上一张图)、`Re-binding editor...`(正在后台重新查找编辑器地址)、`Editor read failed, retrying`(读取持续失败,程序按退避重试并继续绘制最后一份有效数据)。 - **上次异常退出后性能相关设置被自动调整**:程序检测到上次非正常退出时会自动关闭批量渲染,可在设置里重新打开。 - **`osu-wine --fixprefix` 之后查看器起不来**:prefix 被重装不影响查看器本体(它位于 `osuconfig` 下,自带的运行时与 `GdiPlus.dll` 也不会被清掉);若仍启动失败,确认使用的是自包含版。 -- **如何反馈问题**:请附带 `logs/` 目录下的 `log_YYYYMMDD.log`(当天运行日志)和 `crash_*.log`(崩溃报告)。该目录位于 prefix 的 `%LocalAppData%\OsuCatch-Editor-RealtimeViewer\logs\`,对应 Linux 路径 `~/.local/share/wineprefixes/osu-wineprefix/drive_c/users/<你的用户名>/AppData/Local/OsuCatch-Editor-RealtimeViewer/logs/`。 +- **如何反馈问题**:请附带 `logs/` 目录下的 `log_YYYYMMDD.log`(当天运行日志)和 `crash_*.log`(崩溃报告)。该目录位于 prefix 的 `%LocalAppData%\OsuCatch-Editor-RealtimeViewer\logs\`,对应 Linux 路径 `~/.local/share/wineprefixes/osu-wineprefix/drive_c/users/<你的用户名>/AppData/Local/OsuCatch-Editor-RealtimeViewer/logs/`。如果问题与读取编辑器有关(例如反复出现 `Periodic task error: ... FetchEditor error.`),请先在 **设置 → 日志 → Editor Reader** 打开对应开关(并把日志级别调成 Debug)再复现:不开这个开关时日志里只有摘要,没有真正的原因(哪次读取失败、哪个区域卡住、哪份快照校验不通过)。 ## 相关链接 diff --git a/osucatch-editor-realtimeviewer/Canvas.cs b/osucatch-editor-realtimeviewer/Canvas.cs index 37ae26c..8042d17 100644 --- a/osucatch-editor-realtimeviewer/Canvas.cs +++ b/osucatch-editor-realtimeviewer/Canvas.cs @@ -64,8 +64,23 @@ private sealed class LineBatch /// public static bool UseBatchRendering = false; - private readonly float Border_Height = 32; - private readonly float Border_Width = 32; + /// + /// playfield 四周额外留出的边框(世界坐标),与 里 GL.Ortho 的投影范围一致; + /// 单屏模式(ScreensContain == 1)不留边框。 + /// + private const float Border_Height = 32; + private const float Border_Width = 32; + + /// + /// 画面可视区域的世界 Y 上边缘(与 的投影范围一致)。 + /// 时间轴向上,所以上边缘对应最晚的时刻。 + /// + public static float VisibleTopY => (screensContain > 1) ? -Border_Height : 0f; + + /// + /// 画面可视区域的世界 Y 下边缘(时间轴向上,所以下边缘对应最早的时刻)。 + /// + public static float VisibleBottomY => (screensContain > 1) ? 480f * screensContain + Border_Height : 480f; public Canvas() : base() @@ -605,18 +620,26 @@ private static void GrowLineBatch(LineBatch batch, int minVertices) private static void DrawJudgementLine() { - if (screensContain > 1) - { - Vector2 rp0 = new Vector2(64, (float)(240.0 * screensContain)); - Vector2 rp1 = new Vector2(576, (float)(240.0 * screensContain)); - DrawLine(rp0, rp1, Color.White, 1f, LineType.Solid, true); - } - else - { - Vector2 rp0 = new Vector2(64, 408); - Vector2 rp1 = new Vector2(576, 408); - DrawLine(rp0, rp1, Color.White, 1f, LineType.Solid, true); - } + double baseY = (screensContain > 1) ? 240.0 * screensContain : 408; + DrawingHelper drawing = Form1.drawingHelper; + double y = JudgementLineY(baseY, drawing.FixedPreviewTime, drawing.EditorTime, drawing.CurrentTime, drawing.TimePerPixels); + + Vector2 rp0 = new Vector2(64, (float)y); + Vector2 rp1 = new Vector2(576, (float)y); + DrawLine(rp0, rp1, Color.White, 1f, LineType.Solid, true); + } + + /// + /// 判定线(当前时刻线)在画面上的 Y 坐标。 + /// 正常模式:画在判定线本身的位置(当前时刻所在高度)。 + /// 固定预览时刻:画面停在预览时刻,判定线改为按 editor 时刻在画面上的位置绘制 + /// (换算方式与绘制物件完全一致:baseY - (time - CurrentTime) / TimePerPixels), + /// 这样固定预览时也能一眼看出当前编辑位置在画面的哪里。 + /// + private static double JudgementLineY(double baseY, bool fixedPreviewTime, double editorTime, double currentTime, double timePerPixels) + { + if (!fixedPreviewTime || !(timePerPixels > 0)) return baseY; + return baseY - (editorTime - currentTime) / timePerPixels; } } } diff --git a/osucatch-editor-realtimeviewer/DrawingHelper.cs b/osucatch-editor-realtimeviewer/DrawingHelper.cs index 6306bf9..f05992f 100644 --- a/osucatch-editor-realtimeviewer/DrawingHelper.cs +++ b/osucatch-editor-realtimeviewer/DrawingHelper.cs @@ -40,9 +40,24 @@ namespace osucatch_editor_realtimeviewer public class DrawingHelper { /// - /// Editor's current time of beatmap (ms). + /// 预览时刻(ms):画布按这个时刻绘制。默认跟随编辑器当前时刻, + /// 打开“固定预览时刻”快捷开关后会被钉在某一刻不再跟随。 /// public float CurrentTime { get; set; } + + /// + /// 编辑器当前时刻(ms):始终跟随编辑器,不受“固定预览时刻”影响。 + /// 距离辅助线(光锥)等表示“当前编辑位置”的提示必须用这个时刻, + /// 否则固定预览后就看不到当前放置物件相对上个物件的可达距离了。 + /// + public float EditorTime { get; set; } + + /// + /// 是否处于“固定预览时刻”模式:为 true 时 被钉住不再跟随编辑器, + /// 判定线改画在 对应的画面位置上(见 Canvas.DrawJudgementLine)。 + /// + public bool FixedPreviewTime { get; set; } + public ControlPointInfo? ControlPointInfo { get; set; } List BarLines { get; set; } public List CatchHitObjects { get; set; } @@ -103,6 +118,7 @@ public DrawingHelper() { ScreensContain = 4; CurrentTime = 0; + EditorTime = 0; LabelType = HitObjectLabelType.None; CatchHitObjects = new List { }; NearbyHitObjects = new List { }; @@ -152,6 +168,41 @@ public void ApplyBuildResult(DrawingHelper staged) LabelType = staged.LabelType; } + /// + /// 当前时刻(deltaTime = 0)在画面上的 Y 坐标,也就是判定线的基准高度。 + /// + public double JudgeLineBaseY => (ScreensContain <= 1) ? 408 : 240.0 * ScreensContain; + + /// + /// 整页翻页后判定线与画面边缘保留的距离(占可视高度的比例)。 + /// 留余量有两个作用:翻页后判定线不贴边(看得清), + /// 并且翻页落点与触发边界之间留出足够间隔——否则落点正好压在边缘上时, + /// 浮点换算的零点几像素误差会让它在“刚好越界/刚好没越界”之间来回翻页,肉眼看到不停闪烁。 + /// + private const double PageMarginRatio = 0.06; + + /// + /// 固定预览模式下按“整页”跟随 editor:画面本身不连续滚动,但 editor 的位置始终留在画面内。 + /// editor 时刻在画面上的位置(判定线)越过画面上边缘 时, + /// 预览时刻整页向前翻,使判定线落到画面下边缘内侧; + /// 越过下边缘时反向翻页,使判定线落到上边缘内侧(内侧距离见 )。 + /// 翻页后的时刻由 editor 时刻直接反推,所以 editor 一次跳很远(拖动进度条)也只需一页即可到位。 + /// + /// 是否发生了翻页。未处于固定预览模式或时刻数据无效时不做事。 + public bool PageToKeepEditorVisible(double visibleTopY, double visibleBottomY) + { + if (!FixedPreviewTime || !(TimePerPixels > 0)) return false; + + double baseY = JudgeLineBaseY; + double lineY = baseY - (EditorTime - CurrentTime) / TimePerPixels; + 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); + return true; + } + public void Draw() { BuildNearby(); @@ -435,17 +486,21 @@ private double NextBarLineTime(double time) } /// - /// 距离辅助线(光锥):当当前时间点上有 Fruit 时, + /// 距离辅助线(光锥):当编辑器当前时刻点上有 Fruit 时, /// 从“上个物件”中心向左上/右上画两条放射线。 /// 白线默认 1x、红线默认 2x(SameWithEditor 速度倍率,可在设置中修改), /// 用于判断当前放置物件相对上个物件的可达距离。 + /// 画哪个物件由编辑器当前时刻()决定, + /// 这样固定预览时刻后辅助线仍然指示当前编辑位置的可达距离; + /// 而锚点在画面上的位置要按预览时刻()换算——画面就是按预览时刻画的, + /// 用 editor 时刻会让锚点脱离画面上那个物件,并随 editor 时刻前进一起向下漂移。 /// private void DrawDistanceHelper() { if (!app.Default.Show_Distance_Helper) return; if (CatchHitObjects == null || CatchHitObjects.Count <= 0) return; - int currentIndex = FindFruitIndexAtTime(CurrentTime); + int currentIndex = FindFruitIndexAtTime(EditorTime); if (currentIndex < 0) return; PalpableCatchHitObject? previous = ((Fruit)CatchHitObjects[currentIndex]).lastObject; @@ -458,7 +513,7 @@ private void DrawDistanceHelper() if (topY >= baseY) return; double anchorX = 64 + previous.EffectiveX; - double anchorY = baseY - (previous.StartTime - CurrentTime) / TimePerPixels; + double anchorY = DistanceHelperAnchorY(previous.StartTime, baseY); // SameWithEditor 速度换算(参考 BeatmapConverter.CalDistanceToNext): // 水平速度 = 倍率 × (SliderMultiplier × 100 × SliderVelocity) / BeatLength @@ -469,18 +524,26 @@ private void DrawDistanceHelper() DrawConeRays(anchorX, anchorY, topY, redSpeed, Color.Red); } + /// + /// 距离辅助线锚点(上个物件)在画面上的 Y 坐标。 + /// 基准时间是预览时刻 :画面上物件的位置就是按这个时刻算的, + /// 换成 editor 时刻会让锚点落到画面上另一个位置(固定预览后还会随 editor 前进而向下漂移)。 + /// + private double DistanceHelperAnchorY(double previousStartTime, double baseY) + => baseY - (previousStartTime - CurrentTime) / TimePerPixels; + /// /// 按 SameWithEditor 语义换算水平速度(px/ms): /// speed = 倍率 × (SliderMultiplier × 100 × SliderVelocity) / BeatLength。 /// 与 的 XDistToNext_SameWithEditor 一致, - /// 参数取当前播放头处的拍长与滑条速度。数据无效时返回 0(对应射线不画)。 + /// 参数取编辑器当前时刻()处的拍长与滑条速度。数据无效时返回 0(对应射线不画)。 /// private double CalcSameWithEditorSpeed(double multiplier) { if (!(multiplier > 0) || ControlPointInfo == null || !(SliderMultiplier > 0)) return 0; - TimingControlPoint timing = ControlPointInfo.TimingPointAt(CurrentTime); - DifficultyControlPoint difficulty = (ControlPointInfo as LegacyControlPointInfo)?.DifficultyPointAt(CurrentTime) ?? DifficultyControlPoint.DEFAULT; + TimingControlPoint timing = ControlPointInfo.TimingPointAt(EditorTime); + DifficultyControlPoint difficulty = (ControlPointInfo as LegacyControlPointInfo)?.DifficultyPointAt(EditorTime) ?? DifficultyControlPoint.DEFAULT; double beatLength = timing.BeatLength; double sliderVelocity = difficulty.SliderVelocity; diff --git a/osucatch-editor-realtimeviewer/EditorReader/EditorReader.cs b/osucatch-editor-realtimeviewer/EditorReader/EditorReader.cs index 85e7533..a46cc9c 100644 --- a/osucatch-editor-realtimeviewer/EditorReader/EditorReader.cs +++ b/osucatch-editor-realtimeviewer/EditorReader/EditorReader.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 @@ -29,6 +29,25 @@ public class EditorReader private Process process; + /// + /// 目标进程句柄。默认走 ; + /// 若宿主已用 OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_LIMITED_INFORMATION) 打开句柄, + /// 可写入 以避免 .NET 默认权限不足(osu! 以管理员身份运行时会出现)。 + /// + private IntPtr TargetHandle => forceHandle != IntPtr.Zero ? forceHandle : process.Handle; + + private IntPtr forceHandle; + + /// + /// 由宿主提供的目标进程句柄(例如只有 VM_READ 权限的句柄)。 + /// 设为非 0 后所有读取都使用它;宿主负责在换绑进程时替换/关闭句柄。 + /// + public IntPtr ForceHandle + { + get => forceHandle; + set => forceHandle = value; + } + private IntPtr pEditor; private IntPtr pCompose; @@ -99,6 +118,30 @@ public class EditorReader public List hitObjects; + /// + /// 目标进程的指针宽度。绝不能再用 : + /// 那是 viewer 自己的位数,而 osu! 是 32 位进程。viewer 汇编成 AnyCPU/x64 时 + /// IntPtr.Size == 8,会把每 8 字节才读一次指针,从第二个物件起全部错位 + /// ——表现为"物件读出来全是垃圾值 / 数量为 0",而不是报错。 + /// 因此 32 位目标必须按 4 字节指针读取。 + /// + private const int TargetPointerSize = 4; + + /// + /// 诊断用:最近一次 的失败分类计数。 + /// 仅用于性能/稳定性测量(EditorReaderHarness),不参与正常读取逻辑。 + /// + public readonly long[] DiagObjectRejectReasons = new long[8]; + + /// 诊断用:最近一次 读取的对象数。 + public int DiagLastInvalidObjectIndex = -1; + + /// 诊断用:最近一次 中被拒绝的对象样本(物件字符串 + 原因)。 + public string? DiagLastRejectSample; + + /// 诊断用:最近一次 抛异常时的物件下标与地址。 + public string? DiagReadObjectFailure; + private IntPtr pClipboardL; private IntPtr pClipboardA; @@ -127,21 +170,44 @@ public class EditorReader private const long MaxScanRegionSize = 256L * 1024 * 1024; /// - /// 每次 ReadProcessMemory 的最大块大小(可能再加少量重叠字节), - /// 避免在 32 位进程中一次性分配超大缓冲区。 + /// 单次内存扫描的最长时间。Wine 下 ReadProcessMemory 可能对某些区域永久阻塞, + /// 无法从超时点中断原生调用,因此把扫描放到独立线程,超时后放弃该线程并稍后重试。 /// - private const int ReadChunkSize = 8 * 1024 * 1024; + 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; + } /// - /// 单次内存扫描的最长时间。Wine 下 ReadProcessMemory 可能对某些区域永久阻塞, - /// 无法从超时点中断原生调用,因此把扫描放到独立线程,超时后放弃该线程并跳过对应区域。 + /// ReadProcessMemory 长时间不返回过的区域(Wine 上确实存在):按指数退避延后重试, + /// 而不是永久跳过。永久拉黑会造成"该区域恰好含有 editor 对象时,本进程内永远无法重新绑定", + /// 用户只能不断重启 viewer —— 这正是 issue 中"重启 3 次以上 / 永久卡住"的来源之一。 /// - private const int ScanTimeoutMs = 30000; + private readonly Dictionary penalizedRegions = new(); + private readonly object penalizedRegionsLock = new(); + + /// 上次成功找到 editor 的区域起始地址:重绑时优先扫描它(快路径)。 + private long lastFoundRegionBase; + + /// 上次扫描超时后继续扫描的起点,避免每次重试都从头开始。 + private int scanCursorIndex; /// - /// Wine 下 ReadProcessMemory 永久阻塞过的区域起始地址,重试时跳过。 + /// 仍在运行的扫描线程数(含超时被放弃、但可能还阻塞在 ReadProcessMemory 里的线程)。 + /// 超时的线程无法被中断,这里限制并发数,避免反复重试时线程无限堆积。 /// - private static readonly HashSet blockedRegionAddresses = new(); + private int liveScanThreads; + private const int MaxLiveScanThreads = 3; /// 当前扫描线程正在读取的区域起始地址(供超时看门狗定位卡点)。 private long scanningRegionAddress; @@ -205,6 +271,54 @@ private static void EnsureBuffer(ref byte[] buf, int size) } } + /// 目标进程的页大小(读失败时按页对齐切分重试用)。 + private const int TargetPageSize = 4096; + + /// + /// 分段读取:先整块读,失败则按页边界切分后逐段读。 + /// 为什么需要它:osu! 的物件结构体跨页时,后一页可能已被回收 + /// (编辑器增删物件会让堆在页边界上增长/收缩,压在页尾的那个物件就会有一段读不到)。 + /// 单次 RPM 只要有任何一页不可读就整体失败,于是那个物件永远读不出来 —— + /// 全量读取每 tick 都抛异常,客户端就永久卡在"重试中"。 + /// 分段读取能把这些"大部分仍可读"的物件救回来:实测 0xB981FEF0 这个物件 + /// 336 字节里只有前 256 字节可读,而普通物件真正用到的字段全在前 148 字节内。 + /// + /// true 表示至少读到了前 字节。 + private bool SafeReadSegmented(IntPtr address, byte[] buf, int size, int minRequired) + { + // 1) 常规情况:一次读成功 + if (ReadProcessMemory(TargetHandle, address, buf, size, ref bytesRead)) + { + return true; + } + + // 2) 失败:按页边界切分,逐段读进临时缓冲再拼起来 + EnsureBuffer(ref buf, size); + byte[] tmp = new byte[Math.Min(TargetPageSize, size)]; + int offset = 0; + + while (offset < size) + { + long here = address.ToInt64() + offset; + long pageEnd = ((here / TargetPageSize) + 1) * TargetPageSize; + int chunk = (int)Math.Min(pageEnd - here, size - offset); + if (chunk <= 0) break; + + if (!ReadProcessMemory(TargetHandle, IntPtr.Add(address, offset), tmp, chunk, ref bytesRead)) + { + if (offset >= minRequired) break; // 需要的字段已经读到了,后面的字节用不上 + Log.ConsoleLog("SafeReadSegmented: unreadable at " + (address.ToInt64() + offset) + + " (need " + minRequired + " bytes, got " + offset + ")", Log.LogType.EditorReader, Log.LogLevel.Error); + throw new Exception("ReadProcessMemory Error. Cancelled reading."); + } + + Buffer.BlockCopy(tmp, 0, buf, offset, chunk); + offset += chunk; + } + + return offset >= minRequired; + } + private string ReadString(IntPtr pString) { if (pString == IntPtr.Zero) @@ -212,10 +326,10 @@ private string ReadString(IntPtr pString) return null; } - SafeReadProcessMemory(process.Handle, pString + 4, buffer4, 4, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pString + 4, buffer4, 4, ref bytesRead); int num = SafeBitConverterToInt32(buffer4, 0, "ReadString num"); byte[] array = new byte[2 * num]; - SafeReadProcessMemory(process.Handle, pString + 8, array, 2 * num, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pString + 8, array, 2 * num, ref bytesRead); char[] array2 = new char[num]; Buffer.BlockCopy(array, 0, array2, 0, 2 * num); return new string(array2); @@ -255,54 +369,85 @@ private IntPtr FindEditorAddress() Log.ConsoleLog("FindEditorAddress: start enumerating memory regions.", Log.LogType.EditorReader, Log.LogLevel.Info); Internals internals = new Internals(); - internals.MemInfo(process.Handle); - Log.ConsoleLog("FindEditorAddress: " + internals.MemReg.Count + " region(s) to scan.", Log.LogType.EditorReader, Log.LogLevel.Info); + internals.MemInfo(TargetHandle); + int regionCount = internals.MemReg.Count; + Log.ConsoleLog("FindEditorAddress: " + regionCount + " region(s) to scan.", Log.LogType.EditorReader, Log.LogLevel.Info); // 把扫描放到独立线程:若某个 ReadProcessMemory 在 Wine 下永久阻塞, - // Join 超时后放弃该线程、记录阻塞区域并在下次重试时跳过,而不是让整个程序卡死。 - IntPtr result = IntPtr.Zero; + // Join 超时后放弃该线程,把该区域延后重试(而不是永久跳过),而不是让整个程序卡死。 + // 扫描线程自带缓冲区(见 ScanForEditorAddress),超时被放弃后继续运行也不会与主线程抢共享字段。 + if (Interlocked.Increment(ref liveScanThreads) > MaxLiveScanThreads) + { + Interlocked.Decrement(ref liveScanThreads); + throw new InvalidOperationException("Too many memory scans are still running; skipping this attempt."); + } + + (IntPtr Address, long RegionBase, int RegionIndex) scanResult = (IntPtr.Zero, 0, -1); Exception? scanError = null; Thread scanThread = new Thread(() => { - try { result = ScanForEditorAddress(internals); } + try { scanResult = ScanForEditorAddress(internals); } catch (Exception ex) { scanError = ex; } + finally { Interlocked.Decrement(ref liveScanThreads); } }); scanThread.Start(); if (!scanThread.Join(ScanTimeoutMs)) { - long blocked = Interlocked.Read(ref scanningRegionAddress); - int blockedIndex = scanningRegionIndex; - if (blocked != 0) - { - lock (blockedRegionAddresses) blockedRegionAddresses.Add(blocked); - } - Log.ConsoleLog("FindEditorAddress: scan aborted after " + ScanTimeoutMs + " ms, blocked at region " + blockedIndex + "/" + internals.MemReg.Count + " (address " + blocked + "). Will skip it on the next attempt.", Log.LogType.EditorReader, Log.LogLevel.Warning); + long stalled = Interlocked.Read(ref scanningRegionAddress); + int stalledIndex = scanningRegionIndex; + if (stalled != 0) PenalizeRegion(stalled); + // 下一次从卡住区域之后继续扫(环形),保证多次重试能向前推进、最终覆盖整个地址空间 + if (regionCount > 0) scanCursorIndex = (stalledIndex + 1) % regionCount; + Log.ConsoleLog("FindEditorAddress: scan aborted after " + ScanTimeoutMs + " ms, stalled at region " + stalledIndex + "/" + regionCount + " (address " + stalled + "). It will be retried with backoff.", Log.LogType.EditorReader, Log.LogLevel.Warning); throw new InvalidOperationException("Memory scan aborted: ReadProcessMemory did not return in time."); } if (scanError != null) throw scanError; - return result; + + if (scanResult.Address != IntPtr.Zero) + { + lastFoundRegionBase = scanResult.RegionBase; + scanCursorIndex = scanResult.RegionIndex; + ClearRegionPenalties(); + } + else + { + // 完整扫过一遍没有结果:下次重新从 0 开始 + scanCursorIndex = 0; + } + + return scanResult.Address; } - private IntPtr ScanForEditorAddress(Internals internals) + /// + /// 在目标进程里扫描编辑器签名。 + /// 扫描线程不共享任何实例缓冲区:超时被放弃的线程可能仍在读内存, + /// 若与主线程共用 buffer/bytesRead 会读到互相覆盖的数据。 + /// + private (IntPtr Address, long RegionBase, int RegionIndex) ScanForEditorAddress(Internals internals) { + const int ReadChunkSize = 8 * 1024 * 1024; + byte[] array = ToByteArray("230000001400000019000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0C000000eeeeeeeeeeeeeeeeeeeeeeeeee00"); int overlap = array.Length - 1; + byte[] scanBuffer = null; + byte[] probe16 = new byte[16]; + byte[] probe4 = new byte[4]; + IntPtr read = IntPtr.Zero; + int skippedPenalized = 0; - for (int i = 0; i < internals.MemReg.Count; i++) + foreach (int i in GetScanOrder(internals)) { Internals.MEMORY_BASIC_INFORMATION mEMORY_BASIC_INFORMATION = internals.MemReg[i]; long regionBase = mEMORY_BASIC_INFORMATION.BaseAddress.ToInt64(); long regionSize = mEMORY_BASIC_INFORMATION.RegionSize.ToInt64(); - lock (blockedRegionAddresses) + if (IsRegionPenalized(regionBase)) { - if (blockedRegionAddresses.Contains(regionBase)) - { - Log.ConsoleLog("FindEditorAddress: skip blocked region " + i + "/" + internals.MemReg.Count + " (address " + regionBase + ").", Log.LogType.EditorReader, Log.LogLevel.Info); - continue; - } + skippedPenalized++; + Log.ConsoleLog("FindEditorAddress: region " + i + "/" + internals.MemReg.Count + " (address " + regionBase + ") is in backoff, skipped for now.", Log.LogType.EditorReader, Log.LogLevel.Info); + continue; } if (regionSize > MaxScanRegionSize) @@ -324,38 +469,149 @@ private IntPtr ScanForEditorAddress(Internals internals) int readSize = chunkSize; if (offset + chunkSize < regionSize) readSize += overlap; - EnsureBuffer(ref buffer, readSize); - if (!ReadProcessMemory(process.Handle, IntPtr.Add(mEMORY_BASIC_INFORMATION.BaseAddress, (int)offset), buffer, readSize, ref bytesRead)) + EnsureBuffer(ref scanBuffer, readSize); + if (!ReadProcessMemory(TargetHandle, IntPtr.Add(mEMORY_BASIC_INFORMATION.BaseAddress, (int)offset), scanBuffer, readSize, ref read)) { offset += chunkSize; continue; } - int bytesToScan = (int)bytesRead; + int bytesToScan = (int)read; for (int j = 0; j <= bytesToScan - array.Length; j += 4) { - if (PatternCheck(buffer, array, j) && !EditorMissingObjects(new IntPtr(mEMORY_BASIC_INFORMATION.BaseAddress.ToInt64() + offset + j - 160))) - { - IntPtr editorAddress = new IntPtr(mEMORY_BASIC_INFORMATION.BaseAddress.ToInt64() + offset + j - 160); - Log.ConsoleLog("FindEditorAddress: found at " + editorAddress, Log.LogType.EditorReader, Log.LogLevel.Debug); - return editorAddress; - } + if (!PatternCheck(scanBuffer, array, j)) continue; + + IntPtr candidate = new IntPtr(mEMORY_BASIC_INFORMATION.BaseAddress.ToInt64() + offset + j - 160); + if (!IsPlausibleEditor(candidate, probe16, probe4)) continue; + + Log.ConsoleLog("FindEditorAddress: found at " + candidate, Log.LogType.EditorReader, Log.LogLevel.Debug); + return (candidate, regionBase, i); } offset += chunkSize; - if ((long)bytesRead < readSize) break; // 区域剩余部分不可读,停止该区域 + if ((long)read < readSize) break; // 区域剩余部分不可读,停止该区域 } } + if (skippedPenalized > 0) + { + Log.ConsoleLog("FindEditorAddress: " + skippedPenalized + " region(s) skipped due to backoff.", Log.LogType.EditorReader, Log.LogLevel.Warning); + } + Log.ConsoleLog("FindEditorAddress: no active editor found.", Log.LogType.EditorReader, Log.LogLevel.Warning); 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 后会重建编辑器对象,堆里可能残留同样能通过签名的死副本, + /// 选错副本会造成之后持续读取失败。这里额外校验编辑器状态、HOM 与物件列表。 + /// 失败返回 false(不抛异常),让扫描继续找下一个候选。 + /// + private bool IsPlausibleEditor(IntPtr pE, byte[] probe16, byte[] probe4) + { + if (pE == IntPtr.Zero) return false; + + IntPtr read = IntPtr.Zero; + + // 编辑器状态字段(与 EditorNeedsReload 的判定一致) + if (!ReadProcessMemory(TargetHandle, pE + 160, probe16, 16, ref read)) return false; + if (BitConverter.ToInt32(probe16, 0) != 35 || BitConverter.ToInt32(probe16, 4) != 20 || BitConverter.ToInt32(probe16, 8) != 25) return false; + + // HOM 与物件列表:死副本通常指向已释放/清零的内存 + if (!ReadProcessMemory(TargetHandle, pE + 28, probe4, 4, ref read)) return false; + IntPtr pHom = ToIntPtr(probe4, 0); + if (pHom == IntPtr.Zero) return false; + + 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; + + return true; + } + + /// + /// 绑定 osu! 进程。切换到新进程时清空扫描状态:地址空间已完全不同, + /// 旧的退避表/命中区域/扫描游标都失去意义。 + /// public void SetProcess(Process forceProcess = null) { if (forceProcess != null) { this.process = forceProcess; + ResetScanState(); return; } @@ -365,6 +621,7 @@ public void SetProcess(Process forceProcess = null) if (process.MainModule.ModuleName == "osu!.exe" && process.MainModule.FileVersionInfo.ProductName == "osu!") { this.process = process; + ResetScanState(); return; } } @@ -372,6 +629,14 @@ public void SetProcess(Process forceProcess = null) throw new InvalidOperationException("No process for osu!.exe found."); } + private void ResetScanState() + { + ClearRegionPenalties(); + lastFoundRegionBase = 0; + scanCursorIndex = 0; + Interlocked.Exchange(ref scanningRegionAddress, 0); + } + public bool ProcessNeedsReload() { if (process != null) @@ -400,12 +665,17 @@ public string ProcessTitle() private IntPtr ToIntPtr(byte[] value, int startIndex) { - if (IntPtr.Size > 4) + // 目标进程(osu!)是 32 位:必须按 4 字节读取,不能跟随 viewer 自身的 IntPtr.Size。 + // 必须按无符号读:指针最高位为 1(地址 >= 0x80000000,编辑器堆长大后会用到) + // 时,ToInt32 得到负数,在 x64 宿主上转 IntPtr 会符号扩展成 0xFFFFFFFF8xxxxxxx, + // ReadProcessMemory 立刻失败 —— 表现就是"编辑一会儿之后某个物件开始永久读不到, + // 整个全量读取每 tick 抛异常,客户端卡在重试中"。ToUInt32 零扩展,得到正确的 0x8xxxxxxx。 + if (TargetPointerSize > 4) { - return (IntPtr)BitConverter.ToUInt32(value, startIndex); + return (IntPtr)(long)BitConverter.ToUInt64(value, startIndex); } - return (IntPtr)BitConverter.ToInt32(value, startIndex); + return (IntPtr)(long)BitConverter.ToUInt32(value, startIndex); } public void SetEditor() @@ -415,10 +685,12 @@ public void SetEditor() /// /// 清空已缓存的编辑器地址,强制下一次检查时重新扫描内存。 + /// 同时清掉扫描退避表/命中区域/游标:手动重绑时应当从干净状态重新开始。 /// public void ResetEditor() { pEditor = IntPtr.Zero; + ResetScanState(); } public bool EditorNeedsReload() @@ -434,8 +706,8 @@ public bool EditorNeedsReload() // 读取失败时必须视为需要重载,不能依赖上次成功读取残留的 buffer 值做判断 // (osu! 从 test mode 退出重建 editor 后,旧 pEditor 可能已失效,ReadProcessMemory 失败但 buffer 仍是旧签名) - if (!ReadProcessMemory(process.Handle, pEditor + 160, buffer16, 16, ref bytesRead) || - !ReadProcessMemory(process.Handle, pEditor + 208, buffer4, 4, ref bytesRead)) + if (!ReadProcessMemory(TargetHandle, pEditor + 160, buffer16, 16, ref bytesRead) || + !ReadProcessMemory(TargetHandle, pEditor + 208, buffer4, 4, ref bytesRead)) { return true; } @@ -452,13 +724,13 @@ private bool EditorMissingObjects(IntPtr pE) { try { - SafeReadProcessMemory(process.Handle, pE + 28, buffer4, 4, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pE + 28, buffer4, 4, ref bytesRead); IntPtr intPtr = ToIntPtr(buffer4, 0); if (intPtr == IntPtr.Zero) { return true; } - SafeReadProcessMemory(process.Handle, intPtr + 72, buffer4, 4, ref bytesRead); + SafeReadProcessMemory(TargetHandle, intPtr + 72, buffer4, 4, ref bytesRead); IntPtr intPtr2 = ToIntPtr(buffer4, 0); return intPtr2 == IntPtr.Zero; } @@ -472,40 +744,40 @@ private bool EditorMissingObjects(IntPtr pE) public int EditorTime() { - SafeReadProcessMemory(process.Handle, pEditor + 176, buffer16, 16, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pEditor + 176, buffer16, 16, ref bytesRead); return (BitConverter.ToInt32(buffer16, 8) + BitConverter.ToInt32(buffer16, 12)) / 2; } public void SetHOM() { - SafeReadProcessMemory(process.Handle, pEditor + 28, buffer4, 4, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pEditor + 28, buffer4, 4, ref bytesRead); pHOM = ToIntPtr(buffer4, 0); - SafeReadProcessMemory(process.Handle, pEditor + 112, buffer4, 4, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pEditor + 112, buffer4, 4, ref bytesRead); pCompose = ToIntPtr(buffer4, 0); } public void ReadHOM() { EnsureBuffer(ref buffer, 80); - SafeReadProcessMemory(process.Handle, pHOM, buffer, 80, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pHOM, buffer, 80, ref bytesRead); objectRadius = BitConverter.ToSingle(buffer, 24); stackOffset = BitConverter.ToSingle(buffer, 44); pBookmarksL = ToIntPtr(buffer, 56); pObjectsL = ToIntPtr(buffer, 72); EnsureBuffer(ref buffer, 256); - SafeReadProcessMemory(process.Handle, pCompose, buffer, 256, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pCompose, buffer, 256, ref bytesRead); pClipboardL = ToIntPtr(buffer, 48); pSelectedL = ToIntPtr(buffer, 72); } public void FetchBookmarks() { - SafeReadProcessMemory(process.Handle, pBookmarksL, buffer16, 16, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pBookmarksL, buffer16, 16, ref bytesRead); pBookmarksA = ToIntPtr(buffer16, 4); numBookmarks = SafeBitConverterToInt32(buffer16, 12, "numBookmarks"); EnsureBuffer(ref buffer, 4 * numBookmarks); bookmarks = new int[numBookmarks]; - SafeReadProcessMemory(process.Handle, pBookmarksA + 8, buffer, 4 * numBookmarks, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pBookmarksA + 8, buffer, 4 * numBookmarks, ref bytesRead); Buffer.BlockCopy(buffer, 0, bookmarks, 0, 4 * numBookmarks); } @@ -541,7 +813,7 @@ public bool TryReadSelectedIndices(out int[] selectedIndices) return false; } - if (!ReadProcessMemory(process.Handle, pSelectedL, buffer16, 16, ref bytesRead)) + if (!ReadProcessMemory(TargetHandle, pSelectedL, buffer16, 16, ref bytesRead)) { return false; } @@ -564,7 +836,7 @@ public bool TryReadSelectedIndices(out int[] selectedIndices) } EnsureBuffer(ref pSelected, 4 * selCount); - if (!ReadProcessMemory(process.Handle, pSelA + 8, pSelected, 4 * selCount, ref bytesRead)) + if (!ReadProcessMemory(TargetHandle, pSelA + 8, pSelected, 4 * selCount, ref bytesRead)) { return false; } @@ -592,7 +864,7 @@ public bool TryReadSelectedIndices(out int[] selectedIndices) private bool ReadListCount(IntPtr pList, out int count) { count = -1; - if (!ReadProcessMemory(process.Handle, pList, buffer16, 16, ref bytesRead)) + if (!ReadProcessMemory(TargetHandle, pList, buffer16, 16, ref bytesRead)) { return false; } @@ -609,14 +881,14 @@ private bool ReadListCount(IntPtr pList, out int count) public void SetBeatmap() { - SafeReadProcessMemory(process.Handle, pHOM + 48, buffer4, 4, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pHOM + 48, buffer4, 4, ref bytesRead); pBeatmap = ToIntPtr(buffer4, 0); } public void ReadBeatmap() { EnsureBuffer(ref buffer, 320); - SafeReadProcessMemory(process.Handle, pBeatmap, buffer, 320, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pBeatmap, buffer, 320, ref bytesRead); SliderMultiplier = BitConverter.ToDouble(buffer, 8); SliderTickRate = BitConverter.ToDouble(buffer, 16); ApproachRate = BitConverter.ToSingle(buffer, 44); @@ -634,13 +906,13 @@ public void ReadBeatmap() public void SetControlPoints() { EnsureBuffer(ref buffer, 192); - SafeReadProcessMemory(process.Handle, pBeatmap, buffer, 192, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pBeatmap, buffer, 192, ref bytesRead); pControlPointsL = ToIntPtr(buffer, 176); - SafeReadProcessMemory(process.Handle, pControlPointsL, buffer16, 16, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pControlPointsL, buffer16, 16, ref bytesRead); pControlPointsA = ToIntPtr(buffer16, 4); numControlPoints = SafeBitConverterToInt32(buffer16, 12, "numControlPoints"); EnsureBuffer(ref pControlPoints, 4 * numControlPoints); - SafeReadProcessMemory(process.Handle, pControlPointsA + 8, pControlPoints, 4 * numControlPoints, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pControlPointsA + 8, pControlPoints, 4 * numControlPoints, ref bytesRead); } public void ReadControlPoints() @@ -654,7 +926,7 @@ public void ReadControlPoints() private ControlPoint ReadControlPoint(IntPtr pControlPoint) { - SafeReadProcessMemory(process.Handle, pControlPoint, bufferCp, 48, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pControlPoint, bufferCp, 48, ref bytesRead); return new ControlPoint { BeatLength = BitConverter.ToDouble(bufferCp, 4), @@ -670,11 +942,11 @@ private ControlPoint ReadControlPoint(IntPtr pControlPoint) public void SetObjects() { - SafeReadProcessMemory(process.Handle, pObjectsL, buffer16, 16, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pObjectsL, buffer16, 16, ref bytesRead); pObjectsA = ToIntPtr(buffer16, 4); numObjects = SafeBitConverterToInt32(buffer16, 12, "numObjects"); EnsureBuffer(ref pObjects, 4 * numObjects); - SafeReadProcessMemory(process.Handle, pObjectsA + 8, pObjects, 4 * numObjects, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pObjectsA + 8, pObjects, 4 * numObjects, ref bytesRead); // 建立指针 -> 主物件下标映射,供高频选中读取使用 if (masterIndexByPointer == null || masterIndexByPointer.Count != numObjects) @@ -693,16 +965,111 @@ public void SetObjects() public void ReadObjects(bool fetchHitSound = true) { - hitObjects = new List(); + var hitObjects = new List(); + DiagReadObjectFailure = null; for (int i = 0; i < numObjects; i++) { - hitObjects.Add(ReadObject(ToIntPtr(pObjects, 4 * i), fetchHitSound)); + IntPtr pObject = ToIntPtr(pObjects, 4 * i); + try + { + hitObjects.Add(ReadObject(pObject, fetchHitSound)); + } + catch (Exception ex) + { + // 把"读到第几个物件、地址是多少"记下来:否则只剩一句笼统的 RPM 错误无从定位 + DiagReadObjectFailure = $"index={i} ptr=0x{pObject.ToInt64():X} numObjects={numObjects} : {ex.Message}"; + Log.ConsoleLog("ReadObjects failed: " + DiagReadObjectFailure, Log.LogType.EditorReader, Log.LogLevel.Error); + throw; + } + } + + // 诊断:统计哪些字段越界(EditorReaderHarness 使用,正常运行时开销为 0) + for (int i = 0; i < hitObjects.Count; i++) + { + int reason = RejectReason(hitObjects[i]); + if (reason >= 0) + { + DiagObjectRejectReasons[reason]++; + DiagLastInvalidObjectIndex = i; + DiagLastRejectSample = "reason=" + reason + " index=" + i + " ptr=" + ToIntPtr(pObjects, 4 * i) + " " + hitObjects[i]; + } } + + this.hitObjects = hitObjects; + } + + /// + /// 与 BeatmapInfoCollection 的合法性校验保持一致的分类: + /// 0=序号,1=X,2=Y,3=SegmentCount,4=Type,5=SampleSet,6=SampleSetAdditions,7=SampleVolume;-1 表示合法。 + /// + private static int RejectReason(HitObject ho) + { + if (ho.X > 1000 || ho.X < -1000) return 1; + if (ho.Y > 1000 || ho.Y < -1000) return 2; + if (ho.SegmentCount > 9000) return 3; + if (ho.Type == 0) return 4; + if (ho.SampleSet > 1000) return 5; + if (ho.SampleSetAdditions > 1000) return 6; + if (ho.SampleVolume > 1000) return 7; + return -1; } + /// + /// 诊断用:物件列表的三个关键指针,用于在 harness 里复现/对比读取路径。 + /// + public (IntPtr ListHeader, IntPtr DataArray, int Count, int PointerSize) GetObjectPointers() + => (pObjectsL, pObjectsA, numObjects, TargetPointerSize); + + /// 诊断用:当前缓存的编辑器对象地址(不触发重新扫描)。 + public IntPtr EditorAddress => pEditor; + + /// 诊断用:当前缓存的 HOM 地址。 + public IntPtr HomAddress => pHOM; + + /// 诊断用:当前缓存的 Beatmap 地址。 + public IntPtr BeatmapAddress => pBeatmap; + + /// + /// 读取物件结构体时"至少要读到多少字节"。 + /// 普通物件(圆/滑条头)解析用到的最大偏移是 144(BaseY)+4 = 148; + /// 滑条额外用到 286(unifiedSoundAddition)+1 = 287,以及 196/224/228/232 处的子列表指针。 + /// 为什么必须区分:编辑器增删物件会让堆在页边界上收缩,压在页尾的物件 + /// 可能只有前 256 字节可读(实测 0xB981FEF0 就是这种)。若一律要求 287 字节, + /// 这类物件会永久读取失败,一个物件就能让整个全量读取每 tick 抛异常、客户端卡死。 + /// + private const int ObjectRequiredBytesBase = 148; + private const int ObjectRequiredBytesSlider = 287; + + /// 先用它把 Type(偏移 24)读出来,据此决定这个物件要读到多少字节。 + private const int ObjectTypeProbeBytes = 32; + private HitObject ReadObject(IntPtr pObject, bool fetchHitSound) { - SafeReadProcessMemory(process.Handle, pObject, bufferOb, 336, ref bytesRead); + int required; + + // 快路径:整块读成功就说明 336 字节全可读,直接判类型,省掉一次探测读取 + //(每秒约 1.6 万次物件读取,多一次 RPM 就是多 1.6ms/帧)。 + if (ReadProcessMemory(TargetHandle, pObject, bufferOb, 336, ref bytesRead)) + { + required = (BitConverter.ToInt32(bufferOb, 24) & 2) > 0 ? ObjectRequiredBytesSlider : ObjectRequiredBytesBase; + } + else + { + // 慢路径:物件跨页且后一页被回收。先读结构体头部拿 Type,据此决定需要多少字节。 + byte[] head = new byte[ObjectTypeProbeBytes]; + if (!SafeReadSegmented(pObject, head, ObjectTypeProbeBytes, sizeof(int))) + { + throw new Exception("ReadProcessMemory Error. Cancelled reading."); + } + + required = (BitConverter.ToInt32(head, 24) & 2) > 0 ? ObjectRequiredBytesSlider : ObjectRequiredBytesBase; + if (!SafeReadSegmented(pObject, bufferOb, 336, required)) + { + throw new Exception("ReadProcessMemory Error. Cancelled reading."); + } + } + + bool isSlider = required == ObjectRequiredBytesSlider; HitObject hitObject = new HitObject(); hitObject.SpatialLength = BitConverter.ToDouble(bufferOb, 8); hitObject.StartTime = BitConverter.ToInt32(bufferOb, 16); @@ -721,7 +1088,7 @@ private HitObject ReadObject(IntPtr pObject, bool fetchHitSound) hitObject.IsSelected = BitConverter.ToBoolean(bufferOb, 133); hitObject.BaseX = BitConverter.ToSingle(bufferOb, 140); hitObject.BaseY = BitConverter.ToSingle(bufferOb, 144); - if (hitObject.IsSlider()) + if (isSlider) { hitObject.curveLength = BitConverter.ToDouble(bufferOb, 148); hitObject.CurveType = BitConverter.ToInt32(bufferOb, 248); @@ -730,34 +1097,34 @@ private HitObject ReadObject(IntPtr pObject, bool fetchHitSound) pSTL = ToIntPtr(bufferOb, 224); pSSL = ToIntPtr(bufferOb, 228); pSSAL = ToIntPtr(bufferOb, 232); - SafeReadProcessMemory(process.Handle, pPointsL, buffer16, 16, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pPointsL, buffer16, 16, ref bytesRead); pTempA = ToIntPtr(buffer16, 4); numTemp = SafeBitConverterToInt32(buffer16, 12, "numTemp"); EnsureBuffer(ref bTemp, 8 * numTemp); - SafeReadProcessMemory(process.Handle, pTempA + 8, bTemp, 8 * numTemp, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pTempA + 8, bTemp, 8 * numTemp, ref bytesRead); hitObject.sliderCurvePoints = new float[2 * numTemp]; Buffer.BlockCopy(bTemp, 0, hitObject.sliderCurvePoints, 0, 8 * numTemp); if (!hitObject.unifiedSoundAddition) { - SafeReadProcessMemory(process.Handle, pSTL, buffer16, 16, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pSTL, buffer16, 16, ref bytesRead); pTempA = ToIntPtr(buffer16, 4); numTemp = SafeBitConverterToInt32(buffer16, 12, "numTemp"); EnsureBuffer(ref bTemp, 4 * numTemp); - SafeReadProcessMemory(process.Handle, pTempA + 8, bTemp, 4 * numTemp, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pTempA + 8, bTemp, 4 * numTemp, ref bytesRead); hitObject.SoundTypeList = new int[numTemp]; Buffer.BlockCopy(bTemp, 0, hitObject.SoundTypeList, 0, 4 * numTemp); - SafeReadProcessMemory(process.Handle, pSSL, buffer16, 16, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pSSL, buffer16, 16, ref bytesRead); pTempA = ToIntPtr(buffer16, 4); numTemp = SafeBitConverterToInt32(buffer16, 12, "numTemp"); EnsureBuffer(ref bTemp, 4 * numTemp); - SafeReadProcessMemory(process.Handle, pTempA + 8, bTemp, 4 * numTemp, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pTempA + 8, bTemp, 4 * numTemp, ref bytesRead); hitObject.SampleSetList = new int[numTemp]; Buffer.BlockCopy(bTemp, 0, hitObject.SampleSetList, 0, 4 * numTemp); - SafeReadProcessMemory(process.Handle, pSSAL, buffer16, 16, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pSSAL, buffer16, 16, ref bytesRead); pTempA = ToIntPtr(buffer16, 4); numTemp = SafeBitConverterToInt32(buffer16, 12, "numTemp"); EnsureBuffer(ref bTemp, 4 * numTemp); - SafeReadProcessMemory(process.Handle, pTempA + 8, bTemp, 4 * numTemp, ref bytesRead); + SafeReadProcessMemory(TargetHandle, pTempA + 8, bTemp, 4 * numTemp, ref bytesRead); hitObject.SampleSetAdditionsList = new int[numTemp]; Buffer.BlockCopy(bTemp, 0, hitObject.SampleSetAdditionsList, 0, 4 * numTemp); } diff --git a/osucatch-editor-realtimeviewer/EditorReaderHelper.cs b/osucatch-editor-realtimeviewer/EditorReaderHelper.cs index 0e1712a..bf1ecaf 100644 --- a/osucatch-editor-realtimeviewer/EditorReaderHelper.cs +++ b/osucatch-editor-realtimeviewer/EditorReaderHelper.cs @@ -1,4 +1,4 @@ -using Editor_Reader; +using Editor_Reader; namespace osucatch_editor_realtimeviewer { @@ -6,10 +6,27 @@ namespace osucatch_editor_realtimeviewer public class EditorReaderHelper { - private static readonly EditorReader reader = new(); + private readonly EditorReader reader = new(); + + /// + /// 编辑器读取状态:区分"编辑器本来就没打开"(正常空闲,例如 test mode / 选歌) + /// 与"在编辑器里但读不到"(异常),调用方据此决定是安静等待还是记录错误。 + /// + public enum EditorState + { + Unknown, + /// 已成功绑定编辑器,数据可读。 + Active, + /// osu! 在运行,但当前不在编辑器(test mode / 选歌 / 加载中)。 + NotInEditor, + /// 当前在编辑器里,但绑定或读取失败。 + Failed, + } + + /// 最近一次编辑器检查的结果,供调用方区分空闲与故障。 + public EditorState LastEditorState { get; private set; } = EditorState.Unknown; private bool Is_Doing_SetProcess = false; - private bool Is_Doing_FetchEditor = false; private bool Is_Osu_Running = false; public bool Is_Editor_Running = false; @@ -22,6 +39,18 @@ public class EditorReaderHelper private int fetchAll_Failed_Count = 0; private const int FetchAll_MaxRetry_Count = 10; + /// + /// 一次全量读取因"快照被编辑器改到一半"而失败时,原地重试几次。 + /// 实测切难度时的撕裂失败重读一次即可恢复,因此默认 1 次; + /// 重试仍失败才计入连败(连败 10 次会触发 ForceRebind,那是一次最长 30 秒的后台重扫)。 + /// + private const int FetchAll_SnapshotRetryCount = 1; + + // 连续读取失败后的指数退避区间:编辑器批量修改(拖拽/撤销/加载)期间会连续读到 + // 不一致的快照,此时按最高频率反复重读既无意义又会拖慢 osu!。 + private const long FetchAll_RetryBaseIntervalMs = 200; + private const long FetchAll_RetryMaxIntervalMs = 3000; + // 高频/低频分离:多数 tick 只读 EditorTime,全量读取有间隔限制 private BeatmapInfoCollection? cachedCollection; private string cachedTitle = ""; @@ -29,21 +58,130 @@ public class EditorReaderHelper private long lastEditorCheckTimestamp; private long lastEditorFailedTimestamp; private long lastFullFetchTimestamp; + private long lastFullFetchAttemptTimestamp; private bool editorCheckSucceeded; /// /// FetchEditor 失败后的最小重试间隔:避免以 Drawing_Interval(默认 20ms)级别的 - /// 高频循环反复触发全量内存扫描(Wine 下扫描可能很慢)。 + /// 高频循环反复做编辑器校验。真正的重扫由 + /// 自己的退避(2 秒起)控制,因此这里可以放短一些,让 test mode 退出后尽快恢复。 /// - private const long FetchEditor_RetryIntervalMs = 1000; - // 低频读取间隔(静止时)与全量读取间隔(编辑器前台且鼠标正在移动时),可在设置面板中自定义 - private static long FullCheckIntervalMs => Math.Clamp(app.Default.LowFreqRead_Interval, 5, 10000); - private static long FullCheckIntervalMsEditing => Math.Clamp(app.Default.FullRead_Interval, 5, 10000); + private const long FetchEditor_RetryIntervalMs = 250; + /// + /// 全量(物件数据)读取间隔:编辑器在前台且鼠标正在移动时用 FullRead_Interval(默认 20ms, + /// 保持作图期间的跟随手感),其它情况用 LowFreqRead_Interval(默认 100ms)。 + /// 注意:拖动/放置物件时,画面里唯一的动态内容就是"物件跟着鼠标走",而物件当前位置 + /// 只能从编辑器内存读到,因此这个间隔直接等于跟手的帧率(20ms ≈ 50fps,50ms 会看出明显卡顿)。 + /// 不要把默认值调大来"省读取";真要降低读取量,应该只对正在拖动/选中的物件做增量读取, + /// 而不是降低整个谱面的重读频率。 + /// + private static long FullCheckIntervalMs => Math.Clamp(app.Default.FullRead_Interval, 5, 10000); + private static long LowFreqCheckIntervalMs => Math.Clamp(app.Default.LowFreqRead_Interval, 5, 10000); + + /// + /// 后台重绑是否正在进行。 + /// 内存扫描最长可能耗时 ScanTimeoutMs(默认 30 秒),绝不能在绘制循环上同步执行, + /// 否则读取失败期间画面会整段停住(issue 里的"卡死")。扫描期间循环只绘制上一份数据。 + /// + public bool IsRebinding => rebinding; + + private volatile bool rebinding; + private int rebindAttemptCount; + private long nextRebindAllowedTimestamp; + + /// 重绑失败后的最小间隔,逐次翻倍至上限,避免不断重复整轮内存扫描。 + private const int Rebind_MinIntervalMs = 2000; + private const int Rebind_MaxIntervalMs = 60000; public EditorReaderHelper() { reader.autoDeStack = true; } + /// + /// 在后台线程重新绑定 osu! 进程与编辑器地址(必要时重扫整个地址空间)。 + /// 扫描期间 为 true,调用方应跳过所有读取、继续绘制上一份数据。 + /// + private void StartBackgroundRebind(string reason) + { + if (rebinding) return; + + long now = stopwatch.ElapsedMilliseconds; + if (now < nextRebindAllowedTimestamp) return; + + rebindAttemptCount++; + nextRebindAllowedTimestamp = now + Math.Min((long)Rebind_MinIntervalMs << Math.Min(rebindAttemptCount - 1, 8), Rebind_MaxIntervalMs); + rebinding = true; + + Log.ConsoleLog("Start background editor re-bind (" + reason + "), attempt " + rebindAttemptCount + ".", Log.LogType.EditorReader, Log.LogLevel.Info); + + Task.Run(() => + { + try + { + if (reader.ProcessNeedsReload()) + { + reader.SetProcess(); + Is_Osu_Running = true; + } + + // 强制重扫:清掉旧的编辑器地址与扫描退避/游标状态 + reader.ResetEditor(); + reader.FetchEditor(); + Log.ConsoleLog("Background editor re-bind succeeded.", Log.LogType.EditorReader, Log.LogLevel.Info); + } + catch (Exception ex) + { + Log.ConsoleLog("Background editor re-bind failed.\r\n" + ex, Log.LogType.EditorReader, Log.LogLevel.Error); + } + finally + { + rebinding = false; + } + }); + } + + /// + /// 强制丢弃当前的进程/编辑器绑定与所有缓存,下一个 tick 会重新查找 osu! 进程并重新扫描编辑器地址。 + /// 供"Reset"菜单与"连续读取失败"路径使用:此前的 Reset 只是新建 Helper, + /// 而 EditorReader 是静态实例,编辑器地址与扫描状态都没被清掉,卡住时按 Reset 等于没按。 + /// + /// 手动 Reset 时传 true:立即允许下一次重扫, + /// 而不是等内部退避(最长 60 秒)过去。 + public void ForceRebind(bool resetRebindBackoff = false) + { + Is_Osu_Running = false; + Is_Doing_SetProcess = false; + Is_Editor_Running = false; + editorCheckSucceeded = false; + LastEditorState = EditorState.Unknown; + + fetchEditor_Failed_Count = 0; + fetchAll_Failed_Count = 0; + cachedCollection = null; + cachedTitle = ""; + lastEditorCheckTimestamp = 0; + lastEditorFailedTimestamp = 0; + lastFullFetchTimestamp = 0; + lastFullFetchAttemptTimestamp = 0; + + if (resetRebindBackoff) + { + rebindAttemptCount = 0; + nextRebindAllowedTimestamp = 0; + } + + try + { + reader.ResetEditor(); + } + catch (Exception ex) + { + Log.ConsoleLog("Reset editor address failed.\r\n" + ex, Log.LogType.EditorReader, Log.LogLevel.Error); + } + + Log.ConsoleLog("Editor binding was reset manually.", Log.LogType.EditorReader, Log.LogLevel.Info); + } + /// /// 当前连接的 osu! 进程 ID。 /// @@ -55,6 +193,13 @@ public EditorReaderHelper() /// Is success or not. public bool FetchProcess() { + // 后台重绑正在操作 EditorReader:此时不要在循环线程上碰同一个实例 + if (rebinding) + { + Is_Editor_Running = false; + return false; + } + bool isNeedReload = true; try { @@ -118,93 +263,90 @@ public string FetchTitle() /// /// Fetch editor if needed for Editor Reader. /// + /// true 时不再相信"地址看起来还有效"的廉价校验,直接重新绑定。 + /// 读取连续失败时必须用它,否则会被成功缓存吞掉——这正是此前"连败后重绑"无效的原因。 /// Is success or not. - public bool FetchEditor() + public bool FetchEditor(bool force = false) { + // 后台重绑进行中:本轮不做任何读取(扫描可能长达数十秒) + if (rebinding) + { + Is_Editor_Running = false; + beatmap_path = ""; + LastEditorState = EditorState.Unknown; + return false; + } + + long elapsed = stopwatch.ElapsedMilliseconds; + // 高频路径:FullCheckIntervalMs 内已经完整验证过 editor,直接复用上次结果 - if (editorCheckSucceeded && stopwatch.ElapsedMilliseconds - lastEditorCheckTimestamp < FullCheckIntervalMs) + if (!force && editorCheckSucceeded && elapsed - lastEditorCheckTimestamp < FullCheckIntervalMs) { Is_Editor_Running = true; + LastEditorState = EditorState.Active; return true; } - // 失败节流:上次检测/扫描失败后至少等 1 秒再重试 - if (!editorCheckSucceeded && stopwatch.ElapsedMilliseconds - lastEditorFailedTimestamp < FetchEditor_RetryIntervalMs) + // 失败节流:上次检测失败后至少等 FetchEditor_RetryIntervalMs 再做下一次校验(0 表示还没检测过) + if (!force && !editorCheckSucceeded && lastEditorFailedTimestamp != 0 && + elapsed - lastEditorFailedTimestamp < FetchEditor_RetryIntervalMs) { Is_Editor_Running = false; beatmap_path = ""; return false; } - lastEditorFailedTimestamp = stopwatch.ElapsedMilliseconds; + lastEditorFailedTimestamp = elapsed; beatmap_title = ""; string title = FetchTitle(); if (title == "") { + // osu! 没运行/窗口还没建好:属正常空闲,不是故障 Log.ConsoleLog("Empty osu title.", Log.LogType.EditorReader, Log.LogLevel.Info); - Is_Editor_Running = false; - beatmap_path = ""; - editorCheckSucceeded = false; + SetNotInEditor(); return false; } if (!title.EndsWith(".osu")) { + // test mode / 选歌 / 加载中:此刻本来就无法读取,属正常空闲,不是故障 Log.ConsoleLog("Osu title is not editor: " + title, Log.LogType.EditorReader, Log.LogLevel.Info); - Is_Editor_Running = false; - beatmap_path = ""; - editorCheckSucceeded = false; + SetNotInEditor(); return false; } try { - if (reader.EditorNeedsReload()) + if (force || reader.EditorNeedsReload()) { - Log.ConsoleLog("Editor needs Reload.", Log.LogType.EditorReader, Log.LogLevel.Info); - if (Is_Doing_SetProcess || Is_Doing_FetchEditor) - { - Log.ConsoleLog("Still fetching editor.", Log.LogType.EditorReader, Log.LogLevel.Info); - editorCheckSucceeded = false; - return false; - } - if (reader.ProcessNeedsReload()) - { - Log.ConsoleLog("Process needs reload.", Log.LogType.EditorReader, Log.LogLevel.Info); - editorCheckSucceeded = false; - return false; - } - Log.ConsoleLog("Try fetch editor.", Log.LogType.EditorReader, Log.LogLevel.Info); - Is_Doing_FetchEditor = true; - reader.FetchEditor(); - Is_Doing_FetchEditor = false; - Log.ConsoleLog("Fetch editor successfully.", Log.LogType.EditorReader, Log.LogLevel.Info); - Is_Osu_Running = true; - Is_Editor_Running = true; + Log.ConsoleLog(force ? "Editor binding is suspect, re-binding." : "Editor needs Reload.", Log.LogType.EditorReader, Log.LogLevel.Info); + Is_Editor_Running = false; + beatmap_path = ""; + editorCheckSucceeded = false; + LastEditorState = EditorState.Unknown; + + // 扫描放到后台线程:这一步最长可达 30 秒,同步执行会让画面整段停住 + StartBackgroundRebind(force ? "read failures" : "editor address invalid"); + return false; } } catch (Exception ex) { + // 廉价校验本身失败(例如地址已不可读):同样交给后台重绑,而不是在循环里扫内存 Log.ConsoleLog("Fetch editor failed.\r\n" + ex, Log.LogType.EditorReader, Log.LogLevel.Error); - Is_Doing_FetchEditor = false; Is_Editor_Running = false; beatmap_path = ""; editorCheckSucceeded = false; + LastEditorState = EditorState.Failed; - // 连续失败(例如 osu! test mode 快速进出后旧地址失效)时,强制重新绑定进程并清空 editor 地址, - // 避免永远停留在"检测到失效却不去重新扫描"的状态 fetchEditor_Failed_Count++; if (fetchEditor_Failed_Count > FetchEditor_MaxRetry_Count) { Log.ConsoleLog("Refetching osu! process and editor...", Log.LogType.EditorReader, Log.LogLevel.Warning); fetchEditor_Failed_Count = 0; - try - { - reader.SetProcess(); - } - catch (Exception setProcessEx) - { - Log.ConsoleLog("Refetch osu! process failed.\r\n" + setProcessEx, Log.LogType.EditorReader, Log.LogLevel.Error); - } - reader.ResetEditor(); + ForceRebind(); + } + else + { + StartBackgroundRebind("validation failed"); } return false; } @@ -213,9 +355,23 @@ public bool FetchEditor() lastEditorCheckTimestamp = stopwatch.ElapsedMilliseconds; editorCheckSucceeded = true; fetchEditor_Failed_Count = 0; + rebindAttemptCount = 0; + nextRebindAllowedTimestamp = 0; + LastEditorState = EditorState.Active; return true; } + /// + /// 标记"当前不在编辑器":清掉成功缓存,退出 test mode 后下一次检查会重新做真实校验。 + /// + private void SetNotInEditor() + { + Is_Editor_Running = false; + beatmap_path = ""; + editorCheckSucceeded = false; + LastEditorState = EditorState.NotInEditor; + } + /// /// Call Editor Reader's FetchAll(). /// @@ -241,21 +397,36 @@ public bool FetchEditor() /// /// 高频/低频分离读取:多数 tick 只读 EditorTime 并复用缓存数据, /// 只有在编辑器/地图变化、物件数量变化或超过 FullCheckIntervalMs 时才做全量读取。 + /// 读取失败不抛异常,只返回 null:调用方应继续用上一份有效数据绘制。 /// private BeatmapInfoCollection? FetchWithCache(bool filterNearby, double partialLoadingHalfTimeSpan) { try { + // 后台重绑正在操作 EditorReader:本轮不读,调用方继续绘制上一份数据 + if (rebinding) return null; + + long now = stopwatch.ElapsedMilliseconds; + + // 连续失败退避:这一轮直接不读,调用方继续绘制上一份有效数据 + if (fetchAll_Failed_Count > 0 && now - lastFullFetchAttemptTimestamp < FetchAllBackoffMs(fetchAll_Failed_Count)) + { + return null; + } + if (fetchAll_Failed_Count > FetchAll_MaxRetry_Count) { + // 连续读不到:强制重绑编辑器地址(忽略成功缓存),并丢弃缓存数据 Log.ConsoleLog("Refetching editor...", Log.LogType.EditorReader, Log.LogLevel.Warning); fetchAll_Failed_Count = 0; - FetchEditor(); cachedCollection = null; + FetchEditor(true); return null; } - if (!IsFullFetchDue()) + // 只有"绑定被认为是有效的"才允许走只读播放头的高频路径, + // 否则会对着已失效的地址反复读 EditorTime + if (!IsFullFetchDue() && editorCheckSucceeded) { // 高频路径:只刷新播放头时间,其余数据沿用上次全量读取 cachedCollection!.EditorTime = reader.EditorTime(); @@ -265,31 +436,81 @@ public bool FetchEditor() return cachedCollection; } - Log.ConsoleLog("Start FetchAll().", Log.LogType.EditorReader, Log.LogLevel.Debug); - bool needFetchFull = app.Default.Backup_Enabled && !filterNearby; - reader.FetchAll(needFetchFull); - BeatmapInfoCollection thisReaderData = filterNearby - ? new BeatmapInfoCollection(reader, partialLoadingHalfTimeSpan) - : new BeatmapInfoCollection(reader); + lastFullFetchAttemptTimestamp = now; - Log.ConsoleLog("FetchAll complete.", Log.LogType.EditorReader, Log.LogLevel.Debug); - - cachedCollection = thisReaderData; - cachedTitle = beatmap_title; - lastFullFetchTimestamp = stopwatch.ElapsedMilliseconds; - fetchAll_Failed_Count = 0; - thisReaderData.IsFreshFetch = true; - if (app.Default.Selected_Show) RefreshSelection(cachedCollection); - return thisReaderData; + // 编辑器正在加载谱面(换难度/新谱面)时,列表头、指针数组与物件数据 + // 不属于同一时刻,必然会读到撕裂的快照而失败。实测切难度时约 1% 的读取会这样, + // 且同一物件重读立刻就好(11 次失败落在 11 个不同下标,无一重复)。 + // 这类失败不该累加连败计数:一旦累到 10 次就会触发 ForceRebind, + // 而那会触发最长 30 秒的后台重扫,正好发生在"用户刚切完难度想看图"的时候。 + // 所以这里先原地重试一次,仍然失败才计入连败。 + for (int attempt = 0; ; attempt++) + { + try + { + Log.ConsoleLog(attempt == 0 ? "Start FetchAll()." : "FetchAll retry #" + attempt + ".", + Log.LogType.EditorReader, Log.LogLevel.Debug); + bool needFetchFull = app.Default.Backup_Enabled && !filterNearby; + reader.FetchAll(needFetchFull); + BeatmapInfoCollection thisReaderData = filterNearby + ? new BeatmapInfoCollection(reader, partialLoadingHalfTimeSpan) + : new BeatmapInfoCollection(reader); + + Log.ConsoleLog("FetchAll complete.", Log.LogType.EditorReader, Log.LogLevel.Debug); + + cachedCollection = thisReaderData; + cachedTitle = beatmap_title; + lastFullFetchTimestamp = stopwatch.ElapsedMilliseconds; + fetchAll_Failed_Count = 0; + thisReaderData.IsFreshFetch = true; + if (app.Default.Selected_Show) RefreshSelection(cachedCollection); + return thisReaderData; + } + catch (Exception ex) + { + // 快照撕裂会表现为两类异常:读内存失败(SafeReadProcessMemory)与 + // 读到不一致数据(BeatmapInfoCollection 的校验)。两类都值得原地重试一次。 + if (attempt < FetchAll_SnapshotRetryCount) + { + Log.ConsoleLog("FetchAll snapshot invalid, retrying immediately.\r\n" + ex.Message, + Log.LogType.EditorReader, Log.LogLevel.Info); + continue; + } + + Log.ConsoleLog("FetchAll failed.(" + fetchAll_Failed_Count + ")\r\n" + ex.ToString(), Log.LogType.EditorReader, Log.LogLevel.Error); + fetchAll_Failed_Count++; + lastFullFetchAttemptTimestamp = stopwatch.ElapsedMilliseconds; + + // 读失败说明缓存的 editor 地址可能已经失效:让下一次 FetchEditor 做真实校验, + // 而不是继续复用"上次校验成功"的缓存结果(否则重绑永远被缓存吞掉) + editorCheckSucceeded = false; + return null; + } + } } catch (Exception ex) { - Log.ConsoleLog("FetchAll failed.(" + fetchAll_Failed_Count + ")\r\n" + ex.ToString(), Log.LogType.EditorReader, Log.LogLevel.Error); + // 兜底:异常发生在重试循环之外(例如 IsFullFetchDue / RefreshSelection)。 + // 这里同样要计入连败并记时间戳,否则退避永远不生效、每 tick 都会重走一遍。 + Log.ConsoleLog("FetchWithCache failed.\r\n" + ex, Log.LogType.EditorReader, Log.LogLevel.Error); fetchAll_Failed_Count++; + lastFullFetchAttemptTimestamp = stopwatch.ElapsedMilliseconds; + editorCheckSucceeded = false; return null; } } + /// + /// 连续失败后的重试退避:200ms 起,逐次翻倍,上限 3 秒。 + /// 编辑器批量修改(拖拽/撤销/加载地图)时会连续读到不一致快照,退避可避免 + /// 以最高频率反复失败、把 osu! 和自身都拖慢。 + /// + private static long FetchAllBackoffMs(int failedCount) + { + long backoff = FetchAll_RetryBaseIntervalMs << Math.Min(Math.Max(failedCount - 1, 0), 8); + return Math.Min(backoff, FetchAll_RetryMaxIntervalMs); + } + /// /// 轻量刷新选中态:只读编辑器当前的选中列表(1~2 次 ReadProcessMemory), /// 原位更新 中每行的选中标志,供绘制实时查表。 @@ -316,7 +537,7 @@ private void RefreshSelection(BeatmapInfoCollection collection) } } - foreach (ReaderHitObjectWithSelect line in collection.HitObjectLines) + foreach (ReaderHitObjectWithSelect line in collection.SelectionLines) { line.IsSelect = line.MasterIndex >= 0 && line.MasterIndex < selectionScratch.Length && selectionScratch[line.MasterIndex]; } @@ -327,12 +548,21 @@ private void RefreshSelection(BeatmapInfoCollection collection) private bool IsFullFetchDue() { if (cachedCollection == null) return true; - if (cachedTitle != beatmap_title) return true; - // 编辑器前台且鼠标正在移动时缩短全量读取间隔,保证编辑操作实时反映到预览 + // 编辑器前台且鼠标正在移动时用 FullRead_Interval(作图期间保持跟随手感), + // 其它情况(不在前台 / 鼠标静止)用 LowFreqRead_Interval。 long interval = (ProcessFocus.IsEditorForeground(reader.OsuProcessId) && ProcessFocus.IsMouseMoving()) - ? FullCheckIntervalMsEditing : FullCheckIntervalMs; - if (stopwatch.ElapsedMilliseconds - lastFullFetchTimestamp >= interval) return true; + ? FullCheckIntervalMs : LowFreqCheckIntervalMs; + bool intervalElapsed = stopwatch.ElapsedMilliseconds - lastFullFetchTimestamp >= interval; + + // 切换谱面:标题变化时立即全量读取,不必等间隔。 + // 注意顺序:这个判断必须放在间隔判断之后。 + // beatmap_title 由 FetchEditor() 在**每次成功校验时**刷新,而 cachedTitle 只在全量读取成功后才赋值, + // 于是校验与读取之间标题一变,两者就会永久不等 —— 放在前面会让本方法每 tick 都返回 true, + // "高频/低频分离"整个失效(等于回到每 tick 全量读取)。 + // 放到间隔之后,最坏代价只是把切换谱面的检测推迟到下一个间隔(前台移动鼠标时 20ms)。 + if (intervalElapsed) return true; + if (cachedTitle != beatmap_title) return true; // 物件/控制点数量变化(增删)立即触发全量读取,不必等间隔 if (reader.TryReadCounts(out int numObjects, out int numControlPoints, out _) && @@ -369,7 +599,63 @@ public class BeatmapInfoCollection public int BeatmapVersion; public int[] Bookmarks; public List ControlPointLines; - public List HitObjectLines; + private List? hitObjectLines; + + /// + /// 每个物件一行 .osu 文本 + 选中标志。 + /// 惰性构建:这些字符串只在"重建谱面"() + /// 与备份时才被消费;绘制路径只用 。 + /// 而 ho.ToString() 在 16000 物件的谱面上要 ~33ms —— 每次全量读取都构建它就是白白花掉的时间, + /// 所以这里改成首次访问时才生成(并缓存)。 + /// 注意:访问前请确认 thisReader= 已赋值,且不要再往 里追加物件。 + /// + public List HitObjectLines + { + get + { + if (hitObjectLines == null) + { + var lines = new List(HitObjects.Count); + for (int i = 0; i < HitObjects.Count; i++) + { + Editor_Reader.HitObject ho = HitObjects[i]; + lines.Add(new ReaderHitObjectWithSelect(ho.ToString(), ho.IsSelected, i)); + } + hitObjectLines = lines; + } + return hitObjectLines; + } + set => hitObjectLines = value; + } + + /// + /// 显式生成每行 .osu 文本(等价于访问 )。 + /// 在"决定要重建谱面"的那一刻调用:既不拖慢每次全量读取,又保证后台重建线程 + /// 拿到的是已经固定下来的表(此时读取循环已经不会再修改这个 collection)。 + /// + public void EnsureHitObjectLines() => _ = HitObjectLines; + + /// + /// 只建"每行对象"但不拼字符串:绘制高亮需要的 IsSelect 标志走这条路径, + /// 避免在每次全量读取时付出 ToString() 的代价(16000 物件约 33ms/次)。 + /// 顺序与 一一对应,因此 MasterIndex 就是列表下标。 + /// + public static List BuildSelectionLines(List hitObjects) + { + var lines = new List(hitObjects.Count); + for (int i = 0; i < hitObjects.Count; i++) + { + lines.Add(new ReaderHitObjectWithSelect(null!, hitObjects[i].IsSelected, i)); + } + return lines; + } + + /// + /// 绘制用(高频刷新选中态)的行表:只携带 IsSelect,不带 .osu 文本。 + /// 仅在重建谱面/备份时才需要。 + /// + public List SelectionLines = new(); + public List ControlPoints; public List HitObjects; @@ -379,7 +665,6 @@ public BeatmapInfoCollection() Filename = ""; Bookmarks = []; ControlPointLines = new(); - HitObjectLines = new(); ControlPoints = new(); HitObjects = new(); IsFull = false; @@ -443,9 +728,10 @@ public BeatmapInfoCollection(EditorReader reader) BeatmapVersion = reader.BeatmapVersion; Bookmarks = reader.bookmarks; ControlPointLines = reader.controlPoints.Select((cp) => cp.ToString()).ToList(); - HitObjectLines = reader.hitObjects.Select((ho, i) => new ReaderHitObjectWithSelect(ho.ToString(), ho.IsSelected, i)).ToList(); ControlPoints = reader.controlPoints; HitObjects = reader.hitObjects; + // HitObjectLines(.osu 文本)改为惰性构建:只有重建谱面/备份才需要,见属性注释 + SelectionLines = BuildSelectionLines(HitObjects); // We don't need breaks because editor force a new combo after every break. } @@ -503,9 +789,10 @@ public BeatmapInfoCollection(EditorReader reader, double partialLoadingHalfTimeS BeatmapVersion = reader.BeatmapVersion; Bookmarks = reader.bookmarks; ControlPointLines = reader.controlPoints.Select((cp) => cp.ToString()).ToList(); - HitObjectLines = NearbyHitObjects.Select((pair) => new ReaderHitObjectWithSelect(pair.Object.ToString(), pair.Object.IsSelected, pair.Index)).ToList(); - ControlPoints = reader.controlPoints; HitObjects = NearbyHitObjects.Select((pair) => pair.Object).ToList(); + // 过滤模式下不能走惰性:惰性表用列表下标当 MasterIndex,会丢掉 pair.Index 这个主列表下标 + SelectionLines = NearbyHitObjects.Select((pair) => new ReaderHitObjectWithSelect(pair.Object.ToString(), pair.Object.IsSelected, pair.Index)).ToList(); + ControlPoints = reader.controlPoints; // We don't need breaks because editor force a new combo after every break. } diff --git a/osucatch-editor-realtimeviewer/Form1.cs b/osucatch-editor-realtimeviewer/Form1.cs index 0f7a5c0..719d522 100644 --- a/osucatch-editor-realtimeviewer/Form1.cs +++ b/osucatch-editor-realtimeviewer/Form1.cs @@ -1,4 +1,4 @@ -using Microsoft.Win32; +using Microsoft.Win32; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; using System.ComponentModel; @@ -31,6 +31,24 @@ public partial class Form1 : Form private ToolStripMenuItem? unloadTemplateStripMenuItem; private TemplateBeatmapData? templateData; + // 快捷开关条:可吸附在菜单栏下方的工具栏行里,也可拖出为浮动小窗口 + private QuickToggleBar? quickToggleBar; + private QuickToggleDockRow? quickToggleDockRow; + private QuickToggleDocking? quickToggleDocking; + private ToolStripMenuItem? quickToggleStripMenuItem; + + /// 快捷开关“固定预览时刻”的标识(勾选状态随其它开关一起持久化)。 + private const string FreezePreviewTimeKey = "FreezePreviewTime"; + + /// 预览时刻是否被固定(“固定预览时刻”开关打开):为 true 时预览画布不再跟随编辑器。 + private bool previewTimeFrozen; + + /// 固定预览时刻后是否还没完成“钉住”(启动恢复固定状态、或还没读到过 editor 时刻)。 + private bool previewTimePinPending; + + /// 是否已经从编辑器读到过时刻(用于避免把预览固定在未初始化的 0 上)。 + private bool editorTimeAvailable; + /// /// 已提交(正在绘制)的解析/转换结果快照。 /// @@ -124,6 +142,10 @@ public Form1() // 模板菜单在构造函数里创建,确保语言资源能应用到它 CreateTemplateMenu(); + // 快捷开关条(开关内容在 CreateQuickToggles 里添加) + CreateQuickToggleBar(); + CreateQuickToggles(); + if (app.Default.Language_String != "") { defaultLanguageToolStripMenuItem.Checked = false; @@ -231,6 +253,13 @@ private void Form1_Load(object sender, EventArgs e) ReapplyBookmarkStyles(); + // 快捷开关栏:恢复开关状态与停靠 / 浮动状态(浮窗要等主窗口显示出来后再弹出) + quickToggleBar?.ApplyCheckedStates(app.Default.QuickToggle_States); + // ApplyCheckedStates 不触发事件,这里手动把“固定预览时刻”的内部状态同步过来 + SetPreviewTimeFrozen(quickToggleBar?.IsChecked(FreezePreviewTimeKey) ?? false); + quickToggleDocking?.ApplyStartupState(app.Default.QuickToggle_Visible, app.Default.QuickToggle_Floating); + if (quickToggleStripMenuItem != null) quickToggleStripMenuItem.Checked = app.Default.QuickToggle_Visible; + // osu path if (app.Default.osu_path == "") { @@ -350,6 +379,8 @@ private void CheckTopmost() Invoke(new MethodInvoker(delegate () { if (this != null && !this.IsDisposed && !this.Disposing) this.TopMost = shouldTopmost; + // 浮动小窗口跟随主窗口置顶,编辑器在前台时开关也能一直看见 + quickToggleDocking?.SyncTopMost(shouldTopmost); })); } @@ -414,31 +445,107 @@ private bool FetchOsuProcess() if (!editorReaderHelper.FetchProcess()) { - Invoke(new MethodInvoker(delegate () - { - StateToolStripStatusLabel.Text = "Osu!.exe is not running"; - })); - _committed.Reader = null; + // 还没有任何可绘制数据时也要让状态栏说明原因(有数据时由 DrawLastKnownFrame 一并更新) + if (_committed.Reader == null) SetStatusText("Osu!.exe is not running"); return false; } return true; } + /// + /// 检查编辑器绑定。 + /// 返回 false 时调用方应继续用上一份有效数据绘制(详见 ), + /// 而不是清空数据并抛异常中断这一帧——那正是"错误刷屏 + 画面冻结"的原因。 + /// private bool FetchEditor() { if (!editorReaderHelper.FetchEditor()) { + if (_committed.Reader == null) SetStatusText(UnavailableStatusText()); + return false; + } + return true; + } + + /// + /// 读取不可用时的状态栏文本:区分"编辑器没打开"(正常空闲)、"正在重绑"与"读取失败"。 + /// + private string UnavailableStatusText() + { + if (editorReaderHelper.IsRebinding) return "Re-binding editor..."; + if (editorReaderHelper.LastEditorState == EditorReaderHelper.EditorState.Active) return "Editor read failed, retrying"; + return StatusTextFor(editorReaderHelper.LastEditorState); + } + + /// + /// 状态栏文本:区分"编辑器没打开"(正常空闲)与"读取失败"(故障)。 + /// + private static string StatusTextFor(EditorReaderHelper.EditorState state) + { + return state switch + { + EditorReaderHelper.EditorState.Active => "Drawing", + EditorReaderHelper.EditorState.NotInEditor => "Editor is not running", + EditorReaderHelper.EditorState.Failed => "Editor read failed, retrying", + _ => "Connecting to editor", + }; + } + + private void SetStatusText(string text) + { + try + { + if (IsDisposed || Disposing) return; Invoke(new MethodInvoker(delegate () { - StateToolStripStatusLabel.Text = "Editor is not running"; + if (IsDisposed || Disposing) return; + if (StateToolStripStatusLabel.Text != text) StateToolStripStatusLabel.Text = text; })); - _committed.Reader = null; - return false; } - else + catch (Exception ex) + { + // 窗口正在关闭时 Invoke 会失败,这里不影响读取循环 + Log.ConsoleLog("Set status text failed.\r\n" + ex, Log.LogType.Program, Log.LogLevel.Debug); + } + } + + /// + /// 用上一份有效数据绘制一帧:读取失败/编辑器暂时不可用时保持画面,只更新状态栏。 + /// 没有任何有效数据时什么都不做(等待第一次成功读取)。 + /// + private void DrawLastKnownFrame(string statusText) + { + if (_committed.Reader == null) return; + + RequestDraw(null, null, statusText); + } + + /// + /// 向 UI 线程提交一帧绘制。 + /// 为 null 时保持当前播放头不变: + /// 读取失败时沿用上一份数据绘制,不应把播放头退回旧值。 + /// + private void RequestDraw(int? editorTime, string? title, string statusText) + { + try { - return true; + if (editorTime.HasValue) ApplyEditorTime(editorTime.Value); + + Invoke(new MethodInvoker(delegate () + { + if (IsDisposed || Disposing) return; + if (title != null && this.Text != title) this.Text = title; + if (StateToolStripStatusLabel.Text != statusText) StateToolStripStatusLabel.Text = statusText; + this.Canvas.Canvas_Paint(null, null); + })); + Log.ConsoleLog("Draw a frame successful.", Log.LogType.Drawing, Log.LogLevel.Debug); } + catch (Exception ex) + { + Log.ConsoleLog("Draw a frame failed.\r\n" + ex, Log.LogType.Drawing, Log.LogLevel.Debug); + } + + if (DateTime.Now.Ticks > LastDrawingTimeStamp) LastDrawingTimeStamp = DateTime.Now.Ticks; } /// @@ -597,12 +704,32 @@ private async Task reader_timer_Work(CancellationToken cancellationToken) try { + // 后台重建任务与读取是否成功无关:先消费掉,避免读取中断期间重建结果一直不生效 + ConsumeFinishedRebuild(); + + // 后台重绑(可能长达 30 秒的内存扫描)期间不碰 EditorReader,只继续绘制上一份数据 + if (editorReaderHelper.IsRebinding) + { + DrawLastKnownFrame(UnavailableStatusText()); + return; + } + // Step1. fetch osu! process - if (!FetchOsuProcess()) throw new Exception("FetchOsuProcess error."); + if (!FetchOsuProcess()) + { + DrawLastKnownFrame("Osu!.exe is not running"); + return; + } // Step2. fetch editor - if (!FetchEditor()) throw new Exception("FetchEditor error."); + if (!FetchEditor()) + { + // 编辑器不可用(test mode/选歌/读取失败)不是致命错误: + // 保留上一份有效数据继续绘制,只更新状态栏 + DrawLastKnownFrame(UnavailableStatusText()); + return; + } // Step3. fetch all @@ -618,10 +745,14 @@ private async Task reader_timer_Work(CancellationToken cancellationToken) thisReader = editorReaderHelper.FetchAll(partialLoadingHalfTimeSpan); } else thisReader = editorReaderHelper.FetchAll(); - if (thisReader == null) throw new Exception("FetchAll error."); + if (thisReader == null) + { + // 读取失败:helper 内部已按退避重试/触发后台重绑,这里同样不中断绘制 + DrawLastKnownFrame(UnavailableStatusText()); + return; + } - // Step4. 后台流水线:先消费已完成的重建结果,再判断是否需要启动新重建 - ConsumeFinishedRebuild(); + // Step4. 判断是否需要启动新重建 int mods = GetMods(); HitObjectLabelType labelType = GetHitObjectLabelType(); @@ -639,7 +770,7 @@ private async Task reader_timer_Work(CancellationToken cancellationToken) // 高频路径:数据与上次全量读取完全相同,无需逐物件比较 differenceType = DifferenceType.None; } - drawingHelper.SelectionLines = thisReader.HitObjectLines; + drawingHelper.SelectionLines = thisReader.SelectionLines; // Step5. Build osu file Path string filepath = ""; @@ -666,6 +797,10 @@ private async Task reader_timer_Work(CancellationToken cancellationToken) if (needRebuild && _rebuildTask == null && DateTime.Now.Ticks - _rebuildRetryTicks > TimeSpan.FromMilliseconds(500).Ticks) { + // 重建才需要每行 .osu 文本(16000 物件约 33ms):在这里一次性生成, + // 而不是每次全量读取都生成。生成后的表归 thisReader 所有,读取循环不再碰它。 + thisReader.EnsureHitObjectLines(); + int generation = _rebuildGeneration; CommittedState committedSnapshot = _committed; _rebuildTask = Task.Run(() => BuildNewState(thisReader, committedSnapshot, filepath, mods, labelType, converterIsStable, differenceType), cancellationToken); @@ -699,30 +834,13 @@ private async Task reader_timer_Work(CancellationToken cancellationToken) // Step11. drawing(标题/状态栏/绘制合成一次跨线程调用,减少每 tick 的 Invoke 次数) - try - { - drawingHelper.CurrentTime = thisReader.EditorTime; - Log.ConsoleLog("Start drawing.", Log.LogType.Drawing, Log.LogLevel.Debug); + Log.ConsoleLog("Start drawing.", Log.LogType.Drawing, Log.LogLevel.Debug); - string title = editorReaderHelper.beatmap_title; - if (drawingHelper.LabelType == HitObjectLabelType.Difficulty_Stars && !app.Default.FilterNearbyHitObjects && _committed.ConvertedBeatmap != null) - title = "Stars: " + _committed.ConvertedBeatmap.BeatmapInfo.StarRating.ToString("0.00") + "*"; + string title = editorReaderHelper.beatmap_title; + if (drawingHelper.LabelType == HitObjectLabelType.Difficulty_Stars && !app.Default.FilterNearbyHitObjects && _committed.ConvertedBeatmap != null) + title = "Stars: " + _committed.ConvertedBeatmap.BeatmapInfo.StarRating.ToString("0.00") + "*"; - Invoke(new MethodInvoker(delegate () - { - if (this.Text != title) this.Text = title; - StateToolStripStatusLabel.Text = "Drawing"; - this.Canvas.Canvas_Paint(null, null); - })); - Log.ConsoleLog("Draw a frame successful.", Log.LogType.Drawing, Log.LogLevel.Debug); - } - catch (Exception ex) - { - Log.ConsoleLog("Draw a frame failed.\r\n" + ex, Log.LogType.Drawing, Log.LogLevel.Debug); - } - - - if (DateTime.Now.Ticks > LastDrawingTimeStamp) LastDrawingTimeStamp = DateTime.Now.Ticks; + RequestDraw(thisReader.EditorTime, title, "Drawing"); } @@ -744,6 +862,11 @@ private async void Form1_FormClosing(object sender, FormClosingEventArgs e) healthMonitor?.Dispose(); healthMonitor = null; + // 保存开关状态与停靠 / 浮动位置,并释放浮窗(释放时会把开关条放回停靠行) + quickToggleDocking?.SaveState(); + quickToggleDocking?.Dispose(); + quickToggleDocking = null; + if (app.Default.Bookmark_RegisterHotKey) { GlobalHotkey.UnRegisterGlobalHotKey(this.Handle); @@ -1076,6 +1199,7 @@ public static void ApplyResources(Form form) if (form is Form1 form1) { form1.RestoreTemplateMenuText(); + form1.ApplyQuickToggleLanguage(); } } @@ -1158,15 +1282,21 @@ private void defaultLanguageToolStripMenuItem_Click(object sender, EventArgs e) private async void forceResetStripMenuItem_Click(object sender, EventArgs e) { + Log.Breadcrumb("Manual reset requested."); _committed = new CommittedState(); _rebuildGeneration++; _rebuildTask = null; _rebuildRetryTicks = 0; - editorReaderHelper = new(); + // 强制丢弃进程/编辑器绑定与全部缓存,并立即重新查找 osu! 进程、重扫编辑器地址。 + // 此前只是 editorReaderHelper = new(),而 EditorReader 是静态实例, + // 编辑器地址/扫描退避表都还在,卡住时按 Reset 等于没按。 + editorReaderHelper.ForceRebind(resetRebindBackoff: true); await runner.StopAsync(); - runner = new PeriodicTaskRunner(app.Default.Idle_Interval, app.Default.Idle_Interval, reader_timer_Work); + // 恢复正常的绘制间隔:此前用 Idle_Interval 重建 runner, + // 于是 Reset 之后预览会变成每 3 秒才刷新一次 + runner = new PeriodicTaskRunner(app.Default.Drawing_Interval, app.Default.Idle_Interval, reader_timer_Work); runner.Start(); } @@ -1275,6 +1405,169 @@ private void RestoreTemplateMenuText() unloadTemplateStripMenuItem.Text = (templateData != null) ? baseText + " (" + templateData.Filename + ")" : baseText; } + /// + /// 创建快捷开关条(菜单栏正下方的工具栏行):吸附在工具栏与拖出为浮动小窗口之间切换。 + /// + private void CreateQuickToggleBar() + { + quickToggleBar = new QuickToggleBar(); + // 先于停靠管理器订阅:停靠管理器在自己的 ToggleChanged 处理里统一保存设置, + // 这里先更新运行状态,保证同一次保存里带上的都是最新状态 + quickToggleBar.ToggleChanged += quickToggleBar_ToggleChanged; + + quickToggleDockRow = new QuickToggleDockRow(); + quickToggleDocking = new QuickToggleDocking(this, quickToggleBar, quickToggleDockRow, menuStrip1); + + QuickToggleDocking docking = quickToggleDocking; + docking.StateChanged += (sender, e) => + { + if (quickToggleStripMenuItem != null) quickToggleStripMenuItem.Checked = docking.BarVisible; + }; + + // 停靠行要排在菜单栏之后、画布之前参与停靠布局: + // WinForms 按控件集合的倒序布局停靠控件(集合末尾的控件最先被安排), + // 把行插到菜单栏原来的位置上,它才会正好占据菜单栏下方的一行, + // 而画布(Dock=Fill)在其下方填满剩余空间;否则行会盖在画布上而不是挤开画布。 + Controls.Add(quickToggleDockRow); + Controls.SetChildIndex(quickToggleDockRow, Controls.GetChildIndex(menuStrip1)); + + CreateQuickToggleMenu(); + } + + /// + /// 创建快捷开关条上的即时开关。开关内容后续继续在这里添加。 + /// + private void CreateQuickToggles() + { + if (quickToggleBar == null) return; + + (string text, string toolTip) = FreezePreviewTimeText(previewTimeFrozen); + quickToggleBar.AddToggle(FreezePreviewTimeKey, text, false); + quickToggleBar.SetToggleText(FreezePreviewTimeKey, text, toolTip); + } + + /// + /// “固定预览时刻”开关在当前语言下的按钮文本与提示。 + /// 按钮文本按“点击后会做什么”显示(与右端“浮动/吸附”按钮一致): + /// 跟随中显示 ⏸️(点击固定),已固定显示 ▶️(点击恢复跟随)。 + /// + private static (string Text, string ToolTip) FreezePreviewTimeText(bool frozen) + { + bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh"; + string text = frozen ? "▶️" : "⏸️"; + string toolTip = frozen + ? (chinese ? "恢复跟随 editor 时刻" : "Resume following the editor time") + : (chinese + ? "固定预览时刻:预览停在当前 editor 时刻(距离辅助线仍跟随 editor 实时时刻)" + : "Freeze the preview at the current editor time (the distance helper still follows the editor)"); + return (text, toolTip); + } + + private void quickToggleBar_ToggleChanged(object? sender, QuickToggleChangedEventArgs e) + { + if (e.Key == FreezePreviewTimeKey) SetPreviewTimeFrozen(e.IsChecked); + } + + /// + /// 切换“固定预览时刻”:打开后预览画布钉在当前 editor 时刻不再跟随,关闭后恢复跟随。 + /// 只影响预览时刻(); + /// 距离辅助线用的是始终跟随编辑器的 ,不受影响。 + /// + private void SetPreviewTimeFrozen(bool frozen) + { + previewTimeFrozen = frozen; + previewTimePinPending = frozen; + // 判定线据此改为跟随 editor 时刻在画面上的位置 + drawingHelper.FixedPreviewTime = frozen; + + // 按钮文本/提示跟随状态:⏸ 表示点击后固定,▶ 表示点击后恢复跟随 + (string text, string toolTip) = FreezePreviewTimeText(frozen); + quickToggleBar?.SetToggleText(FreezePreviewTimeKey, text, toolTip); + + if (!frozen) + { + Log.ConsoleLog("Preview time follows editor again.", Log.LogType.Drawing, Log.LogLevel.Info); + return; + } + + if (editorTimeAvailable) + { + // 已经读到过 editor 时刻:立刻钉住(即使随后还有一帧在路上,时刻也只差一帧,约 20ms) + previewTimePinPending = false; + drawingHelper.CurrentTime = drawingHelper.EditorTime; + Log.ConsoleLog("Preview time frozen at " + drawingHelper.EditorTime.ToString("F0") + " ms.", Log.LogType.Drawing, Log.LogLevel.Info); + } + else + { + // 还没读到过 editor 时刻(例如启动时恢复固定状态):等第一帧读到时刻时再钉住,避免固定到 0 + Log.ConsoleLog("Preview time freeze is waiting for the first editor time.", Log.LogType.Drawing, Log.LogLevel.Info); + } + } + + /// + /// 用一帧读到的 editor 时刻更新绘制时间: + /// 始终跟随编辑器; + /// 预览时刻只在正常模式(未固定)或刚打开固定模式(需要钉住)时更新。 + /// 固定模式下预览时刻不连续跟随,而是在 editor 位置(判定线)越出画面时才整页翻页, + /// 见 。 + /// + private void ApplyEditorTime(int editorTime) + { + drawingHelper.EditorTime = editorTime; + editorTimeAvailable = true; + + if (!previewTimeFrozen || previewTimePinPending) + { + // 正常跟随;或刚打开固定模式:把预览钉在这一刻 + previewTimePinPending = false; + drawingHelper.CurrentTime = editorTime; + return; + } + + if (drawingHelper.PageToKeepEditorVisible(Canvas.VisibleTopY, Canvas.VisibleBottomY)) + { + Log.ConsoleLog("Preview time paged to keep editor position visible.", Log.LogType.Drawing, Log.LogLevel.Info); + } + } + + /// + /// 程序内创建“快捷开关栏”菜单项(显示 / 隐藏整行)。 + /// + private void CreateQuickToggleMenu() + { + quickToggleStripMenuItem = new ToolStripMenuItem + { + Name = "quickToggleStripMenuItem", + Checked = true, + }; + quickToggleStripMenuItem.Click += quickToggleStripMenuItem_Click; + + // 归入 Viewer 菜单的显示选项分组(分隔线之前的最后一项) + int insertIndex = viewerToolStripMenuItem.DropDownItems.IndexOf(toolStripSeparator1); + if (insertIndex < 0) insertIndex = viewerToolStripMenuItem.DropDownItems.Count; + viewerToolStripMenuItem.DropDownItems.Insert(insertIndex, quickToggleStripMenuItem); + } + + private void quickToggleStripMenuItem_Click(object? sender, EventArgs e) + { + if (quickToggleDocking == null) return; + + bool visible = !quickToggleDocking.BarVisible; + quickToggleDocking.BarVisible = visible; + if (quickToggleStripMenuItem != null) quickToggleStripMenuItem.Checked = visible; + quickToggleDocking.SaveState(); + app.Default.Save(); + } + + /// 语言切换后刷新快捷开关条上的固定文本。 + private void ApplyQuickToggleLanguage() + { + quickToggleDocking?.ApplyLanguage(); + + (string text, string toolTip) = FreezePreviewTimeText(previewTimeFrozen); + quickToggleBar?.SetToggleText(FreezePreviewTimeKey, text, toolTip); + } + /// /// 解析模板 .osu 文件并转换为可接物件(在后台线程执行)。 /// 只读使用,不影响主谱面的解析/转换时序。 diff --git a/osucatch-editor-realtimeviewer/Form1.resx b/osucatch-editor-realtimeviewer/Form1.resx index 9b7d4f0..739f53b 100644 --- a/osucatch-editor-realtimeviewer/Form1.resx +++ b/osucatch-editor-realtimeviewer/Form1.resx @@ -1006,4 +1006,7 @@ &Unload Template + + &Quick Toggle Bar + diff --git a/osucatch-editor-realtimeviewer/Form1.zh-Hans.resx b/osucatch-editor-realtimeviewer/Form1.zh-Hans.resx index d985752..3a2a084 100644 --- a/osucatch-editor-realtimeviewer/Form1.zh-Hans.resx +++ b/osucatch-editor-realtimeviewer/Form1.zh-Hans.resx @@ -442,4 +442,7 @@ 卸载模板 + + 快捷开关栏 (&Q) + diff --git a/osucatch-editor-realtimeviewer/ProcessFocus.cs b/osucatch-editor-realtimeviewer/ProcessFocus.cs index cedb23f..db13e5d 100644 --- a/osucatch-editor-realtimeviewer/ProcessFocus.cs +++ b/osucatch-editor-realtimeviewer/ProcessFocus.cs @@ -46,7 +46,8 @@ public static bool IsEditorForeground(int? osuProcessId) /// /// 鼠标是否正在移动(通过轮询光标位置变化判断)。 - /// 编辑器操作通常是"悬停到目标位置再点击",鼠标移动期间需要实时刷新预览。 + /// 编辑器操作通常是"悬停到目标位置再点击",鼠标移动期间需要按 FullRead_Interval + /// 保持预览的高刷新率(默认 20ms,接近 60fps 的跟随手感)。 /// public static bool IsMouseMoving() { diff --git a/osucatch-editor-realtimeviewer/QuickToggleBar.cs b/osucatch-editor-realtimeviewer/QuickToggleBar.cs new file mode 100644 index 0000000..459b710 --- /dev/null +++ b/osucatch-editor-realtimeviewer/QuickToggleBar.cs @@ -0,0 +1,379 @@ +using System.Text; + +namespace osucatch_editor_realtimeviewer +{ + /// + /// 快捷开关条:类似 的工具条控件。 + /// 它既能吸附在主窗口菜单栏下方的一行里,也能拖出来变成浮动小窗口: + /// 按住左侧手柄(或条上的空白处)拖动即可拖出 / 拖回,双击手柄或点击右端按钮同样可以切换。 + /// + /// + /// 开关内容由调用方添加,例如: + /// + /// quickToggleBar.AddToggle("ShowDistanceLabel", "距离标签", app.Default.Show_Distance_Helper); + /// quickToggleBar.ToggleChanged += (s, e) => { /* 立即生效 */ }; + /// + /// 勾选状态可通过 / 持久化到用户设置。 + /// + internal sealed class QuickToggleBar : ToolStrip + { + /// 拖动判定阈值(屏幕像素):小于该距离视为普通点击,不会拖出浮窗。 + private const int DragThreshold = 4; + + /// 拖动轮询间隔(毫秒)。这里用轮询而不是鼠标捕获:条拖出时会被移到另一个窗体里, + /// 重新挂父窗口可能丢失鼠标捕获,轮询鼠标物理状态更可靠(Wine 下同样可用)。 + private const int DragPollIntervalMs = 15; + + private readonly Dictionary toggles = new(StringComparer.Ordinal); + + /// 浮窗/吸附切换按钮:手柄之外的第二种操作方式,便于发现该功能。 + private readonly ToolStripButton floatingButton = new(); + + private readonly ContextMenuStrip barContextMenu = new(); + private readonly ToolStripMenuItem floatMenuItem = new(); + private readonly ToolStripMenuItem hideMenuItem = new(); + + private readonly System.Windows.Forms.Timer dragTimer = new() { Interval = DragPollIntervalMs }; + + private Point dragStartCursor; + private Point dragGrabOffset; + private bool dragArmed; + private bool dragging; + private long lastDragAreaClickTicks; + private Point lastDragAreaClickPoint; + private bool suppressToggleEvents; + private string title = "Quick Toggles"; + + internal QuickToggleBar() + { + Name = "quickToggleBar"; + GripStyle = ToolStripGripStyle.Visible; + // 尺寸由宿主(停靠行 / 浮窗)统一管理,避免 AutoSize 与宿主布局互相打架 + AutoSize = false; + Dock = DockStyle.None; + TabStop = false; + CanOverflow = true; + + floatingButton.Name = "quickToggleFloatingButton"; + floatingButton.DisplayStyle = ToolStripItemDisplayStyle.Text; + floatingButton.Alignment = ToolStripItemAlignment.Right; + // 窗口很窄时右端按钮不能被收进溢出菜单,否则没法把条吸附回来 + floatingButton.Overflow = ToolStripItemOverflow.Never; + floatingButton.AutoToolTip = false; + floatingButton.Click += (sender, e) => FloatingToggleRequested?.Invoke(this, EventArgs.Empty); + Items.Add(floatingButton); + + floatMenuItem.Name = "quickToggleFloatMenuItem"; + floatMenuItem.Click += (sender, e) => FloatingToggleRequested?.Invoke(this, EventArgs.Empty); + hideMenuItem.Name = "quickToggleHideMenuItem"; + hideMenuItem.Click += (sender, e) => HideRequested?.Invoke(this, EventArgs.Empty); + barContextMenu.Items.Add(floatMenuItem); + barContextMenu.Items.Add(hideMenuItem); + ContextMenuStrip = barContextMenu; + + dragTimer.Tick += DragTimer_Tick; + + ApplyLanguage(); + } + + #region 开关内容 + + /// 开关被用户勾选/取消时触发(从设置恢复时不触发)。 + internal event EventHandler? ToggleChanged; + + /// 开关增删导致内容变化时触发:宿主据此重新计算停靠行高度 / 浮窗尺寸。 + internal event EventHandler? ContentChanged; + + /// 向条上添加一个即时开关,返回可继续定制(图标、提示等)的按钮。 + /// 开关标识,用于读写状态与持久化,需保持稳定。 + /// 按钮文本,后续语言资源可按 覆盖。 + /// 初始勾选状态。 + internal ToolStripButton AddToggle(string key, string text, bool isChecked = false) + { + ArgumentException.ThrowIfNullOrEmpty(key); + + if (toggles.TryGetValue(key, out ToolStripButton? existing)) return existing; + + ToolStripButton item = new() + { + Name = key, + Text = text, + ToolTipText = text, + AutoToolTip = false, + CheckOnClick = true, + Checked = isChecked, + DisplayStyle = ToolStripItemDisplayStyle.Text, + Alignment = ToolStripItemAlignment.Left, + }; + item.CheckedChanged += (sender, e) => + { + if (suppressToggleEvents) return; + ToggleChanged?.Invoke(this, new QuickToggleChangedEventArgs(key, item.Checked)); + }; + + toggles[key] = item; + Items.Add(item); + ContentChanged?.Invoke(this, EventArgs.Empty); + return item; + } + + /// 移除一个开关。 + internal void RemoveToggle(string key) + { + if (!toggles.Remove(key, out ToolStripButton? item)) return; + Items.Remove(item); + item.Dispose(); + ContentChanged?.Invoke(this, EventArgs.Empty); + } + + /// 读取开关的勾选状态;开关不存在时返回 false。 + internal bool IsChecked(string key) => toggles.TryGetValue(key, out ToolStripButton? item) && item.Checked; + + /// + /// 更新开关按钮的文本与提示(语言切换等场景), + /// 并通知宿主重新计算浮窗尺寸(条自身不自动调整大小)。 + /// + internal void SetToggleText(string key, string text, string? toolTip = null) + { + if (!toggles.TryGetValue(key, out ToolStripButton? item)) return; + + item.Text = text; + item.ToolTipText = toolTip ?? text; + item.AutoToolTip = false; + ContentChanged?.Invoke(this, EventArgs.Empty); + } + + /// 设置开关的勾选状态(会触发 )。 + internal void SetChecked(string key, bool isChecked) + { + if (toggles.TryGetValue(key, out ToolStripButton? item)) item.Checked = isChecked; + } + + /// 把所有开关状态序列化成可写入设置的字符串。 + internal string GetCheckedStates() + { + StringBuilder builder = new(); + foreach (KeyValuePair pair in toggles) + { + if (builder.Length > 0) builder.Append(';'); + builder.Append(pair.Key).Append('=').Append(pair.Value.Checked ? "True" : "False"); + } + return builder.ToString(); + } + + /// 从设置字符串恢复开关状态(不触发 ,避免启动时重复应用)。 + internal void ApplyCheckedStates(string? states) + { + if (string.IsNullOrEmpty(states)) return; + + suppressToggleEvents = true; + try + { + foreach (string entry in states.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + int separator = entry.IndexOf('='); + if (separator <= 0) continue; + + string key = entry[..separator].Trim(); + string value = entry[(separator + 1)..].Trim(); + if (toggles.TryGetValue(key, out ToolStripButton? item) && bool.TryParse(value, out bool isChecked)) + { + item.Checked = isChecked; + } + } + } + finally + { + suppressToggleEvents = false; + } + } + + #endregion + + #region 停靠 / 浮动 + + /// 请求把条拖出为浮窗 / 吸附回工具栏(双击手柄、点击右端按钮或右键菜单)。 + internal event EventHandler? FloatingToggleRequested; + + /// 请求隐藏整个快捷开关栏。 + internal event EventHandler? HideRequested; + + /// 开始拖动(需要弹出为浮窗)时触发。 + internal event EventHandler? DragStarted; + + /// 拖动过程中触发。 + internal event EventHandler? DragMoved; + + /// 拖动结束(鼠标左键抬起)时触发。 + internal event EventHandler? DragEnded; + + /// 当前是否处于浮动小窗口状态,由停靠管理器维护。 + internal bool IsFloating { get; set; } + + /// 浮窗标题 / 提示用的名称。 + internal string BarTitle => title; + + /// 浮窗尺寸:按内容计算,并限制在工作区之内。 + internal Size PreferredFloatSize() + { + Size preferred = GetPreferredSize(new Size(4096, 0)); + Rectangle workingArea = Screen.FromControl(this).WorkingArea; + return new Size( + Math.Max(96, Math.Min(preferred.Width, workingArea.Width)), + Math.Max(24, Math.Min(preferred.Height, workingArea.Height))); + } + + /// 按当前语言刷新条上的固定文本(按钮、右键菜单、浮窗标题)。 + internal void ApplyLanguage() + { + bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh"; + + title = chinese ? "快捷开关栏" : "Quick Toggle Bar"; + floatingButton.Text = IsFloating ? (chinese ? "吸附" : "Dock") : (chinese ? "浮动" : "Float"); + floatingButton.ToolTipText = chinese + ? "浮动为小窗口 / 吸附回工具栏(也可拖动左侧手柄或双击)" + : "Float as a window / dock back to the toolbar (or drag the grip)"; + floatMenuItem.Text = IsFloating + ? (chinese ? "吸附到工具栏" : "Dock to toolbar") + : (chinese ? "浮动为小窗口" : "Float as window"); + hideMenuItem.Text = chinese ? "隐藏快捷开关栏" : "Hide quick toggle bar"; + + if (Parent is QuickToggleFloatForm floatForm) floatForm.Text = title; + } + + #endregion + + #region 拖出手柄 + + /// 手柄或空白处都算拖动区:手柄太窄时用户也能轻松把条拖出来。 + private bool IsDragArea(Point location) + { + if (GripStyle == ToolStripGripStyle.Visible && GripRectangle.Contains(location)) return true; + return GetItemAt(location.X, location.Y) == null; + } + + protected override void OnMouseDown(MouseEventArgs mea) + { + base.OnMouseDown(mea); + + if (mea.Button != MouseButtons.Left || !IsDragArea(mea.Location)) return; + + long now = DateTime.UtcNow.Ticks; + long doubleClickTicks = TimeSpan.FromMilliseconds(SystemInformation.DoubleClickTime).Ticks; + Size doubleClickSize = SystemInformation.DoubleClickSize; + bool isDoubleClick = now - lastDragAreaClickTicks <= doubleClickTicks + && Math.Abs(mea.X - lastDragAreaClickPoint.X) <= doubleClickSize.Width + && Math.Abs(mea.Y - lastDragAreaClickPoint.Y) <= doubleClickSize.Height; + + lastDragAreaClickTicks = now; + lastDragAreaClickPoint = mea.Location; + + if (isDoubleClick) + { + // 双击手柄:停靠 <-> 浮动 + lastDragAreaClickTicks = 0; + FloatingToggleRequested?.Invoke(this, EventArgs.Empty); + return; + } + + dragArmed = true; + dragging = false; + dragStartCursor = Cursor.Position; + Point barScreen = PointToScreen(Point.Empty); + dragGrabOffset = new Point(dragStartCursor.X - barScreen.X, dragStartCursor.Y - barScreen.Y); + dragTimer.Start(); + } + + private void DragTimer_Tick(object? sender, EventArgs e) + { + if (!dragArmed && !dragging) + { + dragTimer.Stop(); + return; + } + + if ((Control.MouseButtons & MouseButtons.Left) == 0) + { + EndDragSession(); + return; + } + + Point cursor = Cursor.Position; + if (!dragging) + { + if (Math.Abs(cursor.X - dragStartCursor.X) < DragThreshold && + Math.Abs(cursor.Y - dragStartCursor.Y) < DragThreshold) + { + return; + } + + dragging = true; + DragStarted?.Invoke(this, new QuickToggleDragEventArgs(cursor, dragGrabOffset)); + } + + DragMoved?.Invoke(this, new QuickToggleDragEventArgs(cursor, dragGrabOffset)); + } + + private void EndDragSession() + { + dragTimer.Stop(); + bool wasDragging = dragging; + dragging = false; + dragArmed = false; + if (wasDragging) DragEnded?.Invoke(this, new QuickToggleDragEventArgs(Cursor.Position, dragGrabOffset)); + } + + protected override void OnMouseCaptureChanged(EventArgs e) + { + base.OnMouseCaptureChanged(e); + // 条被移到浮窗里时可能丢失捕获:这里只做兜底,正常结束依赖上面的轮询 + if (!dragging && !dragArmed) return; + if ((Control.MouseButtons & MouseButtons.Left) == 0) EndDragSession(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + dragTimer.Stop(); + dragTimer.Dispose(); + barContextMenu.Dispose(); + } + base.Dispose(disposing); + } + + #endregion + } + + /// 开关状态变化事件参数。 + internal sealed class QuickToggleChangedEventArgs : EventArgs + { + internal QuickToggleChangedEventArgs(string key, bool isChecked) + { + Key = key; + IsChecked = isChecked; + } + + /// 开关标识( 中传入的 key)。 + internal string Key { get; } + + /// 新的勾选状态。 + internal bool IsChecked { get; } + } + + /// 拖动事件参数,坐标均为屏幕坐标。 + internal sealed class QuickToggleDragEventArgs : EventArgs + { + internal QuickToggleDragEventArgs(Point cursorScreen, Point grabOffset) + { + CursorScreen = cursorScreen; + GrabOffset = grabOffset; + } + + /// 当前鼠标位置(屏幕坐标)。 + internal Point CursorScreen { get; } + + /// 鼠标相对条左上角的偏移(屏幕坐标差值),拖出浮窗时用它保持鼠标抓住的位置不变。 + internal Point GrabOffset { get; } + } +} diff --git a/osucatch-editor-realtimeviewer/QuickToggleDocking.cs b/osucatch-editor-realtimeviewer/QuickToggleDocking.cs new file mode 100644 index 0000000..d19f6f2 --- /dev/null +++ b/osucatch-editor-realtimeviewer/QuickToggleDocking.cs @@ -0,0 +1,529 @@ +namespace osucatch_editor_realtimeviewer +{ + /// + /// 快捷开关条的停靠行:吸附时占据菜单栏正下方的一行;浮动或隐藏时高度归零, + /// 配合 = false 完全不占用画布空间。 + /// + internal sealed class QuickToggleDockRow : Panel + { + private QuickToggleBar? bar; + + internal QuickToggleDockRow() + { + Name = "quickToggleDockRow"; + Dock = DockStyle.Top; + AutoSize = false; + Height = 0; + Margin = Padding.Empty; + Padding = Padding.Empty; + TabStop = false; + } + + internal bool HasBar => bar != null && bar.Parent == this; + + protected override void OnControlAdded(ControlEventArgs e) + { + base.OnControlAdded(e); + if (e.Control is QuickToggleBar added) bar = added; + PerformLayout(); + } + + protected override void OnControlRemoved(ControlEventArgs e) + { + base.OnControlRemoved(e); + if (e.Control != bar) return; + bar = null; + if (Height != 0) Height = 0; + } + + protected override void OnLayout(LayoutEventArgs e) + { + base.OnLayout(e); + if (bar == null || bar.Parent != this) return; + + // 行高由条的首选高度决定;条本身不自动调整大小,宽度始终铺满窗口 + int height = Math.Max(bar.PreferredSize.Height, 1); + bar.Bounds = new Rectangle(0, 0, Math.Max(Width, 1), height); + if (Height != height) Height = height; + } + } + + /// 浮动小窗口:浮动状态下承载快捷开关条。 + internal sealed class QuickToggleFloatForm : Form + { + private readonly QuickToggleBar bar; + + internal QuickToggleFloatForm(QuickToggleBar bar) + { + this.bar = bar; + + Name = "quickToggleFloatForm"; + Text = bar.BarTitle; + FormBorderStyle = FormBorderStyle.FixedToolWindow; + ShowInTaskbar = false; + MinimizeBox = false; + MaximizeBox = false; + StartPosition = FormStartPosition.Manual; + // 主窗口按 DPI 自行缩放,这里不再自动缩放,避免高 DPI 下被放大两次 + AutoScaleMode = AutoScaleMode.None; + Controls.Add(bar); + Refit(); + } + + /// 让窗口贴合开关条尺寸(开关数量变化时也会调用)。 + internal void Refit() + { + Size size = bar.PreferredFloatSize(); + if (ClientSize != size) ClientSize = size; + } + + protected override void OnLayout(LayoutEventArgs levent) + { + base.OnLayout(levent); + bar.Bounds = new Rectangle(Point.Empty, ClientSize); + } + } + + /// 拖动浮窗时提示吸附目标位置的无边框小窗(不抢焦点)。 + internal sealed class QuickToggleDropHintForm : Form + { + internal QuickToggleDropHintForm() + { + Name = "quickToggleDropHintForm"; + FormBorderStyle = FormBorderStyle.None; + ShowInTaskbar = false; + StartPosition = FormStartPosition.Manual; + BackColor = SystemColors.Highlight; + Opacity = 0.55; + TopMost = true; + AutoScaleMode = AutoScaleMode.None; + } + + // 拖动过程中弹出提示窗不应打断鼠标交互 + protected override bool ShowWithoutActivation => true; + + protected override CreateParams CreateParams + { + get + { + CreateParams cp = base.CreateParams; + cp.ExStyle |= 0x08000000; // WS_EX_NOACTIVATE + cp.ExStyle |= 0x00000080; // WS_EX_TOOLWINDOW + return cp; + } + } + } + + /// + /// 快捷开关条的停靠 / 浮动管理器:在“菜单栏下方的停靠行”和“浮动小窗口”之间搬运开关条, + /// 处理拖出、拖回时的吸附判定,并把停靠状态与开关状态持久化到用户设置。 + /// + internal sealed class QuickToggleDocking : IDisposable + { + /// 吸附判定距离(屏幕像素):拖到停靠行 / 菜单栏附近这么多像素内松手即吸附回去。 + private const int SnapDistance = 32; + + /// 吸附提示条高度(屏幕像素)。 + private const int HintHeight = 6; + + private readonly Form owner; + private readonly QuickToggleBar bar; + private readonly QuickToggleDockRow dockRow; + private readonly Control? snapAnchor; + private readonly QuickToggleFloatForm floatForm; + private readonly QuickToggleDropHintForm hintForm; + + private bool barVisible = true; + private bool floating; + private bool dragging; + private bool disposed; + private bool suppressReDock; + + private Point dragOriginCursor; + private Point dragOriginFormLocation; + + internal QuickToggleDocking(Form owner, QuickToggleBar bar, QuickToggleDockRow dockRow, Control? snapAnchor) + { + this.owner = owner; + this.bar = bar; + this.dockRow = dockRow; + this.snapAnchor = snapAnchor; + + floatForm = new QuickToggleFloatForm(bar); + hintForm = new QuickToggleDropHintForm(); + + // 浮窗 / 提示窗都归属主窗口:主窗口置顶时一起置顶,主窗口关闭时一起关闭 + floatForm.Owner = owner; + floatForm.FormClosing += FloatForm_FormClosing; + hintForm.Owner = owner; + + bar.DragStarted += Bar_DragStarted; + bar.DragMoved += Bar_DragMoved; + bar.DragEnded += Bar_DragEnded; + bar.FloatingToggleRequested += Bar_FloatingToggleRequested; + bar.HideRequested += Bar_HideRequested; + bar.ContentChanged += Bar_ContentChanged; + bar.ToggleChanged += Bar_ToggleChanged; + + dockRow.Controls.Add(bar); + } + + /// 停靠状态(停靠 / 浮动 / 显示 / 隐藏)发生变化时触发。 + internal event EventHandler? StateChanged; + + /// 当前是否浮动为小窗口。 + internal bool IsFloating => floating; + + /// 快捷开关栏是否显示(隐藏时停靠行与浮窗都不占屏幕空间)。 + internal bool BarVisible + { + get => barVisible; + set + { + if (disposed || barVisible == value) return; + barVisible = value; + ApplyVisibility(); + SaveState(); + RaiseStateChanged(); + } + } + + /// + /// 按用户设置恢复启动状态。 为 true 时等主窗口显示出来之后再弹出浮窗, + /// 避免启动过程中浮窗出现在错误的位置。 + /// + internal void ApplyStartupState(bool visible, bool floatMode) + { + if (disposed) return; + + barVisible = visible; + if (!visible || !floatMode) + { + DockCore(); + return; + } + + owner.BeginInvoke(new Action(() => + { + if (disposed) return; + FloatCore(SavedFloatLocation()); + })); + } + + /// 吸附回工具栏。 + internal void Dock() + { + if (disposed) return; + DockCore(); + SaveState(); + } + + /// 浮动为小窗口; 为空时沿用上次保存的位置。 + internal void Float(Point? location = null) + { + if (disposed) return; + FloatCore(location ?? SavedFloatLocation()); + SaveState(); + } + + /// 在停靠与浮动之间切换。 + internal void ToggleFloating() + { + if (floating) Dock(); + else Float(); + } + + /// 主窗口置顶状态变化时同步浮窗,保证浮窗与预览窗口一起显示在编辑器之上。 + internal void SyncTopMost(bool topMost) + { + if (disposed || !floating || floatForm.IsDisposed) return; + if (floatForm.TopMost != topMost) floatForm.TopMost = topMost; + } + + /// 按当前语言刷新条与浮窗上的文本。 + internal void ApplyLanguage() + { + bar.ApplyLanguage(); + if (!floatForm.IsDisposed) floatForm.Text = bar.BarTitle; + } + + /// + /// 把停靠状态写入用户设置(不调用 app.Default.Save(),由调用方统一保存)。 + /// + internal void SaveState() + { + if (disposed) return; + + app.Default.QuickToggle_Visible = barVisible; + app.Default.QuickToggle_Floating = floating; + if (floating && floatForm.Visible) + { + app.Default.QuickToggle_Float_X = floatForm.Location.X; + app.Default.QuickToggle_Float_Y = floatForm.Location.Y; + } + app.Default.QuickToggle_States = bar.GetCheckedStates(); + } + + #region 停靠 / 浮动切换 + + private void DockCore() + { + MoveBar(dockRow); + floating = false; + if (floatForm.Visible) floatForm.Hide(); + dockRow.Visible = barVisible; + bar.IsFloating = false; + bar.ApplyLanguage(); + RaiseStateChanged(); + } + + private void FloatCore(Point? location) + { + // 弹出前记下条在屏幕上的位置:用作“就地在工具栏下方弹出”的默认位置 + Rectangle barScreenBefore = bar.IsHandleCreated ? bar.RectangleToScreen(bar.ClientRectangle) : Rectangle.Empty; + + MoveBar(floatForm); + floating = true; + floatForm.Refit(); + + floatForm.Location = ClampToScreen(location ?? DefaultFloatLocation(barScreenBefore), floatForm.Size); + floatForm.Show(); + SyncTopMost(owner.TopMost); + + dockRow.Visible = false; + bar.IsFloating = true; + bar.ApplyLanguage(); + RaiseStateChanged(); + } + + private void ApplyVisibility() + { + if (floating) + { + if (barVisible) FloatCore(SavedFloatLocation() ?? floatForm.Location); + else floatForm.Hide(); + dockRow.Visible = false; + } + else + { + dockRow.Visible = barVisible; + } + } + + private void MoveBar(Control parent) + { + if (bar.Parent == parent) return; + // 先摘再挂:停靠行会在移除时把高度归零,加入时按条的首选高度重新撑开 + bar.Parent?.Controls.Remove(bar); + parent.Controls.Add(bar); + parent.PerformLayout(); + } + + private Point DefaultFloatLocation(Rectangle barScreenBefore) + { + if (barScreenBefore.IsEmpty) return floatForm.Location; + return new Point(barScreenBefore.X, barScreenBefore.Bottom + 4); + } + + private static Point ClampToScreen(Point location, Size size) + { + Rectangle area = Screen.FromPoint(location).WorkingArea; + int x = Math.Clamp(location.X, area.Left - size.Width + 60, area.Right - 60); + int y = Math.Clamp(location.Y, area.Top, area.Bottom - 30); + return new Point(x, y); + } + + private Point? SavedFloatLocation() + { + int x = app.Default.QuickToggle_Float_X; + int y = app.Default.QuickToggle_Float_Y; + if (x < 0 || y < 0) return null; + + // 上次的浮窗位置可能已经不在任何屏幕内(拔掉显示器 / 改了分辨率),此时回退到默认位置 + bool onScreen = false; + foreach (Screen screen in Screen.AllScreens) + { + if (screen.WorkingArea.Contains(x + 20, y + 5)) + { + onScreen = true; + break; + } + } + return onScreen ? new Point(x, y) : null; + } + + #endregion + + #region 拖动吸附 + + private void Bar_DragStarted(object? sender, QuickToggleDragEventArgs e) + { + if (disposed) return; + dragging = true; + + if (floating) + { + dragOriginCursor = e.CursorScreen; + dragOriginFormLocation = floatForm.Location; + } + else + { + // 从停靠状态拖出:先弹出为浮窗,再整体平移,使鼠标仍抓住条上的同一点 + FloatCore(null); + Point desiredBarScreen = new(e.CursorScreen.X - e.GrabOffset.X, e.CursorScreen.Y - e.GrabOffset.Y); + Point currentBarScreen = bar.PointToScreen(Point.Empty); + floatForm.Location = new Point( + floatForm.Location.X + desiredBarScreen.X - currentBarScreen.X, + floatForm.Location.Y + desiredBarScreen.Y - currentBarScreen.Y); + dragOriginCursor = e.CursorScreen; + dragOriginFormLocation = floatForm.Location; + } + + UpdateHint(IsInSnapZone(e.CursorScreen)); + } + + private void Bar_DragMoved(object? sender, QuickToggleDragEventArgs e) + { + if (disposed || !dragging) return; + + Point location = new( + dragOriginFormLocation.X + e.CursorScreen.X - dragOriginCursor.X, + dragOriginFormLocation.Y + e.CursorScreen.Y - dragOriginCursor.Y); + if (floatForm.Location != location) floatForm.Location = location; + + UpdateHint(IsInSnapZone(e.CursorScreen)); + } + + private void Bar_DragEnded(object? sender, QuickToggleDragEventArgs e) + { + if (disposed) return; + dragging = false; + UpdateHint(false); + + if (IsInSnapZone(e.CursorScreen)) + { + Dock(); + return; + } + + // 停在屏幕外会让用户找不回浮窗,松手时夹回可见区域 + floatForm.Location = ClampToScreen(floatForm.Location, floatForm.Size); + SaveState(); + } + + /// 拖到停靠行所在的一行(或菜单栏上)即视为要吸附回去。 + private bool IsInSnapZone(Point cursorScreen) + { + Rectangle target = DockTargetScreenRect(); + target.Inflate(0, SnapDistance); + if (target.Contains(cursorScreen)) return true; + + Control? anchor = snapAnchor; + return anchor != null && anchor.IsHandleCreated && + anchor.RectangleToScreen(anchor.ClientRectangle).Contains(cursorScreen); + } + + /// 吸附目标矩形:菜单栏正下方、与主窗口同宽的一行(屏幕坐标)。 + private Rectangle DockTargetScreenRect() + { + int menuBottom = snapAnchor != null ? snapAnchor.Bottom : 0; + Point topLeft = owner.PointToScreen(new Point(0, menuBottom)); + int height = Math.Max(bar.PreferredSize.Height, 1); + return new Rectangle(topLeft, new Size(owner.ClientSize.Width, height)); + } + + private void UpdateHint(bool show) + { + if (show) + { + Rectangle target = DockTargetScreenRect(); + hintForm.Bounds = new Rectangle(target.X, target.Y, Math.Max(target.Width, 1), HintHeight); + if (!hintForm.Visible) hintForm.Show(); + } + else if (hintForm.Visible) + { + hintForm.Hide(); + } + } + + #endregion + + #region 条的事件 + + private void Bar_FloatingToggleRequested(object? sender, EventArgs e) + { + ToggleFloating(); + } + + private void Bar_HideRequested(object? sender, EventArgs e) + { + BarVisible = false; + } + + private void Bar_ContentChanged(object? sender, EventArgs e) + { + // 条不自动调整大小,内容变化后由宿主重新计算行高 / 浮窗尺寸 + if (disposed) return; + if (dockRow.HasBar) dockRow.PerformLayout(); + if (floating) floatForm.Refit(); + } + + private void Bar_ToggleChanged(object? sender, QuickToggleChangedEventArgs e) + { + // 即时开关:状态一变就写入设置,不需要等退出程序。 + // 写设置失败(配置文件只读/被占用等)不应让点开关变成崩溃,记录一条日志即可。 + SaveState(); + try + { + app.Default.Save(); + } + catch (Exception ex) + { + Log.ConsoleLog("Save quick toggle state failed.\r\n" + ex, Log.LogType.Program, Log.LogLevel.Warning); + } + } + + private void FloatForm_FormClosing(object? sender, FormClosingEventArgs e) + { + if (disposed || suppressReDock) return; + // 程序退出(含主窗口关闭时自动关闭所属窗口)时正常关闭,不做吸附 + if (e.CloseReason is CloseReason.ApplicationExitCall or CloseReason.WindowsShutDown + or CloseReason.TaskManagerClosing or CloseReason.FormOwnerClosing) + { + return; + } + if (owner.IsDisposed || owner.Disposing) return; + + // 点浮窗的关闭按钮 = 吸附回工具栏(而不是丢失快捷开关条) + e.Cancel = true; + Dock(); + } + + private void RaiseStateChanged() => StateChanged?.Invoke(this, EventArgs.Empty); + + #endregion + + public void Dispose() + { + if (disposed) return; + disposed = true; + suppressReDock = true; + + bar.DragStarted -= Bar_DragStarted; + bar.DragMoved -= Bar_DragMoved; + bar.DragEnded -= Bar_DragEnded; + bar.FloatingToggleRequested -= Bar_FloatingToggleRequested; + bar.HideRequested -= Bar_HideRequested; + bar.ContentChanged -= Bar_ContentChanged; + bar.ToggleChanged -= Bar_ToggleChanged; + + if (hintForm.Visible) hintForm.Hide(); + hintForm.Dispose(); + + // 把条放回停靠行再销毁浮窗:条始终属于主窗口的控件树,不会被浮窗一起销毁 + MoveBar(dockRow); + if (floatForm.Visible) floatForm.Hide(); + floatForm.Dispose(); + } + } +} diff --git a/osucatch-editor-realtimeviewer/SettingsForm.cs b/osucatch-editor-realtimeviewer/SettingsForm.cs index 3d9e16d..48095e4 100644 --- a/osucatch-editor-realtimeviewer/SettingsForm.cs +++ b/osucatch-editor-realtimeviewer/SettingsForm.cs @@ -264,6 +264,8 @@ private void button_LowFreqReadInterval_reset_Click(object sender, EventArgs e) private void button_FullReadInterval_reset_Click(object sender, EventArgs e) { + // 与 app.settings 的默认值保持一致:20ms 对应约 50fps 的跟随手感, + // 调大会让作图时对象数据(拖拽/编辑结果)的反映明显变慢 numericUpDown_FullReadInterval.Value = 20; } } diff --git a/osucatch-editor-realtimeviewer/app.Designer.cs b/osucatch-editor-realtimeviewer/app.Designer.cs index 06ce384..46966d9 100644 --- a/osucatch-editor-realtimeviewer/app.Designer.cs +++ b/osucatch-editor-realtimeviewer/app.Designer.cs @@ -1007,5 +1007,65 @@ public bool Use_Batch_Rendering { } } + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("True")] + public bool QuickToggle_Visible { + get { + return ((bool)(this["QuickToggle_Visible"])); + } + set { + this["QuickToggle_Visible"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("False")] + public bool QuickToggle_Floating { + get { + return ((bool)(this["QuickToggle_Floating"])); + } + set { + this["QuickToggle_Floating"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("-1")] + public int QuickToggle_Float_X { + get { + return ((int)(this["QuickToggle_Float_X"])); + } + set { + this["QuickToggle_Float_X"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("-1")] + public int QuickToggle_Float_Y { + get { + return ((int)(this["QuickToggle_Float_Y"])); + } + set { + this["QuickToggle_Float_Y"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("")] + public string QuickToggle_States { + get { + return ((string)(this["QuickToggle_States"])); + } + set { + this["QuickToggle_States"] = value; + } + } + } } diff --git a/osucatch-editor-realtimeviewer/app.settings b/osucatch-editor-realtimeviewer/app.settings index 1fcc9b8..c209e87 100644 --- a/osucatch-editor-realtimeviewer/app.settings +++ b/osucatch-editor-realtimeviewer/app.settings @@ -248,5 +248,20 @@ False + + True + + + False + + + -1 + + + -1 + + + +