先说结论。SDGB 的登录不是一个请求就完事,完整流程大概是这样:

二维码 -> Aime Server -> userId + token
       -> GetUserPreviewApi
       -> UserLoginApi -> JSESSIONID
       -> 一堆 GetUser*Api
       -> UpsertUserAllApi / UpsertUserChargelogApi
       -> UserLogoutApi

Aime Login

二维码不是直接拿去给 Title Server 的。Ichino 这里先 POST 到配置里的 aimeUrl。

二维码如果超过 64 个字符,只取最后 64 个。前面的东西可以丢。

qrCode = qrCodeToken.length > 64
    ? qrCodeToken.substring(qrCodeToken.length - 64)
    : qrCodeToken;

然后生成 timestamp。是 Tokyo time,格式为 yymmddhhmmss,不是 Unix timestamp。

timestamp = yyMMDDHHmmss
key = SHA256(chipId + timestamp + aimeSalt).toUpperCase()

实际 JSON:

{
  "chipID": "...",
  "openGameID": "MAID",
  "key": "SHA256_RESULT",
  "qrCode": "最后 64 位二维码内容",
  "timestamp": "250101123456"
}

Header 里有:

Content-Type: application/json
User-Agent: WC_AIME_LIB

返回值主要看这几个:

{
  "errorID": 0,
  "userID": 123456,
  "token": "..."
}

errorID == 0 才算成功。后面 Preview 和 UserLogin 都要带这个 userID + token。

Title Server 包装

Title Server 的 body 不是明文 JSON。这里最容易看错。

先根据 API 名字算 URL:

hash = MD5(apiName + "MaimaiChn" + obfuscateParam)
POST titleServerUrl + "/" + hash

body 的处理顺序:

JSON
 -> UTF-8
 -> zlib compress
 -> AES-CBC + PKCS7 padding
 -> POST

响应反过来:

AES decrypt
 -> zlib decompress
 -> UTF-8 JSON

请求 Header 基本固定:

User-Agent: {hash}#{userId}
Content-Type: application/json
Mai-Encoding: {apiVersion}
Accept-Encoding:
Charset: UTF-8
Content-Encoding: deflate
number: 0

如果还拿到了 JSESSIONID,再加:

Cookie: JSESSIONID=...

额,Content-Encoding: deflate 看起来和上面的 AES body 对不上。但 Ichino 就是这么发的。不要自作聪明改成 gzip,server 不一定理你。

User-Agent 这里也不是随便写。普通请求是 {hash}#{userId},如果 userId 是 0 才用 clientId 当后缀。

Preview

在登录的时候,机器会调用 GetUserPreviewApi。

packet:

{
  "userId": 123456,
  "segaIdAuthKey": "",
  "token": "AIME_LOGIN_TOKEN",
  "clientId": "..."
}

这个接口返回的就是上机前那个确认界面需要的信息:昵称、rating、头像、称号、最后登录时间之类的。重点字段:

isLogin
banState
errorId

isLogin == true 的时候说明这个号已经有人登录了。机器不会继续走完整登录,通常就是 15min 小黑屋。

所以流程不是“Preview 成功就 Login”。要先看 isLogin。

二维码在有效期内可以重复使用,但前提是没有刷新新的二维码。刷新了新的,旧的基本就废了。

Login

接着机器发送 UserLoginApi 到 Title Server。

packet 是下面这样:

{
  "userId": 123456,
  "accessCode": "",
  "regionId": 0,
  "placeId": 0,
  "clientId": "...",
  "dateTime": 1700000000,
  "loginDateTime": 1700000600,
  "isContinue": false,
  "genericFlag": 0,
  "token": "AIME_LOGIN_TOKEN"
}

这里的 dateTime 是 now - 600,loginDateTime 是当前 Unix timestamp,单位秒。

返回值看 returnCode:

returnCode == 1 -> login 成功

成功之后会拿到:

loginId
lastLoginDate
token

同时,Title Server 的 response header 里会下发 Set-Cookie。只截取里面的 JSESSIONID,后面所有大部分请求都带这个 Cookie。

这里有两个 token,别混了:

  • Aime 返回的 token:放进 GetUserPreviewApi / UserLoginApi 的 JSON。
  • Title Server 返回的 JSESSIONID:放进 HTTP Cookie header。

事实上,Ichino 后面的 GetData 请求没有再把 Aime token 到处塞,而是主要依赖这个 JSESSIONID。

GetData

GetData 不是一个 API。额这个名字容易误导。

Ichino 的 fetchUserAllData 实际上并发调用下面 7 个接口,packet 都只有一个 userId:

{
  "userId": 123456
}

接口和返回字段大概是:

