feat: WaHappy v0 —— 舞萌DX 纯网络游戏助手(占位版)

当前仅 upsert.py 可用:扫码登录 -> 上传一局成绩 -> 落库回查 -> 登出。

- api/ 下 40+ 个接口模块已实现,236 项零网络自检全绿
- 上传成绩链路已真机跑通,报文与真机抓包逐字节一致
- 开发文档见 docs 分支;source/ 与 logs/ 不入库
This commit is contained in:
Txiaochen
2026-10-06 14:42:06 +08:00
commit 1636a79141
51 changed files with 10965 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
MaimaiNetSpy —— MelonLoader Mod:hook 舞萌DX 游戏网络层做实时拦截
==========================================================================
目标框架必须与游戏/MelonLoader 一致:net472(MonoBleedingEdge + MelonLoader 0.6.4)
构建:dotnet build -c Release
产物:bin/Release/net472/MaimaiNetSpy.dll -> 复制到 D:\SDGB2026\Package\Mods\
-->
<PropertyGroup>
<TargetFramework>net472</TargetFramework>
<AssemblyName>MaimaiNetSpy</AssemblyName>
<RootNamespace>MaimaiNetSpy</RootNamespace>
<LangVersion>latest</LangVersion>
<Nullable>disable</Nullable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<Version>1.0.0</Version>
<!-- 不生成 apphost / 不需要运行时配置 -->
<UseAppHost>false</UseAppHost>
<GenerateRuntimeConfigurationFiles>false</GenerateRuntimeConfigurationFiles>
<!-- 允许引用 net472 参考程序集 -->
<AutomaticallyUseReferenceAssemblyPackages>true</AutomaticallyUseReferenceAssemblyPackages>
</PropertyGroup>
<!-- 游戏程序集与 MelonLoader 的引用(全部 Private=false,不复制到输出) -->
<ItemGroup>
<Reference Include="MelonLoader">
<HintPath>D:\SDGB2026\Package\MelonLoader\net35\MelonLoader.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>D:\SDGB2026\Package\MelonLoader\net35\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Assembly-CSharp">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\Assembly-CSharp.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Assembly-CSharp-firstpass">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\Assembly-CSharp-firstpass.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\UnityEngine.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="AMDaemon.NET">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\AMDaemon.NET.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ChimeLib.NET">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\ChimeLib.NET.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
+376
View File
@@ -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 的硬件标志位
///
/// 输出:
/// &lt;游戏根目录&gt;/MaimaiNetSpy/YYYY-MM-DD_HHmmss.log 人类可读时间线
/// &lt;游戏根目录&gt;/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 { }
}
}
}
}