# 舞萌DX 纯网络脚本工程 · 项目总览 > 项目名 **WaHappy** —— 舞萌DX 的游戏助手。 > 面向使用者的说明见根目录 [`README.md`](../README.md); > 本文件是**开发向的主文档**,开工前先读它;遇到「该放哪、该怎么写」的问题以本文件为准。 --- ## 一、这个工程是什么 用 **Python 脚本**,通过**纯网络(纯 HTTP)** 形式直接与舞萌DX(SDGB 国服)的**游戏服务器**交互, 复刻游戏客户端的协议行为。 | 对象 | 是什么 | 对应反编译源码 | |---|---|---| | **游戏服务器** | 玩家数据读写:扫码登录、取数、上传成绩、登出 | `source/Assembly-CSharp/` | | **纯网络形式** | 脚本**直接收发 HTTP 报文**(AES 加密 JSON),不依赖运行中的游戏客户端 | `source/Assembly-CSharp/Net/` | | **扫码登录** | 微信二维码换 userId + token,走 aimedb | `source/ChimeLib.NET/` | --- ## 二、目录结构 ``` wahappy-v0-goal/ ├── config.py ★ 运行时可配置项(密钥/设备/区域/歌曲数据/日志级别) ├── upsert.py ★ 主脚本范例:扫码登录 → 上传成绩 → 登出 ├── buy_ticket.py ★ 主脚本范例:扫码登录 → 购买功能票 → 登出 ├── selftest.py 零网络自检(220 项,判据=源码+真机日志+真机抓包) │ ├── api/ ★ 所有与服务器交互的脚本(一个 api 一个脚本) │ ├── __init__.py 统一导出 + 日志跟踪登记 │ ├── log_setup.py 统一日志(INFO / DEBUG 双模式) │ ├── maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie) │ ├── user_getter_common.py 取数脚本公共底座 │ ├── chime.py 扫码登录 │ ├── upsert_user_all.py ★ 上传成绩 │ └── ... 其余见第六节 │ ├── source/ ★ 反编译源码 + 网络日志,只读查证用 ├── docs/ ★ 文档 └── logs/ ★ 日志输出(latest.log + 日期.log) ``` --- ## 三、api/ 脚本契约 `api/` 里的每个脚本是一个**功能模块**,对外暴露**函数**;函数**能接收参数**, 内部完成「组包 → 发包 → 解析 → 返回结果」的全过程。 ```python def 某Api函数(必填参数, *, 可选参数...) -> 结果: """一句话说明(会出现在日志的 [接口说明] 里)""" ... ``` 参数形态规律: - **业务数据**(user_id / token / 二维码 / playlog_id)→ **位置参数** - **配置项**(client_id / region_id / timeout_ms)→ **关键字参数**,可省略走默认值 --- ## 四、日志 ### 4.1 两种模式 | 模式 | 输出内容 | |---|---| | **INFO**(默认) | 只展示 api 脚本的**运行情况和结果** | | **DEBUG** | 在 INFO 基础上,额外展示**向 api 发送的数据**和**api 返回的数据** | 切换只改 `config.py` 一行: ```python LOG_LEVEL = "info" # 只看流程 LOG_LEVEL = "debug" # 连收发数据一起看 ``` ### 4.2 运行提示 每个 api 函数工作时会提示开始,结束时提示结束: ``` === ParseQRCode 开始运行 === [接口说明] 解析二维码并换取 userId 与 token。 [位置参数] 'SGWCMAID2610022219463A297AF4CA...' [运行结果] 成功 tuple(2 项)(耗时 412 ms) === ParseQRCode 运行结束 === ``` DEBUG 模式额外输出: ``` -> 请求 GetGameSettingApi URL : https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/83a5... 请求头 : {"Content-Type":"application/json","Mai-Encoding":"1.55"} 请求体 : { "placeId": 1000, "clientId": "TESTCLIENT1" } <- 响应 GetGameSettingApi 状态码 : 200 耗时 : 312 ms 响应体 : { "isAouAccession": false, "gameSetting": { ... } } ``` ### 4.3 日志文件(两种形式同时存在、同时写入) | 文件 | 模式 | 说明 | |---|---|---| | `logs/latest.log` | **覆盖**(`w`) | 只包含**当次**主脚本运行产出的日志,下次运行被覆盖 | | `logs/YYYY-MM-DD.log` | **追加**(`a`) | 存放**当日**产生的所有日志 | 每行都带 `[YYYY-MM-DD HH:MM:SS]` 时间戳。 ### 4.4 主脚本怎么写 ```python from api import setup_logging, teardown_logging, ParseQRCode, UserLogin def main(): setup_logging("我的主脚本") # 开头一句;api 调用从此自动记录 try: user_id, token = ParseQRCode(qr) login = UserLogin(user_id, token) finally: teardown_logging() # 结尾一句(不写也会在退出时自动收尾) ``` **不需要**写装饰器,**不需要**自己埋点 —— `api/__init__.py` 在导入时就统一套好了日志外壳。 --- ## 五、协议实现要点(全部有源码依据) | 环节 | 依据 | 要点 | |---|---|---| | **URL 生成** | `Packet.cs:485` + `NetQuery.cs:14` | BASE_URL + md5(接口名 + "MaimaiChn" + "8bF76dE9") | | **请求封装** | `NetHttpClient.cs:262-300` | JSON → 原始 deflate → 0x78 0x9C + Adler32(大端) → AES-256-CBC | | **响应解封装** | `NetHttpClient.cs:355-380` | AES 解密 → 去 2 字节头 / 4 字节尾 → deflate → JSON | | **AES 参数** | `CipherAES.cs:11-12` | Key 32B / IV 16B / CBC / PKCS7 | | **请求头** | `NetHttpClient.cs:174-190` | Mai-Encoding: 1.55、Content-Encoding: deflate、UA = 混淆名#userId | | **number 头** | `Packet.cs:187-189` + `PingChecker.cs:227` | = Ping 心跳的往返毫秒(`GameManager.PingMsec`),**只由 Ping 刷新**;除 Ping 外每个接口都带 | | **Cookie** | `Packet.cs:190-193,228-234` | ★ **UserLoginApi 响应会下发 `Set-Cookie: JSESSIONID=...`**,之后每个带 userId 的请求都会带上;`requests.Session` 自动处理,但**必须复用同一个 client**(`get_default_client()`) | | **playSpecial** | `VOExtensions.cs:846-855` + `GameManager.cs:332-378` | `位反转32(Random(1,1037933)*2069 + CalcSpecialNum)`;标准 ACA 机台 `CalcSpecialNum = 1024 = 0x400` | | **扫码签名** | 技术规范 §4 | SHA256(chipID + timestamp + KEY) 大写;**不带 Content-Type** | | **loginDateTime** | `PacketUserLogin.cs:29` | 「本地墙上时间当 UTC」= 标准 Unix 秒 + 时区偏移 | ### ★ 一个必须注意的坑:Accept-Encoding 必须为空 `requests` 默认发 `Accept-Encoding: gzip, deflate`。本协议响应体是 AES 密文(二进制), 服务端若据此做内容编码会返回**空 body**。C# 端不需要是因为 `HttpWebRequest` 默认 `AutomaticDecompression = None`。所以 `maimai_net.py` 固定置空该头。 --- ## 六、API 清单 ### 6.1 会话链路(6 个) | 脚本 | 函数 | 对应接口 | |---|---|---| | `chime.py` | `ParseQRCode(qr, *, chip_id)` | aimedb /wc_aime/api/get_data | | `gamesettings.py` | `GetGameSettings(*, place_id, client_id)` | GetGameSettingApi | | `ping.py` | `Ping(*, client_id)` | Ping | | `userpreview.py` | `UserPreview(user_id, token, *, ...)` | GetUserPreviewApi | | `userlogin.py` | `UserLogin(user_id, token, *, region_id, place_id)` | UserLoginApi | | `userlogout.py` | `UserLogout(user_id, token, login_dt, *, logout_type)` | UserLogoutApi | ### 6.2 上传成绩(2 个) | 脚本 | 函数 | 说明 | |---|---|---| | `upsert_user_all.py` | `UpsertUserAll(user_id, playlog_id, *, login_date_time, auth_date_time, token, snapshot, extra_data, play, ...)` | ★ **一个函数跑完整条结算链路**:抓快照 → 组包 → 前置接口 → 自检 → 60 秒冷却 → 发包 → 落库回查。唯一有副作用的接口 | | `upsert_user_all.py` | `derive_play_characters(snapshot)` | 从服务器快照推「本局 5 只角色 + 游玩前等级」,免得手填的 `config.PLAY_CHARACTERS` 过期 | | `upsert_user_all.py` | `load_extra_data(extra)` / `merge_extra_data(user_all, extra)` | 把外部 JSON(dict / JSON 串 / 文件路径)自动匹配插入 36 节点 | | `upsert_user_all.py` | `verify_upsert_landing(user_id, snapshot, play)` | 发包后重新拉服务器数据,确认真的落库 | | `upsert_snapshot.py` | `fetch_upsert_snapshot(user_id, ...)` | 一键抓结算基线快照(`UpsertUserAll` 内部会按需自动调) | | `upsert_user_chargelog.py` | `UpsertUserChargelog(user_id, charge_id, login_date_time, ...)` | ★ **购买功能票**(UpsertUserChargelogApi):自动取当前票券/档案、算 validDate 与 price、闸门、发包 | | `upsert_user_chargelog.py` | `calc_valid_date(now)` / `ticket_price(id)` / `ticket_name(id)` | 有效期算法(-4h+90d)、票的 creditNum、票名 | ### 6.3 取数(一个接口一个脚本) | 脚本 | 函数 | 对应接口 | |---|---|---| | `userdata.py` | UserData | GetUserDataApi | | `user_extend.py` | UserExtend | GetUserExtendApi | | `user_option.py` | UserOption | GetUserOptionApi | | `user_character.py` | UserCharacter | GetUserCharacterApi | | `user_ghost.py` | UserGhost | GetUserGhostApi | | `user_map.py` | UserMap / UserMapAll | GetUserMapApi | | `user_login_bonus.py` | UserLoginBonus / UserLoginBonusAll | GetUserLoginBonusApi | | `user_rating.py` | UserRating | GetUserRatingApi | | `user_item.py` | UserItem / UserItemAll | GetUserItemApi | | `user_music.py` | UserMusicDetail / UserMusicDetailAll | GetUserMusicApi | | `user_course.py` | UserCourse / UserCourseAll | GetUserCourseApi | | `user_charge.py` | UserCharge | GetUserChargeApi | | `user_favorite.py` | UserFavorite / UserFavoriteAll | GetUserFavoriteApi | | `user_activity.py` | UserActivity | GetUserActivityApi | | `user_mission.py` | UserMissionData | GetUserMissionDataApi | | `user_intimate.py` | UserIntimate | GetUserIntimateApi | | `user_shop_stock.py` | UserShopStock | GetUserShopStockApi | | `user_favorite_item.py` | UserFavoriteItem / UserFavoriteItemAll | GetUserFavoriteItemApi | | `user_rival.py` | UserRivalData / UserRivalMusic(All) | GetUserRivalDataApi / GetUserRivalMusicApi | | `user_friend_bonus.py` | UserFriendBonus | GetUserFriendBonusApi | | `user_kaleidx_scope.py` | UserKaleidxScope / GameKaleidxScope | GetUserKaleidxScopeApi / GetGameKaleidxScopeApi | | `user_region.py` | UserRegion | GetUserRegionApi | | `user_recommend_music.py` | UserRecommendRateMusic / UserRecommendSelectMusic | GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi | | `user_new_item.py` | UserNewItemList | GetUserNewItemListApi | | `game_ranking.py` | GetGameRanking | GetGameRankingApi | | `game_event.py` | GetGameEvent | GetGameEventApi | | `game_charge.py` | GetGameCharge | GetGameChargeApi | | `game_weekly_data.py` | GetGameWeeklyData | GetGameWeeklyDataApi | | `game_tournament_info.py` | GetGameTournamentInfo | GetGameTournamentInfoApi | | `game_ng_music_id.py` | GetGameNgMusicId | GetGameNgMusicIdApi | --- ## 七、新增一个 api 要做什么 1. 在 `api/` 下新建脚本,定义一个能传参的函数 2. 函数首行写 docstring(会变成日志的 `[接口说明]`) 3. 用 `from .log_setup import log_info, log_request, log_response` 记日志 4. 用 `from .maimai_net import request_dict` 发包 5. 在 `api/__init__.py` 里**导入**并**加进 `_API_FUNCS`** ← 漏了这步就不会有运行提示 --- ## 八、相关文档 | 文件 | 内容 | |---|---| | `docs/上传成绩开发文档.md` | UpsertUserAll 的 36 节点 / 110 字段 / 差分语义 / 真机对照 | | `docs/主脚本编写指南.md` | ★ **写自己的主脚本**:三条铁律 / 最小骨架 / 四个流程模板 / 常见错误 | | `docs/购买功能票流程.md` | ★ 登录 → 买票 → 登出:接口结构 / 源码流程 / validDate 算法 / 待抓包确认项 | | `docs/UpsertUserAll踩坑清单.md` | ★ **28 条坑的逐条记录**:现象 / 根因 / 源码依据 / 正确做法 / 自检项 + 发包前 Checklist | | `docs/实跑验证记录.md` | ★ **真实跑通记录**:returnCode=1 + 回读确认 playCount 1260→1261,含完整时间线与落库证据 | | `docs/真机Hook对照报告.md` | ★ MelonLoader Mod 真机抓包(70 请求)与本实现的逐项对照:number / Cookie / CalcSpecialNum / 剩余差异说明 | | `docs/UpsertUserAll实跑对比.md` | 逐字段对比明细(110 字段 × 真机 vs 本工程) | | `docs/UpsertUserAll落库排查报告.md` | 「returnCode=1 但没落库」的根因分析 | | `docs/日志与主脚本指南.md` | 日志模块完整参考 + 主脚本写法 | | `docs/舞萌DX_扫码登录协议技术规范.md` | 扫码登录协议(上一代整理) | | `source/2026-10-05.log` | 真机网络日志(本工程最重要的判据) | --- *新增 api 后请同步更新第六节清单。*