GetUserDataApi          -> userData + banState
GetUserExtendApi        -> userExtend
GetUserOptionApi        -> userOption
GetUserRatingApi        -> userRating
GetUserChargeApi        -> userChargeList
GetUserActivityApi      -> userActivity
GetUserMissionDataApi   -> userMissionDataList + userWeeklyData

首页只需要 GetUserDataApi 就能显示基本信息。

但是后面要发 UpsertUserAllApi,这 7 个基本都得拉。少一个不是简单地补 {} 就完事,很多时候 server 会直接静默丢掉整包。很烦。

另外还有一个单独的 GetUserCharacterApi,返回 userCharacterList。当前 UserAll builder 没把它混进普通的 7 个请求里。

发票

这里原来写的 targetUserId、UserChargeLogData 更像 Java model 的写法。实际 wire packet 不是那样。

真正发的是 UpsertUserChargelogApi:

{
  "userId": 123456,
  "userChargelog": {
    "chargeId": 12345,
    "price": 0,
    "purchaseDate": "2025-01-01 12:34:56.0",
    "playCount": 0,
    "playerRating": 12345,
    "placeId": 0,
    "regionId": 0,
    "clientId": "..."
  },
  "userCharge": {
    "chargeId": 12345,
    "stock": 1,
    "purchaseDate": "2025-01-01 12:34:56.0",
    "validDate": "2025-04-01 04:00:00"
  },
  "loginDateTime": 1700000600
}

注意字段名是 userChargelog,中间那个 l 是小写。

purchaseDate 用 yyyy-MM-dd HH:mm:ss.0。 validDate 是当天凌晨 4 点再加 90 天,格式没有最后的 .0。

当前 app 还会先检查这张票的 stock。已经有库存就不继续发票,省得重复写一坨奇怪的数据。

UpsertUserAll

这个才是最容易炸的地方。

Ichino 的做法是先拉上面那 7 份数据,然后以它们为基础拼一个 UserAll。外层结构:

{
  "userId": 123456,
  "playlogId": 987654,
  "isEventMode": false,
  "isFreePlay": false,
  "loginDateTime": 1700000600,
  "userPlaylogList": [
    "一条 userPlaylog"
  ],
  "upsertUserAll": {
    "...": "见下面"
  }
}

userData 不能原样塞回去

从 GetUserDataApi 拿到的 userData 要先 copy 一份,然后删掉:

friendCode
nameplateId
trophyId
cmLastEmoneyBrand
cmLastEmoneyCredit

这些字段放进 UpsertUserAllApi.userData 里,Lionheart 可能直接静默丢整包。不是返回一个好看的 error 给你,什么都不说,挺阴间。

然后覆盖 / 更新:

accessCode = ""
isNetMember = 1
playCount = 原 playCount + 1
currentPlayCount = 原 currentPlayCount + 1
lastGameId = "SDGB"
lastLoginDate = loginDateTime 格式化后的时间
lastPlayDate = 当前上海时间
lastPlayCredit = 1
lastPlayMode = 0
lastPlaceId = config.placeId
lastPlaceName = config.placeName
lastAllNetId = 0
lastRegionId = config.regionId
lastRegionName = config.regionName
lastClientId = config.clientId
lastCountryCode = "CHN"
banState = GetUserDataApi 的 banState
dateTime = loginDateTime

时间格式是 yyyy-MM-dd HH:mm:ss.0。

其他数据也要清洗

不是只有 userData 有坑。

userOption 要删掉:

tempoVolume

userRating.udemae 里的大写重复字段也要删:

MaxLoseNum
NpcLoseNum
NpcMaxLoseNum
NpcMaxWinNum
NpcTotalLoseNum
NpcTotalWinNum
NpcWinNum

userChargeList 不要原样带回去,只保留 4 个字段:

chargeId
stock
purchaseDate
validDate

userMissionDataList 只取前 6 条,每条只保留:

type
difficulty
targetGenreId
targetGenreTableId
conditionGenreId
conditionGenreTableId
clearFlag

userWeeklyData 只保留:

lastLoginWeek
beforeLoginWeek
friendBonusFlag

upsertUserAll 的主体

builder 最后拼出来的主体大概是这样:

userData: [newUserData]
userExtend: [userExtend]
userOption: [newUserOption]
userCharacterList: []
userGhost: []
userMapList: []
userLoginBonusList: []
userRatingList: [userRating]
userItemList: []                 // 后面按功能 patch
userMusicDetailList: [musicData]
userCourseList: []
userFriendSeasonRankingList: []
userChargeList: 清洗后的列表
userFavoriteList: []
userActivityList: [userActivity]
userMissionDataList: missions
userWeeklyData: weekly
userGamePlaylogList: [一条记录]
user2pPlaylog: 空的双人记录
userIntimateList: []
userShopItemStockList: []
userGetPointList: []
userTradeItemList: []
userFavoritemusicList: []
userKaleidxScopeList: []

