Files
Ananta-ps/tools/AnantaSpoonProbe/SpoonProbePlugin.cs

298 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 { }
}
}