diff --git a/README.md b/README.md index 75e9cf1..e41f9dd 100644 --- a/README.md +++ b/README.md @@ -21,31 +21,18 @@ WaHappy 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游 --- -## 当前状态:v0,**未完工** - -**v0 目前只是一个占位版本,请以「能不能用」而不是「有多少代码」来判断它。** +## 当前状态:v0 | 脚本 | 状态 | |---|---| | **`upsert.py`** | ✅ **可用** —— 扫码登录 → 上传一局成绩 → 落库回查 → 登出 | -| **`buy_ticket.py`** | ✅ **可用** —— 购买功能票(2026-10-06 真机验证通过) | -| **`use_ticket.py`** | ✅ **可用** —— 读取当前持有的功能票并用 `UpsertUserAll` 消耗掉(已实跑) | -| **`reincarnate.py`** | 🔧 已实现(**未实跑**)—— 账号数据迁移(转生),每包 ≤4 首 + 续关登录 | -| **`netdelivery.py`** | ✅ **可用** —— 查 / 下 `.app` / `.opt` 更新包(ALL.Net DLI,只读,无副作用) | +| **`buy_ticket.py`** | ✅ **可用** —— 购买功能票 | +| **`use_ticket.py`** | ✅ **可用** —— 消耗当前持有的功能票 | +| **`reincarnate.py`** | 🔧 已实现(**未实跑**)—— 账号数据迁移(转生) | +| **`netdelivery.py`** | ✅ **可用** —— 查 / 下 `.app` / `.opt` 更新包(只读) | | `api/` 下 40+ 个接口模块 | 🔧 已实现并通过零网络自检,但**未全部在真机上验证过** | -| **文档** | 📄 在 **`docs` 分支**(`mai` 分支不含)—— 开发过程、踩坑记录、实跑验证,内容比代码本身还多 | -### 关于购买功能票(已解决) - -`UpsertUserChargelogApi` 曾经一直返回 `returnCode=0`。根因是 **请求头多发了 -`Accept` 与 `Connection`** —— 真机(Unity Mono 的 `HttpWebRequest`)**不发这两个头**: - -``` -number / Content-Type / User-Agent / charset / Mai-Encoding / Content-Encoding / Cookie -``` - -删掉之后一次通过(`stock` 0 → 1,落库确认)。完整排查过程见 -[购买功能票流程](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/购买功能票流程.md)。 +**文档**在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs),见文末[文档索引](#文档索引)。 --- @@ -73,14 +60,14 @@ v0 的开发过程中,我反复把同一个目标拆成小段丢给 AI,结 ``` WaHappy/ -├── config.py 运行时可配置项(密钥 / 机台 / 歌曲数据 / 日志级别)★ 需自建 -├── config.py.example 配置模板(机台与店铺标识留空待填) +├── config.py 运行时可配置项(机台标识 / 歌曲数据 / 日志级别)★ 需自建 +├── config.py.example 配置模板 ├── upsert.py ★ 主脚本:扫码登录 → 上传成绩 → 登出 ├── buy_ticket.py 主脚本:购买功能票 ├── use_ticket.py 主脚本:消耗当前持有的功能票 ├── reincarnate.py 主脚本:账号数据迁移(转生) -├── netdelivery.py 主脚本:查 / 下 .app / .opt 更新包(ALL.Net DLI) -├── selftest.py 零网络自检(238 项,判据 = 源码 + 真机日志) +├── netdelivery.py 主脚本:查 / 下 .app / .opt 更新包 +├── selftest.py 零网络自检(249 项,判据 = 源码 + 真机日志) │ ├── api/ ★ 所有与服务器交互的模块(一个接口一个脚本) │ ├── __init__.py 统一导出 + 日志自动跟踪 @@ -102,18 +89,15 @@ WaHappy/ **命名约定**:服务端 API 名里带 `Get` 的,**脚本名与函数名一律带 `get` / `Get`** (不要模棱两可)。所以取数脚本全是 `get_*.py`,函数是 `GetXxx`。 -> **`docs/` 在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs),`source/` 与 `logs/` 不入库。** -> -> `source/` 是反编译源码,**需要自备**。 - ### 分支说明 | 分支 | 内容 | |---|---| -| **`mai`** | 主分支 —— 代码(`api/` / `upsert.py` / `config.py` / `selftest.py` / `gamehook/`) | +| **`mai`** | 主分支 —— 代码 | | **`docs`** | 孤儿分支 —— 只有 `docs/`,开发文档与踩坑记录 | 两个分支**没有共同的提交历史**,各自独立。 +`source/` 是反编译源码,**需要自备**。 --- @@ -134,25 +118,25 @@ cp config.py.example config.py # Linux / macOS copy config.py.example config.py # Windows ``` -然后**必须**填好 **`config.py` 第二节「机台与店铺标识」的 6 个值**: +然后**必须**填好 **`config.py` 第二节「机台与店铺标识」**: | 变量 | 填什么 | |---|---| -| `KEYCHIP_ID` | 你的机台 Keychip(**完整形式**,带连字符,形如 `XXXX-XXXXXXXXXXX`) | -| `CLIENT_ID` | Keychip **短值**(去掉连字符取前 11 位) | -| `REGION_ID` | 你所在区域 ID | +| `CLIENT_ID` | Keychip 短值(15 位 Keychip 去掉连字符取前 11 位),形如 `A63E01ABCDE` | | `PLACE_ID` | 你所在店铺 ID | -| `PLACE_NAME` | 店铺名(游戏服务器不返回,只能手填) | +| `REGION_ID` | 你所在区域 ID | +| `PLACE_NAME` | 店铺名 | | `REGION_NAME` | 区域名 | +| `KEYCHIP_ID` | **由上面两个值自动推导**,不要手改 | -> ⚠️ **这 6 个值留空或填错,发出去的请求带的就是别人的机台身份,服务端会直接拒绝。** +> ⚠️ 填错的话,发出去的请求带的就是别人的机台身份,服务端会直接拒绝。 > > `config.py` 已在 `.gitignore` 里 —— 它是你的私有配置,**不要提交**。 ### 3. 跑自检 ```bash -python selftest.py # 零网络自检,应该 238 项全绿(不依赖你的 config) +python selftest.py # 零网络自检,应该 249 项全绿(不依赖你的 config) ``` ### 主脚本一览 @@ -169,6 +153,8 @@ python selftest.py # 零网络自检,应该 238 项全绿(不依赖 > **所有有副作用的脚本默认都不发包**:不传 `--send` 时只构造报文(dry-run)。 > 想看报文而不产生任何后果,先跑一次不带 `--send` 的。 +> 二维码从机台的「扫码登录」界面获取,**有效期 10 分钟**(不是按次数计量的)。 + --- ## upsert.py —— 上传一局成绩 @@ -188,49 +174,18 @@ python upsert.py "<二维码>" --send --no-verify # 跳过上传后的落库 **换歌 / 换成绩只改 `config.py` 的 `PLAY_*`,不用动任何代码。** -> 二维码从机台的「扫码登录」界面获取,**有效期 10 分钟**。 - ### 额外塞节点:`--extra-data` -真机报文的 36 个节点绝大多数能由快照 + 本局演算自动推出来, -但偶尔想手工覆盖某几个(比如本局拿到的新 KaleidxScope 门),就用 `--extra-data`: +想手工覆盖某几个节点(比如本局拿到的新 KaleidxScope 门): ```bash -# ① 从文件读(★ 推荐 —— 不用跟 shell 引号搏斗) python upsert.py "<二维码>" --send --extra-data extra.json - -# ② 直接写 JSON 字符串 -python upsert.py "<二维码>" --send --extra-data '{"isNewMapList":"1"}' - -# ③ 多个来源,后给的覆盖先给的 -python upsert.py "<二维码>" --send --extra-data base.json --extra-data patch.json ``` -`extra.json` 长这样: +键必须是 `upsertUserAll` 的 **36 个节点**之一,写错会报错并列出可用节点名。 +**JSON 在扫码之前就校验**,写错直接退出,不浪费二维码。 -```json -{ - "userKaleidxScopeList": [ - {"gateId": 1, "isGateFound": true, "isKeyFound": true, "isClear": false, - "totalRestLife": 0, "totalAchievement": 0, "totalDeluxscore": 0, - "bestAchievement": 0, "bestDeluxscore": 0, - "bestAchievementDate": "", "bestDeluxscoreDate": "", - "playCount": 0, "clearDate": "", "lastPlayDate": "", "isInfoWatched": false} - ], - "isNewKaleidxScopeList": "1" -} -``` - -| 规则 | 说明 | -|---|---| -| 键 | 必须是 `upsertUserAll` 的 **36 个节点**之一(含 11 条 `isNew*` 位串),写错会报错并列出可用节点名 | -| 值形态 | 按节点形态强制转换,传错立刻报错 | -| 位串 | 给了数组没给位串 → 按长度补 `"0"` 并提示。**新增条目请显式传 `"1"`**(`"0"` = 存量更新) | -| 优先级 | 最高,覆盖快照演算结果 | -| 校验时机 | ★ **扫码之前**就校验 —— JSON 写错直接退出,不浪费二维码 | - -> ⚠ 在 PowerShell / CMD 里写内联 JSON 很容易被吞掉双引号, -> **建议一律用文件**(`--extra-data extra.json`)。 +> ⚠️ 在 PowerShell / CMD 里写内联 JSON 很容易被吞掉双引号,**建议一律用文件**。 --- @@ -242,83 +197,41 @@ python buy_ticket.py "<二维码>" --charge-id 2 # 预检:只看 python buy_ticket.py "<二维码>" --charge-id 2 --send # ★ 真实购买 ``` -| 参数 | 默认 | 说明 | -|---|---|---| -| `--charge-id N` | **5** | 买哪张票 | -| `--list` | — | 只列票券与可购买清单,不做任何写操作 | -| `--send` | 关 | ★ 真实发包 | -| `--debug` | 关 | 日志里带完整收发报文 | -| `--skip-entry` | 关 | 跳过登录后的「入场全量下载」 | -| `--wait-before N` | **30** | 买票前等待秒数(真机 login→买票是 26 秒) | - -| chargeId | 票 | 上报的 `price`(= 票的 creditNum) | +| chargeId | 票 | 上报的 `price` | |---|---|---| | 2 | 2倍チケット | 1 | | 3 | 3倍チケット | 2 | -| 4 | 4倍チケット | 3 | | 5 | 5倍チケット | 4 | -| 6 | 6倍チケット | 5 | -### 它做了哪些安全检查 +> 默认 `--charge-id 5`。**不同店铺上架的票不一样**,买之前先 `--list`。 -1. **买之前**先查 `GetGameChargeApi` —— 目标票**不在当前可购买清单里就直接退出**, - 连登录都不做(不浪费二维码)。依据 `TicketManager.cs:137-150`。 -2. **跑前查一次** `GetUserChargeApi`:**账号已经持有功能票时直接拒绝购买** - (重复购买有封号风险)。想清掉手里的票用下面的 `use_ticket.py`。 -3. **跑后再查一次**,确认票券状态符合预期。 +### 它做的安全检查 -> ⚠️ 不同店铺上架的票不一样。**买之前先 `--list` 看一眼**, -> 你所在的机台可能只卖其中几种。 +1. 买之前查 `GetGameChargeApi` —— 目标票不在可购买清单里就**连登录都不做**直接退出 +2. 跑前查 `GetUserChargeApi` —— **账号已持有功能票时直接拒绝购买**(重复购买有封号风险) +3. 跑后再查一次确认状态 --- ## use_ticket.py —— 消耗当前持有的功能票 -读取账号**持有**的功能票,然后用 `UpsertUserAll` 表达「用这张票打了一局」把它消耗掉。 - ```bash python use_ticket.py "<二维码>" --list # 只列当前持有的票 -python use_ticket.py "<二维码>" --ticket-id 2 # 预检:只看报文 python use_ticket.py "<二维码>" --ticket-id 2 --send # ★ 真实消耗 ``` -| 参数 | 默认 | 说明 | -|---|---|---| -| `--ticket-id N` | 第一张持有的 | 消耗哪一张 | -| `--list` | — | 只列票券,不做任何写操作 | -| `--send` | 关 | ★ 真实发包 | -| `--music-id N` / `--level N` | `config.PLAY_MUSIC_ID` / `PLAY_MUSIC_LEVEL` | 「这一局」打的歌 | -| `--skip-entry` | 关 | 跳过入场全量下载 | -| `--debug` | 关 | 日志里带完整收发报文 | - -### 原理 - -`GetUserChargeApi` 与 `UpsertUserAllApi` 里的 `userChargeList` -**格式完全相同**,所以只要把该节点里目标票的 `stock` 减 1, -再让 playlog 带上 `useTicketId` / `playCredit`,服务端就认为这张票被这一局用掉了。 - -真机实证(2026-10-06 22:23 那局正好用了 2 倍券): - -``` -userChargeList: chargeId=2 stock 1 -> 0 ← 票被消耗 -userGamePlaylogList[0].useTicketId = 2 ← 用了哪张票 -userGamePlaylogList[0].playCredit = 2 -userPlaylogList[0].playCredit = 2 -userData.lastPlayCredit = 2 -``` - -`playCredit` 的算法(`VOExtensions.cs:838-845`): - -``` -playCredit = 投币数(普通局 = 1) + 票的 creditNum -``` +**原理**:`GetUserChargeApi` 与 `UpsertUserAllApi` 里的 `userChargeList` **格式完全相同**, +所以把该节点里目标票的 `stock` 减 1,再让 playlog 带上 `useTicketId` / `playCredit`, +服务端就认为这张票被这一局用掉了。 > **典型用途**:账号手里挂着票时 `buy_ticket.py` 会拒绝再买; -> 想再买一张就先把它用掉。也可以直接在机台上打一局并选用它。 +> 想再买一张就先用这个把它用掉。 + +详细原理与报文依据见 [功能票使用说明](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/功能票使用说明.md)。 --- -## reincarnate.py —— 账号数据迁移(转生) +## reincarnate.py —— 账号转生 把一个账号(源 A)的数据克隆到另一个账号(目标 B)。 @@ -326,100 +239,36 @@ playCredit = 投币数(普通局 = 1) + 票的 creditNum # 源数据来自二维码(会联网抓 A 的数据并落盘到 data/.json) python reincarnate.py --dst-qr "" --src-qr "" -# 源数据来自已保存的 JSON(离线,推荐:不用反复消耗 A 的二维码) +# 源数据来自已保存的 JSON(离线,推荐) python reincarnate.py --dst-qr "" --src-json data/12345678.json --plan python reincarnate.py --dst-qr "" --src-json data/12345678.json --send ``` +**它干了什么**: + +1. 取源账号 A 的 19 个节点数据(联网抓或读 `data/.json`) +2. 用目标账号 B 的二维码登录 +3. 把 A 的 `userMusicDetailList` **分批**上传到 B —— 每包最多 4 首, + 并自动避开**被服务端禁止的 2 首包**(`[4,4,2]` → `[4,3,3]`) +4. 每包之间:**等待 → 上传 → 登出(type=1)→ 续关登录**,循环到传完 +5. 每包的 playlog 从曲目记录**反推合成**(`achievement` / `deluxscore` / `scoreRank` …) + | 参数 | 默认 | 说明 | |---|---|---| -| `--dst-qr` | 必填 | 目标账号 B 的二维码 | -| `--src-qr` / `--src-json` | 二选一 | 源数据来源(二维码联网抓 / 读 `data/.json`) | | `--plan` | — | 只列分批计划,不发包(**建议先跑这个**) | -| `--send` | 关 | ★ 真实发包 | | `--limit N` | 0(全部) | 只迁前 N 首(试跑用) | -| `--per-packet N` | 4 | 每包曲目数,**不能超过 4** | -| `--clone-identity` | 关 | 连 `userName` / `accessCode` 一起克隆(默认**保留 B 自己的**) | -| `--no-continue` | 关 | 不续关,只发一包(调试用) | -| `--debug` | 关 | 日志里带完整收发报文 | +| `--per-packet N` | 4 | 每包曲目数,**只能是 1 / 3 / 4** | +| `--wait N` | 5.0 | 每次上传前的等待秒数 | +| `--clone-identity` | 关 | 连 `userName` / `accessCode` 一起克隆(默认保留 B 自己的) | -### 流程 +> ⚠️ **未实跑过。** 建议先 `--plan`,再 `--limit 8` 小批量试。 -``` -源账号 A:二维码 → GetNeedUserData(并落盘 data/.json) - 或直接读 data/.json - ↓ -目标账号 B:二维码 → UserPreview → UserLogin - 循环: - UpsertUserAll(每包 ≤4 首 + 从曲目记录反推的 playlog) - UserLogin(is_continue=True) ← ★ 续关,换新 loginId - 收尾:UserLogout -``` - -### 为什么一次最多 4 首 - -真机一局(一个 credit)最多打 4 首,所以一条 `UpsertUserAll` 报文里的 -`userPlaylogList` / `userGamePlaylogList` **最多 4 条**。 -本脚本把源账号的 `userMusicDetailList` 按 4 条一组切分,一组 = 一局。 - -### 为什么要「续关登录」 - -`UpsertUserAll` 的 `playlogId` 必须等于**本次登录**的 `loginId`。 -一局结束后这个会话就被消费掉了,要继续上传必须重新登录 —— -真机走的就是「续关」(`Process/ContinueProcess.cs:403`): - -```csharp -new PacketUserLogin(userID, accessCode, isContinue: true, ...) -``` - -本工程对应 `UserLogin(user_id, token, is_continue=True)`。 - -### playlog 从哪来 - -`GetNeedUserData` 抓不到 `userPlaylogList`(那是逐局记录,没有对应的 Get 接口), -所以本脚本**从曲目记录反推合成**: - -| `UserMusicDetail`(9 字段) | → | `UserPlaylog`(110 字段) | -|---|---|---| -| `musicId` / `level` | | `musicId` / `level` | -| `achievement` | | `achievement` | -| `deluxscoreMax` | | `deluxscore` | -| `scoreRank` | | `scoreRank` / `isClear` | -| `comboStatus` / `syncStatus` | | 同名 | -| 其余 | | `config.PLAY_*` 默认值 | - -### 迁移范围 - -默认迁**全部节点**(整账号克隆)。第 1 包带全部非曲目节点,之后每包只带这一批曲目; -位串 `isNew*` 按 **B 的基线**逐条算(B 没有 → `"1"`,已有 → `"0"`)。 - -> ⚠️ **未实跑过。** 建议先用 `--plan` 看计划,再 `--limit 8` 小批量试,确认无误再全量。 +完整流程、分批规则、续关与登出类型的依据见 [账号转生说明](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/账号转生说明.md)。 --- ## netdelivery.py —— 查 / 下 `.app` / `.opt` 更新包 -机台做完 ALL.Net 认证后,AMDaemon 会再问一次「我该下什么包」—— -这就是 **DLI(Download Instruction,配信指示書)**: - -``` -POST http://at.sys-allnet.cn/net/delivery/instruction -body = title_id=SDGB&title_ver=<版本>&client_id= - -→ result=1&uri=|