297 lines
12 KiB
C#
297 lines
12 KiB
C#
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<SpoonProbeBehaviour>();
|
||
|
|
AddComponent<SpoonProbeBehaviour>();
|
||
|
|
}
|
||
|
|
|
||
|
|
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<string>();
|
||
|
|
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 $"<sumfail {ex.Message}>"; }
|
||
|
|
}
|
||
|
|
|
||
|
|
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 $"<spoonfail {ex.Message}>"; }
|
||
|
|
}
|
||
|
|
|
||
|
|
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<string>();
|
||
|
|
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 { }
|
||
|
|
}
|
||
|
|
}
|