后面还有一堆 isNew*List 标志。普通情况下大部分是空字符串,isNewMusicDetailList 默认是 "0",有歌曲数据更新时改成 "1"。

musicData

Ichino 里默认构造的 musicData 很小:

{
  "musicId": 11538,
  "level": 0,
  "playCount": 1,
  "achievement": 0,
  "comboStatus": 0,
  "syncStatus": 0,
  "deluxscoreMax": 0,
  "scoreRank": 0,
  "extNum1": 0
}

解锁歌曲时,musicId 换成输入的 ID。这个对象会放进:

upsertUserAll.userMusicDetailList = [musicData]
isNewMusicDetailList = "1"

然后在 userItemList 里塞解锁道具:

{
  "itemKind": 5,
  "itemId": 834,
  "stock": 1,
  "isValid": true
}

itemKind:

5 = 歌曲
6 = Master
7 = Re:Master

三个可以一起发。 isNewItemList 按 item 数量生成对应的 1 字符串,比如三个就是 "111"。

收藏品也是同一条 UserAll,只是 itemKind 换成 1/2/3/10/11/12。packet:

{
  "itemKind": 3,
  "itemId": 250103,
  "stock": 1,
  "isValid": true
}

userPlaylog

每次 UserAll 里会附带一条假的 playlog。不是只发一个 musicId,字段很多。

重点字段如下:

userId = 0
orderId = 0
playlogId = loginId
version = convertVersionNumber(lastRomVersion)
placeId = config.placeId
placeName = config.placeName
loginDate = loginDateTime
playDate = yyyy-MM-dd
userPlayDate = yyyy-MM-dd HH:mm:ss.0
type = 0
musicId = musicData.musicId
level = musicData.level
trackNo = 1
playerNum = 1
achievement = musicData.achievement
deluxscore = musicData.deluxscoreMax
scoreRank = musicData.scoreRank

version 有个转换:

1.56.00 -> 1056000

算法就是 major * 1000000 + minor * 1000 + patch。版本解析失败时 fallback 到 1053000。

判定相关的默认值也写死了:

tapMiss = 1
totalCombo = 1
maxCombo = 0
maxSync = 1
isTap = true
isHold = false
isSlide = false
isTouch = false
isBreak = false
isClear = achievement >= 500000

其他 note 判定基本都是 0。 beforeRating / afterRating 都直接用当前 playerRating,所以这条假 playlog 本身没有做 rating 计算。

角色槽位会补到 5 个:没有的填 0,每个角色的 level 是 1,awakening 是 0。

另外,userGamePlaylogList 里还有一条 session 级别记录:

playlogId = loginId
version = 原始 lastRomVersion 字符串
playDate = 当前时间
playMode = 0
useTicketId = -1
playCredit = 1
playTrack = 1
clientId = config.clientId
playCount = 0
playSpecial = calcRandom()
playOtherUserId = 0

这里的 playSpecial 不是随便写 0。Ichino 生成一个随机数,再做 32 位 bit reverse。server 如果突然抽风,先看这个字段。

发包功能

最后调用:

UpsertUserAllApi

返回有 returnCode 的话要检查是不是 1。有些 fire-and-forget API 会不返回 returnCode,这种情况 Ichino 会当作成功;只有字段存在且不是 1 才报错。

源码里还实现了 UploadUserPlaylogApi,但当前歌曲解锁和收藏品 workflow 走的不是它,实际走的是 UpsertUserAllApi。

Logout

退出登录调用 UserLogoutApi:

{
  "userId": 123456,
  "accessCode": "",
  "regionId": 0,
  "placeId": 0,
  "clientId": "...",
  "loginDateTime": 1700000600,
  "type": 1
}

type = 1 就是普通 Logout。

Ichino 的 UI 在 login 之后会冷却 60 秒才允许使用功能票、解锁歌曲或发收藏品。上传完成后如果勾了自动退出,还会等 5 秒再发 Logout。这个是 app 行为,不是协议硬要求,但不等的话有些环境确实容易出毛病。

the past

在 1.53 更新之前,GetUserPreviewApi 可以直接调用,不需要携带任何 Token,唯一需要的参数就是 userId。所以可以脚本批量扫号。

同时 Login 也只需要一个 userId。但需要在 30min 以内有一个可用的二维码被刷新出来。

1.53 之后,Wa1eak 给 Preview 加了 Token 校验,需要传 token + userId 才能调;UserLoginApi 也变成需要 token + userId。

另外 UserLogin 会在 Title Server response header 回传 JSESSIONID,后面的 GetData / Upsert 基本靠这个 Cookie。这个和 Aime 返回的 token 不是一回事。

所以现在再拿旧脚本直接扫,额,就是寄。