using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Reflection; using System.Runtime.InteropServices; using BepInEx; using BepInEx.Unity.IL2CPP; using HarmonyLib; using Il2CppInterop.Runtime.Injection; using UnityEngine; namespace AnantaSpoonProbe; // AnantaSpoonProbe v1 — read-only Spoon graph oracle for Q1/Q2. // // What it does: // - Harmony PREFIX/POSTFIX on L50.Spoon.SpoonRunTime.SpoonTaskMgr load/deserialize // methods. Every hook only LOGS + DUMPS bytes; it never blocks or alters the call. // - Dumps any byte[] / native-pointer graph payload to BepInEx/dumps/spoon/. // - F8 = safe probe (no state change): path constants + workAction count for 60004938. // - Ctrl+F8 = LoadSingleTask(60004938) attempt with empty md5 (safe-ignore value per R211b). // // What it does NOT do: // - No INativeDetour, no native icall hooks (frozen game twice before). // - No fake md5 (hard-crashes client, R211b). SpoonMd5 is always "". // - No server RPCs. All local. [BepInPlugin("ananta.tools.spoonprobe", "Ananta Spoon Probe", "1.0.1")] public class SpoonProbePlugin : BasePlugin { internal static string DumpDir = ""; internal static string ProbeLog = ""; internal new static BepInEx.Logging.ManualLogSource Log = null!; public override void Load() { Log = base.Log; try { var gameRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); DumpDir = Path.Combine(gameRoot, "BepInEx", "dumps", "spoon"); Directory.CreateDirectory(DumpDir); ProbeLog = Path.Combine(DumpDir, "spoonprobe.log"); } catch (Exception ex) { Log.LogWarning($"[SpoonProbe] dump dir setup failed: {ex.Message}"); DumpDir = ""; ProbeLog = ""; } PLog("=== AnantaSpoonProbe v1.0.1 loaded (void-postfix fix) ==="); PLog("hooks: SpoonTaskMgr load/deserialize (log+dumponly, never block)"); var harmony = new Harmony("ananta.tools.spoonprobe"); int ok = 0, miss = 0; string mgr = "L50.Spoon.SpoonRunTime.SpoonTaskMgr"; string[] methods = { "GetSpoonTaskFilePath", "ReadRawTaskFile", "ReadRawTaskFileHelper", "ReadRawTaskFileByPointAsync", "GetTaskPatchHelper", "DeserializeTaskByteByPointer", "CreateTaskGraphGroup", "CreateResidentAndTask", "LoadTaskGraphConfig", "LoadTaskResidentInfoConfig", "LoadTaskEventConfig", "LoadTaskEventInfoHelper", "GetResidentPath", "GetSpoonEventPath", "LoadSingleTask", "LoadGraphGroup", "LoadTaskGraph", "LoadTaskGraphHelper", "LoadTaskWorkActionData", "GetTaskWorkActionByTask", "CheckRpcIsLoadTaskGraph", "SyncPlayerSingleTask", "SyncAcceptSingleTask", "ProcessSingleTaskStateChange", "SyncSingleTaskStateChange", "LoadTaskEventInfo", "LoadTaskResidentInfo", "ReloadSpoon", }; foreach (var m in methods) { if (TryPatch(harmony, mgr, m)) ok++; else miss++; } PLog($"hooks armed: ok={ok} miss={miss} (miss = overload sig mismatch, see lines above)"); Log.LogInfo($"[SpoonProbe] armed ok={ok} miss={miss}, dump={DumpDir} (F8=safe probe, Ctrl+F8=LoadSingleTask 60004938)"); ClassInjector.RegisterTypeInIl2Cpp(); AddComponent(); } internal static void PLog(string msg) { try { var line = $"[{DateTime.Now:HH:mm:ss.fff}] {msg}\n"; if (!string.IsNullOrEmpty(ProbeLog)) File.AppendAllText(ProbeLog, line); } catch { } try { Log.LogInfo($"[SpoonProbe] {msg}"); } catch { } try { UnityEngine.Debug.Log($"[SpoonProbe] {msg}"); } catch { } } internal static string Summarize(object? o) { try { if (o is null) return "null"; if (o is string s) return $"\"{s}\""; if (o is byte[] b) return $"byte[{b.Length}] md5={Md5Of(b)}"; if (o is IntPtr p) return $"0x{p.ToInt64():X}"; var t = o.GetType(); if (t.FullName == "UX.Game.TaskSpoonViewInfo" || t.Name == "TaskSpoonViewInfo") return SummarizeSpoon(o); if (o is IEnumerable e && !(o is string)) { int n = 0; var parts = new List(); foreach (var it in e) { if (n < 8) parts.Add(Summarize(it)); n++; } return $"[{string.Join(",", parts)}{(n > 8 ? $",+{n - 8}" : "")}]"; } var s2 = o.ToString(); if (!string.IsNullOrEmpty(s2) && s2 != t.FullName) return s2!; return $"{t.Name}"; } catch (Exception ex) { return $""; } } internal static string SummarizeSpoon(object o) { try { return $"Spoon{{md5=\"{Field(o, "SpoonMd5")}\" spRaid={Field(o, "SpRaidId")} " + $"start={Field(o, "StartTaskId")} end={Field(o, "EndTaskId")} " + $"alias=\"{Field(o, "Alias")}\" event={Field(o, "EventId")} eventStart={Field(o, "EventStartTaskId")}}}"; } catch (Exception ex) { return $""; } } internal static object? Field(object o, string name) { var t = o.GetType(); var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); if (f is not null) return f.GetValue(o); var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); if (p is not null) return p.GetValue(o); return null; } internal static string Md5Of(byte[] b) { try { using var md5 = System.Security.Cryptography.MD5.Create(); return BitConverter.ToString(md5.ComputeHash(b)).Replace("-", "").ToLowerInvariant(); } catch { return "n/a"; } } internal static void DumpBytes(byte[] b, string label) { try { if (string.IsNullOrEmpty(DumpDir) || b is null || b.Length == 0) return; if (b.Length > 80_000_000) { PLog($"dump SKIP {label}: {b.Length} bytes (cap)"); return; } var ts = DateTime.Now.ToString("yyyyMMdd-HHmmss-fff"); var safe = ""; foreach (var c in label) safe += (char.IsLetterOrDigit(c) || c is '_' or '-' or '.') ? c : '_'; if (safe.Length > 60) safe = safe[^60..]; var path = Path.Combine(DumpDir, $"{ts}_{safe}_{b.Length}b_{Md5Of(b)}.bin"); File.WriteAllBytes(path, b); PLog($"dump OK {label}: {b.Length} bytes md5={Md5Of(b)} -> {Path.GetFileName(path)}"); } catch (Exception ex) { PLog($"dump FAIL {label}: {ex.Message}"); } } internal static void DumpPointer(IntPtr ptr, int length, string label) { try { if (ptr == IntPtr.Zero || length <= 0 || length > 80_000_000) return; var buf = new byte[length]; Marshal.Copy(ptr, buf, 0, length); DumpBytes(buf, label + "-ptr"); } catch (Exception ex) { PLog($"ptr dump FAIL {label}: {ex.Message}"); } } private static bool TryPatch(Harmony harmony, string typeName, string methodName) { try { var type = AccessTools.TypeByName(typeName); if (type is null) { PLog($"HOOK MISS {typeName}:{methodName} (type not found)"); return false; } var methods = AccessTools.GetDeclaredMethods(type); int found = 0, patched = 0; foreach (var mi in methods) { if (mi.Name != methodName) continue; found++; try { // v2 fix: a postfix declaring __result cannot compile on void // methods (Harmony "IL Compile Error"). All 12 v1 OK hooks // return values; all 16 v1 FAIL hooks return void. var postfix = mi.ReturnType == typeof(void) ? new HarmonyMethod(typeof(SpoonProbePlugin), nameof(GenericPostfixVoid)) : new HarmonyMethod(typeof(SpoonProbePlugin), nameof(GenericPostfix)); harmony.Patch(mi, prefix: new HarmonyMethod(typeof(SpoonProbePlugin), nameof(GenericPrefix)), postfix: postfix); patched++; } catch (Exception ex) { PLog($"HOOK FAIL {typeName}:{methodName} ({mi}): {ex.Message}"); } } if (found == 0) { PLog($"HOOK MISS {typeName}:{methodName} (no declared method by that name)"); return false; } if (patched > 0) { PLog($"HOOK OK {typeName}:{methodName} x{patched}"); return true; } PLog($"HOOK BROKEN {typeName}:{methodName} x{found} (found but patch threw)"); return false; } catch (Exception ex) { PLog($"HOOK FAIL {typeName}:{methodName}: {ex.Message}"); return false; } } // Generic log-only hooks. __args/__result are supported by Harmony 2.x. // Never throws, never skips the original. public static void GenericPrefix(object __instance, object[] __args, MethodBase __originalMethod) { try { var parts = new List(); if (__args is not null) for (int i = 0; i < __args.Length; i++) parts.Add($"a{i}={Summarize(__args[i])}"); PLog($"ENTER {__originalMethod?.DeclaringType?.Name}.{__originalMethod?.Name}({string.Join(" ", parts)})"); // Byte-carrying payloads: dump every byte[] arg immediately. if (__args is not null) { foreach (var a in __args) { if (a is byte[] b && b.Length > 0 && b.Length <= 80_000_000) DumpBytes(b, $"{__originalMethod?.Name}-arg"); } } // Native pointer + length pairs (CreateTaskGraphGroup, ReadRawTaskFileHelper). if (__args is not null && __args.Length >= 2 && __args[0] is IntPtr ptr && ptr != IntPtr.Zero) { try { int len = -1; if (__args[1] is int li) len = li; else if (__args[1] is uint lu) len = unchecked((int)lu); if (len > 0 && len <= 80_000_000) DumpPointer(ptr, len, $"{__originalMethod?.Name}"); } catch { } } } catch { } } public static void GenericPostfix(object __instance, object? __result, object[] __args, MethodBase __originalMethod) { try { PLog($"LEAVE {__originalMethod?.DeclaringType?.Name}.{__originalMethod?.Name} -> {Summarize(__result)}"); if (__result is byte[] b && b.Length > 0) DumpBytes(b, $"{__originalMethod?.Name}-result"); } catch { } } // Void methods: no __result (declaring it fails IL compile on void targets). public static void GenericPostfixVoid(object __instance, object[] __args, MethodBase __originalMethod) { try { PLog($"LEAVE {__originalMethod?.DeclaringType?.Name}.{__originalMethod?.Name} -> void"); } catch { } } }