feat: WaHappy v0 —— 舞萌DX 纯网络游戏助手(占位版)
当前仅 upsert.py 可用:扫码登录 -> 上传一局成绩 -> 落库回查 -> 登出。 - api/ 下 40+ 个接口模块已实现,236 项零网络自检全绿 - 上传成绩链路已真机跑通,报文与真机抓包逐字节一致 - 开发文档见 docs 分支;source/ 与 logs/ 不入库
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using HarmonyLib;
|
||||
using MelonLoader;
|
||||
|
||||
// ★ MelonLoader 靠这个特性识别 mod 并调用 OnInitializeMelon。
|
||||
// 缺了它只会 "Melon Assembly loaded" 但不会实例化(表现为 "N Mods loaded" 不计入)。
|
||||
[assembly: MelonInfo(typeof(MaimaiNetSpy.Main), "MaimaiNetSpy", "1.0.0", "wahappy")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
[assembly: MelonColor(255, 120, 200, 255)]
|
||||
|
||||
namespace MaimaiNetSpy
|
||||
{
|
||||
/// <summary>
|
||||
/// MaimaiNetSpy —— hook 舞萌DX 游戏网络层,做实时拦截与落盘。
|
||||
///
|
||||
/// 拦截点(全部依据 Assembly-CSharp 的实际签名):
|
||||
/// Net.NetHttpClient.AddHeader(name, value) 所有请求头
|
||||
/// Net.NetHttpClient.AddCookie(userId) Cookie
|
||||
/// Net.NetHttpClient.Request(bytes, ua, method) ★ 加密前的明文 JSON
|
||||
/// Net.NetHttpClient.GetResponse() 解密后的响应
|
||||
/// Net.NetPacketUtil.GetUserAgent(query) 逻辑 API 名
|
||||
/// Manager.GameManager.CalcSpecialNum() playSpecial 的硬件标志位
|
||||
///
|
||||
/// 输出:
|
||||
/// <游戏根目录>/MaimaiNetSpy/YYYY-MM-DD_HHmmss.log 人类可读时间线
|
||||
/// <游戏根目录>/MaimaiNetSpy/raw/*.json 每个接口的请求/响应原文
|
||||
/// </summary>
|
||||
public class Main : MelonMod
|
||||
{
|
||||
internal static MelonLogger.Instance Log;
|
||||
|
||||
//: 当前请求的上下文(按线程隔离,游戏是异步回调模型)
|
||||
private static readonly object _lock = new object();
|
||||
|
||||
private static string _outDir;
|
||||
private static StreamWriter _writer;
|
||||
|
||||
//: 本次请求累积的头部(AddHeader 逐条调用,Request 时才真正发出)
|
||||
[ThreadStatic] private static List<KeyValuePair<string, string>> _headers;
|
||||
[ThreadStatic] private static string _cookie;
|
||||
[ThreadStatic] private static string _currentApi;
|
||||
[ThreadStatic] private static string _currentUrl;
|
||||
|
||||
//: 统计
|
||||
private static int _reqCount;
|
||||
private static int _respCount;
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
Log = LoggerInstance;
|
||||
|
||||
string root = MelonLoader.Utils.MelonEnvironment.GameRootDirectory;
|
||||
_outDir = Path.Combine(root, "MaimaiNetSpy");
|
||||
Directory.CreateDirectory(_outDir);
|
||||
Directory.CreateDirectory(Path.Combine(_outDir, "raw"));
|
||||
|
||||
string file = Path.Combine(_outDir,
|
||||
DateTime.Now.ToString("yyyy-MM-dd_HHmmss") + ".log");
|
||||
_writer = new StreamWriter(file, false, new UTF8Encoding(false)) { AutoFlush = true };
|
||||
|
||||
Line("=".PadRight(78, '='));
|
||||
Line(" MaimaiNetSpy 已加载 —— 舞萌DX 网络层实时拦截");
|
||||
Line(" 日志: " + file);
|
||||
Line("=".PadRight(78, '='));
|
||||
|
||||
try
|
||||
{
|
||||
var h = new HarmonyLib.Harmony("MaimaiNetSpy");
|
||||
int n = 0;
|
||||
|
||||
// ★ Request 有两个重载:Request() 与 Request(byte[], string, string)
|
||||
// 必须按参数类型精确匹配,否则会命中无参版本导致
|
||||
// "Parameter bytes not found in method Request()"
|
||||
n += PatchBySig(h, "Net.NetHttpClient", "Create",
|
||||
new[] { typeof(string) },
|
||||
nameof(PreCreate), null);
|
||||
n += PatchBySig(h, "Net.NetHttpClient", "AddHeader",
|
||||
new[] { typeof(string), typeof(string) },
|
||||
nameof(PreAddHeader), null);
|
||||
n += PatchBySig(h, "Net.NetHttpClient", "AddCookie",
|
||||
new[] { typeof(ulong) },
|
||||
nameof(PreAddCookie), null);
|
||||
n += PatchBySig(h, "Net.NetHttpClient", "Request",
|
||||
new[] { typeof(byte[]), typeof(string), typeof(string) },
|
||||
nameof(PreRequest), null);
|
||||
n += PatchBySig(h, "Net.NetHttpClient", "GetResponse",
|
||||
Type.EmptyTypes,
|
||||
null, nameof(PostGetResponse));
|
||||
n += PatchBySig(h, "Net.NetPacketUtil", "GetUserAgent",
|
||||
new[] { AccessTools.TypeByName("Net.INetQuery") },
|
||||
null, nameof(PostGetUserAgent));
|
||||
n += PatchBySig(h, "Manager.GameManager", "CalcSpecialNum",
|
||||
Type.EmptyTypes,
|
||||
null, nameof(PostCalcSpecialNum));
|
||||
n += PatchBySig(h, "Net.NetHttpClient", "SetError",
|
||||
new[] { typeof(System.Net.WebExceptionStatus), typeof(string),
|
||||
typeof(int), typeof(System.Net.HttpWebResponse) },
|
||||
nameof(PreSetError), null);
|
||||
|
||||
Line(" 已挂载 " + n + " 个 hook");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Line(" !! hook 挂载失败: " + e);
|
||||
}
|
||||
Line("");
|
||||
}
|
||||
|
||||
public override void OnDeinitializeMelon()
|
||||
{
|
||||
try { _writer?.Flush(); _writer?.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// hook 挂载工具
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// 按**参数类型**精确挂载 hook。
|
||||
///
|
||||
/// ★ 为什么必须精确匹配:NetHttpClient.Request 有两个重载
|
||||
/// Boolean Request() —— 只发请求(无体)
|
||||
/// Boolean Request(Byte[] bytes, String ua, String m) —— 带请求体
|
||||
/// 用 AccessTools.Method(t, "Request") 会命中前者,
|
||||
/// 导致 prefix 里的 bytes 参数对不上而抛
|
||||
/// "Parameter bytes not found in method Request()"。
|
||||
/// </summary>
|
||||
private static int PatchBySig(HarmonyLib.Harmony h, string typeName, string methodName,
|
||||
Type[] paramTypes, string pre, string post)
|
||||
{
|
||||
var t = AccessTools.TypeByName(typeName);
|
||||
if (t == null) { Line(" [skip] 类型未找到: " + typeName); return 0; }
|
||||
if (paramTypes == null || paramTypes.Any(x => x == null))
|
||||
{ Line(" [skip] 参数类型解析失败: " + typeName + "." + methodName); return 0; }
|
||||
|
||||
var m = AccessTools.Method(t, methodName, paramTypes);
|
||||
if (m == null)
|
||||
{
|
||||
Line(" [skip] 方法未找到: " + typeName + "." + methodName
|
||||
+ "(" + string.Join(", ", Array.ConvertAll(paramTypes, x => x.Name)) + ")");
|
||||
return 0;
|
||||
}
|
||||
|
||||
HarmonyMethod preM = pre == null ? null
|
||||
: new HarmonyMethod(typeof(Main).GetMethod(pre, BindingFlags.Static | BindingFlags.NonPublic));
|
||||
HarmonyMethod postM = post == null ? null
|
||||
: new HarmonyMethod(typeof(Main).GetMethod(post, BindingFlags.Static | BindingFlags.NonPublic));
|
||||
|
||||
try
|
||||
{
|
||||
h.Patch(m, preM, postM);
|
||||
Line(" [ok] " + typeName + "." + methodName
|
||||
+ "(" + string.Join(", ", Array.ConvertAll(paramTypes, x => x.Name)) + ")");
|
||||
return 1;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Line(" [fail] " + typeName + "." + methodName + " -> " + e.Message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Create —— 捕获完整 URL(NetHttpClient.Create(url))
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static void PreCreate(string url)
|
||||
{
|
||||
_currentUrl = url;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// AddHeader —— 收集请求头
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static void PreAddHeader(string name, string value)
|
||||
{
|
||||
if (_headers == null) _headers = new List<KeyValuePair<string, string>>();
|
||||
_headers.Add(new KeyValuePair<string, string>(name, value));
|
||||
}
|
||||
|
||||
private static void PostAddHeader()
|
||||
{
|
||||
// 不在这里输出:AddHeader 会被多次调用,等 Request 时统一打印
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// AddCookie —— 记录 Cookie
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static void PreAddCookie(ulong userId)
|
||||
{
|
||||
_cookie = "userId=" + userId;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Request —— ★ 核心:加密前的明文 JSON 在这里
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static void PreRequest(byte[] bytes, string userAgent, string method)
|
||||
{
|
||||
_reqCount++;
|
||||
string plain = null;
|
||||
try
|
||||
{
|
||||
if (bytes != null && bytes.Length > 0)
|
||||
plain = Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
catch { }
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("+" + new string('-', 76));
|
||||
sb.AppendLine("| [" + _reqCount + "] >>> 请求 "
|
||||
+ DateTime.Now.ToString("HH:mm:ss.fff"));
|
||||
sb.AppendLine("+" + new string('-', 76));
|
||||
sb.AppendLine("| API : " + (_currentApi ?? "(unknown)"));
|
||||
if (!string.IsNullOrEmpty(_currentUrl))
|
||||
sb.AppendLine("| URL : " + _currentUrl);
|
||||
sb.AppendLine("| User-Agent : " + userAgent);
|
||||
sb.AppendLine("| Method : " + method);
|
||||
sb.AppendLine("| Cookie : " + (_cookie ?? "(none)"));
|
||||
sb.AppendLine("| 明文长度 : " + (bytes == null ? 0 : bytes.Length) + " 字节");
|
||||
|
||||
if (_headers != null && _headers.Count > 0)
|
||||
{
|
||||
sb.AppendLine("| ---- 请求头(AddHeader 收集)----");
|
||||
foreach (var kv in _headers)
|
||||
sb.AppendLine("| " + kv.Key + ": " + kv.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("| ---- 请求头: (无 AddHeader 调用) ----");
|
||||
}
|
||||
|
||||
if (plain != null)
|
||||
{
|
||||
sb.AppendLine("| ---- 请求体(加密前明文)----");
|
||||
foreach (var ln in Wrap(plain, 150))
|
||||
sb.AppendLine("| " + ln);
|
||||
}
|
||||
Line(sb.ToString());
|
||||
|
||||
// 落盘原始明文
|
||||
try
|
||||
{
|
||||
string safe = Sanitize(_currentApi ?? ("req" + _reqCount));
|
||||
File.WriteAllText(
|
||||
Path.Combine(_outDir, "raw", _reqCount.ToString("D3") + "_" + safe + "_request.json"),
|
||||
plain ?? "", new UTF8Encoding(false));
|
||||
}
|
||||
catch { }
|
||||
|
||||
// 清空累积状态(本次请求已消费)
|
||||
_headers = null;
|
||||
}
|
||||
|
||||
private static void PostRequest()
|
||||
{
|
||||
// 状态清理交给下一次 PreRequest
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// GetResponse —— 解密后的响应
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static void PostGetResponse(ref MemoryStream __result)
|
||||
{
|
||||
_respCount++;
|
||||
try
|
||||
{
|
||||
if (__result == null) return;
|
||||
long pos = __result.Position;
|
||||
__result.Position = 0;
|
||||
byte[] buf = new byte[__result.Length];
|
||||
int read = __result.Read(buf, 0, buf.Length);
|
||||
__result.Position = pos;
|
||||
|
||||
string txt = Encoding.UTF8.GetString(buf, 0, read);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("| [" + _respCount + "] <<< 响应 "
|
||||
+ DateTime.Now.ToString("HH:mm:ss.fff")
|
||||
+ " (" + read + " 字节)");
|
||||
foreach (var ln in Wrap(txt, 150))
|
||||
sb.AppendLine("| " + ln);
|
||||
Line(sb.ToString());
|
||||
|
||||
string safe = Sanitize(_currentApi ?? ("resp" + _respCount));
|
||||
File.WriteAllText(
|
||||
Path.Combine(_outDir, "raw", _respCount.ToString("D3") + "_" + safe + "_response.json"),
|
||||
txt, new UTF8Encoding(false));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Line("| [响应解析失败] " + e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// GetUserAgent —— 从 UA 反推 API 名;同时记录 URL
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static void PostGetUserAgent(Net.INetQuery query, ref string __result)
|
||||
{
|
||||
try
|
||||
{
|
||||
_currentApi = query?.Api;
|
||||
// UA 形如 "<md5>#<userId>"
|
||||
Line(" [API] " + _currentApi + " UA=" + __result);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// CalcSpecialNum —— playSpecial 的硬件标志位
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static void PostCalcSpecialNum(ref int __result)
|
||||
{
|
||||
Line(" [CalcSpecialNum] = " + __result
|
||||
+ " (SpecialAP=" + SafeBool(() => Manager.GameManager.SpecialAP)
|
||||
+ " IsFreedomMode=" + SafeBool(() => Manager.GameManager.IsFreedomMode)
|
||||
+ ")");
|
||||
}
|
||||
|
||||
private static string SafeBool(Func<bool> f)
|
||||
{
|
||||
try { return f().ToString(); } catch { return "?"; }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// SetError —— 记录网络层错误(含 HTTP 状态码)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static void PreSetError(System.Net.WebExceptionStatus status, string error,
|
||||
int state, System.Net.HttpWebResponse response)
|
||||
{
|
||||
Line(" [NetError] status=" + status + " state=" + state
|
||||
+ " error=" + error
|
||||
+ (response != null ? (" http=" + (int)response.StatusCode) : ""));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 工具
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static string Sanitize(string s)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (char c in s)
|
||||
sb.Append(char.IsLetterOrDigit(c) || c == '_' || c == '-' ? c : '_');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Wrap(string s, int width)
|
||||
{
|
||||
if (s == null) yield break;
|
||||
for (int i = 0; i < s.Length; i += width)
|
||||
yield return s.Substring(i, Math.Min(width, s.Length - i));
|
||||
}
|
||||
|
||||
internal static void Line(string s)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
try { _writer?.WriteLine(s); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user