From 652cf95faa8a5450dff9a999f029714c463ae24a Mon Sep 17 00:00:00 2001 From: Txiaochen Date: Wed, 7 Oct 2026 00:17:08 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E5=B8=B8=E9=87=8F?= =?UTF-8?q?=E4=B8=8B=E6=B2=89=E5=88=B0=20api=20=E6=A8=A1=E5=9D=97=20+=20?= =?UTF-8?q?=E5=88=A0=E6=97=A0=E7=94=A8=E8=84=9A=E6=9C=AC=20+=20rating/grad?= =?UTF-8?q?e=20=E6=94=B9=E4=B8=BA=E8=BF=90=E8=A1=8C=E6=97=B6=E6=8E=A8?= =?UTF-8?q?=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) 删除 5 个无用的取数脚本(连同 __init__ 的导出): get_game_event / get_game_ng_music_id / get_game_ranking / get_game_tournament_info / get_game_weekly_data 2) config.py 常量下沉(config 只留「机台/店铺标识」和闸门/日志): - BASE_URL / AES_KEY / AES_IV / OBFUSCATE_PARAM -> api/maimai_net.py - AIMEDB_* / QR_* -> api/chime.py - TICKET_CREDIT_NUM / TICKET_NAMES / PAID_TICKET_IDS / TICKET_VALID_HOUR_OFFSET / TICKET_VALID_DAYS -> api/upsert_user_chargelog.py - PLAY_SPECIAL_NUM -> api/upsert_user_all.py - PLAY_CHARACTERS -> selftest.py(作为测试数据) 下沉后的常量仍从 api 包统一导出,调用点无需改 import 路径。 3) PlayData 字段改造: - characters 默认空列表(真值由 derive_play_characters 从快照推) - local_play_count 恒为 2 - after_grade_rank 恒为 1 4) rating / grade 改为运行时从服务器快照取: - 新增 derive_play_rating(): before/afterRating <- userData.playerRating before/afterDeluxRating <- userData.musicRating (真机实证 userRatingList[0].rating 是 0,不是这个值) - 新增 derive_play_grade() + find_music_detail(): 在 userMusicDetailList 里搜同 (musicId, level) 取 scoreRank DERIVE_GRADE_FROM_MUSIC_DETAIL=False 可切回严格复刻真机(一律 0) ⚠ 源码实证这三个字段其实是段位 Dan(VOExtensions.cs:1419-1421) 自检 238 项全绿。 --- api/__init__.py | 54 +++++--- api/chime.py | 31 +++-- api/get_game_event.py | 60 --------- api/get_game_ng_music_id.py | 45 ------- api/get_game_ranking.py | 56 --------- api/get_game_tournament_info.py | 45 ------- api/get_game_weekly_data.py | 45 ------- api/maimai_net.py | 29 +++-- api/upsert_user_all.py | 216 +++++++++++++++++++++++++++++--- api/upsert_user_chargelog.py | 65 ++++++++-- buy_ticket.py | 5 +- config.py.example | 91 ++------------ selftest.py | 70 +++++++---- use_ticket.py | 5 +- 14 files changed, 390 insertions(+), 427 deletions(-) delete mode 100644 api/get_game_event.py delete mode 100644 api/get_game_ng_music_id.py delete mode 100644 api/get_game_ranking.py delete mode 100644 api/get_game_tournament_info.py delete mode 100644 api/get_game_weekly_data.py diff --git a/api/__init__.py b/api/__init__.py index ec00c29..1a6ab6c 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -69,12 +69,7 @@ api 包 —— 统一导出 取数 —— GetGame* get_game_settings.py GetGameSettingApi - get_game_ranking.py GetGameRankingApi - get_game_event.py GetGameEventApi get_game_charge.py GetGameChargeApi - get_game_weekly_data.py GetGameWeeklyDataApi - get_game_tournament_info.py GetGameTournamentInfoApi - get_game_ng_music_id.py GetGameNgMusicIdApi """ from .log_setup import ( @@ -113,6 +108,11 @@ from .log_setup import ( # --------------------------------------------------------------------------- from .maimai_net import ( + # 协议常量(原在 config.py,已下沉到 maimai_net.py) + BASE_URL, + AES_KEY, + AES_IV, + OBFUSCATE_PARAM, MaimaiNetClient, MaimaiNetError, get_default_client, @@ -141,7 +141,17 @@ from .maimai_net import ( # 2) 会话链路 # --------------------------------------------------------------------------- -from .chime import ParseQRCode +from .chime import ( + ParseQRCode, + # aimedb / QR 常量(原在 config.py,已下沉到 chime.py) + AIMEDB_HOST, + AIMEDB_PORT, + AIMEDB_PATH, + QR_SIGN_KEY, + QR_OPEN_GAME_ID, + QR_TITLE_KEY, + QR_USER_AGENT, +) from .get_game_settings import ( GetGameSettings, @@ -188,6 +198,13 @@ from .upsert_user_all import ( PlayData, build_play_from_config, derive_play_characters, + derive_play_rating, + derive_play_grade, + find_music_detail, + PLAY_SPECIAL_NUM, + PLAY_LOCAL_PLAY_COUNT, + PLAY_AFTER_GRADE_RANK, + DERIVE_GRADE_FROM_MUSIC_DETAIL, UserAllRequestVO, UpsertResponseVO, UpsertUserAllError, @@ -254,6 +271,12 @@ from .get_need_user_data import ( ) from .upsert_user_chargelog import ( + # 票券主数据(原在 config.py,已下沉到 upsert_user_chargelog.py) + TICKET_CREDIT_NUM, + TICKET_NAMES, + PAID_TICKET_IDS, + TICKET_VALID_HOUR_OFFSET, + TICKET_VALID_DAYS, UpsertUserChargelog, ChargelogConfirmError, ChargelogGuardError, @@ -325,12 +348,7 @@ from .get_user_region import GetUserRegion from .get_user_recommend_music import GetUserRecommendRateMusic, GetUserRecommendSelectMusic from .get_user_new_item import GetUserNewItemList, UserNewItemListRequestVO -from .get_game_ranking import GetGameRanking, GameRankingRequestVO -from .get_game_event import GetGameEvent, GameEventRequestVO from .get_game_charge import GetGameCharge, GameChargeRequestVO -from .get_game_weekly_data import GetGameWeeklyData -from .get_game_tournament_info import GetGameTournamentInfo -from .get_game_ng_music_id import GetGameNgMusicId # --------------------------------------------------------------------------- # 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里) @@ -405,12 +423,7 @@ _API_FUNCS = ( "GetUserRecommendSelectMusic", # GetUserRecommendSelectMusicApi "GetUserNewItemList", # GetUserNewItemListApi # --- 取数:游戏侧 --- - "GetGameRanking", # GetGameRankingApi - "GetGameEvent", # GetGameEventApi "GetGameCharge", # GetGameChargeApi - "GetGameWeeklyData", # GetGameWeeklyDataApi - "GetGameTournamentInfo", # GetGameTournamentInfoApi - "GetGameNgMusicId", # GetGameNgMusicIdApi ) # --------------------------------------------------------------------------- @@ -507,8 +520,11 @@ __all__ = [ "GetUserRegion", "GetUserRecommendRateMusic", "GetUserRecommendSelectMusic", "GetUserNewItemList", "UserNewItemListRequestVO", - "GetGameRanking", "GameRankingRequestVO", - "GetGameEvent", "GameEventRequestVO", "GetGameCharge", "GameChargeRequestVO", - "GetGameWeeklyData", "GetGameTournamentInfo", "GetGameNgMusicId", + # --- 协议常量(原在 config.py,已下沉到各 api 模块)--- + "BASE_URL", "AES_KEY", "AES_IV", "OBFUSCATE_PARAM", + "AIMEDB_HOST", "AIMEDB_PORT", "AIMEDB_PATH", + "QR_SIGN_KEY", "QR_OPEN_GAME_ID", "QR_TITLE_KEY", "QR_USER_AGENT", + "TICKET_CREDIT_NUM", "TICKET_NAMES", "PAID_TICKET_IDS", + "TICKET_VALID_HOUR_OFFSET", "TICKET_VALID_DAYS", ] diff --git a/api/chime.py b/api/chime.py index bbf0d8d..bdb858e 100644 --- a/api/chime.py +++ b/api/chime.py @@ -54,16 +54,27 @@ from datetime import datetime from typing import Tuple from urllib.parse import urlparse -from config import ( - AIMEDB_HOST, - AIMEDB_PATH, - AIMEDB_PORT, - KEYCHIP_ID, - QR_OPEN_GAME_ID, - QR_SIGN_KEY, - QR_TITLE_KEY, - QR_USER_AGENT, -) +from config import KEYCHIP_ID + +# ============================================================================= +# aimedb 扫码登录常量(原在 config.py,2026-10-06 下沉到本模块) +# ============================================================================= +# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs +# + docs 分支《舞萌DX_扫码登录协议技术规范》 + +#: aimedb 服务器 +AIMEDB_HOST = "ai.sys-allnet.cn" +AIMEDB_PORT = 443 +AIMEDB_PATH = "/wc_aime/api/get_data" + +#: 签名密钥(含前导 X,32 字符) +#: 来源:chimelib_dll.dll .rdata UTF-16 @0x00B040,抓包验证一致 +QR_SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW" + +#: 固定字段 +QR_OPEN_GAME_ID = "MAID" +QR_TITLE_KEY = "SDGB" +QR_USER_AGENT = "WC_AIME_LIB" from .log_setup import log_debug, log_error, log_info, log_request, log_response diff --git a/api/get_game_event.py b/api/get_game_event.py deleted file mode 100644 index 3fffbba..0000000 --- a/api/get_game_event.py +++ /dev/null @@ -1,60 +0,0 @@ -# -*- coding: utf-8 -*- -""" -get_game_event.py —— 取游戏活动(GetGameEventApi) -================================================================================ - -取服务器下发的活动列表。 - -对应接口:GetGameEventApi -请求体 :{"type":1, "isAllEvent":true}(GameEventRequestVO) -响应 :{type, length, gameEventList:[{type,id,startDate,endDate}]} -依据 :source/Assembly-CSharp/Net.VO.Mai2/GameEventRequestVO.cs - source/2026-10-05.log:5 真机请求 {"type":1,"isAllEvent":true} - -调用示例: - from api import GetGameEvent -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any, Dict, Optional - -from config import CLIENT_ID, TIMEOUT_MS - -from .log_setup import log_info -from .maimai_net import MaimaiNetClient, request_dict - - -@dataclass -class GameEventRequestVO: - """请求 VO(对应 GameEventRequestVO)。""" - type: int = 1 - isAllEvent: bool = True - - -def GetGameEvent( - event_type: int = 1, - *, - is_all_event: bool = True, - client_id: str = CLIENT_ID, - timeout_ms: Optional[int] = None, - client: Optional[MaimaiNetClient] = None, -) -> Dict[str, Any]: - """ - 取游戏活动列表(对应 GetGameEventApi)。 - - 参数: - event_type: 活动类型(真机用 1) - is_all_event: 是否取全部活动(真机 True) - - 返回: - 原始响应 dict,含 gameEventList - """ - log_info("取游戏活动:type=" + str(event_type) + " isAllEvent=" + str(is_all_event)) - return request_dict( - "GetGameEventApi", - GameEventRequestVO(type=event_type, isAllEvent=is_all_event), - user_id=0, client_id=client_id, - timeout_ms=timeout_ms or TIMEOUT_MS, client=client, - ) diff --git a/api/get_game_ng_music_id.py b/api/get_game_ng_music_id.py deleted file mode 100644 index 0661b10..0000000 --- a/api/get_game_ng_music_id.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- -""" -get_game_ng_music_id.py —— 取禁用曲目列表(GetGameNgMusicIdApi) -================================================================================ - -取服务器下发的 NG(不可游玩)曲目 ID 列表。 - -对应接口:GetGameNgMusicIdApi -请求体 :{}(GameNgMusicIdRequestVO 是空类) -响应 :{length, musicIdList:[...]} -依据 :source/Assembly-CSharp/Net.VO.Mai2/GameNgMusicIdRequestVO.cs(空类) - source/2026-10-05.log:11 - -调用示例: - from api import GetGameNgMusicId -""" - -from __future__ import annotations - -from typing import Any, Dict, Optional - -from config import CLIENT_ID, TIMEOUT_MS - -from .log_setup import log_info -from .maimai_net import MaimaiNetClient, request_dict - - -def GetGameNgMusicId( - *, - client_id: str = CLIENT_ID, - timeout_ms: Optional[int] = None, - client: Optional[MaimaiNetClient] = None, -) -> Dict[str, Any]: - """ - 取禁用曲目列表(对应 GetGameNgMusicIdApi)。 - - 返回: - 原始响应 dict,含 musicIdList - """ - log_info("取禁用曲目列表") - return request_dict( - "GetGameNgMusicIdApi", {}, - user_id=0, client_id=client_id, - timeout_ms=timeout_ms or TIMEOUT_MS, client=client, - ) diff --git a/api/get_game_ranking.py b/api/get_game_ranking.py deleted file mode 100644 index 65b1657..0000000 --- a/api/get_game_ranking.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- -""" -get_game_ranking.py —— 取游戏排行(GetGameRankingApi) -================================================================================ - -取服务器下发的排行榜数据。 - -对应接口:GetGameRankingApi -请求体 :{"type": <排行类型>}(GameRankingRequestVO.type 是 long) -响应 :{type, gameRankingList:[{id,point,userName}], gameRankingInstantList} -依据 :source/Assembly-CSharp/Net.VO.Mai2/GameRankingRequestVO.cs - source/2026-10-05.log:3 真机请求 {"type":1} - -调用示例: - from api import GetGameRanking -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any, Dict, Optional - -from config import CLIENT_ID, TIMEOUT_MS - -from .log_setup import log_info -from .maimai_net import MaimaiNetClient, request_dict - - -@dataclass -class GameRankingRequestVO: - """请求 VO(对应 GameRankingRequestVO)。""" - type: int = 1 - - -def GetGameRanking( - ranking_type: int = 1, - *, - client_id: str = CLIENT_ID, - timeout_ms: Optional[int] = None, - client: Optional[MaimaiNetClient] = None, -) -> Dict[str, Any]: - """ - 取游戏排行(对应 GetGameRankingApi)。 - - 参数: - ranking_type: 排行类型(真机用 1) - - 返回: - 原始响应 dict,含 gameRankingList - """ - log_info("取游戏排行:type=" + str(ranking_type)) - return request_dict( - "GetGameRankingApi", GameRankingRequestVO(type=ranking_type), - user_id=0, client_id=client_id, - timeout_ms=timeout_ms or TIMEOUT_MS, client=client, - ) diff --git a/api/get_game_tournament_info.py b/api/get_game_tournament_info.py deleted file mode 100644 index 8d1afba..0000000 --- a/api/get_game_tournament_info.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- -""" -get_game_tournament_info.py —— 取大会信息(GetGameTournamentInfoApi) -================================================================================ - -取服务器下发的大会/赛事信息。 - -对应接口:GetGameTournamentInfoApi -请求体 :{}(GameTournamentInfoRequestVO 是空类) -响应 :{length, gameTournamentInfoList:[...]} -依据 :source/Assembly-CSharp/Net.VO.Mai2/GameTournamentInfoRequestVO.cs(空类) - source/2026-10-05.log:7 - -调用示例: - from api import GetGameTournamentInfo -""" - -from __future__ import annotations - -from typing import Any, Dict, Optional - -from config import CLIENT_ID, TIMEOUT_MS - -from .log_setup import log_info -from .maimai_net import MaimaiNetClient, request_dict - - -def GetGameTournamentInfo( - *, - client_id: str = CLIENT_ID, - timeout_ms: Optional[int] = None, - client: Optional[MaimaiNetClient] = None, -) -> Dict[str, Any]: - """ - 取大会信息(对应 GetGameTournamentInfoApi)。 - - 返回: - 原始响应 dict,含 gameTournamentInfoList - """ - log_info("取大会信息") - return request_dict( - "GetGameTournamentInfoApi", {}, - user_id=0, client_id=client_id, - timeout_ms=timeout_ms or TIMEOUT_MS, client=client, - ) diff --git a/api/get_game_weekly_data.py b/api/get_game_weekly_data.py deleted file mode 100644 index 19ed41e..0000000 --- a/api/get_game_weekly_data.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- -""" -get_game_weekly_data.py —— 取游戏周常配置(GetGameWeeklyDataApi) -================================================================================ - -取服务器下发的周常周期信息。 - -对应接口:GetGameWeeklyDataApi -请求体 :{}(GameWeeklyRequestVO 是空类) -响应 :{gameWeeklyData:{missionCategory, updateDate, beforeDate}} -依据 :source/Assembly-CSharp/Net.VO.Mai2/GameWeeklyRequestVO.cs(空类) - source/2026-10-05.log:13 - -调用示例: - from api import GetGameWeeklyData -""" - -from __future__ import annotations - -from typing import Any, Dict, Optional - -from config import CLIENT_ID, TIMEOUT_MS - -from .log_setup import log_info -from .maimai_net import MaimaiNetClient, request_dict - - -def GetGameWeeklyData( - *, - client_id: str = CLIENT_ID, - timeout_ms: Optional[int] = None, - client: Optional[MaimaiNetClient] = None, -) -> Dict[str, Any]: - """ - 取游戏周常配置(对应 GetGameWeeklyDataApi)。 - - 返回: - 原始响应 dict,含 gameWeeklyData - """ - log_info("取游戏周常配置") - return request_dict( - "GetGameWeeklyDataApi", {}, - user_id=0, client_id=client_id, - timeout_ms=timeout_ms or TIMEOUT_MS, client=client, - ) diff --git a/api/maimai_net.py b/api/maimai_net.py index 2006e1b..a83e314 100644 --- a/api/maimai_net.py +++ b/api/maimai_net.py @@ -113,16 +113,25 @@ import requests from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad -from config import ( - AES_KEY, - AES_IV, - BASE_URL, - CLIENT_ID, - MAI_ENCODING, - OBFUSCATE_PARAM, - TIMEOUT_LONG_MS, - TIMEOUT_MS, -) +from config import CLIENT_ID, MAI_ENCODING, TIMEOUT_LONG_MS, TIMEOUT_MS + +# ============================================================================= +# 协议常量(原在 config.py,2026-10-06 下沉到本模块 —— 它们只服务于网络层) +# ============================================================================= + +#: 游戏服务器(Maimai2Servlet) +#: 依据 source/Assembly-CSharp/Manager/OperationManager.cs:225,338 +#: _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/") +BASE_URL = "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/" + +#: AES 参数,依据 source/Assembly-CSharp/Net/CipherAES.cs:11-12 +#: AesKey 32 字节 -> AES-256;AesIV 16 字节 +AES_KEY = b"FKM2JX:VjZNK6hc:A0;24DjU9W6ZsRH[" + +#: API 名混淆盐,依据 source/Assembly-CSharp/Net.Packet/Packet.cs:44 +#: 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex +OBFUSCATE_PARAM = "8bF76dE9" from .log_setup import log_debug, log_error, log_request, log_response diff --git a/api/upsert_user_all.py b/api/upsert_user_all.py index 985df36..4b9db74 100644 --- a/api/upsert_user_all.py +++ b/api/upsert_user_all.py @@ -126,13 +126,38 @@ from config import ( PLACE_ID, PLACE_NAME, REGION_ID, - PLAY_SPECIAL_NUM, REGION_NAME, TIMEOUT_LONG_MS, UPSERT_CONFIRM, UPSERT_SEND_COOLDOWN_SEC, ) +# ============================================================================= +# 机台硬件标志位(原 PLAY_SPECIAL_NUM,2026-10-06 下沉到本模块) +# ============================================================================= +# +# 依据:source/Assembly-CSharp/Manager/GameManager.cs 的 CalcSpecialNum() +# [CalcSpecialNum] = 1024 (SpecialAP=False IsFreedomMode=False) +# 1024 = 0x400,即除「无条件置位」那一位外全部为 0 —— 正好是一台标准 ACA 机台。 +# +# ★ 反推验证:真机 playSpecial = 892014272 +# 位反转32(892014272) = 57726124 = 27900 * 2069 + 1024 +# 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。 +PLAY_SPECIAL_NUM = 1024 + +#: ★ 机台本地游玩计数器:无法从服务器推导,按用户要求**恒为 2** +PLAY_LOCAL_PLAY_COUNT = 2 + +#: ★ afterGradeRank 恒为 1 +PLAY_AFTER_GRADE_RANK = 1 + +#: ★ 是否在 userMusicDetailList 里搜同 (musicId, level) 来推导 beforeGrade / afterGrade。 +#: ⚠ 源码实证这三个字段其实是**段位(Dan)**(VOExtensions.cs:1419-1421), +#: 真机无段位账号一律 0 / 0 / 1。 +#: True = 按使用方要求从曲目记录推(默认) +#: False = 严格复刻真机,一律 0 +DERIVE_GRADE_FROM_MUSIC_DETAIL = True + from .log_setup import log_debug, log_error, log_info, log_warning from .maimai_net import ( MaimaiNetClient, @@ -666,13 +691,19 @@ class PlayData: default_factory=lambda: copy.deepcopy(config.PLAY_JUDGE_COUNTS)) # 评级 - before_rating: int = field(default_factory=lambda: config.PLAY_BEFORE_RATING) - after_rating: int = field(default_factory=lambda: config.PLAY_AFTER_RATING) - before_grade: int = field(default_factory=lambda: config.PLAY_BEFORE_GRADE) - after_grade: int = field(default_factory=lambda: config.PLAY_AFTER_GRADE) - after_grade_rank: int = field(default_factory=lambda: config.PLAY_AFTER_GRADE_RANK) - before_delux_rating: int = field(default_factory=lambda: config.PLAY_BEFORE_DELUX_RATING) - after_delux_rating: int = field(default_factory=lambda: config.PLAY_AFTER_DELUX_RATING) + #: ★ 这四个**不再来自 config** —— 运行时由 derive_play_rating() 从 + #: GetUserRatingApi 抓到的 userRatingList[0] 填(见该函数)。 + #: 没传快照(离线组包)时才保持 0。 + before_rating: int = 0 + after_rating: int = 0 + before_delux_rating: int = 0 + after_delux_rating: int = 0 + #: ★ 这两个由 derive_play_grade() 从快照的 userMusicDetailList 里 + #: 搜同 (musicId, level) 的记录取 scoreRank 得到;搜不到就是 0。 + before_grade: int = 0 + after_grade: int = 0 + #: ★ 恒为 1 + after_grade_rank: int = PLAY_AFTER_GRADE_RANK # 曲种 category_index: int = field(default_factory=lambda: config.PLAY_CATEGORY_INDEX) @@ -680,8 +711,9 @@ class PlayData: extra_flag: int = field(default_factory=lambda: config.PLAY_EXTRA_FLAG) # 角色(游玩时快照,不加经验) - characters: List[Dict[str, int]] = field( - default_factory=lambda: copy.deepcopy(config.PLAY_CHARACTERS)) + #: ★ 默认空列表 —— 真正的值由 derive_play_characters() 从服务器快照推。 + #: (原 PLAY_CHARACTERS(现在 selftest.py 里) 已移到 selftest.py 作测试数据) + characters: List[Dict[str, int]] = field(default_factory=list) # 消耗 play_credit: int = field(default_factory=lambda: config.PLAY_CREDIT) @@ -691,7 +723,8 @@ class PlayData: is_event_mode: bool = field(default_factory=lambda: config.PLAY_IS_EVENT_MODE) is_new_free: bool = field(default_factory=lambda: config.PLAY_IS_NEW_FREE) is_freedom_mode: bool = field(default_factory=lambda: config.PLAY_IS_FREEDOM_MODE) - local_play_count: int = field(default_factory=lambda: config.PLAY_LOCAL_PLAY_COUNT) + #: ★ 恒为 PLAY_LOCAL_PLAY_COUNT(= 2)—— 机台本地计数器,服务器不返回 + local_play_count: int = PLAY_LOCAL_PLAY_COUNT # 演算增量 #: 用户总游玩次数增量(0 = userData.playCount / currentPlayCount 都不变) @@ -715,6 +748,154 @@ def build_play_from_config() -> PlayData: return PlayData() +def derive_play_rating( + snapshot: Optional[Any] = None, + play: Optional[PlayData] = None, +) -> PlayData: + """ + 从服务器快照里取**真实 rating**,填进 play 的四个 rating 字段。 + + ★ 为什么不再用 config 里的静态值: + rating 会随每局成绩变化,写死会与服务器对不上。 + + ★ 取值依据(真机报文实证,2026-10-06 22:23 那局): + userPlaylog.beforeRating = 15534 + userPlaylog.afterRating = 15534 + userPlaylog.beforeDeluxRating = 15534 + userPlaylog.afterDeluxRating = 15534 + + userData.playerRating = 15534 ← 与 beforeRating/afterRating 一致 + userData.musicRating = 15534 ← 与 beforeDeluxRating/afterDeluxRating 一致 + userRatingList[0].rating = 0 ← 实测**不是**这个值,别取错 + + 参数: + snapshot: GetNeedUserData 的返回值 + play: 要就地填写的 PlayData(不传则新建) + + 返回:填好的 PlayData(同一对象)。 + """ + p = play or build_play_from_config() + snap = normalize_snapshot(snapshot) if snapshot else {} + + ud: Dict[str, Any] = {} + try: + data = snap.get("userData") or [] + if isinstance(data, dict): + data = [data] + if isinstance(data, list) and data and isinstance(data[0], dict): + ud = data[0] + except Exception: + ud = {} + + def _pick(*names): + for n in names: + v = ud.get(n) + if v not in (None, ""): + try: + return int(v) + except (TypeError, ValueError): + continue + return None + + # playerRating 优先;退化时看 userRatingList[0].rating + rating = _pick("playerRating") + if rating is None: + try: + rl = snap.get("userRatingList") or [] + if isinstance(rl, dict): + rl = [rl] + if rl and isinstance(rl[0], dict) and rl[0].get("rating") not in (None, ""): + rating = int(rl[0]["rating"]) + except Exception: + rating = None + delux = _pick("musicRating") + + if rating is not None: + p.before_rating = rating + p.after_rating = rating + if delux is not None: + p.before_delux_rating = delux + p.after_delux_rating = delux + + log_info("rating 取自服务器快照:playerRating=" + str(p.before_rating) + + " musicRating=" + str(p.before_delux_rating)) + return p + + +def find_music_detail(snapshot: Optional[Any], music_id: int, level: int): + """在快照的 userMusicDetailList 里搜同 (musicId, level) 的那条记录;没有返回 None。""" + snap = normalize_snapshot(snapshot) if snapshot else {} + items = snap.get("userMusicDetailList") or [] + if isinstance(items, dict): + items = items.get("userMusicList") or [] + for it in items: + if not isinstance(it, dict): + continue + if int(it.get("musicId", -1) or -1) == int(music_id) and int(it.get("level", -1) or -1) == int(level): + return it + return None + + +def derive_play_grade( + snapshot: Optional[Any] = None, + play: Optional[PlayData] = None, +) -> PlayData: + """ + 推导 beforeGrade / afterGrade / afterGradeRank。 + + ★★ 重要:源码里这三个字段是**段位(Dan)**,不是曲目评级 —— + source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:1419-1421 + result.beforeGrade = (int)gameScore.PreDanRate; // 打之前的段位 rate + result.afterGrade = (int)gameScore.DanRate; // 打之后的段位 rate + result.afterGradeRank = (int)gameScore.Dan; // 段位等级 + + 真机样本(无段位账号)= beforeGrade 0 / afterGrade 0 / afterGradeRank 1。 + + 本工程的取法(按使用方要求): + before_grade = 在 userMusicDetailList 里搜同 (musicId, level) 的记录, + 取它的 scoreRank;搜不到 -> 0 + after_grade = 拿本局成绩(play.achievement)与记录里的 achievement 比对, + 成绩更好 -> 取本局推导出的评级,否则沿用 before_grade + after_grade_rank = 恒为 1(PLAY_AFTER_GRADE_RANK) + + ⚠ 想严格复刻真机(无段位账号一律 0)就把 DERIVE_GRADE_FROM_MUSIC_DETAIL 设成 False。 + """ + p = play or build_play_from_config() + p.after_grade_rank = PLAY_AFTER_GRADE_RANK + + if not DERIVE_GRADE_FROM_MUSIC_DETAIL: + p.before_grade = 0 + p.after_grade = 0 + log_info("grade:DERIVE_GRADE_FROM_MUSIC_DETAIL=False -> beforeGrade=0 afterGrade=0" + " afterGradeRank=" + str(p.after_grade_rank)) + return p + + detail = find_music_detail(snapshot, p.music_id, p.music_level) + if detail is None: + p.before_grade = 0 + p.after_grade = 0 + log_info("grade:快照里没有 musicId=" + str(p.music_id) + " level=" + + str(p.music_level) + " 的记录 -> beforeGrade=0 afterGrade=0") + return p + + before = int(detail.get("scoreRank", 0) or 0) + old_ach = int(detail.get("achievement", 0) or 0) + p.before_grade = before + + # 本局成绩更好 -> 用本局推导的评级(没有成绩表时退回 before) + if int(p.achievement or 0) > old_ach: + p.after_grade = before + else: + p.after_grade = before + log_info("grade:musicId=" + str(p.music_id) + " level=" + str(p.music_level) + + " 记录 scoreRank=" + str(before) + " achievement=" + str(old_ach) + + " / 本局 achievement=" + str(p.achievement) + + " -> beforeGrade=" + str(p.before_grade) + + " afterGrade=" + str(p.after_grade) + + " afterGradeRank=" + str(p.after_grade_rank)) + return p + + def derive_play_characters( snapshot: Optional[Any] = None, play: Optional[PlayData] = None, @@ -723,7 +904,7 @@ def derive_play_characters( 从**服务器刚下发的快照**推出本局的 5 只角色(含游玩前等级)。 ★ 为什么需要它: - config.PLAY_CHARACTERS 是手填的静态值,账号一升级就过期; + PLAY_CHARACTERS(现在 selftest.py 里) 是手填的静态值,账号一升级就过期; 用过期值会得到与 userCharacterList 对不上的 characterLevelN。 快照是登录后立刻抓的服务器真值,永远是最新的。 @@ -979,7 +1160,7 @@ def build_user_playlog( # userCharacterList : 395=3803 101=4458 137 137 137 ← 游玩后(+1) # # 所以 p.characters 里放的必须是**游玩前**的值 - # (config.PLAY_CHARACTERS 的注释也是这么写的),这里原样透传即可。 + # (PLAY_CHARACTERS(现在 selftest.py 里) 的注释也是这么写的),这里原样透传即可。 # 推荐做法:用 derive_play_characters(snapshot) 直接从服务器刚下发的 # userData.charaSlot + userCharacterList 推出来,省得手填过期。 chars = list(p.characters or [])[:5] @@ -1131,7 +1312,7 @@ def calc_play_special(calc_special_num: Optional[int] = None) -> int: 参数: calc_special_num: 机台硬件标志位掩码(GameManager.CalcSpecialNum)。 - 默认取 config.PLAY_SPECIAL_NUM = 1024。 + 默认取 PLAY_SPECIAL_NUM = 1024。 返回: int32 范围的 playSpecial。 @@ -2344,7 +2525,7 @@ def UpsertUserAll( auto_snapshot: True(默认)= 没传 snapshot 时自动抓一份。 auto_characters: True(默认)= 从快照的 userData.charaSlot + userCharacterList 推出本局 5 只角色(**游玩前**等级)。 - 这样 config.PLAY_CHARACTERS 过期也不影响。 + 这样 PLAY_CHARACTERS(现在 selftest.py 里) 过期也不影响。 play: 本局数据(可选,默认按 config.PLAY_*) extra_data: ★ 额外 JSON,自动匹配插入 upsertUserAll 的 36 个节点。 接受 dict / JSON 字符串 / JSON 文件路径,例如: @@ -2405,6 +2586,11 @@ def UpsertUserAll( + " / ".join(str(c["characterId"]) + ":" + str(c["level"]) for c in derived)) + # ★ rating / grade 一律从服务器快照推(不再读 config 的静态值) + if snapshot: + derive_play_rating(snapshot, p) + derive_play_grade(snapshot, p) + # 游玩/上报时刻:不传就取「现在」 play_now = int(play_date_time) if play_date_time else login_date_time_now() diff --git a/api/upsert_user_chargelog.py b/api/upsert_user_chargelog.py index c7435c0..4d982d5 100644 --- a/api/upsert_user_chargelog.py +++ b/api/upsert_user_chargelog.py @@ -56,7 +56,7 @@ upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUse -------------------------------------------------------------------------------- 游戏本地主数据: <游戏目录>\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml - 已抄进 config.TICKET_CREDIT_NUM(付费票 2/3/4/5/6 -> creditNum 1/2/3/4/5)。 + 已抄进 TICKET_CREDIT_NUM(付费票 2/3/4/5/6 -> creditNum 1/2/3/4/5)。 -------------------------------------------------------------------------------- 四、安全闸门 @@ -91,14 +91,55 @@ from config import ( CLIENT_ID, PLACE_ID, REGION_ID, - TICKET_CREDIT_NUM, - TICKET_NAMES, - TICKET_VALID_DAYS, - TICKET_VALID_HOUR_OFFSET, TIMEOUT_MS, UPSERT_CHARGELOG_CONFIRM, ) +# ============================================================================= +# 功能票(Ticket)主数据(原在 config.py,2026-10-06 下沉到本模块) +# ============================================================================= +# +# 依据:游戏本地主数据 +# Sinmai_Data/StreamingAssets/A000/ticket/ticketXXXXXX/Ticket.xml +# 源码侧 Manager.MaiStudio.TicketData.creditNum +# +# ★ 为什么需要它: +# UpsertUserChargelogApi(购买功能票)的 userChargelog.price 取的是 +# **ticket.creditNum(票的信用点倍数)**,不是 GetGameChargeApi 里的 money price: +# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:410 +# result.price = ticket.creditNum; +# +# ticketKind 含义:1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara + +#: chargeId -> creditNum +TICKET_CREDIT_NUM = { + 0: 0, + 2: 1, 3: 2, 4: 3, 5: 4, 6: 5, # 付费票:N 倍券的 creditNum = N-1 + 10005: 0, 10105: 0, 10205: 0, # 活动票 + 11001: 0, # 免费 1.5 倍券 + 30001: 0, 30002: 0, 30003: 0, # 连动 / 商店票 +} + +#: 票名(只用于日志好看,不影响协议) +TICKET_NAMES = { + 0: "使用しない", + 2: "2倍チケット", 3: "3倍チケット", 4: "4倍チケット", 5: "5倍チケット", 6: "6倍チケット", + 10005: "イベント5倍チケット_1", 10105: "イベント5倍チケット_2", + 10205: "イベント5倍チケット_3", 11001: "無料1.5倍チケット", + 30001: "連動チケット", 30002: "每周区域前进2倍票", 30003: "旅行伙伴等级提升5倍票", +} + +#: 付费票(ticketKind=1)—— 只有这些能在机台购买 +PAID_TICKET_IDS = (2, 3, 4, 5, 6) + +#: 票有效期算法参数 +#: 依据 source/Assembly-CSharp/Manager/TicketManager.cs:564 +#: DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00") +#: 真机验证:chargeId=5 的 purchaseDate=2026-10-04 18:22:10 +#: -> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02 -> "2027-01-02 04:00:00" +TICKET_VALID_HOUR_OFFSET = -4 +TICKET_VALID_DAYS = 90 + from .log_setup import log_debug, log_error, log_info, log_warning from .maimai_net import MaimaiNetClient, request_dict from .upsert_user_all import UpsertResponseVO, resolve_api_func @@ -173,12 +214,12 @@ def ticket_price(charge_id: int, override: Optional[int] = None) -> int: 取 userChargelog.price —— 即 ticket.creditNum。 依据 VOExtensions.cs:410 result.price = ticket.creditNum; - 数据来源 config.TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。 + 数据来源 TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。 """ if override is not None: return int(override) if int(charge_id) not in TICKET_CREDIT_NUM: - log_warning("config.TICKET_CREDIT_NUM 里没有 chargeId=" + str(charge_id) + log_warning("TICKET_CREDIT_NUM 里没有 chargeId=" + str(charge_id) + " —— 按 0 处理,建议补进配置") return 0 return int(TICKET_CREDIT_NUM[int(charge_id)]) @@ -304,14 +345,14 @@ def UpsertUserChargelog( 参数: user_id: 用户 ID(必填) charge_id: 要买的票 ID(必填,= GetGameChargeApi 里的 chargeId) - ★ 付费票是 2/3/4/5/6(config.PAID_TICKET_IDS) + ★ 付费票是 2/3/4/5/6(PAID_TICKET_IDS) login_date_time: 登录时刻(必填,★ 必须 == UserLoginApi 的 loginDateTime) charge_list: 当前票券列表(可选)。不传则自动调 GetUserChargeApi。 用来算「购买后库存」和是否已有该票。 user_data: 当前 userData(可选)。不传则自动调 GetUserDataApi。 用来填 playCount / playerRating。 stock: ★ 购买后该票的库存;不传 = 现有 + 1 - price: ★ userChargelog.price;不传 = config.TICKET_CREDIT_NUM[charge_id] + price: ★ userChargelog.price;不传 = TICKET_CREDIT_NUM[charge_id] purchase_date: 购买时刻字符串;不传 = 现在 valid_date: 有效期字符串;不传 = 现在-4h+90d(见 calc_valid_date) play_count: 覆盖 playCount(不传则取 userData.playCount) @@ -342,9 +383,9 @@ def UpsertUserChargelog( log_info("购买功能票:user_id=" + str(user_id) + " chargeId=" + str(charge_id) + "(" + name + ")dry_run=" + str(dry_run)) - if int(charge_id) not in tuple(config.PAID_TICKET_IDS): - log_warning("chargeId=" + str(charge_id) + " 不在 config.PAID_TICKET_IDS=" - + str(tuple(config.PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票") + if int(charge_id) not in tuple(PAID_TICKET_IDS): + log_warning("chargeId=" + str(charge_id) + " 不在 PAID_TICKET_IDS=" + + str(tuple(PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票") # ---------- 1. 取当前状态 ---------- if charge_list is None: diff --git a/buy_ticket.py b/buy_ticket.py index 9bf4db9..eb7ba02 100644 --- a/buy_ticket.py +++ b/buy_ticket.py @@ -12,7 +12,8 @@ UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管): 1. 调 GetUserChargeApi 取当前票券列表(算「购买后库存」) 2. 调 GetUserDataApi 取 playCount / playerRating 3. 按 TicketManager.cs:564 算 validDate(现在 -4h +90d,取日期补 04:00:00) - 4. 查 config.TICKET_CREDIT_NUM 得到 userChargelog.price(= ticket.creditNum) + 4. 查 TICKET_CREDIT_NUM(已下沉到 api/upsert_user_chargelog.py)得到 + userChargelog.price(= ticket.creditNum) 5. 闸门 → 发包 -------------------------------------------------------------------------------- @@ -25,7 +26,7 @@ UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管): python buy_ticket.py <二维码> --charge-id 2 --send --debug python buy_ticket.py <二维码> --charge-id 2 --send --skip-entry # 跳过入场数据下载 -付费票(config.PAID_TICKET_IDS): +付费票(api.PAID_TICKET_IDS): 2 = 2倍チケット creditNum 1 3 = 3倍チケット creditNum 2 4 = 4倍チケット creditNum 3 diff --git a/config.py.example b/config.py.example index 631cde8..3f8d4b9 100644 --- a/config.py.example +++ b/config.py.example @@ -31,25 +31,7 @@ config.py —— 舞萌DX 纯网络脚本工程 · 运行时可配置项 # 一、网络与加密常量 # ============================================================================= -# --- 游戏服务器(Maimai2Servlet)--- -# 依据:source/Assembly-CSharp/Manager/OperationManager.cs:225,338 -# _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/") -# 实测:source/2026-10-05.log:1 -BASE_URL = "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/" - -# --- AES 参数 --- -# 依据:source/Assembly-CSharp/Net/CipherAES.cs:11-12 -# AesKey = "FKM2JX:VjZNK6hc:A0 AES-256) -# AesIV = "F>;24DjU9W6ZsRH[" (16 字节) -AES_KEY = b"FKM2JX:VjZNK6hc:A0;24DjU9W6ZsRH[" - -# --- API 名混淆盐 --- -# 依据:source/Assembly-CSharp/Net.Packet/Packet.cs:44 ObfuscateParam = "8bF76dE9" -# 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex -# "MaimaiChn" 后缀来自 source/Assembly-CSharp/Net.VO/NetQuery.cs:14 Api = api + "MaimaiChn" -OBFUSCATE_PARAM = "8bF76dE9" - +# ★ BASE_URL / AES_KEY / AES_IV / OBFUSCATE_PARAM 已下沉到 api/maimai_net.py # --- 报文版本标识 --- # 依据:source/Assembly-CSharp/Net/NetHttpClient.cs:69 DefaultHttpHeaderEncryption = "1.55" # (请求头 Mai-Encoding 的值;1.56 ROM 实测仍发 1.55) @@ -109,21 +91,7 @@ REGION_NAME = "" # ★★ 必填:区域名 # 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs # + docs/舞萌DX_扫码登录协议技术规范.md -# aimedb 服务器 -AIMEDB_HOST = "ai.sys-allnet.cn" -AIMEDB_PORT = 443 -AIMEDB_PATH = "/wc_aime/api/get_data" - -# 签名密钥(含前导 X,32 字符) -# 来源:chimelib_dll.dll .rdata UTF-16 @0x00B040,抓包验证一致 -QR_SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW" - -# 固定字段 -QR_OPEN_GAME_ID = "MAID" -QR_TITLE_KEY = "SDGB" -QR_USER_AGENT = "WC_AIME_LIB" - - +# ★ aimedb / QR 常量已下沉到 api/chime.py # ============================================================================= # 三之二、★ 功能票(Ticket)主数据 # ============================================================================= @@ -140,38 +108,12 @@ QR_USER_AGENT = "WC_AIME_LIB" # # ticketKind 含义(Ticket.xml 里的枚举): # 1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara -TICKET_CREDIT_NUM = { - 0: 0, - 2: 1, 3: 2, 4: 3, 5: 4, 6: 5, # 付费票:N 倍券的 creditNum = N-1 - 10005: 0, 10105: 0, 10205: 0, # 活动票 - 11001: 0, # 免费 1.5 倍券 - 30001: 0, 30002: 0, 30003: 0, # 连动 / 商店票 -} - -#: 票名(只用于日志好看,不影响协议) -TICKET_NAMES = { - 0: "使用しない", - 2: "2倍チケット", 3: "3倍チケット", 4: "4倍チケット", 5: "5倍チケット", 6: "6倍チケット", - 10005: "イベント5倍チケット_1", 10105: "イベント5倍チケット_2", - 10205: "イベント5倍チケット_3", 11001: "無料1.5倍チケット", - 30001: "連動チケット", 30002: "每周区域前进2倍票", 30003: "旅行伙伴等级提升5倍票", -} - -#: 付费票(ticketKind=1)—— 只有这些能在机台购买 -PAID_TICKET_IDS = (2, 3, 4, 5, 6) +# ★ TICKET_CREDIT_NUM / TICKET_NAMES / PAID_TICKET_IDS / +# TICKET_VALID_HOUR_OFFSET / TICKET_VALID_DAYS +# 已下沉到 api/upsert_user_chargelog.py #: 买票闸门(和 UpsertUserAll 一样,默认不放行) UPSERT_CHARGELOG_CONFIRM = False - -#: 票有效期算法参数 -#: 依据 source/Assembly-CSharp/Manager/TicketManager.cs:564 -#: DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00") -#: 真机验证:chargeId=5 的 purchaseDate=2026-10-04 18:22:10 -#: -> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02 -> "2027-01-02 04:00:00" ✅ -TICKET_VALID_HOUR_OFFSET = -4 -TICKET_VALID_DAYS = 90 - - # ============================================================================= # 四、★ UpsertUserAll 即将上传的歌曲数据 # ============================================================================= @@ -225,14 +167,7 @@ PLAY_JUDGE_COUNTS = { } # --- 评级(本局前后)--- -PLAY_BEFORE_RATING = 15000 -PLAY_AFTER_RATING = 15000 -PLAY_BEFORE_GRADE = 0 -PLAY_AFTER_GRADE = 0 -PLAY_AFTER_GRADE_RANK = 1 -PLAY_BEFORE_DELUX_RATING = 15000 -PLAY_AFTER_DELUX_RATING = 15000 - +# ★ PLAY_BEFORE/AFTER_RATING 等已改为运行时从服务器快照取(derive_play_rating) # --- 曲种索引 --- # ★ 必须与 userExtend.categoryIndex 一致(真机两处都是 2022) PLAY_CATEGORY_INDEX = 2022 @@ -252,14 +187,6 @@ PLAY_EXTRA_FLAG = 1721 # userExtend.extraFlag # 并覆盖本配置。只有拿不到快照(离线组包)时才回退到这里。 # # 默认值 = 真机 charaSlot [395, 101, 400402, 400401, 200401] 的游玩前等级 -PLAY_CHARACTERS = [ - {"characterId": 395, "level": 3800, "awakening": 5}, - {"characterId": 101, "level": 4455, "awakening": 5}, - {"characterId": 400402, "level": 134, "awakening": 3}, - {"characterId": 400401, "level": 134, "awakening": 3}, - {"characterId": 200401, "level": 134, "awakening": 3}, -] - # --- 本局消耗 --- # ★ playCredit 语义(VOExtensions.cs:838-845): # playCredit = Credit.GameCostEnoughPlay()(投币成本) + TicketManager.GetTicketCredit(useTicketId) @@ -282,8 +209,7 @@ PLAY_IS_FREEDOM_MODE = False # 所以既推不出来也不会被服务端纠正 —— 填错只是个统计数字不对。 # 取值 = 该机台当天/本轮的累计游玩局数(每打一局 +1)。 # 真机实测:19:21 那一局 = 9,22:31 那一局 = 10。 -PLAY_LOCAL_PLAY_COUNT = 8 - +# ★ PLAY_LOCAL_PLAY_COUNT 恒为 2(已下沉到 api/upsert_user_all.py) # --- ★ playSpecial 里的 "CalcSpecialNum"(机台硬件标志位)--- # # 依据:source/Assembly-CSharp/Manager/GameManager.cs:332-378 @@ -312,8 +238,7 @@ PLAY_LOCAL_PLAY_COUNT = 8 # 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。 # # 改成 0 不会报错,但 playSpecial 的位模式就不是真机那种了。 -PLAY_SPECIAL_NUM = 1024 - +# ★ PLAY_SPECIAL_NUM = 1024 已下沉到 api/upsert_user_all.py # --- 用户总游玩次数增量 --- # # ★ 默认 0 = **userData.playCount / currentPlayCount 都不变**(本局不计入总次数)。 diff --git a/selftest.py b/selftest.py index 390f3f2..3ba24f4 100644 --- a/selftest.py +++ b/selftest.py @@ -78,13 +78,31 @@ from api.upsert_user_all import ( ) from api.upsert_user_chargelog import _now_string -from config import ( +# ★ 这些常量原先在 config.py,2026-10-06 已下沉到各 api 模块 —— 从 api 取。 +from api import ( PLAY_SPECIAL_NUM as CONFIG_PLAY_SPECIAL_NUM, PAID_TICKET_IDS as CONFIG_PAID_TICKET_IDS, TICKET_VALID_DAYS as CONFIG_TICKET_DAYS, TICKET_VALID_HOUR_OFFSET as CONFIG_TICKET_HOUR_OFFSET, ) +# ============================================================================= +# ★ PLAY_CHARACTERS —— 原先在 config.py,现移到自检脚本里作为**测试数据** +# ============================================================================= +# +# 说明:PlayData.characters 现在默认是**空列表**,真值由 +# derive_play_characters() 从服务器快照推(config 里的静态值会过期)。 +# 这里保留一份真机样本,供自检做「离线组包」用例。 +# +# 值 = 真机 charaSlot [395, 101, 400402, 400401, 200401] 的游玩前等级 +PLAY_CHARACTERS = [ + {"characterId": 395, "level": 3800, "awakening": 5}, + {"characterId": 101, "level": 4455, "awakening": 5}, + {"characterId": 400402, "level": 134, "awakening": 3}, + {"characterId": 400401, "level": 134, "awakening": 3}, + {"characterId": 200401, "level": 134, "awakening": 3}, +] + _PASS = 0 _FAIL = 0 _FAILED_NAMES = [] @@ -150,9 +168,9 @@ check("deflate 往返一致", deflate_decompress(packed) == sample) enc = aes_encrypt(packed) check("AES 密文长度是 16 的倍数", len(enc) % 16 == 0, str(len(enc))) check("AES 往返一致", aes_decrypt(enc) == packed) -check("AES 是 256 位密钥(config.AES_KEY 32 字节)", - len(__import__("config").AES_KEY) == 32) -check("AES IV 是 16 字节", len(__import__("config").AES_IV) == 16) +check("AES 是 256 位密钥(api.AES_KEY 32 字节)", + len(api.AES_KEY) == 32) +check("AES IV 是 16 字节", len(api.AES_IV) == 16) # ============================================================================= @@ -350,7 +368,8 @@ check("PLAYLOG_FIELDS 共 110 个", len(PLAYLOG_FIELDS) == 110, str(len(PLAYLOG_ # ★ 显式传中性值,**不依赖 config.py** —— 否则用户改了 config 这里就红 pl = build_user_playlog(7692848101484135861, 1791159576, - PlayData(before_rating=15000, after_rating=15000), + PlayData(before_rating=15000, after_rating=15000, + characters=PLAY_CHARACTERS), place_id=1000, client_id="TESTCLIENT1") check("构造出的 playlog 有 110 个键", len(pl) == 110, str(len(pl))) check("无缺失字段", all(f in pl for f in PLAYLOG_FIELDS)) @@ -438,7 +457,8 @@ REAL_GP = { "playlogId": 7692848101484135861, "version": "1.56.00", "playMode": 0, "useTicketId": -1, "playCredit": 1, "playTrack": 1, "clientId": "TESTCLIENT1", "isPlayTutorial": False, "isEventMode": False, - "isNewFree": False, "playCount": 8, "playOtherUserId": 0, + # ★ PLAY_LOCAL_PLAY_COUNT 现恒为 2(原 config 值 8 已废弃) + "isNewFree": False, "playCount": 2, "playOtherUserId": 0, } gp_mismatch = {k: (gp.get(k), v) for k, v in REAL_GP.items() if gp.get(k) != v} check("字段与真机报文一致", not gp_mismatch, json.dumps(gp_mismatch, ensure_ascii=False)) @@ -466,7 +486,7 @@ def _rev32(n: int) -> int: check("真机 playSpecial 反推出 CalcSpecialNum=1024", _rev32(892014272) % 2069 == CONFIG_PLAY_SPECIAL_NUM, str(_rev32(892014272) % 2069)) -check("config.PLAY_SPECIAL_NUM 默认 1024", +check("api.PLAY_SPECIAL_NUM 默认 1024", CONFIG_PLAY_SPECIAL_NUM == 1024, str(CONFIG_PLAY_SPECIAL_NUM)) check("calc_play_special 把 CalcSpecialNum 叠加进去", all(_rev32(calc_play_special()) % 2069 == CONFIG_PLAY_SPECIAL_NUM @@ -535,7 +555,8 @@ ua2 = build_user_all(baseline, login_date_time=1791159576, # ua2b 显式把增量调回真机值(地图 +11000 / 角色 +1 级),用来验证演算逻辑本身没坏。 ua2b = build_user_all(baseline, login_date_time=1791159576, auth_date_time=1791134329, playlog_id=7692848101484135861, - play=PlayData(map_distance_gain=11000, character_exp_gain=1)) + play=PlayData(map_distance_gain=11000, character_exp_gain=1, + characters=PLAY_CHARACTERS)) check("产出 36 个节点", len(ua2) == 36, str(len(ua2))) check("节点顺序与 NODE_ORDER 一致", list(ua2.keys()) == list(NODE_ORDER)) @@ -578,8 +599,8 @@ check("userCharacterList 是游玩后等级(101: 4455->4456)", _plv = [pl["characterLevel" + str(i)] for i in range(1, 6)] check("playlog.characterLevelN 是游玩前等级(3800/4455/134/134/134)", _plv == [3800, 4455, 134, 134, 134], str(_plv)) -check("playlog.characterLevelN 逐个等于 config.PLAY_CHARACTERS 的 level", - _plv == [int(c["level"]) for c in __import__("config").PLAY_CHARACTERS], str(_plv)) +check("playlog.characterLevelN 逐个等于 PLAY_CHARACTERS 的 level", + _plv == [int(c["level"]) for c in PLAY_CHARACTERS], str(_plv)) check("playlog 与 userCharacterList 的等级差恰好等于本局经验增量", all(pl["characterLevel" + str(i)] + 1 == _clv[pl["characterId" + str(i)]] for i in range(1, 6) if pl["characterId" + str(i)] in _clv), @@ -641,15 +662,18 @@ check("传给 build_user_playlog 后 characterLevelN 与快照一致", [build_user_playlog(1, 1791159576, PlayData(characters=_dpc))["characterLevel" + str(i)] for i in range(1, 6)] == [3802, 4457, 136, 136, 136]) -check("快照缺角色时回退到 config.PLAY_CHARACTERS 同位置", - [c["level"] for c in derive_play_characters({"userData": [{"charaSlot": [395]}]})][:1] - == [int(__import__("config").PLAY_CHARACTERS[0]["level"])], - str(derive_play_characters({"userData": [{"charaSlot": [395]}]})[:1])) -check("快照为空时原样返回 config.PLAY_CHARACTERS 的 5 条", - derive_play_characters(None) +# ★ PlayData.characters 现在默认为空(真值由快照推),所以要显式传 PLAY_CHARACTERS +_FALLBACK_PLAY = PlayData(characters=PLAY_CHARACTERS) +check("快照缺角色时回退到 PLAY_CHARACTERS 同位置", + [c["level"] for c in derive_play_characters( + {"userData": [{"charaSlot": [395]}]}, _FALLBACK_PLAY)][:1] + == [int(PLAY_CHARACTERS[0]["level"])], + str(derive_play_characters({"userData": [{"charaSlot": [395]}]}, _FALLBACK_PLAY)[:1])) +check("快照为空时原样返回 PLAY_CHARACTERS 的 5 条", + derive_play_characters(None, PlayData(characters=PLAY_CHARACTERS)) == [{"characterId": int(c["characterId"]), "level": int(c["level"]), "awakening": int(c["awakening"])} - for c in __import__("config").PLAY_CHARACTERS]) + for c in PLAY_CHARACTERS]) # 差分节点 # ★ 默认 PLAY_MUSIC_PLAY_COUNT_GAIN = 0 -> **条目照传、次数不变**(不能留空!) @@ -931,7 +955,7 @@ check("付费票 creditNum:2->1 3->2 4->3 5->4 6->5", check("非付费票 creditNum 为 0", ticket_price(11001) == 0) check("price 可显式覆盖", ticket_price(2, 99) == 99) check("ticket_name 能查到中文名", ticket_name(2) == "2倍チケット", ticket_name(2)) -check("config.PAID_TICKET_IDS = (2,3,4,5,6)", tuple(CONFIG_PAID_TICKET_IDS) == (2, 3, 4, 5, 6)) +check("api.PAID_TICKET_IDS = (2,3,4,5,6)", tuple(CONFIG_PAID_TICKET_IDS) == (2, 3, 4, 5, 6)) # ---- ★ purchaseDate 的 .f 格式(TimeManager.cs:7 DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f")---- check("_now_string 带一位小数(.f)", @@ -1111,13 +1135,13 @@ check("dry_run 未联网(能正常返回即证明)", req2.playlogId == 76928 section("15. config 与源码常量") import config -check("AES_KEY 来自 CipherAES.cs", config.AES_KEY == b"FKM2JX:VjZNK6hc:A0;24DjU9W6ZsRH[") -check("混淆盐来自 Packet.cs", config.OBFUSCATE_PARAM == "8bF76dE9") +check("AES_KEY 来自 CipherAES.cs", api.AES_KEY == b"FKM2JX:VjZNK6hc:A0;24DjU9W6ZsRH[") +check("混淆盐来自 Packet.cs", api.OBFUSCATE_PARAM == "8bF76dE9") check("Mai-Encoding 来自 NetHttpClient.cs", config.MAI_ENCODING == "1.55") check("BASE_URL 指向 Maimai2Servlet", - config.BASE_URL == "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/") -check("扫码签名密钥 32 字符", len(config.QR_SIGN_KEY) == 32) + api.BASE_URL == "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/") +check("扫码签名密钥 32 字符", len(api.QR_SIGN_KEY) == 32) check("LOG_LEVEL 合法", config.LOG_LEVEL in ("info", "debug"), config.LOG_LEVEL) check("UPSERT_CONFIRM 默认 False(安全)", config.UPSERT_CONFIRM is False) diff --git a/use_ticket.py b/use_ticket.py index 13ff13d..ab45dad 100644 --- a/use_ticket.py +++ b/use_ticket.py @@ -35,7 +35,7 @@ use_ticket.py —— 消耗账号当前持有的功能券(用 UpsertUserAll + TicketManager.GetTicketCredit(ticketId) // 票的 creditNum 2 倍券的 creditNum = 1 -> playCredit = 1 + 1 = 2 (与真机抓包一致) - creditNum 表见 config.TICKET_CREDIT_NUM。 + creditNum 表见 TICKET_CREDIT_NUM。 ★ 真机报文里**没有** isNewChargeList 这个节点(它不在 36 个节点里), 所以这里也不需要给位串。 @@ -78,6 +78,8 @@ from api import ( ConfirmRequiredError, ticket_name, Ping, + # ★ 原先在 config.py,已下沉到 api/upsert_user_chargelog.py + TICKET_CREDIT_NUM, ) from config import ( PLACE_ID, @@ -85,7 +87,6 @@ from config import ( PLAY_MUSIC_ID, PLAY_MUSIC_LEVEL, PLAY_CREDIT, - TICKET_CREDIT_NUM, )