# 账号转生说明(`reincarnate.py`)
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里转生部分的**详细版**。
---
## 一、它干了什么
把一个账号(源 **A**)的数据克隆到另一个账号(目标 **B**):
```
源账号 A:二维码 -> ParseQRCode -> UserLogin -> GetNeedUserData
或:直接读 data/.json(离线复用)
↓ 节点数据(19 个节点)
目标账号 B:二维码 -> ParseQRCode -> UserPreview -> UserLogin
循环:
等待 -> UpsertUserAll(一包曲目)
-> UserLogout(type=1)
-> UserLogin(isContinue=true) 续关,拿新 loginId
收尾:最后一包登出后结束(不再续关)
```
---
## 二、用法
```bash
# 源数据来自二维码(会联网抓 A 的数据,并落盘到 data/.json)
python reincarnate.py --dst-qr --src-qr
# 源数据来自已保存的 JSON(离线,推荐:不用反复消耗 A 的二维码)
python reincarnate.py --dst-qr --src-json data/12345678.json
# ★ 真实上传
python reincarnate.py --dst-qr --src-json data/12345678.json --send
# 只看计划,不发包(★ 建议先跑这个)
python reincarnate.py --dst-qr --src-json data/12345678.json --plan
```
| 参数 | 默认 | 说明 |
|---|---|---|
| `--dst-qr` | 必填 | 目标账号 B 的二维码 |
| `--src-qr` / `--src-json` | 二选一 | 源数据来源 |
| `--plan` | — | 只列分批计划,不发包 |
| `--send` | 关 | ★ 真实发包 |
| `--limit N` | 0(全部) | 只迁前 N 首(试跑用) |
| `--per-packet N` | 4 | 每包曲目数,**只能是 1 / 3 / 4** |
| `--wait N` | 5.0 | 每次上传前的等待秒数(0 = 不等) |
| `--clone-identity` | 关 | 连 `userName` / `accessCode` 一起克隆(默认**保留 B 自己的**) |
| `--no-continue` | 关 | 不续关,只发一包(调试用) |
| `--debug` | 关 | 日志里带完整收发报文 |
---
## 三、★ 每包几首:1 / 3 / 4 可以,**恰好 2 首会被服务端拒绝**
真机一局(一个 credit)最多打 4 首,所以一条 `UpsertUserAll` 报文里的
`userPlaylogList` / `userGamePlaylogList` **最多 4 条**。
但实测:**每包恰好 2 首会被服务端拒绝**(1 / 3 / 4 首都正常)。
所以本脚本的分批器会**主动避开 2 首包**:
| 总量 | `--per-packet` | 原始切分 | 修正后 |
|---|---|---|---|
| 10 首 | 4 | `[4,4,2]` | `[4,3,3]` |
| 6 首 | 4 | `[4,2]` | `[3,3]` |
| 2 首 | 4 | `[2]` | `[1,1]` |
| 5 首 | 3 | `[3,2]` | `[3,1,1]` |
| 8 首 | 3 | `[3,3,2]` | `[3,3,1,1]` |
| 5 首 | 4 | `[4,1]` | 本来就合法,不动 |
修正规则(`reincarnate.split_batches`):
1. 前一批 **>= 4** → 从它借 1 首过来(前一批变 >=3,本批变 3)
2. 借不到(前一批只有 3,或自己就是第一批)→ 把这个 2 **拆成 1 + 1**
> ★ 只借位是不够的:前一批是 3 时借完自己变 2,等于把 2 挪了个位置。
自检里有 `1..299 首 × per∈{1,3,4}` 的全量扫描,保证永不产生 2 首包。
---
## 四、★ 为什么要「续关登录」
`UpsertUserAll` 的 `playlogId` 必须等于**本次登录**的 `loginId`
(`PacketUpsertUserAll.cs:31` / `UserAllRequestVO`)。
一局结束后这个会话就被消费掉了,要继续上传必须重新登录 —— 真机走的就是「续关」:
```csharp
// source/Assembly-CSharp/Process/ContinueProcess.cs:403
new PacketUserLogin(userID, accessCode, isContinue: true, GameManager.IsEventMode ? 1 : 0, ...)
```
对应本工程:`UserLogin(user_id, token, is_continue=True)`
—— 和普通登录的唯一区别就是 `UserLoginRequestVO.isContinue = true`,
会返回**新的 `loginId` / `loginDateTime`**。
---
## 五、★ 登出必须用 type=1(Logout),不要用 4(TestIn)
`Net.VO.Mai2/LogoutType.cs`:
```csharp
public enum LogoutType { None=0, Logout=1, Cancel=2, Error=3, TestIn=4, Quit=5 }
```
**真机抓包实证** `UserLogoutApi` 用的是 **type=1**:
```json
{"userId":12570152,"accessCode":"","regionId":8,"placeId":1007,
"clientId":"A63E01C3047","loginDateTime":1791325354,"type":1,"delayLog":{...}}
```
源码侧的正常会话结束也是 type=1:
```csharp
// source/Assembly-CSharp/Process.UserDataNet.State.UserDataULState/StateULUserLogout.cs:24
PacketHelper.StartPacket(new PacketUserLogout(_index, _userId, LogoutType.Logout, ...));
```
**续关场景下用 4(TestIn)会出问题** —— 那是「测试登入 / 顶号」的语义
(`GameMain.cs:104` 退出游戏时才用)。
`UserLogout` 对 type=1 会自动带上完整 `delayLog`
(`PacketUserLogout.cs:21`:`logoutType == LogoutType.Logout` 时才收集会话统计)。
---
## 六、playlog 从哪来
`GetNeedUserData` 抓不到 `userPlaylogList`(那是逐局记录,没有对应的 Get 接口),
所以本脚本**从曲目记录反推合成**:
| `UserMusicDetail`(9 字段) | → | `UserPlaylog`(110 字段) |
|---|---|---|
| `musicId` / `level` | | `musicId` / `level` |
| `achievement` | | `achievement` |
| `deluxscoreMax` | | `deluxscore` |
| `scoreRank` | | `scoreRank` / `isClear` |
| `comboStatus` / `syncStatus` | | 同名 |
| 其余 | | `config.PLAY_*` 默认值 |
`UserMusicDetail` 的全部字段(`Net.VO.Mai2/UserMusicDetail.cs`):
`musicId` / `level` / `playCount` / `achievement` / `comboStatus` /
`syncStatus` / `deluxscoreMax` / `scoreRank` / `extNum1`。
---
## 七、迁移范围
默认迁**全部节点**(整账号克隆):
- **第 1 包**带全部非曲目节点(`userData` / `userExtend` / `userOption` /
`userCharacterList` / `userMapList` / …)
- **之后每包**只带这一批曲目 + 合成的 `userGamePlaylogList`
- 位串 `isNew*` 按 **B 的基线**逐条算(B 没有 → `"1"`,已有 → `"0"`),
主键表见 `api/upsert_user_all.py` 的 `DIFF_PRIMARY_KEYS`
**不迁的节点**(`SKIP_NODES`):
| 节点 | 原因 |
|---|---|
| `userMusicDetailList` | 单独分批 |
| `userGamePlaylogList` | 本脚本按曲目合成 |
| `user2pPlaylog` | 2P 记录,迁移无意义 |
| `userGetPointList` | 里程/奖励,迁过去会凭空发点 |
| `userTradeItemList` | 无 Get 来源 |
| `userFriendSeasonRankingList` | 无 Get 来源 |
**默认不克隆 `userName` / `accessCode`**(避免把 B 改名);要连身份一起搬就加
`--clone-identity`。
---
## 八、⚠️ 注意事项
1. **未实跑过。** 建议先用 `--plan` 看计划,再 `--limit 8` 小批量试,确认无误再全量。
2. **会真实写入账号 B(不可撤销)。** 不传 `--send` 时只构造报文。
3. **耗时**:曲目多的话包数很多(每包最多 4 首),加上 `--wait` 的等待,
几百首会跑很久。想快可以调小 `--wait`。
4. **二维码有效期 10 分钟**,但转生过程中只用一次(登录时)——
续关用的是 token,不需要新二维码。
5. 基线快照只在**第一次登录后抓一次**,后续包沿用同一份做差分。
曲目节点由 `extra_data` 整体覆盖,位串也单独算,不受影响。